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",
|
||||
"version": "0.0.0",
|
||||
"dependencies": {
|
||||
"@lexical/clipboard": "^0.37.0",
|
||||
"@lexical/history": "^0.37.0",
|
||||
"@lexical/link": "^0.37.0",
|
||||
"@lexical/list": "^0.37.0",
|
||||
"@lexical/react": "^0.37.0",
|
||||
"@lexical/rich-text": "^0.37.0",
|
||||
"@lexical/selection": "^0.37.0",
|
||||
"@lexical/utils": "^0.37.0",
|
||||
"i18next": "^25.5.3",
|
||||
"i18next-browser-languagedetector": "^8.2.0",
|
||||
"lexical": "^0.37.0",
|
||||
"react": "^19.1.1",
|
||||
"react-dom": "^19.1.1",
|
||||
"react-i18next": "^16.0.0",
|
||||
@@ -508,6 +517,59 @@
|
||||
"node": "^18.18.0 || ^20.9.0 || >=21.1.0"
|
||||
}
|
||||
},
|
||||
"node_modules/@floating-ui/core": {
|
||||
"version": "1.7.3",
|
||||
"resolved": "https://registry.npmjs.org/@floating-ui/core/-/core-1.7.3.tgz",
|
||||
"integrity": "sha512-sGnvb5dmrJaKEZ+LDIpguvdX3bDlEllmv4/ClQ9awcmCZrlx5jQyyMWFM5kBI+EyNOCDDiKk8il0zeuX3Zlg/w==",
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"@floating-ui/utils": "^0.2.10"
|
||||
}
|
||||
},
|
||||
"node_modules/@floating-ui/dom": {
|
||||
"version": "1.7.4",
|
||||
"resolved": "https://registry.npmjs.org/@floating-ui/dom/-/dom-1.7.4.tgz",
|
||||
"integrity": "sha512-OOchDgh4F2CchOX94cRVqhvy7b3AFb+/rQXyswmzmGakRfkMgoWVjfnLWkRirfLEfuD4ysVW16eXzwt3jHIzKA==",
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"@floating-ui/core": "^1.7.3",
|
||||
"@floating-ui/utils": "^0.2.10"
|
||||
}
|
||||
},
|
||||
"node_modules/@floating-ui/react": {
|
||||
"version": "0.27.16",
|
||||
"resolved": "https://registry.npmjs.org/@floating-ui/react/-/react-0.27.16.tgz",
|
||||
"integrity": "sha512-9O8N4SeG2z++TSM8QA/KTeKFBVCNEz/AGS7gWPJf6KFRzmRWixFRnCnkPHRDwSVZW6QPDO6uT0P2SpWNKCc9/g==",
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"@floating-ui/react-dom": "^2.1.6",
|
||||
"@floating-ui/utils": "^0.2.10",
|
||||
"tabbable": "^6.0.0"
|
||||
},
|
||||
"peerDependencies": {
|
||||
"react": ">=17.0.0",
|
||||
"react-dom": ">=17.0.0"
|
||||
}
|
||||
},
|
||||
"node_modules/@floating-ui/react-dom": {
|
||||
"version": "2.1.6",
|
||||
"resolved": "https://registry.npmjs.org/@floating-ui/react-dom/-/react-dom-2.1.6.tgz",
|
||||
"integrity": "sha512-4JX6rEatQEvlmgU80wZyq9RT96HZJa88q8hp0pBd+LrczeDI4o6uA2M+uvxngVHo4Ihr8uibXxH6+70zhAFrVw==",
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"@floating-ui/dom": "^1.7.4"
|
||||
},
|
||||
"peerDependencies": {
|
||||
"react": ">=16.8.0",
|
||||
"react-dom": ">=16.8.0"
|
||||
}
|
||||
},
|
||||
"node_modules/@floating-ui/utils": {
|
||||
"version": "0.2.10",
|
||||
"resolved": "https://registry.npmjs.org/@floating-ui/utils/-/utils-0.2.10.tgz",
|
||||
"integrity": "sha512-aGTxbpbg8/b5JfU1HXSrbH3wXZuLPJcNEcZQFMxLs3oSzgtVu6nFPkbbGGUvBcUjKV2YyB9Wxxabo+HEH9tcRQ==",
|
||||
"license": "MIT"
|
||||
},
|
||||
"node_modules/@humanfs/core": {
|
||||
"version": "0.19.1",
|
||||
"resolved": "https://registry.npmjs.org/@humanfs/core/-/core-0.19.1.tgz",
|
||||
@@ -610,6 +672,281 @@
|
||||
"@jridgewell/sourcemap-codec": "^1.4.14"
|
||||
}
|
||||
},
|
||||
"node_modules/@lexical/clipboard": {
|
||||
"version": "0.37.0",
|
||||
"resolved": "https://registry.npmjs.org/@lexical/clipboard/-/clipboard-0.37.0.tgz",
|
||||
"integrity": "sha512-hRwASFX/ilaI5r8YOcZuQgONFshRgCPfdxfofNL7uruSFYAO6LkUhsjzZwUgf0DbmCJmbBADFw15FSthgCUhGA==",
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"@lexical/html": "0.37.0",
|
||||
"@lexical/list": "0.37.0",
|
||||
"@lexical/selection": "0.37.0",
|
||||
"@lexical/utils": "0.37.0",
|
||||
"lexical": "0.37.0"
|
||||
}
|
||||
},
|
||||
"node_modules/@lexical/code": {
|
||||
"version": "0.37.0",
|
||||
"resolved": "https://registry.npmjs.org/@lexical/code/-/code-0.37.0.tgz",
|
||||
"integrity": "sha512-ZXA4j/S8yLrxjrTnEp39VeDMp4Rd8bLYUlT4Buy1MQlS1WafxOiMhNQJG7k0BP/pO96YPkAebpA81ATKJL0IgA==",
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"@lexical/utils": "0.37.0",
|
||||
"lexical": "0.37.0",
|
||||
"prismjs": "^1.30.0"
|
||||
}
|
||||
},
|
||||
"node_modules/@lexical/devtools-core": {
|
||||
"version": "0.37.0",
|
||||
"resolved": "https://registry.npmjs.org/@lexical/devtools-core/-/devtools-core-0.37.0.tgz",
|
||||
"integrity": "sha512-iOR+aKLJR92nKYcEOW3K/bgjTN7dJIRC/OM4OvzigU0Xygxped0lXV6UmkYBp0eoqOOwckB8+rZWZszj9lKA8Q==",
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"@lexical/html": "0.37.0",
|
||||
"@lexical/link": "0.37.0",
|
||||
"@lexical/mark": "0.37.0",
|
||||
"@lexical/table": "0.37.0",
|
||||
"@lexical/utils": "0.37.0",
|
||||
"lexical": "0.37.0"
|
||||
},
|
||||
"peerDependencies": {
|
||||
"react": ">=17.x",
|
||||
"react-dom": ">=17.x"
|
||||
}
|
||||
},
|
||||
"node_modules/@lexical/dragon": {
|
||||
"version": "0.37.0",
|
||||
"resolved": "https://registry.npmjs.org/@lexical/dragon/-/dragon-0.37.0.tgz",
|
||||
"integrity": "sha512-iC4OKivEPtt7cGVSwZylLfz5T7Oqr9q9EOosS6E/byMyoqwkYWGjXn/qFiwIv1Xo3+G19vhfChi/+ZcYLXpHPw==",
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"@lexical/extension": "0.37.0",
|
||||
"lexical": "0.37.0"
|
||||
}
|
||||
},
|
||||
"node_modules/@lexical/extension": {
|
||||
"version": "0.37.0",
|
||||
"resolved": "https://registry.npmjs.org/@lexical/extension/-/extension-0.37.0.tgz",
|
||||
"integrity": "sha512-Z58f2tIdz9bn8gltUu5cVg37qROGha38dUZv20gI2GeNugXAkoPzJYEcxlI1D/26tkevJ/7VaFUr9PTk+iKmaA==",
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"@lexical/utils": "0.37.0",
|
||||
"@preact/signals-core": "^1.11.0",
|
||||
"lexical": "0.37.0"
|
||||
}
|
||||
},
|
||||
"node_modules/@lexical/hashtag": {
|
||||
"version": "0.37.0",
|
||||
"resolved": "https://registry.npmjs.org/@lexical/hashtag/-/hashtag-0.37.0.tgz",
|
||||
"integrity": "sha512-DHoDpiokJRBu+GnC0qQH529hamn9YNjL7vzzkTAeEMKsT9+4O848Cq6F2GJn8QjQToySlkVZW3mkh76uf/XLfg==",
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"@lexical/text": "0.37.0",
|
||||
"@lexical/utils": "0.37.0",
|
||||
"lexical": "0.37.0"
|
||||
}
|
||||
},
|
||||
"node_modules/@lexical/history": {
|
||||
"version": "0.37.0",
|
||||
"resolved": "https://registry.npmjs.org/@lexical/history/-/history-0.37.0.tgz",
|
||||
"integrity": "sha512-QKkrWCw4bsn/ZeLIkMVIpbtWKPhMYeax1nE7erHqTEwE52QR6pmZsZBgGSQDO73Ae29vahOmqlN7+ZJFvTKMVA==",
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"@lexical/extension": "0.37.0",
|
||||
"@lexical/utils": "0.37.0",
|
||||
"lexical": "0.37.0"
|
||||
}
|
||||
},
|
||||
"node_modules/@lexical/html": {
|
||||
"version": "0.37.0",
|
||||
"resolved": "https://registry.npmjs.org/@lexical/html/-/html-0.37.0.tgz",
|
||||
"integrity": "sha512-oTsBc45eL8/lmF7fqGR+UCjrJYP04gumzf5nk4TczrxWL2pM4GIMLLKG1mpQI2H1MDiRLzq3T/xdI7Gh74z7Zw==",
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"@lexical/selection": "0.37.0",
|
||||
"@lexical/utils": "0.37.0",
|
||||
"lexical": "0.37.0"
|
||||
}
|
||||
},
|
||||
"node_modules/@lexical/link": {
|
||||
"version": "0.37.0",
|
||||
"resolved": "https://registry.npmjs.org/@lexical/link/-/link-0.37.0.tgz",
|
||||
"integrity": "sha512-gglkjE99tKYnGAxQbrUq9TcaVKBQhidXhgPPbVw3x1Fba9biMafkbSJhE/7/pzQTPoQBAIl0w7DOUWmBOv+JbQ==",
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"@lexical/extension": "0.37.0",
|
||||
"@lexical/utils": "0.37.0",
|
||||
"lexical": "0.37.0"
|
||||
}
|
||||
},
|
||||
"node_modules/@lexical/list": {
|
||||
"version": "0.37.0",
|
||||
"resolved": "https://registry.npmjs.org/@lexical/list/-/list-0.37.0.tgz",
|
||||
"integrity": "sha512-AOC6yAA3mfNvJKbwo+kvAbPJI+13yF2ISA65vbA578CugvJ08zIVgM+pSzxquGhD0ioJY3cXVW7+gdkCP1qu5g==",
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"@lexical/extension": "0.37.0",
|
||||
"@lexical/selection": "0.37.0",
|
||||
"@lexical/utils": "0.37.0",
|
||||
"lexical": "0.37.0"
|
||||
}
|
||||
},
|
||||
"node_modules/@lexical/mark": {
|
||||
"version": "0.37.0",
|
||||
"resolved": "https://registry.npmjs.org/@lexical/mark/-/mark-0.37.0.tgz",
|
||||
"integrity": "sha512-ncjaL6kNHVioekx6vI5oJRDExFDJLbnXT7AdMnUv2LE3sxn/ea+JsZO/MDI4Ygmxq+lGtgZvbBDER8Yh/+5jdA==",
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"@lexical/utils": "0.37.0",
|
||||
"lexical": "0.37.0"
|
||||
}
|
||||
},
|
||||
"node_modules/@lexical/markdown": {
|
||||
"version": "0.37.0",
|
||||
"resolved": "https://registry.npmjs.org/@lexical/markdown/-/markdown-0.37.0.tgz",
|
||||
"integrity": "sha512-pcLMpxWkSxU2QaN2GLA3hNy4lV2A8sJOvb5YEkcsFEcVvFFbAz7lxgyKVYtDboRCW1eZFks1UGGuJEogLeEFdg==",
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"@lexical/code": "0.37.0",
|
||||
"@lexical/link": "0.37.0",
|
||||
"@lexical/list": "0.37.0",
|
||||
"@lexical/rich-text": "0.37.0",
|
||||
"@lexical/text": "0.37.0",
|
||||
"@lexical/utils": "0.37.0",
|
||||
"lexical": "0.37.0"
|
||||
}
|
||||
},
|
||||
"node_modules/@lexical/offset": {
|
||||
"version": "0.37.0",
|
||||
"resolved": "https://registry.npmjs.org/@lexical/offset/-/offset-0.37.0.tgz",
|
||||
"integrity": "sha512-q9Ckftfhb+VepJQeaClOYzpuV+WqWWGkSUuoexV4zjAm/HVjOie9lrNF4NkhQe5crnIBXI5zOofhuEfiCQWsbQ==",
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"lexical": "0.37.0"
|
||||
}
|
||||
},
|
||||
"node_modules/@lexical/overflow": {
|
||||
"version": "0.37.0",
|
||||
"resolved": "https://registry.npmjs.org/@lexical/overflow/-/overflow-0.37.0.tgz",
|
||||
"integrity": "sha512-GC5qoQJQzaofCq1eMMvv9wIGMAbpFbFwny5BKA1C2Nmn+/2bi6v+7qlHwiBlbSVqfLVPvT4nYdrmNdnKoE0jZg==",
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"lexical": "0.37.0"
|
||||
}
|
||||
},
|
||||
"node_modules/@lexical/plain-text": {
|
||||
"version": "0.37.0",
|
||||
"resolved": "https://registry.npmjs.org/@lexical/plain-text/-/plain-text-0.37.0.tgz",
|
||||
"integrity": "sha512-4IxG9Tr0NnQ+clN1eoXfe2W8JTgw0xtPMzqvHP2IaO7RILUE6H8VFSOdhAOI0dHrjlXRMUS3I2Fhqr2ZRq8kdQ==",
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"@lexical/clipboard": "0.37.0",
|
||||
"@lexical/dragon": "0.37.0",
|
||||
"@lexical/selection": "0.37.0",
|
||||
"@lexical/utils": "0.37.0",
|
||||
"lexical": "0.37.0"
|
||||
}
|
||||
},
|
||||
"node_modules/@lexical/react": {
|
||||
"version": "0.37.0",
|
||||
"resolved": "https://registry.npmjs.org/@lexical/react/-/react-0.37.0.tgz",
|
||||
"integrity": "sha512-PGIGmI5xDSAguqpAStd+89TfWsi6hs/R4a3hQAyNwXXDEt4anUFJic4Qet4YftybLGajP3vMvouLE5hrkmBihg==",
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"@floating-ui/react": "^0.27.16",
|
||||
"@lexical/devtools-core": "0.37.0",
|
||||
"@lexical/dragon": "0.37.0",
|
||||
"@lexical/extension": "0.37.0",
|
||||
"@lexical/hashtag": "0.37.0",
|
||||
"@lexical/history": "0.37.0",
|
||||
"@lexical/link": "0.37.0",
|
||||
"@lexical/list": "0.37.0",
|
||||
"@lexical/mark": "0.37.0",
|
||||
"@lexical/markdown": "0.37.0",
|
||||
"@lexical/overflow": "0.37.0",
|
||||
"@lexical/plain-text": "0.37.0",
|
||||
"@lexical/rich-text": "0.37.0",
|
||||
"@lexical/table": "0.37.0",
|
||||
"@lexical/text": "0.37.0",
|
||||
"@lexical/utils": "0.37.0",
|
||||
"@lexical/yjs": "0.37.0",
|
||||
"lexical": "0.37.0",
|
||||
"react-error-boundary": "^6.0.0"
|
||||
},
|
||||
"peerDependencies": {
|
||||
"react": ">=17.x",
|
||||
"react-dom": ">=17.x"
|
||||
}
|
||||
},
|
||||
"node_modules/@lexical/rich-text": {
|
||||
"version": "0.37.0",
|
||||
"resolved": "https://registry.npmjs.org/@lexical/rich-text/-/rich-text-0.37.0.tgz",
|
||||
"integrity": "sha512-A9i5Es/RrZv71tB6dDSyd4TYdbkn/+oUrUdTwnWa+B8EZW26q0h+wgxCGwPtTU7ho4JNP9HOot+EIhe2DbyaYg==",
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"@lexical/clipboard": "0.37.0",
|
||||
"@lexical/dragon": "0.37.0",
|
||||
"@lexical/selection": "0.37.0",
|
||||
"@lexical/utils": "0.37.0",
|
||||
"lexical": "0.37.0"
|
||||
}
|
||||
},
|
||||
"node_modules/@lexical/selection": {
|
||||
"version": "0.37.0",
|
||||
"resolved": "https://registry.npmjs.org/@lexical/selection/-/selection-0.37.0.tgz",
|
||||
"integrity": "sha512-Lix1s2r71jHfsTEs4q/YqK2s3uXKOnyA3fd1VDMWysO+bZzRwEO5+qyDvENZ0WrXSDCnlibNFV1HttWX9/zqyw==",
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"lexical": "0.37.0"
|
||||
}
|
||||
},
|
||||
"node_modules/@lexical/table": {
|
||||
"version": "0.37.0",
|
||||
"resolved": "https://registry.npmjs.org/@lexical/table/-/table-0.37.0.tgz",
|
||||
"integrity": "sha512-g7S8ml8kIujEDLWlzYKETgPCQ2U9oeWqdytRuHjHGi/rjAAGHSej5IRqTPIMxNP3VVQHnBoQ+Y9hBtjiuddhgQ==",
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"@lexical/clipboard": "0.37.0",
|
||||
"@lexical/extension": "0.37.0",
|
||||
"@lexical/utils": "0.37.0",
|
||||
"lexical": "0.37.0"
|
||||
}
|
||||
},
|
||||
"node_modules/@lexical/text": {
|
||||
"version": "0.37.0",
|
||||
"resolved": "https://registry.npmjs.org/@lexical/text/-/text-0.37.0.tgz",
|
||||
"integrity": "sha512-qByNjHp88mlUWHxfYutH4vhSs3nzfCGHKsf/MqUMOC8K7Kmp0V1NK6cOW1sgsHpzkovfpgcNOGDzZxTNCFgHtg==",
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"lexical": "0.37.0"
|
||||
}
|
||||
},
|
||||
"node_modules/@lexical/utils": {
|
||||
"version": "0.37.0",
|
||||
"resolved": "https://registry.npmjs.org/@lexical/utils/-/utils-0.37.0.tgz",
|
||||
"integrity": "sha512-CFp4diY/kR5RqhzQSl/7SwsMod1sgLpI1FBifcOuJ6L/S6YywGpEB4B7aV5zqW21A/jU2T+2NZtxSUn6S+9gMg==",
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"@lexical/list": "0.37.0",
|
||||
"@lexical/selection": "0.37.0",
|
||||
"@lexical/table": "0.37.0",
|
||||
"lexical": "0.37.0"
|
||||
}
|
||||
},
|
||||
"node_modules/@lexical/yjs": {
|
||||
"version": "0.37.0",
|
||||
"resolved": "https://registry.npmjs.org/@lexical/yjs/-/yjs-0.37.0.tgz",
|
||||
"integrity": "sha512-7UjHvXDd+Is/qTdNkpQ/K04Zduh2uh7UTlSWbMiqwbQh8VRJNXXgcH8iK0TXLwc7M3VgVk+FlnNApNvcReKB6g==",
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"@lexical/offset": "0.37.0",
|
||||
"@lexical/selection": "0.37.0",
|
||||
"lexical": "0.37.0"
|
||||
},
|
||||
"peerDependencies": {
|
||||
"yjs": ">=13.5.22"
|
||||
}
|
||||
},
|
||||
"node_modules/@napi-rs/wasm-runtime": {
|
||||
"version": "1.0.5",
|
||||
"resolved": "https://registry.npmjs.org/@napi-rs/wasm-runtime/-/wasm-runtime-1.0.5.tgz",
|
||||
@@ -681,6 +1018,16 @@
|
||||
"url": "https://github.com/sponsors/Boshen"
|
||||
}
|
||||
},
|
||||
"node_modules/@preact/signals-core": {
|
||||
"version": "1.12.1",
|
||||
"resolved": "https://registry.npmjs.org/@preact/signals-core/-/signals-core-1.12.1.tgz",
|
||||
"integrity": "sha512-BwbTXpj+9QutoZLQvbttRg5x3l5468qaV2kufh+51yha1c53ep5dY4kTuZR35+3pAZxpfQerGJiQqg34ZNZ6uA==",
|
||||
"license": "MIT",
|
||||
"funding": {
|
||||
"type": "opencollective",
|
||||
"url": "https://opencollective.com/preact"
|
||||
}
|
||||
},
|
||||
"node_modules/@rolldown/binding-android-arm64": {
|
||||
"version": "1.0.0-beta.39",
|
||||
"resolved": "https://registry.npmjs.org/@rolldown/binding-android-arm64/-/binding-android-arm64-1.0.0-beta.39.tgz",
|
||||
@@ -2131,6 +2478,17 @@
|
||||
"dev": true,
|
||||
"license": "ISC"
|
||||
},
|
||||
"node_modules/isomorphic.js": {
|
||||
"version": "0.2.5",
|
||||
"resolved": "https://registry.npmjs.org/isomorphic.js/-/isomorphic.js-0.2.5.tgz",
|
||||
"integrity": "sha512-PIeMbHqMt4DnUP3MA/Flc0HElYjMXArsw1qwJZcm9sqR8mq3l8NYizFMty0pWwE/tzIGH3EKK5+jes5mAr85yw==",
|
||||
"license": "MIT",
|
||||
"peer": true,
|
||||
"funding": {
|
||||
"type": "GitHub Sponsors ❤",
|
||||
"url": "https://github.com/sponsors/dmonad"
|
||||
}
|
||||
},
|
||||
"node_modules/js-tokens": {
|
||||
"version": "4.0.0",
|
||||
"resolved": "https://registry.npmjs.org/js-tokens/-/js-tokens-4.0.0.tgz",
|
||||
@@ -2222,6 +2580,34 @@
|
||||
"node": ">= 0.8.0"
|
||||
}
|
||||
},
|
||||
"node_modules/lexical": {
|
||||
"version": "0.37.0",
|
||||
"resolved": "https://registry.npmjs.org/lexical/-/lexical-0.37.0.tgz",
|
||||
"integrity": "sha512-r5VJR2TioQPAsZATfktnJFrGIiy6gjQN8b/+0a2u1d7/QTH7lhbB7byhGSvcq1iaa1TV/xcf/pFV55a5V5hTDQ==",
|
||||
"license": "MIT"
|
||||
},
|
||||
"node_modules/lib0": {
|
||||
"version": "0.2.114",
|
||||
"resolved": "https://registry.npmjs.org/lib0/-/lib0-0.2.114.tgz",
|
||||
"integrity": "sha512-gcxmNFzA4hv8UYi8j43uPlQ7CGcyMJ2KQb5kZASw6SnAKAf10hK12i2fjrS3Cl/ugZa5Ui6WwIu1/6MIXiHttQ==",
|
||||
"license": "MIT",
|
||||
"peer": true,
|
||||
"dependencies": {
|
||||
"isomorphic.js": "^0.2.4"
|
||||
},
|
||||
"bin": {
|
||||
"0ecdsa-generate-keypair": "bin/0ecdsa-generate-keypair.js",
|
||||
"0gentesthtml": "bin/gentesthtml.js",
|
||||
"0serve": "bin/0serve.js"
|
||||
},
|
||||
"engines": {
|
||||
"node": ">=16"
|
||||
},
|
||||
"funding": {
|
||||
"type": "GitHub Sponsors ❤",
|
||||
"url": "https://github.com/sponsors/dmonad"
|
||||
}
|
||||
},
|
||||
"node_modules/lightningcss": {
|
||||
"version": "1.30.2",
|
||||
"resolved": "https://registry.npmjs.org/lightningcss/-/lightningcss-1.30.2.tgz",
|
||||
@@ -2735,6 +3121,15 @@
|
||||
"node": ">= 0.8.0"
|
||||
}
|
||||
},
|
||||
"node_modules/prismjs": {
|
||||
"version": "1.30.0",
|
||||
"resolved": "https://registry.npmjs.org/prismjs/-/prismjs-1.30.0.tgz",
|
||||
"integrity": "sha512-DEvV2ZF2r2/63V+tK8hQvrR2ZGn10srHbXviTlcv7Kpzw8jWiNTqbVgjO3IY8RxrrOUF8VPMQQFysYYYv0YZxw==",
|
||||
"license": "MIT",
|
||||
"engines": {
|
||||
"node": ">=6"
|
||||
}
|
||||
},
|
||||
"node_modules/punycode": {
|
||||
"version": "2.3.1",
|
||||
"resolved": "https://registry.npmjs.org/punycode/-/punycode-2.3.1.tgz",
|
||||
@@ -2787,6 +3182,18 @@
|
||||
"react": "^19.1.1"
|
||||
}
|
||||
},
|
||||
"node_modules/react-error-boundary": {
|
||||
"version": "6.0.0",
|
||||
"resolved": "https://registry.npmjs.org/react-error-boundary/-/react-error-boundary-6.0.0.tgz",
|
||||
"integrity": "sha512-gdlJjD7NWr0IfkPlaREN2d9uUZUlksrfOx7SX62VRerwXbMY6ftGCIZua1VG1aXFNOimhISsTq+Owp725b9SiA==",
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"@babel/runtime": "^7.12.5"
|
||||
},
|
||||
"peerDependencies": {
|
||||
"react": ">=16.13.1"
|
||||
}
|
||||
},
|
||||
"node_modules/react-i18next": {
|
||||
"version": "16.0.0",
|
||||
"resolved": "https://registry.npmjs.org/react-i18next/-/react-i18next-16.0.0.tgz",
|
||||
@@ -3028,6 +3435,12 @@
|
||||
"node": ">=8"
|
||||
}
|
||||
},
|
||||
"node_modules/tabbable": {
|
||||
"version": "6.2.0",
|
||||
"resolved": "https://registry.npmjs.org/tabbable/-/tabbable-6.2.0.tgz",
|
||||
"integrity": "sha512-Cat63mxsVJlzYvN51JmVXIgNoUokrIaT2zLclCXjRd8boZ0004U4KCs/sToJ75C6sdlByWxpYnb5Boif1VSFew==",
|
||||
"license": "MIT"
|
||||
},
|
||||
"node_modules/tinyglobby": {
|
||||
"version": "0.2.15",
|
||||
"resolved": "https://registry.npmjs.org/tinyglobby/-/tinyglobby-0.2.15.tgz",
|
||||
@@ -3352,6 +3765,24 @@
|
||||
"dev": true,
|
||||
"license": "ISC"
|
||||
},
|
||||
"node_modules/yjs": {
|
||||
"version": "13.6.27",
|
||||
"resolved": "https://registry.npmjs.org/yjs/-/yjs-13.6.27.tgz",
|
||||
"integrity": "sha512-OIDwaflOaq4wC6YlPBy2L6ceKeKuF7DeTxx+jPzv1FHn9tCZ0ZwSRnUBxD05E3yed46fv/FWJbvR+Ud7x0L7zw==",
|
||||
"license": "MIT",
|
||||
"peer": true,
|
||||
"dependencies": {
|
||||
"lib0": "^0.2.99"
|
||||
},
|
||||
"engines": {
|
||||
"node": ">=16.0.0",
|
||||
"npm": ">=8.0.0"
|
||||
},
|
||||
"funding": {
|
||||
"type": "GitHub Sponsors ❤",
|
||||
"url": "https://github.com/sponsors/dmonad"
|
||||
}
|
||||
},
|
||||
"node_modules/yocto-queue": {
|
||||
"version": "0.1.0",
|
||||
"resolved": "https://registry.npmjs.org/yocto-queue/-/yocto-queue-0.1.0.tgz",
|
||||
|
||||
@@ -10,8 +10,17 @@
|
||||
"preview": "vite preview"
|
||||
},
|
||||
"dependencies": {
|
||||
"@lexical/clipboard": "^0.37.0",
|
||||
"@lexical/history": "^0.37.0",
|
||||
"@lexical/link": "^0.37.0",
|
||||
"@lexical/list": "^0.37.0",
|
||||
"@lexical/react": "^0.37.0",
|
||||
"@lexical/rich-text": "^0.37.0",
|
||||
"@lexical/selection": "^0.37.0",
|
||||
"@lexical/utils": "^0.37.0",
|
||||
"i18next": "^25.5.3",
|
||||
"i18next-browser-languagedetector": "^8.2.0",
|
||||
"lexical": "^0.37.0",
|
||||
"react": "^19.1.1",
|
||||
"react-dom": "^19.1.1",
|
||||
"react-i18next": "^16.0.0",
|
||||
|
||||
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 Forum from './pages/Forum.tsx'
|
||||
import AuthCallback from './pages/AuthCallback.tsx'
|
||||
import CreatePost from './pages/CreatePost.tsx'
|
||||
|
||||
createRoot(document.getElementById('root')!).render(
|
||||
<StrictMode>
|
||||
@@ -29,6 +30,7 @@ createRoot(document.getElementById('root')!).render(
|
||||
<Route path="/blog" element={<Blog />} />
|
||||
<Route path="/servers" element={<Servers />} />
|
||||
<Route path="/forum" element={<Forum />} />
|
||||
<Route path="/create-post" element={<CreatePost />} />
|
||||
<Route path="/auth/callback" element={<AuthCallback />} />
|
||||
</Routes>
|
||||
</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