diff --git a/.changeset/sharp-shoes-care.md b/.changeset/sharp-shoes-care.md new file mode 100644 index 00000000..347f5777 --- /dev/null +++ b/.changeset/sharp-shoes-care.md @@ -0,0 +1,5 @@ +--- +'pastebar-app-ui': patch +--- + +Added Special copy/paste for history items context menu and settings diff --git a/package-lock.json b/package-lock.json index adcd92a8..81118ce0 100644 --- a/package-lock.json +++ b/package-lock.json @@ -141,6 +141,7 @@ "@types/codemirror": "^5.60.15", "@types/dompurify": "^3.0.5", "@types/events": "^3.0.3", + "@types/html-to-text": "^9.0.4", "@types/js-yaml": "^4.0.9", "@types/linkify-it": "^3.0.5", "@types/lodash-es": "^4.17.12", @@ -168,7 +169,6 @@ "tailwind-merge": "^2.0.0", "tailwindcss": "^3.3.5", "taze": "^0.12.2", - "ts-node": "^10.9.2", "typescript": "^5.8.3", "vite": "^6.0.6", "vite-plugin-tauri": "^4.0.0" @@ -2658,28 +2658,6 @@ "node": ">=0.1.90" } }, - "node_modules/@cspotcode/source-map-support": { - "version": "0.8.1", - "resolved": "https://registry.npmjs.org/@cspotcode/source-map-support/-/source-map-support-0.8.1.tgz", - "integrity": "sha512-IchNf6dN4tHoMFIn/7OE8LWZ19Y6q/67Bmf6vnGREv8RSbBVb9LPJxEcnwrcwX6ixSvaiGoomAUvu4YSxXrVgw==", - "dev": true, - "dependencies": { - "@jridgewell/trace-mapping": "0.3.9" - }, - "engines": { - "node": ">=12" - } - }, - "node_modules/@cspotcode/source-map-support/node_modules/@jridgewell/trace-mapping": { - "version": "0.3.9", - "resolved": "https://registry.npmjs.org/@jridgewell/trace-mapping/-/trace-mapping-0.3.9.tgz", - "integrity": "sha512-3Belt6tdc8bPgAtbcmdtNJlirVoTmEb5e2gC94PnkwEW9jI6CAHUeoG85tjWP5WquqfavoMtMwiG4P926ZKKuQ==", - "dev": true, - "dependencies": { - "@jridgewell/resolve-uri": "^3.0.3", - "@jridgewell/sourcemap-codec": "^1.4.10" - } - }, "node_modules/@dnd-kit/accessibility": { "version": "3.1.0", "resolved": "https://registry.npmjs.org/@dnd-kit/accessibility/-/accessibility-3.1.0.tgz", @@ -6328,6 +6306,19 @@ "win32" ] }, + "node_modules/@selderee/plugin-htmlparser2": { + "version": "0.11.0", + "resolved": "https://registry.npmjs.org/@selderee/plugin-htmlparser2/-/plugin-htmlparser2-0.11.0.tgz", + "integrity": "sha512-P33hHGdldxGabLFjPPpaTxVolMrzrcegejx+0GxjrIb9Zv48D8yAIA/QTDR2dFl7Uz7urX8aX6+5bCZslr+gWQ==", + "license": "MIT", + "dependencies": { + "domhandler": "^5.0.3", + "selderee": "^0.11.0" + }, + "funding": { + "url": "https://ko-fi.com/killymxi" + } + }, "node_modules/@sigstore/bundle": { "version": "2.1.0", "resolved": "https://registry.npmjs.org/@sigstore/bundle/-/bundle-2.1.0.tgz", @@ -7304,30 +7295,6 @@ "node": ">=10.13.0" } }, - "node_modules/@tsconfig/node10": { - "version": "1.0.11", - "resolved": "https://registry.npmjs.org/@tsconfig/node10/-/node10-1.0.11.tgz", - "integrity": "sha512-DcRjDCujK/kCk/cUe8Xz8ZSpm8mS3mNNpta+jGCA6USEDfktlNvm1+IuZ9eTcDbNk41BHwpHHeW+N1lKCz4zOw==", - "dev": true - }, - "node_modules/@tsconfig/node12": { - "version": "1.0.11", - "resolved": "https://registry.npmjs.org/@tsconfig/node12/-/node12-1.0.11.tgz", - "integrity": "sha512-cqefuRsh12pWyGsIoBKJA9luFu3mRxCA+ORZvA4ktLSzIuCUtWVxGIuXigEwO5/ywWFMZ2QEGKWvkZG1zDMTag==", - "dev": true - }, - "node_modules/@tsconfig/node14": { - "version": "1.0.3", - "resolved": "https://registry.npmjs.org/@tsconfig/node14/-/node14-1.0.3.tgz", - "integrity": "sha512-ysT8mhdixWK6Hw3i1V2AeRqZ5WfXg1G43mqoYlM2nc6388Fq5jcXyr5mRsqViLx/GJYdoL0bfXD8nmF+Zn/Iow==", - "dev": true - }, - "node_modules/@tsconfig/node16": { - "version": "1.0.4", - "resolved": "https://registry.npmjs.org/@tsconfig/node16/-/node16-1.0.4.tgz", - "integrity": "sha512-vxhUy4J8lyeyinH7Azl1pdd43GJhZH/tP2weN8TntQblOY+A0XbT8DJk1/oCPuOOyg/Ja757rG0CgHcWC8OfMA==", - "dev": true - }, "node_modules/@tufjs/canonical-json": { "version": "2.0.0", "resolved": "https://registry.npmjs.org/@tufjs/canonical-json/-/canonical-json-2.0.0.tgz", @@ -7515,6 +7482,13 @@ "integrity": "sha512-trOc4AAUThEz9hapPtSd7wf5tiQKvTtu5b371UxXdTuqzIh0ArcRspRP0i0Viu+LXstIQ1z96t1nsPxT9ol01g==", "dev": true }, + "node_modules/@types/html-to-text": { + "version": "9.0.4", + "resolved": "https://registry.npmjs.org/@types/html-to-text/-/html-to-text-9.0.4.tgz", + "integrity": "sha512-pUY3cKH/Nm2yYrEmDlPR1mR7yszjGx4DrwPjQ702C4/D5CwHuZTgZdIdwPkRbcuhs7BAh2L5rg3CL5cbRiGTCQ==", + "dev": true, + "license": "MIT" + }, "node_modules/@types/istanbul-lib-coverage": { "version": "2.0.6", "resolved": "https://registry.npmjs.org/@types/istanbul-lib-coverage/-/istanbul-lib-coverage-2.0.6.tgz", @@ -7839,18 +7813,6 @@ "node": ">=0.4.0" } }, - "node_modules/acorn-walk": { - "version": "8.3.4", - "resolved": "https://registry.npmjs.org/acorn-walk/-/acorn-walk-8.3.4.tgz", - "integrity": "sha512-ueEepnujpqee2o5aIYnvHU6C0A42MNdsIDeqy5BydrkuC5R1ZuUFnm27EeFJGoEHJQgn3uleRvmTXaJgfXbt4g==", - "dev": true, - "dependencies": { - "acorn": "^8.11.0" - }, - "engines": { - "node": ">=0.4.0" - } - }, "node_modules/agent-base": { "version": "6.0.2", "resolved": "https://registry.npmjs.org/agent-base/-/agent-base-6.0.2.tgz", @@ -9246,12 +9208,6 @@ "node": ">=10" } }, - "node_modules/create-require": { - "version": "1.1.1", - "resolved": "https://registry.npmjs.org/create-require/-/create-require-1.1.1.tgz", - "integrity": "sha512-dcKFX3jn0MpIaXjisoRvexIJVEKzaq7z2rZKxf+MSr9TkdmHmsU4m2lcLojrj/FHl8mk5VxMmYA+ftRkP/3oKQ==", - "dev": true - }, "node_modules/crelt": { "version": "1.0.6", "resolved": "https://registry.npmjs.org/crelt/-/crelt-1.0.6.tgz", @@ -9744,15 +9700,6 @@ "integrity": "sha512-gxtyfqMg7GKyhQmb056K7M3xszy/myH8w+B4RT+QXBQsvAOdc3XymqDDPHx1BgPgsdAA5SIifona89YtRATDzw==", "dev": true }, - "node_modules/diff": { - "version": "4.0.2", - "resolved": "https://registry.npmjs.org/diff/-/diff-4.0.2.tgz", - "integrity": "sha512-58lmxKSA4BNyLz+HHMUzlOEpg09FV+ev6ZMe3vJihgdxzgcwZ8VoEEPmALCZG9LmqfVoNMMKpttIYTVG6uDY7A==", - "dev": true, - "engines": { - "node": ">=0.3.1" - } - }, "node_modules/dir-glob": { "version": "3.0.1", "resolved": "https://registry.npmjs.org/dir-glob/-/dir-glob-3.0.1.tgz", @@ -11205,6 +11152,41 @@ "void-elements": "3.1.0" } }, + "node_modules/html-to-text": { + "version": "9.0.5", + "resolved": "https://registry.npmjs.org/html-to-text/-/html-to-text-9.0.5.tgz", + "integrity": "sha512-qY60FjREgVZL03vJU6IfMV4GDjGBIoOyvuFdpBDIX9yTlDw0TjxVBQp+P8NvpdIXNJvfWBTNul7fsAQJq2FNpg==", + "license": "MIT", + "dependencies": { + "@selderee/plugin-htmlparser2": "^0.11.0", + "deepmerge": "^4.3.1", + "dom-serializer": "^2.0.0", + "htmlparser2": "^8.0.2", + "selderee": "^0.11.0" + }, + "engines": { + "node": ">=14" + } + }, + "node_modules/htmlparser2": { + "version": "8.0.2", + "resolved": "https://registry.npmjs.org/htmlparser2/-/htmlparser2-8.0.2.tgz", + "integrity": "sha512-GYdjWKDkbRLkZ5geuHs5NY1puJ+PXwP7+fHPRz06Eirsb9ugf6d8kkXav6ADhcODhFFPMIXyxkxSuMf3D6NCFA==", + "funding": [ + "https://github.com/fb55/htmlparser2?sponsor=1", + { + "type": "github", + "url": "https://github.com/sponsors/fb55" + } + ], + "license": "MIT", + "dependencies": { + "domelementtype": "^2.3.0", + "domhandler": "^5.0.3", + "domutils": "^3.0.1", + "entities": "^4.4.0" + } + }, "node_modules/http-cache-semantics": { "version": "4.1.1", "resolved": "https://registry.npmjs.org/http-cache-semantics/-/http-cache-semantics-4.1.1.tgz", @@ -12071,6 +12053,15 @@ "node": ">=6" } }, + "node_modules/leac": { + "version": "0.6.0", + "resolved": "https://registry.npmjs.org/leac/-/leac-0.6.0.tgz", + "integrity": "sha512-y+SqErxb8h7nE/fiEX07jsbuhrpO9lL8eca7/Y1nuWV2moNlXhyd59iDGcRf6moVyDMbmTNzL40SUyrFU/yDpg==", + "license": "MIT", + "funding": { + "url": "https://ko-fi.com/killymxi" + } + }, "node_modules/lilconfig": { "version": "2.1.0", "resolved": "https://registry.npmjs.org/lilconfig/-/lilconfig-2.1.0.tgz", @@ -12194,12 +12185,6 @@ "resolved": "https://registry.npmjs.org/@jridgewell/sourcemap-codec/-/sourcemap-codec-1.5.0.tgz", "integrity": "sha512-gv3ZRaISU3fjPAgNsriBRqGWQL6quFx04YMPW/zD8XMLsU32mhCCbfbO6KZFLjvYpCZ8zyDEgqsgf+PwPaM7GQ==" }, - "node_modules/make-error": { - "version": "1.3.6", - "resolved": "https://registry.npmjs.org/make-error/-/make-error-1.3.6.tgz", - "integrity": "sha512-s8UhlNe7vPKomQhC1qFelMokr/Sc3AgNbso3n74mVPA5LTZwkB9NlXf4XPamLxJE8h0gh73rM94xvwRT2CVInw==", - "dev": true - }, "node_modules/make-fetch-happen": { "version": "11.1.1", "resolved": "https://registry.npmjs.org/make-fetch-happen/-/make-fetch-happen-11.1.1.tgz", @@ -13468,6 +13453,19 @@ "resolved": "https://registry.npmjs.org/parse5/-/parse5-6.0.1.tgz", "integrity": "sha512-Ofn/CTFzRGTTxwpNEs9PP93gXShHcTq255nzRYSKe8AkVpZY7e1fpmTfOyoIvjP5HG7Z2ZM7VS9PPhQGW2pOpw==" }, + "node_modules/parseley": { + "version": "0.12.1", + "resolved": "https://registry.npmjs.org/parseley/-/parseley-0.12.1.tgz", + "integrity": "sha512-e6qHKe3a9HWr0oMRVDTRhKce+bRO8VGQR3NyVwcjwrbhMmFCX9KszEV35+rn4AdilFAq9VPxP/Fe1wC9Qjd2lw==", + "license": "MIT", + "dependencies": { + "leac": "^0.6.0", + "peberminta": "^0.9.0" + }, + "funding": { + "url": "https://ko-fi.com/killymxi" + } + }, "node_modules/pastebar-app-ui": { "resolved": "packages/pastebar-app-ui", "link": true @@ -13538,6 +13536,15 @@ "integrity": "sha512-whLdWMYL2TwI08hn8/ZqAbrVemu0LNaNNJZX73O6qaIdCTfXutsLhMkjdENX0qhsQ9uIimo4/aQOmXkoon2nDQ==", "dev": true }, + "node_modules/peberminta": { + "version": "0.9.0", + "resolved": "https://registry.npmjs.org/peberminta/-/peberminta-0.9.0.tgz", + "integrity": "sha512-XIxfHpEuSJbITd1H3EeQwpcZbTLHc+VVr8ANI9t5sit565tsI4/xK3KWTUFE2e6QiangUkh3B0jihzmGnNrRsQ==", + "license": "MIT", + "funding": { + "url": "https://ko-fi.com/killymxi" + } + }, "node_modules/performance-now": { "version": "2.1.0", "resolved": "https://registry.npmjs.org/performance-now/-/performance-now-2.1.0.tgz", @@ -15117,6 +15124,18 @@ "resolved": "https://registry.npmjs.org/scriptjs/-/scriptjs-2.5.9.tgz", "integrity": "sha512-qGVDoreyYiP1pkQnbnFAUIS5AjenNwwQBdl7zeos9etl+hYKWahjRTfzAZZYBv5xNHx7vNKCmaLDQZ6Fr2AEXg==" }, + "node_modules/selderee": { + "version": "0.11.0", + "resolved": "https://registry.npmjs.org/selderee/-/selderee-0.11.0.tgz", + "integrity": "sha512-5TF+l7p4+OsnP8BCCvSyZiSPc4x4//p5uPwK8TCnVPJYRmU2aYKMpOXvw8zM5a5JvuuCGN1jmsMwuU2W02ukfA==", + "license": "MIT", + "dependencies": { + "parseley": "^0.12.0" + }, + "funding": { + "url": "https://ko-fi.com/killymxi" + } + }, "node_modules/semver": { "version": "6.3.1", "resolved": "https://registry.npmjs.org/semver/-/semver-6.3.1.tgz", @@ -16179,55 +16198,6 @@ "integrity": "sha512-Y/arvbn+rrz3JCKl9C4kVNfTfSm2/mEp5FSz5EsZSANGPSlQrpRI5M4PKF+mJnE52jOO90PnPSc3Ur3bTQw0gA==", "dev": true }, - "node_modules/ts-node": { - "version": "10.9.2", - "resolved": "https://registry.npmjs.org/ts-node/-/ts-node-10.9.2.tgz", - "integrity": "sha512-f0FFpIdcHgn8zcPSbf1dRevwt047YMnaiJM3u2w2RewrB+fob/zePZcrOyQoLMMO7aBIddLcQIEK5dYjkLnGrQ==", - "dev": true, - "dependencies": { - "@cspotcode/source-map-support": "^0.8.0", - "@tsconfig/node10": "^1.0.7", - "@tsconfig/node12": "^1.0.7", - "@tsconfig/node14": "^1.0.0", - "@tsconfig/node16": "^1.0.2", - "acorn": "^8.4.1", - "acorn-walk": "^8.1.1", - "arg": "^4.1.0", - "create-require": "^1.1.0", - "diff": "^4.0.1", - "make-error": "^1.1.1", - "v8-compile-cache-lib": "^3.0.1", - "yn": "3.1.1" - }, - "bin": { - "ts-node": "dist/bin.js", - "ts-node-cwd": "dist/bin-cwd.js", - "ts-node-esm": "dist/bin-esm.js", - "ts-node-script": "dist/bin-script.js", - "ts-node-transpile-only": "dist/bin-transpile.js", - "ts-script": "dist/bin-script-deprecated.js" - }, - "peerDependencies": { - "@swc/core": ">=1.2.50", - "@swc/wasm": ">=1.2.50", - "@types/node": "*", - "typescript": ">=2.7" - }, - "peerDependenciesMeta": { - "@swc/core": { - "optional": true - }, - "@swc/wasm": { - "optional": true - } - } - }, - "node_modules/ts-node/node_modules/arg": { - "version": "4.1.3", - "resolved": "https://registry.npmjs.org/arg/-/arg-4.1.3.tgz", - "integrity": "sha512-58S9QDqG0Xx27YwPSt9fJxivjYl432YCwfDMfZ+71RAqUrZef7LrKQZ3LHLOwCS4FLNBplP533Zx895SeOCHvA==", - "dev": true - }, "node_modules/tsconfig-paths": { "version": "3.14.2", "resolved": "https://registry.npmjs.org/tsconfig-paths/-/tsconfig-paths-3.14.2.tgz", @@ -16662,12 +16632,6 @@ "uuid": "dist/bin/uuid" } }, - "node_modules/v8-compile-cache-lib": { - "version": "3.0.1", - "resolved": "https://registry.npmjs.org/v8-compile-cache-lib/-/v8-compile-cache-lib-3.0.1.tgz", - "integrity": "sha512-wa7YjyUGfNZngI/vtK0UHAN+lgDCxBPCylVXGp0zu59Fz5aiGtNXaq3DhIov063MorB+VfufLh3JlF2KdTK3xg==", - "dev": true - }, "node_modules/validate-npm-package-license": { "version": "3.0.4", "resolved": "https://registry.npmjs.org/validate-npm-package-license/-/validate-npm-package-license-3.0.4.tgz", @@ -17150,15 +17114,6 @@ "node": ">=12" } }, - "node_modules/yn": { - "version": "3.1.1", - "resolved": "https://registry.npmjs.org/yn/-/yn-3.1.1.tgz", - "integrity": "sha512-Ux4ygGWsu2c7isFWe8Yu1YluJmqVhxqK2cLXNQA5AcC3QfbGNpM7fu0Y8b/z16pXLnFxZYvWhd3fhBY9DLmC6Q==", - "dev": true, - "engines": { - "node": ">=6" - } - }, "node_modules/youtube-player": { "version": "5.5.2", "resolved": "https://registry.npmjs.org/youtube-player/-/youtube-player-5.5.2.tgz", @@ -17307,6 +17262,7 @@ "framer-motion": "^10.16.5", "garbados-crypt": "^3.0.0-beta", "glob-all": "^3.3.1", + "html-to-text": "^9.0.5", "i18next": "^23.10.0", "i18next-browser-languagedetector": "^7.2.0", "idb-keyval": "^6.2.1", @@ -19906,27 +19862,6 @@ "integrity": "sha512-ooWCrlZP11i8GImSjTHYHLkvFDP48nS4+204nGb1RiX/WXYHmJA2III9/e2DWVabCESdW7hBAEzHRqUn9OUVvQ==", "optional": true }, - "@cspotcode/source-map-support": { - "version": "0.8.1", - "resolved": "https://registry.npmjs.org/@cspotcode/source-map-support/-/source-map-support-0.8.1.tgz", - "integrity": "sha512-IchNf6dN4tHoMFIn/7OE8LWZ19Y6q/67Bmf6vnGREv8RSbBVb9LPJxEcnwrcwX6ixSvaiGoomAUvu4YSxXrVgw==", - "dev": true, - "requires": { - "@jridgewell/trace-mapping": "0.3.9" - }, - "dependencies": { - "@jridgewell/trace-mapping": { - "version": "0.3.9", - "resolved": "https://registry.npmjs.org/@jridgewell/trace-mapping/-/trace-mapping-0.3.9.tgz", - "integrity": "sha512-3Belt6tdc8bPgAtbcmdtNJlirVoTmEb5e2gC94PnkwEW9jI6CAHUeoG85tjWP5WquqfavoMtMwiG4P926ZKKuQ==", - "dev": true, - "requires": { - "@jridgewell/resolve-uri": "^3.0.3", - "@jridgewell/sourcemap-codec": "^1.4.10" - } - } - } - }, "@dnd-kit/accessibility": { "version": "3.1.0", "resolved": "https://registry.npmjs.org/@dnd-kit/accessibility/-/accessibility-3.1.0.tgz", @@ -22193,6 +22128,15 @@ "integrity": "sha512-+10CMg9vt1MoHj6x1pxyjPSMjHTIlqs8/tBztXvPAx24SKs9jwVnKqHJumlH/IzhaPUaj3T6T6wfZr8okdXaIg==", "optional": true }, + "@selderee/plugin-htmlparser2": { + "version": "0.11.0", + "resolved": "https://registry.npmjs.org/@selderee/plugin-htmlparser2/-/plugin-htmlparser2-0.11.0.tgz", + "integrity": "sha512-P33hHGdldxGabLFjPPpaTxVolMrzrcegejx+0GxjrIb9Zv48D8yAIA/QTDR2dFl7Uz7urX8aX6+5bCZslr+gWQ==", + "requires": { + "domhandler": "^5.0.3", + "selderee": "^0.11.0" + } + }, "@sigstore/bundle": { "version": "2.1.0", "resolved": "https://registry.npmjs.org/@sigstore/bundle/-/bundle-2.1.0.tgz", @@ -22697,30 +22641,6 @@ "resolved": "https://registry.npmjs.org/@trysound/sax/-/sax-0.2.0.tgz", "integrity": "sha512-L7z9BgrNEcYyUYtF+HaEfiS5ebkh9jXqbszz7pC0hRBPaatV0XjSD3+eHrpqFemQfgwiFF0QPIarnIihIDn7OA==" }, - "@tsconfig/node10": { - "version": "1.0.11", - "resolved": "https://registry.npmjs.org/@tsconfig/node10/-/node10-1.0.11.tgz", - "integrity": "sha512-DcRjDCujK/kCk/cUe8Xz8ZSpm8mS3mNNpta+jGCA6USEDfktlNvm1+IuZ9eTcDbNk41BHwpHHeW+N1lKCz4zOw==", - "dev": true - }, - "@tsconfig/node12": { - "version": "1.0.11", - "resolved": "https://registry.npmjs.org/@tsconfig/node12/-/node12-1.0.11.tgz", - "integrity": "sha512-cqefuRsh12pWyGsIoBKJA9luFu3mRxCA+ORZvA4ktLSzIuCUtWVxGIuXigEwO5/ywWFMZ2QEGKWvkZG1zDMTag==", - "dev": true - }, - "@tsconfig/node14": { - "version": "1.0.3", - "resolved": "https://registry.npmjs.org/@tsconfig/node14/-/node14-1.0.3.tgz", - "integrity": "sha512-ysT8mhdixWK6Hw3i1V2AeRqZ5WfXg1G43mqoYlM2nc6388Fq5jcXyr5mRsqViLx/GJYdoL0bfXD8nmF+Zn/Iow==", - "dev": true - }, - "@tsconfig/node16": { - "version": "1.0.4", - "resolved": "https://registry.npmjs.org/@tsconfig/node16/-/node16-1.0.4.tgz", - "integrity": "sha512-vxhUy4J8lyeyinH7Azl1pdd43GJhZH/tP2weN8TntQblOY+A0XbT8DJk1/oCPuOOyg/Ja757rG0CgHcWC8OfMA==", - "dev": true - }, "@tufjs/canonical-json": { "version": "2.0.0", "resolved": "https://registry.npmjs.org/@tufjs/canonical-json/-/canonical-json-2.0.0.tgz", @@ -22898,6 +22818,12 @@ "integrity": "sha512-trOc4AAUThEz9hapPtSd7wf5tiQKvTtu5b371UxXdTuqzIh0ArcRspRP0i0Viu+LXstIQ1z96t1nsPxT9ol01g==", "dev": true }, + "@types/html-to-text": { + "version": "9.0.4", + "resolved": "https://registry.npmjs.org/@types/html-to-text/-/html-to-text-9.0.4.tgz", + "integrity": "sha512-pUY3cKH/Nm2yYrEmDlPR1mR7yszjGx4DrwPjQ702C4/D5CwHuZTgZdIdwPkRbcuhs7BAh2L5rg3CL5cbRiGTCQ==", + "dev": true + }, "@types/istanbul-lib-coverage": { "version": "2.0.6", "resolved": "https://registry.npmjs.org/@types/istanbul-lib-coverage/-/istanbul-lib-coverage-2.0.6.tgz", @@ -23173,15 +23099,6 @@ "resolved": "https://registry.npmjs.org/acorn/-/acorn-8.14.0.tgz", "integrity": "sha512-cl669nCJTZBsL97OF4kUQm5g5hC2uihk0NxY3WENAC0TYdILVkAyHymAntgxGkl7K+t0cXIrH5siy5S4XkFycA==" }, - "acorn-walk": { - "version": "8.3.4", - "resolved": "https://registry.npmjs.org/acorn-walk/-/acorn-walk-8.3.4.tgz", - "integrity": "sha512-ueEepnujpqee2o5aIYnvHU6C0A42MNdsIDeqy5BydrkuC5R1ZuUFnm27EeFJGoEHJQgn3uleRvmTXaJgfXbt4g==", - "dev": true, - "requires": { - "acorn": "^8.11.0" - } - }, "agent-base": { "version": "6.0.2", "resolved": "https://registry.npmjs.org/agent-base/-/agent-base-6.0.2.tgz", @@ -24199,12 +24116,6 @@ "yaml": "^1.10.0" } }, - "create-require": { - "version": "1.1.1", - "resolved": "https://registry.npmjs.org/create-require/-/create-require-1.1.1.tgz", - "integrity": "sha512-dcKFX3jn0MpIaXjisoRvexIJVEKzaq7z2rZKxf+MSr9TkdmHmsU4m2lcLojrj/FHl8mk5VxMmYA+ftRkP/3oKQ==", - "dev": true - }, "crelt": { "version": "1.0.6", "resolved": "https://registry.npmjs.org/crelt/-/crelt-1.0.6.tgz", @@ -24540,12 +24451,6 @@ "integrity": "sha512-gxtyfqMg7GKyhQmb056K7M3xszy/myH8w+B4RT+QXBQsvAOdc3XymqDDPHx1BgPgsdAA5SIifona89YtRATDzw==", "dev": true }, - "diff": { - "version": "4.0.2", - "resolved": "https://registry.npmjs.org/diff/-/diff-4.0.2.tgz", - "integrity": "sha512-58lmxKSA4BNyLz+HHMUzlOEpg09FV+ev6ZMe3vJihgdxzgcwZ8VoEEPmALCZG9LmqfVoNMMKpttIYTVG6uDY7A==", - "dev": true - }, "dir-glob": { "version": "3.0.1", "resolved": "https://registry.npmjs.org/dir-glob/-/dir-glob-3.0.1.tgz", @@ -25638,6 +25543,29 @@ "void-elements": "3.1.0" } }, + "html-to-text": { + "version": "9.0.5", + "resolved": "https://registry.npmjs.org/html-to-text/-/html-to-text-9.0.5.tgz", + "integrity": "sha512-qY60FjREgVZL03vJU6IfMV4GDjGBIoOyvuFdpBDIX9yTlDw0TjxVBQp+P8NvpdIXNJvfWBTNul7fsAQJq2FNpg==", + "requires": { + "@selderee/plugin-htmlparser2": "^0.11.0", + "deepmerge": "^4.3.1", + "dom-serializer": "^2.0.0", + "htmlparser2": "^8.0.2", + "selderee": "^0.11.0" + } + }, + "htmlparser2": { + "version": "8.0.2", + "resolved": "https://registry.npmjs.org/htmlparser2/-/htmlparser2-8.0.2.tgz", + "integrity": "sha512-GYdjWKDkbRLkZ5geuHs5NY1puJ+PXwP7+fHPRz06Eirsb9ugf6d8kkXav6ADhcODhFFPMIXyxkxSuMf3D6NCFA==", + "requires": { + "domelementtype": "^2.3.0", + "domhandler": "^5.0.3", + "domutils": "^3.0.1", + "entities": "^4.4.0" + } + }, "http-cache-semantics": { "version": "4.1.1", "resolved": "https://registry.npmjs.org/http-cache-semantics/-/http-cache-semantics-4.1.1.tgz", @@ -26246,6 +26174,11 @@ "integrity": "sha512-eTIzlVOSUR+JxdDFepEYcBMtZ9Qqdef+rnzWdRZuMbOywu5tO2w2N7rqjoANZ5k9vywhL6Br1VRjUIgTQx4E8w==", "dev": true }, + "leac": { + "version": "0.6.0", + "resolved": "https://registry.npmjs.org/leac/-/leac-0.6.0.tgz", + "integrity": "sha512-y+SqErxb8h7nE/fiEX07jsbuhrpO9lL8eca7/Y1nuWV2moNlXhyd59iDGcRf6moVyDMbmTNzL40SUyrFU/yDpg==" + }, "lilconfig": { "version": "2.1.0", "resolved": "https://registry.npmjs.org/lilconfig/-/lilconfig-2.1.0.tgz", @@ -26353,12 +26286,6 @@ } } }, - "make-error": { - "version": "1.3.6", - "resolved": "https://registry.npmjs.org/make-error/-/make-error-1.3.6.tgz", - "integrity": "sha512-s8UhlNe7vPKomQhC1qFelMokr/Sc3AgNbso3n74mVPA5LTZwkB9NlXf4XPamLxJE8h0gh73rM94xvwRT2CVInw==", - "dev": true - }, "make-fetch-happen": { "version": "11.1.1", "resolved": "https://registry.npmjs.org/make-fetch-happen/-/make-fetch-happen-11.1.1.tgz", @@ -27335,6 +27262,15 @@ } } }, + "parseley": { + "version": "0.12.1", + "resolved": "https://registry.npmjs.org/parseley/-/parseley-0.12.1.tgz", + "integrity": "sha512-e6qHKe3a9HWr0oMRVDTRhKce+bRO8VGQR3NyVwcjwrbhMmFCX9KszEV35+rn4AdilFAq9VPxP/Fe1wC9Qjd2lw==", + "requires": { + "leac": "^0.6.0", + "peberminta": "^0.9.0" + } + }, "pastebar-app-ui": { "version": "file:packages/pastebar-app-ui", "requires": { @@ -27435,6 +27371,7 @@ "framer-motion": "^10.16.5", "garbados-crypt": "^3.0.0-beta", "glob-all": "^3.3.1", + "html-to-text": "^9.0.5", "i18next": "^23.10.0", "i18next-browser-languagedetector": "^7.2.0", "idb-keyval": "^6.2.1", @@ -27883,6 +27820,11 @@ "integrity": "sha512-whLdWMYL2TwI08hn8/ZqAbrVemu0LNaNNJZX73O6qaIdCTfXutsLhMkjdENX0qhsQ9uIimo4/aQOmXkoon2nDQ==", "dev": true }, + "peberminta": { + "version": "0.9.0", + "resolved": "https://registry.npmjs.org/peberminta/-/peberminta-0.9.0.tgz", + "integrity": "sha512-XIxfHpEuSJbITd1H3EeQwpcZbTLHc+VVr8ANI9t5sit565tsI4/xK3KWTUFE2e6QiangUkh3B0jihzmGnNrRsQ==" + }, "performance-now": { "version": "2.1.0", "resolved": "https://registry.npmjs.org/performance-now/-/performance-now-2.1.0.tgz", @@ -28896,6 +28838,14 @@ "resolved": "https://registry.npmjs.org/scriptjs/-/scriptjs-2.5.9.tgz", "integrity": "sha512-qGVDoreyYiP1pkQnbnFAUIS5AjenNwwQBdl7zeos9etl+hYKWahjRTfzAZZYBv5xNHx7vNKCmaLDQZ6Fr2AEXg==" }, + "selderee": { + "version": "0.11.0", + "resolved": "https://registry.npmjs.org/selderee/-/selderee-0.11.0.tgz", + "integrity": "sha512-5TF+l7p4+OsnP8BCCvSyZiSPc4x4//p5uPwK8TCnVPJYRmU2aYKMpOXvw8zM5a5JvuuCGN1jmsMwuU2W02ukfA==", + "requires": { + "parseley": "^0.12.0" + } + }, "semver": { "version": "6.3.1", "resolved": "https://registry.npmjs.org/semver/-/semver-6.3.1.tgz", @@ -29686,35 +29636,6 @@ "integrity": "sha512-Y/arvbn+rrz3JCKl9C4kVNfTfSm2/mEp5FSz5EsZSANGPSlQrpRI5M4PKF+mJnE52jOO90PnPSc3Ur3bTQw0gA==", "dev": true }, - "ts-node": { - "version": "10.9.2", - "resolved": "https://registry.npmjs.org/ts-node/-/ts-node-10.9.2.tgz", - "integrity": "sha512-f0FFpIdcHgn8zcPSbf1dRevwt047YMnaiJM3u2w2RewrB+fob/zePZcrOyQoLMMO7aBIddLcQIEK5dYjkLnGrQ==", - "dev": true, - "requires": { - "@cspotcode/source-map-support": "^0.8.0", - "@tsconfig/node10": "^1.0.7", - "@tsconfig/node12": "^1.0.7", - "@tsconfig/node14": "^1.0.0", - "@tsconfig/node16": "^1.0.2", - "acorn": "^8.4.1", - "acorn-walk": "^8.1.1", - "arg": "^4.1.0", - "create-require": "^1.1.0", - "diff": "^4.0.1", - "make-error": "^1.1.1", - "v8-compile-cache-lib": "^3.0.1", - "yn": "3.1.1" - }, - "dependencies": { - "arg": { - "version": "4.1.3", - "resolved": "https://registry.npmjs.org/arg/-/arg-4.1.3.tgz", - "integrity": "sha512-58S9QDqG0Xx27YwPSt9fJxivjYl432YCwfDMfZ+71RAqUrZef7LrKQZ3LHLOwCS4FLNBplP533Zx895SeOCHvA==", - "dev": true - } - } - }, "tsconfig-paths": { "version": "3.14.2", "resolved": "https://registry.npmjs.org/tsconfig-paths/-/tsconfig-paths-3.14.2.tgz", @@ -30009,12 +29930,6 @@ "resolved": "https://registry.npmjs.org/uuid/-/uuid-10.0.0.tgz", "integrity": "sha512-8XkAphELsDnEGrDxUOHB3RGvXz6TeuYSGEZBOjtTtPm2lwhGBjLgOzLHB63IUWfBpNucQjND6d3AOudO+H3RWQ==" }, - "v8-compile-cache-lib": { - "version": "3.0.1", - "resolved": "https://registry.npmjs.org/v8-compile-cache-lib/-/v8-compile-cache-lib-3.0.1.tgz", - "integrity": "sha512-wa7YjyUGfNZngI/vtK0UHAN+lgDCxBPCylVXGp0zu59Fz5aiGtNXaq3DhIov063MorB+VfufLh3JlF2KdTK3xg==", - "dev": true - }, "validate-npm-package-license": { "version": "3.0.4", "resolved": "https://registry.npmjs.org/validate-npm-package-license/-/validate-npm-package-license-3.0.4.tgz", @@ -30343,12 +30258,6 @@ "integrity": "sha512-tVpsJW7DdjecAiFpbIB1e3qxIQsE6NoPc5/eTdrbbIC4h0LVsWhnoa3g+m2HclBIujHzsxZ4VJVA+GUuc2/LBw==", "dev": true }, - "yn": { - "version": "3.1.1", - "resolved": "https://registry.npmjs.org/yn/-/yn-3.1.1.tgz", - "integrity": "sha512-Ux4ygGWsu2c7isFWe8Yu1YluJmqVhxqK2cLXNQA5AcC3QfbGNpM7fu0Y8b/z16pXLnFxZYvWhd3fhBY9DLmC6Q==", - "dev": true - }, "youtube-player": { "version": "5.5.2", "resolved": "https://registry.npmjs.org/youtube-player/-/youtube-player-5.5.2.tgz", diff --git a/package.json b/package.json index 85c922fa..7daff615 100644 --- a/package.json +++ b/package.json @@ -156,6 +156,7 @@ "@types/codemirror": "^5.60.15", "@types/dompurify": "^3.0.5", "@types/events": "^3.0.3", + "@types/html-to-text": "^9.0.4", "@types/js-yaml": "^4.0.9", "@types/linkify-it": "^3.0.5", "@types/lodash-es": "^4.17.12", diff --git a/packages/pastebar-app-ui/package.json b/packages/pastebar-app-ui/package.json index 6c730f6f..5009c997 100644 --- a/packages/pastebar-app-ui/package.json +++ b/packages/pastebar-app-ui/package.json @@ -82,6 +82,7 @@ "framer-motion": "^10.16.5", "garbados-crypt": "^3.0.0-beta", "glob-all": "^3.3.1", + "html-to-text": "^9.0.5", "i18next": "^23.10.0", "i18next-browser-languagedetector": "^7.2.0", "idb-keyval": "^6.2.1", diff --git a/packages/pastebar-app-ui/src/App.tsx b/packages/pastebar-app-ui/src/App.tsx index 8e56f7c3..fbfb561c 100644 --- a/packages/pastebar-app-ui/src/App.tsx +++ b/packages/pastebar-app-ui/src/App.tsx @@ -34,6 +34,8 @@ import { settingsStoreAtom, themeStoreAtom, uiStoreAtom, + DEFAULT_SPECIAL_PASTE_OPERATIONS, + DEFAULT_SPECIAL_PASTE_CATEGORIES, } from './store' const appIdleEvents = ['mousemove', 'keydown', 'scroll', 'keypress', 'mousedown'] @@ -238,6 +240,14 @@ function App() { globalTemplates: settings.globalTemplates?.valueText ? settings.globalTemplates.valueText // Will be parsed by initSettings in store : [], // Default to empty array + isSpecialCopyPasteHistoryEnabled: + settings.isSpecialCopyPasteHistoryEnabled?.valueBool ?? true, + enabledSpecialPasteOperations: settings.enabledSpecialPasteOperations?.valueText + ? settings.enabledSpecialPasteOperations.valueText.split(',').filter(Boolean) + : [...DEFAULT_SPECIAL_PASTE_OPERATIONS], + specialPasteCategoriesOrder: settings.specialPasteCategoriesOrder?.valueText + ? settings.specialPasteCategoriesOrder.valueText.split(',').filter(Boolean) + : [...DEFAULT_SPECIAL_PASTE_CATEGORIES], isAppReady: true, }) settingsStore.initConstants({ diff --git a/packages/pastebar-app-ui/src/hooks/use-delete-confirmation-items.ts b/packages/pastebar-app-ui/src/hooks/use-delete-confirmation-items.ts index 7b3681b6..85c9fa3f 100644 --- a/packages/pastebar-app-ui/src/hooks/use-delete-confirmation-items.ts +++ b/packages/pastebar-app-ui/src/hooks/use-delete-confirmation-items.ts @@ -13,7 +13,7 @@ const useDeleteConfirmationTimer = ({ onConfirmedDelete: () => Promise onConfirmedReset?: () => void selectedHistoryItems: UniqueIdentifier[] - hoveringHistoryRowId: Signal | null + hoveringHistoryRowId: Signal timerDuration?: number }) => { const timerRef = useRef(null) as React.MutableRefObject @@ -34,15 +34,17 @@ const useDeleteConfirmationTimer = ({ if (timerRef.current) { clearTimeout(timerRef.current) } - if (hoveringHistoryRowId?.value && selectedHistoryItems.length === 0) { + + if (selectedHistoryItems.length === 0) { seHoveringHistoryIdDelete(hoveringHistoryRowId.value) } + setShowConfirmation(true) timerRef.current = setTimeout(() => { resetTimer() }, timerDuration) - }, [timerDuration, resetTimer, selectedHistoryItems]) + }, [timerDuration, resetTimer, selectedHistoryItems, hoveringHistoryRowId]) useEffect(() => { return () => { diff --git a/packages/pastebar-app-ui/src/hooks/use-keyboard-delete-confirmation.ts b/packages/pastebar-app-ui/src/hooks/use-keyboard-delete-confirmation.ts new file mode 100644 index 00000000..aa1416bf --- /dev/null +++ b/packages/pastebar-app-ui/src/hooks/use-keyboard-delete-confirmation.ts @@ -0,0 +1,102 @@ +import { useCallback, useEffect, useRef, useState } from 'react' +import { UniqueIdentifier } from '@dnd-kit/core' +import { Signal } from '@preact/signals-react' +import { useHotkeys } from 'react-hotkeys-hook' + +const useKeyboardDeleteConfirmation = ({ + onConfirmedDelete, + keyboardSelectedItemId, + onConfirmedReset, + selectedHistoryItems, + timerDuration = 3000, +}: { + onConfirmedDelete: () => Promise + onConfirmedReset?: () => void + selectedHistoryItems: UniqueIdentifier[] + keyboardSelectedItemId: Signal + timerDuration?: number +}) => { + const timerRef = useRef(null) as React.MutableRefObject + const [showConfirmation, setShowConfirmation] = useState(false) + const [keyboardItemIdDelete, setKeyboardItemIdDelete] = + useState(null) + + const resetTimer = useCallback(() => { + if (timerRef.current) { + clearTimeout(timerRef.current) + } + setKeyboardItemIdDelete(null) + setShowConfirmation(false) + onConfirmedReset?.() + }, [onConfirmedReset]) + + const startTimer = useCallback(() => { + if (timerRef.current) { + clearTimeout(timerRef.current) + } + + // Only proceed if there's a keyboard selected item and no multi-selection + if (!keyboardSelectedItemId.value || selectedHistoryItems.length > 0) { + return + } + + setKeyboardItemIdDelete(keyboardSelectedItemId.value) + setShowConfirmation(true) + + timerRef.current = setTimeout(() => { + resetTimer() + }, timerDuration) + }, [timerDuration, resetTimer, selectedHistoryItems, keyboardSelectedItemId]) + + // Reset confirmation when the keyboard selected item changes + useEffect(() => { + if (showConfirmation && keyboardItemIdDelete !== keyboardSelectedItemId.value) { + resetTimer() + } + }, [keyboardSelectedItemId.value, showConfirmation, keyboardItemIdDelete, resetTimer]) + + // Reset confirmation when there are selected items (multi-selection mode) + useEffect(() => { + if (showConfirmation && selectedHistoryItems.length > 0) { + resetTimer() + } + }, [selectedHistoryItems.length, showConfirmation, resetTimer]) + + useEffect(() => { + return () => { + if (timerRef.current) { + clearTimeout(timerRef.current) + } + } + }, []) + + useHotkeys( + ['delete', 'backspace'], + async (e) => { + e.preventDefault() + + // Only handle keyboard delete when there's a keyboard selected item and no multi-selection + if (!keyboardSelectedItemId.value || selectedHistoryItems.length > 0) { + return + } + + if (showConfirmation) { + await onConfirmedDelete() + resetTimer() + } else { + startTimer() + } + }, + { + enableOnFormTags: false, + } + ) + + return { + showConfirmation, + keyboardItemIdDelete, + resetTimer, + } +} + +export default useKeyboardDeleteConfirmation diff --git a/packages/pastebar-app-ui/src/hooks/use-special-copypaste-history-item.ts b/packages/pastebar-app-ui/src/hooks/use-special-copypaste-history-item.ts new file mode 100644 index 00000000..93e65fd6 --- /dev/null +++ b/packages/pastebar-app-ui/src/hooks/use-special-copypaste-history-item.ts @@ -0,0 +1,164 @@ +import { useCallback, useRef } from 'react' +import { UniqueIdentifier } from '@dnd-kit/core/dist/types' +import { signal } from '@preact/signals-react' +import { invoke } from '@tauri-apps/api/tauri' +import { settingsStoreAtom } from '~/store' +import { useAtomValue } from 'jotai' + +import { applyTransform, TEXT_TRANSFORMS } from '~/lib/text-transforms' + +// Signals for tracking special copy/paste operations +export const specialCopiedItem = signal('') +export const specialPastedItem = signal('') +export const specialPastedItemCountDown = signal(0) + +interface UseSpecialCopyPasteOptions { + delay?: number +} + +export const useSpecialCopyPasteHistoryItem = ({ + delay = 800, +}: UseSpecialCopyPasteOptions = {}) => { + const { copyPasteDelay } = useAtomValue(settingsStoreAtom) + const countdownRef = useRef() + + // Special copy function - applies transformation and copies to clipboard + const specialCopy = async ( + historyId: UniqueIdentifier, + value: string, + transformId: string + ): Promise => { + try { + if (!value || !historyId) { + console.warn('No value or historyId to copy') + return + } + + // Set the signal to show UI feedback + specialCopiedItem.value = historyId + + // Apply the text transformation - this will throw if it fails + const transformedText = await applyTransform(value, transformId) + + // Only copy if transformation was successful + setTimeout(() => { + invoke('copy_text', { text: transformedText }) + .then(res => { + if (res === 'ok') { + requestAnimationFrame(() => { + specialCopiedItem.value = '' + }) + } else { + specialCopiedItem.value = '' + console.error('Failed to copy transformed text', res) + } + }) + .catch(err => { + specialCopiedItem.value = '' + console.error('Failed to copy transformed text', err) + }) + }, delay) + } catch (error) { + // Clear UI feedback immediately on transformation error + specialCopiedItem.value = '' + console.error('Failed to special copy - transformation error:', error) + // Don't copy anything to clipboard when transformation fails + throw error + } + } + + // Countdown helper for paste operations + const pasteCountdown = useCallback( + (initialCount: number, intervalMs = 1000): Promise => { + clearInterval(countdownRef.current) + return new Promise(resolve => { + specialPastedItemCountDown.value = initialCount + countdownRef.current = setInterval(() => { + if (specialPastedItemCountDown.value > 0) { + if (specialPastedItemCountDown.value === 1) { + resolve() + } + specialPastedItemCountDown.value -= 1 + } else { + clearInterval(countdownRef.current) + } + }, intervalMs) + }) + }, + [] + ) + + // Execute paste action with transformed text + const executePasteAction = (text: string, delay = 0): Promise => { + return new Promise((resolve, reject) => { + invoke('copy_paste', { text, delay }) + .then(res => { + if (res === 'ok') { + resolve() + } else { + console.error('Failed to paste transformed text', res) + reject() + } + }) + .catch(err => { + console.error('Failed to paste transformed text', err) + reject() + }) + }) + } + + // Special paste function - applies transformation and pastes directly + const specialPaste = async ( + historyId: UniqueIdentifier, + value: string, + transformId: string, + delaySeconds?: number + ): Promise => { + try { + delaySeconds = delaySeconds ?? copyPasteDelay + + if (!value || !historyId) { + console.warn('No value or historyId to paste') + return + } + + // Set the signal to show UI feedback + specialPastedItem.value = historyId + + // Apply the text transformation - this will throw if it fails + const transformedText = await applyTransform(value, transformId) + + // Handle countdown if delay is specified (only if transformation succeeded) + if (delaySeconds > 0) { + await pasteCountdown(delaySeconds) + } + + // Execute paste with transformed text (only if transformation succeeded) + await executePasteAction(transformedText, 0) + + // Clear the signal after a short delay + setTimeout(() => { + requestAnimationFrame(() => { + specialPastedItem.value = '' + specialPastedItemCountDown.value = 0 + }) + }, delay) + } catch (error) { + // Clear UI feedback immediately on transformation error + specialPastedItem.value = '' + specialPastedItemCountDown.value = 0 + console.error('Failed to special paste - transformation error:', error) + // Don't paste anything when transformation fails + throw error + } + } + + return { + specialCopy, + specialPaste, + availableTransforms: TEXT_TRANSFORMS, + specialCopiedItem: specialCopiedItem.value, + specialPastedItem: specialPastedItem.value, + specialPastedItemCountDown: specialPastedItemCountDown.value, + } +} diff --git a/packages/pastebar-app-ui/src/lib/text-transforms.ts b/packages/pastebar-app-ui/src/lib/text-transforms.ts new file mode 100644 index 00000000..f9433384 --- /dev/null +++ b/packages/pastebar-app-ui/src/lib/text-transforms.ts @@ -0,0 +1,505 @@ +/** + * Text transformation utilities for special copy/paste operations + * Organized by categories with enable/disable controls + */ + +import DOMPurify from 'dompurify' + +export interface TextTransform { + id: string + label: string + transform: (text: string) => string | Promise +} + +export interface TransformCategory { + id: string + label: string + transforms?: TextTransform[] + subcategories?: TransformSubcategory[] +} + +export interface TransformSubcategory { + id: string + label: string + transforms: TextTransform[] +} + +// Transform functions for Text Case +const toUpperCase = (text: string): string => text.toUpperCase() +const toLowerCase = (text: string): string => text.toLowerCase() +const toTitleCase = (text: string): string => + text.replace(/\b\w/g, char => char.toUpperCase()) // Renamed from toCapitalize +const toSentenceCase = (text: string): string => + text.charAt(0).toUpperCase() + text.slice(1).toLowerCase() // Keep as is, assumes single string capitalization +const toInvertCase = (text: string): string => + text.replace(/[a-zA-Z]/g, char => + char === char.toUpperCase() ? char.toLowerCase() : char.toUpperCase() + ) + +// Transform functions for Code Formatting +const toCamelCase = (text: string): string => { + const normalized = text.replace(/[^a-zA-Z0-9]+/g, ' ').trim() // Normalize spaces/delimiters + return normalized + .replace(/(?:^\w|[A-Z]|\b\w)/g, (word, index) => + index === 0 ? word.toLowerCase() : word.toUpperCase() + ) + .replace(/\s+/g, '') +} + +const toSnakeCase = (text: string): string => { + return text + .replace(/([a-z0-9])([A-Z])/g, '$1_$2') // Add underscore before capital letters (camelCase to snake_case part) + .replace(/[^a-zA-Z0-9]+/g, '_') // Replace non-alphanumeric with underscore + .toLowerCase() + .replace(/^_|_$/g, '') // Remove leading/trailing underscores +} + +const toKebabCase = (text: string): string => { + return text + .replace(/([a-z0-9])([A-Z])/g, '$1-$2') // Add hyphen before capital letters + .replace(/[^a-zA-Z0-9]+/g, '-') // Replace non-alphanumeric with hyphen + .toLowerCase() + .replace(/^-|-$/g, '') // Remove leading/trailing hyphens +} + +const toPascalCase = (text: string): string => { + const normalized = text.replace(/[^a-zA-Z0-9]+/g, ' ').trim() // Normalize spaces/delimiters + return normalized + .replace(/(?:^\w|[A-Z]|\b\w)/g, word => word.toUpperCase()) + .replace(/\s+/g, '') +} + +// Transform functions for Whitespace & Lines +const trimWhiteSpace = (text: string): string => text.trim() +const removeLineFeeds = ( + text: string +): string => // More aggressive removal of multiple line feeds to single space + text + .replace(/\r?\n|\r/g, ' ') + .replace(/\s+/g, ' ') + .trim() +const addOneLineFeed = (text: string): string => text + '\n' +const addTwoLineFeeds = (text: string): string => text + '\n\n' +const removeExtraSpaces = (text: string): string => text.replace(/\s+/g, ' ') +const sortLinesAlphabetically = (text: string): string => { + const lines = text.split(/\r?\n|\r/) + return lines.sort((a, b) => a.localeCompare(b)).join('\n') +} +const removeDuplicateLines = (text: string): string => { + const lines = text.split(/\r?\n|\r/) + const uniqueLines = [...new Set(lines)] + return uniqueLines.join('\n') +} +const addLineNumbers = (text: string): string => { + const lines = text.split(/\r?\n|\r/) + return lines.map((line, index) => `${index + 1}. ${line}`).join('\n') +} + +// Transform functions for Encoding & Security +const toBase64Encode = (text: string): string => { + try { + // This is generally the most robust method for UTF-8 in browsers without polyfills + return btoa(String.fromCharCode(...new TextEncoder().encode(text))) + } catch (e) { + console.error('Base64 encode error:', e) + return text + } +} +const toBase64Decode = (text: string): string => { + try { + // This is generally the most robust method for UTF-8 in browsers without polyfills + return new TextDecoder().decode( + Uint8Array.from(atob(text), charCode => charCode.charCodeAt(0)) + ) + } catch (e) { + console.error('Base64 decode error:', e) + return text + } +} +const toUrlEncode = (text: string): string => encodeURIComponent(text) +const toUrlDecode = (text: string): string => { + try { + return decodeURIComponent(text) + } catch (e) { + console.error('URL decode error:', e) + return text + } +} + +function toHtmlEncode(str: string): string { + // Ensure the input is a string + if (typeof str !== 'string') { + console.warn('Input to encodeHtmlSpecialChars was not a string:', str) + return '' // Or throw an error, depending on desired behavior + } + + return str.replace(/[&<>"']/g, function (char) { + switch (char) { + case '&': + return '&' // Ampersand + case '<': + return '<' // Less than + case '>': + return '>' // Greater than + case '"': + return '"' // Double quote + case "'": + return ''' // Single quote (apostrophe) + default: + return char // Should not happen with the given regex, but good practice + } + }) +} + +const toHtmlDecode = (text: string): string => { + const sanitized = DOMPurify.sanitize(text, { RETURN_DOM: true }) + return sanitized.textContent || '' +} + +// Transform functions for Text Tools +const reverseText = (text: string): string => text.split('').reverse().join('') +const addCurrentDateTime = (text: string): string => { + const now = new Date() + return text + '\n' + now.toLocaleString() +} +const countCharacters = (text: string): string => { + const count = text.length + return `Character count: ${count}` +} +const countWords = (text: string): string => { + const words = text + .trim() + .split(/\s+/) + .filter(word => word.length > 0) + const count = words.length + return `Word count: ${count}` +} +const countLines = (text: string): string => { + const lines = text.split(/\r?\n|\r/) + const count = lines.length + return `Line count: ${count}` +} +const countSentences = (text: string): string => { + const sentences = text.split(/[.!?]+/).filter(sentence => sentence.trim().length > 0) + const count = sentences.length + return `Sentence count: ${count}` +} +const toJsonStringify = (text: string): string => { + try { + const parsed = JSON.parse(text) + return JSON.stringify(parsed, null, 2) + } catch { + return JSON.stringify(text) // If not valid JSON, stringify the text itself + } +} + +// Format Converter subcategories - organized by source format +const formatConverterSubcategories = [ + { + id: 'html', + label: 'HTML', + transforms: [ + { + id: 'htmlToMarkdown', + label: 'HTML to Markdown', + transform: (text: string) => convertFormat(text, 'html_to_markdown'), + }, + { + id: 'htmlToReact', + label: 'HTML to React JSX', + transform: (text: string) => convertFormat(text, 'html_to_react'), + }, + { + id: 'htmlToReactComponent', + label: 'HTML to React Component', + transform: (text: string) => convertFormat(text, 'html_to_react_components'), + }, + { + id: 'htmlToText', + label: 'HTML to Text', + transform: (text: string) => convertFormat(text, 'html_to_text'), + }, + { + id: 'htmlToPlainText', + label: 'HTML to Plain Text', + transform: async (text: string) => { + // dynamic import for html-to-text + const { convert } = await import('html-to-text') + return convert(text, { + wordwrap: 130, + preserveNewlines: false, + // selectors: [{ selector: 'a', format: }], + }) + .replace(/\n{3,}/g, '\n\n') // Replace 3+ newlines with double newline + .trim() // Remove leading and trailing whitespace + }, + }, + ], + }, + { + id: 'markdown', + label: 'Markdown', + transforms: [ + { + id: 'markdownToHtml', + label: 'Markdown to HTML', + transform: (text: string) => convertFormat(text, 'markdown_to_html'), + }, + { + id: 'markdownToText', + label: 'Markdown to Text', + transform: (text: string) => convertFormat(text, 'markdown_to_text'), + }, + ], + }, + { + id: 'json', + label: 'JSON', + transforms: [ + { + id: 'jsonToCsv', + label: 'JSON to CSV', + transform: (text: string) => convertFormat(text, 'json_to_csv'), + }, + { + id: 'jsonToYaml', + label: 'JSON to YAML', + transform: (text: string) => convertFormat(text, 'json_to_yaml'), + }, + { + id: 'jsonToXml', + label: 'JSON to XML', + transform: (text: string) => convertFormat(text, 'json_to_xml'), + }, + { + id: 'jsonToToml', + label: 'JSON to TOML', + transform: (text: string) => convertFormat(text, 'json_to_toml'), + }, + { + id: 'jsonToTable', + label: 'JSON to Markdown Table', + transform: (text: string) => convertFormat(text, 'json_to_table'), + }, + ], + }, + { + id: 'csv', + label: 'CSV', + transforms: [ + { + id: 'csvToJson', + label: 'CSV to JSON', + transform: (text: string) => convertFormat(text, 'csv_to_json'), + }, + { + id: 'csvToTable', + label: 'CSV to Markdown Table', + transform: (text: string) => convertFormat(text, 'csv_to_table'), + }, + ], + }, + { + id: 'yaml', + label: 'YAML', + transforms: [ + { + id: 'yamlToJson', + label: 'YAML to JSON', + transform: (text: string) => convertFormat(text, 'yaml_to_json'), + }, + ], + }, + { + id: 'xml', + label: 'XML', + transforms: [ + { + id: 'xmlToJson', + label: 'XML to JSON', + transform: (text: string) => convertFormat(text, 'xml_to_json'), + }, + ], + }, + { + id: 'toml', + label: 'TOML', + transforms: [ + { + id: 'tomlToJson', + label: 'TOML to JSON', + transform: (text: string) => convertFormat(text, 'toml_to_json'), + }, + ], + }, +] + +// Helper function to call Rust format conversion +function convertFormat(text: string, conversionType: string): Promise { + return new Promise(async (resolve, reject) => { + try { + const { invoke } = await import('@tauri-apps/api/tauri') + const result = await invoke('format_convert', { text, conversionType }) + resolve(result as string) + } catch (error) { + console.error(`Format conversion failed for ${conversionType}:`, error) + + // Show error dialog to user + try { + const { message } = await import('@tauri-apps/api/dialog') + const errorMessage = error instanceof Error ? error.message : String(error) + + // Clean up the error message for better user experience + const cleanErrorMessage = errorMessage + .replace(/^Error: /, '') + .replace(/^format_convert returned an error: /, '') + + await message(`${cleanErrorMessage}`, { + title: 'Format Conversion Error', + type: 'error', + }) + } catch (dialogError) { + console.error('Failed to show error dialog:', dialogError) + } + + // Reject the promise so the error propagates and prevents copy/paste + reject(error) + } + }) +} + +// Special Convert category will be implemented in Rust later for better performance + +// Categorized transformations +export const TRANSFORM_CATEGORIES: TransformCategory[] = [ + { + id: 'textCase', + label: 'Text Case', + transforms: [ + { id: 'upperCase', label: 'UPPER CASE', transform: toUpperCase }, + { id: 'lowerCase', label: 'lower case', transform: toLowerCase }, + { id: 'titleCase', label: 'Title Case', transform: toTitleCase }, + { id: 'sentenceCase', label: 'Sentence case', transform: toSentenceCase }, + { id: 'invertCase', label: 'iNVERT cASE', transform: toInvertCase }, + ], + }, + { + id: 'codeFormatting', + label: 'Code Formatting', + transforms: [ + { id: 'camelCase', label: 'camelCase', transform: toCamelCase }, + { id: 'snakeCase', label: 'snake_case', transform: toSnakeCase }, + { id: 'kebabCase', label: 'kebab-case', transform: toKebabCase }, + { id: 'pascalCase', label: 'PascalCase', transform: toPascalCase }, + { id: 'jsonStringify', label: 'JSON Stringify', transform: toJsonStringify }, + ], + }, + { + id: 'whitespaceLines', + label: 'Whitespace & Lines', + transforms: [ + { id: 'trimWhiteSpace', label: 'Trim White Space', transform: trimWhiteSpace }, + { id: 'removeLineFeeds', label: 'Remove Line Feeds', transform: removeLineFeeds }, + { id: 'addOneLineFeed', label: 'Add One Line Feed', transform: addOneLineFeed }, + { id: 'addTwoLineFeeds', label: 'Add Two Line Feeds', transform: addTwoLineFeeds }, + { + id: 'removeExtraSpaces', + label: 'Remove Extra Spaces', + transform: removeExtraSpaces, + }, + { + id: 'sortLinesAlphabetically', + label: 'Sort Lines Alphabetically', + transform: sortLinesAlphabetically, + }, + { + id: 'removeDuplicateLines', + label: 'Remove Duplicate Lines', + transform: removeDuplicateLines, + }, + { id: 'addLineNumbers', label: 'Add Line Numbers', transform: addLineNumbers }, + ], + }, + { + id: 'encodingSecurity', + label: 'Encode/Decode', + transforms: [ + { id: 'base64Encode', label: 'Base64 Encode', transform: toBase64Encode }, + { id: 'base64Decode', label: 'Base64 Decode', transform: toBase64Decode }, + { id: 'urlEncode', label: 'URL Encode', transform: toUrlEncode }, + { id: 'urlDecode', label: 'URL Decode', transform: toUrlDecode }, + { id: 'htmlEncode', label: 'HTML Encode', transform: toHtmlEncode }, + { id: 'htmlDecode', label: 'HTML Decode', transform: toHtmlDecode }, + ], + }, + { + id: 'textTools', + label: 'Text Tools', + transforms: [ + { id: 'reverseText', label: 'Reverse Text', transform: reverseText }, + { + id: 'addCurrentDateTime', + label: 'Add Current Date/Time', + transform: addCurrentDateTime, + }, + { id: 'countCharacters', label: 'Count Characters', transform: countCharacters }, + { id: 'countWords', label: 'Count Words', transform: countWords }, + { id: 'countLines', label: 'Count Lines', transform: countLines }, + { id: 'countSentences', label: 'Count Sentences', transform: countSentences }, + ], + }, + { + id: 'formatConverter', + label: 'Format Converter', + subcategories: formatConverterSubcategories, + }, +] + +// Flat list of all transformations for backward compatibility +export const TEXT_TRANSFORMS: TextTransform[] = TRANSFORM_CATEGORIES.flatMap(category => { + if (category.subcategories) { + // For categories with subcategories, flatten all transforms from all subcategories + return category.subcategories.flatMap(subcategory => subcategory.transforms) + } else { + // For categories with direct transforms + return category.transforms || [] + } +}) + +// Helper to get a transform by ID +export const getTransformById = (id: string): TextTransform | undefined => + TEXT_TRANSFORMS.find(t => t.id === id) + +// Helper to get a category by ID +export const getCategoryById = (id: string): TransformCategory | undefined => + TRANSFORM_CATEGORIES.find(c => c.id === id) + +// Helper to apply a transform by ID +export const applyTransform = async ( + text: string, + transformId: string +): Promise => { + const transform = getTransformById(transformId) + if (!transform) { + throw new Error(`Transform not found: ${transformId}`) + } + + try { + const result = transform.transform(text) + // Handle both sync and async transforms + return await Promise.resolve(result) + } catch (error) { + console.error(`Transform failed for ${transformId}:`, error) + // Re-throw the error so calling functions can handle it + throw error + } +} + +// Helper to get all category IDs +export const getAllCategoryIds = (): string[] => TRANSFORM_CATEGORIES.map(c => c.id) + +// Helper to get all transform IDs in a category +export const getTransformIdsInCategory = (categoryId: string): string[] => { + const category = getCategoryById(categoryId) + return category ? category.transforms?.map(t => t.id) ?? [] : [] +} diff --git a/packages/pastebar-app-ui/src/locales/lang/de/specialCopyPaste.yaml b/packages/pastebar-app-ui/src/locales/lang/de/specialCopyPaste.yaml new file mode 100644 index 00000000..9631efc7 --- /dev/null +++ b/packages/pastebar-app-ui/src/locales/lang/de/specialCopyPaste.yaml @@ -0,0 +1,73 @@ +Add Current Date/Time: Aktuelles Datum/Uhrzeit hinzufügen +Add Line Numbers: Zeilennummern hinzufügen +Add One Line Feed: Einen Zeilenvorschub hinzufügen +Add Two Line Feeds: Zwei Zeilenvorschübe hinzufügen +Base64 Decode: Base64 dekodieren +Base64 Encode: Base64 kodieren +CSV: CSV +CSV to JSON: CSV zu JSON +CSV to Markdown Table: CSV zu Markdown-Tabelle +Capitalize Case: Erster Buchstabe groß +Code Formatting: Code-Formatierung +Count Characters: Zeichen zählen +Count Lines: Zeilen zählen +Count Sentences: Sätze zählen +Count Words: Wörter zählen +Data Conversion: Datenkonvertierung +Drag and drop category to prioritize its order in the special copy/paste menu.: Ziehe eine Kategorie per Drag & Drop, um ihre Reihenfolge im speziellen Kopieren/Einfügen-Menü zu priorisieren. +Enable All: Alle aktivieren +Enable special text transformation options for clipboard history items: Spezielle Textumwandlungsoptionen für Zwischenablage-Verlaufselemente aktivieren +Enabled Operations: Aktivierte Operationen +Encode/Decode: Kodieren/Dekodieren +Format Converter: Formatkonverter +HTML: HTML +HTML Decode: HTML dekodieren +HTML Encode: HTML kodieren +HTML to Markdown: HTML zu Markdown +HTML to Plain Text: HTML zu reinem Text +HTML to React Component: HTML zu React-Komponente +HTML to React JSX: HTML zu React JSX +HTML to Text: HTML zu Text +JSON: JSON +JSON Stringify: JSON Stringify +JSON to CSV: JSON zu CSV +JSON to Markdown Table: JSON zu Markdown-Tabelle +JSON to TOML: JSON zu TOML +JSON to XML: JSON zu XML +JSON to YAML: JSON zu YAML +Markdown: Markdown +Markdown to HTML: Markdown zu HTML +Markdown to Text: Markdown zu Text +Operations: Operationen +PascalCase: PascalCase +Prioritize Category Order: Kategorie-Reihenfolge priorisieren +Remove Duplicate Lines: Doppelte Zeilen entfernen +Remove Extra Spaces: Zusätzliche Leerzeichen entfernen +Remove Line Feeds: Zeilenvorschübe entfernen +Reverse Text: Text umkehren +Select Operations: Operationen auswählen +Sentence case: Satzbau +Sort Lines Alphabetically: Zeilen alphabetisch sortieren +Special Copy: Spezielles Kopieren +Special Copy/Paste Operations: Spezielle Kopier-/Einfüge-Operationen +Special Paste: Spezielles Einfügen +Special Settings: Spezielle Einstellungen +TOML: TOML +TOML to JSON: TOML zu JSON +Text Case: Groß-/Kleinschreibung +Text Tools: Textwerkzeuge +Title Case: Titel-Fall +Trim White Space: Leerzeichen entfernen +UPPER CASE: GROSSBUCHSTABEN +URL Decode: URL dekodieren +URL Encode: URL kodieren +Whitespace & Lines: Leerraum & Zeilen +XML: XML +XML to JSON: XML zu JSON +YAML: YAML +YAML to JSON: YAML zu JSON +camelCase: camelCase +iNVERT cASE: gROSS/kLEIN uMKEHREN +kebab-case: kebab-case +lower case: kleinbuchstaben +snake_case: snake_case \ No newline at end of file diff --git a/packages/pastebar-app-ui/src/locales/lang/en/specailCopyPaste.yaml b/packages/pastebar-app-ui/src/locales/lang/en/specailCopyPaste.yaml new file mode 100644 index 00000000..c7a3bd1c --- /dev/null +++ b/packages/pastebar-app-ui/src/locales/lang/en/specailCopyPaste.yaml @@ -0,0 +1 @@ +Special Settings: Special Settings diff --git a/packages/pastebar-app-ui/src/locales/lang/en/specialCopyPaste.yaml b/packages/pastebar-app-ui/src/locales/lang/en/specialCopyPaste.yaml new file mode 100644 index 00000000..1586b8c9 --- /dev/null +++ b/packages/pastebar-app-ui/src/locales/lang/en/specialCopyPaste.yaml @@ -0,0 +1,74 @@ +Add Current Date/Time: Add Current Date/Time +Add Line Numbers: Add Line Numbers +Add One Line Feed: Add One Line Feed +Add Two Line Feeds: Add Two Line Feeds +Base64 Decode: Base64 Decode +Base64 Encode: Base64 Encode +CSV: CSV +CSV to JSON: CSV to JSON +CSV to Markdown Table: CSV to Markdown Table +Capitalize Case: Capitalize Case +Code Formatting: Code Formatting +Count Characters: Count Characters +Count Lines: Count Lines +Count Sentences: Count Sentences +Count Words: Count Words +Data Conversion: Data Conversion +Drag and drop category to prioritize its order in the special copy/paste menu.: Drag and drop category to prioritize its order in the special copy/paste menu. +Enable All: Enable All +Enable special text transformation options for clipboard history items: Enable special text transformation options for clipboard history items +Enabled Operations: Enabled Operations +Encode/Decode: Encode/Decode +Format Converter: Format Converter +HTML: HTML +HTML Decode: HTML Decode +HTML Encode: HTML Encode +HTML to Markdown: HTML to Markdown +HTML to Plain Text: HTML to Plain Text +HTML to React Component: HTML to React Component +HTML to React JSX: HTML to React JSX +HTML to Text: HTML to Text +JSON: JSON +JSON Stringify: JSON Stringify +JSON to CSV: JSON to CSV +JSON to Markdown Table: JSON to Markdown Table +JSON to TOML: JSON to TOML +JSON to XML: JSON to XML +JSON to YAML: JSON to YAML +Markdown: Markdown +Markdown to HTML: Markdown to HTML +Markdown to Text: Markdown to Text +Operations: Operations +PascalCase: PascalCase +Prioritize Category Order: Prioritize Category Order +PrioritizeCategoryOrderNote: PrioritizeCategoryOrderNote +Remove Duplicate Lines: Remove Duplicate Lines +Remove Extra Spaces: Remove Extra Spaces +Remove Line Feeds: Remove Line Feeds +Reverse Text: Reverse Text +Select Operations: Select Operations +Sentence case: Sentence case +Sort Lines Alphabetically: Sort Lines Alphabetically +Special Copy: Special Copy +Special Copy/Paste Operations: Special Copy/Paste Operations +Special Paste: Special Paste +Special Settings: Special Settings +TOML: TOML +TOML to JSON: TOML to JSON +Text Case: Text Case +Text Tools: Text Tools +Title Case: Title Case +Trim White Space: Trim White Space +UPPER CASE: UPPER CASE +URL Decode: URL Decode +URL Encode: URL Encode +Whitespace & Lines: Whitespace & Lines +XML: XML +XML to JSON: XML to JSON +YAML: YAML +YAML to JSON: YAML to JSON +camelCase: camelCase +iNVERT cASE: iNVERT cASE +kebab-case: kebab-case +lower case: lower case +snake_case: snake_case diff --git a/packages/pastebar-app-ui/src/locales/lang/esES/specialCopyPaste.yaml b/packages/pastebar-app-ui/src/locales/lang/esES/specialCopyPaste.yaml new file mode 100644 index 00000000..97a4dbd6 --- /dev/null +++ b/packages/pastebar-app-ui/src/locales/lang/esES/specialCopyPaste.yaml @@ -0,0 +1,73 @@ +Add Current Date/Time: Agregar Fecha/Hora Actual +Add Line Numbers: Agregar Números de Línea +Add One Line Feed: Agregar un Salto de Línea +Add Two Line Feeds: Agregar dos Saltos de Línea +Base64 Decode: Decodificar Base64 +Base64 Encode: Codificar Base64 +CSV: CSV +CSV to JSON: CSV a JSON +CSV to Markdown Table: CSV a Tabla Markdown +Capitalize Case: Poner en Mayúscula +Code Formatting: Formateo de Código +Count Characters: Contar Caracteres +Count Lines: Contar Líneas +Count Sentences: Contar Oraciones +Count Words: Contar Palabras +Data Conversion: Conversión de Datos +Drag and drop category to prioritize its order in the special copy/paste menu.: Arrastra y suelta la categoría para priorizar su orden en el menú especial de copiar/pegar. +Enable All: Habilitar Todo +Enable special text transformation options for clipboard history items: Habilitar opciones especiales de transformación de texto para elementos del historial del portapapeles +Enabled Operations: Operaciones Habilitadas +Encode/Decode: Codificar/Decodificar +Format Converter: Convertidor de Formato +HTML: HTML +HTML Decode: Decodificar HTML +HTML Encode: Codificar HTML +HTML to Markdown: HTML a Markdown +HTML to Plain Text: HTML a Texto Plano +HTML to React Component: HTML a Componente React +HTML to React JSX: HTML a React JSX +HTML to Text: HTML a Texto +JSON: JSON +JSON Stringify: JSON a Cadena +JSON to CSV: JSON a CSV +JSON to Markdown Table: JSON a Tabla Markdown +JSON to TOML: JSON a TOML +JSON to XML: JSON a XML +JSON to YAML: JSON a YAML +Markdown: Markdown +Markdown to HTML: Markdown a HTML +Markdown to Text: Markdown a Texto +Operations: Operaciones +PascalCase: PascalCase +Prioritize Category Order: Priorizar Orden de Categorías +Remove Duplicate Lines: Eliminar Líneas Duplicadas +Remove Extra Spaces: Eliminar Espacios Extra +Remove Line Feeds: Eliminar Saltos de Línea +Reverse Text: Invertir Texto +Select Operations: Seleccionar Operaciones +Sentence case: Tipo oración +Sort Lines Alphabetically: Ordenar Líneas Alfabéticamente +Special Copy: Copia Especial +Special Copy/Paste Operations: Operaciones Especiales de Copiar/Pegar +Special Paste: Pegado Especial +Special Settings: Ajustes Especiales +TOML: TOML +TOML to JSON: TOML a JSON +Text Case: Mayúsculas/Minúsculas +Text Tools: Herramientas de Texto +Title Case: Tipo Título +Trim White Space: Recortar Espacios en Blanco +UPPER CASE: MAYÚSCULAS +URL Decode: Decodificar URL +URL Encode: Codificar URL +Whitespace & Lines: Espacios y Líneas +XML: XML +XML to JSON: XML a JSON +YAML: YAML +YAML to JSON: YAML a JSON +camelCase: camelCase +iNVERT cASE: iNVERTIR cASO +kebab-case: kebab-case +lower case: minúsculas +snake_case: snake_case \ No newline at end of file diff --git a/packages/pastebar-app-ui/src/locales/lang/fr/specialCopyPaste.yaml b/packages/pastebar-app-ui/src/locales/lang/fr/specialCopyPaste.yaml new file mode 100644 index 00000000..dddf604a --- /dev/null +++ b/packages/pastebar-app-ui/src/locales/lang/fr/specialCopyPaste.yaml @@ -0,0 +1,73 @@ +Add Current Date/Time: Ajouter Date/Heure Actuelle +Add Line Numbers: Ajouter Numéros de Ligne +Add One Line Feed: Ajouter un Saut de Ligne +Add Two Line Feeds: Ajouter deux Sauts de Ligne +Base64 Decode: Décoder Base64 +Base64 Encode: Encoder Base64 +CSV: CSV +CSV to JSON: CSV vers JSON +CSV to Markdown Table: CSV vers Tableau Markdown +Capitalize Case: Mettre en Capitale +Code Formatting: Formatage de Code +Count Characters: Compter les Caractères +Count Lines: Compter les Lignes +Count Sentences: Compter les Phrases +Count Words: Compter les Mots +Data Conversion: Conversion de Données +Drag and drop category to prioritize its order in the special copy/paste menu.: Glissez-déposez la catégorie pour prioriser son ordre dans le menu spécial copier/coller. +Enable All: Activer Tout +Enable special text transformation options for clipboard history items: Activer les options de transformation de texte spéciales pour les éléments de l'historique du presse-papiers +Enabled Operations: Opérations Activées +Encode/Decode: Encoder/Décoder +Format Converter: Convertisseur de Format +HTML: HTML +HTML Decode: Décoder HTML +HTML Encode: Encoder HTML +HTML to Markdown: HTML vers Markdown +HTML to Plain Text: HTML vers Texte Brut +HTML to React Component: HTML vers Composant React +HTML to React JSX: HTML vers React JSX +HTML to Text: HTML vers Texte +JSON: JSON +JSON Stringify: JSON Stringify +JSON to CSV: JSON vers CSV +JSON to Markdown Table: JSON vers Tableau Markdown +JSON to TOML: JSON vers TOML +JSON to XML: JSON vers XML +JSON to YAML: JSON vers YAML +Markdown: Markdown +Markdown to HTML: Markdown vers HTML +Markdown to Text: Markdown vers Texte +Operations: Opérations +PascalCase: PascalCase +Prioritize Category Order: Prioriser l'Ordre des Catégories +Remove Duplicate Lines: Supprimer Lignes Doubles +Remove Extra Spaces: Supprimer Espaces Superflus +Remove Line Feeds: Supprimer Sauts de Ligne +Reverse Text: Inverser le Texte +Select Operations: Sélectionner Opérations +Sentence case: Cas de la phrase +Sort Lines Alphabetically: Trier Lignes par Ordre Alphabétique +Special Copy: Copie Spéciale +Special Copy/Paste Operations: Opérations Spéciales Copier/Coller +Special Paste: Collage Spécial +Special Settings: Paramètres Spéciaux +TOML: TOML +TOML to JSON: TOML vers JSON +Text Case: Casse du Texte +Text Tools: Outils Texte +Title Case: Cas du Titre +Trim White Space: Supprimer Espaces Blancs +UPPER CASE: MAJUSCULES +URL Decode: Décoder URL +URL Encode: Encoder URL +Whitespace & Lines: Espaces & Lignes +XML: XML +XML to JSON: XML vers JSON +YAML: YAML +YAML to JSON: YAML vers JSON +camelCase: camelCase +iNVERT cASE: iNVERSER cASSE +kebab-case: kebab-case +lower case: minuscules +snake_case: snake_case \ No newline at end of file diff --git a/packages/pastebar-app-ui/src/locales/lang/it/specialCopyPaste.yaml b/packages/pastebar-app-ui/src/locales/lang/it/specialCopyPaste.yaml new file mode 100644 index 00000000..e03933b6 --- /dev/null +++ b/packages/pastebar-app-ui/src/locales/lang/it/specialCopyPaste.yaml @@ -0,0 +1,73 @@ +Add Current Date/Time: Aggiungi Data/Ora Attuale +Add Line Numbers: Aggiungi Numeri di Riga +Add One Line Feed: Aggiungi un a Capo +Add Two Line Feeds: Aggiungi due a Capo +Base64 Decode: Decodifica Base64 +Base64 Encode: Codifica Base64 +CSV: CSV +CSV to JSON: CSV in JSON +CSV to Markdown Table: CSV in Tabella Markdown +Capitalize Case: Maiuscolo Iniziale +Code Formatting: Formattazione Codice +Count Characters: Conta Caratteri +Count Lines: Conta Righe +Count Sentences: Conta Frasi +Count Words: Conta Parole +Data Conversion: Conversione Dati +Drag and drop category to prioritize its order in the special copy/paste menu.: Trascina e rilascia la categoria per dare priorità al suo ordine nel menu speciale di copia/incolla. +Enable All: Abilita Tutto +Enable special text transformation options for clipboard history items: Abilita opzioni speciali di trasformazione del testo per gli elementi della cronologia degli appunti +Enabled Operations: Operazioni Abilitate +Encode/Decode: Codifica/Decodifica +Format Converter: Convertitore di Formato +HTML: HTML +HTML Decode: Decodifica HTML +HTML Encode: Codifica HTML +HTML to Markdown: HTML in Markdown +HTML to Plain Text: HTML in Testo Semplice +HTML to React Component: HTML in Componente React +HTML to React JSX: HTML in React JSX +HTML to Text: HTML in Testo +JSON: JSON +JSON Stringify: JSON Stringify +JSON to CSV: JSON in CSV +JSON to Markdown Table: JSON in Tabella Markdown +JSON to TOML: JSON in TOML +JSON to XML: JSON in XML +JSON to YAML: JSON in YAML +Markdown: Markdown +Markdown to HTML: Markdown in HTML +Markdown to Text: Markdown in Testo +Operations: Operazioni +PascalCase: PascalCase +Prioritize Category Order: Priorità Ordine Categorie +Remove Duplicate Lines: Rimuovi Righe Duplicate +Remove Extra Spaces: Rimuovi Spazi Extra +Remove Line Feeds: Rimuovi a Capo +Reverse Text: Inverti Testo +Select Operations: Seleziona Operazioni +Sentence case: Maiuscolo a inizio frase +Sort Lines Alphabetically: Ordina Righe Alfabeticamente +Special Copy: Copia Speciale +Special Copy/Paste Operations: Operazioni Speciali di Copia/Incolla +Special Paste: Incolla Speciale +Special Settings: Impostazioni Speciali +TOML: TOML +TOML to JSON: TOML in JSON +Text Case: Maiuscole/Minuscole +Text Tools: Strumenti Testo +Title Case: Maiuscolo a Inizio Parola +Trim White Space: Taglia Spazi Bianchi +UPPER CASE: MAIUSCOLO +URL Decode: Decodifica URL +URL Encode: Codifica URL +Whitespace & Lines: Spazi & Righe +XML: XML +XML to JSON: XML in JSON +YAML: YAML +YAML to JSON: YAML in JSON +camelCase: camelCase +iNVERT cASE: iNVERTI cASO +kebab-case: kebab-case +lower case: minuscolo +snake_case: snake_case \ No newline at end of file diff --git a/packages/pastebar-app-ui/src/locales/lang/ru/specialCopyPaste.yaml b/packages/pastebar-app-ui/src/locales/lang/ru/specialCopyPaste.yaml new file mode 100644 index 00000000..aaaa1602 --- /dev/null +++ b/packages/pastebar-app-ui/src/locales/lang/ru/specialCopyPaste.yaml @@ -0,0 +1,73 @@ +Add Current Date/Time: Добавить текущие дату/время +Add Line Numbers: Добавить номера строк +Add One Line Feed: Добавить один перевод строки +Add Two Line Feeds: Добавить два перевода строки +Base64 Decode: Декодировать Base64 +Base64 Encode: Кодировать Base64 +CSV: CSV +CSV to JSON: CSV в JSON +CSV to Markdown Table: CSV в таблицу Markdown +Capitalize Case: С большой буквы +Code Formatting: Форматирование кода +Count Characters: Подсчет символов +Count Lines: Подсчет строк +Count Sentences: Подсчет предложений +Count Words: Подсчет слов +Data Conversion: Преобразование данных +Drag and drop category to prioritize its order in the special copy/paste menu.: Перетащите категорию, чтобы задать ей приоритет в специальном меню копирования/вставки. +Enable All: Включить все +Enable special text transformation options for clipboard history items: Включить специальные параметры преобразования текста для элементов истории буфера обмена +Enabled Operations: Включенные операции +Encode/Decode: Кодировать/Декодировать +Format Converter: Конвертер форматов +HTML: HTML +HTML Decode: Декодировать HTML +HTML Encode: Кодировать HTML +HTML to Markdown: HTML в Markdown +HTML to Plain Text: HTML в обычный текст +HTML to React Component: HTML в компонент React +HTML to React JSX: HTML в React JSX +HTML to Text: HTML в текст +JSON: JSON +JSON Stringify: JSON в строку +JSON to CSV: JSON в CSV +JSON to Markdown Table: JSON в таблицу Markdown +JSON to TOML: JSON в TOML +JSON to XML: JSON в XML +JSON to YAML: JSON в YAML +Markdown: Markdown +Markdown to HTML: Markdown в HTML +Markdown to Text: Markdown в текст +Operations: Операции +PascalCase: PascalCase +Prioritize Category Order: Приоритет порядка категорий +Remove Duplicate Lines: Удалить дублирующиеся строки +Remove Extra Spaces: Удалить лишние пробелы +Remove Line Feeds: Удалить переводы строк +Reverse Text: Обратный текст +Select Operations: Выбрать операции +Sentence case: Регистр предложений +Sort Lines Alphabetically: Сортировать строки по алфавиту +Special Copy: Специальное копирование +Special Copy/Paste Operations: Специальные операции копирования/вставки +Special Paste: Специальная вставка +Special Settings: Специальные настройки +TOML: TOML +TOML to JSON: TOML в JSON +Text Case: Регистр текста +Text Tools: Инструменты для текста +Title Case: Регистр заголовков +Trim White Space: Обрезать пробелы +UPPER CASE: ВЕРХНИЙ РЕГИСТР +URL Decode: Декодировать URL +URL Encode: Кодировать URL +Whitespace & Lines: Пробелы и строки +XML: XML +XML to JSON: XML в JSON +YAML: YAML +YAML to JSON: YAML в JSON +camelCase: camelCase +iNVERT cASE: иНВЕРТИРОВАТЬ рЕГИСТР +kebab-case: kebab-case +lower case: нижний регистр +snake_case: snake_case \ No newline at end of file diff --git a/packages/pastebar-app-ui/src/locales/lang/tr/specialCopyPaste.yaml b/packages/pastebar-app-ui/src/locales/lang/tr/specialCopyPaste.yaml new file mode 100644 index 00000000..d359f7ea --- /dev/null +++ b/packages/pastebar-app-ui/src/locales/lang/tr/specialCopyPaste.yaml @@ -0,0 +1,73 @@ +Add Current Date/Time: Geçerli Tarih/Saati Ekle +Add Line Numbers: Satır Numaraları Ekle +Add One Line Feed: Bir Satır Başı Ekle +Add Two Line Feeds: İki Satır Başı Ekle +Base64 Decode: Base64 Kodunu Çöz +Base64 Encode: Base64 Kodla +CSV: CSV +CSV to JSON: CSV'yi JSON'a Dönüştür +CSV to Markdown Table: CSV'yi Markdown Tablosuna Dönüştür +Capitalize Case: Baş Harfleri Büyüt +Code Formatting: Kod Formatlama +Count Characters: Karakter Sayısı +Count Lines: Satır Sayısı +Count Sentences: Cümle Sayısı +Count Words: Kelime Sayısı +Data Conversion: Veri Dönüştürme +Drag and drop category to prioritize its order in the special copy/paste menu.: Özel kopyala/yapıştır menüsünde kategorinin sırasını önceliklendirmek için sürükleyip bırakın. +Enable All: Tümünü Etkinleştir +Enable special text transformation options for clipboard history items: Pano geçmişi öğeleri için özel metin dönüştürme seçeneklerini etkinleştirin +Enabled Operations: Etkin İşlemler +Encode/Decode: Kodla/Kodu Çöz +Format Converter: Format Dönüştürücü +HTML: HTML +HTML Decode: HTML Kodunu Çöz +HTML Encode: HTML Kodla +HTML to Markdown: HTML'yi Markdown'a Dönüştür +HTML to Plain Text: HTML'yi Düz Metne Dönüştür +HTML to React Component: HTML'yi React Bileşenine Dönüştür +HTML to React JSX: HTML'yi React JSX'e Dönüştür +HTML to Text: HTML'yi Metne Dönüştür +JSON: JSON +JSON Stringify: JSON Stringify +JSON to CSV: JSON'u CSV'ye Dönüştür +JSON to Markdown Table: JSON'u Markdown Tablosuna Dönüştür +JSON to TOML: JSON'u TOML'a Dönüştür +JSON to XML: JSON'u XML'e Dönüştür +JSON to YAML: JSON'u YAML'a Dönüştür +Markdown: Markdown +Markdown to HTML: Markdown'u HTML'ye Dönüştür +Markdown to Text: Markdown'u Metne Dönüştür +Operations: İşlemler +PascalCase: PascalCase +Prioritize Category Order: Kategori Sırasını Önceliklendir +Remove Duplicate Lines: Yinelenen Satırları Kaldır +Remove Extra Spaces: Fazla Boşlukları Kaldır +Remove Line Feeds: Satır Başlarını Kaldır +Reverse Text: Metni Ters Çevir +Select Operations: İşlemleri Seç +Sentence case: Cümle düzeni +Sort Lines Alphabetically: Satırları Alfabetik Olarak Sırala +Special Copy: Özel Kopyala +Special Copy/Paste Operations: Özel Kopyala/Yapıştır İşlemleri +Special Paste: Özel Yapıştır +Special Settings: Özel Ayarlar +TOML: TOML +TOML to JSON: TOML'u JSON'a Dönüştür +Text Case: Metin Kılıfı +Text Tools: Metin Araçları +Title Case: Başlık Düzeni +Trim White Space: Beyaz Boşlukları Kırp +UPPER CASE: BÜYÜK HARF +URL Decode: URL Kodunu Çöz +URL Encode: URL Kodla +Whitespace & Lines: Boşluk & Satırlar +XML: XML +XML to JSON: XML'i JSON'a Dönüştür +YAML: YAML +YAML to JSON: YAML'ı JSON'a Dönüştür +camelCase: camelCase +iNVERT cASE: dURUMU tERSINE cEVIR +kebab-case: kebab-case +lower case: küçük harf +snake_case: snake_case \ No newline at end of file diff --git a/packages/pastebar-app-ui/src/locales/lang/tr/templates.yaml b/packages/pastebar-app-ui/src/locales/lang/tr/templates.yaml index 0c5631ed..b5cea561 100644 --- a/packages/pastebar-app-ui/src/locales/lang/tr/templates.yaml +++ b/packages/pastebar-app-ui/src/locales/lang/tr/templates.yaml @@ -15,7 +15,7 @@ confirmDeleteTemplateMessage: "'{{name}}' global şablonunu silmek istediğinizd confirmDeleteTemplateTitle: Silmeyi Onayla deleteTemplateButtonTooltip: Şablonu Sil enableGlobalTemplatesLabel: Global Şablonları Etkinleştir -globalTemplatesDescription: "{{template_name}} kullanarak herhangi bir klibe eklenebilen yeniden kullanılabilir metin parçacıklarını yönetin." +globalTemplatesDescription: '{{template_name}} kullanarak herhangi bir klibe eklenebilen yeniden kullanılabilir metin parçacıklarını yönetin.' globalTemplatesTitle: Global Şablonlar localTemplateConflictWarning: "'{{label}}' adlı bir global şablon da mevcut. Yerel şablon bu klibin formu içinde öncelik alacaktır." noGlobalTemplatesYet: Henüz global şablon tanımlanmamış. Bir tane oluşturmak için 'Şablon Ekle'ye tıklayın. diff --git a/packages/pastebar-app-ui/src/locales/lang/uk/specialCopyPaste.yaml b/packages/pastebar-app-ui/src/locales/lang/uk/specialCopyPaste.yaml new file mode 100644 index 00000000..3a2f8ae9 --- /dev/null +++ b/packages/pastebar-app-ui/src/locales/lang/uk/specialCopyPaste.yaml @@ -0,0 +1,73 @@ +Add Current Date/Time: Додати поточну дату/час +Add Line Numbers: Додати номери рядків +Add One Line Feed: Додати один розрив рядка +Add Two Line Feeds: Додати два розриви рядка +Base64 Decode: Декодувати Base64 +Base64 Encode: Кодувати Base64 +CSV: CSV +CSV to JSON: CSV у JSON +CSV to Markdown Table: CSV у таблицю Markdown +Capitalize Case: З великої літери +Code Formatting: Форматування коду +Count Characters: Підрахунок символів +Count Lines: Підрахунок рядків +Count Sentences: Підрахунок речень +Count Words: Підрахунок слів +Data Conversion: Перетворення даних +Drag and drop category to prioritize its order in the special copy/paste menu.: Перетягніть категорію, щоб задати їй пріоритет у спеціальному меню копіювання/вставки. +Enable All: Увімкнути все +Enable special text transformation options for clipboard history items: Увімкнути спеціальні параметри перетворення тексту для елементів історії буфера обміну +Enabled Operations: Увімкнені операції +Encode/Decode: Кодувати/Декодувати +Format Converter: Конвертер форматів +HTML: HTML +HTML Decode: Декодувати HTML +HTML Encode: Кодувати HTML +HTML to Markdown: HTML у Markdown +HTML to Plain Text: HTML у звичайний текст +HTML to React Component: HTML у компонент React +HTML to React JSX: HTML у React JSX +HTML to Text: HTML у текст +JSON: JSON +JSON Stringify: JSON у рядок +JSON to CSV: JSON у CSV +JSON to Markdown Table: JSON у таблицю Markdown +JSON to TOML: JSON у TOML +JSON to XML: JSON у XML +JSON to YAML: JSON у YAML +Markdown: Markdown +Markdown to HTML: Markdown у HTML +Markdown to Text: Markdown у текст +Operations: Операції +PascalCase: PascalCase +Prioritize Category Order: Пріоритет порядку категорій +Remove Duplicate Lines: Видалити дублікати рядків +Remove Extra Spaces: Видалити зайві пробіли +Remove Line Feeds: Видалити розриви рядків +Reverse Text: Зворотний текст +Select Operations: Вибрати операції +Sentence case: Регістр речень +Sort Lines Alphabetically: Сортувати рядки за алфавітом +Special Copy: Спеціальне копіювання +Special Copy/Paste Operations: Спеціальні операції копіювання/вставки +Special Paste: Спеціальна вставка +Special Settings: Спеціальні налаштування +TOML: TOML +TOML to JSON: TOML у JSON +Text Case: Регістр тексту +Text Tools: Інструменти для тексту +Title Case: Регістр заголовків +Trim White Space: Обрізати пробіли +UPPER CASE: ВЕРХНІЙ РЕГІСТР +URL Decode: Декодувати URL +URL Encode: Кодувати URL +Whitespace & Lines: Пробіли та рядки +XML: XML +XML to JSON: XML у JSON +YAML: YAML +YAML to JSON: YAML у JSON +camelCase: camelCase +iNVERT cASE: іНВЕРТУВАТИ рЕГІСТР +kebab-case: kebab-case +lower case: нижній регістр +snake_case: snake_case \ No newline at end of file diff --git a/packages/pastebar-app-ui/src/locales/lang/zhCN/dashboard.yaml b/packages/pastebar-app-ui/src/locales/lang/zhCN/dashboard.yaml index acd10e3a..b44e2731 100644 --- a/packages/pastebar-app-ui/src/locales/lang/zhCN/dashboard.yaml +++ b/packages/pastebar-app-ui/src/locales/lang/zhCN/dashboard.yaml @@ -122,7 +122,7 @@ Errors: No fields found in the template.: 模板中未找到字段。 Please fix output template or confirm to save as is.: 请修复输出模板或确认保持现状。 Please fix template fields or confirm to save as is.: 请修复模板字段或确认保持现状。 - Please fix the problem or confirm to save as is.: 请修复问题或确认保持现状。 + Please fix the problem or confirm to save as is.: 请修复问题或确认保持现状。 Please verify your link for any errors, or confirm to save as is.: 请检查链接是否有错误,或或确认保持现状。 Please verify your path for any errors, or confirm to save as is.: 请检查路径是否有错误,或确认保持现状。 Your command runs with errors, confirm you want to save as is.: 您的命令运行出错,确认是否保持现状。 diff --git a/packages/pastebar-app-ui/src/locales/lang/zhCN/specialCopyPaste.yaml b/packages/pastebar-app-ui/src/locales/lang/zhCN/specialCopyPaste.yaml new file mode 100644 index 00000000..9ba3b5dd --- /dev/null +++ b/packages/pastebar-app-ui/src/locales/lang/zhCN/specialCopyPaste.yaml @@ -0,0 +1,73 @@ +Add Current Date/Time: 添加当前日期/时间 +Add Line Numbers: 添加行号 +Add One Line Feed: 添加一个换行符 +Add Two Line Feeds: 添加两个换行符 +Base64 Decode: Base64 解码 +Base64 Encode: Base64 编码 +CSV: CSV +CSV to JSON: CSV 转 JSON +CSV to Markdown Table: CSV 转 Markdown 表格 +Capitalize Case: 首字母大写 +Code Formatting: 代码格式化 +Count Characters: 计算字符数 +Count Lines: 计算行数 +Count Sentences: 计算句子数 +Count Words: 计算单词数 +Data Conversion: 数据转换 +Drag and drop category to prioritize its order in the special copy/paste menu.: 拖放类别以在特殊复制/粘贴菜单中优先排列其顺序。 +Enable All: 全部启用 +Enable special text transformation options for clipboard history items: 为剪贴板历史记录项启用特殊文本转换选项 +Enabled Operations: 已启用的操作 +Encode/Decode: 编码/解码 +Format Converter: 格式转换器 +HTML: HTML +HTML Decode: HTML 解码 +HTML Encode: HTML 编码 +HTML to Markdown: HTML 转 Markdown +HTML to Plain Text: HTML 转纯文本 +HTML to React Component: HTML 转 React 组件 +HTML to React JSX: HTML 转 React JSX +HTML to Text: HTML 转文本 +JSON: JSON +JSON Stringify: JSON 字符串化 +JSON to CSV: JSON 转 CSV +JSON to Markdown Table: JSON 转 Markdown 表格 +JSON to TOML: JSON 转 TOML +JSON to XML: JSON 转 XML +JSON to YAML: JSON 转 YAML +Markdown: Markdown +Markdown to HTML: Markdown 转 HTML +Markdown to Text: Markdown 转文本 +Operations: 操作 +PascalCase: PascalCase +Prioritize Category Order: 确定类别顺序优先级 +Remove Duplicate Lines: 删除重复行 +Remove Extra Spaces: 删除多余空格 +Remove Line Feeds: 删除换行符 +Reverse Text: 反转文本 +Select Operations: 选择操作 +Sentence case: 句子大小写 +Sort Lines Alphabetically: 按字母顺序对行排序 +Special Copy: 特殊复制 +Special Copy/Paste Operations: 特殊复制/粘贴操作 +Special Paste: 特殊粘贴 +Special Settings: 特殊设置 +TOML: TOML +TOML to JSON: TOML 转 JSON +Text Case: 文本大小写 +Text Tools: 文本工具 +Title Case: 标题大小写 +Trim White Space: 修剪空白 +UPPER CASE: 大写 +URL Decode: URL 解码 +URL Encode: URL 编码 +Whitespace & Lines: 空白和行 +XML: XML +XML to JSON: XML 转 JSON +YAML: YAML +YAML to JSON: YAML 转 JSON +camelCase: camelCase +iNVERT cASE: 反转大小写 +kebab-case: kebab-case +lower case: 小写 +snake_case: snake_case \ No newline at end of file diff --git a/packages/pastebar-app-ui/src/pages/components/ClipboardHistory/ClipboardHistoryIconMenu.tsx b/packages/pastebar-app-ui/src/pages/components/ClipboardHistory/ClipboardHistoryIconMenu.tsx index 647c4ab6..7c0b427c 100644 --- a/packages/pastebar-app-ui/src/pages/components/ClipboardHistory/ClipboardHistoryIconMenu.tsx +++ b/packages/pastebar-app-ui/src/pages/components/ClipboardHistory/ClipboardHistoryIconMenu.tsx @@ -63,7 +63,6 @@ export const ClipboardHistoryIconMenu = ({ onDelete, setIsDeleting, isDark, - setSelectHistoryItem, setSelectedHistoryItems, showSelectHistoryItems, }: ClipboardHistoryIconMenuProps) => { @@ -85,12 +84,6 @@ export const ClipboardHistoryIconMenu = ({ setShowSelectHistoryItems(!showSelectHistoryItems) }) - useHotkeys(['control+s'], () => { - if (hoveringHistoryRowId.value) { - setSelectHistoryItem(hoveringHistoryRowId.value) - } - }) - useHotkeys(['alt+h', 'meta+h'], () => { setIsHistoryEnabled(!isHistoryEnabled) }) diff --git a/packages/pastebar-app-ui/src/pages/components/ClipboardHistory/ClipboardHistoryRow.tsx b/packages/pastebar-app-ui/src/pages/components/ClipboardHistory/ClipboardHistoryRow.tsx index b7afc6e0..a7e7ac55 100644 --- a/packages/pastebar-app-ui/src/pages/components/ClipboardHistory/ClipboardHistoryRow.tsx +++ b/packages/pastebar-app-ui/src/pages/components/ClipboardHistory/ClipboardHistoryRow.tsx @@ -19,6 +19,7 @@ import { isKeyAltPressed, isKeyCtrlPressed, showHistoryDeleteConfirmationId, + showKeyboardNavContextMenuHistoryId, } from '~/store' import { ArrowDownToLine, @@ -42,7 +43,7 @@ import ImageWithFallback from '~/components/atoms/image/image-with-fallback-on-e import LinkCard from '~/components/atoms/link-card/link-card' import PlayButton from '~/components/atoms/play-button/PlayButton' import ToolTip from '~/components/atoms/tooltip' -import { Badge, Box, ContextMenu, ContextMenuTrigger, Flex, Text } from '~/components/ui' +import { Badge, Box, Flex, Text } from '~/components/ui' import YoutubeEmbed from '~/components/video-player/YoutubeEmbed' import { useSignal } from '~/hooks/use-signal' @@ -55,7 +56,7 @@ import { hyperlinkText, hyperlinkTextWithPreview, } from '../helpers' -import ClipboardHistoryRowContextMenu from './context-menu/ClipboardHistoryRowContextMenu' +import ContextMenuTrigger from './context-menu/ContextMenuTrigger' interface ClipboardHistoryRowProps { index?: number @@ -123,7 +124,7 @@ interface ClipboardHistoryRowProps { setHistoryFilters?: Dispatch> setAppFilters?: Dispatch> isSingleClickToCopyPaste?: boolean - historyPreviewLineLimit?: number + historyPreviewLineLimit?: number | null } // eslint-disable-next-line sonarjs/cognitive-complexity @@ -193,6 +194,7 @@ export function ClipboardHistoryRowComponent({ const contentElementRendered = useSignal(false) const contextMenuOpen = useSignal(false) + const isDeleteConfirmationFromContext = useSignal(false) const isHovering = !isPinnedTop ? (hoveringHistoryRowId.value === clipboard?.historyId && @@ -254,6 +256,30 @@ export function ClipboardHistoryRowComponent({ }) }, [isExpanded, isWrapText]) + useEffect(() => { + if (showKeyboardNavContextMenuHistoryId.value === clipboard?.historyId) { + if (contextMenuTriggerRef?.current) { + const targetElement = contextMenuTriggerRef.current + const rect = targetElement.getBoundingClientRect() + + const contextMenuEvent = new MouseEvent('contextmenu', { + bubbles: true, + cancelable: true, + view: window, + clientX: rect.x + 50, + clientY: rect.y + 20, + button: 2, // Right mouse button + }) + + targetElement.dispatchEvent(contextMenuEvent) + } + } + }, [ + showKeyboardNavContextMenuHistoryId.value, + clipboard?.historyId, + contextMenuTriggerRef?.current, + ]) + if (!clipboard) { return null } @@ -361,22 +387,22 @@ export function ClipboardHistoryRowComponent({ const pinnedTopOffsetFirst = !isPinnedTopFirst ? 'top-[-10px]' : 'top-[5px]' const bgToolsPanel = `${ - isKeyboardSelected - ? 'bg-blue-50 dark:bg-blue-950/80' - : !isPinnedTop && isOverPinned && !isNowItem - ? 'bg-orange-50 dark:!bg-transparent' - : isDeleting - ? 'bg-red-50 dark:bg-red-950/80' - : contextMenuOpen.value - ? `bg-slate-100 dark:bg-slate-900 ${ - isNowItem ? 'bg-teal-50/80 dark:bg-sky-900/80' : '' - }` - : isCopiedOrPasted - ? 'dark:bg-green-950/80' - : isSaved - ? 'dark:bg-sky-950/80' - : isSelected - ? 'bg-yellow-50 dark:bg-amber-950/80' + !isPinnedTop && isOverPinned && !isNowItem + ? 'bg-orange-50 dark:!bg-transparent' + : isDeleting || isDeleteConfirmationFromContext.value + ? 'bg-red-50 dark:bg-red-950/80' + : isSelected + ? 'bg-yellow-50 dark:bg-amber-950/80' + : isKeyboardSelected + ? 'bg-blue-50 dark:bg-blue-950/80' + : contextMenuOpen.value + ? `bg-slate-100 dark:bg-slate-900 ${ + isNowItem ? 'bg-teal-50/80 dark:bg-sky-900/80' : '' + }` + : isCopiedOrPasted + ? 'dark:bg-green-950/80' + : isSaved + ? 'dark:bg-sky-950/80' : isNowItem ? 'bg-teal-50/90 dark:bg-sky-950' : 'bg-white dark:bg-slate-950/80' @@ -387,7 +413,8 @@ export function ClipboardHistoryRowComponent({ style={{ ...style, opacity: - (isDeleting || isOverPinned) && !isDragPreview + (isDeleting || isDeleteConfirmationFromContext.value || isOverPinned) && + !isDragPreview ? 1 : isDragging ? 0.7 @@ -414,758 +441,764 @@ export function ClipboardHistoryRowComponent({ )} - { contextMenuOpen.value = isOpen showHistoryDeleteConfirmationId.value = null + // Reset he keyboard nav signal when menu opens + if ( + !isOpen && + showKeyboardNavContextMenuHistoryId.value === clipboard.historyId + ) { + showKeyboardNavContextMenuHistoryId.value = null + } + }} + historyId={clipboard.historyId} + copiedFromApp={clipboard.copiedFromApp} + isMasked={clipboard.isMasked} + setSavingItem={setSavingItem} + value={clipboard.value} + isImage={clipboard.isImage} + isMp3={isMp3} + isText={clipboard.isText} + isPinned={clipboard.isPinned} + isFavorite={clipboard.isFavorite} + isImageData={clipboard.isImageData} + detectedLanguage={clipboard.detectedLanguage} + setLargeViewItemId={setLargeViewItemId} + isLargeView={isLargeView} + arrLinks={clipboard.arrLinks} + hasLinkCard={hasLinkCard} + isSelected={isSelected} + invalidateClipboardHistoryQuery={invalidateClipboardHistoryQuery} + generateLinkMetaData={generateLinkMetaData} + removeLinkMetaData={removeLinkMetaData} + setSelectHistoryItem={setSelectHistoryItem} + setSelectedHistoryItems={setSelectedHistoryItems} + selectedHistoryItems={selectedHistoryItems} + onCopyPaste={onCopyPaste} + setHistoryFilters={setHistoryFilters} + setAppFilters={setAppFilters} + onDeleteConfirmationChange={(historyId, isMultiSelect) => { + if (isMultiSelect) { + // For multi-select, highlight this item if it's in the selected items + isDeleteConfirmationFromContext.value = selectedHistoryItems.includes( + clipboard.historyId + ) + } else { + // For single select, highlight only if this is the specific item + isDeleteConfirmationFromContext.value = historyId === clipboard.historyId + } }} > - - Date.now() - MINUTE_IN_MS && - !isCopiedOrPasted && - !isDeleting && - !isKeyboardSelected && - !isSelected - ? 'bg-teal-50 hover:border-slate-300 dark:bg-sky-900/40 dark:hover:border-slate-700 hover:bg-teal-50/90 hover:dark:bg-sky-950' - : isKeyboardSelected - ? `bg-blue-50 ring-2 scale-[.98] ring-blue-400 dark:!ring-blue-600 ring-offset-1 !shadow-sm border-blue-300 dark:bg-blue-950/80 dark:hover:border-blue-800 hover:bg-blue-50/80 ring-offset-white dark:ring-offset-gray-800 ${ - isPinnedTop ? ' dark:!bg-amber-950' : '' + className={`rounded-md justify-start duration-300 history-box relative px-3 py-1 hover:shadow-sm my-0.5 shadow-none border-2 flex flex-col ${ + isKeyboardSelected + ? 'ring-2 scale-[.98] ring-blue-400 dark:!ring-blue-600 ring-offset-1 !shadow-sm ring-offset-white dark:ring-offset-gray-800' + : '' + } ${ + index === 0 && + clipboard.updatedAt > Date.now() - MINUTE_IN_MS && + !isCopiedOrPasted && + !isDeleting && + !isKeyboardSelected && + !isSelected + ? 'bg-teal-50 hover:border-slate-300 dark:bg-sky-900/40 dark:hover:border-slate-700 hover:bg-teal-50/90 hover:dark:bg-sky-950' + : (isDeleting || isDeleteConfirmationFromContext.value) && + !isDragPreview + ? 'border-red-400 bg-red-50 dark:bg-red-950/80 dark:border-red-900/80 dark:hover:border-red-800' + : isSelected + ? `bg-amber-50 border-amber-300 dark:bg-amber-950/80 dark:border-amber-900/80 hover:border-amber-300/80 dark:hover:border-amber-800 hover:bg-amber-50/80 ${ + isPinnedTop ? '!border dark:!bg-amber-950' : '' }` - : isDeleting && !isDragPreview - ? 'border-red-400 bg-red-50 dark:bg-red-950/80 dark:border-red-900/80 dark:hover:border-red-800' - : contextMenuOpen.value - ? 'bg-slate-100 dark:bg-slate-950/80 border-slate-300 dark:border-slate-600' - : isSaved && !isDragPreview - ? 'bg-sky-50 border-sky-600 dark:bg-sky-950/80 dark:border-sky-900/80 dark:hover:border-sky-800' - : isCopiedOrPasted && !isDragPreview - ? `bg-green-50 border-green-600 dark:bg-green-950/80 dark:border-green-800` - : isSelected - ? `bg-amber-50 border-amber-300 dark:bg-amber-950/80 dark:border-amber-900/80 hover:border-amber-300/80 dark:hover:border-amber-800 hover:bg-amber-50/80 ${ - isPinnedTop ? '!border dark:!bg-amber-950' : '' - }` - : `hover:bg-white dark:hover:bg-slate-950/80 ${ - isLargeView - ? 'border-slate-500 bg-white dark:bg-slate-950 hover:dark:border-slate-500' - : `${ - !isPinnedTop && isOverPinned - ? 'border-orange-300 dark:border-orange-400/80 dark:bg-orange-900/80 bg-orange-50' - : isPinnedTop - ? 'bg-slate-50 dark:!bg-slate-900 dark:hover:!bg-slate-950 hover:!border-orange-300/90 border-orange-300/50 dark:!border-orange-800/60 dark:hover:!border-orange-900' - : 'bg-slate-50 hover:border-slate-300 dark:border-slate-800' - }` - } dark:hover:border-slate-700 dark:bg-slate-900 ${ - isDragPreview ? 'dark:border-slate-700' : '' - }` - }`} - onClickCapture={e => { - if ( - (isSingleClickToCopyPaste && - !getSelectedText().text && - isWindows && - e.ctrlKey) || - (e.metaKey && !isWindows) - ) { - e.preventDefault() - e.stopPropagation() - onCopyPaste(clipboard.historyId) - } else if ((isWindows && e.ctrlKey) || (e.metaKey && !isWindows)) { - setSelectHistoryItem(clipboard.historyId) - } else if (e.ctrlKey || e.metaKey) { - e.preventDefault() - e.stopPropagation() - } else if (e.shiftKey) { - e.preventDefault() - e.stopPropagation() - window.getSelection()?.removeAllRanges() - setLargeViewItemId(isLargeView ? null : clipboard.historyId) - } else if (largeViewItemId && !isLargeView) { - window.getSelection()?.removeAllRanges() - setLargeViewItemId(clipboard.historyId) - } else if (isSingleClickToCopyPaste && !getSelectedText().text) { - // Check if click is on context menu button or its children - const isContextMenuClick = - contextMenuButtonRef.current && - (contextMenuButtonRef.current.contains(e.target as Node) || - contextMenuButtonRef.current === e.target) + : isKeyboardSelected + ? `bg-blue-50 border-blue-300 dark:bg-blue-950/80 dark:hover:border-blue-800 hover:bg-blue-50/80 ${ + isPinnedTop ? ' dark:!bg-amber-950' : '' + }` + : contextMenuOpen.value + ? 'bg-slate-100 dark:bg-slate-950/80 border-slate-300 dark:border-slate-600' + : isSaved && !isDragPreview + ? 'bg-sky-50 border-sky-600 dark:bg-sky-950/80 dark:border-sky-900/80 dark:hover:border-sky-800' + : isCopiedOrPasted && !isDragPreview + ? `bg-green-50 border-green-600 dark:bg-green-950/80 dark:border-green-800` + : `hover:bg-white dark:hover:bg-slate-950/80 ${ + isLargeView + ? 'border-slate-500 bg-white dark:bg-slate-950 hover:dark:border-slate-500' + : `${ + !isPinnedTop && isOverPinned + ? 'border-orange-300 dark:border-orange-400/80 dark:bg-orange-900/80 bg-orange-50' + : isPinnedTop + ? 'bg-slate-50 dark:!bg-slate-900 dark:hover:!bg-slate-950 hover:!border-orange-300/90 border-orange-300/50 dark:!border-orange-800/60 dark:hover:!border-orange-900' + : 'bg-slate-50 hover:border-slate-300 dark:border-slate-800' + }` + } dark:hover:border-slate-700 dark:bg-slate-900 ${ + isDragPreview ? 'dark:border-slate-700' : '' + }` + }`} + onClickCapture={e => { + if ( + (isSingleClickToCopyPaste && + !getSelectedText().text && + isWindows && + e.ctrlKey) || + (e.metaKey && !isWindows) + ) { + e.preventDefault() + e.stopPropagation() + onCopyPaste(clipboard.historyId) + } else if ((isWindows && e.ctrlKey) || (e.metaKey && !isWindows)) { + setSelectHistoryItem(clipboard.historyId) + } else if (e.ctrlKey || e.metaKey) { + e.preventDefault() + e.stopPropagation() + } else if (e.shiftKey) { + e.preventDefault() + e.stopPropagation() + window.getSelection()?.removeAllRanges() + setLargeViewItemId(isLargeView ? null : clipboard.historyId) + } else if (largeViewItemId && !isLargeView) { + window.getSelection()?.removeAllRanges() + setLargeViewItemId(clipboard.historyId) + } else if (isSingleClickToCopyPaste && !getSelectedText().text) { + // Check if click is on context menu button or its children + const isContextMenuClick = + contextMenuButtonRef.current && + (contextMenuButtonRef.current.contains(e.target as Node) || + contextMenuButtonRef.current === e.target) - if (isContextMenuClick) { - return // Don't copy/paste if clicking on context menu - } - - if ( - e.altKey || - (e.metaKey && isWindows) || - (e.ctrlKey && !isWindows) - ) { - onCopyPaste(clipboard.historyId) - } else { - onCopy(clipboard.historyId) - } - } else { - hoveringHistoryRowId.value = !isPinnedTop - ? clipboard.historyId - : `${clipboard.historyId}::pinned` + if (isContextMenuClick) { + return // Don't copy/paste if clicking on context menu } - }} - onMouseEnter={() => { + + if (e.altKey || (e.metaKey && isWindows) || (e.ctrlKey && !isWindows)) { + onCopyPaste(clipboard.historyId) + } else { + onCopy(clipboard.historyId) + } + } else { hoveringHistoryRowId.value = !isPinnedTop ? clipboard.historyId : `${clipboard.historyId}::pinned` - }} - onMouseLeave={() => { - hoveringHistoryRowId.value = null - }} - onDoubleClickCapture={e => { - if (!isSingleClickToCopyPaste && !getSelectedText().text) { - if (e.altKey || e.metaKey) { - onCopyPaste(clipboard.historyId) - } else { - onCopy(clipboard.historyId) - } + } + }} + onMouseEnter={() => { + hoveringHistoryRowId.value = !isPinnedTop + ? clipboard.historyId + : `${clipboard.historyId}::pinned` + }} + onMouseLeave={() => { + hoveringHistoryRowId.value = null + }} + onDoubleClickCapture={e => { + if (!isSingleClickToCopyPaste && !getSelectedText().text) { + if (e.altKey || e.metaKey) { + onCopyPaste(clipboard.historyId) + } else { + onCopy(clipboard.historyId) } - }} + } + }} + > + - - {showSelectHistoryItems && !isDragPreview ? ( + {showSelectHistoryItems && !isDragPreview ? ( + + { + setSelectHistoryItem(clipboard.historyId) + }} + checked={isSelected} + /> + + ) : ( + showCopyPasteIndexNumber && ( - { - setSelectHistoryItem(clipboard.historyId) + + {index === 9 ? 0 : index + 1} + + + ) + )} + {clipboard.isImageData ? ( + + + { + setBrokenImageItem(clipboard.historyId) }} - checked={isSelected} - /> - - ) : ( - showCopyPasteIndexNumber && ( - - - {index === 9 ? 0 : index + 1} - - - ) - )} - {clipboard.isImageData ? ( - - - { - setBrokenImageItem(clipboard.historyId) - }} - draggable={false} - decoding="async" - onLoad={() => { - contentElementRendered.value = true - }} - className="max-w-full max-h-56 min-h-10 rounded-md shadow-sm border border-slate-100 dark:border-slate-700" - /> - - - {searchTerm ? ( - highlightWithPreviewMatchedText(clipboard.value, searchTerm) - ) : ( - {valuePreview} - )} - - - ) : clipboard.isLink && clipboard.isImage ? ( - - - { - setBrokenImageItem(clipboard.historyId) - }} - draggable={false} - decoding="async" - onLoad={() => { - contentElementRendered.value = true - }} - className="max-w-full max-h-56 min-h-10 rounded-md shadow-sm border border-slate-100 dark:border-slate-700" - /> - - - {searchTerm - ? highlightWithPreviewMatchedText(stringValue, searchTerm) - : hyperlinkText(stringValue, clipboard.arrLinks)} - - - ) : clipboard.isLink && clipboard.isVideo ? ( - - - - {searchTerm - ? highlightWithPreviewMatchedText(stringValue, searchTerm) - : hyperlinkText(stringValue, clipboard.arrLinks)} - - - ) : clipboard.isImage && clipboard.imageDataUrl ? ( - - { contentElementRendered.value = true }} className="max-w-full max-h-56 min-h-10 rounded-md shadow-sm border border-slate-100 dark:border-slate-700" /> - ) : clipboard.detectedLanguage && valuePreview ? ( - { - if (ref) { - contentElementRendered.value = true - } - }} - className={`text-ellipsis self-start text-sm w-full overflow-hidden`} - > - - {({ className, style, tokens, getLineProps, getTokenProps }) => { - return ( - - {tokens.map((line, i) => { - return ( -
- {line.map((token, key) => ( - - {!searchTerm - ? token.content - : highlightMatchedText( - token.content, - searchTerm - )} - - ))} -
- ) - })} -
- ) - }} -
-
- ) : ( - { - if (ref) { - contentElementRendered.value = true - } - }} - className="text-ellipsis self-start text-sm w-full overflow-hidden" - > - {hasLinkCard && ( - - - - )} - {isExpanded ? ( - - {searchTerm - ? highlightMatchedText(stringValue, searchTerm) - : hyperlinkText(stringValue, clipboard.arrLinks)} - + + {searchTerm ? ( + highlightWithPreviewMatchedText(clipboard.value, searchTerm) ) : ( - - {searchTerm - ? highlightWithPreviewMatchedText( - stringValue ?? '', - searchTerm - ) - : hyperlinkTextWithPreview({ - previewLinkCard: !hasLinkCard && isLinkCardPreviewEnabled, - isPreviewError: hasClipboardHistoryURLErrors, - value: valuePreview ?? '', - links: clipboard.arrLinks, - itemId: null, - historyId: clipboard.historyId, - })} - {isMp3 && ( - - )} - + {valuePreview} )} + + + ) : clipboard.isLink && clipboard.isImage ? ( + + + { + setBrokenImageItem(clipboard.historyId) + }} + draggable={false} + decoding="async" + onLoad={() => { + contentElementRendered.value = true + }} + className="max-w-full max-h-56 min-h-10 rounded-md shadow-sm border border-slate-100 dark:border-slate-700" + /> - )} - {(valueMorePreviewLines || valueMorePreviewChars) && - !isCopiedOrPasted && ( + + {searchTerm + ? highlightWithPreviewMatchedText(stringValue, searchTerm) + : hyperlinkText(stringValue, clipboard.arrLinks)} + + + ) : clipboard.isLink && clipboard.isVideo ? ( + + + + {searchTerm + ? highlightWithPreviewMatchedText(stringValue, searchTerm) + : hyperlinkText(stringValue, clipboard.arrLinks)} + + + ) : clipboard.isImage && clipboard.imageDataUrl ? ( + + { + contentElementRendered.value = true + }} + className="max-w-full max-h-56 min-h-10 rounded-md shadow-sm border border-slate-100 dark:border-slate-700" + /> + + ) : clipboard.detectedLanguage && valuePreview ? ( + { + if (ref) { + contentElementRendered.value = true + } + }} + className={`text-ellipsis self-start text-sm w-full overflow-hidden`} + > + + {({ className, style, tokens, getLineProps, getTokenProps }) => { + return ( + + {tokens.map((line, i) => { + return ( +
+ {line.map((token, key) => ( + + {!searchTerm + ? token.content + : highlightMatchedText(token.content, searchTerm)} + + ))} +
+ ) + })} +
+ ) + }} +
+
+ ) : ( + { + if (ref) { + contentElementRendered.value = true + } + }} + className="text-ellipsis self-start text-sm w-full overflow-hidden" + > + {hasLinkCard && ( + + + + )} + {isExpanded ? ( + + {searchTerm + ? highlightMatchedText(stringValue, searchTerm) + : hyperlinkText(stringValue, clipboard.arrLinks)} + + ) : ( + + {searchTerm + ? highlightWithPreviewMatchedText(stringValue ?? '', searchTerm) + : hyperlinkTextWithPreview({ + previewLinkCard: !hasLinkCard && isLinkCardPreviewEnabled, + isPreviewError: hasClipboardHistoryURLErrors, + value: valuePreview ?? '', + links: clipboard.arrLinks, + itemId: null, + historyId: clipboard.historyId, + })} + {isMp3 && ( + + )} + + )} + + )} + {(valueMorePreviewLines || valueMorePreviewChars) && + !isCopiedOrPasted && ( + { + setExpanded(clipboard.historyId, !isExpanded) + }} > + + {!isExpanded ? ( + valueMorePreviewChars ? ( + <> + +{valueMorePreviewChars} {t('chars', { ns: 'common' })} + + ) : ( + <> + +{valueMorePreviewLines} {t('lines', { ns: 'common' })} + + ) + ) : ( + <>- {t('show less', { ns: 'common' })} + )} + + + {isExpanded && ( { - setExpanded(clipboard.historyId, !isExpanded) - }} + className={`text-xs text-muted-foreground px-1.5 cursor-pointer`} + onClick={() => setWrapText(clipboard.historyId, !isWrapText)} > - {!isExpanded ? ( - valueMorePreviewChars ? ( - <> - +{valueMorePreviewChars} {t('chars', { ns: 'common' })} - - ) : ( - <> - +{valueMorePreviewLines} {t('lines', { ns: 'common' })} - - ) + {!isWrapText ? ( + ) : ( - <>- {t('show less', { ns: 'common' })} + )} - {isExpanded && ( - setWrapText(clipboard.historyId, !isWrapText)} - > - - {!isWrapText ? ( - - ) : ( - - )} - - - )} - - )} - {clipboard.isImage && !clipboard.isLink && ( - - - {clipboard.imageWidth}x{clipboard.imageHeight} - + )} )} - <> - {clipboard.isFavorite && ( - - )} + {clipboard.isImage && !clipboard.isLink && ( + + + {clipboard.imageWidth}x{clipboard.imageHeight} + + + )} + <> + {clipboard.isFavorite && ( + + )} - {clipboard.isPinned && !clipboard.isFavorite && !isPinnedTop && ( - - )} - - - {isHovering || isSelected ? ( -
- {isPinnedTop ? ( - - + )} + + + {isHovering || isSelected ? ( +
+ {isPinnedTop ? ( + + + - - { - if (isDisabledPinnedMoveUp) { - return - } - onMovePinnedUpDown({ - historyId: clipboard.historyId, - moveUp: true, - }) - }} - /> - - - - - { - if (isDisabledPinnedMoveDown) { - return - } - onMovePinnedUpDown({ - historyId: clipboard.historyId, - moveDown: true, - }) - }} - /> - - - - ) : ( - clipboard.timeAgoShort && ( - - - {clipboard.timeAgoShort} - - - ) - )} - - - {isKeyAltPressed.value ? ( - { - onCopyPaste(clipboard.historyId) - }} - /> - ) : ( - { - onCopy(clipboard.historyId) - }} - /> - )} - - - - - - - - - - { - contextMenuTriggerRef?.current?.dispatchEvent( - new MouseEvent('contextmenu', { - bubbles: true, - clientX: - contextMenuButtonRef?.current?.getBoundingClientRect() - .x, - clientY: - contextMenuButtonRef?.current?.getBoundingClientRect() - .y, + if (isDisabledPinnedMoveUp) { + return + } + onMovePinnedUpDown({ + historyId: clipboard.historyId, + moveUp: true, }) - ) + }} + /> + + + + + { + if (isDisabledPinnedMoveDown) { + return + } + onMovePinnedUpDown({ + historyId: clipboard.historyId, + moveDown: true, + }) + }} + /> + + + + ) : ( + clipboard.timeAgoShort && ( + + + {clipboard.timeAgoShort} + + + ) + )} + + + {isKeyAltPressed.value ? ( + { + onCopyPaste(clipboard.historyId) }} /> - - -
- ) : ( -
- {clipboard.hasMaskedWords && ( - - - - )} + ) : ( + { + onCopy(clipboard.historyId) + }} + /> + )} + + + + + + + + + + { + contextMenuTriggerRef?.current?.dispatchEvent( + new MouseEvent('contextmenu', { + bubbles: true, + clientX: + contextMenuButtonRef?.current?.getBoundingClientRect() + .x, + clientY: + contextMenuButtonRef?.current?.getBoundingClientRect() + .y, + }) + ) + }} + /> + + +
+ ) : ( +
+ {clipboard.hasMaskedWords && ( + + + + )} - {clipboard.isMasked && ( - - {t('Type:::Secret', { ns: 'common' })} - - )} + {clipboard.isMasked && ( + + {t('Type:::Secret', { ns: 'common' })} + + )} - {clipboard.detectedLanguage && ( - - {clipboard.detectedLanguage} - - )} + {clipboard.detectedLanguage && ( + + {clipboard.detectedLanguage} + + )} - {clipboard.isLink && ( - - {clipboard.isVideo - ? t('Type:::Video', { ns: 'common' }) - : isEmailNotUrl(stringValue) - ? t('Type:::Email', { ns: 'common' }) - : isMp3 - ? t('Type:::Mp3', { ns: 'common' }) - : t('Type:::Link', { ns: 'common' })} - - )} + {clipboard.isLink && ( + + {clipboard.isVideo + ? t('Type:::Video', { ns: 'common' }) + : isEmailNotUrl(stringValue) + ? t('Type:::Email', { ns: 'common' }) + : isMp3 + ? t('Type:::Mp3', { ns: 'common' }) + : t('Type:::Link', { ns: 'common' })} + + )} - {clipboard.hasEmoji && ( - - {t('Type:::Emoji', { ns: 'common' })} - - )} + {clipboard.hasEmoji && ( + + {t('Type:::Emoji', { ns: 'common' })} + + )} - {clipboard.isImageData && ( - - {t('Type:::Image Base64', { ns: 'common' })} - - )} -
- )} -
- {selectedItemsCount > 1 && ( - - - {selectedItemsCount} - - + {clipboard.isImageData && ( + + {t('Type:::Image Base64', { ns: 'common' })} + + )} +
)}
-
- {isSaved ? ( - - - - {t('Saved', { ns: 'common' })} - - - ) : isCopiedOrPasted && !pastingCountDown ? ( - - - - {isCopied - ? t('Copied', { ns: 'common' }) - : isPasted - ? t('Pasted', { ns: 'common' }) - : ''} - - - ) : !isLargeView ? ( - pastingCountDown && - pastingCountDown > 0 && ( - - - {t('Paste in {{pastingCountDown}}...', { - ns: 'common', - pastingCountDown, - })} + {selectedItemsCount > 1 && ( + + + {selectedItemsCount} - ) - ) : ( + )} + +
+ {isSaved ? ( + + + + {t('Saved', { ns: 'common' })} + + + ) : isCopiedOrPasted && !pastingCountDown ? ( + + + + {isCopied + ? t('Copied', { ns: 'common' }) + : isPasted + ? t('Pasted', { ns: 'common' }) + : ''} + + + ) : !isLargeView ? ( + pastingCountDown && + pastingCountDown > 0 && ( - - {t('In View', { ns: 'common' })} - - { - setLargeViewItemId(null) - }} - /> + {t('Paste in {{pastingCountDown}}...', { + ns: 'common', + pastingCountDown, + })} - )} -
-
- -
+ ) + ) : ( + + + + {t('In View', { ns: 'common' })} + + { + setLargeViewItemId(null) + }} + /> + + + )} + + ) diff --git a/packages/pastebar-app-ui/src/pages/components/ClipboardHistory/ClipboardHistoryWindowIcons.tsx b/packages/pastebar-app-ui/src/pages/components/ClipboardHistory/ClipboardHistoryWindowIcons.tsx index e0ae5d2f..9c3f84aa 100644 --- a/packages/pastebar-app-ui/src/pages/components/ClipboardHistory/ClipboardHistoryWindowIcons.tsx +++ b/packages/pastebar-app-ui/src/pages/components/ClipboardHistory/ClipboardHistoryWindowIcons.tsx @@ -70,12 +70,6 @@ export const ClipboardHistoryWindowIcons = ({ setShowSelectHistoryItems(!showSelectHistoryItems) }) - useHotkeys(['control+s'], () => { - if (hoveringHistoryRowId.value) { - setSelectHistoryItem(hoveringHistoryRowId.value) - } - }) - useHotkeys(['alt+h', 'meta+h'], () => { setIsHistoryEnabled(!isHistoryEnabled) }) diff --git a/packages/pastebar-app-ui/src/pages/components/ClipboardHistory/context-menu/ClipboardHistoryRowContextMenu.tsx b/packages/pastebar-app-ui/src/pages/components/ClipboardHistory/context-menu/ClipboardHistoryRowContextMenu.tsx index 74654854..0691487e 100644 --- a/packages/pastebar-app-ui/src/pages/components/ClipboardHistory/context-menu/ClipboardHistoryRowContextMenu.tsx +++ b/packages/pastebar-app-ui/src/pages/components/ClipboardHistory/context-menu/ClipboardHistoryRowContextMenu.tsx @@ -1,15 +1,16 @@ -import { Dispatch, SetStateAction } from 'react' +import { Dispatch, SetStateAction, useEffect, useRef, useState } from 'react' import { UniqueIdentifier } from '@dnd-kit/core' import { useQueryClient } from '@tanstack/react-query' import { invoke } from '@tauri-apps/api' import { message } from '@tauri-apps/api/dialog' import { emit } from '@tauri-apps/api/event' import { - clipboardHistoryStoreAtom, createClipHistoryItemIds, createMenuItemFromHistoryId, + DEFAULT_SPECIAL_PASTE_CATEGORIES, hasDashboardItemCreate, isCreatingMenuItem, + isKeyAltPressed, settingsStoreAtom, } from '~/store' import { useAtomValue } from 'jotai' @@ -27,6 +28,7 @@ import { PanelTop, Pin, PinOff, + Settings, Shrink, SquareAsterisk, Star, @@ -35,6 +37,7 @@ import { import { useTranslation } from 'react-i18next' import { useNavigate } from 'react-router-dom' +import { TRANSFORM_CATEGORIES, type TransformCategory } from '~/lib/text-transforms' import { ensureUrlPrefix } from '~/lib/utils' import { @@ -58,6 +61,7 @@ import { useUpdateClipboardHistoryById, } from '~/hooks/queries/use-history-items' import { useSignal } from '~/hooks/use-signal' +import { useSpecialCopyPasteHistoryItem } from '~/hooks/use-special-copypaste-history-item' import { LinkMetadata } from '~/types/history' import { CreateDashboardItemType } from '~/types/menu' @@ -92,6 +96,10 @@ interface ClipboardHistoryRowContextMenuProps { onCopyPaste: (id: UniqueIdentifier, delay?: number) => void setHistoryFilters?: Dispatch> setAppFilters?: Dispatch> + onDeleteConfirmationChange?: ( + historyId: UniqueIdentifier | null, + isMultiSelect?: boolean + ) => void } export default function ClipboardHistoryRowContextMenu({ @@ -121,8 +129,10 @@ export default function ClipboardHistoryRowContextMenu({ setSelectHistoryItem, selectedHistoryItems, onCopyPaste, + onDeleteConfirmationChange = () => {}, }: ClipboardHistoryRowContextMenuProps) { const { t } = useTranslation() + const navigate = useNavigate() const queryClient = useQueryClient() const { copyPasteDelay, @@ -130,14 +140,67 @@ export default function ClipboardHistoryRowContextMenu({ historyDetectLanguagesEnabledList, setIsExclusionAppListEnabled, addToHistoryExclusionAppList, + enabledSpecialPasteOperations, + specialPasteCategoriesOrder, + isSpecialCopyPasteHistoryEnabled, } = useAtomValue(settingsStoreAtom) - const showDeleteMenuItemsConfirmation = useSignal(false) + const [specialActionInProgress, setSpecialActionInProgress] = useState( + null + ) + const deleteTimerRef = useRef(null) + + // Moved hook declarations before useHotkeys to resolve TS errors const { updateClipboardHistoryById } = useUpdateClipboardHistoryById() const { deleteClipboardHistoryByIds } = useDeleteClipboardHistoryByIds() const { pinnedClipboardHistoryByIds } = usePinnedClipboardHistoryByIds() - const navigate = useNavigate() + // Track pending delete ID for two-step deletion + const [pendingDeleteId, setPendingDeleteId] = useState(null) + + // Cleanup timer on unmount + useEffect(() => { + return () => { + if (deleteTimerRef.current) { + clearTimeout(deleteTimerRef.current) + deleteTimerRef.current = null + } + } + }, []) + + const { specialCopy, specialPaste } = useSpecialCopyPasteHistoryItem() + + // Ensure we always have an array of categories + const categoriesOrder = specialPasteCategoriesOrder || [ + ...DEFAULT_SPECIAL_PASTE_CATEGORIES, + ] + + // Filter categories to only include those with enabled transforms + const categoriesWithTransforms = categoriesOrder + .map(categoryId => TRANSFORM_CATEGORIES.find(c => c.id === categoryId)) + .filter((category): category is TransformCategory => { + if (!category || !categoriesOrder.includes(category.id)) return false + + // Check if category has any enabled transforms + if (category.subcategories) { + // For categories with subcategories, check if any subcategory has enabled transforms + const hasEnabledSubcategories = category.subcategories.some(subcategory => + subcategory.transforms.some(transform => + enabledSpecialPasteOperations.includes(transform.id) + ) + ) + return hasEnabledSubcategories + } else { + // For categories with transforms, check if any transform is enabled + const enabledTransforms = + category.transforms?.filter(transform => + enabledSpecialPasteOperations.includes(transform.id) + ) || [] + return enabledTransforms.length > 0 + } + }) + + const hasEnabledCategories = categoriesWithTransforms.length > 0 const errorMessage = (err: string) => { message( @@ -155,7 +218,67 @@ export default function ClipboardHistoryRowContextMenu({ return ( - + { + // Prevent closing on interaction outside during deletion confirmation + if (pendingDeleteId) { + e.preventDefault() + } + }} + onEscapeKeyDown={e => { + // Allow escape to close even during confirmation + setPendingDeleteId(null) + }} + onKeyDown={e => { + // Handle Delete/Backspace keys + if (e.key === 'Delete' || e.key === 'Backspace') { + e.preventDefault() + e.stopPropagation() + + if (isSelected && selectedHistoryItems && selectedHistoryItems.length > 1) { + // Multi-select delete + if (pendingDeleteId === 'multi') { + // Confirm multi-delete + deleteClipboardHistoryByIds({ historyIds: selectedHistoryItems }) + setTimeout(() => { + setSelectedHistoryItems([]) + }, 400) + setPendingDeleteId(null) + if (deleteTimerRef.current) { + clearTimeout(deleteTimerRef.current) + } + } else { + // Start multi-delete confirmation + setPendingDeleteId('multi') + onDeleteConfirmationChange?.(null, true) + deleteTimerRef.current = setTimeout(() => { + setPendingDeleteId(null) + onDeleteConfirmationChange?.(null, false) + }, 3000) + } + } else { + // Single delete + if (pendingDeleteId === historyId) { + // Confirm single delete + deleteClipboardHistoryByIds({ historyIds: [historyId] }) + setPendingDeleteId(null) + if (deleteTimerRef.current) { + clearTimeout(deleteTimerRef.current) + } + } else { + // Start single delete confirmation + setPendingDeleteId(historyId) + onDeleteConfirmationChange?.(historyId, false) + deleteTimerRef.current = setTimeout(() => { + setPendingDeleteId(null) + onDeleteConfirmationChange?.(null, false) + }, 3000) + } + } + } + }} + > { setSelectHistoryItem(historyId) @@ -247,6 +370,167 @@ export default function ClipboardHistoryRowContextMenu({ + + {/* Special Copy/Paste submenu - only show for text items when enabled */} + {isSpecialCopyPasteHistoryEnabled && !isImage && value && ( + <> + + + {isKeyAltPressed.value + ? t('Special Paste', { ns: 'specialCopyPaste' }) + : t('Special Copy', { ns: 'specialCopyPaste' })} + + + {categoriesWithTransforms.map(category => { + // Handle categories with subcategories (like Format Converter) + if (category.subcategories) { + const enabledSubcategories = category.subcategories.filter( + subcategory => + subcategory.transforms.some(transform => + enabledSpecialPasteOperations.includes(transform.id) + ) + ) + + return ( + + + {t(category.label, { + ns: 'specialCopyPaste', + })} + + + {enabledSubcategories.map(subcategory => { + const enabledTransforms = subcategory.transforms.filter( + transform => + enabledSpecialPasteOperations.includes(transform.id) + ) + + return ( + + + {t(subcategory.label, { + ns: 'specialCopyPaste', + })} + + + {enabledTransforms.map(transform => ( + { + setSpecialActionInProgress(transform.id) + try { + if (isKeyAltPressed.value) { + await specialPaste( + historyId, + value, + transform.id + ) + } else { + await specialCopy( + historyId, + value, + transform.id + ) + } + setSpecialActionInProgress(null) + } catch (error) { + console.error( + 'Special copy/paste failed:', + error + ) + setSpecialActionInProgress(null) + } + }} + > + {t(transform.label, { + ns: 'specialCopyPaste', + })} + {specialActionInProgress === transform.id && ( +
+ + ... + +
+ )} +
+ ))} +
+
+ ) + })} +
+
+ ) + } else { + // Handle categories with direct transforms + const enabledTransforms = + category.transforms?.filter(transform => + enabledSpecialPasteOperations.includes(transform.id) + ) || [] + + return ( + + + {t(category.label, { + ns: 'specialCopyPaste', + })} + + + {enabledTransforms.map(transform => ( + { + setSpecialActionInProgress(transform.id) + try { + if (isKeyAltPressed.value) { + await specialPaste(historyId, value, transform.id) + } else { + await specialCopy(historyId, value, transform.id) + } + setSpecialActionInProgress(null) + } catch (error) { + console.error('Special copy/paste failed:', error) + setSpecialActionInProgress(null) + } + }} + > + {t(transform.label, { + ns: 'specialCopyPaste', + })} + {specialActionInProgress === transform.id && ( +
+ + ... + +
+ )} +
+ ))} +
+
+ ) + } + })} + {hasEnabledCategories && } + { + navigate('/app-settings/history#specialCopyPasteHistory', { + replace: true, + }) + }} + > + {t('Special Settings', { ns: 'specailCopyPaste' })} +
+ +
+
+
+
+ + )} + { @@ -552,25 +836,34 @@ export default function ClipboardHistoryRowContextMenu({ {isSelected && selectedHistoryItems && selectedHistoryItems.length > 1 ? ( { - if (showDeleteMenuItemsConfirmation.value) { + className={ + pendingDeleteId === 'multi' ? 'bg-red-500/20 dark:bg-red-600/20' : '' + } + onSelect={async e => { + e.preventDefault() + + if (pendingDeleteId === 'multi') { await deleteClipboardHistoryByIds({ historyIds: selectedHistoryItems }) setTimeout(() => { setSelectedHistoryItems([]) }, 400) - showDeleteMenuItemsConfirmation.value = false + setPendingDeleteId(null) + if (deleteTimerRef.current) { + clearTimeout(deleteTimerRef.current) + } } else { - e.preventDefault() - showDeleteMenuItemsConfirmation.value = true - setTimeout(() => { - showDeleteMenuItemsConfirmation.value = false + setPendingDeleteId('multi') + onDeleteConfirmationChange?.(null, true) + deleteTimerRef.current = setTimeout(() => { + setPendingDeleteId(null) + onDeleteConfirmationChange?.(null, false) }, 3000) } }} > - {!showDeleteMenuItemsConfirmation.value + {pendingDeleteId !== 'multi' ? t('Delete', { ns: 'common' }) : t('Click to Confirm', { ns: 'common' })} - {!showDeleteMenuItemsConfirmation.value && ( + {pendingDeleteId !== 'multi' && (
DEL @@ -591,29 +884,36 @@ export default function ClipboardHistoryRowContextMenu({ ) : ( { - if (showDeleteMenuItemsConfirmation.value) { + className={ + pendingDeleteId === historyId ? 'bg-red-500/20 dark:bg-red-600/20' : '' + } + onSelect={async e => { + e.preventDefault() + + if (pendingDeleteId === historyId) { await deleteClipboardHistoryByIds({ historyIds: [historyId] }) - setTimeout(() => { - showDeleteMenuItemsConfirmation.value = false - }, 400) + setPendingDeleteId(null) + if (deleteTimerRef.current) { + clearTimeout(deleteTimerRef.current) + } } else { - e.preventDefault() - showDeleteMenuItemsConfirmation.value = true - setTimeout(() => { - showDeleteMenuItemsConfirmation.value = false + setPendingDeleteId(historyId) + onDeleteConfirmationChange?.(historyId, false) + deleteTimerRef.current = setTimeout(() => { + setPendingDeleteId(null) + onDeleteConfirmationChange?.(null, false) }, 3000) } }} > - {!showDeleteMenuItemsConfirmation.value + {pendingDeleteId !== historyId ? t('Delete', { ns: 'common' }) : t('Click to Confirm', { ns: 'common' })} - {!showDeleteMenuItemsConfirmation.value && ( + {pendingDeleteId !== historyId && (
DEL diff --git a/packages/pastebar-app-ui/src/pages/components/ClipboardHistory/context-menu/ContextMenuTrigger.tsx b/packages/pastebar-app-ui/src/pages/components/ClipboardHistory/context-menu/ContextMenuTrigger.tsx new file mode 100644 index 00000000..f353a3eb --- /dev/null +++ b/packages/pastebar-app-ui/src/pages/components/ClipboardHistory/context-menu/ContextMenuTrigger.tsx @@ -0,0 +1,142 @@ +import { Dispatch, forwardRef, SetStateAction, useState } from 'react' +import { UniqueIdentifier } from '@dnd-kit/core' + +import { + ContextMenu, + ContextMenuTrigger as ContextMenuTriggerPrimitive, +} from '~/components/ui' + +import { LinkMetadata } from '~/types/history' + +import ClipboardHistoryRowContextMenu from './ClipboardHistoryRowContextMenu' + +// // Lazy load the heavy context menu component +// const ClipboardHistoryRowContextMenu = lazy( +// () => import('./ClipboardHistoryRowContextMenu') +// ) + +interface ContextMenuTriggerProps { + children: React.ReactNode + onOpenChange?: (isOpen: boolean) => void + historyId: UniqueIdentifier + value: string | null + arrLinks: string[] + isImage: boolean + isText: boolean + copiedFromApp?: string | null + isMasked: boolean + isImageData: boolean + isMp3: boolean | undefined + hasLinkCard: boolean | undefined | string | null + isSelected: boolean + isLargeView: boolean + isPinned: boolean + isFavorite: boolean + detectedLanguage: string | null + setLargeViewItemId: (historyId: UniqueIdentifier | null) => void + setSavingItem: (historyId: UniqueIdentifier | null) => void + invalidateClipboardHistoryQuery?: () => void + generateLinkMetaData?: ( + historyId: UniqueIdentifier, + url: string + ) => Promise + removeLinkMetaData?: (historyId: UniqueIdentifier) => Promise + setSelectHistoryItem: (id: UniqueIdentifier) => void + setSelectedHistoryItems?: (ids: UniqueIdentifier[]) => void + selectedHistoryItems?: UniqueIdentifier[] + onCopyPaste: (id: UniqueIdentifier, delay?: number) => void + setHistoryFilters?: Dispatch> + setAppFilters?: Dispatch> + onDeleteConfirmationChange?: ( + historyId: UniqueIdentifier | string | null, + isMultiSelect?: boolean + ) => void +} + +const ContextMenuTrigger = forwardRef( + ( + { + children, + onOpenChange, + historyId, + value, + arrLinks, + isImage, + isText, + copiedFromApp, + isMasked, + isImageData, + isMp3, + hasLinkCard, + isSelected, + isLargeView, + isPinned, + isFavorite, + detectedLanguage, + setLargeViewItemId, + setSavingItem, + invalidateClipboardHistoryQuery, + generateLinkMetaData, + removeLinkMetaData, + setSelectHistoryItem, + setSelectedHistoryItems, + selectedHistoryItems, + onCopyPaste, + setHistoryFilters, + setAppFilters, + onDeleteConfirmationChange, + }, + ref + ) => { + const [isOpen, setIsOpen] = useState(false) + + const handleOpenChange = (open: boolean) => { + setIsOpen(open) + onOpenChange?.(open) + } + + return ( + + + {children} + + + {isOpen && ( + + )} + + ) + } +) + +ContextMenuTrigger.displayName = 'ContextMenuTrigger' + +export default ContextMenuTrigger diff --git a/packages/pastebar-app-ui/src/pages/components/Dashboard/components/ClipCard.tsx b/packages/pastebar-app-ui/src/pages/components/Dashboard/components/ClipCard.tsx index a4b146d7..c50d80b8 100644 --- a/packages/pastebar-app-ui/src/pages/components/Dashboard/components/ClipCard.tsx +++ b/packages/pastebar-app-ui/src/pages/components/Dashboard/components/ClipCard.tsx @@ -25,6 +25,7 @@ import { settingsStoreAtom, showClipFindKeyPressed, showClipsMoveOnBoardId, + showKeyboardNavContextMenuClipId, showLargeViewClipId, showLinkedClipId, } from '~/store' @@ -562,6 +563,30 @@ export function ClipCard({ } }, [isKeyboardSelected]) + useEffect(() => { + if (showKeyboardNavContextMenuClipId.value === clip.id) { + if (contextMenuTriggerRef?.current) { + const targetElement = contextMenuTriggerRef.current + const rect = targetElement.getBoundingClientRect() + + contextMenuClipId.value = clip.id + + const contextMenuEvent = new MouseEvent('contextmenu', { + bubbles: true, + cancelable: true, + view: window, + clientX: rect.x + 50, + clientY: rect.y + 20, + button: 2, // Right mouse button + }) + + targetElement.dispatchEvent(contextMenuEvent) + + // Focus the first menu item after the context menu opens + } + } + }, [showKeyboardNavContextMenuClipId.value, clip.id, contextMenuTriggerRef?.current]) + const isEditing = isClipNameEditing || isClipEdit const copyDisabled = @@ -602,10 +627,18 @@ export function ClipCard({ { contextMenuOpen.value = isOpen + if (!isOpen && showKeyboardNavContextMenuClipId.value === clip.id) { + showKeyboardNavContextMenuClipId.value = null + } }} > diff --git a/packages/pastebar-app-ui/src/pages/components/Dashboard/components/ClipViewTemplate.tsx b/packages/pastebar-app-ui/src/pages/components/Dashboard/components/ClipViewTemplate.tsx index 34c663d8..abbfc78c 100644 --- a/packages/pastebar-app-ui/src/pages/components/Dashboard/components/ClipViewTemplate.tsx +++ b/packages/pastebar-app-ui/src/pages/components/Dashboard/components/ClipViewTemplate.tsx @@ -123,7 +123,12 @@ const renderWithBadges = ( : 'dark:!text-gray-300 text-gray-400 bg-gray-100 dark:bg-gray-800 hover:bg-gray-100 dark:hover:bg-gray-800 border-gray-200/80 dark:border-gray-700/80' } text-normal pr-2.5`} > - + {field.label} } @@ -163,7 +168,12 @@ const renderWithBadges = ( : 'bg-gray-100 dark:bg-gray-800 hover:bg-gray-100 dark:hover:bg-gray-800 border-gray-200/80 dark:border-gray-700/80' } text-normal pr-2.5`} > - + {field.label} @@ -196,7 +206,10 @@ const renderWithBadges = ( variant="outline" className="!text-purple-700 dark:!text-purple-300 bg-purple-100 dark:bg-purple-800 hover:bg-purple-200 dark:hover:bg-purple-700 border-purple-200 dark:border-purple-800 text-normal pr-2.5" > - + {field.label} (Global) } @@ -223,7 +236,10 @@ const renderWithBadges = ( variant="outline" className="!text-purple-700 dark:!text-purple-300 bg-purple-100 dark:bg-purple-800 hover:bg-purple-200 dark:hover:bg-purple-700 border-purple-200 dark:border-purple-800 text-normal pr-2.5" > - + {field.label} @@ -407,17 +423,18 @@ export function ClipViewTemplate({ .filter(f => f.label !== undefined) .map(({ label, isEnable, value, isValueMasked, isGlobal }) => { // For global templates, get the current value from globalTemplates - const actualValue = isGlobal && globalTemplatesEnabled - ? globalTemplates.find(gt => gt.isEnabled && gt.name === label)?.value || '' - : value; - + const actualValue = + isGlobal && globalTemplatesEnabled + ? globalTemplates.find(gt => gt.isEnabled && gt.name === label)?.value || '' + : value + return { label, isValueMasked, value: actualValue, isEnable, isGlobal, - }; + } }), clipboardValueSignal.value, templateShowFormat.value === 'values', @@ -587,9 +604,7 @@ export function ClipViewTemplate({ {field.label} @@ -781,7 +796,10 @@ export function ClipViewTemplate({ title={`Global Template: ${field.label}`} /> - + {t('Global', { ns: 'templates' })} diff --git a/packages/pastebar-app-ui/src/pages/components/Dashboard/components/GlobalSearch.tsx b/packages/pastebar-app-ui/src/pages/components/Dashboard/components/GlobalSearch.tsx index b02b10b8..659a8f69 100644 --- a/packages/pastebar-app-ui/src/pages/components/Dashboard/components/GlobalSearch.tsx +++ b/packages/pastebar-app-ui/src/pages/components/Dashboard/components/GlobalSearch.tsx @@ -394,18 +394,18 @@ export function GlobalSearch({ isDark }: { isDark: boolean }) { ) } } else if (e.key === 'Tab' && !e.shiftKey) { - e.preventDefault() - e.stopPropagation() if (availableTabs.length > 1) { + e.preventDefault() + e.stopPropagation() const currentTabIndex = availableTabs.indexOf(filter) const nextTabIndex = (currentTabIndex + 1) % availableTabs.length setFilter(availableTabs[nextTabIndex]) setSelectedIndex(-1) } } else if (e.key === 'Tab' && e.shiftKey) { - e.preventDefault() - e.stopPropagation() if (availableTabs.length > 1) { + e.preventDefault() + e.stopPropagation() const currentTabIndex = availableTabs.indexOf(filter) const prevTabIndex = (currentTabIndex - 1 + availableTabs.length) % availableTabs.length @@ -422,6 +422,18 @@ export function GlobalSearch({ isDark }: { isDark: boolean }) { e.preventDefault() e.stopPropagation() setShowSearchModal(false) + } else if (e.key === 'PageUp') { + e.preventDefault() + e.stopPropagation() + } else if (e.key === 'PageDown') { + e.preventDefault() + e.stopPropagation() + } else if (e.key === 'Home') { + e.preventDefault() + e.stopPropagation() + } else if (e.altKey && e.key === 'ArrowDown') { + e.preventDefault() + e.stopPropagation() } }, [filter, selectedIndex, handleCopySelectedItem, flattenedItems] diff --git a/packages/pastebar-app-ui/src/pages/components/Menu/components/MenuCardViewBody.tsx b/packages/pastebar-app-ui/src/pages/components/Menu/components/MenuCardViewBody.tsx index fd027fd5..6bbd4b05 100644 --- a/packages/pastebar-app-ui/src/pages/components/Menu/components/MenuCardViewBody.tsx +++ b/packages/pastebar-app-ui/src/pages/components/Menu/components/MenuCardViewBody.tsx @@ -90,7 +90,11 @@ export function MenuCardViewBody({ const { t } = useTranslation() const isWrapText = useSignal(false) - const { valuePreview, morePreviewLines, morePreviewChars } = getValuePreview(value, false, false) + const { valuePreview, morePreviewLines, morePreviewChars } = getValuePreview( + value, + false, + false + ) const textValue: string = value || '' const isBrokenImage = useSignal(false) const pathTypeCheck = useSignal('') diff --git a/packages/pastebar-app-ui/src/pages/main/ClipboardHistoryPage.tsx b/packages/pastebar-app-ui/src/pages/main/ClipboardHistoryPage.tsx index bf585ad5..ceeacacb 100644 --- a/packages/pastebar-app-ui/src/pages/main/ClipboardHistoryPage.tsx +++ b/packages/pastebar-app-ui/src/pages/main/ClipboardHistoryPage.tsx @@ -35,6 +35,9 @@ import { showClipsMoveOnBoardId, showDetailsClipId, showHistoryDeleteConfirmationId, + showHistoryMultiDeleteConfirmationIds, + showKeyboardNavContextMenuClipId, + showKeyboardNavContextMenuHistoryId, showLargeViewClipId, showLargeViewHistoryId, showOrganizeLayout, @@ -120,7 +123,13 @@ import { } from '~/hooks/use-copypaste-history-item' import { useDebounce } from '~/hooks/use-debounce' import useDeleteConfirmationTimer from '~/hooks/use-delete-confirmation-items' +import useKeyboardDeleteConfirmation from '~/hooks/use-keyboard-delete-confirmation' import { useSignal } from '~/hooks/use-signal' +import { + specialCopiedItem, + specialPastedItem, + specialPastedItemCountDown, +} from '~/hooks/use-special-copypaste-history-item' import { ClipboardHistoryIconMenu, @@ -269,6 +278,38 @@ export default function ClipboardHistoryPage() { }, }) + const { showConfirmation: showConfirmationKeyboardDelete, keyboardItemIdDelete, resetTimer: resetKeyboardDeleteTimer } = + useKeyboardDeleteConfirmation({ + keyboardSelectedItemId: keyboardSelectedItemId, + selectedHistoryItems, + onConfirmedDelete: async () => { + if (keyboardSelectedItemId.value) { + // Calculate next selection before deletion + const currentIndex = clipboardHistory.findIndex( + item => item.historyId === keyboardSelectedItemId.value + ) + let nextSelectedId: UniqueIdentifier | null = null + if (currentIndex !== -1) { + if (currentIndex < clipboardHistory.length - 1) { + // Select next item + nextSelectedId = clipboardHistory[currentIndex + 1].historyId + } else if (currentIndex > 0) { + // Select previous item + nextSelectedId = clipboardHistory[currentIndex - 1].historyId + } + // If only one item, nextSelectedId remains null + } + + await deleteClipboardHistoryByIds({ + historyIds: [keyboardSelectedItemId.value], + }) + + // Update selection to the calculated next item + keyboardSelectedItemId.value = nextSelectedId + } + }, + }) + const isPinnedPanelHoverOpen = useMemo(() => { return isPinnedPanelKeepOpen.value || isPinnedPanelHovering.value }, [isPinnedPanelHovering.value, isPinnedPanelKeepOpen.value]) @@ -339,6 +380,18 @@ export default function ClipboardHistoryPage() { const pastedItemValue = useMemo(() => pastedItem, [pastedItem]) const copiedItemValue = useMemo(() => copiedItem, [copiedItem]) + const specialCopiedItemValue = useMemo( + () => specialCopiedItem.value, + [specialCopiedItem.value] + ) + const specialPastedItemValue = useMemo( + () => specialPastedItem.value, + [specialPastedItem.value] + ) + const specialPastingCountDown = useMemo( + () => specialPastedItemCountDown.value, + [specialPastedItemCountDown.value] + ) const clipboardHistory = hasSearchOrFilter ? foundClipboardHistory : allClipboardHistory @@ -436,13 +489,16 @@ export default function ClipboardHistoryPage() { currentNavigationContext.value === null) && keyboardSelectedItemId.value ) { + // Reset keyboard delete confirmation when copying + resetKeyboardDeleteTimer() setCopiedItem(keyboardSelectedItemId.value) } else if ( (currentNavigationContext.value === 'history' || currentNavigationContext.value === null) && clipboardHistory.length > 0 ) { - setCopiedItem(clipboardHistory[0]?.historyId) + // TODO: Fix this + // setCopiedItem(clipboardHistory[0]?.historyId) } currentNavigationContext.value = null keyboardSelectedItemId.value = null @@ -465,6 +521,9 @@ export default function ClipboardHistoryPage() { e => { e.preventDefault() + // Reset delete confirmation when navigating + showHistoryDeleteConfirmationId.value = null + if (keyboardSelectedBoardId.value) { const clipsOnBoard = clipItems .filter( @@ -627,10 +686,46 @@ export default function ClipboardHistoryPage() { } } + useHotkeys(['control+s'], e => { + if (hoveringHistoryRowId.value) { + setSelectHistoryItem(hoveringHistoryRowId.value) + } + }) + useHotkeys(['tab'], handleTabNavigation('forward'), { enabled: !shouldKeyboardNavigationBeDisabled.value, }) + useHotkeys( + ['space'], + () => { + if ( + currentNavigationContext.value === 'history' || + currentNavigationContext.value === null + ) { + if (keyboardSelectedItemId.value) { + // Reset keyboard delete confirmation when selecting + resetKeyboardDeleteTimer() + + setSelectHistoryItem(keyboardSelectedItemId.value) + const currentItemIndex = clipboardHistory.findIndex( + item => item.historyId === keyboardSelectedItemId.value + ) + const nextItem = clipboardHistory[currentItemIndex + 1] + if (nextItem) { + keyboardSelectedItemId.value = nextItem.historyId + if (showLargeViewHistoryId.value) { + showLargeViewHistoryId.value = nextItem.historyId + } + } + } + } + }, + { + enabled: !shouldKeyboardNavigationBeDisabled.value, + } + ) + useHotkeys(['shift+tab'], handleTabNavigation('backward'), { enabled: !shouldKeyboardNavigationBeDisabled.value, }) @@ -638,7 +733,27 @@ export default function ClipboardHistoryPage() { useHotkeys( 'esc', () => { + // Clear any delete timeout when escaping + if (deleteTimeoutRef.current) { + clearTimeout(deleteTimeoutRef.current) + deleteTimeoutRef.current = null + } + + // Reset delete confirmation on escape + showHistoryDeleteConfirmationId.value = null + + // Reset keyboard delete confirmation on escape + resetKeyboardDeleteTimer() + // Escape closes large view first, then performs normal escape behavior + if (showKeyboardNavContextMenuHistoryId.value) { + showKeyboardNavContextMenuHistoryId.value = null + } + + if (showKeyboardNavContextMenuClipId.value) { + showKeyboardNavContextMenuClipId.value = null + } + if (showLargeViewHistoryId.value) { showLargeViewHistoryId.value = null } else { @@ -654,6 +769,19 @@ export default function ClipboardHistoryPage() { ['arrowdown'], e => { e.preventDefault() + + // Clear any delete timeout when navigating away + if (deleteTimeoutRef.current) { + clearTimeout(deleteTimeoutRef.current) + deleteTimeoutRef.current = null + } + + // Reset delete confirmation when navigating to a different item + showHistoryDeleteConfirmationId.value = null + + // Reset keyboard delete confirmation when navigating + resetKeyboardDeleteTimer() + const currentItemIndex = clipboardHistory.findIndex( item => item.historyId === keyboardSelectedItemId.value ) @@ -677,6 +805,19 @@ export default function ClipboardHistoryPage() { ['arrowup'], e => { e.preventDefault() + + // Clear any delete timeout when navigating away + if (deleteTimeoutRef.current) { + clearTimeout(deleteTimeoutRef.current) + deleteTimeoutRef.current = null + } + + // Reset delete confirmation when navigating to a different item + showHistoryDeleteConfirmationId.value = null + + // Reset keyboard delete confirmation when navigating + resetKeyboardDeleteTimer() + if ( currentNavigationContext.value === 'history' || currentNavigationContext.value === null @@ -768,6 +909,179 @@ export default function ClipboardHistoryPage() { } ) + useHotkeys( + ['home'], + e => { + e.preventDefault() + if ( + currentNavigationContext.value === 'history' || + currentNavigationContext.value === null + ) { + // Navigate to first history item + if (clipboardHistory.length > 0) { + keyboardSelectedItemId.value = clipboardHistory[0].historyId + scrollToTopHistoryList() + if (showLargeViewHistoryId.value) { + showLargeViewHistoryId.value = clipboardHistory[0].historyId + } + } + } else if (currentNavigationContext.value === 'board') { + // Navigate to first clip in first board with clips + const boardsWithClips = clipItems + .filter(item => item.isBoard && item.tabId === currentTab) + .filter(board => + clipItems.some( + clip => + clip.isClip && clip.parentId === board.itemId && clip.tabId === currentTab + ) + ) + .sort((a, b) => a.orderNumber - b.orderNumber) + + if (boardsWithClips.length > 0) { + const firstBoard = boardsWithClips[0] + const firstBoardClips = clipItems + .filter( + item => + item.isClip && + item.parentId === firstBoard.itemId && + item.tabId === currentTab + ) + .sort((a, b) => a.orderNumber - b.orderNumber) + + if (firstBoardClips.length > 0) { + keyboardSelectedBoardId.value = firstBoard.itemId + keyboardSelectedClipId.value = firstBoardClips[0].itemId + currentBoardIndex.value = 0 + } + } + } + }, + { + enabled: !shouldKeyboardNavigationBeDisabled.value, + } + ) + + useHotkeys( + ['pageup'], + e => { + e.preventDefault() + if ( + currentNavigationContext.value === 'history' || + currentNavigationContext.value === null + ) { + // Move up by 5 items in history + const currentIndex = clipboardHistory.findIndex( + item => item.historyId === keyboardSelectedItemId.value + ) + const newIndex = Math.max(0, currentIndex - 5) + if (clipboardHistory[newIndex]) { + keyboardSelectedItemId.value = clipboardHistory[newIndex].historyId + if (showLargeViewHistoryId.value) { + showLargeViewHistoryId.value = clipboardHistory[newIndex].historyId + } + } + } else if ( + currentNavigationContext.value === 'board' && + keyboardSelectedBoardId.value + ) { + // Move up by 5 clips in current board + const clipsOnBoard = clipItems + .filter( + item => + item.isClip && + item.parentId === keyboardSelectedBoardId.value && + item.tabId === currentTab + ) + .sort((a, b) => a.orderNumber - b.orderNumber) + + const currentIndex = clipsOnBoard.findIndex( + clip => clip.itemId === keyboardSelectedClipId.value + ) + const newIndex = Math.max(0, currentIndex - 5) + if (clipsOnBoard[newIndex]) { + keyboardSelectedClipId.value = clipsOnBoard[newIndex].itemId + } + } + }, + { + enabled: !shouldKeyboardNavigationBeDisabled.value, + } + ) + + useHotkeys( + ['pagedown'], + e => { + e.preventDefault() + if ( + currentNavigationContext.value === 'history' || + currentNavigationContext.value === null + ) { + // Move down by 5 items in history + const currentIndex = clipboardHistory.findIndex( + item => item.historyId === keyboardSelectedItemId.value + ) + const newIndex = Math.min(clipboardHistory.length - 1, currentIndex + 5) + if (clipboardHistory[newIndex]) { + keyboardSelectedItemId.value = clipboardHistory[newIndex].historyId + if (showLargeViewHistoryId.value) { + showLargeViewHistoryId.value = clipboardHistory[newIndex].historyId + } + } + } else if ( + currentNavigationContext.value === 'board' && + keyboardSelectedBoardId.value + ) { + // Move down by 5 clips in current board + const clipsOnBoard = clipItems + .filter( + item => + item.isClip && + item.parentId === keyboardSelectedBoardId.value && + item.tabId === currentTab + ) + .sort((a, b) => a.orderNumber - b.orderNumber) + + const currentIndex = clipsOnBoard.findIndex( + clip => clip.itemId === keyboardSelectedClipId.value + ) + const newIndex = Math.min(clipsOnBoard.length - 1, currentIndex + 5) + if (clipsOnBoard[newIndex]) { + keyboardSelectedClipId.value = clipsOnBoard[newIndex].itemId + } + } + }, + { + enabled: !shouldKeyboardNavigationBeDisabled.value, + } + ) + + useHotkeys( + ['alt+arrowdown'], + e => { + e.preventDefault() + if ( + currentNavigationContext.value === 'history' || + currentNavigationContext.value === null + ) { + // Open context menu for selected history item + if (keyboardSelectedItemId.value) { + showKeyboardNavContextMenuHistoryId.value = keyboardSelectedItemId.value + } + } else if (currentNavigationContext.value === 'board') { + // Open context menu for selected clip item + if (keyboardSelectedClipId.value) { + showKeyboardNavContextMenuClipId.value = keyboardSelectedClipId.value + } + } + }, + { + enabled: !shouldKeyboardNavigationBeDisabled.value, + } + ) + + // Store timeout reference to clear it if needed + const deleteTimeoutRef = useRef(null) + useEffect(() => { const handleKeyDown = (e: KeyboardEvent) => { if (e.key === 'Control' || e.key === 'Meta') { @@ -777,6 +1091,11 @@ export default function ClipboardHistoryPage() { window.addEventListener('keydown', handleKeyDown) return () => { window.removeEventListener('keydown', handleKeyDown) + // Clean up delete timeout on unmount + if (deleteTimeoutRef.current) { + clearTimeout(deleteTimeoutRef.current) + deleteTimeoutRef.current = null + } } }, [ clipboardHistory, @@ -797,6 +1116,18 @@ export default function ClipboardHistoryPage() { } }, [currentNavigationContext.value, keyboardSelectedItemId.value, clipboardHistory]) + useEffect(() => { + if (keyboardSelectedItemId.value && listRef.current) { + const selectedIndex = clipboardHistory.findIndex( + item => item.historyId === keyboardSelectedItemId.value + ) + if (selectedIndex !== -1) { + // @ts-expect-error - scrollToItem is not in the types + listRef.current.scrollToItem?.(selectedIndex, 'smart') + } + } + }, [keyboardSelectedItemId.value, clipboardHistory]) + useEffect(() => { const listenToClipboardUnlisten = listen( 'clipboard://clipboard-monitor/update', @@ -1015,13 +1346,22 @@ export default function ClipboardHistoryPage() { const hasIsDeleting = (historyId: UniqueIdentifier) => { return ( - (showConfirmation && selectedHistoryItems.includes(historyId)) || + // Keyboard delete confirmation - only for the specific keyboard selected item + (showConfirmationKeyboardDelete && + historyId === keyboardItemIdDelete && + historyId === keyboardSelectedItemId.value) || + // Mouse delete confirmation - only when keyboard delete is NOT active + (showConfirmation && !showConfirmationKeyboardDelete && selectedHistoryItems.includes(historyId)) || + // Single item delete confirmation historyId === showHistoryDeleteConfirmationId.value || - (showConfirmation && historyId === hoveringHistoryIdDelete) || + // Hovering delete confirmation - only when keyboard delete is NOT active + (showConfirmation && !showConfirmationKeyboardDelete && historyId === hoveringHistoryIdDelete) || + // Drag over trash historyId === dragOverTrashId || (Boolean(dragOverTrashId) && Boolean(activeDragId) && selectedHistoryItems.includes(historyId)) || + // Menu deleting (isMenuDeleting && selectedHistoryItems.includes(historyId)) ) } @@ -1480,10 +1820,18 @@ export default function ClipboardHistoryPage() { pastingCountDown={ historyId === pastedItemValue ? pastingCountDown - : undefined + : historyId === specialPastedItemValue + ? specialPastingCountDown + : undefined + } + isPasted={ + historyId === pastedItemValue || + historyId === specialPastedItemValue + } + isCopied={ + historyId === copiedItemValue || + historyId === specialCopiedItemValue } - isPasted={historyId === pastedItemValue} - isCopied={historyId === copiedItemValue} isSaved={historyId === savingItem} setSavingItem={setSavingItem} isDeleting={hasIsDeleting(historyId)} @@ -1523,7 +1871,9 @@ export default function ClipboardHistoryPage() { isSingleClickToCopyPaste={ isSingleClickToCopyPaste } - historyPreviewLineLimit={historyPreviewLineLimit} + historyPreviewLineLimit={ + historyPreviewLineLimit + } /> ) @@ -1887,6 +2237,7 @@ export default function ClipboardHistoryPage() { className="pointer-events-auto rounded-full bg-slate-300 dark:bg-slate-600 hover:bg-slate-200 hover:dark:bg-slate-700" onClick={() => { scrollToTopHistoryList(true) + resetKeyboardNavigation() }} > @@ -2090,9 +2441,14 @@ export default function ClipboardHistoryPage() { pastingCountDown={ historyId === pastedItemValue ? pastingCountDown - : undefined + : historyId === specialPastedItemValue + ? specialPastingCountDown + : undefined + } + isPasted={ + historyId === pastedItemValue || + historyId === specialPastedItemValue } - isPasted={historyId === pastedItemValue} isKeyboardSelected={ (currentNavigationContext.value === 'history' || @@ -2100,7 +2456,10 @@ export default function ClipboardHistoryPage() { null) && historyId === keyboardSelectedItemId.value } - isCopied={historyId === copiedItemValue} + isCopied={ + historyId === copiedItemValue || + historyId === specialCopiedItemValue + } isSaved={historyId === savingItem} setSavingItem={setSavingItem} key={historyId} @@ -2150,7 +2509,9 @@ export default function ClipboardHistoryPage() { isSingleClickToCopyPaste={ isSingleClickToCopyPaste } - historyPreviewLineLimit={historyPreviewLineLimit} + historyPreviewLineLimit={ + historyPreviewLineLimit + } index={index} style={style} /> @@ -2443,10 +2804,19 @@ export default function ClipboardHistoryPage() { pastingCountDown={ inLargeViewItem.historyId === pastedItemValue ? pastingCountDown - : null + : inLargeViewItem.historyId === + specialPastedItemValue + ? specialPastingCountDown + : null + } + isPasted={ + inLargeViewItem.historyId === pastedItemValue || + inLargeViewItem.historyId === specialPastedItemValue + } + isCopied={ + inLargeViewItem.historyId === copiedItemValue || + inLargeViewItem.historyId === specialCopiedItemValue } - isPasted={inLargeViewItem.historyId === pastedItemValue} - isCopied={inLargeViewItem.historyId === copiedItemValue} isSaved={inLargeViewItem.historyId === savingItem} isMp3={ inLargeViewItem.isLink && diff --git a/packages/pastebar-app-ui/src/pages/settings/ClipboardHistorySettings.tsx b/packages/pastebar-app-ui/src/pages/settings/ClipboardHistorySettings.tsx index 9385fe11..2382f9d0 100644 --- a/packages/pastebar-app-ui/src/pages/settings/ClipboardHistorySettings.tsx +++ b/packages/pastebar-app-ui/src/pages/settings/ClipboardHistorySettings.tsx @@ -7,13 +7,19 @@ import { verticalListSortingStrategy, } from '@dnd-kit/sortable' import { CSS } from '@dnd-kit/utilities' -import { settingsStoreAtom, uiStoreAtom } from '~/store' +import { + DEFAULT_SPECIAL_PASTE_CATEGORIES, + DEFAULT_SPECIAL_PASTE_OPERATIONS, + settingsStoreAtom, + uiStoreAtom, +} from '~/store' import { useAtomValue } from 'jotai' -import { Grip } from 'lucide-react' +import { ChevronDown, Grip } from 'lucide-react' import { useTranslation } from 'react-i18next' import { Link } from 'react-router-dom' import AutoSize from 'react-virtualized-auto-sizer' +import { TEXT_TRANSFORMS, TRANSFORM_CATEGORIES } from '~/lib/text-transforms' import { arraysEqual, isStringArrayEmpty, @@ -25,6 +31,7 @@ import Spacer from '~/components/atoms/spacer' import SimpleBar from '~/components/libs/simplebar-react' import InputField from '~/components/molecules/input' import { + Badge, Box, Button, Card, @@ -32,6 +39,12 @@ import { CardHeader, CardTitle, CheckBoxFilter, + DropdownMenu, + DropdownMenuCheckboxItem, + DropdownMenuContent, + DropdownMenuLabel, + DropdownMenuSeparator, + DropdownMenuTrigger, Flex, Select, SelectContent, @@ -79,6 +92,224 @@ function SortableItem({ id, language }: SortableItemProps) { ) } +interface SortableCategoryItemProps { + categoryId: string + localCategoriesOrder: string[] + setLocalCategoriesOrder: (categories: string[]) => void +} +function SortableCategoryItem({ + categoryId, + localCategoriesOrder, + setLocalCategoriesOrder, +}: SortableCategoryItemProps) { + const { t } = useTranslation() + const { attributes, listeners, setNodeRef, transform, transition, isDragging } = + useSortable({ id: categoryId }) + + const { + enabledSpecialPasteOperations, + setEnabledSpecialPasteOperations, + setSpecialPasteCategoriesOrder, + } = useAtomValue(settingsStoreAtom) + + const category = TRANSFORM_CATEGORIES.find(c => c.id === categoryId) + if (!category) return null + + const isCategoryEnabled = localCategoriesOrder.includes(category.id) + + // Get all transforms in category (including from subcategories) + const allTransformsInCategory = category.subcategories + ? category.subcategories.flatMap(subcategory => subcategory.transforms) + : category.transforms || [] + + const enabledTransformsInCategory = allTransformsInCategory.filter(transform => + enabledSpecialPasteOperations.includes(transform.id) + ) + + const style = { + transform: CSS.Transform.toString(transform), + transition, + } + + return ( +
+ + {/* Category Header */} + + + + + {t(category.label, { ns: 'specialCopyPaste' })} + + + + + {enabledTransformsInCategory.length}/{allTransformsInCategory.length} + + { + if (checked) { + const newLocalOrder = localCategoriesOrder.includes(category.id) + ? localCategoriesOrder + : [...localCategoriesOrder, category.id] + setLocalCategoriesOrder(newLocalOrder) + setSpecialPasteCategoriesOrder(newLocalOrder) + + // Enable all transforms in the category (including from subcategories) + const allTransformIds = allTransformsInCategory.map(t => t.id) + const newOps = [ + ...new Set([...enabledSpecialPasteOperations, ...allTransformIds]), + ] + setEnabledSpecialPasteOperations(newOps) + } else { + const newLocalOrder = localCategoriesOrder.filter( + id => id !== category.id + ) + setLocalCategoriesOrder(newLocalOrder) + setSpecialPasteCategoriesOrder(newLocalOrder) + + const transformIds = allTransformsInCategory.map(t => t.id) + const newOps = enabledSpecialPasteOperations.filter( + op => !transformIds.includes(op) + ) + setEnabledSpecialPasteOperations(newOps) + } + }} + /> + + + + {/* Individual Transform Controls */} + {isCategoryEnabled && ( + + + + + + + + {t(category.label, { ns: 'specialCopyPaste' })}{' '} + {t('Operations', { ns: 'specialCopyPaste' })} + + + + {category.subcategories + ? // Handle categories with subcategories (like Format Converter) + category.subcategories.map(subcategory => ( +
+ + {t(subcategory.label, { ns: 'specialCopyPaste' })} + + {subcategory.transforms.map(transform => ( + { + e.preventDefault() + }} + onCheckedChange={checked => { + if (checked) { + setEnabledSpecialPasteOperations([ + ...enabledSpecialPasteOperations, + transform.id, + ]) + } else { + setEnabledSpecialPasteOperations( + enabledSpecialPasteOperations.filter( + op => op !== transform.id + ) + ) + } + }} + className="pl-6" + > + {t(transform.label, { ns: 'specialCopyPaste' })} + + ))} + {category.subcategories && + subcategory !== + category.subcategories[ + category.subcategories.length - 1 + ] && } +
+ )) + : // Handle categories with direct transforms + (category.transforms || []).map(transform => ( + { + e.preventDefault() + }} + onCheckedChange={checked => { + if (checked) { + setEnabledSpecialPasteOperations([ + ...enabledSpecialPasteOperations, + transform.id, + ]) + } else { + setEnabledSpecialPasteOperations( + enabledSpecialPasteOperations.filter( + op => op !== transform.id + ) + ) + } + }} + > + {t(transform.label, { ns: 'specialCopyPaste' })} + + ))} +
+
+
+
+ )} +
+
+ ) +} + export default function ClipboardHistorySettings() { const { isHistoryEnabled, @@ -131,6 +362,12 @@ export default function ClipboardHistorySettings() { setIsKeepPinnedOnClearEnabled, isKeepStarredOnClearEnabled, setIsKeepStarredOnClearEnabled, + isSpecialCopyPasteHistoryEnabled, + setIsSpecialCopyPasteHistoryEnabled, + enabledSpecialPasteOperations, + setEnabledSpecialPasteOperations, + specialPasteCategoriesOrder, + setSpecialPasteCategoriesOrder, isAppReady, CONST: { APP_DETECT_LANGUAGES_SUPPORTED: languageList }, } = useAtomValue(settingsStoreAtom) @@ -138,6 +375,19 @@ export default function ClipboardHistorySettings() { const { returnRoute } = useAtomValue(uiStoreAtom) const { t } = useTranslation() + useEffect(() => { + const id = window.location.hash.substring(1) + + if (id == null) { + return + } + + setTimeout(() => { + const releventDiv = document.getElementById(id) + releventDiv?.scrollIntoView({ behavior: 'smooth', block: 'start' }) + }, 400) + }, []) + const [exclusionListValue, setExclusionListValue] = useState('') const [exclusionAppListValue, setExclusionAppListValue] = useState('') const [autoMaskListValue, setAutoMaskListValue] = useState('') @@ -149,6 +399,7 @@ export default function ClipboardHistorySettings() { const debouncedAutoMaskListValue = useDebounce(autoMaskListValue, 300) const [prioritizedLanguages, setPrioritizedLanguages] = useState([]) + const [localCategoriesOrder, setLocalCategoriesOrder] = useState([]) useEffect(() => { if ( @@ -161,6 +412,45 @@ export default function ClipboardHistorySettings() { } }, [historyDetectLanguagesEnabledList, historyDetectLanguagesPrioritizedList]) + // Initialize local categories order from store + useEffect(() => { + if ( + Array.isArray(specialPasteCategoriesOrder) && + specialPasteCategoriesOrder.length > 0 + ) { + setLocalCategoriesOrder(specialPasteCategoriesOrder) + } else { + setLocalCategoriesOrder([...DEFAULT_SPECIAL_PASTE_CATEGORIES]) + } + }, [specialPasteCategoriesOrder]) + + // Show all categories, ordered by user preference with enabled ones first + const orderedCategories = (() => { + const enabled = + localCategoriesOrder.length > 0 + ? localCategoriesOrder + : [...DEFAULT_SPECIAL_PASTE_CATEGORIES] + + // Get all categories that exist but aren't in the enabled list + const allCategoryIds = [...DEFAULT_SPECIAL_PASTE_CATEGORIES] + const disabled = allCategoryIds.filter(id => !enabled.includes(id)) + + // Return enabled categories first, then disabled ones + return [...enabled, ...disabled] + })() + + console.log( + 'Component render - specialPasteCategoriesOrder:', + specialPasteCategoriesOrder, + typeof specialPasteCategoriesOrder, + Array.isArray(specialPasteCategoriesOrder) + ) + console.log( + 'Component render - orderedCategories:', + orderedCategories, + Array.isArray(orderedCategories) + ) + useEffect(() => { if (isAppReady) { setHistoryExclusionList(trimAndRemoveExtraNewlines(debouncedExclusionListValue)) @@ -627,7 +917,7 @@ export default function ClipboardHistorySettings() { - + + + + + + {t('Special Copy/Paste Operations', { ns: 'specialCopyPaste' })} + + { + setIsSpecialCopyPasteHistoryEnabled( + !isSpecialCopyPasteHistoryEnabled + ) + }} + /> + + + + {t( + 'Enable special text transformation options for clipboard history items', + { ns: 'specialCopyPaste' } + )} + + + {isSpecialCopyPasteHistoryEnabled && ( + <> + + {t( + 'Drag and drop category to prioritize its order in the special copy/paste menu.', + { ns: 'specialCopyPaste' } + )} + + { + const { active, over } = event + if (over?.id && active.id !== over?.id) { + setLocalCategoriesOrder(items => { + const activeId = active.id.toString() + const overId = over.id.toString() + + // Check if both active and over items are in the enabled list + if ( + items.includes(activeId) && + items.includes(overId) + ) { + const oldIndex = items.indexOf(activeId) + const newIndex = items.indexOf(overId) + const newArray = arrayMove(items, oldIndex, newIndex) + + // Update the store if array changed + if (!arraysEqual(items, newArray)) { + setSpecialPasteCategoriesOrder(newArray) + } + return newArray + } + return items + }) + } + }} + > + + + {orderedCategories + .map(categoryId => + TRANSFORM_CATEGORIES.find(c => c.id === categoryId) + ) + .filter(category => category) + .map(category => { + if (!category) return null + + return ( + + ) + })} + + + + + {/* Summary */} + + + {t('Enabled Operations', { ns: 'specialCopyPaste' })} ( + {enabledSpecialPasteOperations.length}): + + {enabledSpecialPasteOperations.length > 0 ? ( + + {enabledSpecialPasteOperations.map(opId => { + const transform = TEXT_TRANSFORMS.find( + t => t.id === opId + ) + return transform ? ( + + {t(transform.label, { ns: 'specialCopyPaste' })} + + ) : null + })} + + ) : ( + + {t('None', { ns: 'specialCopyPaste' })} + + )} + + {/* Reset Button */} + + + + + )} + + + + diff --git a/packages/pastebar-app-ui/src/store/constants.ts b/packages/pastebar-app-ui/src/store/constants.ts index 23746b65..5442f5b4 100644 --- a/packages/pastebar-app-ui/src/store/constants.ts +++ b/packages/pastebar-app-ui/src/store/constants.ts @@ -61,6 +61,77 @@ export const SCREEN_AUTO_LOCK_TIMES_IN_MINUTES = [5, 10, 15, 20, 30, 45, 60] export const RESET_TIME_DELAY_SECONDS = 60 export const APP_NAME = 'PasteBar' +// Default special copy/paste settings +export const DEFAULT_SPECIAL_PASTE_OPERATIONS = [ + // Text Case + 'upperCase', + 'lowerCase', + 'titleCase', + 'sentenceCase', + 'invertCase', + // Code Formatting + 'camelCase', + 'snakeCase', + 'kebabCase', + 'pascalCase', + 'jsonStringify', + // Whitespace & Lines + 'trimWhiteSpace', + 'removeLineFeeds', + 'addOneLineFeed', + 'addTwoLineFeeds', + 'removeExtraSpaces', + 'sortLinesAlphabetically', + 'removeDuplicateLines', + 'addLineNumbers', + // Encode/Decode + 'base64Encode', + 'base64Decode', + 'urlEncode', + 'urlDecode', + 'htmlEncode', + 'htmlDecode', + // Text Tools + 'reverseText', + 'addCurrentDateTime', + 'countCharacters', + 'countWords', + 'countLines', + 'countSentences', + // Format Converter - HTML + 'htmlToMarkdown', + 'htmlToReact', + 'htmlToReactComponent', + 'htmlToText', + // Format Converter - Markdown + 'markdownToHtml', + 'markdownToText', + // Format Converter - JSON + 'jsonToCsv', + 'jsonToYaml', + 'jsonToXml', + 'jsonToToml', + 'jsonToTable', + // Format Converter - CSV + 'csvToJson', + 'csvToTable', + // Format Converter - YAML + 'yamlToJson', + // Format Converter - XML + 'xmlToJson', + // Format Converter - TOML + 'tomlToJson', +] as const + +export const DEFAULT_SPECIAL_PASTE_CATEGORIES = [ + 'textCase', + 'codeFormatting', + 'whitespaceLines', + 'encodingSecurity', + 'textTools', + 'formatConverter', +] as const + window['PasteBar'] = { APP_UI_VERSION: APP_UI_VERSION, APP_VERSION: APP_VERSION, diff --git a/packages/pastebar-app-ui/src/store/settingsStore.ts b/packages/pastebar-app-ui/src/store/settingsStore.ts index d0b591dd..674e66b1 100644 --- a/packages/pastebar-app-ui/src/store/settingsStore.ts +++ b/packages/pastebar-app-ui/src/store/settingsStore.ts @@ -13,6 +13,10 @@ import { atomWithStore } from 'jotai-zustand' import { createStore } from 'zustand/vanilla' import DOMPurify from '../components/libs/dompurify' +import { + DEFAULT_SPECIAL_PASTE_CATEGORIES, + DEFAULT_SPECIAL_PASTE_OPERATIONS, +} from './constants' import { availableVersionBody, availableVersionDate, @@ -111,6 +115,9 @@ type Settings = { isDoubleClickTrayToOpenEnabledOnWindows: boolean isLeftClickTrayToOpenEnabledOnWindows: boolean isLeftClickTrayDisabledOnWindows: boolean + isSpecialCopyPasteHistoryEnabled: boolean + enabledSpecialPasteOperations: string[] + specialPasteCategoriesOrder: string[] } type Constants = { @@ -235,6 +242,9 @@ export interface SettingsStoreState { deleteGlobalTemplate: (templateId: string) => void toggleGlobalTemplateEnabledState: (templateId: string) => void setIsDoubleClickTrayToOpenEnabledOnWindows: (isEnabled: boolean) => void + setIsSpecialCopyPasteHistoryEnabled: (isEnabled: boolean) => void + setEnabledSpecialPasteOperations: (operations: string[]) => void + setSpecialPasteCategoriesOrder: (categories: string[]) => void } const initialState: SettingsStoreState & Settings = { @@ -322,9 +332,15 @@ const initialState: SettingsStoreState & Settings = { isDoubleClickTrayToOpenEnabledOnWindows: false, isLeftClickTrayToOpenEnabledOnWindows: false, isLeftClickTrayDisabledOnWindows: false, + isSpecialCopyPasteHistoryEnabled: true, + enabledSpecialPasteOperations: [...DEFAULT_SPECIAL_PASTE_OPERATIONS], + specialPasteCategoriesOrder: [...DEFAULT_SPECIAL_PASTE_CATEGORIES], setIsDoubleClickTrayToOpenEnabledOnWindows: () => {}, setIsLeftClickTrayToOpenEnabledOnWindows: () => {}, setIsLeftClickTrayDisabledOnWindows: () => {}, + setIsSpecialCopyPasteHistoryEnabled: () => {}, + setEnabledSpecialPasteOperations: () => {}, + setSpecialPasteCategoriesOrder: () => {}, setHasPinProtectedCollections: async () => {}, CONST: { APP_DETECT_LANGUAGES_SUPPORTED: [], @@ -524,6 +540,18 @@ export const settingsStore = createStore()((set, } } + if (name === 'enabledSpecialPasteOperations' && typeof value === 'string') { + return set(() => ({ + enabledSpecialPasteOperations: value.split(',').filter(Boolean), + })) + } + + if (name === 'specialPasteCategoriesOrder' && typeof value === 'string') { + return set(() => ({ + specialPasteCategoriesOrder: value.split(',').filter(Boolean), + })) + } + return set(() => ({ [name]: value })) } catch (e) { console.error(e) @@ -882,6 +910,15 @@ export const settingsStore = createStore()((set, setIsDoubleClickTrayToOpenEnabledOnWindows: async (isEnabled: boolean) => { return get().updateSetting('isDoubleClickTrayToOpenEnabledOnWindows', isEnabled) }, + setIsSpecialCopyPasteHistoryEnabled: async (isEnabled: boolean) => { + return get().updateSetting('isSpecialCopyPasteHistoryEnabled', isEnabled) + }, + setEnabledSpecialPasteOperations: async (operations: string[]) => { + return get().updateSetting('enabledSpecialPasteOperations', operations.join(',')) + }, + setSpecialPasteCategoriesOrder: async (categories: string[]) => { + return get().updateSetting('specialPasteCategoriesOrder', categories.join(',')) + }, setIsLeftClickTrayToOpenEnabledOnWindows: async (isEnabled: boolean) => { const result = await get().updateSetting( 'isLeftClickTrayToOpenEnabledOnWindows', diff --git a/packages/pastebar-app-ui/src/store/signalStore.ts b/packages/pastebar-app-ui/src/store/signalStore.ts index aaa7cf51..800387a2 100644 --- a/packages/pastebar-app-ui/src/store/signalStore.ts +++ b/packages/pastebar-app-ui/src/store/signalStore.ts @@ -37,8 +37,12 @@ export const resetTimeModalInterval = signal(null) // Clipboard History Signals export const showHistoryDeleteConfirmationId = signal(null) +export const showHistoryMultiDeleteConfirmationIds = signal( + null +) export const hoveringHistoryRowId = signal(null) export const showLargeViewHistoryId = signal(null) +export const showKeyboardNavContextMenuHistoryId = signal(null) export const isHistoryCopyPasting = signal(false) // Tabs Dashboard Signals @@ -55,6 +59,7 @@ export const forceSaveClipNameEditingError = signal(false) export const hoveringClipIdBoardId = signal(null) export const showDeleteClipConfirmationId = signal(null) export const contextMenuClipId = signal(null) +export const showKeyboardNavContextMenuClipId = signal(null) export const showDeleteImageClipConfirmationId = signal(null) export const isDeletingSelectedClips = signal(false) export const addSelectedTextToClipBoard = signal(null) @@ -145,7 +150,7 @@ export function resetMenuCreateOrEdit() { export function resetKeyboardNavigation() { currentNavigationContext.value = null keyboardSelectedItemId.value = null - hoveringHistoryRowId.value = null + // hoveringHistoryRowId.value = null keyboardSelectedBoardId.value = null keyboardSelectedClipId.value = null currentBoardIndex.value = 0 @@ -243,7 +248,10 @@ effect(() => { newMenuItemId.value || addSelectedTextToMenu.value || // Text selection states - addSelectedTextToClipBoard.value + addSelectedTextToClipBoard.value || + // Context menu states + showKeyboardNavContextMenuHistoryId.value || + showKeyboardNavContextMenuClipId.value ) { // console.log('Disabling keyboard navigation due to edit or delete actions') // Disable keyboard navigation when editing diff --git a/src-tauri/Cargo.lock b/src-tauri/Cargo.lock index d64e8307..e1a0b290 100644 --- a/src-tauri/Cargo.lock +++ b/src-tauri/Cargo.lock @@ -979,6 +979,12 @@ version = "0.4.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "6245d59a3e82a7fc217c5828a6692dbc6dfb63a0c8c90495621f7b9d79704a0e" +[[package]] +name = "convert_case" +version = "0.5.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "fb4a24b1aaf0fd0ce8b45161144d6f42cd91677fd5940fd431183eb023b3a2b8" + [[package]] name = "core-foundation" version = "0.9.4" @@ -1174,6 +1180,27 @@ dependencies = [ "syn 2.0.102", ] +[[package]] +name = "csv" +version = "1.3.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "acdc4883a9c96732e4733212c01447ebd805833b7275a73ca3ee080fd77afdaf" +dependencies = [ + "csv-core", + "itoa 1.0.15", + "ryu", + "serde", +] + +[[package]] +name = "csv-core" +version = "0.1.12" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "7d02f3b0da4c6504f86e9cd789d8dbafab48c2321be74e9987593de5a894d93d" +dependencies = [ + "memchr", +] + [[package]] name = "ctor" version = "0.2.9" @@ -1252,7 +1279,7 @@ version = "0.99.20" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "6edb4b64a43d977b8e99788fe3a04d483834fba1215a7e02caa415b626497f7f" dependencies = [ - "convert_case", + "convert_case 0.4.0", "proc-macro2", "quote", "rustc_version", @@ -2035,7 +2062,7 @@ version = "0.2.23" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "cba6ae63eb948698e300f645f87c70f76630d505f23b8907cf1e193ee85048c1" dependencies = [ - "unicode-width", + "unicode-width 0.2.1", ] [[package]] @@ -2370,6 +2397,43 @@ dependencies = [ "utf8-width", ] +[[package]] +name = "html-to-react" +version = "0.5.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "962bea78a5dac58a7e0f08853264d4e96aa0c2e562bb71436240a661631445b4" +dependencies = [ + "convert_case 0.5.0", + "lazy_static", +] + +[[package]] +name = "html2md" +version = "0.2.15" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "8cff9891f2e0d9048927fbdfc28b11bf378f6a93c7ba70b23d0fbee9af6071b4" +dependencies = [ + "html5ever 0.27.0", + "jni 0.19.0", + "lazy_static", + "markup5ever_rcdom", + "percent-encoding", + "regex", +] + +[[package]] +name = "html2text" +version = "0.6.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "74cda84f06c1cc83476f79ae8e2e892b626bdadafcb227baec54c918cadc18a0" +dependencies = [ + "html5ever 0.26.0", + "markup5ever 0.11.0", + "tendril", + "unicode-width 0.1.14", + "xml5ever 0.17.0", +] + [[package]] name = "html5ever" version = "0.26.0" @@ -2968,6 +3032,20 @@ dependencies = [ "system-deps 5.0.0", ] +[[package]] +name = "jni" +version = "0.19.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c6df18c2e3db7e453d3c6ac5b3e9d5182664d28788126d39b91f2d1e22b017ec" +dependencies = [ + "cesu8", + "combine", + "jni-sys", + "log", + "thiserror 1.0.69", + "walkdir", +] + [[package]] name = "jni" version = "0.20.0" @@ -3349,6 +3427,18 @@ dependencies = [ "tendril", ] +[[package]] +name = "markup5ever_rcdom" +version = "0.3.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "edaa21ab3701bfee5099ade5f7e1f84553fd19228cf332f13cd6e964bf59be18" +dependencies = [ + "html5ever 0.27.0", + "markup5ever 0.12.1", + "tendril", + "xml5ever 0.18.1", +] + [[package]] name = "matchers" version = "0.1.0" @@ -4180,6 +4270,7 @@ dependencies = [ "clokwerk", "cocoa 0.26.1", "colored_json", + "csv", "diesel", "diesel_migrations", "dirs 5.0.1", @@ -4187,6 +4278,9 @@ dependencies = [ "fns", "fs_extra", "html-escape", + "html-to-react", + "html2md", + "html2text", "http-cache-mokadeser", "http-cache-reqwest", "id3", @@ -4211,6 +4305,8 @@ dependencies = [ "once_cell", "opener", "platform-dirs", + "pulldown-cmark", + "quick-xml 0.31.0", "r2d2", "regex", "reqwest", @@ -4230,6 +4326,7 @@ dependencies = [ "tl", "tld", "tokio", + "toml 0.8.23", "tracing", "tracing-subscriber", "url", @@ -4668,6 +4765,25 @@ dependencies = [ "unicode-ident", ] +[[package]] +name = "pulldown-cmark" +version = "0.10.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "76979bea66e7875e7509c4ec5300112b316af87fa7a252ca91c448b32dfe3993" +dependencies = [ + "bitflags 2.9.1", + "getopts", + "memchr", + "pulldown-cmark-escape", + "unicase", +] + +[[package]] +name = "pulldown-cmark-escape" +version = "0.10.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "bd348ff538bc9caeda7ee8cad2d1d48236a1f443c1fa3913c6a02fe0043b1dd3" + [[package]] name = "qoi" version = "0.4.1" @@ -4695,6 +4811,16 @@ dependencies = [ "memchr", ] +[[package]] +name = "quick-xml" +version = "0.31.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1004a344b30a54e2ee58d66a71b32d2db2feb0a31f9a2d302bf0536f15de2a33" +dependencies = [ + "memchr", + "serde", +] + [[package]] name = "quick-xml" version = "0.32.0" @@ -5749,7 +5875,7 @@ dependencies = [ "gtk", "image 0.24.9", "instant", - "jni", + "jni 0.20.0", "lazy_static", "libappindicator", "libc", @@ -6509,6 +6635,12 @@ version = "1.12.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "f6ccf251212114b54433ec949fd6a7841275f9ada20dddd2f29e9ceea4501493" +[[package]] +name = "unicode-width" +version = "0.1.14" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "7dd6e30e90baa6f72411720665d41d89b9a3d039dc45b8faea1ddd07f617f6af" + [[package]] name = "unicode-width" version = "0.2.1" @@ -7783,6 +7915,28 @@ dependencies = [ "windows-sys 0.59.0", ] +[[package]] +name = "xml5ever" +version = "0.17.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "4034e1d05af98b51ad7214527730626f019682d797ba38b51689212118d8e650" +dependencies = [ + "log", + "mac", + "markup5ever 0.11.0", +] + +[[package]] +name = "xml5ever" +version = "0.18.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9bbb26405d8e919bc1547a5aa9abc95cbfa438f04844f5fdd9dc7596b748bf69" +dependencies = [ + "log", + "mac", + "markup5ever 0.12.1", +] + [[package]] name = "yansi" version = "0.5.1" diff --git a/src-tauri/Cargo.toml b/src-tauri/Cargo.toml index ebbf6961..0e97991f 100644 --- a/src-tauri/Cargo.toml +++ b/src-tauri/Cargo.toml @@ -29,6 +29,14 @@ http-cache-mokadeser = "0.1.3" # log = "0.4" serde_yaml = "0.9.0" scraper = "0.19.0" +# Format conversion dependencies +csv = "1.3" +html2text = "0.6" +html2md = "0.2" +html-to-react = "0.5.2" +pulldown-cmark = "0.10" +quick-xml = { version = "0.31", features = ["serialize"] } +toml = "0.8" jsonpath-rust = "0.4.0" ajson = "0.3.1" diff --git a/src-tauri/src/commands/format_converter_commands.rs b/src-tauri/src/commands/format_converter_commands.rs new file mode 100644 index 00000000..c6fff83e --- /dev/null +++ b/src-tauri/src/commands/format_converter_commands.rs @@ -0,0 +1,375 @@ +use csv::{Reader, Writer}; +use html2text; +use pulldown_cmark::{html, Options, Parser}; +use quick_xml::de::from_str as xml_from_str; +use quick_xml::se::to_string as xml_to_string; +use serde_json::{from_str as json_from_str, to_string_pretty, Value as JsonValue}; +use serde_yaml::{from_str as yaml_from_str, to_string as yaml_to_string, Value as YamlValue}; +use std::collections::HashMap; +use toml::{from_str as toml_from_str, to_string as toml_to_string, Value as TomlValue}; +// Import html_to_react crate +extern crate html_to_react; + +/// Convert CSV to JSON +fn csv_to_json(text: &str) -> Result { + if !text.contains(',') && !text.contains('\t') && !text.contains(';') { + return Err("Input does not appear to be valid CSV format (no delimiters found)".to_string()); + } + + let mut reader = Reader::from_reader(text.as_bytes()); + let mut records = Vec::new(); + + // Get headers + let headers = reader + .headers() + .map_err(|e| { + format!( + "Failed to read CSV headers - ensure the text is properly formatted CSV: {}", + e + ) + })? + .clone(); + + if headers.is_empty() { + return Err("CSV file appears to have no headers".to_string()); + } + + // Read all records + for result in reader.records() { + let record = result.map_err(|e| format!("Failed to read CSV record: {}", e))?; + let mut map = HashMap::new(); + + for (i, field) in record.iter().enumerate() { + if let Some(header) = headers.get(i) { + map.insert(header.to_string(), field.to_string()); + } + } + records.push(map); + } + + to_string_pretty(&records).map_err(|e| format!("Failed to serialize to JSON: {}", e)) +} + +/// Convert JSON to CSV +fn json_to_csv(text: &str) -> Result { + let json_data: JsonValue = + json_from_str(text).map_err(|e| format!("Invalid JSON format: {}", e))?; + + let mut output = Vec::new(); + { + let mut writer = Writer::from_writer(&mut output); + + match json_data { + JsonValue::Array(ref array) => { + if array.is_empty() { + return Ok(String::new()); + } + + // Extract headers from first object + if let Some(JsonValue::Object(first_obj)) = array.first() { + let headers: Vec = first_obj.keys().cloned().collect(); + writer + .write_record(&headers) + .map_err(|e| format!("Failed to write CSV headers: {}", e))?; + + // Write data rows + for item in array { + if let JsonValue::Object(obj) = item { + let row: Vec = headers + .iter() + .map(|header| { + obj + .get(header) + .map(|v| match v { + JsonValue::String(s) => s.clone(), + JsonValue::Number(n) => n.to_string(), + JsonValue::Bool(b) => b.to_string(), + JsonValue::Null => String::new(), + _ => v.to_string(), + }) + .unwrap_or_default() + }) + .collect(); + writer + .write_record(&row) + .map_err(|e| format!("Failed to write CSV row: {}", e))?; + } + } + } + } + _ => return Err("JSON must be an array of objects for CSV conversion".to_string()), + } + + writer + .flush() + .map_err(|e| format!("Failed to flush CSV writer: {}", e))?; + } // writer is dropped here, releasing the borrow + + String::from_utf8(output).map_err(|e| format!("Failed to convert CSV to string: {}", e)) +} + +/// Convert YAML to JSON +fn yaml_to_json(text: &str) -> Result { + let yaml_data: YamlValue = yaml_from_str(text).map_err(|e| format!("Invalid YAML: {}", e))?; + + to_string_pretty(&yaml_data).map_err(|e| format!("Failed to serialize to JSON: {}", e)) +} + +/// Convert JSON to YAML +fn json_to_yaml(text: &str) -> Result { + let json_data: JsonValue = json_from_str(text).map_err(|e| format!("Invalid JSON: {}", e))?; + + yaml_to_string(&json_data).map_err(|e| format!("Failed to serialize to YAML: {}", e)) +} + +/// Convert Markdown to HTML +fn markdown_to_html(text: &str) -> Result { + let mut options = Options::empty(); + options.insert(Options::ENABLE_STRIKETHROUGH); + options.insert(Options::ENABLE_TABLES); + options.insert(Options::ENABLE_FOOTNOTES); + options.insert(Options::ENABLE_TASKLISTS); + + let parser = Parser::new_ext(text, options); + let mut html_output = String::new(); + html::push_html(&mut html_output, parser); + Ok(html_output) +} + +/// Convert HTML to Markdown +fn html_to_markdown(text: &str) -> Result { + // Use html2md for better HTML to Markdown conversion + Ok(html2md::parse_html(text)) +} + +/// Convert HTML to plain text +fn html_to_text(text: &str) -> Result { + Ok(html2text::from_read(text.as_bytes(), text.len())) +} + +/// Convert Markdown to plain text +fn markdown_to_text(text: &str) -> Result { + // First convert markdown to HTML, then HTML to text + let html = markdown_to_html(text)?; + html_to_text(&html) +} + +// Convert HTML to React Component (JSX) +fn html_to_react_components(text: &str) -> Result { + // Use html_to_react crate to convert HTML to React JSX + let component = html_to_react::convert_to_react(text.to_string(), "MyComponent".to_string()); + Ok(component) +} + +/// Convert HTML to React JSX with comprehensive HTML to JSX conversion +fn convert_html_to_react_jsx(text: &str) -> Result { + // Comprehensive HTML to JSX converter with support for: + // - HTML attributes to JSX attributes (class -> className, for -> htmlFor, etc.) + // - Self-closing tags + // - HTML comments to JSX comments + // - Boolean attributes + // - CSS style properties to camelCase + let jsx = html_to_react::convert_props_react(text.to_string()); + + Ok(jsx) +} + +/// Convert XML to JSON +fn xml_to_json(text: &str) -> Result { + let xml_data: JsonValue = xml_from_str(text).map_err(|e| format!("Invalid XML: {}", e))?; + + to_string_pretty(&xml_data).map_err(|e| format!("Failed to serialize to JSON: {}", e)) +} + +/// Convert JSON to XML +fn json_to_xml(text: &str) -> Result { + let json_data: JsonValue = json_from_str(text).map_err(|e| format!("Invalid JSON: {}", e))?; + + xml_to_string(&json_data).map_err(|e| format!("Failed to serialize to XML: {}", e)) +} + +/// Convert TOML to JSON +fn toml_to_json(text: &str) -> Result { + let toml_data: TomlValue = toml_from_str(text).map_err(|e| format!("Invalid TOML: {}", e))?; + + to_string_pretty(&toml_data).map_err(|e| format!("Failed to serialize to JSON: {}", e)) +} + +/// Convert JSON to TOML +fn json_to_toml(text: &str) -> Result { + let json_data: JsonValue = json_from_str(text).map_err(|e| format!("Invalid JSON: {}", e))?; + + // Convert JsonValue to TomlValue + let toml_data = json_to_toml_value(json_data)?; + toml_to_string(&toml_data).map_err(|e| format!("Failed to serialize to TOML: {}", e)) +} + +/// Helper to convert JsonValue to TomlValue +fn json_to_toml_value(json: JsonValue) -> Result { + match json { + JsonValue::Null => Ok(TomlValue::String("".to_string())), + JsonValue::Bool(b) => Ok(TomlValue::Boolean(b)), + JsonValue::Number(n) => { + if let Some(i) = n.as_i64() { + Ok(TomlValue::Integer(i)) + } else if let Some(f) = n.as_f64() { + Ok(TomlValue::Float(f)) + } else { + Err("Invalid number format".to_string()) + } + } + JsonValue::String(s) => Ok(TomlValue::String(s)), + JsonValue::Array(arr) => { + let mut toml_array = Vec::new(); + for item in arr { + toml_array.push(json_to_toml_value(item)?); + } + Ok(TomlValue::Array(toml_array)) + } + JsonValue::Object(obj) => { + let mut toml_table = toml::value::Table::new(); + for (key, value) in obj { + toml_table.insert(key, json_to_toml_value(value)?); + } + Ok(TomlValue::Table(toml_table)) + } + } +} + +/// Convert CSV to Markdown table +fn csv_to_table(text: &str) -> Result { + let mut reader = Reader::from_reader(text.as_bytes()); + let mut markdown = String::new(); + + // Get headers + let headers = reader + .headers() + .map_err(|e| format!("Failed to read CSV headers: {}", e))?; + + // Write header row + markdown.push('|'); + for header in headers.iter() { + markdown.push(' '); + markdown.push_str(header); + markdown.push_str(" |"); + } + markdown.push('\n'); + + // Write separator row + markdown.push('|'); + for _ in headers.iter() { + markdown.push_str(" --- |"); + } + markdown.push('\n'); + + // Write data rows + for result in reader.records() { + let record = result.map_err(|e| format!("Failed to read CSV record: {}", e))?; + markdown.push('|'); + for field in record.iter() { + markdown.push(' '); + markdown.push_str(field); + markdown.push_str(" |"); + } + markdown.push('\n'); + } + + Ok(markdown) +} + +/// Convert JSON to Markdown table +fn json_to_table(text: &str) -> Result { + let json_data: JsonValue = json_from_str(text).map_err(|e| format!("Invalid JSON: {}", e))?; + + match json_data { + JsonValue::Array(ref array) => { + if array.is_empty() { + return Ok(String::new()); + } + + let mut markdown = String::new(); + + // Extract headers from first object + if let Some(JsonValue::Object(first_obj)) = array.first() { + let headers: Vec = first_obj.keys().cloned().collect(); + + // Write header row + markdown.push('|'); + for header in &headers { + markdown.push(' '); + markdown.push_str(header); + markdown.push_str(" |"); + } + markdown.push('\n'); + + // Write separator row + markdown.push('|'); + for _ in &headers { + markdown.push_str(" --- |"); + } + markdown.push('\n'); + + // Write data rows + for item in array { + if let JsonValue::Object(obj) = item { + markdown.push('|'); + for header in &headers { + markdown.push(' '); + if let Some(value) = obj.get(header) { + let cell_value = match value { + JsonValue::String(s) => s.clone(), + JsonValue::Number(n) => n.to_string(), + JsonValue::Bool(b) => b.to_string(), + JsonValue::Null => String::new(), + _ => value.to_string(), + }; + markdown.push_str(&cell_value); + } + markdown.push_str(" |"); + } + markdown.push('\n'); + } + } + } + + Ok(markdown) + } + _ => Err("JSON must be an array of objects for table conversion".to_string()), + } +} + +/// Main format converter command +#[tauri::command] +pub async fn format_convert(text: String, conversion_type: String) -> Result { + // Validate input + if text.trim().is_empty() { + return Err("Input text cannot be empty".to_string()); + } + + // Log the conversion attempt for debugging + // eprintln!( + // "Converting {} with type: {}", + // text.chars().take(50).collect::(), + // conversion_type + // ); + + match conversion_type.as_str() { + "csv_to_json" => csv_to_json(&text).map_err(|e| format!("CSV to JSON conversion failed: {}", e)), + "json_to_csv" => json_to_csv(&text).map_err(|e| format!("JSON to CSV conversion failed: {}", e)), + "yaml_to_json" => yaml_to_json(&text).map_err(|e| format!("YAML to JSON conversion failed: {}", e)), + "json_to_yaml" => json_to_yaml(&text).map_err(|e| format!("JSON to YAML conversion failed: {}", e)), + "markdown_to_html" => markdown_to_html(&text).map_err(|e| format!("Markdown to HTML conversion failed: {}", e)), + "html_to_markdown" => html_to_markdown(&text).map_err(|e| format!("HTML to Markdown conversion failed: {}", e)), + "html_to_react_components" => html_to_react_components(&text).map_err(|e| format!("HTML to React Component conversion failed: {}", e)), + "html_to_text" => html_to_text(&text).map_err(|e| format!("HTML to Text conversion failed: {}", e)), + "markdown_to_text" => markdown_to_text(&text).map_err(|e| format!("Markdown to Text conversion failed: {}", e)), + "html_to_react" => convert_html_to_react_jsx(&text).map_err(|e| format!("HTML to React JSX conversion failed: {}", e)), + "xml_to_json" => xml_to_json(&text).map_err(|e| format!("XML to JSON conversion failed: {}", e)), + "json_to_xml" => json_to_xml(&text).map_err(|e| format!("JSON to XML conversion failed: {}", e)), + "toml_to_json" => toml_to_json(&text).map_err(|e| format!("TOML to JSON conversion failed: {}", e)), + "json_to_toml" => json_to_toml(&text).map_err(|e| format!("JSON to TOML conversion failed: {}", e)), + "csv_to_table" => csv_to_table(&text).map_err(|e| format!("CSV to Table conversion failed: {}", e)), + "json_to_table" => json_to_table(&text).map_err(|e| format!("JSON to Table conversion failed: {}", e)), + _ => Err(format!("Unsupported conversion type: '{}'. Available types: csv_to_json, json_to_csv, yaml_to_json, json_to_yaml, markdown_to_html, html_to_markdown, html_to_text, markdown_to_text, html_to_react, xml_to_json, json_to_xml, toml_to_json, json_to_toml, csv_to_table, json_to_table", conversion_type)), + } +} diff --git a/src-tauri/src/commands/mod.rs b/src-tauri/src/commands/mod.rs index 2ef13766..903d81f0 100644 --- a/src-tauri/src/commands/mod.rs +++ b/src-tauri/src/commands/mod.rs @@ -2,6 +2,7 @@ pub(crate) mod backup_restore_commands; pub(crate) mod clipboard_commands; pub(crate) mod collections_commands; pub(crate) mod download_update; +pub(crate) mod format_converter_commands; pub(crate) mod history_commands; pub(crate) mod items_commands; pub(crate) mod link_metadata_commands; diff --git a/src-tauri/src/main.rs b/src-tauri/src/main.rs index 204d573a..1640d9ce 100644 --- a/src-tauri/src/main.rs +++ b/src-tauri/src/main.rs @@ -56,6 +56,7 @@ use commands::backup_restore_commands; use commands::clipboard_commands; use commands::collections_commands; use commands::download_update; +use commands::format_converter_commands; use commands::history_commands; use commands::items_commands; use commands::link_metadata_commands; @@ -188,6 +189,12 @@ fn update_left_click_tray_env(is_toggle_enabled: bool, is_disabled: bool) -> Res Ok(()) } +#[cfg(target_os = "macos")] +#[tauri::command] +fn update_left_click_tray_env(is_toggle_enabled: bool, is_disabled: bool) -> Result<(), String> { + Ok(()) +} + #[tauri::command] fn is_autostart_enabled() -> Result { let current_exe = current_exe().unwrap(); @@ -1380,6 +1387,7 @@ async fn main() { user_settings_command::cmd_get_setting, user_settings_command::cmd_set_setting, user_settings_command::cmd_remove_setting, + format_converter_commands::format_convert, open_osx_accessibility_preferences, check_osx_accessibility_preferences, open_path_or_app,