├── .github └── workflows │ └── main.yml ├── .gitignore ├── .npmignore ├── .storybook ├── main.ts └── preview.ts ├── CHANGELOG.md ├── LICENSE ├── README.md ├── biome.json ├── package.json ├── pnpm-lock.yaml ├── src ├── Editor.tsx ├── Icons.tsx ├── index.tsx ├── plugins │ ├── CodeHighlightPlugin.js │ ├── ListMaxIndentLevelPlugin.js │ └── ToolbarPlugin.jsx ├── stories │ ├── remindoro.stories.tsx │ └── slite.stories.tsx ├── styles.css ├── themes │ └── DefaultTheme.ts └── utils.ts ├── test └── slite.test.tsx ├── tsconfig.json ├── vite.config.ts └── vitest.config.js /.github/workflows/main.yml: -------------------------------------------------------------------------------- 1 | name: CI 2 | on: [push] 3 | jobs: 4 | build: 5 | name: Build, lint, and test on Node ${{ matrix.node }} and ${{ matrix.os }} 6 | 7 | runs-on: ${{ matrix.os }} 8 | strategy: 9 | matrix: 10 | node: ['20.x'] 11 | os: [ubuntu-latest] 12 | 13 | steps: 14 | - name: Checkout repo and Cache pnpm modules 15 | uses: actions/checkout@v4 16 | 17 | - name: Cache pnpm modules 18 | uses: actions/cache@v4 19 | with: 20 | path: ~/.pnpm-store 21 | key: ${{ runner.os }}-${{ hashFiles('**/pnpm-lock.yaml') }} 22 | restore-keys: | 23 | ${{ runner.os }}- 24 | 25 | - uses: pnpm/action-setup@v4 26 | with: 27 | version: 10 28 | run_install: true 29 | 30 | - name: Use Node ${{ matrix.node }} 31 | uses: actions/setup-node@v4 32 | with: 33 | node-version: ${{ matrix.node }} 34 | cache: 'pnpm' 35 | 36 | - name: Lint 37 | run: pnpm lint 38 | 39 | - name: Test 40 | run: pnpm test 41 | 42 | - name: Build 43 | run: pnpm build 44 | -------------------------------------------------------------------------------- /.gitignore: -------------------------------------------------------------------------------- 1 | *.log 2 | .DS_Store 3 | node_modules 4 | .cache 5 | dist 6 | coverage 7 | 8 | *.code-workspace 9 | -------------------------------------------------------------------------------- /.npmignore: -------------------------------------------------------------------------------- 1 | node_modules 2 | .cache 3 | dist -------------------------------------------------------------------------------- /.storybook/main.ts: -------------------------------------------------------------------------------- 1 | import type { StorybookConfig } from '@storybook/react-vite' 2 | const config: StorybookConfig = { 3 | stories: ['../src/**/*.mdx', '../src/**/*.stories.@(js|jsx|ts|tsx)'], 4 | 5 | addons: [ 6 | '@storybook/addon-links', 7 | '@storybook/addon-essentials', 8 | '@storybook/addon-interactions', 9 | '@chromatic-com/storybook' 10 | ], 11 | 12 | framework: { 13 | name: '@storybook/react-vite', 14 | options: {}, 15 | }, 16 | 17 | core: { 18 | disableTelemetry: true, 19 | }, 20 | 21 | docs: {}, 22 | 23 | typescript: { 24 | reactDocgen: 'react-docgen-typescript' 25 | } 26 | } 27 | export default config 28 | -------------------------------------------------------------------------------- /.storybook/preview.ts: -------------------------------------------------------------------------------- 1 | import type { Preview } from '@storybook/react' 2 | 3 | const preview: Preview = { 4 | parameters: { 5 | controls: { 6 | matchers: { 7 | color: /(background|color)$/i, 8 | date: /Date$/, 9 | }, 10 | }, 11 | }, 12 | } 13 | 14 | export default preview 15 | -------------------------------------------------------------------------------- /CHANGELOG.md: -------------------------------------------------------------------------------- 1 | # `react-slite` 2 | 3 | ## `0.3.1` 4 | - Externalize `react/jsx-runtime` and `react-dom/client` 5 | 6 | ## `0.3.0` 7 | - Lexical `v0.27.1` 8 | - biome 9 | - storybook v8 10 | 11 | ## `0.2.5` 12 | - No soft break hacks and util exports (for now) 13 | 14 | ## `0.2.4` 15 | - Fixes for handling soft breaks 16 | - example storybook simulating remindoro input 17 | 18 | ## `0.2.3` 19 | - Utils for handling soft breaks 20 | - `readOnly` prop for Editor component 21 | - Hiding placeholder for readonly mode 22 | 23 | ## `0.2.2` 24 | - Exporting dropdown class identifier 25 | 26 | ## `0.2.1` 27 | - Dark theme for code block 28 | 29 | ## `0.2.0` 30 | - [lexical](https://github.com/facebook/lexical) powered `react-slite`. 31 | 32 | ------------- 33 | 34 | ## `0.1.5` 35 | - Handle thematic break hanging when clicked on toolbar by adding new para after thematic break. 36 | 37 | ## `0.1.4` 38 | - Better Thematic Break handling - https://github.com/palerdot/react-slite/commit/193b79da9e6613dcf9f65f89b99302177d9d8b28 39 | 40 | 41 | ## `0.1.1` 42 | - Export `Descendant` type from `slate` 43 | 44 | ## `0.1.0` 45 | 46 | - Thematic break support 47 | - Markdown utils 48 | - Fixed list to para conversion bug 49 | -------------------------------------------------------------------------------- /LICENSE: -------------------------------------------------------------------------------- 1 | MIT License 2 | 3 | Copyright (c) 2022 palerdot 4 | 5 | Permission is hereby granted, free of charge, to any person obtaining a copy 6 | of this software and associated documentation files (the "Software"), to deal 7 | in the Software without restriction, including without limitation the rights 8 | to use, copy, modify, merge, publish, distribute, sublicense, and/or sell 9 | copies of the Software, and to permit persons to whom the Software is 10 | furnished to do so, subject to the following conditions: 11 | 12 | The above copyright notice and this permission notice shall be included in all 13 | copies or substantial portions of the Software. 14 | 15 | THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR 16 | IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, 17 | FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE 18 | AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER 19 | LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, 20 | OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE 21 | SOFTWARE. -------------------------------------------------------------------------------- /README.md: -------------------------------------------------------------------------------- 1 | ## react-slite 2 | 3 | > `SL`ack `I`nspired `T`ext `E`diting 4 | 5 | This react component **aims** to provide a slack like rich text editing experience powered by [lexical library](https://lexical.dev). This project is currently in alpha stage and used internally for [Remindoro extension](https://palerdot.in/remindoro). 6 | 7 | NOTE: Starting `v0.2.0`, `react-slite` is powered by [lexical](https://github.com/facebook/lexical). Right now, it is just a thin wrapper around vanilla lexical functionality. As lexical becomes more stable and moves towards `v1.x`, this library will become more feature complete. Till then, apis are subject to change. 8 | 9 | ### Usage 10 | 11 | ```javascript 12 | import Slite, { Toolbar, Editor, SliteProps, ThemeClassList, SLITE_EDITOR_CONTAINER_CLASS } from '../index' 13 | 14 | function SliteWrapper({ initialValue, onChange, readOnly }: SliteProps) { 15 | return ( 16 | {}} 19 | readOnly={false} 20 | > 21 | {/* decide if and how you want to display the toolbar */} 22 | {!readOnly && } 23 | {/* editor text area */} 24 | 25 | 26 | ) 27 | } 28 | 29 | // ThemeClassList has all the classes used within the rich text editor 30 | // SLITE_EDITOR_CONTAINER_CLASS exports the container class name, which is `slite-container` 31 | // With this info, theming can be done via classes 32 | ``` 33 | For more examples, please run `pnpm run storybook` in the repo. 34 | 35 | 36 | -------------------------------------------------------------------------------- /biome.json: -------------------------------------------------------------------------------- 1 | { 2 | "$schema": "https://biomejs.dev/schemas/1.9.4/schema.json", 3 | "vcs": { "enabled": false, "clientKind": "git", "useIgnoreFile": false }, 4 | "files": { 5 | "ignoreUnknown": false, 6 | "include": ["src", "test"], 7 | "ignore": ["coverage", "dist"] 8 | }, 9 | "formatter": { 10 | "enabled": true, 11 | "useEditorconfig": true, 12 | "formatWithErrors": false, 13 | "indentStyle": "space", 14 | "indentWidth": 2, 15 | "lineEnding": "lf", 16 | "lineWidth": 80, 17 | "attributePosition": "auto", 18 | "bracketSpacing": true 19 | }, 20 | "organizeImports": { "enabled": false }, 21 | "linter": { 22 | "enabled": true, 23 | "rules": { 24 | "recommended": true, 25 | "a11y": { 26 | "noSvgWithoutTitle": "off" 27 | } 28 | } 29 | }, 30 | "javascript": { 31 | "formatter": { 32 | "jsxQuoteStyle": "double", 33 | "quoteProperties": "asNeeded", 34 | "trailingCommas": "es5", 35 | "semicolons": "asNeeded", 36 | "arrowParentheses": "asNeeded", 37 | "bracketSameLine": false, 38 | "quoteStyle": "single", 39 | "attributePosition": "auto", 40 | "bracketSpacing": true 41 | } 42 | }, 43 | "json": { 44 | "formatter": { 45 | "enabled": false 46 | } 47 | } 48 | } 49 | -------------------------------------------------------------------------------- /package.json: -------------------------------------------------------------------------------- 1 | { 2 | "name": "react-slite", 3 | "version": "0.3.1", 4 | "main": "dist/index.js", 5 | "typings": "dist/index.d.ts", 6 | "license": "MIT", 7 | "author": "palerdot", 8 | "repository": "https://github.com/palerdot/react-slite", 9 | "module": "dist/react-slite.es.js", 10 | "files": [ 11 | "dist" 12 | ], 13 | "engines": { 14 | "node": ">=14" 15 | }, 16 | "peerDependencies": { 17 | "react": ">=18" 18 | }, 19 | "husky": { 20 | "hooks": { 21 | "pre-commit": "tsdx lint" 22 | } 23 | }, 24 | "scripts": { 25 | "start": "vite", 26 | "build": "tsc && vite build", 27 | "test": "vitest", 28 | "lint": "biome check src/ test/", 29 | "lint:fix": "biome check --write src/ test/", 30 | "prepare": "pnpm build", 31 | "storybook": "storybook dev -p 6006", 32 | "build-storybook": "storybook build" 33 | }, 34 | "devDependencies": { 35 | "@biomejs/biome": "1.9.4", 36 | "@chromatic-com/storybook": "^3.2.5", 37 | "@size-limit/preset-small-lib": "^11.2.0", 38 | "@storybook/addon-essentials": "^8.6.4", 39 | "@storybook/addon-interactions": "^8.6.4", 40 | "@storybook/addon-links": "^8.6.4", 41 | "@storybook/blocks": "^8.6.4", 42 | "@storybook/react": "^8.6.4", 43 | "@storybook/react-vite": "^8.6.4", 44 | "@storybook/test": "^8.6.4", 45 | "@types/react": "^19.0.10", 46 | "@types/react-dom": "^19.0.4", 47 | "@vitejs/plugin-react": "^4.3.4", 48 | "babel-loader": "^10.0.0", 49 | "happy-dom": "^17.4.2", 50 | "husky": "^9.1.7", 51 | "prop-types": "^15.8.1", 52 | "react": "^19.0.0", 53 | "react-dom": "^19.0.0", 54 | "react-is": "^19.0.0", 55 | "storybook": "^8.6.4", 56 | "tslib": "^2.8.1", 57 | "typescript": "^5.8.2", 58 | "vite": "^6.2.1", 59 | "vite-plugin-css-injected-by-js": "^3.5.2", 60 | "vite-plugin-dts": "^4.5.3", 61 | "vitest": "^3.0.8" 62 | }, 63 | "dependencies": { 64 | "@lexical/code": "^0.27.1", 65 | "@lexical/link": "^0.27.1", 66 | "@lexical/list": "^0.27.1", 67 | "@lexical/markdown": "^0.27.1", 68 | "@lexical/react": "^0.27.1", 69 | "@lexical/rich-text": "^0.27.1", 70 | "@lexical/selection": "^0.27.1", 71 | "@lexical/table": "^0.27.1", 72 | "@lexical/utils": "^0.27.1", 73 | "lexical": "^0.27.1" 74 | } 75 | } 76 | -------------------------------------------------------------------------------- /src/Editor.tsx: -------------------------------------------------------------------------------- 1 | import { LexicalComposer } from '@lexical/react/LexicalComposer' 2 | import { OnChangePlugin } from '@lexical/react/LexicalOnChangePlugin' 3 | import { RichTextPlugin } from '@lexical/react/LexicalRichTextPlugin' 4 | import { ContentEditable } from '@lexical/react/LexicalContentEditable' 5 | import { AutoFocusPlugin } from '@lexical/react/LexicalAutoFocusPlugin' 6 | import { LexicalErrorBoundary } from '@lexical/react/LexicalErrorBoundary' 7 | import { HeadingNode, QuoteNode } from '@lexical/rich-text' 8 | import { ListItemNode, ListNode } from '@lexical/list' 9 | import { CodeHighlightNode, CodeNode } from '@lexical/code' 10 | import { ListPlugin } from '@lexical/react/LexicalListPlugin' 11 | import { TableCellNode, TableNode, TableRowNode } from '@lexical/table' 12 | import { AutoLinkNode, LinkNode } from '@lexical/link' 13 | import { MarkdownShortcutPlugin } from '@lexical/react/LexicalMarkdownShortcutPlugin' 14 | import { 15 | $convertFromMarkdownString, 16 | $convertToMarkdownString, 17 | TRANSFORMERS, 18 | } from '@lexical/markdown' 19 | import { $createParagraphNode, $getRoot } from 'lexical' 20 | 21 | import ToolbarPlugin from './plugins/ToolbarPlugin' 22 | import ListMaxIndentLevelPlugin from './plugins/ListMaxIndentLevelPlugin' 23 | import CodeHighlightPlugin from './plugins/CodeHighlightPlugin' 24 | import DefaultTheme, { 25 | SLITE_EDITOR_CONTAINER_CLASS, 26 | } from './themes/DefaultTheme' 27 | 28 | import type { EditorState } from 'lexical' 29 | import type { InitialConfigType } from '@lexical/react/LexicalComposer' 30 | 31 | export interface SliteProps { 32 | initialValue?: string 33 | onChange: (text: string) => void 34 | readOnly?: boolean 35 | children: React.ReactNode 36 | } 37 | 38 | function Placeholder() { 39 | return
{'Enter some rich text ...'}
40 | } 41 | 42 | // ref: https://stackoverflow.com/questions/71976652/with-lexical-how-do-i-set-default-initial-text 43 | const onChangeHandler = ( 44 | editorState: EditorState, 45 | onChange: SliteProps['onChange'] 46 | ) => { 47 | editorState.read(() => { 48 | const markdown = $convertToMarkdownString(TRANSFORMERS) 49 | onChange(markdown) 50 | }) 51 | } 52 | 53 | const getInitialConfig = ( 54 | initialValue: string, 55 | editable: boolean 56 | ): InitialConfigType => { 57 | return { 58 | editorState: () => { 59 | // ref: https://stackoverflow.com/a/72172529/1410291 60 | // ref: https://github.com/facebook/lexical/issues/2308#issuecomment-1382721253 61 | if (initialValue === '') { 62 | const paragraph = $createParagraphNode() 63 | $getRoot().append(paragraph) 64 | paragraph.select() 65 | } else { 66 | $convertFromMarkdownString(initialValue, TRANSFORMERS) 67 | } 68 | }, 69 | // The editor theme 70 | theme: DefaultTheme, 71 | // Handling of errors during update 72 | onError(error: Error) { 73 | throw error 74 | }, 75 | // Any custom nodes go here 76 | nodes: [ 77 | HeadingNode, 78 | ListNode, 79 | ListItemNode, 80 | QuoteNode, 81 | CodeNode, 82 | CodeHighlightNode, 83 | TableNode, 84 | TableCellNode, 85 | TableRowNode, 86 | AutoLinkNode, 87 | LinkNode, 88 | ], 89 | namespace: '', 90 | editable, 91 | } 92 | } 93 | 94 | export function Editor({ readOnly }: { readOnly: SliteProps['readOnly'] }) { 95 | return ( 96 |
97 | } 99 | placeholder={readOnly ? null : } 100 | ErrorBoundary={LexicalErrorBoundary} 101 | /> 102 | 103 | 104 | 105 | 106 |
107 | ) 108 | } 109 | 110 | Editor.defaultProps = { 111 | readOnly: false, 112 | } 113 | 114 | export default function LexicalWrapper({ 115 | initialValue, 116 | onChange, 117 | readOnly, 118 | children, 119 | }: SliteProps) { 120 | const editable = !readOnly 121 | 122 | return ( 123 | 126 |
127 | {editable && ( 128 | onChangeHandler(editorState, onChange)} 130 | /> 131 | )} 132 | {editable && } 133 | {children} 134 |
135 |
136 | ) 137 | } 138 | 139 | export { ToolbarPlugin } 140 | -------------------------------------------------------------------------------- /src/Icons.tsx: -------------------------------------------------------------------------------- 1 | export const ParagraphIcon = () => { 2 | return ( 3 | 9 | 13 | 14 | ) 15 | } 16 | 17 | export const HeadingOneIcon = () => { 18 | return ( 19 | 25 | 26 | 27 | ) 28 | } 29 | 30 | export const HeadingTwoIcon = () => { 31 | return ( 32 | 38 | 39 | 40 | ) 41 | } 42 | 43 | export const HeadingThreeIcon = () => { 44 | return ( 45 | 51 | 52 | 53 | ) 54 | } 55 | 56 | export const BulletListIcon = () => { 57 | return ( 58 | 64 | 68 | 69 | ) 70 | } 71 | 72 | export const NumberedListIcon = () => { 73 | return ( 74 | 80 | 84 | 85 | 86 | ) 87 | } 88 | 89 | export const QuoteIcon = () => { 90 | return ( 91 | 97 | 98 | 99 | 100 | ) 101 | } 102 | 103 | export const CodeIcon = () => { 104 | return ( 105 | 111 | 112 | 113 | ) 114 | } 115 | 116 | export function getBlockTypeIcon(type: string) { 117 | switch (type) { 118 | case 'paragraph': 119 | return 120 | 121 | case 'h1': 122 | return 123 | 124 | case 'h2': 125 | return 126 | 127 | case 'h3': 128 | return 129 | 130 | case 'ul': 131 | return 132 | 133 | case 'ol': 134 | return 135 | 136 | case 'quote': 137 | return 138 | 139 | default: 140 | return null 141 | } 142 | } 143 | 144 | export const ChevronDownIcon = () => { 145 | return ( 146 | 152 | 156 | 157 | ) 158 | } 159 | 160 | export const BoldIcon = () => { 161 | return ( 162 | 168 | 169 | 170 | ) 171 | } 172 | 173 | export const ItalicIcon = () => { 174 | return ( 175 | 181 | 182 | 183 | ) 184 | } 185 | 186 | export const StrikeThroughIcon = () => { 187 | return ( 188 | 194 | 195 | 196 | ) 197 | } 198 | 199 | export const UnderlineIcon = () => { 200 | return ( 201 | 207 | 208 | 209 | ) 210 | } 211 | 212 | export const JustifyIcon = () => { 213 | return ( 214 | 220 | 224 | 225 | ) 226 | } 227 | 228 | export const TextLeftIcon = () => { 229 | return ( 230 | 236 | 240 | 241 | ) 242 | } 243 | 244 | export const TextCenterIcon = () => { 245 | return ( 246 | 252 | 256 | 257 | ) 258 | } 259 | 260 | export const TextRightIcon = () => { 261 | return ( 262 | 268 | 272 | 273 | ) 274 | } 275 | 276 | export const ArrowClockwiseIcon = () => { 277 | return ( 278 | 284 | 288 | 289 | 290 | ) 291 | } 292 | 293 | export const ArrowCounterClockwiseIcon = () => { 294 | return ( 295 | 301 | 305 | 306 | 307 | ) 308 | } 309 | 310 | export const JournalCodeIcon = () => { 311 | return ( 312 | 318 | 322 | 323 | 324 | 325 | ) 326 | } 327 | 328 | export const JournalTextIcon = () => { 329 | return ( 330 | 336 | 337 | 338 | 339 | 340 | ) 341 | } 342 | 343 | export const LinkIcon = () => { 344 | return ( 345 | 351 | 352 | 353 | 354 | ) 355 | } 356 | 357 | export const PencilFillIcon = () => { 358 | return ( 359 | 365 | 366 | 367 | ) 368 | } 369 | -------------------------------------------------------------------------------- /src/index.tsx: -------------------------------------------------------------------------------- 1 | import LexicalWrapper, { 2 | type SliteProps, 3 | Editor, 4 | ToolbarPlugin, 5 | } from './Editor' 6 | import './styles.css' 7 | 8 | import ThemeClassList, { 9 | SLITE_EDITOR_CONTAINER_CLASS, 10 | SLITE_DROPDOWN_CLASS, 11 | } from './themes/DefaultTheme' 12 | 13 | export { 14 | ThemeClassList, 15 | SLITE_EDITOR_CONTAINER_CLASS, 16 | SLITE_DROPDOWN_CLASS, 17 | Editor, 18 | ToolbarPlugin as Toolbar, 19 | } 20 | 21 | export type { SliteProps } 22 | 23 | function Slite({ initialValue, onChange, readOnly, children }: SliteProps) { 24 | return ( 25 | 30 | {children} 31 | 32 | ) 33 | } 34 | 35 | export default Slite 36 | -------------------------------------------------------------------------------- /src/plugins/CodeHighlightPlugin.js: -------------------------------------------------------------------------------- 1 | import { registerCodeHighlighting } from '@lexical/code' 2 | import { useLexicalComposerContext } from '@lexical/react/LexicalComposerContext' 3 | import { useEffect } from 'react' 4 | 5 | export default function CodeHighlightPlugin() { 6 | const [editor] = useLexicalComposerContext() 7 | 8 | useEffect(() => { 9 | return registerCodeHighlighting(editor) 10 | }, [editor]) 11 | 12 | return null 13 | } 14 | -------------------------------------------------------------------------------- /src/plugins/ListMaxIndentLevelPlugin.js: -------------------------------------------------------------------------------- 1 | import { $getListDepth, $isListItemNode, $isListNode } from '@lexical/list' 2 | import { useLexicalComposerContext } from '@lexical/react/LexicalComposerContext' 3 | import { 4 | $getSelection, 5 | $isElementNode, 6 | $isRangeSelection, 7 | INDENT_CONTENT_COMMAND, 8 | COMMAND_PRIORITY_HIGH, 9 | } from 'lexical' 10 | import { useEffect } from 'react' 11 | 12 | function getElementNodesInSelection(selection) { 13 | const nodesInSelection = selection.getNodes() 14 | 15 | if (nodesInSelection.length === 0) { 16 | return new Set([ 17 | selection.anchor.getNode().getParentOrThrow(), 18 | selection.focus.getNode().getParentOrThrow(), 19 | ]) 20 | } 21 | 22 | return new Set( 23 | nodesInSelection.map(n => ($isElementNode(n) ? n : n.getParentOrThrow())) 24 | ) 25 | } 26 | 27 | function isIndentPermitted(maxDepth) { 28 | const selection = $getSelection() 29 | 30 | if (!$isRangeSelection(selection)) { 31 | return false 32 | } 33 | 34 | const elementNodesInSelection = getElementNodesInSelection(selection) 35 | 36 | let totalDepth = 0 37 | 38 | for (const elementNode of elementNodesInSelection) { 39 | if ($isListNode(elementNode)) { 40 | totalDepth = Math.max($getListDepth(elementNode) + 1, totalDepth) 41 | } else if ($isListItemNode(elementNode)) { 42 | const parent = elementNode.getParent() 43 | if (!$isListNode(parent)) { 44 | throw new Error( 45 | 'ListMaxIndentLevelPlugin: A ListItemNode must have a ListNode for a parent.' 46 | ) 47 | } 48 | 49 | totalDepth = Math.max($getListDepth(parent) + 1, totalDepth) 50 | } 51 | } 52 | 53 | return totalDepth <= maxDepth 54 | } 55 | 56 | export default function ListMaxIndentLevelPlugin({ maxDepth }) { 57 | const [editor] = useLexicalComposerContext() 58 | 59 | useEffect(() => { 60 | return editor.registerCommand( 61 | INDENT_CONTENT_COMMAND, 62 | () => !isIndentPermitted(maxDepth ?? 7), 63 | COMMAND_PRIORITY_HIGH 64 | ) 65 | }, [editor, maxDepth]) 66 | 67 | return null 68 | } 69 | -------------------------------------------------------------------------------- /src/plugins/ToolbarPlugin.jsx: -------------------------------------------------------------------------------- 1 | import { useLexicalComposerContext } from '@lexical/react/LexicalComposerContext' 2 | import { useCallback, useEffect, useMemo, useRef, useState } from 'react' 3 | import { 4 | // CAN_REDO_COMMAND, 5 | // CAN_UNDO_COMMAND, 6 | // REDO_COMMAND, 7 | // UNDO_COMMAND, 8 | SELECTION_CHANGE_COMMAND, 9 | FORMAT_TEXT_COMMAND, 10 | // FORMAT_ELEMENT_COMMAND, 11 | $getSelection, 12 | $isRangeSelection, 13 | $createParagraphNode, 14 | $getNodeByKey, 15 | } from 'lexical' 16 | // import { $isLinkNode, TOGGLE_LINK_COMMAND } from '@lexical/link' 17 | import { 18 | $isParentElementRTL, 19 | $wrapNodes, 20 | $isAtNodeEnd, 21 | } from '@lexical/selection' 22 | import { $getNearestNodeOfType, mergeRegister } from '@lexical/utils' 23 | import { 24 | INSERT_ORDERED_LIST_COMMAND, 25 | INSERT_UNORDERED_LIST_COMMAND, 26 | REMOVE_LIST_COMMAND, 27 | $isListNode, 28 | ListNode, 29 | } from '@lexical/list' 30 | import { createPortal } from 'react-dom' 31 | import { 32 | $createHeadingNode, 33 | $createQuoteNode, 34 | $isHeadingNode, 35 | } from '@lexical/rich-text' 36 | import { 37 | $createCodeNode, 38 | $isCodeNode, 39 | getDefaultCodeLanguage, 40 | getCodeLanguages, 41 | } from '@lexical/code' 42 | 43 | import { 44 | ParagraphIcon, 45 | HeadingOneIcon, 46 | HeadingTwoIcon, 47 | HeadingThreeIcon, 48 | BulletListIcon, 49 | NumberedListIcon, 50 | QuoteIcon, 51 | CodeIcon, 52 | BoldIcon, 53 | UnderlineIcon, 54 | StrikeThroughIcon, 55 | ItalicIcon, 56 | TextLeftIcon, 57 | TextCenterIcon, 58 | TextRightIcon, 59 | JustifyIcon, 60 | ChevronDownIcon, 61 | getBlockTypeIcon, 62 | } from '../Icons' 63 | 64 | import { SLITE_DROPDOWN_CLASS } from '../themes/DefaultTheme' 65 | 66 | const LowPriority = 1 67 | 68 | const supportedBlockTypes = new Set([ 69 | 'paragraph', 70 | 'quote', 71 | 'code', 72 | 'h1', 73 | 'h2', 74 | 'h3', 75 | 'ul', 76 | 'ol', 77 | ]) 78 | 79 | const blockTypeToBlockName = { 80 | code: 'Code Block', 81 | h1: 'Large Heading', 82 | h2: 'Medium Heading', 83 | h3: 'Small Heading', 84 | h4: 'Heading', 85 | h5: 'Heading', 86 | ol: 'Numbered List', 87 | paragraph: 'Normal', 88 | quote: 'Quote', 89 | ul: 'Bulleted List', 90 | } 91 | 92 | function Divider() { 93 | return
94 | } 95 | 96 | function positionEditorElement(editor, rect) { 97 | if (rect === null) { 98 | editor.style.opacity = '0' 99 | editor.style.top = '-1000px' 100 | editor.style.left = '-1000px' 101 | } else { 102 | editor.style.opacity = '1' 103 | editor.style.top = `${rect.top + rect.height + window.pageYOffset + 10}px` 104 | editor.style.left = `${ 105 | rect.left + window.pageXOffset - editor.offsetWidth / 2 + rect.width / 2 106 | }px` 107 | } 108 | } 109 | 110 | // function FloatingLinkEditor({ editor }) { 111 | // const editorRef = useRef(null) 112 | // const inputRef = useRef(null) 113 | // const mouseDownRef = useRef(false) 114 | // const [linkUrl, setLinkUrl] = useState('') 115 | // const [isEditMode, setEditMode] = useState(false) 116 | // const [lastSelection, setLastSelection] = useState(null) 117 | 118 | // const updateLinkEditor = useCallback(() => { 119 | // const selection = $getSelection() 120 | // if ($isRangeSelection(selection)) { 121 | // const node = getSelectedNode(selection) 122 | // const parent = node.getParent() 123 | // if ($isLinkNode(parent)) { 124 | // setLinkUrl(parent.getURL()) 125 | // } else if ($isLinkNode(node)) { 126 | // setLinkUrl(node.getURL()) 127 | // } else { 128 | // setLinkUrl('') 129 | // } 130 | // } 131 | // const editorElem = editorRef.current 132 | // const nativeSelection = window.getSelection() 133 | // const activeElement = document.activeElement 134 | 135 | // if (editorElem === null) { 136 | // return 137 | // } 138 | 139 | // const rootElement = editor.getRootElement() 140 | // if ( 141 | // selection !== null && 142 | // !nativeSelection.isCollapsed && 143 | // rootElement !== null && 144 | // rootElement.contains(nativeSelection.anchorNode) 145 | // ) { 146 | // const domRange = nativeSelection.getRangeAt(0) 147 | // let rect 148 | // if (nativeSelection.anchorNode === rootElement) { 149 | // let inner = rootElement 150 | // while (inner.firstElementChild != null) { 151 | // inner = inner.firstElementChild 152 | // } 153 | // rect = inner.getBoundingClientRect() 154 | // } else { 155 | // rect = domRange.getBoundingClientRect() 156 | // } 157 | 158 | // if (!mouseDownRef.current) { 159 | // positionEditorElement(editorElem, rect) 160 | // } 161 | // setLastSelection(selection) 162 | // } else if (!activeElement || activeElement.className !== 'link-input') { 163 | // positionEditorElement(editorElem, null) 164 | // setLastSelection(null) 165 | // setEditMode(false) 166 | // setLinkUrl('') 167 | // } 168 | 169 | // return true 170 | // }, [editor]) 171 | 172 | // useEffect(() => { 173 | // return mergeRegister( 174 | // editor.registerUpdateListener(({ editorState }) => { 175 | // editorState.read(() => { 176 | // updateLinkEditor() 177 | // }) 178 | // }), 179 | 180 | // editor.registerCommand( 181 | // SELECTION_CHANGE_COMMAND, 182 | // () => { 183 | // updateLinkEditor() 184 | // return true 185 | // }, 186 | // LowPriority 187 | // ) 188 | // ) 189 | // }, [editor, updateLinkEditor]) 190 | 191 | // useEffect(() => { 192 | // editor.getEditorState().read(() => { 193 | // updateLinkEditor() 194 | // }) 195 | // }, [editor, updateLinkEditor]) 196 | 197 | // useEffect(() => { 198 | // if (isEditMode && inputRef.current) { 199 | // inputRef.current.focus() 200 | // } 201 | // }, [isEditMode]) 202 | 203 | // return ( 204 | //
205 | // {isEditMode ? ( 206 | // { 211 | // setLinkUrl(event.target.value) 212 | // }} 213 | // onKeyDown={event => { 214 | // if (event.key === 'Enter') { 215 | // event.preventDefault() 216 | // if (lastSelection !== null) { 217 | // if (linkUrl !== '') { 218 | // editor.dispatchCommand(TOGGLE_LINK_COMMAND, linkUrl) 219 | // } 220 | // setEditMode(false) 221 | // } 222 | // } else if (event.key === 'Escape') { 223 | // event.preventDefault() 224 | // setEditMode(false) 225 | // } 226 | // }} 227 | // /> 228 | // ) : ( 229 | // <> 230 | //
231 | // 232 | // {linkUrl} 233 | // 234 | //
event.preventDefault()} 239 | // onClick={() => { 240 | // setEditMode(true) 241 | // }} 242 | // /> 243 | //
244 | // 245 | // )} 246 | //
247 | // ) 248 | // } 249 | 250 | function Select({ onChange, className, options, value }) { 251 | return ( 252 | 260 | ) 261 | } 262 | 263 | function getSelectedNode(selection) { 264 | const anchor = selection.anchor 265 | const focus = selection.focus 266 | const anchorNode = selection.anchor.getNode() 267 | const focusNode = selection.focus.getNode() 268 | if (anchorNode === focusNode) { 269 | return anchorNode 270 | } 271 | const isBackward = selection.isBackward() 272 | if (isBackward) { 273 | return $isAtNodeEnd(focus) ? anchorNode : focusNode 274 | } 275 | // else { 276 | // return $isAtNodeEnd(anchor) ? focusNode : anchorNode 277 | // } 278 | } 279 | 280 | function BlockOptionsDropdownList({ 281 | editor, 282 | blockType, 283 | toolbarRef, 284 | setShowBlockOptionsDropDown, 285 | }) { 286 | const dropDownRef = useRef(null) 287 | 288 | useEffect(() => { 289 | const toolbar = toolbarRef.current 290 | const dropDown = dropDownRef.current 291 | 292 | if (toolbar !== null && dropDown !== null) { 293 | const { top, left } = toolbar.getBoundingClientRect() 294 | dropDown.style.top = `${top + 40}px` 295 | dropDown.style.left = `${left}px` 296 | } 297 | }, [toolbarRef]) 298 | 299 | useEffect(() => { 300 | const dropDown = dropDownRef.current 301 | const toolbar = toolbarRef.current 302 | 303 | if (dropDown !== null && toolbar !== null) { 304 | const handle = event => { 305 | const target = event.target 306 | 307 | if (!dropDown.contains(target) && !toolbar.contains(target)) { 308 | setShowBlockOptionsDropDown(false) 309 | } 310 | } 311 | document.addEventListener('click', handle) 312 | 313 | return () => { 314 | document.removeEventListener('click', handle) 315 | } 316 | } 317 | }, [setShowBlockOptionsDropDown, toolbarRef]) 318 | 319 | const formatParagraph = () => { 320 | if (blockType !== 'paragraph') { 321 | editor.update(() => { 322 | const selection = $getSelection() 323 | 324 | if ($isRangeSelection(selection)) { 325 | $wrapNodes(selection, () => $createParagraphNode()) 326 | } 327 | }) 328 | } 329 | setShowBlockOptionsDropDown(false) 330 | } 331 | 332 | const formatLargeHeading = () => { 333 | if (blockType !== 'h1') { 334 | editor.update(() => { 335 | const selection = $getSelection() 336 | 337 | if ($isRangeSelection(selection)) { 338 | $wrapNodes(selection, () => $createHeadingNode('h1')) 339 | } 340 | }) 341 | } 342 | setShowBlockOptionsDropDown(false) 343 | } 344 | 345 | const formatMediumHeading = () => { 346 | if (blockType !== 'h2') { 347 | editor.update(() => { 348 | const selection = $getSelection() 349 | 350 | if ($isRangeSelection(selection)) { 351 | $wrapNodes(selection, () => $createHeadingNode('h2')) 352 | } 353 | }) 354 | } 355 | setShowBlockOptionsDropDown(false) 356 | } 357 | 358 | const formatSmallHeading = () => { 359 | if (blockType !== 'h3') { 360 | editor.update(() => { 361 | const selection = $getSelection() 362 | 363 | if ($isRangeSelection(selection)) { 364 | $wrapNodes(selection, () => $createHeadingNode('h3')) 365 | } 366 | }) 367 | } 368 | setShowBlockOptionsDropDown(false) 369 | } 370 | 371 | const formatBulletList = () => { 372 | if (blockType !== 'ul') { 373 | editor.dispatchCommand(INSERT_UNORDERED_LIST_COMMAND) 374 | } else { 375 | editor.dispatchCommand(REMOVE_LIST_COMMAND) 376 | } 377 | setShowBlockOptionsDropDown(false) 378 | } 379 | 380 | const formatNumberedList = () => { 381 | if (blockType !== 'ol') { 382 | editor.dispatchCommand(INSERT_ORDERED_LIST_COMMAND) 383 | } else { 384 | editor.dispatchCommand(REMOVE_LIST_COMMAND) 385 | } 386 | setShowBlockOptionsDropDown(false) 387 | } 388 | 389 | const formatQuote = () => { 390 | if (blockType !== 'quote') { 391 | editor.update(() => { 392 | const selection = $getSelection() 393 | 394 | if ($isRangeSelection(selection)) { 395 | $wrapNodes(selection, () => $createQuoteNode()) 396 | } 397 | }) 398 | } 399 | setShowBlockOptionsDropDown(false) 400 | } 401 | 402 | const formatCode = () => { 403 | if (blockType !== 'code') { 404 | editor.update(() => { 405 | const selection = $getSelection() 406 | 407 | if ($isRangeSelection(selection)) { 408 | $wrapNodes(selection, () => $createCodeNode()) 409 | } 410 | }) 411 | } 412 | setShowBlockOptionsDropDown(false) 413 | } 414 | 415 | return ( 416 |
417 | {/* Paragraph */} 418 | 425 | {/* H1: Large Heading */} 426 | 433 | {/* H2: Medium Heading */} 434 | 441 | {/* H3: Small Heading */} 442 | 449 | 456 | 463 | 470 | 477 |
478 | ) 479 | } 480 | 481 | export default function ToolbarPlugin() { 482 | const [editor] = useLexicalComposerContext() 483 | const toolbarRef = useRef(null) 484 | // const [canUndo, setCanUndo] = useState(false) 485 | // const [canRedo, setCanRedo] = useState(false) 486 | const [blockType, setBlockType] = useState('paragraph') 487 | const [selectedElementKey, setSelectedElementKey] = useState(null) 488 | const [showBlockOptionsDropDown, setShowBlockOptionsDropDown] = 489 | useState(false) 490 | const [codeLanguage, setCodeLanguage] = useState('') 491 | const [isRTL, setIsRTL] = useState(false) 492 | // const [isLink, setIsLink] = useState(false) 493 | const [isBold, setIsBold] = useState(false) 494 | const [isItalic, setIsItalic] = useState(false) 495 | const [isUnderline, setIsUnderline] = useState(false) 496 | const [isStrikethrough, setIsStrikethrough] = useState(false) 497 | const [isCode, setIsCode] = useState(false) 498 | 499 | const updateToolbar = useCallback(() => { 500 | const selection = $getSelection() 501 | if ($isRangeSelection(selection)) { 502 | const anchorNode = selection.anchor.getNode() 503 | const element = 504 | anchorNode.getKey() === 'root' 505 | ? anchorNode 506 | : anchorNode.getTopLevelElementOrThrow() 507 | const elementKey = element.getKey() 508 | const elementDOM = editor.getElementByKey(elementKey) 509 | if (elementDOM !== null) { 510 | setSelectedElementKey(elementKey) 511 | if ($isListNode(element)) { 512 | const parentList = $getNearestNodeOfType(anchorNode, ListNode) 513 | const type = parentList ? parentList.getTag() : element.getTag() 514 | setBlockType(type) 515 | } else { 516 | const type = $isHeadingNode(element) 517 | ? element.getTag() 518 | : element.getType() 519 | setBlockType(type) 520 | if ($isCodeNode(element)) { 521 | setCodeLanguage(element.getLanguage() || getDefaultCodeLanguage()) 522 | } 523 | } 524 | } 525 | // Update text format 526 | setIsBold(selection.hasFormat('bold')) 527 | setIsItalic(selection.hasFormat('italic')) 528 | setIsUnderline(selection.hasFormat('underline')) 529 | setIsStrikethrough(selection.hasFormat('strikethrough')) 530 | setIsCode(selection.hasFormat('code')) 531 | setIsRTL($isParentElementRTL(selection)) 532 | 533 | // Update links 534 | // const node = getSelectedNode(selection) 535 | // const parent = node.getParent() 536 | // if ($isLinkNode(parent) || $isLinkNode(node)) { 537 | // setIsLink(true) 538 | // } else { 539 | // setIsLink(false) 540 | // } 541 | } 542 | }, [editor]) 543 | 544 | useEffect(() => { 545 | return mergeRegister( 546 | editor.registerUpdateListener(({ editorState }) => { 547 | editorState.read(() => { 548 | updateToolbar() 549 | }) 550 | }), 551 | editor.registerCommand( 552 | SELECTION_CHANGE_COMMAND, 553 | (_payload, newEditor) => { 554 | updateToolbar() 555 | return false 556 | }, 557 | LowPriority 558 | ) 559 | // editor.registerCommand( 560 | // CAN_UNDO_COMMAND, 561 | // payload => { 562 | // setCanUndo(payload) 563 | // return false 564 | // }, 565 | // LowPriority 566 | // ), 567 | // editor.registerCommand( 568 | // CAN_REDO_COMMAND, 569 | // payload => { 570 | // setCanRedo(payload) 571 | // return false 572 | // }, 573 | // LowPriority 574 | // ) 575 | ) 576 | }, [editor, updateToolbar]) 577 | 578 | const codeLanguges = useMemo(() => getCodeLanguages(), []) 579 | const onCodeLanguageSelect = useCallback( 580 | e => { 581 | editor.update(() => { 582 | if (selectedElementKey !== null) { 583 | const node = $getNodeByKey(selectedElementKey) 584 | if ($isCodeNode(node)) { 585 | node.setLanguage(e.target.value) 586 | } 587 | } 588 | }) 589 | }, 590 | [editor, selectedElementKey] 591 | ) 592 | 593 | // const insertLink = useCallback(() => { 594 | // if (!isLink) { 595 | // editor.dispatchCommand(TOGGLE_LINK_COMMAND, 'https://') 596 | // } else { 597 | // editor.dispatchCommand(TOGGLE_LINK_COMMAND, null) 598 | // } 599 | // }, [editor, isLink]) 600 | 601 | return ( 602 |
603 | {/* 613 | 623 | */} 624 | {supportedBlockTypes.has(blockType) && ( 625 | <> 626 | 642 | {showBlockOptionsDropDown && 643 | createPortal( 644 | , 650 | document.body 651 | )} 652 | 653 | 654 | )} 655 | {blockType === 'code' ? ( 656 | <> 657 | { 38 | setLive(event.target.checked) 39 | }} 40 | /> 41 | 42 |
43 |
44 | {isLive ? ( 45 | setContent(c)} 48 | readOnly={false} 49 | > 50 | {null} 51 | 52 | ) : ( 53 |