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
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:
431
package-lock.json
generated
431
package-lock.json
generated
@@ -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",
|
||||||
|
|||||||
@@ -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
81
src/editor/Editor.css
Normal 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
67
src/editor/Editor.tsx
Normal 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
144
src/editor/README.md
Normal 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
6
src/editor/index.ts
Normal 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'
|
||||||
393
src/editor/nodes/ImageComponent.tsx
Normal file
393
src/editor/nodes/ImageComponent.tsx
Normal 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>
|
||||||
|
)
|
||||||
|
}
|
||||||
189
src/editor/nodes/ImageNode.tsx
Normal file
189
src/editor/nodes/ImageNode.tsx
Normal 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
|
||||||
|
}
|
||||||
108
src/editor/plugins/ImagesPlugin.tsx
Normal file
108
src/editor/plugins/ImagesPlugin.tsx
Normal 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
|
||||||
|
}
|
||||||
7
src/editor/plugins/ImagesPluginCommands.ts
Normal file
7
src/editor/plugins/ImagesPluginCommands.ts
Normal 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')
|
||||||
100
src/editor/plugins/ToolbarPlugin.css
Normal file
100
src/editor/plugins/ToolbarPlugin.css
Normal 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");
|
||||||
|
}
|
||||||
169
src/editor/plugins/ToolbarPlugin.tsx
Normal file
169
src/editor/plugins/ToolbarPlugin.tsx
Normal 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>
|
||||||
|
)
|
||||||
|
}
|
||||||
@@ -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
179
src/pages/CreatePost.css
Normal 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
104
src/pages/CreatePost.tsx
Normal 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>
|
||||||
|
)
|
||||||
|
}
|
||||||
Reference in New Issue
Block a user