diff --git a/README.md b/README.md index bed7a40..9cca9ec 100644 --- a/README.md +++ b/README.md @@ -14,6 +14,7 @@ Version Platform License + Ask DeepWiki

> 🍎 **macOS support is currently under active development.** Some features may be limited or unavailable on macOS. diff --git a/kobar-settings.json b/kobar-settings.json index cc9b80b..4494afb 100644 --- a/kobar-settings.json +++ b/kobar-settings.json @@ -1,5 +1,5 @@ { - "version": "1.0.26", + "version": "1.0.27", "buildStandard": true, "buildAppx": true, "enableAutoUpdate": true, diff --git a/package-lock.json b/package-lock.json index c138da7..e689308 100644 --- a/package-lock.json +++ b/package-lock.json @@ -1,13 +1,20 @@ { "name": "kobar", - "version": "1.0.26", + "version": "1.0.27", "lockfileVersion": 3, "requires": true, "packages": { "": { "name": "kobar", - "version": "1.0.26", - "dependencies": { + "version": "1.0.27", + "dependencies": { + "@editorjs/code": "^2.9.4", + "@editorjs/delimiter": "^1.4.2", + "@editorjs/editorjs": "^2.31.6", + "@editorjs/header": "^2.8.9", + "@editorjs/image": "^2.10.3", + "@editorjs/list": "^2.0.9", + "@editorjs/quote": "^2.7.6", "@floating-ui/dom": "^1.7.6", "@tiptap/extension-image": "^3.20.1", "@tiptap/extension-placeholder": "^3.20.1", @@ -24,6 +31,7 @@ "emoji-picker-react": "^4.18.0", "express": "^5.2.1", "googleapis": "^171.4.0", + "jsdom": "^29.1.1", "konva": "^10.2.3", "material-symbols": "^0.44.9", "node-machine-id": "^1.1.12", @@ -65,6 +73,53 @@ "@coooookies/windows-smtc-monitor": "^1.0.12" } }, + "node_modules/@asamuzakjp/css-color": { + "version": "5.1.11", + "resolved": "https://registry.npmjs.org/@asamuzakjp/css-color/-/css-color-5.1.11.tgz", + "integrity": "sha512-KVw6qIiCTUQhByfTd78h2yD1/00waTmm9uy/R7Ck/ctUyAPj+AEDLkQIdJW0T8+qGgj3j5bpNKK7Q3G+LedJWg==", + "license": "MIT", + "dependencies": { + "@asamuzakjp/generational-cache": "^1.0.1", + "@csstools/css-calc": "^3.2.0", + "@csstools/css-color-parser": "^4.1.0", + "@csstools/css-parser-algorithms": "^4.0.0", + "@csstools/css-tokenizer": "^4.0.0" + }, + "engines": { + "node": "^20.19.0 || ^22.12.0 || >=24.0.0" + } + }, + "node_modules/@asamuzakjp/dom-selector": { + "version": "7.1.1", + "resolved": "https://registry.npmjs.org/@asamuzakjp/dom-selector/-/dom-selector-7.1.1.tgz", + "integrity": "sha512-67RZDnYRc8H/8MLDgQCDE//zoqVFwajkepHZgmXrbwybzXOEwOWGPYGmALYl9J2DOLfFPPs6kKCqmbzV895hTQ==", + "license": "MIT", + "dependencies": { + "@asamuzakjp/generational-cache": "^1.0.1", + "@asamuzakjp/nwsapi": "^2.3.9", + "bidi-js": "^1.0.3", + "css-tree": "^3.2.1", + "is-potential-custom-element-name": "^1.0.1" + }, + "engines": { + "node": "^20.19.0 || ^22.12.0 || >=24.0.0" + } + }, + "node_modules/@asamuzakjp/generational-cache": { + "version": "1.0.1", + "resolved": "https://registry.npmjs.org/@asamuzakjp/generational-cache/-/generational-cache-1.0.1.tgz", + "integrity": "sha512-wajfB8KqzMCN2KGNFdLkReeHncd0AslUSrvHVvvYWuU8ghncRJoA50kT3zP9MVL0+9g4/67H+cdvBskj9THPzg==", + "license": "MIT", + "engines": { + "node": "^20.19.0 || ^22.12.0 || >=24.0.0" + } + }, + "node_modules/@asamuzakjp/nwsapi": { + "version": "2.3.9", + "resolved": "https://registry.npmjs.org/@asamuzakjp/nwsapi/-/nwsapi-2.3.9.tgz", + "integrity": "sha512-n8GuYSrI9bF7FFZ/SjhwevlHc8xaVlb/7HmHelnc/PZXBD2ZR49NnN9sMMuDdEGPeeRQ5d0hqlSlEpgCX3Wl0Q==", + "license": "MIT" + }, "node_modules/@babel/code-frame": { "version": "7.29.0", "resolved": "https://registry.npmjs.org/@babel/code-frame/-/code-frame-7.29.0.tgz", @@ -357,6 +412,24 @@ "node": ">=6.9.0" } }, + "node_modules/@bramus/specificity": { + "version": "2.4.2", + "resolved": "https://registry.npmjs.org/@bramus/specificity/-/specificity-2.4.2.tgz", + "integrity": "sha512-ctxtJ/eA+t+6q2++vj5j7FYX3nRu311q1wfYH3xjlLOsczhlhxAg2FWNUXhpGvAw3BWo1xBcvOV6/YLc2r5FJw==", + "license": "MIT", + "dependencies": { + "css-tree": "^3.0.0" + }, + "bin": { + "specificity": "bin/cli.js" + } + }, + "node_modules/@codexteam/icons": { + "version": "0.3.3", + "resolved": "https://registry.npmjs.org/@codexteam/icons/-/icons-0.3.3.tgz", + "integrity": "sha512-cp7mkZPgmBuSxigTm3Vb+DtVHYeX7qXfQd7o05vcLD8Ag5WvRlol2QSn5P10k0CDAJwmkH9nQGQLBycErS9lsQ==", + "license": "MIT" + }, "node_modules/@coooookies/windows-smtc-monitor": { "version": "1.0.12", "resolved": "https://registry.npmjs.org/@coooookies/windows-smtc-monitor/-/windows-smtc-monitor-1.0.12.tgz", @@ -420,6 +493,142 @@ "node": ">=16.0.0" } }, + "node_modules/@csstools/color-helpers": { + "version": "6.0.2", + "resolved": "https://registry.npmjs.org/@csstools/color-helpers/-/color-helpers-6.0.2.tgz", + "integrity": "sha512-LMGQLS9EuADloEFkcTBR3BwV/CGHV7zyDxVRtVDTwdI2Ca4it0CCVTT9wCkxSgokjE5Ho41hEPgb8OEUwoXr6Q==", + "funding": [ + { + "type": "github", + "url": "https://github.com/sponsors/csstools" + }, + { + "type": "opencollective", + "url": "https://opencollective.com/csstools" + } + ], + "license": "MIT-0", + "engines": { + "node": ">=20.19.0" + } + }, + "node_modules/@csstools/css-calc": { + "version": "3.2.1", + "resolved": "https://registry.npmjs.org/@csstools/css-calc/-/css-calc-3.2.1.tgz", + "integrity": "sha512-DtdHlgXh5ZkA43cwBcAm+huzgJiwx3ZTWVjBs94kwz2xKqSimDA3lBgCjphYgwgVUMWatSM0pDd8TILB1yrVVg==", + "funding": [ + { + "type": "github", + "url": "https://github.com/sponsors/csstools" + }, + { + "type": "opencollective", + "url": "https://opencollective.com/csstools" + } + ], + "license": "MIT", + "engines": { + "node": ">=20.19.0" + }, + "peerDependencies": { + "@csstools/css-parser-algorithms": "^4.0.0", + "@csstools/css-tokenizer": "^4.0.0" + } + }, + "node_modules/@csstools/css-color-parser": { + "version": "4.1.1", + "resolved": "https://registry.npmjs.org/@csstools/css-color-parser/-/css-color-parser-4.1.1.tgz", + "integrity": "sha512-eZ5XOtyhK+mggRafYUWzA0tvaYOFgdY8AkgQiCJF9qNAePnUo/zmsqqYubBBb3sQ8uNUaSKTY9s9klfRaAXL0g==", + "funding": [ + { + "type": "github", + "url": "https://github.com/sponsors/csstools" + }, + { + "type": "opencollective", + "url": "https://opencollective.com/csstools" + } + ], + "license": "MIT", + "dependencies": { + "@csstools/color-helpers": "^6.0.2", + "@csstools/css-calc": "^3.2.1" + }, + "engines": { + "node": ">=20.19.0" + }, + "peerDependencies": { + "@csstools/css-parser-algorithms": "^4.0.0", + "@csstools/css-tokenizer": "^4.0.0" + } + }, + "node_modules/@csstools/css-parser-algorithms": { + "version": "4.0.0", + "resolved": "https://registry.npmjs.org/@csstools/css-parser-algorithms/-/css-parser-algorithms-4.0.0.tgz", + "integrity": "sha512-+B87qS7fIG3L5h3qwJ/IFbjoVoOe/bpOdh9hAjXbvx0o8ImEmUsGXN0inFOnk2ChCFgqkkGFQ+TpM5rbhkKe4w==", + "funding": [ + { + "type": "github", + "url": "https://github.com/sponsors/csstools" + }, + { + "type": "opencollective", + "url": "https://opencollective.com/csstools" + } + ], + "license": "MIT", + "peer": true, + "engines": { + "node": ">=20.19.0" + }, + "peerDependencies": { + "@csstools/css-tokenizer": "^4.0.0" + } + }, + "node_modules/@csstools/css-syntax-patches-for-csstree": { + "version": "1.1.4", + "resolved": "https://registry.npmjs.org/@csstools/css-syntax-patches-for-csstree/-/css-syntax-patches-for-csstree-1.1.4.tgz", + "integrity": "sha512-wgsqt92b7C7tQhIdPNxj0n9zuUbQlvAuI1exyzeNrOKOi62SD7ren8zqszmpVREjAOqg8cD2FqYhQfAuKjk4sw==", + "funding": [ + { + "type": "github", + "url": "https://github.com/sponsors/csstools" + }, + { + "type": "opencollective", + "url": "https://opencollective.com/csstools" + } + ], + "license": "MIT-0", + "peerDependencies": { + "css-tree": "^3.2.1" + }, + "peerDependenciesMeta": { + "css-tree": { + "optional": true + } + } + }, + "node_modules/@csstools/css-tokenizer": { + "version": "4.0.0", + "resolved": "https://registry.npmjs.org/@csstools/css-tokenizer/-/css-tokenizer-4.0.0.tgz", + "integrity": "sha512-QxULHAm7cNu72w97JUNCBFODFaXpbDg+dP8b/oWFAZ2MTRppA3U00Y2L1HqaS4J6yBqxwa/Y3nMBaxVKbB/NsA==", + "funding": [ + { + "type": "github", + "url": "https://github.com/sponsors/csstools" + }, + { + "type": "opencollective", + "url": "https://opencollective.com/csstools" + } + ], + "license": "MIT", + "peer": true, + "engines": { + "node": ">=20.19.0" + } + }, "node_modules/@develar/schema-utils": { "version": "2.6.5", "resolved": "https://registry.npmjs.org/@develar/schema-utils/-/schema-utils-2.6.5.tgz", @@ -438,6 +647,121 @@ "url": "https://opencollective.com/webpack" } }, + "node_modules/@editorjs/caret": { + "version": "1.1.0", + "resolved": "https://registry.npmjs.org/@editorjs/caret/-/caret-1.1.0.tgz", + "integrity": "sha512-dzUjrPV7mtqM0HR/7IwT1/1PfyMxUfe5MRXo9joFmBRDIcrQ5ikbqVxSL9+Hy5tQ+BUWBwqLUgX1UKTJylWW9Q==", + "license": "MIT", + "dependencies": { + "@editorjs/dom": "^1.1.0" + } + }, + "node_modules/@editorjs/code": { + "version": "2.9.4", + "resolved": "https://registry.npmjs.org/@editorjs/code/-/code-2.9.4.tgz", + "integrity": "sha512-c0zyWodNqjL/0WI67sZvACIOFU9IAHG0UeeIpjss8pZGGNBum+UWkh7nKULK0SYvaOrdPdlWWqjuFU1TFA5jUA==", + "license": "MIT", + "dependencies": { + "@codexteam/icons": "^0.3.2" + } + }, + "node_modules/@editorjs/delimiter": { + "version": "1.4.2", + "resolved": "https://registry.npmjs.org/@editorjs/delimiter/-/delimiter-1.4.2.tgz", + "integrity": "sha512-S8q2LpeYdYkVShLp7K8c4HLthDHBevLw+sT+iO0+SH0oMvFmld9SUon3DFzMQ2gG07EOdZGRZ958+sVxyvFjZw==", + "license": "MIT", + "dependencies": { + "@codexteam/icons": "^0.3.2" + } + }, + "node_modules/@editorjs/dom": { + "version": "1.1.0", + "resolved": "https://registry.npmjs.org/@editorjs/dom/-/dom-1.1.0.tgz", + "integrity": "sha512-aH8OeS3DXCDG2WP6yt/uS6Z9PstrA3Re930boIilxtMdF7Ht+pKJrq3OJnd7OCMPVO/y7UOAmtHNesVx6v4K2g==", + "license": "MIT", + "dependencies": { + "@editorjs/helpers": "^1.2.0" + } + }, + "node_modules/@editorjs/editorjs": { + "version": "2.31.6", + "resolved": "https://registry.npmjs.org/@editorjs/editorjs/-/editorjs-2.31.6.tgz", + "integrity": "sha512-j2nxuFCD3DnoJDUD0hPO5tirpuQ6sWmmuHATl3EI7fG1it3Ml7jk0WJtu3E+TRLBd7zfNeJhDThbP3LnBEn2GA==", + "license": "Apache-2.0", + "dependencies": { + "@editorjs/caret": "^1.0.1", + "codex-notifier": "^1.1.2", + "codex-tooltip": "^1.0.5" + } + }, + "node_modules/@editorjs/header": { + "version": "2.8.9", + "resolved": "https://registry.npmjs.org/@editorjs/header/-/header-2.8.9.tgz", + "integrity": "sha512-eBL9/PLDKiE9ADZycmgps3PLz9Lw8w6rPFXr0mfHuhHQI/ynP/q1vLpyruBrRCflTLD2Dlj5nYPoJjL5mP/MFw==", + "license": "MIT", + "dependencies": { + "@codexteam/icons": "^0.0.5", + "@editorjs/editorjs": "^2.30.7" + } + }, + "node_modules/@editorjs/header/node_modules/@codexteam/icons": { + "version": "0.0.5", + "resolved": "https://registry.npmjs.org/@codexteam/icons/-/icons-0.0.5.tgz", + "integrity": "sha512-s6H2KXhLz2rgbMZSkRm8dsMJvyUNZsEjxobBEg9ztdrb1B2H3pEzY6iTwI4XUPJWJ3c3qRKwV4TrO3J5jUdoQA==", + "license": "MIT" + }, + "node_modules/@editorjs/helpers": { + "version": "1.2.2", + "resolved": "https://registry.npmjs.org/@editorjs/helpers/-/helpers-1.2.2.tgz", + "integrity": "sha512-saDNARvY3xd70H4KE0eBq2hlV0Z7SpfI1/1iVqyVt5PqhlEOIR5rWJFH4c63BHTa6BfiR/+PMl1n7X/X7SlptQ==", + "license": "MIT", + "dependencies": { + "codex-tooltip": "^1.0.6" + } + }, + "node_modules/@editorjs/image": { + "version": "2.10.3", + "resolved": "https://registry.npmjs.org/@editorjs/image/-/image-2.10.3.tgz", + "integrity": "sha512-ekCsGICZOIdghF/U2T34H7CItqaWAoJDXbkRD+x8l/LIo/7Ozf7KovYm21qz+CluArgV4RurVFHqwlz+O0vfJA==", + "license": "MIT", + "dependencies": { + "@codexteam/icons": "^0.3.0" + } + }, + "node_modules/@editorjs/list": { + "version": "2.0.9", + "resolved": "https://registry.npmjs.org/@editorjs/list/-/list-2.0.9.tgz", + "integrity": "sha512-rUTgDSt5wygD3Dp24bNyp6vvye/Xf4UWju0ZuvWeP13Z4cu2z1Jb5JFSTEhCou72XUGuf4xVhtsd8cm/bwUS1g==", + "license": "MIT", + "dependencies": { + "@codexteam/icons": "^0.3.2" + } + }, + "node_modules/@editorjs/quote": { + "version": "2.7.6", + "resolved": "https://registry.npmjs.org/@editorjs/quote/-/quote-2.7.6.tgz", + "integrity": "sha512-D01KUMSDj2r+6Z+xjDkQqI+y6URpeHCvj0+P4pah+GtkG040lWjFb2H4pgHFXuol2cbfyAoraYSw85fuPheCvw==", + "license": "MIT", + "dependencies": { + "@codexteam/icons": "^0.3.2", + "@editorjs/dom": "^0.0.5" + } + }, + "node_modules/@editorjs/quote/node_modules/@editorjs/dom": { + "version": "0.0.5", + "resolved": "https://registry.npmjs.org/@editorjs/dom/-/dom-0.0.5.tgz", + "integrity": "sha512-SZ78Gwpkp3EUhjBIp0lSojeQ35V9acF8SubJsMeOH/vlOUE40GOnvvwWZnF05lO7bIB0dOHhhJy4N7IIAWxP2w==", + "license": "MIT", + "dependencies": { + "@editorjs/helpers": "^0.0.4" + } + }, + "node_modules/@editorjs/quote/node_modules/@editorjs/helpers": { + "version": "0.0.4", + "resolved": "https://registry.npmjs.org/@editorjs/helpers/-/helpers-0.0.4.tgz", + "integrity": "sha512-ieg3dzo2m1/ELze/RMNADiAiC5amXxIlVXoJ5vvXITOu/p/dPsrF+Oi3h5gBYvtGk9vg5LJUSG5YWU0tBUO1tw==", + "license": "MIT" + }, "node_modules/@electron/asar": { "version": "3.4.1", "resolved": "https://registry.npmjs.org/@electron/asar/-/asar-3.4.1.tgz", @@ -1451,6 +1775,23 @@ "node": "^18.18.0 || ^20.9.0 || >=21.1.0" } }, + "node_modules/@exodus/bytes": { + "version": "1.15.1", + "resolved": "https://registry.npmjs.org/@exodus/bytes/-/bytes-1.15.1.tgz", + "integrity": "sha512-S6mL0yNB/Abt9Ei4tq8gDhcczc4S3+vQ4ra7vxnAf+YHC02srtqxKKZghx2Dq6p0e66THKwR6r8N6P95wEty7Q==", + "license": "MIT", + "engines": { + "node": "^20.19.0 || ^22.12.0 || >=24.0.0" + }, + "peerDependencies": { + "@noble/hashes": "^1.8.0 || ^2.0.0" + }, + "peerDependenciesMeta": { + "@noble/hashes": { + "optional": true + } + } + }, "node_modules/@floating-ui/core": { "version": "1.7.5", "resolved": "https://registry.npmjs.org/@floating-ui/core/-/core-1.7.5.tgz", @@ -3905,6 +4246,7 @@ "version": "0.5.17", "resolved": "https://registry.npmjs.org/adm-zip/-/adm-zip-0.5.17.tgz", "integrity": "sha512-+Ut8d9LLqwEvHHJl1+PIHqoyDxFgVN847JTVM3Izi3xHDWPE4UtzzXysMZQs64DMcrJfBeS/uoEP4AD3HQHnQQ==", + "license": "MIT", "engines": { "node": ">=12.0" } @@ -4420,6 +4762,15 @@ "node": ">=6.0.0" } }, + "node_modules/bidi-js": { + "version": "1.0.3", + "resolved": "https://registry.npmjs.org/bidi-js/-/bidi-js-1.0.3.tgz", + "integrity": "sha512-RKshQI1R3YQ+n9YJz2QQ147P66ELpa1FQEg20Dk8oW9t2KgLbpDLLp9aGZ7y8WHSshDknG0bknqGw5/tyCs5tw==", + "license": "MIT", + "dependencies": { + "require-from-string": "^2.0.2" + } + }, "node_modules/bignumber.js": { "version": "9.3.1", "resolved": "https://registry.npmjs.org/bignumber.js/-/bignumber.js-9.3.1.tgz", @@ -5019,6 +5370,18 @@ "url": "https://github.com/sponsors/sindresorhus" } }, + "node_modules/codex-notifier": { + "version": "1.1.2", + "resolved": "https://registry.npmjs.org/codex-notifier/-/codex-notifier-1.1.2.tgz", + "integrity": "sha512-DCp6xe/LGueJ1N5sXEwcBc3r3PyVkEEDNWCVigfvywAkeXcZMk9K41a31tkEFBW0Ptlwji6/JlAb49E3Yrxbtg==", + "license": "MIT" + }, + "node_modules/codex-tooltip": { + "version": "1.0.6", + "resolved": "https://registry.npmjs.org/codex-tooltip/-/codex-tooltip-1.0.6.tgz", + "integrity": "sha512-3MVjBjeJFQ4jGlPuYHYV3Yj0wnl1l9ppB5zeIFJeyo8Ala3WlZYtLK5yZEoyND2fxIyVmqsZORCftDGX3vbSGQ==", + "license": "MIT" + }, "node_modules/color-convert": { "version": "2.0.1", "resolved": "https://registry.npmjs.org/color-convert/-/color-convert-2.0.1.tgz", @@ -5318,6 +5681,19 @@ "integrity": "sha512-KALDyEYgpY+Rlob/iriUtjV6d5Eq+Y191A5g4UqLAi8CyGP9N1+FdVbkc1SxKc2r4YAYqG8JzO2KGL+AizD70Q==", "license": "MIT" }, + "node_modules/css-tree": { + "version": "3.2.1", + "resolved": "https://registry.npmjs.org/css-tree/-/css-tree-3.2.1.tgz", + "integrity": "sha512-X7sjQzceUhu1u7Y/ylrRZFU2FS6LRiFVp6rKLPg23y3x3c3DOKAwuXGDp+PAGjh6CSnCjYeAul8pcT8bAl+lSA==", + "license": "MIT", + "dependencies": { + "mdn-data": "2.27.1", + "source-map-js": "^1.2.1" + }, + "engines": { + "node": "^10 || ^12.20.0 || ^14.13.0 || >=15.0.0" + } + }, "node_modules/csstype": { "version": "3.2.3", "resolved": "https://registry.npmjs.org/csstype/-/csstype-3.2.3.tgz", @@ -5333,6 +5709,19 @@ "node": ">= 12" } }, + "node_modules/data-urls": { + "version": "7.0.0", + "resolved": "https://registry.npmjs.org/data-urls/-/data-urls-7.0.0.tgz", + "integrity": "sha512-23XHcCF+coGYevirZceTVD7NdJOqVn+49IHyxgszm+JIiHLoB2TkmPtsYkNWT1pvRSGkc35L6NHs0yHkN2SumA==", + "license": "MIT", + "dependencies": { + "whatwg-mimetype": "^5.0.0", + "whatwg-url": "^16.0.0" + }, + "engines": { + "node": "^20.19.0 || ^22.12.0 || >=24.0.0" + } + }, "node_modules/date-fns": { "version": "4.1.0", "resolved": "https://registry.npmjs.org/date-fns/-/date-fns-4.1.0.tgz", @@ -5375,6 +5764,12 @@ } } }, + "node_modules/decimal.js": { + "version": "10.6.0", + "resolved": "https://registry.npmjs.org/decimal.js/-/decimal.js-10.6.0.tgz", + "integrity": "sha512-YpgQiITW3JXGntzdUmyUR1V812Hn8T1YVXhCu+wO3OpS4eU9l4YdD3qjyiKdV6mvV29zapkMeD390UVEf2lkUg==", + "license": "MIT" + }, "node_modules/decode-named-character-reference": { "version": "1.3.0", "resolved": "https://registry.npmjs.org/decode-named-character-reference/-/decode-named-character-reference-1.3.0.tgz", @@ -7470,6 +7865,18 @@ "dev": true, "license": "ISC" }, + "node_modules/html-encoding-sniffer": { + "version": "6.0.0", + "resolved": "https://registry.npmjs.org/html-encoding-sniffer/-/html-encoding-sniffer-6.0.0.tgz", + "integrity": "sha512-CV9TW3Y3f8/wT0BRFc1/KAVQ3TUHiXmaAb6VW9vtiMFf7SLoMd1PdAc4W3KFOFETBJUb90KatHqlsZMWV+R9Gg==", + "license": "MIT", + "dependencies": { + "@exodus/bytes": "^1.6.0" + }, + "engines": { + "node": "^20.19.0 || ^22.12.0 || >=24.0.0" + } + }, "node_modules/html-url-attributes": { "version": "3.0.1", "resolved": "https://registry.npmjs.org/html-url-attributes/-/html-url-attributes-3.0.1.tgz", @@ -7779,6 +8186,12 @@ "url": "https://github.com/sponsors/sindresorhus" } }, + "node_modules/is-potential-custom-element-name": { + "version": "1.0.1", + "resolved": "https://registry.npmjs.org/is-potential-custom-element-name/-/is-potential-custom-element-name-1.0.1.tgz", + "integrity": "sha512-bCYeRA2rVibKZd+s2625gGnGF/t7DSqDs4dP7CrLA1m7jKWz6pps0LpYLJN8Q64HtmPKJ1hrN3nzPNKFEKOUiQ==", + "license": "MIT" + }, "node_modules/is-promise": { "version": "4.0.0", "resolved": "https://registry.npmjs.org/is-promise/-/is-promise-4.0.0.tgz", @@ -7921,6 +8334,55 @@ "js-yaml": "bin/js-yaml.js" } }, + "node_modules/jsdom": { + "version": "29.1.1", + "resolved": "https://registry.npmjs.org/jsdom/-/jsdom-29.1.1.tgz", + "integrity": "sha512-ECi4Fi2f7BdJtUKTflYRTiaMxIB0O6zfR1fX0GXpUrf6flp8QIYn1UT20YQqdSOfk2dfkCwS8LAFoJDEppNK5Q==", + "license": "MIT", + "dependencies": { + "@asamuzakjp/css-color": "^5.1.11", + "@asamuzakjp/dom-selector": "^7.1.1", + "@bramus/specificity": "^2.4.2", + "@csstools/css-syntax-patches-for-csstree": "^1.1.3", + "@exodus/bytes": "^1.15.0", + "css-tree": "^3.2.1", + "data-urls": "^7.0.0", + "decimal.js": "^10.6.0", + "html-encoding-sniffer": "^6.0.0", + "is-potential-custom-element-name": "^1.0.1", + "lru-cache": "^11.3.5", + "parse5": "^8.0.1", + "saxes": "^6.0.0", + "symbol-tree": "^3.2.4", + "tough-cookie": "^6.0.1", + "undici": "^7.25.0", + "w3c-xmlserializer": "^5.0.0", + "webidl-conversions": "^8.0.1", + "whatwg-mimetype": "^5.0.0", + "whatwg-url": "^16.0.1", + "xml-name-validator": "^5.0.0" + }, + "engines": { + "node": "^20.19.0 || ^22.13.0 || >=24.0.0" + }, + "peerDependencies": { + "canvas": "^3.0.0" + }, + "peerDependenciesMeta": { + "canvas": { + "optional": true + } + } + }, + "node_modules/jsdom/node_modules/lru-cache": { + "version": "11.5.1", + "resolved": "https://registry.npmjs.org/lru-cache/-/lru-cache-11.5.1.tgz", + "integrity": "sha512-RPimw/7aMdv2oqRrxKwvZXcPfwBrn/JZ2xYcY9Hus/6LaS3VOAKVWKWgNLCFSiOm1ESXinjsDlidVU7JlnCN2A==", + "license": "BlueOak-1.0.0", + "engines": { + "node": "20 || >=22" + } + }, "node_modules/jsesc": { "version": "3.1.0", "resolved": "https://registry.npmjs.org/jsesc/-/jsesc-3.1.0.tgz", @@ -8824,6 +9286,12 @@ "url": "https://opencollective.com/unified" } }, + "node_modules/mdn-data": { + "version": "2.27.1", + "resolved": "https://registry.npmjs.org/mdn-data/-/mdn-data-2.27.1.tgz", + "integrity": "sha512-9Yubnt3e8A0OKwxYSXyhLymGW4sCufcLG6VdiDdUGVkPhpqLxlvP5vl1983gQjJl3tqbrM731mjaZaP68AgosQ==", + "license": "CC0-1.0" + }, "node_modules/mdurl": { "version": "2.0.0", "resolved": "https://registry.npmjs.org/mdurl/-/mdurl-2.0.0.tgz", @@ -10121,6 +10589,30 @@ "integrity": "sha512-CmBKiL6NNo/OqgmMn95Fk9Whlp2mtvIv+KNpQKN2F4SjvrEesubTRWGYSg+BnWZOnlCaSTU1sMpsBOzgbYhnsA==", "license": "MIT" }, + "node_modules/parse5": { + "version": "8.0.1", + "resolved": "https://registry.npmjs.org/parse5/-/parse5-8.0.1.tgz", + "integrity": "sha512-z1e/HMG90obSGeidlli3hj7cbocou0/wa5HacvI3ASx34PecNjNQeaHNo5WIZpWofN9kgkqV1q5YvXe3F0FoPw==", + "license": "MIT", + "dependencies": { + "entities": "^8.0.0" + }, + "funding": { + "url": "https://github.com/inikulin/parse5?sponsor=1" + } + }, + "node_modules/parse5/node_modules/entities": { + "version": "8.0.0", + "resolved": "https://registry.npmjs.org/entities/-/entities-8.0.0.tgz", + "integrity": "sha512-zwfzJecQ/Uej6tusMqwAqU/6KL2XaB2VZ2Jg54Je6ahNBGNH6Ek6g3jjNCF0fG9EWQKGZNddNjU5F1ZQn/sBnA==", + "license": "BSD-2-Clause", + "engines": { + "node": ">=20.19.0" + }, + "funding": { + "url": "https://github.com/fb55/entities?sponsor=1" + } + }, "node_modules/parseurl": { "version": "1.3.3", "resolved": "https://registry.npmjs.org/parseurl/-/parseurl-1.3.3.tgz", @@ -10657,7 +11149,6 @@ "version": "2.3.1", "resolved": "https://registry.npmjs.org/punycode/-/punycode-2.3.1.tgz", "integrity": "sha512-vYt7UD1U9Wg6138shLtLOvdAu+8DsC/ilFtEVHcH+wydcSpNE20AfSOduf6MkRFahL5FY7X1oU7nKVZFtfq8Fg==", - "dev": true, "license": "MIT", "engines": { "node": ">=6" @@ -11221,6 +11712,18 @@ "node": ">=11.0.0" } }, + "node_modules/saxes": { + "version": "6.0.0", + "resolved": "https://registry.npmjs.org/saxes/-/saxes-6.0.0.tgz", + "integrity": "sha512-xAg7SOnEhrm5zI3puOOKyy1OMcMlIJZYNJY7xLBwSze0UjhPLnWfj2GF2EpT0jmzaJKIWKHLsaSSajf35bcYnA==", + "license": "ISC", + "dependencies": { + "xmlchars": "^2.2.0" + }, + "engines": { + "node": ">=v12.22.7" + } + }, "node_modules/scheduler": { "version": "0.27.0", "resolved": "https://registry.npmjs.org/scheduler/-/scheduler-0.27.0.tgz", @@ -11550,7 +12053,6 @@ "version": "1.2.1", "resolved": "https://registry.npmjs.org/source-map-js/-/source-map-js-1.2.1.tgz", "integrity": "sha512-UXWMKhLOwVKb728IUtQPXxfYU+usdybtUrK/8uGE8CQMvrhOpwvzDBwj0QhSL7MQc7vIsISBG8VQ8+IDQxpfQA==", - "dev": true, "license": "BSD-3-Clause", "engines": { "node": ">=0.10.0" @@ -11771,6 +12273,12 @@ "node": ">=8" } }, + "node_modules/symbol-tree": { + "version": "3.2.4", + "resolved": "https://registry.npmjs.org/symbol-tree/-/symbol-tree-3.2.4.tgz", + "integrity": "sha512-9QNk5KwDF+Bvz+PyObkmSYjI5ksVUYtjW7AU22r2NKcfLJcXp96hkDWU3+XndOsUb+AQ9QhfzfCT2O+CNWT5Tw==", + "license": "MIT" + }, "node_modules/tagged-tag": { "version": "1.0.0", "resolved": "https://registry.npmjs.org/tagged-tag/-/tagged-tag-1.0.0.tgz", @@ -11937,6 +12445,24 @@ "url": "https://github.com/sponsors/SuperchupuDev" } }, + "node_modules/tldts": { + "version": "7.4.0", + "resolved": "https://registry.npmjs.org/tldts/-/tldts-7.4.0.tgz", + "integrity": "sha512-yHBe+zVfzNZ3QfTPW/Z6KK1G2t340gFjMHqI/4KKSt/abzYydzuCnpqdaF5gCCABby+9Yfbj59oR5F2Fd5CBzg==", + "license": "MIT", + "dependencies": { + "tldts-core": "^7.4.0" + }, + "bin": { + "tldts": "bin/cli.js" + } + }, + "node_modules/tldts-core": { + "version": "7.4.1", + "resolved": "https://registry.npmjs.org/tldts-core/-/tldts-core-7.4.1.tgz", + "integrity": "sha512-sc2nGvGbixlJRHwTh/qQdPXTxJU1UDJboGPQm4d/01YUJ9r/u6aeIulQvEaxUlvKDN7hb1qCLjax+jhVAPLa/g==", + "license": "MIT" + }, "node_modules/tmp": { "version": "0.2.5", "resolved": "https://registry.npmjs.org/tmp/-/tmp-0.2.5.tgz", @@ -11966,6 +12492,30 @@ "node": ">=0.6" } }, + "node_modules/tough-cookie": { + "version": "6.0.1", + "resolved": "https://registry.npmjs.org/tough-cookie/-/tough-cookie-6.0.1.tgz", + "integrity": "sha512-LktZQb3IeoUWB9lqR5EWTHgW/VTITCXg4D21M+lvybRVdylLrRMnqaIONLVb5mav8vM19m44HIcGq4qASeu2Qw==", + "license": "BSD-3-Clause", + "dependencies": { + "tldts": "^7.0.5" + }, + "engines": { + "node": ">=16" + } + }, + "node_modules/tr46": { + "version": "6.0.0", + "resolved": "https://registry.npmjs.org/tr46/-/tr46-6.0.0.tgz", + "integrity": "sha512-bLVMLPtstlZ4iMQHpFHTR7GAGj2jxi8Dg0s2h2MafAE4uSWF98FC/3MomU51iQAMf8/qDUbKWf5GxuvvVcXEhw==", + "license": "MIT", + "dependencies": { + "punycode": "^2.3.1" + }, + "engines": { + "node": ">=20" + } + }, "node_modules/tree-kill": { "version": "1.2.2", "resolved": "https://registry.npmjs.org/tree-kill/-/tree-kill-1.2.2.tgz", @@ -12149,6 +12699,15 @@ "url": "https://github.com/sponsors/sindresorhus" } }, + "node_modules/undici": { + "version": "7.26.0", + "resolved": "https://registry.npmjs.org/undici/-/undici-7.26.0.tgz", + "integrity": "sha512-3O9Tf67pGhgOv9jM35AbhkXAKi13f3oy3aE4CSgr+TckGeY+/iu97ZXN+J7DpHPzLbVApFd1IFhcnBjREYXYcg==", + "license": "MIT", + "engines": { + "node": ">=20.18.1" + } + }, "node_modules/undici-types": { "version": "7.16.0", "resolved": "https://registry.npmjs.org/undici-types/-/undici-types-7.16.0.tgz", @@ -12493,6 +13052,18 @@ "integrity": "sha512-dpojBhNsCNN7T82Tm7k26A6G9ML3NkhDsnw9n/eoxSRlVBB4CEtIQ/KTCLI2Fwf3ataSXRhYFkQi3SlnFwPvPQ==", "license": "MIT" }, + "node_modules/w3c-xmlserializer": { + "version": "5.0.0", + "resolved": "https://registry.npmjs.org/w3c-xmlserializer/-/w3c-xmlserializer-5.0.0.tgz", + "integrity": "sha512-o8qghlI8NZHU1lLPrpi2+Uq7abh4GGPpYANlalzWxyWteJOCsr/P+oPBA49TOLu5FTZO4d3F9MnWJfiMo4BkmA==", + "license": "MIT", + "dependencies": { + "xml-name-validator": "^5.0.0" + }, + "engines": { + "node": ">=18" + } + }, "node_modules/wait-on": { "version": "9.0.4", "resolved": "https://registry.npmjs.org/wait-on/-/wait-on-9.0.4.tgz", @@ -12532,6 +13103,38 @@ "node": ">= 8" } }, + "node_modules/webidl-conversions": { + "version": "8.0.1", + "resolved": "https://registry.npmjs.org/webidl-conversions/-/webidl-conversions-8.0.1.tgz", + "integrity": "sha512-BMhLD/Sw+GbJC21C/UgyaZX41nPt8bUTg+jWyDeg7e7YN4xOM05YPSIXceACnXVtqyEw/LMClUQMtMZ+PGGpqQ==", + "license": "BSD-2-Clause", + "engines": { + "node": ">=20" + } + }, + "node_modules/whatwg-mimetype": { + "version": "5.0.0", + "resolved": "https://registry.npmjs.org/whatwg-mimetype/-/whatwg-mimetype-5.0.0.tgz", + "integrity": "sha512-sXcNcHOC51uPGF0P/D4NVtrkjSU2fNsm9iog4ZvZJsL3rjoDAzXZhkm2MWt1y+PUdggKAYVoMAIYcs78wJ51Cw==", + "license": "MIT", + "engines": { + "node": ">=20" + } + }, + "node_modules/whatwg-url": { + "version": "16.0.1", + "resolved": "https://registry.npmjs.org/whatwg-url/-/whatwg-url-16.0.1.tgz", + "integrity": "sha512-1to4zXBxmXHV3IiSSEInrreIlu02vUOvrhxJJH5vcxYTBDAx51cqZiKdyTxlecdKNSjj8EcxGBxNf6Vg+945gw==", + "license": "MIT", + "dependencies": { + "@exodus/bytes": "^1.11.0", + "tr46": "^6.0.0", + "webidl-conversions": "^8.0.1" + }, + "engines": { + "node": "^20.19.0 || ^22.12.0 || >=24.0.0" + } + }, "node_modules/when-exit": { "version": "2.1.5", "resolved": "https://registry.npmjs.org/when-exit/-/when-exit-2.1.5.tgz", @@ -12607,6 +13210,15 @@ "integrity": "sha512-l4Sp/DRseor9wL6EvV2+TuQn63dMkPjZ/sp9XkghTEbV9KlPS1xUsZ3u7/IQO4wxtcFB4bgpQPRcR3QCvezPcQ==", "license": "ISC" }, + "node_modules/xml-name-validator": { + "version": "5.0.0", + "resolved": "https://registry.npmjs.org/xml-name-validator/-/xml-name-validator-5.0.0.tgz", + "integrity": "sha512-EvGK8EJ3DhaHfbRlETOWAS5pO9MZITeauHKJyb8wyajUfQUenkIg2MvLDTZ4T/TgIcm3HU0TFBgWWboAZ30UHg==", + "license": "Apache-2.0", + "engines": { + "node": ">=18" + } + }, "node_modules/xmlbuilder": { "version": "15.1.1", "resolved": "https://registry.npmjs.org/xmlbuilder/-/xmlbuilder-15.1.1.tgz", @@ -12617,6 +13229,12 @@ "node": ">=8.0" } }, + "node_modules/xmlchars": { + "version": "2.2.0", + "resolved": "https://registry.npmjs.org/xmlchars/-/xmlchars-2.2.0.tgz", + "integrity": "sha512-JZnDKK8B0RCDw84FNdDAIpZK+JuJw+s7Lz8nksI7SIuU3UXJJslUthsi+uWBUYOwPFwW7W7PRLRfUKpxjtjFCw==", + "license": "MIT" + }, "node_modules/y18n": { "version": "5.0.8", "resolved": "https://registry.npmjs.org/y18n/-/y18n-5.0.8.tgz", diff --git a/package.json b/package.json index e4fc3a5..c6e3a48 100644 --- a/package.json +++ b/package.json @@ -1,7 +1,7 @@ { "name": "kobar", "productName": "KoBar", - "version": "1.0.26", + "version": "1.0.27", "description": "Modular utility sidebar for desktop. Your multi-threaded creative assistant.", "author": "Ekrem Edali", "private": true, @@ -71,6 +71,13 @@ } }, "dependencies": { + "@editorjs/code": "^2.9.4", + "@editorjs/delimiter": "^1.4.2", + "@editorjs/editorjs": "^2.31.6", + "@editorjs/header": "^2.8.9", + "@editorjs/image": "^2.10.3", + "@editorjs/list": "^2.0.9", + "@editorjs/quote": "^2.7.6", "@floating-ui/dom": "^1.7.6", "@tiptap/extension-image": "^3.20.1", "@tiptap/extension-placeholder": "^3.20.1", @@ -87,6 +94,7 @@ "emoji-picker-react": "^4.18.0", "express": "^5.2.1", "googleapis": "^171.4.0", + "jsdom": "^29.1.1", "konva": "^10.2.3", "material-symbols": "^0.44.9", "node-machine-id": "^1.1.12", @@ -127,4 +135,4 @@ "vite": "^7.3.1", "wait-on": "^9.0.4" } -} +} \ No newline at end of file diff --git a/src/components/notes/BlockInsertionPanel.tsx b/src/components/notes/BlockInsertionPanel.tsx new file mode 100644 index 0000000..2a3aa13 --- /dev/null +++ b/src/components/notes/BlockInsertionPanel.tsx @@ -0,0 +1,225 @@ +import React, { useState, useRef, useEffect, useCallback } from 'react'; +import { createPortal } from 'react-dom'; +import { useAppStore } from '../../store/useAppStore'; + +interface BlockOption { + id: string; + icon: string; + label: string; + description: string; +} + +interface BlockInsertionPanelProps { + position: { x: number; y: number }; + onSelect: (blockType: string) => void; + onClose: () => void; + containerRef: React.RefObject; +} + +const BlockInsertionPanel: React.FC = ({ + position, + onSelect, + onClose, + containerRef, +}) => { + const t = useAppStore((state) => state.t); + const design = useAppStore((state) => state.design); + const isMac = useAppStore((state) => state.isMac); + + const [searchQuery, setSearchQuery] = useState(''); + const [highlightedIndex, setHighlightedIndex] = useState(0); + const panelRef = useRef(null); + const searchInputRef = useRef(null); + + const blockOptions: BlockOption[] = [ + { id: 'header', icon: 'title', label: t('blockHeading') || 'Heading', description: 'H1-H6' }, + { id: 'list-unordered', icon: 'format_list_bulleted', label: t('blockBulletedList') || 'Bulleted List', description: '•' }, + { id: 'list-ordered', icon: 'format_list_numbered', label: t('blockNumberedList') || 'Numbered List', description: '1.' }, + { id: 'checklist', icon: 'checklist', label: t('blockChecklist') || 'Checklist', description: '☑' }, + { id: 'code', icon: 'code', label: t('blockCode') || 'Code', description: '<>' }, + { id: 'quote', icon: 'format_quote', label: t('blockQuote') || 'Quote', description: '"' }, + { id: 'image', icon: 'image', label: t('blockImage') || 'Image', description: '📷' }, + { id: 'delimiter', icon: 'horizontal_rule', label: t('blockDivider') || 'Divider', description: '—' }, + ]; + + const filteredOptions = searchQuery.trim() + ? blockOptions.filter(opt => + opt.label.toLowerCase().includes(searchQuery.toLowerCase()) || + opt.id.toLowerCase().includes(searchQuery.toLowerCase()) + ) + : blockOptions; + + // Auto-focus search input + useEffect(() => { + const timer = setTimeout(() => { + searchInputRef.current?.focus(); + }, 50); + return () => clearTimeout(timer); + }, []); + + // Close on outside click + useEffect(() => { + const handleClickOutside = (e: MouseEvent) => { + if (panelRef.current && !panelRef.current.contains(e.target as Node)) { + onClose(); + } + }; + document.addEventListener('mousedown', handleClickOutside); + return () => document.removeEventListener('mousedown', handleClickOutside); + }, [onClose]); + + // Keyboard navigation + const handleKeyDown = useCallback((e: React.KeyboardEvent) => { + if (e.key === 'ArrowDown') { + e.preventDefault(); + setHighlightedIndex(prev => Math.min(prev + 1, filteredOptions.length - 1)); + } else if (e.key === 'ArrowUp') { + e.preventDefault(); + setHighlightedIndex(prev => Math.max(prev - 1, 0)); + } else if (e.key === 'Enter') { + e.preventDefault(); + if (filteredOptions[highlightedIndex]) { + onSelect(filteredOptions[highlightedIndex].id); + } + } else if (e.key === 'Escape') { + e.preventDefault(); + onClose(); + } + }, [filteredOptions, highlightedIndex, onSelect, onClose]); + + // Reset highlight when filter changes + useEffect(() => { + setHighlightedIndex(0); + }, [searchQuery]); + + // Compute panel position so it doesn't overflow NotePanel edges + const [adjustedPos, setAdjustedPos] = useState(position); + + useEffect(() => { + if (!panelRef.current) { + setAdjustedPos(position); + return; + } + + const panelRect = panelRef.current.getBoundingClientRect(); + const notePanel = containerRef.current?.closest('.relative') as HTMLElement | null; + const notePanelRect = notePanel?.getBoundingClientRect(); + + // Use notePanel bounds if available, otherwise viewport + const boundsRight = notePanelRect?.right ?? window.innerWidth; + const boundsBottom = notePanelRect?.bottom ?? window.innerHeight; + const boundsLeft = notePanelRect?.left ?? 0; + const boundsTop = notePanelRect?.top ?? 0; + + const padding = 12; // Minimum distance from NotePanel edges + + let x = position.x; + let y = position.y; + + // Horizontal constraint + if (x + panelRect.width + padding > boundsRight) { + x = boundsRight - panelRect.width - padding; + } + if (x < boundsLeft + padding) { + x = boundsLeft + padding; + } + + // Vertical constraint — if panel would go below bounds, open upward + if (y + panelRect.height + padding > boundsBottom) { + // Open upward from the block + y = position.y - panelRect.height - 8; + } + if (y < boundsTop + padding) { + y = boundsTop + padding; + } + + setAdjustedPos({ x, y }); + }, [position, containerRef]); + + const panelContent = ( +
+ {/* Search input */} +
+ setSearchQuery(e.target.value)} + placeholder="Filter..." + className="w-full bg-transparent text-sm text-slate-300 outline-none placeholder-slate-600 px-2 py-1.5" + /> +
+ + {/* Block options */} +
+ {filteredOptions.length === 0 ? ( +
+ No blocks found +
+ ) : ( + filteredOptions.map((opt, index) => ( + + )) + )} +
+
+ ); + + return createPortal(panelContent, document.body); +}; + +export default BlockInsertionPanel; diff --git a/src/components/notes/CustomCodeTool.ts b/src/components/notes/CustomCodeTool.ts new file mode 100644 index 0000000..b2213f9 --- /dev/null +++ b/src/components/notes/CustomCodeTool.ts @@ -0,0 +1,284 @@ +import type { API, BlockTool, BlockToolData, ToolConfig } from '@editorjs/editorjs'; + +export interface CodeData extends BlockToolData { + code: string; + language: string; + height: number; +} + +export default class CustomCodeTool implements BlockTool { + api: API; + data: CodeData; + config: ToolConfig; + wrapper: HTMLElement | null = null; + textarea: HTMLTextAreaElement | null = null; + pre: HTMLElement | null = null; + codeContainer: HTMLElement | null = null; + select: HTMLSelectElement | null = null; + + static get isReadOnlySupported() { + return true; + } + + static get enableLineBreaks() { + return true; + } + + static get toolbox() { + return { + icon: '', + title: 'Code' + }; + } + + constructor({ data, config, api }: { data: any; config: ToolConfig; api: API }) { + this.api = api; + this.config = config; + this.data = { + code: data.code || '', + language: data.language || 'javascript', + height: data.height || 150 + }; + } + + render() { + this.wrapper = document.createElement('div'); + this.wrapper.classList.add('custom-code-tool'); + this.wrapper.style.marginBottom = '1.5rem'; + + const header = document.createElement('div'); + header.classList.add('code-header'); + header.style.display = 'flex'; + header.style.justifyContent = 'space-between'; + header.style.alignItems = 'center'; + header.style.backgroundColor = 'var(--theme-bg-dark)'; + header.style.padding = '4px 12px'; + header.style.borderTopLeftRadius = '8px'; + header.style.borderTopRightRadius = '8px'; + header.style.border = '1px solid var(--theme-border)'; + header.style.borderBottom = 'none'; + header.style.userSelect = 'none'; + + this.select = document.createElement('select'); + this.select.style.background = 'transparent'; + this.select.style.color = '#94a3b8'; + this.select.style.border = 'none'; + this.select.style.outline = 'none'; + this.select.style.fontSize = '0.75rem'; + this.select.style.cursor = 'pointer'; + + const langs = ['javascript', 'typescript', 'python', 'html', 'css', 'json', 'bash', 'plaintext']; + langs.forEach(lang => { + const opt = document.createElement('option'); + opt.value = lang; + opt.innerText = lang.toUpperCase(); + opt.style.background = 'var(--theme-bg-base)'; + if (this.data.language === lang) opt.selected = true; + this.select?.appendChild(opt); + }); + + this.select.addEventListener('change', (e) => { + this.data.language = (e.target as HTMLSelectElement).value; + this.highlightCode(); + }); + + const copyBtn = document.createElement('button'); + copyBtn.innerHTML = 'content_copy'; + copyBtn.style.background = 'none'; + copyBtn.style.border = 'none'; + copyBtn.style.color = '#94a3b8'; + copyBtn.style.cursor = 'pointer'; + copyBtn.title = 'Copy code'; + copyBtn.addEventListener('click', () => { + navigator.clipboard.writeText(this.textarea?.value || ''); + copyBtn.style.color = 'var(--theme-primary)'; + setTimeout(() => copyBtn.style.color = '#94a3b8', 1000); + }); + + header.appendChild(this.select); + header.appendChild(copyBtn); + + const container = document.createElement('div'); + container.style.position = 'relative'; + container.style.border = '1px solid var(--theme-border)'; + container.style.borderBottomLeftRadius = '8px'; + container.style.borderBottomRightRadius = '8px'; + container.style.overflow = 'hidden'; + container.style.backgroundColor = 'var(--theme-bg-base)'; + + this.textarea = document.createElement('textarea'); + this.textarea.value = this.data.code; + this.textarea.placeholder = 'Write your code here...'; + this.textarea.spellcheck = false; + + // Exact styling to overlap nicely + this.textarea.style.position = 'absolute'; + this.textarea.style.top = '0'; + this.textarea.style.left = '0'; + this.textarea.style.width = '100%'; + this.textarea.style.height = '100%'; + this.textarea.style.margin = '0'; + this.textarea.style.padding = '12px'; + this.textarea.style.border = 'none'; + this.textarea.style.background = 'transparent'; + this.textarea.style.color = 'transparent'; + this.textarea.style.caretColor = '#e2e8f0'; + this.textarea.style.outline = 'none'; + this.textarea.style.resize = 'none'; + this.textarea.style.fontFamily = 'monospace'; + this.textarea.style.fontSize = '14px'; + this.textarea.style.lineHeight = '1.5'; + this.textarea.style.zIndex = '2'; + + this.pre = document.createElement('pre'); + this.pre.style.margin = '0'; + this.pre.style.padding = '12px'; + this.pre.style.pointerEvents = 'none'; + this.pre.style.position = 'absolute'; + this.pre.style.top = '0'; + this.pre.style.left = '0'; + this.pre.style.width = '100%'; + this.pre.style.height = '100%'; + this.pre.style.zIndex = '1'; + this.pre.style.fontFamily = 'monospace'; + this.pre.style.fontSize = '14px'; + this.pre.style.lineHeight = '1.5'; + this.pre.style.whiteSpace = 'pre-wrap'; + this.pre.style.wordBreak = 'break-all'; + this.pre.style.color = '#e2e8f0'; + this.pre.style.overflow = 'hidden'; + + this.codeContainer = document.createElement('code'); + this.pre.appendChild(this.codeContainer); + + const resizer = document.createElement('div'); + resizer.style.position = 'absolute'; + resizer.style.bottom = '0'; + resizer.style.left = '0'; + resizer.style.width = '100%'; + resizer.style.height = '12px'; + resizer.style.background = 'transparent'; + resizer.style.cursor = 'ns-resize'; + resizer.style.zIndex = '3'; + + // A small dash line for visuals + const dash = document.createElement('div'); + dash.style.width = '30px'; + dash.style.height = '2px'; + dash.style.background = 'var(--theme-border)'; + dash.style.margin = '5px auto'; + resizer.appendChild(dash); + + container.style.height = this.data.height + 'px'; + + let isResizing = false; + let startY = 0; + let startHeight = 0; + + resizer.addEventListener('mousedown', (e) => { + isResizing = true; + startY = e.clientY; + startHeight = container.getBoundingClientRect().height; + e.preventDefault(); + }); + + document.addEventListener('mousemove', (e) => { + if (!isResizing) return; + const newHeight = Math.max(80, startHeight + (e.clientY - startY)); + container.style.height = newHeight + 'px'; + this.data.height = newHeight; + }); + + document.addEventListener('mouseup', () => { + if (isResizing) { + isResizing = false; + // Dispatch change event to save + this.api.saver.save(); + } + }); + + this.textarea.addEventListener('input', () => { + this.highlightCode(); + }); + + this.textarea.addEventListener('keydown', (e) => { + if (e.key === 'Tab') { + e.preventDefault(); + const start = this.textarea!.selectionStart; + const end = this.textarea!.selectionEnd; + this.textarea!.value = this.textarea!.value.substring(0, start) + " " + this.textarea!.value.substring(end); + this.textarea!.selectionStart = this.textarea!.selectionEnd = start + 4; + this.highlightCode(); + } + }); + + this.textarea.addEventListener('scroll', () => { + if (this.pre && this.textarea) { + this.pre.scrollTop = this.textarea.scrollTop; + } + }); + + container.appendChild(this.pre); + container.appendChild(this.textarea); + container.appendChild(resizer); + + this.wrapper.appendChild(header); + this.wrapper.appendChild(container); + + this.highlightCode(); + + return this.wrapper; + } + + highlightCode() { + if (!this.textarea || !this.codeContainer) return; + const code = this.textarea.value; + this.data.code = code; + + // Escape HTML + let html = code.replace(/&/g, '&').replace(//g, '>'); + + // Tokenize to prevent regex overlapping and HTML corruption + const tokens: string[] = []; + const saveToken = (str: string) => { + const id = `__TOKEN_${tokens.length}__`; + tokens.push(str); + return id; + }; + + const lang = this.data.language; + if (lang === 'javascript' || lang === 'typescript' || lang === 'json') { + html = html.replace(/(\/\/.*)/g, match => saveToken(`${match}`)); + html = html.replace(/(["'`].*?["'`])/g, match => saveToken(`${match}`)); + html = html.replace(/\b(const|let|var|function|return|if|else|for|while|class|import|export|from|new|true|false|null|undefined|await|async|interface|type)\b/g, match => saveToken(`${match}`)); + html = html.replace(/\b(\d+)\b/g, match => saveToken(`${match}`)); + } else if (lang === 'html') { + html = html.replace(/(["'].*?["'])/g, match => saveToken(`${match}`)); + html = html.replace(/(<\/?)([a-zA-Z0-9]+)/g, (_, p1, p2) => `${p1}${saveToken(`${p2}`)}`); + html = html.replace(/([a-zA-Z0-9-]+)(=)/g, (_, p1, p2) => `${saveToken(`${p1}`)}${p2}`); + } else if (lang === 'css') { + html = html.replace(/([a-zA-Z0-9-]+)(\s*:)/g, (_, p1, p2) => `${saveToken(`${p1}`)}${p2}`); + html = html.replace(/(#[0-9a-fA-F]+|\b\d+(px|em|rem|%)\b)/g, match => saveToken(`${match}`)); + } else if (lang === 'python') { + html = html.replace(/(#.*)/g, match => saveToken(`${match}`)); + html = html.replace(/(["'`].*?["'`])/g, match => saveToken(`${match}`)); + html = html.replace(/\b(def|return|if|elif|else|for|while|class|import|from|True|False|None|await|async|in|and|or|not)\b/g, match => saveToken(`${match}`)); + html = html.replace(/\b(\d+)\b/g, match => saveToken(`${match}`)); + } + + // Restore tokens + tokens.forEach((token, i) => { + html = html.replace(`__TOKEN_${i}__`, token); + }); + + this.codeContainer.innerHTML = html + (html.endsWith('\n') ? '
' : ''); + } + + save() { + return { + code: this.data.code, + language: this.data.language, + height: this.data.height + }; + } +} diff --git a/src/components/notes/EditorJsEditor.tsx b/src/components/notes/EditorJsEditor.tsx new file mode 100644 index 0000000..a2357b7 --- /dev/null +++ b/src/components/notes/EditorJsEditor.tsx @@ -0,0 +1,627 @@ +import React, { useEffect, useRef, useCallback, useState } from 'react'; +import { createPortal } from 'react-dom'; +import EditorJS from '@editorjs/editorjs'; +import Header from '@editorjs/header'; +import List from '@editorjs/list'; +import CustomCodeTool from './CustomCodeTool'; +import Quote from '@editorjs/quote'; +import Delimiter from '@editorjs/delimiter'; +import { useAppStore } from '../../store/useAppStore'; +import { validateEditorJsData } from './editorConverters'; +import type { EditorJsOutputData } from './editorConverters'; +import BlockInsertionPanel from './BlockInsertionPanel'; + +// Fix Editor.js duplicate checklist bug by overriding the static toolbox getter +// The `@editorjs/list` plugin might be rendered twice by Editor.js Convert-to menu due to overlapping definitions +const OriginalList = List as any; +class CustomList extends OriginalList { + static get toolbox() { + const items = OriginalList.toolbox; + if (Array.isArray(items)) { + const seen = new Set(); + return items.filter((item: any) => { + const key = item.data?.style || item.title; + if (seen.has(key)) return false; + seen.add(key); + return true; + }); + } + return items; + } +} + +const EditorJsEditor: React.FC = React.memo(() => { + const activeNoteId = useAppStore((state) => state.activeNoteId); + const activeNote = useAppStore((state) => state.notes.find(n => n.id === state.activeNoteId)); + const updateNoteTitle = useAppStore((state) => state.updateNoteTitle); + const updateNoteEditorjsData = useAppStore((state) => state.updateNoteEditorjsData); + const t = useAppStore((state) => state.t); + const design = useAppStore((state) => state.design); + + const editorRef = useRef(null); + const holderRef = useRef(null); + const containerRef = useRef(null); + const isMountedRef = useRef(false); + const isSavingRef = useRef(false); + const currentNoteIdRef = useRef(null); + + // Block insertion panel state + const [showInsertPanel, setShowInsertPanel] = useState(false); + const [insertPanelPos, setInsertPanelPos] = useState<{ x: number; y: number }>({ x: 0, y: 0 }); + // Ref-based plus button management (avoids re-renders that cause flickering) + const plusBtnRef = useRef(null); + const plusBlockIndexRef = useRef(null); + const lastHoveredBlockRef = useRef(null); + const hideTimeoutRef = useRef | null>(null); + + // Helper: hide plus button via direct DOM manipulation (no state → no re-render) + const hidePlusButton = useCallback(() => { + const btn = plusBtnRef.current; + if (!btn) return; + btn.style.display = 'none'; + plusBlockIndexRef.current = null; + lastHoveredBlockRef.current = null; + }, []); + const [slashBlockIndex, setSlashBlockIndex] = useState(null); + + // Parse stored Editor.js data for a note + const getEditorData = useCallback((note: typeof activeNote): EditorJsOutputData => { + if (!note?.editorjsData) { + return { time: Date.now(), blocks: [{ type: 'paragraph', data: { text: '' } }], version: '2.30.0' }; + } + try { + const parsed = JSON.parse(note.editorjsData); + + // Migration: Convert old type: 'checklist' blocks to type: 'list' with style: 'checklist' + if (parsed.blocks && Array.isArray(parsed.blocks)) { + parsed.blocks = parsed.blocks.map((block: any) => { + if (block.type === 'checklist' && block.data && Array.isArray(block.data.items)) { + return { + ...block, + type: 'list', + data: { + style: 'checklist', + items: block.data.items.map((item: any) => ({ + content: item.text || '', + meta: { checked: !!item.checked }, + items: [] + })) + } + }; + } + return block; + }); + } + + if (validateEditorJsData(parsed)) { + return parsed; + } + } catch { + // Invalid JSON, return empty + } + return { time: Date.now(), blocks: [{ type: 'paragraph', data: { text: '' } }], version: '2.30.0' }; + }, []); + + // Save current editor content to store + const saveToStore = useCallback(async () => { + if (!editorRef.current || isSavingRef.current) return; + isSavingRef.current = true; + try { + const outputData = await editorRef.current.save(); + const currentId = currentNoteIdRef.current; + if (currentId) { + updateNoteEditorjsData(currentId, JSON.stringify(outputData)); + } + } catch (err) { + console.error('[EditorJS] Save failed:', err); + } finally { + isSavingRef.current = false; + } + }, [updateNoteEditorjsData]); + + // Initialize or re-initialize Editor.js + useEffect(() => { + if (!holderRef.current || !activeNote || activeNote.isSettings) return; + if (activeNote.editorType !== 'editorjs') return; + + // If we're switching to a different note, save the old one first then destroy + const initEditor = async () => { + // Save previous content before switching + if (editorRef.current && currentNoteIdRef.current !== null && currentNoteIdRef.current !== activeNote.id) { + try { + const prevData = await editorRef.current.save(); + updateNoteEditorjsData(currentNoteIdRef.current, JSON.stringify(prevData)); + } catch { /* ignore save errors on switch */ } + } + + // Destroy previous instance + if (editorRef.current) { + try { + editorRef.current.destroy(); + } catch { /* ignore destroy errors */ } + editorRef.current = null; + } + + currentNoteIdRef.current = activeNote.id; + + const data = getEditorData(activeNote); + + // Small delay to ensure DOM element is clean + await new Promise(resolve => setTimeout(resolve, 50)); + + if (!holderRef.current) return; + + // Clear any leftover DOM content + holderRef.current.innerHTML = ''; + + const editor = new EditorJS({ + holder: holderRef.current, + data: data, + placeholder: t('editorjsPlaceholder') || "Press '/' or click '+' to add a block...", + tools: { + header: { + class: Header as any, + config: { + levels: [1, 2, 3, 4, 5, 6], + defaultLevel: 2 + }, + inlineToolbar: true, + }, + list: { + class: CustomList as any, + inlineToolbar: true, + config: { + defaultStyle: 'unordered' + } + }, + code: { + class: CustomCodeTool as any, + }, + quote: { + class: Quote as any, + inlineToolbar: true, + config: { + quotePlaceholder: 'Enter a quote', + captionPlaceholder: 'Quote author', + } + }, + delimiter: { + class: Delimiter as any, + }, + }, + onChange: async () => { + if (isMountedRef.current) { + await saveToStore(); + } + }, + onReady: () => { + isMountedRef.current = true; + // Restore scroll position + if (containerRef.current) { + const savedPos = useAppStore.getState().scrollPositions[`note_editorjs_${activeNote.id}`]; + if (savedPos !== undefined) { + setTimeout(() => { + if (containerRef.current) containerRef.current.scrollTop = savedPos; + }, 50); + } + } + }, + // Minimal config — we handle the toolbox ourselves + minHeight: 100, + }); + + editorRef.current = editor; + }; + + initEditor(); + + return () => { + isMountedRef.current = false; + }; + }, [activeNoteId, activeNote?.editorType]); // Only re-init on tab change or type change + + // Cleanup on unmount + useEffect(() => { + return () => { + if (editorRef.current) { + // Save before destroy + const id = currentNoteIdRef.current; + if (id) { + editorRef.current.save().then(data => { + useAppStore.getState().updateNoteEditorjsData(id, JSON.stringify(data)); + }).catch(() => {}); + } + try { + editorRef.current.destroy(); + } catch { /* ignore */ } + editorRef.current = null; + } + }; + }, []); + + // Track scroll position + useEffect(() => { + if (!containerRef.current || !activeNoteId) return; + const scrollNode = containerRef.current; + + const handleScroll = () => { + useAppStore.getState().setScrollPosition(`note_editorjs_${activeNoteId}`, scrollNode.scrollTop); + }; + + scrollNode.addEventListener('scroll', handleScroll, { passive: true }); + return () => scrollNode.removeEventListener('scroll', handleScroll); + }, [activeNoteId]); + + // Detect "/" key press for slash command and "+" button hover + useEffect(() => { + const holder = holderRef.current; + if (!holder) return; + + const handleKeyDown = (e: KeyboardEvent) => { + if (e.key === '/' && !e.ctrlKey && !e.metaKey && !e.altKey) { + const sel = window.getSelection(); + if (!sel || sel.rangeCount === 0) return; + + const range = sel.getRangeAt(0); + const blockEl = (range.startContainer as HTMLElement).closest?.('.ce-block') + || (range.startContainer.parentElement)?.closest?.('.ce-block'); + + if (!blockEl) return; + + // Only trigger on empty blocks or at the start of text + const contentEl = blockEl.querySelector('.ce-paragraph, [contenteditable]'); + const textContent = contentEl?.textContent || ''; + + // If the block is empty or has only whitespace, show the panel + if (textContent.trim() === '' || textContent.trim() === '/') { + e.preventDefault(); + + // Clear the slash character + if (contentEl && contentEl.textContent) { + contentEl.textContent = ''; + } + + const rect = blockEl.getBoundingClientRect(); + const containerRect = containerRef.current?.getBoundingClientRect(); + + if (containerRect) { + // Find block index + const allBlocks = holder.querySelectorAll('.ce-block'); + let blockIndex = 0; + allBlocks.forEach((b, i) => { if (b === blockEl) blockIndex = i; }); + + setSlashBlockIndex(blockIndex); + setInsertPanelPos({ + x: rect.left, + y: rect.bottom + 4, + }); + setShowInsertPanel(true); + } + } + } + + if (e.key === 'Escape' && showInsertPanel) { + setShowInsertPanel(false); + setSlashBlockIndex(null); + } + }; + + holder.addEventListener('keydown', handleKeyDown); + return () => holder.removeEventListener('keydown', handleKeyDown); + }, [showInsertPanel]); + + // Update plus trigger position on mouse move over blocks (ref-based — zero re-renders) + useEffect(() => { + const holder = holderRef.current; + const container = containerRef.current; + if (!holder) return; + + const handleMouseMove = (e: MouseEvent) => { + if (showInsertPanel) return; // Don't show + while panel is open + + const target = e.target as HTMLElement; + + // If hovering the plus button itself, skip processing + if (target.closest('.editorjs-plus-trigger-btn')) return; + + const blockEl = target.closest('.ce-block'); + + if (!blockEl) { + if (lastHoveredBlockRef.current !== null) { + lastHoveredBlockRef.current = null; + hidePlusButton(); + } + return; + } + + // Same block as before → skip recalculation entirely + if (blockEl === lastHoveredBlockRef.current) return; + + // Check if this block is empty + const contentEl = blockEl.querySelector('.ce-paragraph, [contenteditable]'); + const textContent = contentEl?.textContent || ''; + + if (textContent.trim() !== '') { + if (lastHoveredBlockRef.current !== null) { + lastHoveredBlockRef.current = null; + hidePlusButton(); + } + return; + } + + lastHoveredBlockRef.current = blockEl; + + // Cancel any pending hide from mouseleave + if (hideTimeoutRef.current) { + clearTimeout(hideTimeoutRef.current); + hideTimeoutRef.current = null; + } + + // Find block index + const allBlocks = holder.querySelectorAll('.ce-block'); + let blockIndex = 0; + allBlocks.forEach((b, i) => { if (b === blockEl) blockIndex = i; }); + + // Position the button directly via DOM — no setState, no re-render + const rect = blockEl.getBoundingClientRect(); + const btn = plusBtnRef.current; + if (btn) { + btn.style.left = `${rect.left + 4}px`; + btn.style.top = `${rect.top + rect.height / 2 - 12}px`; + btn.style.display = 'flex'; + } + plusBlockIndexRef.current = blockIndex; + }; + + const handleMouseLeave = (e: MouseEvent) => { + const relatedTarget = e.relatedTarget as HTMLElement | null; + // If mouse is moving to the plus button, don't hide + if (relatedTarget?.closest('.editorjs-plus-trigger-btn')) return; + + // Small delay to prevent flicker during fast mouse movements + if (hideTimeoutRef.current) clearTimeout(hideTimeoutRef.current); + hideTimeoutRef.current = setTimeout(() => { + lastHoveredBlockRef.current = null; + hidePlusButton(); + }, 80); + }; + + // Hide plus button when container scrolls (stale position) + const handleScroll = () => { + hidePlusButton(); + }; + + holder.addEventListener('mousemove', handleMouseMove); + holder.addEventListener('mouseleave', handleMouseLeave); + if (container) { + container.addEventListener('scroll', handleScroll, { passive: true }); + } + return () => { + holder.removeEventListener('mousemove', handleMouseMove); + holder.removeEventListener('mouseleave', handleMouseLeave); + if (container) { + container.removeEventListener('scroll', handleScroll); + } + if (hideTimeoutRef.current) clearTimeout(hideTimeoutRef.current); + }; + }, [showInsertPanel, hidePlusButton]); + + // Plus button mouse events — prevent hide when hovering the button itself + useEffect(() => { + const btn = plusBtnRef.current; + if (!btn) return; + + const handleBtnEnter = () => { + // Cancel any pending hide from mouseleave + if (hideTimeoutRef.current) { + clearTimeout(hideTimeoutRef.current); + hideTimeoutRef.current = null; + } + }; + + const handleBtnLeave = (e: MouseEvent) => { + const relatedTarget = e.relatedTarget as HTMLElement | null; + // If going back to the editor, let mousemove re-evaluate + if (relatedTarget?.closest('.editorjs-holder')) return; + hidePlusButton(); + }; + + btn.addEventListener('mouseenter', handleBtnEnter); + btn.addEventListener('mouseleave', handleBtnLeave as EventListener); + return () => { + btn.removeEventListener('mouseenter', handleBtnEnter); + btn.removeEventListener('mouseleave', handleBtnLeave as EventListener); + }; + }, [hidePlusButton]); + + // Hide plus button when switching notes (stale position from previous note) + useEffect(() => { + hidePlusButton(); + }, [activeNoteId, hidePlusButton]); + + // Hide plus button when insert panel opens + useEffect(() => { + if (showInsertPanel) hidePlusButton(); + }, [showInsertPanel, hidePlusButton]); + + // Handle plus button click + const handlePlusClick = useCallback(() => { + const blockIndex = plusBlockIndexRef.current; + if (blockIndex === null) return; + + const holder = holderRef.current; + if (!holder) return; + + const blockEl = holder.querySelectorAll('.ce-block')[blockIndex]; + if (!blockEl) return; + + const rect = blockEl.getBoundingClientRect(); + setSlashBlockIndex(blockIndex); + setInsertPanelPos({ + x: rect.left, + y: rect.bottom + 4, + }); + setShowInsertPanel(true); + hidePlusButton(); + }, [hidePlusButton]); + + // Handle block type selection from the insertion panel + const handleBlockSelect = useCallback(async (blockType: string) => { + setShowInsertPanel(false); + setSlashBlockIndex(null); + + if (!editorRef.current) return; + + const editor = editorRef.current; + const currentIndex = slashBlockIndex ?? 0; + + try { + // Insert the block at the current position + switch (blockType) { + case 'header': + await editor.blocks.insert('header', { text: '', level: 2 }, undefined, currentIndex, true); + break; + case 'list-unordered': + await editor.blocks.insert('list', { style: 'unordered', items: [''] }, undefined, currentIndex, true); + break; + case 'list-ordered': + await editor.blocks.insert('list', { style: 'ordered', items: [''] }, undefined, currentIndex, true); + break; + case 'checklist': + await editor.blocks.insert('list', { style: 'checklist', items: [{ content: '', meta: { checked: false }, items: [] }] }, undefined, currentIndex, true); + break; + case 'code': + await editor.blocks.insert('code', { code: '' }, undefined, currentIndex, true); + break; + case 'quote': + await editor.blocks.insert('quote', { text: '', caption: '' }, undefined, currentIndex, true); + break; + case 'delimiter': + await editor.blocks.insert('delimiter', {}, undefined, currentIndex, true); + break; + case 'image': + // Trigger file picker for image + triggerImagePicker(); + return; + default: + break; + } + + // Delete the empty placeholder block if we replaced it + if (slashBlockIndex !== null) { + try { + // The new block was inserted, now remove the old empty block that's now shifted + const blockCount = editor.blocks.getBlocksCount(); + if (blockCount > 1) { + // The old block is now at currentIndex + 1 since we inserted before it + editor.blocks.delete(currentIndex + 1); + } + } catch { /* ignore if deletion fails */ } + } + + // Focus the newly inserted block + editor.caret.setToBlock(currentIndex, 'start'); + } catch (err) { + console.error('[EditorJS] Block insert failed:', err); + } + }, [slashBlockIndex]); + + // Image picker for Editor.js + const fileInputRef = useRef(null); + + const triggerImagePicker = useCallback(() => { + fileInputRef.current?.click(); + }, []); + + const handleImageFileSelect = useCallback(async (e: React.ChangeEvent) => { + if (!editorRef.current || !e.target.files || e.target.files.length === 0) return; + const file = e.target.files[0]; + const reader = new FileReader(); + reader.onload = async () => { + const base64 = reader.result as string; + if (editorRef.current) { + const index = slashBlockIndex ?? editorRef.current.blocks.getBlocksCount(); + // Insert a paragraph with image content as a workaround + // Editor.js image tool with base64 inline + await editorRef.current.blocks.insert('paragraph', { + text: `` + }, undefined, index, true); + } + }; + reader.readAsDataURL(file); + e.target.value = ''; + }, [slashBlockIndex]); + + if (!activeNote || activeNote.isSettings) return null; + + return ( +
+ {/* Hidden file input for image selection */} + + + {/* Title area */} +
+ updateNoteTitle(activeNote.id, e.target.value)} + /> +
+ + {/* Editor.js Holder */} +
+ + {/* Plus trigger button — always mounted, visibility controlled via refs (prevents flickering) */} + {createPortal( + , + document.body + )} + + {/* Block Insertion Panel */} + {showInsertPanel && ( + { setShowInsertPanel(false); setSlashBlockIndex(null); }} + containerRef={containerRef} + /> + )} +
+ ); +}); + +export default EditorJsEditor; diff --git a/src/components/notes/NoteEditor.tsx b/src/components/notes/NoteEditor.tsx index 2a1217e..ed0a2a7 100644 --- a/src/components/notes/NoteEditor.tsx +++ b/src/components/notes/NoteEditor.tsx @@ -1,268 +1,25 @@ -import React, { useCallback, useEffect, useRef, useState } from 'react'; -import { useEditor, EditorContent } from '@tiptap/react'; -import StarterKit from '@tiptap/starter-kit'; -import Underline from '@tiptap/extension-underline'; -import Image from '@tiptap/extension-image'; -import Placeholder from '@tiptap/extension-placeholder'; +import React from 'react'; import { useAppStore } from '../../store/useAppStore'; -import { useSpeechToText } from '../../hooks/useSpeechToText'; - +import TiptapEditor from './TiptapEditor'; +import EditorJsEditor from './EditorJsEditor'; + +/** + * NoteEditor Switcher + * Conditionally renders TiptapEditor or EditorJsEditor based on the active note's editorType. + * This component owns zero editor logic — it simply delegates to the correct editor. + */ const NoteEditor: React.FC = React.memo(() => { - const { activeNoteId, updateNoteTitle, t, design, language } = useAppStore(); + const activeNoteId = useAppStore((state) => state.activeNoteId); const activeNote = useAppStore((state) => state.notes.find(n => n.id === activeNoteId)); - const fileInputRef = useRef(null); - const isUpdatingFromStore = useRef(false); - const [, setTick] = useState(0); - - const editor = useEditor({ - extensions: [ - StarterKit.configure({ - bulletList: { keepMarks: true }, - orderedList: { keepMarks: true }, - }), - Underline, - Image.configure({ - inline: false, - allowBase64: true - }), - Placeholder.configure({ - placeholder: 'Start writing your note…', - }), - ], - content: activeNote?.content || '', - editorProps: { - attributes: { - class: 'flex-1 text-slate-300 text-lg leading-relaxed outline-none no-drag-region overflow-y-auto max-w-none', - spellcheck: 'false' - }, - clipboardTextSerializer: (slice) => { - const serializeNode = (node: any, listType: 'bullet' | 'ordered' | null = null, index: number = 0): string => { - if (node.isText) return node.text || ''; - if (node.type.name === 'hardBreak') return '\n'; - - let text = ''; - if (node.type.name === 'bulletList') { - node.forEach((child: any) => { text += serializeNode(child, 'bullet'); }); - return text; - } - if (node.type.name === 'orderedList') { - let i = node.attrs?.start || 1; - node.forEach((child: any) => { - text += serializeNode(child, 'ordered', i); - i++; - }); - return text; - } - if (node.type.name === 'listItem') { - const prefix = listType === 'ordered' ? `${index}. ` : '- '; - let itemText = ''; - node.forEach((child: any) => { itemText += serializeNode(child); }); - return prefix + itemText.trimEnd() + '\n'; - } - - if (node.isBlock) { - node.forEach((child: any) => { text += serializeNode(child); }); - return text + '\n'; - } - if (!node.isLeaf) { - node.forEach((child: any) => { text += serializeNode(child); }); - } - return text; - }; - - let result = ''; - slice.content.forEach((node: any) => { - result += serializeNode(node); - }); - return result.trim(); - }, - }, - onUpdate: ({ editor: ed }) => { - if (isUpdatingFromStore.current) return; - const currentTabId = useAppStore.getState().activeNoteId; - if (currentTabId) { - useAppStore.getState().updateNoteContent(currentTabId, ed.getHTML()); - } - }, - onSelectionUpdate: () => { - setTick((prev) => prev + 1); - }, - onTransaction: () => { - setTick((prev) => prev + 1); - } - }, []); - - const handleTranscript = useCallback((text: string) => { - if (editor) { - editor.chain().focus().insertContent(text + ' ').run(); - } - }, [editor]); - - const { isListening, toggleListening, isSupported, error } = useSpeechToText({ - onTranscript: handleTranscript, - language - }); - - useEffect(() => { - if (error) { - console.error('Speech-to-Text Error:', error); - } - }, [error]); - - // Sync editor content when active tab changes ONLY - useEffect(() => { - if (!editor || !activeNoteId) return; - const note = useAppStore.getState().notes.find(n => n.id === activeNoteId); - if (note) { - isUpdatingFromStore.current = true; - editor.commands.setContent(note.content || ''); - isUpdatingFromStore.current = false; - } - }, [activeNoteId, editor]); // activeNote dependency removed to prevent cursor jumping! - - // 1. Restore scroll position on mount/tab switch (Non-Reactive) - useEffect(() => { - if (!editor || !activeNoteId) return; - const scrollNode = editor.view.dom.parentElement; - if (!scrollNode) return; - - const savedPos = useAppStore.getState().scrollPositions[`note_${activeNoteId}`]; - if (savedPos !== undefined) { - // 50ms delay allows Tiptap to fully inject the new long content before jumping - setTimeout(() => { scrollNode.scrollTop = savedPos; }, 50); - } - }, [editor, activeNoteId]); - - // 2. Safely track scroll position continuously (Non-Reactive) - useEffect(() => { - if (!editor || !activeNoteId) return; - const scrollNode = editor.view.dom.parentElement; - if (!scrollNode) return; - - const handleScroll = () => { - useAppStore.getState().setScrollPosition(`note_${activeNoteId}`, scrollNode.scrollTop); - }; - - // Passive listener ensures zero scroll performance degradation - scrollNode.addEventListener('scroll', handleScroll, { passive: true }); - return () => scrollNode.removeEventListener('scroll', handleScroll); - }, [editor, activeNoteId]); - - // Handle local image file selection - const handleImageFileSelect = useCallback((e: React.ChangeEvent) => { - if (!editor || !e.target.files || e.target.files.length === 0) return; - const file = e.target.files[0]; - const reader = new FileReader(); - reader.onload = () => { - const base64 = reader.result as string; - editor.chain().focus().setImage({ src: base64 }).run(); - }; - reader.readAsDataURL(file); - // Reset input so the same file can be re-selected - e.target.value = ''; - }, [editor]); - - const triggerImagePicker = useCallback(() => { - fileInputRef.current?.click(); - }, []); - - - - if (!editor || !activeNote) return null; - - return ( -
- {/* Hidden file input for image selection */} - - {/* Title area */} -
- updateNoteTitle(activeNote.id, e.target.value)} - /> -
+ if (!activeNote || activeNote.isSettings) return null; - {/* Formatting Toolbar */} -
- - - -
- - -
- - {isSupported && ( - <> -
- - - )} -
+ if (activeNote.editorType === 'editorjs') { + return ; + } - {/* Tiptap Editor Content */} - -
- ); + // Default: Tiptap (also handles legacy notes without editorType) + return ; }); export default NoteEditor; diff --git a/src/components/notes/SettingsPanel.tsx b/src/components/notes/SettingsPanel.tsx index 8164a68..ff5fd23 100644 --- a/src/components/notes/SettingsPanel.tsx +++ b/src/components/notes/SettingsPanel.tsx @@ -147,6 +147,8 @@ const SettingsPanel: React.FC = () => { const setIsPopupSmartPositioning = useAppStore(state => state.setIsPopupSmartPositioning); const orientation = useAppStore(state => state.orientation); const setOrientation = useAppStore(state => state.setOrientation); + const preferredNoteEditor = useAppStore(state => state.preferredNoteEditor); + const setPreferredNoteEditor = useAppStore(state => state.setPreferredNoteEditor); // Workspaces const workspaces = useAppStore(state => state.workspaces); @@ -2064,6 +2066,22 @@ const SettingsPanel: React.FC = () => { {/* General Settings Area */}
+
+ +
+ + +
+
+ +
+
power_settings_new diff --git a/src/components/notes/TiptapEditor.tsx b/src/components/notes/TiptapEditor.tsx new file mode 100644 index 0000000..1973e7a --- /dev/null +++ b/src/components/notes/TiptapEditor.tsx @@ -0,0 +1,268 @@ +import React, { useCallback, useEffect, useRef, useState } from 'react'; +import { useEditor, EditorContent } from '@tiptap/react'; +import StarterKit from '@tiptap/starter-kit'; +import Underline from '@tiptap/extension-underline'; +import Image from '@tiptap/extension-image'; +import Placeholder from '@tiptap/extension-placeholder'; +import { useAppStore } from '../../store/useAppStore'; +import { useSpeechToText } from '../../hooks/useSpeechToText'; + +const TiptapEditor: React.FC = React.memo(() => { + const { activeNoteId, updateNoteTitle, t, design, language } = useAppStore(); + const activeNote = useAppStore((state) => state.notes.find(n => n.id === activeNoteId)); + const fileInputRef = useRef(null); + const isUpdatingFromStore = useRef(false); + const [, setTick] = useState(0); + + const editor = useEditor({ + extensions: [ + StarterKit.configure({ + bulletList: { keepMarks: true }, + orderedList: { keepMarks: true }, + }), + Underline, + Image.configure({ + inline: false, + allowBase64: true + }), + Placeholder.configure({ + placeholder: 'Start writing your note…', + }), + ], + content: activeNote?.content || '', + editorProps: { + attributes: { + class: 'flex-1 text-slate-300 text-lg leading-relaxed outline-none no-drag-region overflow-y-auto max-w-none', + spellcheck: 'false' + }, + clipboardTextSerializer: (slice) => { + const serializeNode = (node: any, listType: 'bullet' | 'ordered' | null = null, index: number = 0): string => { + if (node.isText) return node.text || ''; + if (node.type.name === 'hardBreak') return '\n'; + + let text = ''; + if (node.type.name === 'bulletList') { + node.forEach((child: any) => { text += serializeNode(child, 'bullet'); }); + return text; + } + if (node.type.name === 'orderedList') { + let i = node.attrs?.start || 1; + node.forEach((child: any) => { + text += serializeNode(child, 'ordered', i); + i++; + }); + return text; + } + if (node.type.name === 'listItem') { + const prefix = listType === 'ordered' ? `${index}. ` : '- '; + let itemText = ''; + node.forEach((child: any) => { itemText += serializeNode(child); }); + return prefix + itemText.trimEnd() + '\n'; + } + + if (node.isBlock) { + node.forEach((child: any) => { text += serializeNode(child); }); + return text + '\n'; + } + if (!node.isLeaf) { + node.forEach((child: any) => { text += serializeNode(child); }); + } + return text; + }; + + let result = ''; + slice.content.forEach((node: any) => { + result += serializeNode(node); + }); + return result.trim(); + }, + }, + onUpdate: ({ editor: ed }) => { + if (isUpdatingFromStore.current) return; + const currentTabId = useAppStore.getState().activeNoteId; + if (currentTabId) { + useAppStore.getState().updateNoteContent(currentTabId, ed.getHTML()); + } + }, + onSelectionUpdate: () => { + setTick((prev) => prev + 1); + }, + onTransaction: () => { + setTick((prev) => prev + 1); + } + }, []); + + const handleTranscript = useCallback((text: string) => { + if (editor) { + editor.chain().focus().insertContent(text + ' ').run(); + } + }, [editor]); + + const { isListening, toggleListening, isSupported, error } = useSpeechToText({ + onTranscript: handleTranscript, + language + }); + + useEffect(() => { + if (error) { + console.error('Speech-to-Text Error:', error); + } + }, [error]); + + // Sync editor content when active tab changes ONLY + useEffect(() => { + if (!editor || !activeNoteId) return; + const note = useAppStore.getState().notes.find(n => n.id === activeNoteId); + if (note) { + isUpdatingFromStore.current = true; + editor.commands.setContent(note.content || ''); + isUpdatingFromStore.current = false; + } + }, [activeNoteId, editor]); // activeNote dependency removed to prevent cursor jumping! + + // 1. Restore scroll position on mount/tab switch (Non-Reactive) + useEffect(() => { + if (!editor || !activeNoteId) return; + const scrollNode = editor.view.dom.parentElement; + if (!scrollNode) return; + + const savedPos = useAppStore.getState().scrollPositions[`note_${activeNoteId}`]; + if (savedPos !== undefined) { + // 50ms delay allows Tiptap to fully inject the new long content before jumping + setTimeout(() => { scrollNode.scrollTop = savedPos; }, 50); + } + }, [editor, activeNoteId]); + + // 2. Safely track scroll position continuously (Non-Reactive) + useEffect(() => { + if (!editor || !activeNoteId) return; + const scrollNode = editor.view.dom.parentElement; + if (!scrollNode) return; + + const handleScroll = () => { + useAppStore.getState().setScrollPosition(`note_${activeNoteId}`, scrollNode.scrollTop); + }; + + // Passive listener ensures zero scroll performance degradation + scrollNode.addEventListener('scroll', handleScroll, { passive: true }); + return () => scrollNode.removeEventListener('scroll', handleScroll); + }, [editor, activeNoteId]); + + // Handle local image file selection + const handleImageFileSelect = useCallback((e: React.ChangeEvent) => { + if (!editor || !e.target.files || e.target.files.length === 0) return; + const file = e.target.files[0]; + const reader = new FileReader(); + reader.onload = () => { + const base64 = reader.result as string; + editor.chain().focus().setImage({ src: base64 }).run(); + }; + reader.readAsDataURL(file); + // Reset input so the same file can be re-selected + e.target.value = ''; + }, [editor]); + + const triggerImagePicker = useCallback(() => { + fileInputRef.current?.click(); + }, []); + + + + if (!editor || !activeNote) return null; + + return ( +
+ {/* Hidden file input for image selection */} + + + {/* Title area */} +
+ updateNoteTitle(activeNote.id, e.target.value)} + /> +
+ + {/* Formatting Toolbar */} +
+ + + +
+ + +
+ + {isSupported && ( + <> +
+ + + )} +
+ + {/* Tiptap Editor Content */} + +
+ ); +}); + +export default TiptapEditor; diff --git a/src/components/notes/editorConverters.ts b/src/components/notes/editorConverters.ts new file mode 100644 index 0000000..5a23731 --- /dev/null +++ b/src/components/notes/editorConverters.ts @@ -0,0 +1,238 @@ +/** + * Bidirectional converters between Tiptap HTML and Editor.js OutputData format. + * Used when a user wants to convert a note from one editor engine to the other. + */ + +// ─── Editor.js OutputData Types (inline, no external dep needed) ─── + +export interface EditorJsBlock { + id?: string; + type: string; + data: Record; +} + +export interface EditorJsOutputData { + time?: number; + blocks: EditorJsBlock[]; + version?: string; +} + +// ─── Tiptap HTML → Editor.js OutputData ─── + +export function tiptapHtmlToEditorJs(html: string): EditorJsOutputData { + if (!html || html.trim() === '') { + return { time: Date.now(), blocks: [], version: '2.30.0' }; + } + + const parser = new DOMParser(); + const doc = parser.parseFromString(html, 'text/html'); + const blocks: EditorJsBlock[] = []; + + const children = Array.from(doc.body.childNodes); + + for (const node of children) { + if (node.nodeType === Node.TEXT_NODE) { + const text = (node.textContent || '').trim(); + if (text) { + blocks.push({ type: 'paragraph', data: { text } }); + } + continue; + } + + if (node.nodeType !== Node.ELEMENT_NODE) continue; + const el = node as HTMLElement; + const tagName = el.tagName.toLowerCase(); + + // Skip empty
tags used as spacers + if (tagName === 'br') continue; + + // Headings + const headingMatch = tagName.match(/^h([1-6])$/); + if (headingMatch) { + blocks.push({ + type: 'header', + data: { text: el.innerHTML.trim(), level: parseInt(headingMatch[1], 10) } + }); + continue; + } + + // Unordered list + if (tagName === 'ul') { + const items = Array.from(el.querySelectorAll(':scope > li')).map(li => (li as HTMLElement).innerHTML.trim()); + if (items.length > 0) { + blocks.push({ type: 'list', data: { style: 'unordered', items } }); + } + continue; + } + + // Ordered list + if (tagName === 'ol') { + const items = Array.from(el.querySelectorAll(':scope > li')).map(li => (li as HTMLElement).innerHTML.trim()); + if (items.length > 0) { + blocks.push({ type: 'list', data: { style: 'ordered', items } }); + } + continue; + } + + // Blockquote + if (tagName === 'blockquote') { + blocks.push({ type: 'quote', data: { text: el.innerHTML.trim(), caption: '' } }); + continue; + } + + // Code block + if (tagName === 'pre') { + const code = el.querySelector('code'); + blocks.push({ type: 'code', data: { code: (code || el).textContent || '' } }); + continue; + } + + // Horizontal rule + if (tagName === 'hr') { + blocks.push({ type: 'delimiter', data: {} }); + continue; + } + + // Image + if (tagName === 'img') { + blocks.push({ + type: 'image', + data: { + file: { url: el.getAttribute('src') || '' }, + caption: el.getAttribute('alt') || '', + withBorder: false, + stretched: false, + withBackground: false, + } + }); + continue; + } + + // Paragraph (default) — skip empty paragraphs that are just spacers + if (tagName === 'p') { + const inner = el.innerHTML.trim(); + // Check if paragraph contains only an image + const imgChild = el.querySelector('img'); + if (imgChild && el.childNodes.length === 1) { + blocks.push({ + type: 'image', + data: { + file: { url: imgChild.getAttribute('src') || '' }, + caption: imgChild.getAttribute('alt') || '', + withBorder: false, + stretched: false, + withBackground: false, + } + }); + continue; + } + if (inner && inner !== '
' && inner !== '
') { + blocks.push({ type: 'paragraph', data: { text: inner } }); + } + continue; + } + + // Fallback: treat as paragraph with raw HTML + const fallbackText = el.innerHTML.trim(); + if (fallbackText) { + blocks.push({ type: 'paragraph', data: { text: fallbackText } }); + } + } + + return { time: Date.now(), blocks, version: '2.30.0' }; +} + +// ─── Editor.js OutputData → Tiptap HTML ─── + +export function editorJsToTiptapHtml(data: EditorJsOutputData): string { + if (!data || !data.blocks || data.blocks.length === 0) { + return ''; + } + + const parts: string[] = []; + + for (const block of data.blocks) { + switch (block.type) { + case 'paragraph': + parts.push(`

