feat: add rich text editor for creating blog and forum posts
All checks were successful
CI - Build and Push / Build and Push Docker Image (push) Successful in 2m8s

- Implemented a rich text editor using Facebook's Lexical framework.
- Added features for text formatting (bold, italic, underline, strikethrough, font size).
- Included image support with drag & drop and paste functionality.
- Created a toolbar for text alignment and formatting options.
- Developed a Create Post page with title input and editor integration.
- Added styles for the editor and post creation interface.
- Implemented image resizing functionality within the editor.
- Registered new commands for inserting images and handling image nodes.
- Provided tips and future enhancements for the editor.
This commit is contained in:
2025-10-21 23:43:34 +08:00
parent 6dbb6ff7fb
commit 5217f87012
15 changed files with 1989 additions and 0 deletions

431
package-lock.json generated
View File

@@ -8,8 +8,17 @@
"name": "www.cialloo.com", "name": "www.cialloo.com",
"version": "0.0.0", "version": "0.0.0",
"dependencies": { "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": "^25.5.3",
"i18next-browser-languagedetector": "^8.2.0", "i18next-browser-languagedetector": "^8.2.0",
"lexical": "^0.37.0",
"react": "^19.1.1", "react": "^19.1.1",
"react-dom": "^19.1.1", "react-dom": "^19.1.1",
"react-i18next": "^16.0.0", "react-i18next": "^16.0.0",
@@ -508,6 +517,59 @@
"node": "^18.18.0 || ^20.9.0 || >=21.1.0" "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": { "node_modules/@humanfs/core": {
"version": "0.19.1", "version": "0.19.1",
"resolved": "https://registry.npmjs.org/@humanfs/core/-/core-0.19.1.tgz", "resolved": "https://registry.npmjs.org/@humanfs/core/-/core-0.19.1.tgz",
@@ -610,6 +672,281 @@
"@jridgewell/sourcemap-codec": "^1.4.14" "@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": { "node_modules/@napi-rs/wasm-runtime": {
"version": "1.0.5", "version": "1.0.5",
"resolved": "https://registry.npmjs.org/@napi-rs/wasm-runtime/-/wasm-runtime-1.0.5.tgz", "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" "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": { "node_modules/@rolldown/binding-android-arm64": {
"version": "1.0.0-beta.39", "version": "1.0.0-beta.39",
"resolved": "https://registry.npmjs.org/@rolldown/binding-android-arm64/-/binding-android-arm64-1.0.0-beta.39.tgz", "resolved": "https://registry.npmjs.org/@rolldown/binding-android-arm64/-/binding-android-arm64-1.0.0-beta.39.tgz",
@@ -2131,6 +2478,17 @@
"dev": true, "dev": true,
"license": "ISC" "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": { "node_modules/js-tokens": {
"version": "4.0.0", "version": "4.0.0",
"resolved": "https://registry.npmjs.org/js-tokens/-/js-tokens-4.0.0.tgz", "resolved": "https://registry.npmjs.org/js-tokens/-/js-tokens-4.0.0.tgz",
@@ -2222,6 +2580,34 @@
"node": ">= 0.8.0" "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": { "node_modules/lightningcss": {
"version": "1.30.2", "version": "1.30.2",
"resolved": "https://registry.npmjs.org/lightningcss/-/lightningcss-1.30.2.tgz", "resolved": "https://registry.npmjs.org/lightningcss/-/lightningcss-1.30.2.tgz",
@@ -2735,6 +3121,15 @@
"node": ">= 0.8.0" "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": { "node_modules/punycode": {
"version": "2.3.1", "version": "2.3.1",
"resolved": "https://registry.npmjs.org/punycode/-/punycode-2.3.1.tgz", "resolved": "https://registry.npmjs.org/punycode/-/punycode-2.3.1.tgz",
@@ -2787,6 +3182,18 @@
"react": "^19.1.1" "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": { "node_modules/react-i18next": {
"version": "16.0.0", "version": "16.0.0",
"resolved": "https://registry.npmjs.org/react-i18next/-/react-i18next-16.0.0.tgz", "resolved": "https://registry.npmjs.org/react-i18next/-/react-i18next-16.0.0.tgz",
@@ -3028,6 +3435,12 @@
"node": ">=8" "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": { "node_modules/tinyglobby": {
"version": "0.2.15", "version": "0.2.15",
"resolved": "https://registry.npmjs.org/tinyglobby/-/tinyglobby-0.2.15.tgz", "resolved": "https://registry.npmjs.org/tinyglobby/-/tinyglobby-0.2.15.tgz",
@@ -3352,6 +3765,24 @@
"dev": true, "dev": true,
"license": "ISC" "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": { "node_modules/yocto-queue": {
"version": "0.1.0", "version": "0.1.0",
"resolved": "https://registry.npmjs.org/yocto-queue/-/yocto-queue-0.1.0.tgz", "resolved": "https://registry.npmjs.org/yocto-queue/-/yocto-queue-0.1.0.tgz",

View File

@@ -10,8 +10,17 @@
"preview": "vite preview" "preview": "vite preview"
}, },
"dependencies": { "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": "^25.5.3",
"i18next-browser-languagedetector": "^8.2.0", "i18next-browser-languagedetector": "^8.2.0",
"lexical": "^0.37.0",
"react": "^19.1.1", "react": "^19.1.1",
"react-dom": "^19.1.1", "react-dom": "^19.1.1",
"react-i18next": "^16.0.0", "react-i18next": "^16.0.0",

81
src/editor/Editor.css Normal file
View File

@@ -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);
}

67
src/editor/Editor.tsx Normal file
View File

@@ -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 (
<LexicalComposer initialConfig={initialConfig}>
<div className="editor-container">
<ToolbarPlugin />
<div className="editor-inner">
<RichTextPlugin
contentEditable={
<ContentEditable className="editor-input" />
}
placeholder={
<div className="editor-placeholder">
Start writing your post... You can paste or drag & drop images!
</div>
}
ErrorBoundary={LexicalErrorBoundary}
/>
<HistoryPlugin />
<ImagesPlugin />
{onChange && (
<OnChangePlugin onChange={onChange} />
)}
</div>
</div>
</LexicalComposer>
)
}

144
src/editor/README.md Normal file
View File

@@ -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<EditorState | null>(null)
const handleEditorChange = (newEditorState: EditorState) => {
setEditorState(newEditorState)
// Get content as JSON
const contentJSON = newEditorState.toJSON()
console.log(contentJSON)
}
return (
<Editor onChange={handleEditorChange} />
)
}
```
## 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
<Editor initialContent={savedContentJSON} />
```
## 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

6
src/editor/index.ts Normal file
View File

@@ -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'

View File

@@ -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 (
<img
className={className || undefined}
src={src}
alt={altText}
ref={imageRef}
style={{
height: height === 'inherit' ? 'auto' : height,
width: width === 'inherit' ? 'auto' : width,
}}
draggable="false"
/>
)
}
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 | HTMLImageElement>(null)
const [isSelected, setSelected, clearSelection] =
useLexicalNodeSelection(nodeKey)
const [isResizing, setIsResizing] = useState<boolean>(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 (
<Suspense fallback={null}>
<div draggable="false">
<LazyImage
className={isFocused ? 'focused' : null}
src={src}
altText={altText}
imageRef={imageRef}
width={width}
height={height}
/>
{isFocused && imageRef.current && (
<ImageResizer
imageRef={imageRef}
onResizeStart={onResizeStart}
onResizeEnd={onResizeEnd}
/>
)}
</div>
</Suspense>
)
}
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<HTMLDivElement>,
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 (
<div
style={{
position: 'absolute',
top: 0,
left: 0,
right: 0,
bottom: 0,
pointerEvents: isResizing ? 'auto' : 'none',
}}
>
{/* Corner handles */}
<div
onPointerDown={(e) => handlePointerDown(e, 'nw')}
style={{
position: 'absolute',
top: -5,
left: -5,
width: 10,
height: 10,
backgroundColor: '#4A90E2',
cursor: 'nw-resize',
pointerEvents: 'auto',
borderRadius: '50%',
}}
/>
<div
onPointerDown={(e) => handlePointerDown(e, 'ne')}
style={{
position: 'absolute',
top: -5,
right: -5,
width: 10,
height: 10,
backgroundColor: '#4A90E2',
cursor: 'ne-resize',
pointerEvents: 'auto',
borderRadius: '50%',
}}
/>
<div
onPointerDown={(e) => handlePointerDown(e, 'sw')}
style={{
position: 'absolute',
bottom: -5,
left: -5,
width: 10,
height: 10,
backgroundColor: '#4A90E2',
cursor: 'sw-resize',
pointerEvents: 'auto',
borderRadius: '50%',
}}
/>
<div
onPointerDown={(e) => 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 */}
<div
onPointerDown={(e) => 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%',
}}
/>
<div
onPointerDown={(e) => 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%',
}}
/>
<div
onPointerDown={(e) => 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%',
}}
/>
<div
onPointerDown={(e) => 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%',
}}
/>
</div>
)
}

View File

@@ -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<React.JSX.Element> {
__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 (
<Suspense fallback={null}>
<ImageComponent
src={this.__src}
altText={this.__altText}
width={this.__width}
height={this.__height}
nodeKey={this.getKey()}
/>
</Suspense>
)
}
}
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
}

View File

@@ -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<InsertImagePayload>(
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
}

View File

@@ -0,0 +1,7 @@
import { createCommand, type LexicalCommand } from 'lexical'
import type { ImagePayload } from '../nodes/ImageNode'
export type InsertImagePayload = Readonly<ImagePayload>
export const INSERT_IMAGE_COMMAND: LexicalCommand<InsertImagePayload> =
createCommand('INSERT_IMAGE_COMMAND')

View File

@@ -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");
}

View File

@@ -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<string, string>) => {
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 (
<div className="toolbar">
<button
onClick={() => {
editor.dispatchCommand(FORMAT_TEXT_COMMAND, 'bold')
}}
className={'toolbar-item spaced ' + (isBold ? 'active' : '')}
aria-label="Format Bold"
type="button"
>
<i className="format bold" />
</button>
<button
onClick={() => {
editor.dispatchCommand(FORMAT_TEXT_COMMAND, 'italic')
}}
className={'toolbar-item spaced ' + (isItalic ? 'active' : '')}
aria-label="Format Italics"
type="button"
>
<i className="format italic" />
</button>
<button
onClick={() => {
editor.dispatchCommand(FORMAT_TEXT_COMMAND, 'underline')
}}
className={'toolbar-item spaced ' + (isUnderline ? 'active' : '')}
aria-label="Format Underline"
type="button"
>
<i className="format underline" />
</button>
<button
onClick={() => {
editor.dispatchCommand(FORMAT_TEXT_COMMAND, 'strikethrough')
}}
className={'toolbar-item spaced ' + (isStrikethrough ? 'active' : '')}
aria-label="Format Strikethrough"
type="button"
>
<i className="format strikethrough" />
</button>
<div className="divider" />
<select
className="toolbar-item font-size"
value={fontSize}
onChange={(e) => onFontSizeChange(e.target.value)}
>
{FONT_SIZE_OPTIONS.map(([value, label]) => (
<option key={value} value={value}>
{label}
</option>
))}
</select>
<div className="divider" />
<button
onClick={() => {
editor.dispatchCommand(FORMAT_ELEMENT_COMMAND, 'left')
}}
className="toolbar-item spaced"
aria-label="Left Align"
type="button"
>
<i className="format left-align" />
</button>
<button
onClick={() => {
editor.dispatchCommand(FORMAT_ELEMENT_COMMAND, 'center')
}}
className="toolbar-item spaced"
aria-label="Center Align"
type="button"
>
<i className="format center-align" />
</button>
<button
onClick={() => {
editor.dispatchCommand(FORMAT_ELEMENT_COMMAND, 'right')
}}
className="toolbar-item spaced"
aria-label="Right Align"
type="button"
>
<i className="format right-align" />
</button>
<button
onClick={() => {
editor.dispatchCommand(FORMAT_ELEMENT_COMMAND, 'justify')
}}
className="toolbar-item"
aria-label="Justify Align"
type="button"
>
<i className="format justify-align" />
</button>
</div>
)
}

View File

@@ -14,6 +14,7 @@ import Blog from './pages/Blog.tsx'
import Servers from './pages/Servers.tsx' import Servers from './pages/Servers.tsx'
import Forum from './pages/Forum.tsx' import Forum from './pages/Forum.tsx'
import AuthCallback from './pages/AuthCallback.tsx' import AuthCallback from './pages/AuthCallback.tsx'
import CreatePost from './pages/CreatePost.tsx'
createRoot(document.getElementById('root')!).render( createRoot(document.getElementById('root')!).render(
<StrictMode> <StrictMode>
@@ -29,6 +30,7 @@ createRoot(document.getElementById('root')!).render(
<Route path="/blog" element={<Blog />} /> <Route path="/blog" element={<Blog />} />
<Route path="/servers" element={<Servers />} /> <Route path="/servers" element={<Servers />} />
<Route path="/forum" element={<Forum />} /> <Route path="/forum" element={<Forum />} />
<Route path="/create-post" element={<CreatePost />} />
<Route path="/auth/callback" element={<AuthCallback />} /> <Route path="/auth/callback" element={<AuthCallback />} />
</Routes> </Routes>
</Router> </Router>

179
src/pages/CreatePost.css Normal file
View File

@@ -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%;
}
}

104
src/pages/CreatePost.tsx Normal file
View File

@@ -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<EditorState | null>(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 (
<Layout currentPage={postType === 'blog' ? 'blog' : 'forum'}>
<div className="create-post-container">
<div className="create-post-header">
<h1>Create New Post</h1>
<div className="post-type-selector">
<button
className={postType === 'blog' ? 'active' : ''}
onClick={() => setPostType('blog')}
type="button"
>
📝 Blog Post
</button>
<button
className={postType === 'forum' ? 'active' : ''}
onClick={() => setPostType('forum')}
type="button"
>
💬 Forum Post
</button>
</div>
</div>
<form onSubmit={handleSubmit} className="create-post-form">
<div className="form-group">
<label htmlFor="title">Title</label>
<input
id="title"
type="text"
value={title}
onChange={(e) => setTitle(e.target.value)}
placeholder={`Enter your ${postType} post title...`}
className="title-input"
/>
</div>
<div className="form-group">
<label>Content</label>
<Editor onChange={handleEditorChange} />
</div>
<div className="form-actions">
<button type="button" className="btn-secondary">
Save Draft
</button>
<button type="submit" className="btn-primary">
Publish Post
</button>
</div>
</form>
<div className="editor-tips">
<h3>💡 Editor Tips</h3>
<ul>
<li>Use the toolbar to format your text (bold, italic, underline, etc.)</li>
<li>Drag & drop images directly into the editor</li>
<li>Copy and paste images from your clipboard</li>
<li>Click on images to select them and drag corners to resize</li>
<li>Supported image formats: JPG, PNG, GIF</li>
</ul>
</div>
</div>
</Layout>
)
}