diff --git a/package-lock.json b/package-lock.json index a26285a..1c4b6d9 100644 --- a/package-lock.json +++ b/package-lock.json @@ -8,8 +8,17 @@ "name": "www.cialloo.com", "version": "0.0.0", "dependencies": { + "@lexical/clipboard": "^0.37.0", + "@lexical/history": "^0.37.0", + "@lexical/link": "^0.37.0", + "@lexical/list": "^0.37.0", + "@lexical/react": "^0.37.0", + "@lexical/rich-text": "^0.37.0", + "@lexical/selection": "^0.37.0", + "@lexical/utils": "^0.37.0", "i18next": "^25.5.3", "i18next-browser-languagedetector": "^8.2.0", + "lexical": "^0.37.0", "react": "^19.1.1", "react-dom": "^19.1.1", "react-i18next": "^16.0.0", @@ -508,6 +517,59 @@ "node": "^18.18.0 || ^20.9.0 || >=21.1.0" } }, + "node_modules/@floating-ui/core": { + "version": "1.7.3", + "resolved": "https://registry.npmjs.org/@floating-ui/core/-/core-1.7.3.tgz", + "integrity": "sha512-sGnvb5dmrJaKEZ+LDIpguvdX3bDlEllmv4/ClQ9awcmCZrlx5jQyyMWFM5kBI+EyNOCDDiKk8il0zeuX3Zlg/w==", + "license": "MIT", + "dependencies": { + "@floating-ui/utils": "^0.2.10" + } + }, + "node_modules/@floating-ui/dom": { + "version": "1.7.4", + "resolved": "https://registry.npmjs.org/@floating-ui/dom/-/dom-1.7.4.tgz", + "integrity": "sha512-OOchDgh4F2CchOX94cRVqhvy7b3AFb+/rQXyswmzmGakRfkMgoWVjfnLWkRirfLEfuD4ysVW16eXzwt3jHIzKA==", + "license": "MIT", + "dependencies": { + "@floating-ui/core": "^1.7.3", + "@floating-ui/utils": "^0.2.10" + } + }, + "node_modules/@floating-ui/react": { + "version": "0.27.16", + "resolved": "https://registry.npmjs.org/@floating-ui/react/-/react-0.27.16.tgz", + "integrity": "sha512-9O8N4SeG2z++TSM8QA/KTeKFBVCNEz/AGS7gWPJf6KFRzmRWixFRnCnkPHRDwSVZW6QPDO6uT0P2SpWNKCc9/g==", + "license": "MIT", + "dependencies": { + "@floating-ui/react-dom": "^2.1.6", + "@floating-ui/utils": "^0.2.10", + "tabbable": "^6.0.0" + }, + "peerDependencies": { + "react": ">=17.0.0", + "react-dom": ">=17.0.0" + } + }, + "node_modules/@floating-ui/react-dom": { + "version": "2.1.6", + "resolved": "https://registry.npmjs.org/@floating-ui/react-dom/-/react-dom-2.1.6.tgz", + "integrity": "sha512-4JX6rEatQEvlmgU80wZyq9RT96HZJa88q8hp0pBd+LrczeDI4o6uA2M+uvxngVHo4Ihr8uibXxH6+70zhAFrVw==", + "license": "MIT", + "dependencies": { + "@floating-ui/dom": "^1.7.4" + }, + "peerDependencies": { + "react": ">=16.8.0", + "react-dom": ">=16.8.0" + } + }, + "node_modules/@floating-ui/utils": { + "version": "0.2.10", + "resolved": "https://registry.npmjs.org/@floating-ui/utils/-/utils-0.2.10.tgz", + "integrity": "sha512-aGTxbpbg8/b5JfU1HXSrbH3wXZuLPJcNEcZQFMxLs3oSzgtVu6nFPkbbGGUvBcUjKV2YyB9Wxxabo+HEH9tcRQ==", + "license": "MIT" + }, "node_modules/@humanfs/core": { "version": "0.19.1", "resolved": "https://registry.npmjs.org/@humanfs/core/-/core-0.19.1.tgz", @@ -610,6 +672,281 @@ "@jridgewell/sourcemap-codec": "^1.4.14" } }, + "node_modules/@lexical/clipboard": { + "version": "0.37.0", + "resolved": "https://registry.npmjs.org/@lexical/clipboard/-/clipboard-0.37.0.tgz", + "integrity": "sha512-hRwASFX/ilaI5r8YOcZuQgONFshRgCPfdxfofNL7uruSFYAO6LkUhsjzZwUgf0DbmCJmbBADFw15FSthgCUhGA==", + "license": "MIT", + "dependencies": { + "@lexical/html": "0.37.0", + "@lexical/list": "0.37.0", + "@lexical/selection": "0.37.0", + "@lexical/utils": "0.37.0", + "lexical": "0.37.0" + } + }, + "node_modules/@lexical/code": { + "version": "0.37.0", + "resolved": "https://registry.npmjs.org/@lexical/code/-/code-0.37.0.tgz", + "integrity": "sha512-ZXA4j/S8yLrxjrTnEp39VeDMp4Rd8bLYUlT4Buy1MQlS1WafxOiMhNQJG7k0BP/pO96YPkAebpA81ATKJL0IgA==", + "license": "MIT", + "dependencies": { + "@lexical/utils": "0.37.0", + "lexical": "0.37.0", + "prismjs": "^1.30.0" + } + }, + "node_modules/@lexical/devtools-core": { + "version": "0.37.0", + "resolved": "https://registry.npmjs.org/@lexical/devtools-core/-/devtools-core-0.37.0.tgz", + "integrity": "sha512-iOR+aKLJR92nKYcEOW3K/bgjTN7dJIRC/OM4OvzigU0Xygxped0lXV6UmkYBp0eoqOOwckB8+rZWZszj9lKA8Q==", + "license": "MIT", + "dependencies": { + "@lexical/html": "0.37.0", + "@lexical/link": "0.37.0", + "@lexical/mark": "0.37.0", + "@lexical/table": "0.37.0", + "@lexical/utils": "0.37.0", + "lexical": "0.37.0" + }, + "peerDependencies": { + "react": ">=17.x", + "react-dom": ">=17.x" + } + }, + "node_modules/@lexical/dragon": { + "version": "0.37.0", + "resolved": "https://registry.npmjs.org/@lexical/dragon/-/dragon-0.37.0.tgz", + "integrity": "sha512-iC4OKivEPtt7cGVSwZylLfz5T7Oqr9q9EOosS6E/byMyoqwkYWGjXn/qFiwIv1Xo3+G19vhfChi/+ZcYLXpHPw==", + "license": "MIT", + "dependencies": { + "@lexical/extension": "0.37.0", + "lexical": "0.37.0" + } + }, + "node_modules/@lexical/extension": { + "version": "0.37.0", + "resolved": "https://registry.npmjs.org/@lexical/extension/-/extension-0.37.0.tgz", + "integrity": "sha512-Z58f2tIdz9bn8gltUu5cVg37qROGha38dUZv20gI2GeNugXAkoPzJYEcxlI1D/26tkevJ/7VaFUr9PTk+iKmaA==", + "license": "MIT", + "dependencies": { + "@lexical/utils": "0.37.0", + "@preact/signals-core": "^1.11.0", + "lexical": "0.37.0" + } + }, + "node_modules/@lexical/hashtag": { + "version": "0.37.0", + "resolved": "https://registry.npmjs.org/@lexical/hashtag/-/hashtag-0.37.0.tgz", + "integrity": "sha512-DHoDpiokJRBu+GnC0qQH529hamn9YNjL7vzzkTAeEMKsT9+4O848Cq6F2GJn8QjQToySlkVZW3mkh76uf/XLfg==", + "license": "MIT", + "dependencies": { + "@lexical/text": "0.37.0", + "@lexical/utils": "0.37.0", + "lexical": "0.37.0" + } + }, + "node_modules/@lexical/history": { + "version": "0.37.0", + "resolved": "https://registry.npmjs.org/@lexical/history/-/history-0.37.0.tgz", + "integrity": "sha512-QKkrWCw4bsn/ZeLIkMVIpbtWKPhMYeax1nE7erHqTEwE52QR6pmZsZBgGSQDO73Ae29vahOmqlN7+ZJFvTKMVA==", + "license": "MIT", + "dependencies": { + "@lexical/extension": "0.37.0", + "@lexical/utils": "0.37.0", + "lexical": "0.37.0" + } + }, + "node_modules/@lexical/html": { + "version": "0.37.0", + "resolved": "https://registry.npmjs.org/@lexical/html/-/html-0.37.0.tgz", + "integrity": "sha512-oTsBc45eL8/lmF7fqGR+UCjrJYP04gumzf5nk4TczrxWL2pM4GIMLLKG1mpQI2H1MDiRLzq3T/xdI7Gh74z7Zw==", + "license": "MIT", + "dependencies": { + "@lexical/selection": "0.37.0", + "@lexical/utils": "0.37.0", + "lexical": "0.37.0" + } + }, + "node_modules/@lexical/link": { + "version": "0.37.0", + "resolved": "https://registry.npmjs.org/@lexical/link/-/link-0.37.0.tgz", + "integrity": "sha512-gglkjE99tKYnGAxQbrUq9TcaVKBQhidXhgPPbVw3x1Fba9biMafkbSJhE/7/pzQTPoQBAIl0w7DOUWmBOv+JbQ==", + "license": "MIT", + "dependencies": { + "@lexical/extension": "0.37.0", + "@lexical/utils": "0.37.0", + "lexical": "0.37.0" + } + }, + "node_modules/@lexical/list": { + "version": "0.37.0", + "resolved": "https://registry.npmjs.org/@lexical/list/-/list-0.37.0.tgz", + "integrity": "sha512-AOC6yAA3mfNvJKbwo+kvAbPJI+13yF2ISA65vbA578CugvJ08zIVgM+pSzxquGhD0ioJY3cXVW7+gdkCP1qu5g==", + "license": "MIT", + "dependencies": { + "@lexical/extension": "0.37.0", + "@lexical/selection": "0.37.0", + "@lexical/utils": "0.37.0", + "lexical": "0.37.0" + } + }, + "node_modules/@lexical/mark": { + "version": "0.37.0", + "resolved": "https://registry.npmjs.org/@lexical/mark/-/mark-0.37.0.tgz", + "integrity": "sha512-ncjaL6kNHVioekx6vI5oJRDExFDJLbnXT7AdMnUv2LE3sxn/ea+JsZO/MDI4Ygmxq+lGtgZvbBDER8Yh/+5jdA==", + "license": "MIT", + "dependencies": { + "@lexical/utils": "0.37.0", + "lexical": "0.37.0" + } + }, + "node_modules/@lexical/markdown": { + "version": "0.37.0", + "resolved": "https://registry.npmjs.org/@lexical/markdown/-/markdown-0.37.0.tgz", + "integrity": "sha512-pcLMpxWkSxU2QaN2GLA3hNy4lV2A8sJOvb5YEkcsFEcVvFFbAz7lxgyKVYtDboRCW1eZFks1UGGuJEogLeEFdg==", + "license": "MIT", + "dependencies": { + "@lexical/code": "0.37.0", + "@lexical/link": "0.37.0", + "@lexical/list": "0.37.0", + "@lexical/rich-text": "0.37.0", + "@lexical/text": "0.37.0", + "@lexical/utils": "0.37.0", + "lexical": "0.37.0" + } + }, + "node_modules/@lexical/offset": { + "version": "0.37.0", + "resolved": "https://registry.npmjs.org/@lexical/offset/-/offset-0.37.0.tgz", + "integrity": "sha512-q9Ckftfhb+VepJQeaClOYzpuV+WqWWGkSUuoexV4zjAm/HVjOie9lrNF4NkhQe5crnIBXI5zOofhuEfiCQWsbQ==", + "license": "MIT", + "dependencies": { + "lexical": "0.37.0" + } + }, + "node_modules/@lexical/overflow": { + "version": "0.37.0", + "resolved": "https://registry.npmjs.org/@lexical/overflow/-/overflow-0.37.0.tgz", + "integrity": "sha512-GC5qoQJQzaofCq1eMMvv9wIGMAbpFbFwny5BKA1C2Nmn+/2bi6v+7qlHwiBlbSVqfLVPvT4nYdrmNdnKoE0jZg==", + "license": "MIT", + "dependencies": { + "lexical": "0.37.0" + } + }, + "node_modules/@lexical/plain-text": { + "version": "0.37.0", + "resolved": "https://registry.npmjs.org/@lexical/plain-text/-/plain-text-0.37.0.tgz", + "integrity": "sha512-4IxG9Tr0NnQ+clN1eoXfe2W8JTgw0xtPMzqvHP2IaO7RILUE6H8VFSOdhAOI0dHrjlXRMUS3I2Fhqr2ZRq8kdQ==", + "license": "MIT", + "dependencies": { + "@lexical/clipboard": "0.37.0", + "@lexical/dragon": "0.37.0", + "@lexical/selection": "0.37.0", + "@lexical/utils": "0.37.0", + "lexical": "0.37.0" + } + }, + "node_modules/@lexical/react": { + "version": "0.37.0", + "resolved": "https://registry.npmjs.org/@lexical/react/-/react-0.37.0.tgz", + "integrity": "sha512-PGIGmI5xDSAguqpAStd+89TfWsi6hs/R4a3hQAyNwXXDEt4anUFJic4Qet4YftybLGajP3vMvouLE5hrkmBihg==", + "license": "MIT", + "dependencies": { + "@floating-ui/react": "^0.27.16", + "@lexical/devtools-core": "0.37.0", + "@lexical/dragon": "0.37.0", + "@lexical/extension": "0.37.0", + "@lexical/hashtag": "0.37.0", + "@lexical/history": "0.37.0", + "@lexical/link": "0.37.0", + "@lexical/list": "0.37.0", + "@lexical/mark": "0.37.0", + "@lexical/markdown": "0.37.0", + "@lexical/overflow": "0.37.0", + "@lexical/plain-text": "0.37.0", + "@lexical/rich-text": "0.37.0", + "@lexical/table": "0.37.0", + "@lexical/text": "0.37.0", + "@lexical/utils": "0.37.0", + "@lexical/yjs": "0.37.0", + "lexical": "0.37.0", + "react-error-boundary": "^6.0.0" + }, + "peerDependencies": { + "react": ">=17.x", + "react-dom": ">=17.x" + } + }, + "node_modules/@lexical/rich-text": { + "version": "0.37.0", + "resolved": "https://registry.npmjs.org/@lexical/rich-text/-/rich-text-0.37.0.tgz", + "integrity": "sha512-A9i5Es/RrZv71tB6dDSyd4TYdbkn/+oUrUdTwnWa+B8EZW26q0h+wgxCGwPtTU7ho4JNP9HOot+EIhe2DbyaYg==", + "license": "MIT", + "dependencies": { + "@lexical/clipboard": "0.37.0", + "@lexical/dragon": "0.37.0", + "@lexical/selection": "0.37.0", + "@lexical/utils": "0.37.0", + "lexical": "0.37.0" + } + }, + "node_modules/@lexical/selection": { + "version": "0.37.0", + "resolved": "https://registry.npmjs.org/@lexical/selection/-/selection-0.37.0.tgz", + "integrity": "sha512-Lix1s2r71jHfsTEs4q/YqK2s3uXKOnyA3fd1VDMWysO+bZzRwEO5+qyDvENZ0WrXSDCnlibNFV1HttWX9/zqyw==", + "license": "MIT", + "dependencies": { + "lexical": "0.37.0" + } + }, + "node_modules/@lexical/table": { + "version": "0.37.0", + "resolved": "https://registry.npmjs.org/@lexical/table/-/table-0.37.0.tgz", + "integrity": "sha512-g7S8ml8kIujEDLWlzYKETgPCQ2U9oeWqdytRuHjHGi/rjAAGHSej5IRqTPIMxNP3VVQHnBoQ+Y9hBtjiuddhgQ==", + "license": "MIT", + "dependencies": { + "@lexical/clipboard": "0.37.0", + "@lexical/extension": "0.37.0", + "@lexical/utils": "0.37.0", + "lexical": "0.37.0" + } + }, + "node_modules/@lexical/text": { + "version": "0.37.0", + "resolved": "https://registry.npmjs.org/@lexical/text/-/text-0.37.0.tgz", + "integrity": "sha512-qByNjHp88mlUWHxfYutH4vhSs3nzfCGHKsf/MqUMOC8K7Kmp0V1NK6cOW1sgsHpzkovfpgcNOGDzZxTNCFgHtg==", + "license": "MIT", + "dependencies": { + "lexical": "0.37.0" + } + }, + "node_modules/@lexical/utils": { + "version": "0.37.0", + "resolved": "https://registry.npmjs.org/@lexical/utils/-/utils-0.37.0.tgz", + "integrity": "sha512-CFp4diY/kR5RqhzQSl/7SwsMod1sgLpI1FBifcOuJ6L/S6YywGpEB4B7aV5zqW21A/jU2T+2NZtxSUn6S+9gMg==", + "license": "MIT", + "dependencies": { + "@lexical/list": "0.37.0", + "@lexical/selection": "0.37.0", + "@lexical/table": "0.37.0", + "lexical": "0.37.0" + } + }, + "node_modules/@lexical/yjs": { + "version": "0.37.0", + "resolved": "https://registry.npmjs.org/@lexical/yjs/-/yjs-0.37.0.tgz", + "integrity": "sha512-7UjHvXDd+Is/qTdNkpQ/K04Zduh2uh7UTlSWbMiqwbQh8VRJNXXgcH8iK0TXLwc7M3VgVk+FlnNApNvcReKB6g==", + "license": "MIT", + "dependencies": { + "@lexical/offset": "0.37.0", + "@lexical/selection": "0.37.0", + "lexical": "0.37.0" + }, + "peerDependencies": { + "yjs": ">=13.5.22" + } + }, "node_modules/@napi-rs/wasm-runtime": { "version": "1.0.5", "resolved": "https://registry.npmjs.org/@napi-rs/wasm-runtime/-/wasm-runtime-1.0.5.tgz", @@ -681,6 +1018,16 @@ "url": "https://github.com/sponsors/Boshen" } }, + "node_modules/@preact/signals-core": { + "version": "1.12.1", + "resolved": "https://registry.npmjs.org/@preact/signals-core/-/signals-core-1.12.1.tgz", + "integrity": "sha512-BwbTXpj+9QutoZLQvbttRg5x3l5468qaV2kufh+51yha1c53ep5dY4kTuZR35+3pAZxpfQerGJiQqg34ZNZ6uA==", + "license": "MIT", + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/preact" + } + }, "node_modules/@rolldown/binding-android-arm64": { "version": "1.0.0-beta.39", "resolved": "https://registry.npmjs.org/@rolldown/binding-android-arm64/-/binding-android-arm64-1.0.0-beta.39.tgz", @@ -2131,6 +2478,17 @@ "dev": true, "license": "ISC" }, + "node_modules/isomorphic.js": { + "version": "0.2.5", + "resolved": "https://registry.npmjs.org/isomorphic.js/-/isomorphic.js-0.2.5.tgz", + "integrity": "sha512-PIeMbHqMt4DnUP3MA/Flc0HElYjMXArsw1qwJZcm9sqR8mq3l8NYizFMty0pWwE/tzIGH3EKK5+jes5mAr85yw==", + "license": "MIT", + "peer": true, + "funding": { + "type": "GitHub Sponsors ❤", + "url": "https://github.com/sponsors/dmonad" + } + }, "node_modules/js-tokens": { "version": "4.0.0", "resolved": "https://registry.npmjs.org/js-tokens/-/js-tokens-4.0.0.tgz", @@ -2222,6 +2580,34 @@ "node": ">= 0.8.0" } }, + "node_modules/lexical": { + "version": "0.37.0", + "resolved": "https://registry.npmjs.org/lexical/-/lexical-0.37.0.tgz", + "integrity": "sha512-r5VJR2TioQPAsZATfktnJFrGIiy6gjQN8b/+0a2u1d7/QTH7lhbB7byhGSvcq1iaa1TV/xcf/pFV55a5V5hTDQ==", + "license": "MIT" + }, + "node_modules/lib0": { + "version": "0.2.114", + "resolved": "https://registry.npmjs.org/lib0/-/lib0-0.2.114.tgz", + "integrity": "sha512-gcxmNFzA4hv8UYi8j43uPlQ7CGcyMJ2KQb5kZASw6SnAKAf10hK12i2fjrS3Cl/ugZa5Ui6WwIu1/6MIXiHttQ==", + "license": "MIT", + "peer": true, + "dependencies": { + "isomorphic.js": "^0.2.4" + }, + "bin": { + "0ecdsa-generate-keypair": "bin/0ecdsa-generate-keypair.js", + "0gentesthtml": "bin/gentesthtml.js", + "0serve": "bin/0serve.js" + }, + "engines": { + "node": ">=16" + }, + "funding": { + "type": "GitHub Sponsors ❤", + "url": "https://github.com/sponsors/dmonad" + } + }, "node_modules/lightningcss": { "version": "1.30.2", "resolved": "https://registry.npmjs.org/lightningcss/-/lightningcss-1.30.2.tgz", @@ -2735,6 +3121,15 @@ "node": ">= 0.8.0" } }, + "node_modules/prismjs": { + "version": "1.30.0", + "resolved": "https://registry.npmjs.org/prismjs/-/prismjs-1.30.0.tgz", + "integrity": "sha512-DEvV2ZF2r2/63V+tK8hQvrR2ZGn10srHbXviTlcv7Kpzw8jWiNTqbVgjO3IY8RxrrOUF8VPMQQFysYYYv0YZxw==", + "license": "MIT", + "engines": { + "node": ">=6" + } + }, "node_modules/punycode": { "version": "2.3.1", "resolved": "https://registry.npmjs.org/punycode/-/punycode-2.3.1.tgz", @@ -2787,6 +3182,18 @@ "react": "^19.1.1" } }, + "node_modules/react-error-boundary": { + "version": "6.0.0", + "resolved": "https://registry.npmjs.org/react-error-boundary/-/react-error-boundary-6.0.0.tgz", + "integrity": "sha512-gdlJjD7NWr0IfkPlaREN2d9uUZUlksrfOx7SX62VRerwXbMY6ftGCIZua1VG1aXFNOimhISsTq+Owp725b9SiA==", + "license": "MIT", + "dependencies": { + "@babel/runtime": "^7.12.5" + }, + "peerDependencies": { + "react": ">=16.13.1" + } + }, "node_modules/react-i18next": { "version": "16.0.0", "resolved": "https://registry.npmjs.org/react-i18next/-/react-i18next-16.0.0.tgz", @@ -3028,6 +3435,12 @@ "node": ">=8" } }, + "node_modules/tabbable": { + "version": "6.2.0", + "resolved": "https://registry.npmjs.org/tabbable/-/tabbable-6.2.0.tgz", + "integrity": "sha512-Cat63mxsVJlzYvN51JmVXIgNoUokrIaT2zLclCXjRd8boZ0004U4KCs/sToJ75C6sdlByWxpYnb5Boif1VSFew==", + "license": "MIT" + }, "node_modules/tinyglobby": { "version": "0.2.15", "resolved": "https://registry.npmjs.org/tinyglobby/-/tinyglobby-0.2.15.tgz", @@ -3352,6 +3765,24 @@ "dev": true, "license": "ISC" }, + "node_modules/yjs": { + "version": "13.6.27", + "resolved": "https://registry.npmjs.org/yjs/-/yjs-13.6.27.tgz", + "integrity": "sha512-OIDwaflOaq4wC6YlPBy2L6ceKeKuF7DeTxx+jPzv1FHn9tCZ0ZwSRnUBxD05E3yed46fv/FWJbvR+Ud7x0L7zw==", + "license": "MIT", + "peer": true, + "dependencies": { + "lib0": "^0.2.99" + }, + "engines": { + "node": ">=16.0.0", + "npm": ">=8.0.0" + }, + "funding": { + "type": "GitHub Sponsors ❤", + "url": "https://github.com/sponsors/dmonad" + } + }, "node_modules/yocto-queue": { "version": "0.1.0", "resolved": "https://registry.npmjs.org/yocto-queue/-/yocto-queue-0.1.0.tgz", diff --git a/package.json b/package.json index 5adffe9..9440046 100644 --- a/package.json +++ b/package.json @@ -10,8 +10,17 @@ "preview": "vite preview" }, "dependencies": { + "@lexical/clipboard": "^0.37.0", + "@lexical/history": "^0.37.0", + "@lexical/link": "^0.37.0", + "@lexical/list": "^0.37.0", + "@lexical/react": "^0.37.0", + "@lexical/rich-text": "^0.37.0", + "@lexical/selection": "^0.37.0", + "@lexical/utils": "^0.37.0", "i18next": "^25.5.3", "i18next-browser-languagedetector": "^8.2.0", + "lexical": "^0.37.0", "react": "^19.1.1", "react-dom": "^19.1.1", "react-i18next": "^16.0.0", diff --git a/src/editor/Editor.css b/src/editor/Editor.css new file mode 100644 index 0000000..1404d6a --- /dev/null +++ b/src/editor/Editor.css @@ -0,0 +1,81 @@ +.editor-container { + border-radius: 8px; + border: 1px solid var(--border-color); + background: var(--bg-secondary); + overflow: hidden; + box-shadow: 0 2px 8px rgba(0, 0, 0, 0.1); +} + +.editor-inner { + background: var(--bg-primary); + position: relative; +} + +.editor-input { + min-height: 400px; + resize: none; + font-size: 16px; + caret-color: var(--text-primary); + position: relative; + tab-size: 1; + outline: 0; + padding: 20px; + color: var(--text-primary); +} + +.editor-placeholder { + color: var(--text-secondary); + overflow: hidden; + position: absolute; + text-overflow: ellipsis; + top: 20px; + left: 20px; + font-size: 16px; + user-select: none; + display: inline-block; + pointer-events: none; +} + +.editor-text-bold { + font-weight: bold; +} + +.editor-text-italic { + font-style: italic; +} + +.editor-text-underline { + text-decoration: underline; +} + +.editor-text-strikethrough { + text-decoration: line-through; +} + +.editor-paragraph { + margin: 0; + position: relative; +} + +.editor-image { + cursor: default; + display: inline-block; + position: relative; + user-select: none; + margin: 10px 0; +} + +.editor-image img { + max-width: 100%; + cursor: default; + border-radius: 4px; +} + +.editor-image img.focused { + outline: 2px solid var(--accent-primary); + user-select: none; +} + +.editor-image img.focused:hover { + outline-color: var(--accent-secondary); +} diff --git a/src/editor/Editor.tsx b/src/editor/Editor.tsx new file mode 100644 index 0000000..d4f309e --- /dev/null +++ b/src/editor/Editor.tsx @@ -0,0 +1,67 @@ +import { LexicalComposer } from '@lexical/react/LexicalComposer' +import { RichTextPlugin } from '@lexical/react/LexicalRichTextPlugin' +import { ContentEditable } from '@lexical/react/LexicalContentEditable' +import { HistoryPlugin } from '@lexical/react/LexicalHistoryPlugin' +import { OnChangePlugin } from '@lexical/react/LexicalOnChangePlugin' +import { LexicalErrorBoundary } from '@lexical/react/LexicalErrorBoundary' +import type { EditorState } from 'lexical' +import ToolbarPlugin from './plugins/ToolbarPlugin' +import ImagesPlugin from './plugins/ImagesPlugin' +import { ImageNode } from './nodes/ImageNode' +import './Editor.css' + +const theme = { + paragraph: 'editor-paragraph', + text: { + bold: 'editor-text-bold', + italic: 'editor-text-italic', + underline: 'editor-text-underline', + strikethrough: 'editor-text-strikethrough', + }, + image: 'editor-image', +} + +function onError(error: Error) { + console.error(error) +} + +interface EditorProps { + onChange?: (editorState: EditorState) => void + initialContent?: string +} + +export default function Editor({ onChange, initialContent }: EditorProps) { + const initialConfig = { + namespace: 'RichTextEditor', + theme, + onError, + nodes: [ImageNode], + editorState: initialContent, + } + + return ( + +
+ +
+ + } + placeholder={ +
+ Start writing your post... You can paste or drag & drop images! +
+ } + ErrorBoundary={LexicalErrorBoundary} + /> + + + {onChange && ( + + )} +
+
+
+ ) +} diff --git a/src/editor/README.md b/src/editor/README.md new file mode 100644 index 0000000..3539d22 --- /dev/null +++ b/src/editor/README.md @@ -0,0 +1,144 @@ +# Rich Text Editor + +A powerful rich text editor built with Facebook's Lexical framework for creating blog posts and forum content. + +## Features + +### Text Formatting +- **Bold** - Make text bold +- **Italic** - Make text italic +- **Underline** - Underline text +- **Strikethrough** - Strike through text +- **Font Size** - Adjust text size (10px - 48px) + +### Alignment +- Left align +- Center align +- Right align +- Justify + +### Image Support +- **Drag & Drop** - Drag images directly into the editor +- **Copy & Paste** - Paste images from clipboard +- **Resize** - Click on images to select and drag corners/edges to resize +- **Supported Formats** - JPG, PNG, GIF + +### Other Features +- Undo/Redo history +- Clean, modern UI +- Responsive design +- Theme-aware (light/dark mode) + +## Usage + +```tsx +import Editor from '../editor/Editor' +import type { EditorState } from 'lexical' + +function MyComponent() { + const [editorState, setEditorState] = useState(null) + + const handleEditorChange = (newEditorState: EditorState) => { + setEditorState(newEditorState) + + // Get content as JSON + const contentJSON = newEditorState.toJSON() + console.log(contentJSON) + } + + return ( + + ) +} +``` + +## Structure + +``` +editor/ +├── nodes/ # Custom Lexical nodes +│ ├── ImageNode.tsx # Image node definition +│ └── ImageComponent.tsx # Image rendering & resize +├── plugins/ # Lexical plugins +│ ├── ToolbarPlugin.tsx # Formatting toolbar +│ └── ImagesPlugin.tsx # Image drag & drop, paste +├── ui/ # UI components (future) +├── Editor.tsx # Main editor component +├── Editor.css # Editor styles +└── index.ts # Exports +``` + +## Customization + +### Theme +The editor uses CSS variables for theming. Customize in `Editor.css`: +- `--bg-primary` - Background color +- `--bg-secondary` - Secondary background +- `--text-primary` - Primary text color +- `--text-secondary` - Secondary text color +- `--accent-primary` - Primary accent color +- `--border-color` - Border color + +### Toolbar +Modify `plugins/ToolbarPlugin.tsx` to add/remove formatting options. + +### Image Handling +Customize image upload/storage in `plugins/ImagesPlugin.tsx`. Currently uses base64 encoding. + +## Saving Content + +The editor provides content in Lexical's JSON format: + +```typescript +const contentJSON = editorState.toJSON() + +// Save to your API +await fetch('/api/posts', { + method: 'POST', + body: JSON.stringify({ + title: 'My Post', + content: contentJSON + }) +}) +``` + +## Loading Content + +To load previously saved content: + +```tsx + +``` + +## Testing + +Visit `/create-post` to test the editor with a complete post creation interface. + +## Dependencies + +- `lexical` - Core editor framework +- `@lexical/react` - React bindings +- `@lexical/rich-text` - Rich text features +- `@lexical/history` - Undo/redo +- `@lexical/selection` - Selection utilities +- `@lexical/clipboard` - Clipboard handling + +## Tips + +1. **Images**: For production, implement server-side image upload instead of base64 +2. **Validation**: Add content validation before submission +3. **Auto-save**: Implement auto-save functionality +4. **Mobile**: Test and optimize for mobile devices +5. **Accessibility**: Ensure keyboard navigation works properly + +## Future Enhancements + +- [ ] Link insertion +- [ ] Code blocks with syntax highlighting +- [ ] Tables +- [ ] Bullet/numbered lists +- [ ] Emojis picker +- [ ] Markdown shortcuts +- [ ] Collaborative editing +- [ ] Word count +- [ ] Character limit diff --git a/src/editor/index.ts b/src/editor/index.ts new file mode 100644 index 0000000..1bac292 --- /dev/null +++ b/src/editor/index.ts @@ -0,0 +1,6 @@ +export { default as Editor } from './Editor' +export { ImageNode } from './nodes/ImageNode' +export { $createImageNode, $isImageNode } from './nodes/ImageNode' +export type { ImagePayload } from './nodes/ImageNode' +export { INSERT_IMAGE_COMMAND } from './plugins/ImagesPluginCommands' +export type { InsertImagePayload } from './plugins/ImagesPluginCommands' diff --git a/src/editor/nodes/ImageComponent.tsx b/src/editor/nodes/ImageComponent.tsx new file mode 100644 index 0000000..481765d --- /dev/null +++ b/src/editor/nodes/ImageComponent.tsx @@ -0,0 +1,393 @@ +import { useLexicalComposerContext } from '@lexical/react/LexicalComposerContext' +import { useLexicalNodeSelection } from '@lexical/react/useLexicalNodeSelection' +import { mergeRegister } from '@lexical/utils' +import { + $getNodeByKey, + $getSelection, + $isNodeSelection, + CLICK_COMMAND, + COMMAND_PRIORITY_LOW, + KEY_BACKSPACE_COMMAND, + KEY_DELETE_COMMAND, + type NodeKey, +} from 'lexical' +import { Suspense, useCallback, useEffect, useRef, useState } from 'react' +import type React from 'react' +import { $isImageNode } from './ImageNode' + +const imageCache = new Set() + +function useSuspenseImage(src: string) { + if (!imageCache.has(src)) { + throw new Promise((resolve) => { + const img = new Image() + img.src = src + img.onload = () => { + imageCache.add(src) + resolve(null) + } + }) + } +} + +function LazyImage({ + altText, + className, + imageRef, + src, + width, + height, +}: { + altText: string + className: string | null + height: 'inherit' | number + imageRef: { current: null | HTMLImageElement } + src: string + width: 'inherit' | number +}): React.JSX.Element { + useSuspenseImage(src) + return ( + {altText} + ) +} + +export default function ImageComponent({ + src, + altText, + nodeKey, + width, + height, +}: { + altText: string + height: 'inherit' | number + nodeKey: NodeKey + src: string + width: 'inherit' | number +}): React.JSX.Element { + const imageRef = useRef(null) + const [isSelected, setSelected, clearSelection] = + useLexicalNodeSelection(nodeKey) + const [isResizing, setIsResizing] = useState(false) + const [editor] = useLexicalComposerContext() + + const onDelete = useCallback( + (payload: KeyboardEvent) => { + if (isSelected && $isNodeSelection($getSelection())) { + const event: KeyboardEvent = payload + event.preventDefault() + const node = $getNodeByKey(nodeKey) + if ($isImageNode(node)) { + node.remove() + } + } + return false + }, + [isSelected, nodeKey] + ) + + useEffect(() => { + return mergeRegister( + editor.registerCommand( + CLICK_COMMAND, + (payload) => { + const event = payload + + if (isResizing) { + return true + } + if (event.target === imageRef.current) { + if (!event.shiftKey) { + clearSelection() + } + setSelected(!isSelected) + return true + } + + return false + }, + COMMAND_PRIORITY_LOW + ), + editor.registerCommand( + KEY_DELETE_COMMAND, + onDelete, + COMMAND_PRIORITY_LOW + ), + editor.registerCommand( + KEY_BACKSPACE_COMMAND, + onDelete, + COMMAND_PRIORITY_LOW + ) + ) + }, [ + clearSelection, + editor, + isResizing, + isSelected, + nodeKey, + onDelete, + setSelected, + ]) + + const onResizeEnd = ( + nextWidth: 'inherit' | number, + nextHeight: 'inherit' | number + ) => { + setTimeout(() => { + setIsResizing(false) + }, 200) + + editor.update(() => { + const node = $getNodeByKey(nodeKey) + if ($isImageNode(node)) { + node.setWidthAndHeight(nextWidth, nextHeight) + } + }) + } + + const onResizeStart = () => { + setIsResizing(true) + } + + const isFocused = isSelected || isResizing + + return ( + +
+ + {isFocused && imageRef.current && ( + + )} +
+
+ ) +} + +function ImageResizer({ + imageRef, + onResizeStart, + onResizeEnd, +}: { + imageRef: { current: HTMLImageElement | null } + onResizeEnd: (width: number | 'inherit', height: number | 'inherit') => void + onResizeStart: () => void +}): React.JSX.Element { + const [isResizing, setIsResizing] = useState(false) + + const handlePointerDown = ( + event: React.PointerEvent, + direction: string + ) => { + event.preventDefault() + event.stopPropagation() + + const image = imageRef.current + if (!image) return + + const startWidth = image.offsetWidth + const startHeight = image.offsetHeight + const startX = event.clientX + const startY = event.clientY + const aspectRatio = startWidth / startHeight + + setIsResizing(true) + onResizeStart() + + const handlePointerMove = (e: PointerEvent) => { + if (!image) return + + const deltaX = e.clientX - startX + const deltaY = e.clientY - startY + + let newWidth = startWidth + let newHeight = startHeight + + if (direction.includes('e')) { + newWidth = Math.max(100, startWidth + deltaX) + newHeight = newWidth / aspectRatio + } else if (direction.includes('w')) { + newWidth = Math.max(100, startWidth - deltaX) + newHeight = newWidth / aspectRatio + } + + if (direction.includes('s')) { + newHeight = Math.max(100, startHeight + deltaY) + newWidth = newHeight * aspectRatio + } else if (direction.includes('n')) { + newHeight = Math.max(100, startHeight - deltaY) + newWidth = newHeight * aspectRatio + } + + image.style.width = `${newWidth}px` + image.style.height = `${newHeight}px` + } + + const handlePointerUp = () => { + if (!image) return + + const finalWidth = image.offsetWidth + const finalHeight = image.offsetHeight + + setIsResizing(false) + onResizeEnd(finalWidth, finalHeight) + + document.removeEventListener('pointermove', handlePointerMove) + document.removeEventListener('pointerup', handlePointerUp) + } + + document.addEventListener('pointermove', handlePointerMove) + document.addEventListener('pointerup', handlePointerUp) + } + + return ( +
+ {/* Corner handles */} +
handlePointerDown(e, 'nw')} + style={{ + position: 'absolute', + top: -5, + left: -5, + width: 10, + height: 10, + backgroundColor: '#4A90E2', + cursor: 'nw-resize', + pointerEvents: 'auto', + borderRadius: '50%', + }} + /> +
handlePointerDown(e, 'ne')} + style={{ + position: 'absolute', + top: -5, + right: -5, + width: 10, + height: 10, + backgroundColor: '#4A90E2', + cursor: 'ne-resize', + pointerEvents: 'auto', + borderRadius: '50%', + }} + /> +
handlePointerDown(e, 'sw')} + style={{ + position: 'absolute', + bottom: -5, + left: -5, + width: 10, + height: 10, + backgroundColor: '#4A90E2', + cursor: 'sw-resize', + pointerEvents: 'auto', + borderRadius: '50%', + }} + /> +
handlePointerDown(e, 'se')} + style={{ + position: 'absolute', + bottom: -5, + right: -5, + width: 10, + height: 10, + backgroundColor: '#4A90E2', + cursor: 'se-resize', + pointerEvents: 'auto', + borderRadius: '50%', + }} + /> + {/* Edge handles */} +
handlePointerDown(e, 'n')} + style={{ + position: 'absolute', + top: -5, + left: '50%', + transform: 'translateX(-50%)', + width: 10, + height: 10, + backgroundColor: '#4A90E2', + cursor: 'n-resize', + pointerEvents: 'auto', + borderRadius: '50%', + }} + /> +
handlePointerDown(e, 's')} + style={{ + position: 'absolute', + bottom: -5, + left: '50%', + transform: 'translateX(-50%)', + width: 10, + height: 10, + backgroundColor: '#4A90E2', + cursor: 's-resize', + pointerEvents: 'auto', + borderRadius: '50%', + }} + /> +
handlePointerDown(e, 'w')} + style={{ + position: 'absolute', + top: '50%', + left: -5, + transform: 'translateY(-50%)', + width: 10, + height: 10, + backgroundColor: '#4A90E2', + cursor: 'w-resize', + pointerEvents: 'auto', + borderRadius: '50%', + }} + /> +
handlePointerDown(e, 'e')} + style={{ + position: 'absolute', + top: '50%', + right: -5, + transform: 'translateY(-50%)', + width: 10, + height: 10, + backgroundColor: '#4A90E2', + cursor: 'e-resize', + pointerEvents: 'auto', + borderRadius: '50%', + }} + /> +
+ ) +} diff --git a/src/editor/nodes/ImageNode.tsx b/src/editor/nodes/ImageNode.tsx new file mode 100644 index 0000000..f3ef32c --- /dev/null +++ b/src/editor/nodes/ImageNode.tsx @@ -0,0 +1,189 @@ +import type { + DOMConversionMap, + DOMConversionOutput, + DOMExportOutput, + EditorConfig, + LexicalEditor, + LexicalNode, + NodeKey, + SerializedLexicalNode, + Spread, +} from 'lexical' + +import { $applyNodeReplacement, DecoratorNode } from 'lexical' +import { Suspense, lazy } from 'react' +import type React from 'react' + +const ImageComponent = lazy(() => import('./ImageComponent')) + +export interface ImagePayload { + altText: string + height?: number + key?: NodeKey + src: string + width?: number +} + +function convertImageElement(domNode: Node): null | DOMConversionOutput { + const img = domNode as HTMLImageElement + if (img.src.startsWith('file:///')) { + return null + } + const { alt: altText, src, width, height } = img + const node = $createImageNode({ altText, height, src, width }) + return { node } +} + +export type SerializedImageNode = Spread< + { + altText: string + height?: number + src: string + width?: number + }, + SerializedLexicalNode +> + +export class ImageNode extends DecoratorNode { + __src: string + __altText: string + __width: 'inherit' | number + __height: 'inherit' | number + + static getType(): string { + return 'image' + } + + static clone(node: ImageNode): ImageNode { + return new ImageNode( + node.__src, + node.__altText, + node.__width, + node.__height, + node.__key + ) + } + + static importJSON(serializedNode: SerializedImageNode): ImageNode { + const { altText, height, width, src } = serializedNode + const node = $createImageNode({ + altText, + height, + src, + width, + }) + return node + } + + exportDOM(): DOMExportOutput { + const element = document.createElement('img') + element.setAttribute('src', this.__src) + element.setAttribute('alt', this.__altText) + if (this.__width !== 'inherit') { + element.setAttribute('width', this.__width.toString()) + } + if (this.__height !== 'inherit') { + element.setAttribute('height', this.__height.toString()) + } + return { element } + } + + static importDOM(): DOMConversionMap | null { + return { + img: () => ({ + conversion: convertImageElement, + priority: 0, + }), + } + } + + constructor( + src: string, + altText: string, + width?: 'inherit' | number, + height?: 'inherit' | number, + key?: NodeKey + ) { + super(key) + this.__src = src + this.__altText = altText + this.__width = width || 'inherit' + this.__height = height || 'inherit' + } + + exportJSON(): SerializedImageNode { + return { + altText: this.getAltText(), + height: this.__height === 'inherit' ? 0 : this.__height, + src: this.getSrc(), + type: 'image', + version: 1, + width: this.__width === 'inherit' ? 0 : this.__width, + } + } + + setWidthAndHeight( + width: 'inherit' | number, + height: 'inherit' | number + ): void { + const writable = this.getWritable() + writable.__width = width + writable.__height = height + } + + // View + + createDOM(config: EditorConfig): HTMLElement { + const span = document.createElement('span') + const theme = config.theme + const className = theme.image + if (className !== undefined) { + span.className = className + } + return span + } + + updateDOM(): false { + return false + } + + getSrc(): string { + return this.__src + } + + getAltText(): string { + return this.__altText + } + + decorate(_editor: LexicalEditor): React.JSX.Element { + return ( + + + + ) + } +} + +export function $createImageNode({ + altText, + height, + src, + width, + key, +}: ImagePayload): ImageNode { + return $applyNodeReplacement( + new ImageNode(src, altText, width, height, key) + ) +} + +export function $isImageNode( + node: LexicalNode | null | undefined +): node is ImageNode { + return node instanceof ImageNode +} diff --git a/src/editor/plugins/ImagesPlugin.tsx b/src/editor/plugins/ImagesPlugin.tsx new file mode 100644 index 0000000..96124e7 --- /dev/null +++ b/src/editor/plugins/ImagesPlugin.tsx @@ -0,0 +1,108 @@ +import { useLexicalComposerContext } from '@lexical/react/LexicalComposerContext' +import { $insertNodes, COMMAND_PRIORITY_HIGH } from 'lexical' +import { useEffect } from 'react' +import { $createImageNode, ImageNode } from '../nodes/ImageNode' +import { INSERT_IMAGE_COMMAND, type InsertImagePayload } from './ImagesPluginCommands' + +export default function ImagesPlugin(): null { + const [editor] = useLexicalComposerContext() + + useEffect(() => { + if (!editor.hasNodes([ImageNode])) { + throw new Error('ImagesPlugin: ImageNode not registered on editor') + } + + return editor.registerCommand( + INSERT_IMAGE_COMMAND, + (payload) => { + const imageNode = $createImageNode(payload) + $insertNodes([imageNode]) + return true + }, + COMMAND_PRIORITY_HIGH + ) + }, [editor]) + + // Handle drag and drop + useEffect(() => { + const handleDrop = (event: DragEvent) => { + const files = event.dataTransfer?.files + if (files && files.length > 0) { + event.preventDefault() + event.stopPropagation() + + Array.from(files).forEach((file) => { + if (file.type.startsWith('image/')) { + const reader = new FileReader() + reader.onload = (e) => { + const src = e.target?.result as string + editor.dispatchCommand(INSERT_IMAGE_COMMAND, { + altText: file.name, + src, + }) + } + reader.readAsDataURL(file) + } + }) + } + } + + const handleDragOver = (event: DragEvent) => { + if (event.dataTransfer?.types.includes('Files')) { + event.preventDefault() + } + } + + const editorElement = editor.getRootElement() + if (editorElement) { + editorElement.addEventListener('drop', handleDrop) + editorElement.addEventListener('dragover', handleDragOver) + } + + return () => { + if (editorElement) { + editorElement.removeEventListener('drop', handleDrop) + editorElement.removeEventListener('dragover', handleDragOver) + } + } + }, [editor]) + + // Handle paste + useEffect(() => { + const handlePaste = (event: ClipboardEvent) => { + const items = event.clipboardData?.items + if (items) { + Array.from(items).forEach((item) => { + if (item.type.startsWith('image/')) { + event.preventDefault() + const file = item.getAsFile() + if (file) { + const reader = new FileReader() + reader.onload = (e) => { + const src = e.target?.result as string + editor.dispatchCommand(INSERT_IMAGE_COMMAND, { + altText: file.name, + src, + }) + } + reader.readAsDataURL(file) + } + } + }) + } + } + + const editorElement = editor.getRootElement() + if (editorElement) { + editorElement.addEventListener('paste', handlePaste) + } + + return () => { + if (editorElement) { + editorElement.removeEventListener('paste', handlePaste) + } + } + }, [editor]) + + return null +} diff --git a/src/editor/plugins/ImagesPluginCommands.ts b/src/editor/plugins/ImagesPluginCommands.ts new file mode 100644 index 0000000..f1c8313 --- /dev/null +++ b/src/editor/plugins/ImagesPluginCommands.ts @@ -0,0 +1,7 @@ +import { createCommand, type LexicalCommand } from 'lexical' +import type { ImagePayload } from '../nodes/ImageNode' + +export type InsertImagePayload = Readonly + +export const INSERT_IMAGE_COMMAND: LexicalCommand = + createCommand('INSERT_IMAGE_COMMAND') diff --git a/src/editor/plugins/ToolbarPlugin.css b/src/editor/plugins/ToolbarPlugin.css new file mode 100644 index 0000000..eb901e5 --- /dev/null +++ b/src/editor/plugins/ToolbarPlugin.css @@ -0,0 +1,100 @@ +.toolbar { + display: flex; + background: var(--bg-secondary); + padding: 8px 16px; + border-bottom: 1px solid var(--border-color); + align-items: center; + gap: 4px; + flex-wrap: wrap; + position: sticky; + top: 0; + z-index: 10; +} + +.toolbar-item { + border: 0; + display: flex; + background: none; + border-radius: 6px; + padding: 8px; + cursor: pointer; + vertical-align: middle; + color: var(--text-primary); + transition: background-color 0.2s; +} + +.toolbar-item:hover:not(:disabled) { + background-color: var(--bg-primary); +} + +.toolbar-item.active { + background-color: var(--accent-primary); + color: white; +} + +.toolbar-item:disabled { + cursor: not-allowed; + opacity: 0.5; +} + +.toolbar-item.spaced { + margin-right: 2px; +} + +.toolbar-item.font-size { + width: 100px; + padding: 4px 8px; + border: 1px solid var(--border-color); + border-radius: 4px; + background: var(--bg-primary); + color: var(--text-primary); + cursor: pointer; +} + +.divider { + width: 1px; + background-color: var(--border-color); + margin: 0 8px; + height: 24px; +} + +i.format { + background-size: contain; + display: inline-block; + height: 18px; + width: 18px; + vertical-align: -0.25em; + position: relative; +} + +i.format.bold { + background-image: url("data:image/svg+xml,%3Csvg xmlns='http://www.w3.org/2000/svg' viewBox='0 0 24 24' fill='currentColor'%3E%3Cpath d='M15.6 10.79c.97-.67 1.65-1.77 1.65-2.79 0-2.26-1.75-4-4-4H7v14h7.04c2.09 0 3.71-1.7 3.71-3.79 0-1.52-.86-2.82-2.15-3.42zM10 6.5h3c.83 0 1.5.67 1.5 1.5s-.67 1.5-1.5 1.5h-3v-3zm3.5 9H10v-3h3.5c.83 0 1.5.67 1.5 1.5s-.67 1.5-1.5 1.5z'/%3E%3C/svg%3E"); +} + +i.format.italic { + background-image: url("data:image/svg+xml,%3Csvg xmlns='http://www.w3.org/2000/svg' viewBox='0 0 24 24' fill='currentColor'%3E%3Cpath d='M10 4v3h2.21l-3.42 8H6v3h8v-3h-2.21l3.42-8H18V4z'/%3E%3C/svg%3E"); +} + +i.format.underline { + background-image: url("data:image/svg+xml,%3Csvg xmlns='http://www.w3.org/2000/svg' viewBox='0 0 24 24' fill='currentColor'%3E%3Cpath d='M12 17c3.31 0 6-2.69 6-6V3h-2.5v8c0 1.93-1.57 3.5-3.5 3.5S8.5 12.93 8.5 11V3H6v8c0 3.31 2.69 6 6 6zm-7 2v2h14v-2H5z'/%3E%3C/svg%3E"); +} + +i.format.strikethrough { + background-image: url("data:image/svg+xml,%3Csvg xmlns='http://www.w3.org/2000/svg' viewBox='0 0 24 24' fill='currentColor'%3E%3Cpath d='M10 19h4v-3h-4v3zM5 4v3h5v3h4V7h5V4H5zM3 14h18v-2H3v2z'/%3E%3C/svg%3E"); +} + +i.format.left-align { + background-image: url("data:image/svg+xml,%3Csvg xmlns='http://www.w3.org/2000/svg' viewBox='0 0 24 24' fill='currentColor'%3E%3Cpath d='M15 15H3v2h12v-2zm0-8H3v2h12V7zM3 13h18v-2H3v2zm0 8h18v-2H3v2zM3 3v2h18V3H3z'/%3E%3C/svg%3E"); +} + +i.format.center-align { + background-image: url("data:image/svg+xml,%3Csvg xmlns='http://www.w3.org/2000/svg' viewBox='0 0 24 24' fill='currentColor'%3E%3Cpath d='M7 15v2h10v-2H7zm-4 6h18v-2H3v2zm0-8h18v-2H3v2zm4-6v2h10V7H7zM3 3v2h18V3H3z'/%3E%3C/svg%3E"); +} + +i.format.right-align { + background-image: url("data:image/svg+xml,%3Csvg xmlns='http://www.w3.org/2000/svg' viewBox='0 0 24 24' fill='currentColor'%3E%3Cpath d='M3 21h18v-2H3v2zm6-4h12v-2H9v2zm-6-4h18v-2H3v2zm6-4h12V7H9v2zM3 3v2h18V3H3z'/%3E%3C/svg%3E"); +} + +i.format.justify-align { + background-image: url("data:image/svg+xml,%3Csvg xmlns='http://www.w3.org/2000/svg' viewBox='0 0 24 24' fill='currentColor'%3E%3Cpath d='M3 21h18v-2H3v2zm0-4h18v-2H3v2zm0-4h18v-2H3v2zm0-4h18V7H3v2zm0-6v2h18V3H3z'/%3E%3C/svg%3E"); +} diff --git a/src/editor/plugins/ToolbarPlugin.tsx b/src/editor/plugins/ToolbarPlugin.tsx new file mode 100644 index 0000000..83fdcdb --- /dev/null +++ b/src/editor/plugins/ToolbarPlugin.tsx @@ -0,0 +1,169 @@ +import { useLexicalComposerContext } from '@lexical/react/LexicalComposerContext' +import { + $getSelection, + $isRangeSelection, + FORMAT_TEXT_COMMAND, + FORMAT_ELEMENT_COMMAND, +} from 'lexical' +import { $patchStyleText } from '@lexical/selection' +import { useCallback, useEffect, useState } from 'react' +import type React from 'react' +import './ToolbarPlugin.css' + +const FONT_SIZE_OPTIONS = [ + ['10px', '10px'], + ['12px', '12px'], + ['14px', '14px'], + ['16px', '16px'], + ['18px', '18px'], + ['20px', '20px'], + ['24px', '24px'], + ['30px', '30px'], + ['36px', '36px'], + ['48px', '48px'], +] + +export default function ToolbarPlugin(): React.JSX.Element { + const [editor] = useLexicalComposerContext() + const [isBold, setIsBold] = useState(false) + const [isItalic, setIsItalic] = useState(false) + const [isUnderline, setIsUnderline] = useState(false) + const [isStrikethrough, setIsStrikethrough] = useState(false) + const [fontSize, setFontSize] = useState('16px') + + const updateToolbar = useCallback(() => { + const selection = $getSelection() + if ($isRangeSelection(selection)) { + setIsBold(selection.hasFormat('bold')) + setIsItalic(selection.hasFormat('italic')) + setIsUnderline(selection.hasFormat('underline')) + setIsStrikethrough(selection.hasFormat('strikethrough')) + } + }, []) + + useEffect(() => { + return editor.registerUpdateListener(({ editorState }) => { + editorState.read(() => { + updateToolbar() + }) + }) + }, [editor, updateToolbar]) + + const applyStyleText = useCallback( + (styles: Record) => { + editor.update(() => { + const selection = $getSelection() + if ($isRangeSelection(selection)) { + $patchStyleText(selection, styles) + } + }) + }, + [editor] + ) + + const onFontSizeChange = useCallback( + (value: string) => { + setFontSize(value) + applyStyleText({ 'font-size': value }) + }, + [applyStyleText] + ) + + return ( +
+ + + + +
+ +
+ + + + +
+ ) +} diff --git a/src/main.tsx b/src/main.tsx index ee12b36..165424c 100644 --- a/src/main.tsx +++ b/src/main.tsx @@ -14,6 +14,7 @@ import Blog from './pages/Blog.tsx' import Servers from './pages/Servers.tsx' import Forum from './pages/Forum.tsx' import AuthCallback from './pages/AuthCallback.tsx' +import CreatePost from './pages/CreatePost.tsx' createRoot(document.getElementById('root')!).render( @@ -29,6 +30,7 @@ createRoot(document.getElementById('root')!).render( } /> } /> } /> + } /> } /> diff --git a/src/pages/CreatePost.css b/src/pages/CreatePost.css new file mode 100644 index 0000000..101c147 --- /dev/null +++ b/src/pages/CreatePost.css @@ -0,0 +1,179 @@ +.create-post-container { + max-width: 1000px; + margin: 0 auto; + padding: 100px 2rem 4rem; +} + +.create-post-header { + display: flex; + justify-content: space-between; + align-items: center; + margin-bottom: 2rem; + flex-wrap: wrap; + gap: 1rem; +} + +.create-post-header h1 { + font-size: 2rem; + color: var(--text-primary); + margin: 0; +} + +.post-type-selector { + display: flex; + gap: 0.5rem; + background: var(--bg-secondary); + padding: 4px; + border-radius: 8px; +} + +.post-type-selector button { + padding: 0.5rem 1rem; + border: none; + background: transparent; + color: var(--text-primary); + cursor: pointer; + border-radius: 6px; + font-size: 1rem; + transition: all 0.2s; +} + +.post-type-selector button:hover { + background: var(--bg-primary); +} + +.post-type-selector button.active { + background: var(--accent-primary); + color: white; +} + +.create-post-form { + background: var(--bg-secondary); + padding: 2rem; + border-radius: 12px; + border: 1px solid var(--border-color); + margin-bottom: 2rem; +} + +.form-group { + margin-bottom: 2rem; +} + +.form-group label { + display: block; + margin-bottom: 0.5rem; + color: var(--text-primary); + font-weight: 600; + font-size: 1rem; +} + +.title-input { + width: 100%; + padding: 0.75rem 1rem; + font-size: 1.25rem; + border: 1px solid var(--border-color); + border-radius: 8px; + background: var(--bg-primary); + color: var(--text-primary); + transition: border-color 0.2s; +} + +.title-input:focus { + outline: none; + border-color: var(--accent-primary); +} + +.form-actions { + display: flex; + gap: 1rem; + justify-content: flex-end; + margin-top: 2rem; +} + +.btn-primary, +.btn-secondary { + padding: 0.75rem 1.5rem; + border-radius: 8px; + font-size: 1rem; + font-weight: 600; + cursor: pointer; + transition: all 0.2s; + border: none; +} + +.btn-primary { + background: var(--accent-primary); + color: white; +} + +.btn-primary:hover { + background: var(--accent-secondary); + transform: translateY(-2px); + box-shadow: 0 4px 12px rgba(0, 0, 0, 0.2); +} + +.btn-secondary { + background: var(--bg-primary); + color: var(--text-primary); + border: 1px solid var(--border-color); +} + +.btn-secondary:hover { + background: var(--bg-secondary); +} + +.editor-tips { + background: var(--bg-secondary); + padding: 1.5rem; + border-radius: 12px; + border: 1px solid var(--border-color); +} + +.editor-tips h3 { + margin: 0 0 1rem 0; + color: var(--text-primary); + font-size: 1.1rem; +} + +.editor-tips ul { + margin: 0; + padding-left: 1.5rem; + color: var(--text-secondary); +} + +.editor-tips li { + margin-bottom: 0.5rem; + line-height: 1.6; +} + +@media (max-width: 768px) { + .create-post-container { + padding: 80px 1rem 2rem; + } + + .create-post-header { + flex-direction: column; + align-items: flex-start; + } + + .post-type-selector { + width: 100%; + } + + .post-type-selector button { + flex: 1; + } + + .create-post-form { + padding: 1rem; + } + + .form-actions { + flex-direction: column; + } + + .btn-primary, + .btn-secondary { + width: 100%; + } +} diff --git a/src/pages/CreatePost.tsx b/src/pages/CreatePost.tsx new file mode 100644 index 0000000..1ca0765 --- /dev/null +++ b/src/pages/CreatePost.tsx @@ -0,0 +1,104 @@ +import { useState } from 'react' +import Layout from '../components/Layout' +import Editor from '../editor/Editor' +import type { EditorState } from 'lexical' +import './CreatePost.css' + +export default function CreatePost() { + const [title, setTitle] = useState('') + const [editorState, setEditorState] = useState(null) + const [postType, setPostType] = useState<'blog' | 'forum'>('blog') + + const handleEditorChange = (newEditorState: EditorState) => { + setEditorState(newEditorState) + } + + const handleSubmit = (e: React.FormEvent) => { + e.preventDefault() + + if (!title.trim()) { + alert('Please enter a title') + return + } + + if (!editorState) { + alert('Please write some content') + return + } + + // Get the content as JSON + const contentJSON = editorState.toJSON() + + console.log('Post Type:', postType) + console.log('Title:', title) + console.log('Content JSON:', contentJSON) + + // Here you would send this to your API + alert(`${postType === 'blog' ? 'Blog' : 'Forum'} post created! Check console for data.`) + } + + return ( + +
+
+

Create New Post

+
+ + +
+
+ +
+
+ + setTitle(e.target.value)} + placeholder={`Enter your ${postType} post title...`} + className="title-input" + /> +
+ +
+ + +
+ +
+ + +
+
+ +
+

💡 Editor Tips

+
    +
  • Use the toolbar to format your text (bold, italic, underline, etc.)
  • +
  • Drag & drop images directly into the editor
  • +
  • Copy and paste images from your clipboard
  • +
  • Click on images to select them and drag corners to resize
  • +
  • Supported image formats: JPG, PNG, GIF
  • +
+
+
+
+ ) +}