${block.data.text || ''}

`); + break; + + case 'header': { + const level = block.data.level || 2; + parts.push(`${block.data.text || ''}`); + break; + } + + case 'list': { + if (block.data.style === 'checklist') { + const items = (block.data.items as Array<{ content: string; meta: { checked: boolean } }>) || []; + const lis = items.map(item => + `
  • ${item.meta?.checked ? '☑ ' : '☐ '}${item.content}
  • ` + ).join(''); + parts.push(`
      ${lis}
    `); + } else { + const tag = block.data.style === 'ordered' ? 'ol' : 'ul'; + const items = (block.data.items as any[]) || []; + const lis = items.map(item => { + const content = typeof item === 'string' ? item : (item.content || ''); + return `
  • ${content}
  • `; + }).join(''); + parts.push(`<${tag}>${lis}`); + } + break; + } + + case 'checklist': { + // Keep this case for safety, though blocks should be migrated by getEditorData + const checkItems = (block.data.items as Array<{ text: string; checked: boolean }>) || []; + const lis = checkItems.map(item => + `
  • ${item.checked ? '☑ ' : '☐ '}${item.text}
  • ` + ).join(''); + parts.push(`
      ${lis}
    `); + break; + } + + case 'quote': + parts.push(`
    ${block.data.text || ''}
    `); + break; + + case 'code': + parts.push(`
    ${block.data.code || ''}
    `); + break; + + case 'delimiter': + parts.push('
    '); + break; + + case 'image': { + const fileData = block.data.file as { url?: string } | undefined; + const url = fileData?.url || ''; + const caption = (block.data.caption as string) || ''; + parts.push(`${caption}`); + break; + } + + default: + // Unknown block types: render as paragraph + if (block.data.text) { + parts.push(`

    ${block.data.text}

    `); + } + break; + } + } + + return parts.join('\n'); +} + +// ─── Validator ─── + +export function validateEditorJsData(data: unknown): data is EditorJsOutputData { + if (!data || typeof data !== 'object') return false; + const obj = data as Record; + if (!Array.isArray(obj.blocks)) return false; + return obj.blocks.every((block: unknown) => { + if (!block || typeof block !== 'object') return false; + const b = block as Record; + return typeof b.type === 'string' && typeof b.data === 'object' && b.data !== null; + }); +} diff --git a/src/i18n/translations.ts b/src/i18n/translations.ts index a33c033..c9a9892 100644 --- a/src/i18n/translations.ts +++ b/src/i18n/translations.ts @@ -148,6 +148,18 @@ export const translations = { todoListHeader: "To-Do List", addTaskTitle: "Add Task", todoEmptyState: "All caught up! Add a task to start.", + noteEditorEngine: "Note Editor Engine (Only active for new notes)", + editorTiptap: "Tiptap (Rich Text)", + editorEditorJs: "Editor.js (Block)", + editorjsPlaceholder: "Press '/' or click '+' to add a block...", + blockHeading: "Heading", + blockBulletedList: "Bulleted List", + blockNumberedList: "Numbered List", + blockChecklist: "Checklist", + blockCode: "Code", + blockQuote: "Quote", + blockImage: "Image", + blockDivider: "Divider", todoPlaceholder: "New task...", snippetVaultHeader: "Snippet Vault", searchPlaceholder: "Search snippets, tags...", @@ -411,6 +423,18 @@ export const translations = { todoListHeader: "Yapılacaklar Listesi", addTaskTitle: "Görev Ekle", todoEmptyState: "Hepsi tamam! Başlamak için bir görev ekleyin.", + noteEditorEngine: "Not Editör Motoru (Yalnızca yeni notlarda geçerlidir)", + editorTiptap: "Tiptap (Zengin Metin)", + editorEditorJs: "Editor.js (Blok)", + editorjsPlaceholder: "Blok eklemek için '/' tuşuna veya '+' simgesine tıklayın...", + blockHeading: "Başlık", + blockBulletedList: "Madde İşaretli Liste", + blockNumberedList: "Numaralı Liste", + blockChecklist: "Kontrol Listesi", + blockCode: "Kod", + blockQuote: "Alıntı", + blockImage: "Görsel", + blockDivider: "Ayırıcı", todoPlaceholder: "Yeni görev...", snippetVaultHeader: "Snippet Arşivi", searchPlaceholder: "Snippet, etiket ara...", @@ -674,6 +698,18 @@ export const translations = { todoListHeader: "To-Do-Liste", addTaskTitle: "Aufgabe hinzufügen", todoEmptyState: "Alles erledigt!", + noteEditorEngine: "Note Editor Engine (Nur für neue Notizen aktiv)", + editorTiptap: "Tiptap (Rich Text)", + editorEditorJs: "Editor.js (Block)", + editorjsPlaceholder: "Press '/' or click '+' to add a block...", + blockHeading: "Heading", + blockBulletedList: "Bulleted List", + blockNumberedList: "Numbered List", + blockChecklist: "Checklist", + blockCode: "Code", + blockQuote: "Quote", + blockImage: "Image", + blockDivider: "Divider", todoPlaceholder: "Neue Aufgabe...", snippetVaultHeader: "Snippet-Tresor", searchPlaceholder: "Suchen...", @@ -937,6 +973,18 @@ export const translations = { todoListHeader: "Liste de tâches", addTaskTitle: "Ajouter une tâche", todoEmptyState: "Tout est fait !", + noteEditorEngine: "Note Editor Engine (Actif uniquement pour les nouvelles notes)", + editorTiptap: "Tiptap (Rich Text)", + editorEditorJs: "Editor.js (Block)", + editorjsPlaceholder: "Press '/' or click '+' to add a block...", + blockHeading: "Heading", + blockBulletedList: "Bulleted List", + blockNumberedList: "Numbered List", + blockChecklist: "Checklist", + blockCode: "Code", + blockQuote: "Quote", + blockImage: "Image", + blockDivider: "Divider", todoPlaceholder: "Nouvelle tâche...", snippetVaultHeader: "Coffre à extraits", searchPlaceholder: "Rechercher...", @@ -1200,6 +1248,18 @@ export const translations = { todoListHeader: "Lista de tareas", addTaskTitle: "Añadir tarea", todoEmptyState: "¡Todo listo!", + noteEditorEngine: "Note Editor Engine (Solo activo para notas nuevas)", + editorTiptap: "Tiptap (Rich Text)", + editorEditorJs: "Editor.js (Block)", + editorjsPlaceholder: "Press '/' or click '+' to add a block...", + blockHeading: "Heading", + blockBulletedList: "Bulleted List", + blockNumberedList: "Numbered List", + blockChecklist: "Checklist", + blockCode: "Code", + blockQuote: "Quote", + blockImage: "Image", + blockDivider: "Divider", todoPlaceholder: "Nueva tarea...", snippetVaultHeader: "Bóveda de fragmentos", searchPlaceholder: "Buscar...", @@ -1463,6 +1523,18 @@ export const translations = { todoListHeader: "Список дел", addTaskTitle: "Добавить задачу", todoEmptyState: "Все выполнено!", + noteEditorEngine: "Note Editor Engine (Активно только для новых заметок)", + editorTiptap: "Tiptap (Rich Text)", + editorEditorJs: "Editor.js (Block)", + editorjsPlaceholder: "Press '/' or click '+' to add a block...", + blockHeading: "Heading", + blockBulletedList: "Bulleted List", + blockNumberedList: "Numbered List", + blockChecklist: "Checklist", + blockCode: "Code", + blockQuote: "Quote", + blockImage: "Image", + blockDivider: "Divider", todoPlaceholder: "Новая задача...", snippetVaultHeader: "Хранилище сниппетов", searchPlaceholder: "Поиск...", @@ -1726,6 +1798,18 @@ export const translations = { todoListHeader: "قائمة المهام", addTaskTitle: "إضافة مهمة", todoEmptyState: "اكتملت جميع المهام!", + noteEditorEngine: "Note Editor Engine (نشط فقط للملاحظات الجديدة)", + editorTiptap: "Tiptap (Rich Text)", + editorEditorJs: "Editor.js (Block)", + editorjsPlaceholder: "Press '/' or click '+' to add a block...", + blockHeading: "Heading", + blockBulletedList: "Bulleted List", + blockNumberedList: "Numbered List", + blockChecklist: "Checklist", + blockCode: "Code", + blockQuote: "Quote", + blockImage: "Image", + blockDivider: "Divider", todoPlaceholder: "مهمة جديدة...", snippetVaultHeader: "خزنة المقتطفات", searchPlaceholder: "بحث...", @@ -1989,6 +2073,18 @@ export const translations = { todoListHeader: "待办列表", addTaskTitle: "添加任务", todoEmptyState: "任务已全部完成!", + noteEditorEngine: "Note Editor Engine (仅对新笔记有效)", + editorTiptap: "Tiptap (Rich Text)", + editorEditorJs: "Editor.js (Block)", + editorjsPlaceholder: "Press '/' or click '+' to add a block...", + blockHeading: "Heading", + blockBulletedList: "Bulleted List", + blockNumberedList: "Numbered List", + blockChecklist: "Checklist", + blockCode: "Code", + blockQuote: "Quote", + blockImage: "Image", + blockDivider: "Divider", todoPlaceholder: "新任务...", snippetVaultHeader: "代码段库", searchPlaceholder: "搜索...", @@ -2239,6 +2335,18 @@ export const translations = { todoListHeader: "TODOリスト", addTaskTitle: "タスクを追加", todoEmptyState: "全て完了!タスクを追加してください。", + noteEditorEngine: "Note Editor Engine (新規ノートでのみ有効)", + editorTiptap: "Tiptap (Rich Text)", + editorEditorJs: "Editor.js (Block)", + editorjsPlaceholder: "Press '/' or click '+' to add a block...", + blockHeading: "Heading", + blockBulletedList: "Bulleted List", + blockNumberedList: "Numbered List", + blockChecklist: "Checklist", + blockCode: "Code", + blockQuote: "Quote", + blockImage: "Image", + blockDivider: "Divider", todoPlaceholder: "新しいタスク...", snippetVaultHeader: "スニペット保管庫", searchPlaceholder: "検索...", @@ -2502,6 +2610,18 @@ export const translations = { todoListHeader: "कार्य सूची", addTaskTitle: "कार्य जोड़ें", todoEmptyState: "सब हो गया!", + noteEditorEngine: "Note Editor Engine (केवल नए नोट्स के लिए सक्रिय)", + editorTiptap: "Tiptap (Rich Text)", + editorEditorJs: "Editor.js (Block)", + editorjsPlaceholder: "Press '/' or click '+' to add a block...", + blockHeading: "Heading", + blockBulletedList: "Bulleted List", + blockNumberedList: "Numbered List", + blockChecklist: "Checklist", + blockCode: "Code", + blockQuote: "Quote", + blockImage: "Image", + blockDivider: "Divider", todoPlaceholder: "नया कार्य...", snippetVaultHeader: "स्निपेट वॉल्ट", searchPlaceholder: "खोजें...", diff --git a/src/index.css b/src/index.css index 817f7a3..7554502 100644 --- a/src/index.css +++ b/src/index.css @@ -149,9 +149,11 @@ @layer utilities { .drag-region { + -webkit-app-region: drag; } .no-drag-region { + -webkit-app-region: no-drag; } .custom-radio { @@ -329,6 +331,201 @@ body { border-radius: 0.5rem; margin: 1rem 0; } + +/* Editor.js Dark Theme Overrides */ +.ce-block__content, +.ce-toolbar__content { + max-width: 100%; +} + +/* Editor.js Typography Fixes (Tailwind resets headings) */ +.ce-header { + font-weight: 700; + margin-bottom: 0.5rem; + color: #f8fafc; +} +h1.ce-header { font-size: 2.25rem; line-height: 2.5rem; } +h2.ce-header { font-size: 1.875rem; line-height: 2.25rem; } +h3.ce-header { font-size: 1.5rem; line-height: 2rem; } +h4.ce-header { font-size: 1.25rem; line-height: 1.75rem; } +h5.ce-header { font-size: 1.125rem; line-height: 1.75rem; } +h6.ce-header { font-size: 1rem; line-height: 1.5rem; } + +/* Adjust block spacing to prevent overlapping with plus button */ +.ce-block__content { + padding-left: 36px !important; +} + +/* Base Popovers and Toolbars */ +.ce-toolbar { + z-index: 9999 !important; +} + +.ce-inline-toolbar, +.ce-conversion-toolbar, +.ce-settings, +.ce-popover, +.ce-popover__container { + background-color: var(--theme-bg-base) !important; + border: 1px solid var(--theme-border) !important; + box-shadow: 0 4px 12px rgba(0, 0, 0, 0.5) !important; + color: #cbd5e1 !important; + -webkit-app-region: no-drag !important; + pointer-events: auto !important; + z-index: 9999 !important; +} + +/* Popover Search/Filter */ +.cdx-search-field { + background-color: var(--theme-bg-dark) !important; + border-bottom: 1px solid var(--theme-border) !important; + color: #cbd5e1 !important; +} +.cdx-search-field__input { + color: #e2e8f0 !important; +} +.cdx-search-field__icon svg { + fill: #94a3b8 !important; +} + +/* Popover Items */ +.ce-popover-item, +.ce-popover__item { + color: #cbd5e1 !important; +} +.ce-popover-item:hover, +.ce-popover__item:hover { + background-color: rgba(255,255,255,0.05) !important; + color: var(--theme-primary) !important; +} +.ce-popover-item--active, +.ce-popover__item--active { + color: var(--theme-primary) !important; + background-color: rgba(255,255,255,0.03) !important; +} +.ce-popover-item__icon, +.ce-popover__item-icon { + color: inherit !important; + background-color: transparent !important; + box-shadow: none !important; + border: none !important; +} +.ce-popover-item:hover .ce-popover-item__icon, +.ce-popover__item:hover .ce-popover__item-icon { + background-color: transparent !important; +} + +/* Toolbar Buttons (Drag Handle, Plus) */ +.ce-toolbar__settings-btn { + color: #94a3b8 !important; + background-color: transparent !important; +} +.ce-toolbar__settings-btn:hover { + color: var(--theme-primary) !important; + background-color: var(--theme-bg-base) !important; +} + +/* Hide native plus button */ +.ce-toolbar__plus { + display: none !important; +} + +/* Custom plus trigger button hover */ +.editorjs-plus-trigger-btn { + transition: transform 0.1s ease, background-color 0.1s ease; +} +.editorjs-plus-trigger-btn:hover { + background-color: var(--theme-bg-base) !important; + transform: scale(1.15); +} + +/* Block Selection (prevent white left border) */ +.ce-block--selected .ce-block__content { + background-color: rgba(255, 255, 255, 0.03) !important; + border-radius: 8px !important; +} +.ce-block--selected .cdx-block { + background-color: transparent !important; +} + +/* Inline Tools */ +.ce-inline-toolbar__buttons, +.ce-conversion-toolbar__buttons { + color: #cbd5e1; +} + +.ce-inline-tool, +.ce-conversion-tool, +.ce-settings__button { + color: #cbd5e1 !important; +} + +.ce-inline-tool:hover, +.ce-conversion-tool:hover, +.ce-settings__button:hover { + background-color: rgba(255,255,255,0.05) !important; + color: var(--theme-primary) !important; +} + +.ce-inline-tool--active { + color: var(--theme-primary) !important; +} + +/* Table Tool (if used later) */ +.tc-toolbox, .tc-popover { + background-color: var(--theme-bg-base) !important; + border: 1px solid var(--theme-border) !important; + color: #cbd5e1 !important; + -webkit-app-region: no-drag !important; + pointer-events: auto !important; +} + +.tc-popover__item:hover { + background-color: rgba(255,255,255,0.05) !important; +} + +.codex-editor { + color: #cbd5e1; + font-size: 1.125rem; + line-height: 1.75; +} + +.codex-editor * { + user-select: text !important; + -webkit-user-select: text !important; +} + +/* Quote Redesign */ +.cdx-quote { + border-left: 4px solid var(--theme-primary) !important; + background-color: rgba(255, 255, 255, 0.03) !important; + padding: 1rem 1.5rem !important; + border-radius: 0 0.75rem 0.75rem 0 !important; + margin: 1.5rem 0 !important; + position: relative; + box-shadow: inset 0 0 20px rgba(0, 0, 0, 0.1); +} +.cdx-quote::before { + content: '"'; + position: absolute; + left: 10px; + top: -10px; + font-size: 3rem; + color: var(--theme-primary); + opacity: 0.2; + font-family: serif; + pointer-events: none; +} +.cdx-quote__text { + font-style: italic; + color: #e2e8f0; + margin-bottom: 0.5rem !important; +} +.cdx-quote__caption { + font-size: 0.875rem; + color: #94a3b8; +} + /* Prevent accidental text/element selection globally */ * { user-select: none; diff --git a/src/store/useAppStore.ts b/src/store/useAppStore.ts index 323ac12..c214db2 100644 --- a/src/store/useAppStore.ts +++ b/src/store/useAppStore.ts @@ -62,6 +62,8 @@ export interface Note { icon: string; emoji: string | null; content: string; + editorType: 'tiptap' | 'editorjs'; + editorjsData?: string; isSettings?: boolean; } @@ -181,9 +183,13 @@ interface AppState { addNote: () => void; deleteNote: (id: number) => void; updateNoteContent: (id: number, content: string) => void; + updateNoteEditorjsData: (id: number, data: string) => void; updateNoteTitle: (id: number, title: string) => void; updateNoteEmoji: (id: number, emoji: string) => void; openSettingsTab: () => void; + // Note Editor Engine Preference + preferredNoteEditor: 'tiptap' | 'editorjs'; + setPreferredNoteEditor: (editor: 'tiptap' | 'editorjs') => void; // App Launcher pinnedApps: PinnedApp[]; pinApp: (app: PinnedApp) => void; @@ -436,6 +442,7 @@ const defaultNotes: Note[] = [ title: 'Welcome to KoBar!', icon: 'waving_hand', emoji: '👋', + editorType: 'tiptap', content: `

    Your modular, always-on-top desktop utility sidebar.

    A multi-threaded creative assistant that lives on the edge of your screen.

    @@ -877,6 +884,9 @@ export const useAppStore = create()( activeNoteId: 1, nextNoteId: 2, setActiveNoteId: (id) => set({ activeNoteId: id }), + // Note Editor Engine Preference + preferredNoteEditor: 'tiptap', + setPreferredNoteEditor: (editor) => set({ preferredNoteEditor: editor }), addNote: () => set((state) => { const newNote: Note = { id: state.nextNoteId, @@ -884,6 +894,7 @@ export const useAppStore = create()( icon: 'note', emoji: null, content: '', + editorType: state.preferredNoteEditor, }; return { notes: [...state.notes, newNote], @@ -902,6 +913,9 @@ export const useAppStore = create()( updateNoteContent: (id, content) => set((state) => ({ notes: state.notes.map(n => n.id === id ? { ...n, content } : n), })), + updateNoteEditorjsData: (id, data) => set((state) => ({ + notes: state.notes.map(n => n.id === id ? { ...n, editorjsData: data } : n), + })), updateNoteTitle: (id, title) => set((state) => ({ notes: state.notes.map(n => n.id === id ? { ...n, title } : n), })), @@ -920,6 +934,7 @@ export const useAppStore = create()( icon: 'settings', emoji: null, content: '', + editorType: 'tiptap', isSettings: true, }; nextNotes = [...state.notes, settingsNote]; @@ -1105,7 +1120,7 @@ export const useAppStore = create()( }), { name: 'kobar-storage', - version: 18, + version: 19, migrate: (persistedState: any, version: number) => { // version 18 migration for orientation if (version <= 17) { @@ -1277,6 +1292,20 @@ export const useAppStore = create()( } } + // version 19 migration for Editor.js support + if (version <= 18) { + // Add editorType to all existing notes that lack it + if (persistedState.notes) { + persistedState.notes = persistedState.notes.map((n: any) => ({ + ...n, + editorType: n.editorType || 'tiptap', + })); + } + if (persistedState.preferredNoteEditor === undefined) { + persistedState.preferredNoteEditor = 'tiptap'; + } + } + return persistedState; }, partialize: (state) => ({ @@ -1330,6 +1359,7 @@ export const useAppStore = create()( settingsWorkspaceViewMode: state.settingsWorkspaceViewMode, orientation: state.orientation, edgePosition: state.edgePosition, + preferredNoteEditor: state.preferredNoteEditor, }), onRehydrateStorage: (_) => { console.log('[Store] Hydration starting...'); diff --git a/test-editor.cjs b/test-editor.cjs new file mode 100644 index 0000000..15843ac --- /dev/null +++ b/test-editor.cjs @@ -0,0 +1,65 @@ +const { JSDOM } = require('jsdom'); +const dom = new JSDOM(`
    `, { + url: "http://localhost/", + pretendToBeVisual: true +}); +global.window = dom.window; +global.document = dom.window.document; +global.navigator = dom.window.navigator; +global.HTMLElement = dom.window.HTMLElement; +global.Element = dom.window.Element; +global.Node = dom.window.Node; + +// Import editor.js dynamically since it might be an ES module +async function run() { + const EditorJS = (await import('@editorjs/editorjs')).default; + const List = (await import('@editorjs/list')).default; + const Header = (await import('@editorjs/header')).default; + + const editor = new EditorJS({ + holder: 'editorjs', + tools: { + header: Header, + list: { + class: List, + inlineToolbar: true, + } + }, + onReady: () => { + console.log("Editor is ready!"); + + // Dump all popover item titles + const popoverItems = document.querySelectorAll('.ce-popover-item__title'); + if (popoverItems.length > 0) { + console.log("Popover items found:"); + popoverItems.forEach(el => console.log(el.textContent)); + } + + // Instead of relying on DOM, let's look at editor modules + // This is hacky, but maybe we can trigger the conversion menu + try { + // Create a block + editor.blocks.insert('paragraph', {text: 'test'}); + // Try to open block tunes + setTimeout(() => { + const tuneButton = document.querySelector('.ce-toolbar__settings-btn'); + if (tuneButton) { + tuneButton.click(); + console.log("Clicked tune button"); + setTimeout(() => { + const titles = document.querySelectorAll('.ce-popover-item__title'); + const allTitles = Array.from(titles).map(el => el.textContent); + console.log("Convert To Menu Items: ", allTitles); + }, 500); + } else { + console.log("Tune button not found"); + } + }, 500); + } catch (e) { + console.error(e); + } + } + }); +} + +run(); diff --git a/tsconfig.app.json b/tsconfig.app.json index a9b5a59..c8fd289 100644 --- a/tsconfig.app.json +++ b/tsconfig.app.json @@ -20,7 +20,6 @@ "strict": true, "noUnusedLocals": true, "noUnusedParameters": true, - "erasableSyntaxOnly": true, "noFallthroughCasesInSwitch": true, "noUncheckedSideEffectImports": true }, diff --git a/tsconfig.node.json b/tsconfig.node.json index 8a67f62..b7c768c 100644 --- a/tsconfig.node.json +++ b/tsconfig.node.json @@ -18,7 +18,6 @@ "strict": true, "noUnusedLocals": true, "noUnusedParameters": true, - "erasableSyntaxOnly": true, "noFallthroughCasesInSwitch": true, "noUncheckedSideEffectImports": true }, diff --git a/update_translations.cjs b/update_translations.cjs new file mode 100644 index 0000000..a060a10 --- /dev/null +++ b/update_translations.cjs @@ -0,0 +1,44 @@ +const fs = require('fs'); +const path = require('path'); + +const filePath = path.join(__dirname, 'src', 'i18n', 'translations.ts'); +let content = fs.readFileSync(filePath, 'utf8'); + +const keysEn = ` + noteEditorEngine: "Note Editor Engine", + editorTiptap: "Tiptap (Rich Text)", + editorEditorJs: "Editor.js (Block)", + editorjsPlaceholder: "Press '/' or click '+' to add a block...", + blockHeading: "Heading", + blockBulletedList: "Bulleted List", + blockNumberedList: "Numbered List", + blockChecklist: "Checklist", + blockCode: "Code", + blockQuote: "Quote", + blockImage: "Image", + blockDivider: "Divider"`; + +const keysTr = ` + noteEditorEngine: "Not Editör Motoru", + editorTiptap: "Tiptap (Zengin Metin)", + editorEditorJs: "Editor.js (Blok)", + editorjsPlaceholder: "Blok eklemek için '/' tuşuna veya '+' simgesine tıklayın...", + blockHeading: "Başlık", + blockBulletedList: "Madde İşaretli Liste", + blockNumberedList: "Numaralı Liste", + blockChecklist: "Kontrol Listesi", + blockCode: "Kod", + blockQuote: "Alıntı", + blockImage: "Görsel", + blockDivider: "Ayırıcı"`; + +content = content.replace(/(todoEmptyState:\s*".*?")/g, (match) => { + if (match.includes("Hepsi tamam")) { + return match + "," + keysTr; + } else { + return match + "," + keysEn; + } +}); + +fs.writeFileSync(filePath, content, 'utf8'); +console.log("Translations fixed successfully!");