├── .gitignore ├── LICENSE ├── LICENSE-UPSTREAM ├── README.md ├── example ├── .gitignore ├── README.md ├── app.config.mts ├── package.json ├── public │ └── favicon.ico ├── src │ ├── app.tsx │ ├── components │ │ ├── PlainTextEditor.tsx │ │ ├── RichTextEditor.css │ │ ├── RichTextEditor.tsx │ │ └── RichTextTheme.ts │ ├── entry-client.tsx │ ├── entry-server.tsx │ ├── global.d.ts │ ├── index.css │ ├── plugins │ │ ├── CodeHighlightPlugin.ts │ │ ├── ToolbarPlugin.tsx │ │ └── TreeViewPlugin.tsx │ ├── routes │ │ ├── PlainTextEditor.css │ │ ├── PlainTextEditor.tsx │ │ ├── RichTextEditor.tsx │ │ ├── [...404].tsx │ │ └── index.tsx │ └── themes │ │ └── PlainTextTheme.ts └── tsconfig.json ├── lexical-solid ├── .gitignore ├── LICENSE ├── LICENSE-UPSTREAM ├── build.js ├── package.json ├── src │ ├── LexicalAutoEmbedPlugin.tsx │ ├── LexicalAutoFocusPlugin.tsx │ ├── LexicalAutoLinkPlugin.tsx │ ├── LexicalBlockWithAlignableContents.tsx │ ├── LexicalCharacterLimitPlugin.tsx │ ├── LexicalCheckListPlugin.tsx │ ├── LexicalClearEditorPlugin.tsx │ ├── LexicalClickableLinkPlugin.tsx │ ├── LexicalCollaborationContext.tsx │ ├── LexicalCollaborationPlugin.tsx │ ├── LexicalComposer.tsx │ ├── LexicalComposerContext.tsx │ ├── LexicalContentEditable.tsx │ ├── LexicalContextMenuPlugin.tsx │ ├── LexicalDecoratorBlockNode.tsx │ ├── LexicalDraggableBlockPlugin.tsx │ ├── LexicalEditorRefPlugin.tsx │ ├── LexicalErrorBoundary.tsx │ ├── LexicalHashTagPlugin.tsx │ ├── LexicalHistoryPlugin.tsx │ ├── LexicalHorizontalRuleNode.tsx │ ├── LexicalHorizontalRulePlugin.tsx │ ├── LexicalLinkPlugin.tsx │ ├── LexicalListPlugin.tsx │ ├── LexicalMarkdownShortcutPlugin.tsx │ ├── LexicalNestedComposer.tsx │ ├── LexicalNodeEventPlugin.tsx │ ├── LexicalNodeMenuPlugin.tsx │ ├── LexicalOnChangePlugin.tsx │ ├── LexicalPlainTextPlugin.tsx │ ├── LexicalRichTextPlugin.tsx │ ├── LexicalSelectionAlwaysOnDisplay.tsx │ ├── LexicalTabIndentationPlugin.tsx │ ├── LexicalTableOfContentsPlugin.tsx │ ├── LexicalTablePlugin.tsx │ ├── LexicalTreeView.tsx │ ├── LexicalTypeaheadMenuPlugin.tsx │ ├── devtools-core │ │ ├── TreeView.tsx │ │ ├── generateContent.ts │ │ ├── index.ts │ │ └── useLexicalCommandsLog.ts │ ├── index.tsx │ ├── shared │ │ ├── LexicalContentEditableElement.tsx │ │ ├── LexicalMenu.tsx │ │ ├── mergeRefs.ts │ │ ├── point.ts │ │ ├── rect.ts │ │ ├── useCanShowPlaceholder.tsx │ │ ├── useCharacterLimit.tsx │ │ ├── useDecorators.tsx │ │ ├── useHistory.tsx │ │ ├── useList.tsx │ │ ├── usePlainTextSetup.tsx │ │ ├── useRichTextSetup.tsx │ │ ├── useYjsCollaboration.tsx │ │ └── warnOnlyOnce.ts │ ├── useLexicalEditable.tsx │ ├── useLexicalIsContentEmpty.tsx │ ├── useLexicalNodeSelection.tsx │ ├── useLexicalSubscription.tsx │ ├── useLexicalTextEntity.tsx │ └── utils.tsx └── tsconfig.json ├── package.json ├── pnpm-lock.yaml └── pnpm-workspace.yaml /.gitignore: -------------------------------------------------------------------------------- 1 | node_modules 2 | -------------------------------------------------------------------------------- /LICENSE: -------------------------------------------------------------------------------- 1 | MIT License 2 | 3 | Copyright (c) 2022 Moshe David Uminer 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. 22 | -------------------------------------------------------------------------------- /LICENSE-UPSTREAM: -------------------------------------------------------------------------------- 1 | MIT License 2 | 3 | Copyright (c) Meta Platforms, Inc. and affiliates. 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. 22 | -------------------------------------------------------------------------------- /README.md: -------------------------------------------------------------------------------- 1 | # Lexical-Solid 2 | 3 | SolidJS port of `@lexical/react` 4 | 5 | This is a SolidJS port of [@lexical/react](https://www.npmjs.com/package/@lexical/react) (always based on the same `@lexical/react` version as the pinned [lexical-dependencies](#lexical-dependencies), see that section of the README for the current version). 6 | 7 | If you're using this library, I'd appreciate it if you let me know (in github discussions or in the lexical discord)! 8 | 9 | # Installing 10 | 11 | `npm install lexical-solid` 12 | 13 | or using the package manager of your choice. 14 | 15 | This repository uses `pnpm`, but if you are only a consumer of this library, you do not need it. 16 | 17 | # Lexical Dependencies 18 | 19 | Currently using lexical packages version `0.30.0`, and ported from [@lexical/react](https://www.npmjs.com/package/@lexical/react) of the same version tag. 20 | 21 | This package pins `lexical` and the `@lexical/*` packages to specific minor versions. This means that you can only upgrade your lexical version to the latest _patch_ version compatible with the current version. Attempting to upgrade the minor version will result in a broken state due to mismatched packages. See [#5](https://github.com/mosheduminer/lexical-solid/issues/5) for some discussion of the pinning strategy. 22 | 23 | # License 24 | 25 | This code in this repository is very similar to the source react code, with modifications for use with SolidJS. The license distributed with `@lexical/react` can be found in [LICENSE-UPSTREAM](./LICENSE-UPSTREAM). 26 | -------------------------------------------------------------------------------- /example/.gitignore: -------------------------------------------------------------------------------- 1 | 2 | dist 3 | .solid 4 | .vinxi 5 | .output 6 | .vercel 7 | .netlify 8 | 9 | # dependencies 10 | /node_modules 11 | 12 | # IDEs and editors 13 | /.idea 14 | .project 15 | .classpath 16 | *.launch 17 | .settings/ 18 | 19 | # Temp 20 | gitignore 21 | 22 | # System Files 23 | .DS_Store 24 | Thumbs.db 25 | -------------------------------------------------------------------------------- /example/README.md: -------------------------------------------------------------------------------- 1 | # SolidStart 2 | 3 | Everything you need to build a Solid project, powered by [`solid-start`](https://github.com/ryansolid/solid-start/tree/master/packages/solid-start); 4 | 5 | ## Creating a project 6 | 7 | ```bash 8 | # create a new project in the current directory 9 | npm init solid@next 10 | 11 | # create a new project in my-app 12 | npm init solid@next my-app 13 | ``` 14 | 15 | > Note: the `@next` is temporary 16 | 17 | ## Developing 18 | 19 | Once you've created a project and installed dependencies with `npm install` (or `pnpm install` or `yarn`), start a development server: 20 | 21 | ```bash 22 | npm run dev 23 | 24 | # or start the server and open the app in a new browser tab 25 | npm run dev -- --open 26 | ``` 27 | 28 | ## Building 29 | 30 | Solid apps are built with _adapters_, which optimise your project for deployment to different environments. 31 | 32 | By default, `npm run build` will generate a Node app that you can run with `node build`. To use a different adapter, add it to the `devDependencies` in `package.json` and specify in your `vite.config.js`. -------------------------------------------------------------------------------- /example/app.config.mts: -------------------------------------------------------------------------------- 1 | import { defineConfig } from "@solidjs/start/config"; 2 | 3 | export default defineConfig(); 4 | -------------------------------------------------------------------------------- /example/package.json: -------------------------------------------------------------------------------- 1 | { 2 | "name": "docs", 3 | "scripts": { 4 | "dev": "vinxi dev", 5 | "build": "vinxi build", 6 | "start": "vinxi start" 7 | }, 8 | "type": "module", 9 | "dependencies": { 10 | "@lexical/clipboard": "0.30.0", 11 | "@lexical/code": "0.30.0", 12 | "@lexical/dragon": "0.30.0", 13 | "@lexical/hashtag": "0.30.0", 14 | "@lexical/history": "0.30.0", 15 | "@lexical/link": "0.30.0", 16 | "@lexical/list": "0.30.0", 17 | "@lexical/mark": "0.30.0", 18 | "@lexical/markdown": "0.30.0", 19 | "@lexical/overflow": "0.30.0", 20 | "@lexical/plain-text": "0.30.0", 21 | "@lexical/rich-text": "0.30.0", 22 | "@lexical/selection": "0.30.0", 23 | "@lexical/table": "0.30.0", 24 | "@lexical/text": "0.30.0", 25 | "@lexical/utils": "0.30.0", 26 | "@lexical/yjs": "0.30.0", 27 | "lexical": "0.30.0", 28 | "lexical-solid": "workspace:*" 29 | }, 30 | "devDependencies": { 31 | "@solidjs/meta": "^0.29.4", 32 | "@solidjs/router": "^0.15.3", 33 | "@solidjs/start": "^1.1.3", 34 | "solid-js": "^1.9.5", 35 | "typescript": "^5.8.2", 36 | "vinxi": "^0.5.3", 37 | "vite": "^6.2.4" 38 | }, 39 | "engines": { 40 | "node": ">=20" 41 | } 42 | } -------------------------------------------------------------------------------- /example/public/favicon.ico: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/mosheduminer/lexical-solid/a72995866b64297b00c2685b342360f1d7db019d/example/public/favicon.ico -------------------------------------------------------------------------------- /example/src/app.tsx: -------------------------------------------------------------------------------- 1 | import { MetaProvider, Title } from "@solidjs/meta"; 2 | import { Router } from "@solidjs/router"; 3 | import { FileRoutes } from "@solidjs/start/router"; 4 | import { Suspense } from "solid-js"; 5 | 6 | export default function App() { 7 | return ( 8 | ( 10 | 11 | SolidStart - Basic 12 | Index 13 | {props.children} 14 | 15 | )} 16 | > 17 | 18 | 19 | ); 20 | } 21 | -------------------------------------------------------------------------------- /example/src/components/PlainTextEditor.tsx: -------------------------------------------------------------------------------- 1 | import { $getRoot, $getSelection, EditorState, LexicalEditor } from "lexical"; 2 | 3 | import ExampleTheme from "../themes/PlainTextTheme"; 4 | import { OnChangePlugin } from "lexical-solid/LexicalOnChangePlugin"; 5 | import { AutoFocusPlugin } from "lexical-solid/LexicalAutoFocusPlugin"; 6 | import { LexicalComposer } from "lexical-solid/LexicalComposer"; 7 | import { PlainTextPlugin } from "lexical-solid/LexicalPlainTextPlugin"; 8 | import { ContentEditable } from "lexical-solid/LexicalContentEditable"; 9 | import { HistoryPlugin } from "lexical-solid/LexicalHistoryPlugin"; 10 | import { LexicalErrorBoundary } from "lexical-solid/LexicalErrorBoundary"; 11 | import TreeViewPlugin from "~/plugins/TreeViewPlugin"; 12 | 13 | //import { EmojiNode } from "./nodes/EmojiNode"; 14 | //import EmoticonPlugin from "./plugins/EmoticonPlugin"; 15 | 16 | function Placeholder() { 17 | return
Enter some plain text...
; 18 | } 19 | 20 | // When the editor changes, you can get notified via the 21 | // LexicalOnChangePlugin! 22 | function onChange( 23 | editorState: EditorState, 24 | _tags: Set, 25 | _editor: LexicalEditor 26 | ) { 27 | editorState.read(() => { 28 | // Read the contents of the EditorState here. 29 | const root = $getRoot(); 30 | const selection = $getSelection(); 31 | 32 | console.log(root, selection); 33 | }); 34 | } 35 | 36 | const editorConfig = { 37 | // The editor theme 38 | theme: ExampleTheme, 39 | namespace: "", 40 | // Handling of errors during update 41 | onError(error: any) { 42 | throw error; 43 | }, 44 | // Any custom nodes go here 45 | //nodes: [EmojiNode] 46 | }; 47 | 48 | export default function Editor() { 49 | return ( 50 | 51 |
52 | } 54 | placeholder={} 55 | errorBoundary={LexicalErrorBoundary} 56 | /> 57 | 58 | 59 | 60 | {/**/} 61 | 62 |
63 |
64 | ); 65 | } 66 | -------------------------------------------------------------------------------- /example/src/components/RichTextEditor.tsx: -------------------------------------------------------------------------------- 1 | import { $getRoot, $getSelection, EditorState, LexicalEditor } from "lexical"; 2 | import { LinkNode } from "@lexical/link"; 3 | import { AutoLinkNode } from "@lexical/link"; 4 | import "./RichTextEditor.css"; 5 | import { LinkPlugin } from "lexical-solid/LexicalLinkPlugin"; 6 | import { HeadingNode, QuoteNode } from "@lexical/rich-text"; 7 | import { ListItemNode, ListNode } from "@lexical/list"; 8 | import { CodeHighlightNode, CodeNode } from "@lexical/code"; 9 | import { OnChangePlugin } from "lexical-solid/LexicalOnChangePlugin"; 10 | import { AutoFocusPlugin } from "lexical-solid/LexicalAutoFocusPlugin"; 11 | import { LexicalComposer } from "lexical-solid/LexicalComposer"; 12 | import { RichTextPlugin } from "lexical-solid/LexicalRichTextPlugin"; 13 | import { ContentEditable } from "lexical-solid/LexicalContentEditable"; 14 | import { HistoryPlugin } from "lexical-solid/LexicalHistoryPlugin"; 15 | import TreeViewPlugin from "../plugins/TreeViewPlugin"; 16 | import CodeHighlightPlugin from "~/plugins/CodeHighlightPlugin"; 17 | // import ToolbarPlugin from "~/plugins/ToolbarPlugin"; 18 | import RichTextTheme from "./RichTextTheme"; 19 | import { TableCellNode, TableNode, TableRowNode } from "@lexical/table"; 20 | import { LexicalErrorBoundary } from "lexical-solid/LexicalErrorBoundary"; 21 | //import { EmojiNode } from "./nodes/EmojiNode"; 22 | //import EmoticonPlugin from "./plugins/EmoticonPlugin"; 23 | 24 | function Placeholder() { 25 | return
Enter some plain text...
; 26 | } 27 | 28 | // When the editor changes, you can get notified via the 29 | // LexicalOnChangePlugin! 30 | function onChange( 31 | editorState: EditorState, 32 | tags: Set, 33 | editor: LexicalEditor 34 | ) { 35 | editorState.read(() => { 36 | // Read the contents of the EditorState here. 37 | const root = $getRoot(); 38 | const selection = $getSelection(); 39 | 40 | console.log(root, selection); 41 | }); 42 | } 43 | 44 | const editorConfig = { 45 | // The editor theme 46 | theme: RichTextTheme, 47 | namespace: "", 48 | // Handling of errors during update 49 | onError(error: any) { 50 | throw error; 51 | }, 52 | // Any custom nodes go here 53 | nodes: [ 54 | HeadingNode, 55 | ListNode, 56 | ListItemNode, 57 | QuoteNode, 58 | CodeNode, 59 | CodeHighlightNode, 60 | TableNode, 61 | TableCellNode, 62 | TableRowNode, 63 | AutoLinkNode, 64 | LinkNode, 65 | ] as any, 66 | }; 67 | 68 | export default function Editor() { 69 | return ( 70 | 71 |
72 | {/* */} 73 |
74 | } 76 | placeholder={} 77 | errorBoundary={LexicalErrorBoundary} 78 | /> 79 | 80 | 81 | 82 | 83 | 84 | 85 | 86 |
87 |
88 |
89 | ); 90 | } 91 | -------------------------------------------------------------------------------- /example/src/components/RichTextTheme.ts: -------------------------------------------------------------------------------- 1 | const RichTextTheme = { 2 | ltr: "ltr", 3 | rtl: "rtl", 4 | placeholder: "editor-placeholder", 5 | paragraph: "editor-paragraph", 6 | quote: "editor-quote", 7 | heading: { 8 | h1: "editor-heading-h1", 9 | h2: "editor-heading-h2", 10 | h3: "editor-heading-h3", 11 | h4: "editor-heading-h4", 12 | h5: "editor-heading-h5" 13 | }, 14 | list: { 15 | nested: { 16 | listitem: "editor-nested-listitem" 17 | }, 18 | ol: "editor-list-ol", 19 | ul: "editor-list-ul", 20 | listitem: "editor-listitem" 21 | }, 22 | image: "editor-image", 23 | link: "editor-link", 24 | text: { 25 | bold: "editor-text-bold", 26 | italic: "editor-text-italic", 27 | overflowed: "editor-text-overflowed", 28 | hashtag: "editor-text-hashtag", 29 | underline: "editor-text-underline", 30 | strikethrough: "editor-text-strikethrough", 31 | underlineStrikethrough: "editor-text-underlineStrikethrough", 32 | code: "editor-text-code" 33 | }, 34 | code: "editor-code", 35 | codeHighlight: { 36 | atrule: "editor-tokenAttr", 37 | attr: "editor-tokenAttr", 38 | boolean: "editor-tokenProperty", 39 | builtin: "editor-tokenSelector", 40 | cdata: "editor-tokenComment", 41 | char: "editor-tokenSelector", 42 | class: "editor-tokenFunction", 43 | "class-name": "editor-tokenFunction", 44 | comment: "editor-tokenComment", 45 | constant: "editor-tokenProperty", 46 | deleted: "editor-tokenProperty", 47 | doctype: "editor-tokenComment", 48 | entity: "editor-tokenOperator", 49 | function: "editor-tokenFunction", 50 | important: "editor-tokenVariable", 51 | inserted: "editor-tokenSelector", 52 | keyword: "editor-tokenAttr", 53 | namespace: "editor-tokenVariable", 54 | number: "editor-tokenProperty", 55 | operator: "editor-tokenOperator", 56 | prolog: "editor-tokenComment", 57 | property: "editor-tokenProperty", 58 | punctuation: "editor-tokenPunctuation", 59 | regex: "editor-tokenVariable", 60 | selector: "editor-tokenSelector", 61 | string: "editor-tokenSelector", 62 | symbol: "editor-tokenProperty", 63 | tag: "editor-tokenProperty", 64 | url: "editor-tokenOperator", 65 | variable: "editor-tokenVariable" 66 | } 67 | }; 68 | 69 | export default RichTextTheme; 70 | -------------------------------------------------------------------------------- /example/src/entry-client.tsx: -------------------------------------------------------------------------------- 1 | // @refresh reload 2 | import { mount, StartClient } from "@solidjs/start/client"; 3 | 4 | mount(() => , document.getElementById("app")!); 5 | -------------------------------------------------------------------------------- /example/src/entry-server.tsx: -------------------------------------------------------------------------------- 1 | // @refresh reload 2 | import { createHandler, StartServer } from "@solidjs/start/server"; 3 | 4 | export default createHandler(() => ( 5 | ( 7 | 8 | 9 | 10 | 11 | 12 | {assets} 13 | 14 | 15 |
{children}
16 | {scripts} 17 | 18 | 19 | )} 20 | /> 21 | )); 22 | -------------------------------------------------------------------------------- /example/src/global.d.ts: -------------------------------------------------------------------------------- 1 | /// -------------------------------------------------------------------------------- /example/src/index.css: -------------------------------------------------------------------------------- 1 | body { 2 | font-family: Gordita, Roboto, Oxygen, Ubuntu, Cantarell, 3 | 'Open Sans', 'Helvetica Neue', sans-serif; 4 | } 5 | 6 | main { 7 | text-align: center; 8 | padding: 1em; 9 | margin: 0 auto; 10 | } 11 | 12 | h1 { 13 | color: #335d92; 14 | text-transform: uppercase; 15 | font-size: 4rem; 16 | font-weight: 100; 17 | line-height: 1.1; 18 | margin: 4rem auto; 19 | max-width: 14rem; 20 | } 21 | 22 | p { 23 | max-width: 14rem; 24 | margin: 2rem auto; 25 | line-height: 1.35; 26 | } 27 | 28 | @media (min-width: 480px) { 29 | h1 { 30 | max-width: none; 31 | } 32 | 33 | p { 34 | max-width: none; 35 | } 36 | } -------------------------------------------------------------------------------- /example/src/plugins/CodeHighlightPlugin.ts: -------------------------------------------------------------------------------- 1 | import { useLexicalComposerContext } from "lexical-solid/LexicalComposerContext"; 2 | import { onCleanup, onMount } from "solid-js"; 3 | //@ts-ignore 4 | import { registerCodeHighlighting } from "@lexical/code"; 5 | 6 | export default function CodeHighlightPlugin() { 7 | const [editor] = useLexicalComposerContext(); 8 | onMount(() => { 9 | onCleanup(registerCodeHighlighting(editor)); 10 | }); 11 | return null; 12 | } 13 | -------------------------------------------------------------------------------- /example/src/plugins/TreeViewPlugin.tsx: -------------------------------------------------------------------------------- 1 | import { useLexicalComposerContext } from "lexical-solid/LexicalComposerContext"; 2 | import { TreeView } from "lexical-solid/LexicalTreeView"; 3 | 4 | export default function TreeViewPlugin() { 5 | const [editor] = useLexicalComposerContext(); 6 | return ( 7 | 15 | ); 16 | } 17 | -------------------------------------------------------------------------------- /example/src/routes/PlainTextEditor.css: -------------------------------------------------------------------------------- 1 | body { 2 | margin: 0; 3 | background: #eee; 4 | font-family: system-ui, -apple-system, BlinkMacSystemFont, ".SFNSText-Regular", 5 | sans-serif; 6 | font-weight: 500; 7 | -webkit-font-smoothing: antialiased; 8 | -moz-osx-font-smoothing: grayscale; 9 | } 10 | 11 | .App { 12 | font-family: sans-serif; 13 | text-align: center; 14 | } 15 | 16 | h1 { 17 | font-size: 24px; 18 | color: #333; 19 | } 20 | 21 | .ltr { 22 | text-align: left; 23 | } 24 | 25 | .rtl { 26 | text-align: right; 27 | } 28 | 29 | .editor-container { 30 | background: #fff; 31 | margin: 20px auto 20px auto; 32 | border-radius: 2px; 33 | max-width: 600px; 34 | color: #000; 35 | position: relative; 36 | line-height: 20px; 37 | font-weight: 400; 38 | text-align: left; 39 | border-top-left-radius: 10px; 40 | border-top-right-radius: 10px; 41 | } 42 | 43 | .editor-input { 44 | min-height: 150px; 45 | resize: none; 46 | font-size: 15px; 47 | caret-color: rgb(5, 5, 5); 48 | position: relative; 49 | tab-size: 1; 50 | outline: 0; 51 | padding: 15px 10px; 52 | caret-color: #444; 53 | } 54 | 55 | .editor-placeholder { 56 | color: #999; 57 | overflow: hidden; 58 | position: absolute; 59 | text-overflow: ellipsis; 60 | top: 15px; 61 | left: 10px; 62 | font-size: 15px; 63 | user-select: none; 64 | display: inline-block; 65 | pointer-events: none; 66 | } 67 | 68 | .editor-paragraph { 69 | margin: 0 0 15px 0; 70 | position: relative; 71 | } 72 | 73 | .tree-view-output { 74 | display: block; 75 | background: #222; 76 | color: #fff; 77 | padding: 5px; 78 | font-size: 12px; 79 | white-space: pre-wrap; 80 | margin: 1px auto 10px auto; 81 | max-height: 250px; 82 | position: relative; 83 | border-bottom-left-radius: 10px; 84 | border-bottom-right-radius: 10px; 85 | overflow: hidden; 86 | line-height: 14px; 87 | } 88 | 89 | pre::-webkit-scrollbar { 90 | background: transparent; 91 | width: 10px; 92 | } 93 | 94 | pre::-webkit-scrollbar-thumb { 95 | background: #999; 96 | } 97 | 98 | .debug-timetravel-panel { 99 | overflow: hidden; 100 | padding: 0 0 10px 0; 101 | margin: auto; 102 | display: flex; 103 | } 104 | 105 | .debug-timetravel-panel-slider { 106 | padding: 0; 107 | flex: 8; 108 | } 109 | 110 | .debug-timetravel-panel-button { 111 | padding: 0; 112 | border: 0; 113 | background: none; 114 | flex: 1; 115 | color: #fff; 116 | font-size: 12px; 117 | } 118 | 119 | .debug-timetravel-panel-button:hover { 120 | text-decoration: underline; 121 | } 122 | 123 | .debug-timetravel-button { 124 | border: 0; 125 | padding: 0; 126 | font-size: 12px; 127 | top: 10px; 128 | right: 15px; 129 | position: absolute; 130 | background: none; 131 | color: #fff; 132 | } 133 | 134 | .debug-timetravel-button:hover { 135 | text-decoration: underline; 136 | } 137 | 138 | .emoji { 139 | color: transparent; 140 | background-size: 16px 16px; 141 | background-position: center; 142 | background-repeat: no-repeat; 143 | vertical-align: middle; 144 | margin: 0 -1px; 145 | } 146 | 147 | .emoji-inner { 148 | padding: 0 0.15em; 149 | } 150 | 151 | .emoji-inner::selection { 152 | color: transparent; 153 | background-color: rgba(150, 150, 150, 0.4); 154 | } 155 | 156 | .emoji-inner::moz-selection { 157 | color: transparent; 158 | background-color: rgba(150, 150, 150, 0.4); 159 | } 160 | 161 | .emoji.happysmile { 162 | background-image: url(./images/emoji/1F642.png); 163 | } 164 | -------------------------------------------------------------------------------- /example/src/routes/PlainTextEditor.tsx: -------------------------------------------------------------------------------- 1 | import "./PlainTextEditor.css"; 2 | import Editor from "../components/PlainTextEditor"; 3 | 4 | export default function () { 5 | return ( 6 | <> 7 |

Plain Text Example

8 |

Note: this is an experimental port of @lexical/react

9 | ; 10 | 11 | ); 12 | } 13 | -------------------------------------------------------------------------------- /example/src/routes/RichTextEditor.tsx: -------------------------------------------------------------------------------- 1 | import "./PlainTextEditor.css"; 2 | import Editor from "../components/RichTextEditor"; 3 | 4 | export default function () { 5 | return ( 6 | <> 7 |

Rich Text Example

8 |

Note: this is an experimental port of @lexical/react

9 | 10 | 11 | ); 12 | } 13 | -------------------------------------------------------------------------------- /example/src/routes/[...404].tsx: -------------------------------------------------------------------------------- 1 | import { Title } from "@solidjs/meta"; 2 | import { HttpStatusCode } from "@solidjs/start"; 3 | 4 | export default function NotFound() { 5 | return ( 6 |
7 | Not Found 8 | 9 |

Page Not Found

10 |
11 | ); 12 | } 13 | -------------------------------------------------------------------------------- /example/src/routes/index.tsx: -------------------------------------------------------------------------------- 1 | export default function () { 2 | return ( 3 |
4 |

5 | Plain Text Editor 6 |

7 |

8 | Rich Text Editor 9 |

10 |
11 | ); 12 | } 13 | -------------------------------------------------------------------------------- /example/src/themes/PlainTextTheme.ts: -------------------------------------------------------------------------------- 1 | import { EditorThemeClasses } from "lexical"; 2 | const exampleTheme: EditorThemeClasses = { 3 | ltr: "ltr", 4 | rtl: "rtl", 5 | placeholder: "editor-placeholder", 6 | paragraph: "editor-paragraph", 7 | quote: "editor-quote", 8 | heading: { 9 | h1: "editor-heading-h1", 10 | h2: "editor-heading-h2", 11 | h3: "editor-heading-h3", 12 | h4: "editor-heading-h4", 13 | h5: "editor-heading-h5" 14 | }, 15 | list: { 16 | ol: "editor-list-ol", 17 | ul: "editor-list-ul" 18 | }, 19 | listitem: "editor-listitem", 20 | image: "editor-image", 21 | text: { 22 | bold: "editor-text-bold", 23 | link: "editor-text-link", 24 | italic: "editor-text-italic", 25 | overflowed: "editor-text-overflowed", 26 | hashtag: "editor-text-hashtag", 27 | underline: "editor-text-underline", 28 | strikethrough: "editor-text-strikethrough", 29 | underlineStrikethrough: "editor-text-underlineStrikethrough", 30 | code: "editor-text-code" 31 | }, 32 | code: "editor-code" 33 | }; 34 | 35 | export default exampleTheme; 36 | -------------------------------------------------------------------------------- /example/tsconfig.json: -------------------------------------------------------------------------------- 1 | { 2 | "compilerOptions": { 3 | "allowSyntheticDefaultImports": true, 4 | "esModuleInterop": true, 5 | "target": "ESNext", 6 | "module": "ESNext", 7 | "moduleResolution": "node", 8 | "jsxImportSource": "solid-js", 9 | "jsx": "preserve", 10 | "strict": true, 11 | "noEmit": true, 12 | "types": ["vinxi/client"], 13 | "isolatedModules": true, 14 | "paths": { 15 | "~/*": ["./src/*"] 16 | } 17 | } 18 | } 19 | -------------------------------------------------------------------------------- /lexical-solid/.gitignore: -------------------------------------------------------------------------------- 1 | dist 2 | -------------------------------------------------------------------------------- /lexical-solid/LICENSE: -------------------------------------------------------------------------------- 1 | MIT License 2 | 3 | Copyright (c) 2022 Moshe David Uminer 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. 22 | -------------------------------------------------------------------------------- /lexical-solid/LICENSE-UPSTREAM: -------------------------------------------------------------------------------- 1 | MIT License 2 | 3 | Copyright (c) Meta Platforms, Inc. and affiliates. 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. 22 | -------------------------------------------------------------------------------- /lexical-solid/build.js: -------------------------------------------------------------------------------- 1 | import { rollup } from 'rollup'; 2 | import ts from 'typescript'; 3 | import { existsSync, mkdirSync, rmSync } from 'fs'; 4 | import { resolve, basename } from 'path'; 5 | import { babel } from '@rollup/plugin-babel'; 6 | import { nodeResolve } from '@rollup/plugin-node-resolve'; 7 | import commonjs from '@rollup/plugin-commonjs'; 8 | import fg from 'fast-glob'; 9 | 10 | const lexicalSolidModules = 11 | fg.sync('./src/**') 12 | .map((module) => { 13 | const fileName = 14 | (module.includes('shared') ? 'shared/' : '') + basename(basename(module, '.ts'), '.tsx'); 15 | return { 16 | sourceFileName: module, 17 | outputFileName: fileName 18 | }; 19 | }) 20 | 21 | const externals = [ 22 | 'lexical', 23 | '@lexical/clipboard', 24 | '@lexical/code', 25 | '@lexical/dragon', 26 | '@lexical/hashtag', 27 | '@lexical/history', 28 | '@lexical/html', 29 | '@lexical/link', 30 | '@lexical/list', 31 | '@lexical/mark', 32 | '@lexical/markdown', 33 | '@lexical/overflow', 34 | '@lexical/plain-text', 35 | '@lexical/rich-text', 36 | '@lexical/selection', 37 | '@lexical/table', 38 | '@lexical/text', 39 | '@lexical/utils', 40 | '@lexical/yjs', 41 | 'solid-js', 42 | 'solid-js/web', 43 | 'solid-js/store', 44 | 'yjs', 45 | 'y-websocket', 46 | ...(lexicalSolidModules.map(n => './' + n.outputFileName)), 47 | ...(lexicalSolidModules.map(n => '../' + n.outputFileName)), 48 | ] 49 | 50 | if (existsSync('./dist')) rmSync(resolve('./dist'), { recursive: true }); 51 | mkdirSync('./dist') 52 | 53 | for (const module of lexicalSolidModules) { 54 | let inputFile = resolve(module.sourceFileName); 55 | const inputOptions = (generate) => ( 56 | { 57 | external(modulePath, src) { 58 | return externals.includes(modulePath); 59 | }, 60 | input: inputFile, 61 | plugins: [ 62 | nodeResolve({ 63 | extensions: ['.js', '.jsx', '.ts', '.tsx'], 64 | }), 65 | babel({ 66 | babelHelpers: 'bundled', 67 | babelrc: false, 68 | configFile: false, 69 | exclude: '/**/node_modules/**', 70 | extensions: ['.js', '.jsx', '.ts', '.tsx'], 71 | presets: [ 72 | ["babel-preset-solid", { generate, hydratable: true }], 73 | '@babel/preset-typescript', 74 | ] 75 | }), 76 | commonjs() 77 | ], 78 | treeshake: true, 79 | }) 80 | const resultDom = await rollup(inputOptions("dom")); 81 | resultDom.write({ format: 'esm', file: resolve('dist/esm/' + module.outputFileName) + '.js' }).then(() => resultDom.close()) 82 | const resultSsr = await rollup({...inputOptions("ssr")}); 83 | resultSsr.write({ format: 'cjs', file: resolve('dist/cjs/' + module.outputFileName + '.cjs') }).then(() => resultSsr.close()) 84 | } 85 | 86 | const program = ts.createProgram(lexicalSolidModules.map(module => module.sourceFileName), { 87 | target: ts.ScriptTarget.ESNext, 88 | module: ts.ModuleKind.ESNext, 89 | moduleResolution: ts.ModuleResolutionKind.NodeJs, 90 | jsx: ts.JsxEmit.Preserve, 91 | jsxImportSource: 'solid-js', 92 | allowSyntheticDefaultImports: true, 93 | esModuleInterop: true, 94 | outDir: `dist/source`, 95 | declarationDir: `dist/types`, 96 | declaration: true, 97 | allowJs: true, 98 | paths: { 99 | 'lexical-solid/*': ['./src/*'] 100 | } 101 | }); 102 | 103 | program.emit(); -------------------------------------------------------------------------------- /lexical-solid/package.json: -------------------------------------------------------------------------------- 1 | { 2 | "name": "lexical-solid", 3 | "version": "0.21.0", 4 | "license": "MIT", 5 | "type": "module", 6 | "main": "./dist/cjs/index.cjs", 7 | "module": "./dist/esm/index.js", 8 | "types": "./dist/types/index.d.ts", 9 | "exports": { 10 | ".": { 11 | "solid": "./dist/source/index.jsx", 12 | "types": "./dist/types/index.d.ts", 13 | "import": "./dist/esm/index.js", 14 | "browser": "./dist/esm/index.js", 15 | "require": "./dist/cjs/index.cjs", 16 | "node": "./dist/cjs/index.cjs" 17 | }, 18 | "./*": { 19 | "solid": "./dist/source/*.jsx", 20 | "types": "./dist/types/*.d.ts", 21 | "import": "./dist/esm/*.js", 22 | "browser": "./dist/esm/*.js", 23 | "require": "./dist/cjs/*.cjs", 24 | "node": "./dist/cjs/*.cjs" 25 | } 26 | }, 27 | "typesVersions": { 28 | "*": { 29 | "*": [ 30 | "./dist/types/*.d.ts" 31 | ] 32 | } 33 | }, 34 | "files": [ 35 | "dist", 36 | "LICENSE-UPSTREAM", 37 | "README.md" 38 | ], 39 | "scripts": { 40 | "build": "node build.js" 41 | }, 42 | "dependencies": { 43 | "@lexical/clipboard": "~0.30.0", 44 | "@lexical/code": "~0.30.0", 45 | "@lexical/dragon": "~0.30.0", 46 | "@lexical/hashtag": "~0.30.0", 47 | "@lexical/history": "~0.30.0", 48 | "@lexical/html": "~0.30.0", 49 | "@lexical/link": "~0.30.0", 50 | "@lexical/list": "~0.30.0", 51 | "@lexical/mark": "~0.30.0", 52 | "@lexical/markdown": "~0.30.0", 53 | "@lexical/overflow": "~0.30.0", 54 | "@lexical/plain-text": "~0.30.0", 55 | "@lexical/rich-text": "~0.30.0", 56 | "@lexical/selection": "~0.30.0", 57 | "@lexical/table": "~0.30.0", 58 | "@lexical/text": "~0.30.0", 59 | "@lexical/utils": "~0.30.0", 60 | "@lexical/yjs": "~0.30.0", 61 | "lexical": "~0.30.0" 62 | }, 63 | "devDependencies": { 64 | "@babel/core": "^7.25.8", 65 | "@babel/preset-typescript": "^7.25.7", 66 | "@rollup/plugin-babel": "^6.0.4", 67 | "@rollup/plugin-commonjs": "^28.0.1", 68 | "@rollup/plugin-node-resolve": "^15.3.0", 69 | "babel-preset-solid": "^1.9.2", 70 | "cross-env": "^7.0.3", 71 | "fast-glob": "^3.3.2", 72 | "npm-run-all": "^4.1.5", 73 | "rimraf": "^6.0.1", 74 | "rollup": "^4.24.0", 75 | "solid-js": "^1.9.2", 76 | "typescript": "^5.6.3", 77 | "utility-types": "3.11.0", 78 | "yjs": "^13.6.20" 79 | }, 80 | "peerDependencies": { 81 | "solid-js": "^1.7.11", 82 | "utility-types": "^3.10.0" 83 | } 84 | } -------------------------------------------------------------------------------- /lexical-solid/src/LexicalAutoEmbedPlugin.tsx: -------------------------------------------------------------------------------- 1 | import { 2 | LexicalNode, 3 | MutationListener, 4 | $getNodeByKey, 5 | $getSelection, 6 | COMMAND_PRIORITY_EDITOR, 7 | COMMAND_PRIORITY_LOW, 8 | createCommand, 9 | LexicalCommand, 10 | LexicalEditor, 11 | NodeKey, 12 | TextNode, 13 | CommandListenerPriority, 14 | } from "lexical"; 15 | import { $isLinkNode, AutoLinkNode, LinkNode } from "@lexical/link"; 16 | import { useLexicalComposerContext } from "./LexicalComposerContext"; 17 | import { MenuRenderFn } from "./LexicalTypeaheadMenuPlugin"; 18 | import { mergeRegister } from "@lexical/utils"; 19 | import { 20 | createEffect, 21 | createMemo, 22 | createSignal, 23 | JSX, 24 | Show, 25 | onCleanup, 26 | } from "solid-js"; 27 | import { LexicalNodeMenuPlugin, MenuOption } from "./LexicalNodeMenuPlugin"; 28 | 29 | export type EmbedMatchResult = { 30 | url: string; 31 | id: string; 32 | data?: TEmbedMatchResult; 33 | }; 34 | 35 | export interface EmbedConfig< 36 | TEmbedMatchResultData = unknown, 37 | TEmbedMatchResult = EmbedMatchResult 38 | > { 39 | // Used to identify this config e.g. youtube, tweet, google-maps. 40 | type: string; 41 | // Determine if a given URL is a match and return url data. 42 | parseUrl: ( 43 | text: string 44 | ) => Promise | TEmbedMatchResult | null; 45 | // Create the Lexical embed node from the url data. 46 | insertNode: (editor: LexicalEditor, result: TEmbedMatchResult) => void; 47 | } 48 | 49 | export const URL_MATCHER = 50 | /((https?:\/\/(www\.)?)|(www\.))[-a-zA-Z0-9@:%._+~#=]{1,256}\.[a-zA-Z0-9()]{1,6}\b([-a-zA-Z0-9()@:%_+.~#?&//=]*)/; 51 | 52 | export const INSERT_EMBED_COMMAND: LexicalCommand = 53 | createCommand("INSERT_EMBED_COMMAND"); 54 | 55 | export class AutoEmbedOption extends MenuOption { 56 | title: string; 57 | onSelect: (targetNode: LexicalNode | null) => void; 58 | constructor( 59 | title: string, 60 | options: { 61 | onSelect: (targetNode: LexicalNode | null) => void; 62 | } 63 | ) { 64 | super(title); 65 | this.title = title; 66 | this.onSelect = options.onSelect.bind(this); 67 | } 68 | } 69 | 70 | type LexicalAutoEmbedPluginProps = { 71 | embedConfigs: Array; 72 | onOpenEmbedModalForConfig: (embedConfig: TEmbedConfig) => void; 73 | getMenuOptions: ( 74 | activeEmbedConfig: TEmbedConfig, 75 | embedFn: () => void, 76 | dismissFn: () => void 77 | ) => Array; 78 | menuRenderFn: MenuRenderFn; 79 | menuCommandPriority?: CommandListenerPriority; 80 | }; 81 | 82 | export function LexicalAutoEmbedPlugin( 83 | props: LexicalAutoEmbedPluginProps 84 | ): JSX.Element | null { 85 | const [editor] = useLexicalComposerContext(); 86 | 87 | const [nodeKey, setNodeKey] = createSignal(null); 88 | const [activeEmbedConfig, setActiveEmbedConfig] = 89 | createSignal(null); 90 | 91 | const reset = () => { 92 | setNodeKey(null); 93 | setActiveEmbedConfig(null); 94 | }; 95 | 96 | const checkIfLinkNodeIsEmbeddable = async (key: NodeKey) => { 97 | const url = editor.getEditorState().read(() => { 98 | const linkNode = $getNodeByKey(key); 99 | if ($isLinkNode(linkNode)) { 100 | return linkNode.getURL(); 101 | } 102 | }); 103 | if (url === undefined) { 104 | return; 105 | } 106 | for (const embedConfig of props.embedConfigs) { 107 | const urlMatch = await Promise.resolve(embedConfig.parseUrl(url)); 108 | if (urlMatch != null) { 109 | setActiveEmbedConfig(() => embedConfig); 110 | setNodeKey(key); 111 | } 112 | } 113 | }; 114 | 115 | createEffect(() => { 116 | const listener: MutationListener = ( 117 | nodeMutations, 118 | { updateTags, dirtyLeaves } 119 | ) => { 120 | for (const [key, mutation] of nodeMutations) { 121 | if ( 122 | mutation === "created" && 123 | updateTags.has("paste") && 124 | dirtyLeaves.size <= 3 125 | ) { 126 | checkIfLinkNodeIsEmbeddable(key); 127 | } else if (key === nodeKey()) { 128 | reset(); 129 | } 130 | } 131 | }; 132 | onCleanup( 133 | mergeRegister( 134 | ...[LinkNode, AutoLinkNode].map((Klass) => 135 | editor.registerMutationListener( 136 | Klass, 137 | (...args) => listener(...args), 138 | { 139 | skipInitialization: true, 140 | } 141 | ) 142 | ) 143 | ) 144 | ); 145 | }); 146 | 147 | createEffect(() => { 148 | onCleanup( 149 | editor.registerCommand( 150 | INSERT_EMBED_COMMAND, 151 | (embedConfigType: TEmbedConfig["type"]) => { 152 | const embedConfig = props.embedConfigs.find( 153 | ({ type }) => type === embedConfigType 154 | ); 155 | if (embedConfig) { 156 | props.onOpenEmbedModalForConfig(embedConfig); 157 | return true; 158 | } 159 | return false; 160 | }, 161 | COMMAND_PRIORITY_EDITOR 162 | ) 163 | ); 164 | }); 165 | 166 | const embedLinkViaActiveEmbedConfig = async () => { 167 | if (activeEmbedConfig() != null && nodeKey() != null) { 168 | const linkNode = editor.getEditorState().read(() => { 169 | const node = $getNodeByKey(nodeKey()!); 170 | if ($isLinkNode(node)) { 171 | return node; 172 | } 173 | return null; 174 | }); 175 | 176 | if ($isLinkNode(linkNode)) { 177 | const result = await Promise.resolve( 178 | activeEmbedConfig()!.parseUrl(linkNode.__url) 179 | ); 180 | if (result != null) { 181 | editor.update(() => { 182 | if (!$getSelection()) { 183 | linkNode.selectEnd(); 184 | } 185 | activeEmbedConfig()!.insertNode(editor, result); 186 | if (linkNode.isAttached()) { 187 | linkNode.remove(); 188 | } 189 | }); 190 | } 191 | } 192 | } 193 | }; 194 | 195 | const options = createMemo(() => { 196 | return activeEmbedConfig() != null && nodeKey() != null 197 | ? props.getMenuOptions( 198 | activeEmbedConfig()!, 199 | embedLinkViaActiveEmbedConfig, 200 | reset 201 | ) 202 | : []; 203 | }); 204 | 205 | const onSelectOption = ( 206 | selectedOption: AutoEmbedOption, 207 | targetNode: TextNode | null, 208 | closeMenu: () => void 209 | ) => { 210 | editor.update(() => { 211 | selectedOption.onSelect(targetNode); 212 | closeMenu(); 213 | }); 214 | }; 215 | 216 | return ( 217 | 218 | 219 | nodeKey={nodeKey()} 220 | onClose={reset} 221 | onSelectOption={onSelectOption} 222 | options={options()} 223 | menuRenderFn={props.menuRenderFn} 224 | commandPriority={props.menuCommandPriority ?? COMMAND_PRIORITY_LOW} 225 | /> 226 | 227 | ); 228 | } 229 | -------------------------------------------------------------------------------- /lexical-solid/src/LexicalAutoFocusPlugin.tsx: -------------------------------------------------------------------------------- 1 | import { useLexicalComposerContext } from "./LexicalComposerContext"; 2 | import { onMount } from "solid-js"; 3 | 4 | type Props = { 5 | defaultSelection?: "rootStart" | "rootEnd"; 6 | }; 7 | 8 | export function AutoFocusPlugin(props: Props): null { 9 | const [editor] = useLexicalComposerContext(); 10 | 11 | onMount(() => { 12 | editor.focus( 13 | () => { 14 | // If we try and move selection to the same point with setBaseAndExtent, it won't 15 | // trigger a re-focus on the element. So in the case this occurs, we'll need to correct it. 16 | // Normally this is fine, Selection API !== Focus API, but fore the intents of the naming 17 | // of this plugin, which should preserve focus too. 18 | const activeElement = document.activeElement; 19 | const rootElement = editor.getRootElement() as HTMLDivElement; 20 | if ( 21 | rootElement !== null && 22 | (activeElement === null || !rootElement.contains(activeElement)) 23 | ) { 24 | // Note: preventScroll won't work in Webkit. 25 | rootElement.focus({ preventScroll: true }); 26 | } 27 | }, 28 | { defaultSelection: props.defaultSelection } 29 | ); 30 | }); 31 | 32 | return null; 33 | } 34 | -------------------------------------------------------------------------------- /lexical-solid/src/LexicalBlockWithAlignableContents.tsx: -------------------------------------------------------------------------------- 1 | import { 2 | ElementFormatType, 3 | NodeKey, 4 | $getNodeByKey, 5 | $getSelection, 6 | $isNodeSelection, 7 | $isRangeSelection, 8 | CLICK_COMMAND, 9 | COMMAND_PRIORITY_LOW, 10 | FORMAT_ELEMENT_COMMAND, 11 | } from "lexical"; 12 | import { useLexicalComposerContext } from "./LexicalComposerContext"; 13 | import { $isDecoratorBlockNode } from "./LexicalDecoratorBlockNode"; 14 | import { useLexicalNodeSelection } from "./useLexicalNodeSelection"; 15 | import { 16 | $getNearestBlockElementAncestorOrThrow, 17 | mergeRegister, 18 | } from "@lexical/utils"; 19 | import { JSX } from "solid-js/jsx-runtime"; 20 | import { createEffect, onCleanup } from "solid-js"; 21 | 22 | type Props = Readonly<{ 23 | children: JSX.Element; 24 | format?: ElementFormatType | null; 25 | nodeKey: NodeKey; 26 | classes: Readonly<{ 27 | base: string; 28 | focus: string; 29 | }>; 30 | }>; 31 | 32 | export function BlockWithAlignableContents(props: Props): JSX.Element { 33 | const [editor] = useLexicalComposerContext(); 34 | 35 | const [isSelected, setSelected, clearSelection] = useLexicalNodeSelection( 36 | props.nodeKey 37 | ); 38 | let ref: HTMLDivElement | undefined; 39 | 40 | createEffect(() => { 41 | onCleanup( 42 | mergeRegister( 43 | editor.registerCommand( 44 | FORMAT_ELEMENT_COMMAND, 45 | (formatType) => { 46 | if (isSelected()) { 47 | const selection = $getSelection(); 48 | 49 | if ($isNodeSelection(selection)) { 50 | const node = $getNodeByKey(props.nodeKey)!; 51 | 52 | if ($isDecoratorBlockNode(node)) { 53 | node.setFormat(formatType); 54 | } 55 | } else if ($isRangeSelection(selection)) { 56 | const nodes = selection.getNodes(); 57 | 58 | for (const node of nodes) { 59 | if ($isDecoratorBlockNode(node)) { 60 | node.setFormat(formatType); 61 | } else { 62 | const element = 63 | $getNearestBlockElementAncestorOrThrow(node); 64 | element.setFormat(formatType); 65 | } 66 | } 67 | } 68 | 69 | return true; 70 | } 71 | 72 | return false; 73 | }, 74 | COMMAND_PRIORITY_LOW 75 | ), 76 | editor.registerCommand( 77 | CLICK_COMMAND, 78 | (event) => { 79 | if (event.target === ref) { 80 | event.preventDefault(); 81 | if (!event.shiftKey) { 82 | clearSelection(); 83 | } 84 | 85 | setSelected(!isSelected); 86 | return true; 87 | } 88 | 89 | return false; 90 | }, 91 | COMMAND_PRIORITY_LOW 92 | ) 93 | ) 94 | ); 95 | }); 96 | 97 | return ( 98 |
108 | {props.children} 109 |
110 | ); 111 | } 112 | -------------------------------------------------------------------------------- /lexical-solid/src/LexicalCharacterLimitPlugin.tsx: -------------------------------------------------------------------------------- 1 | import { Dynamic } from "solid-js/web"; 2 | import { useLexicalComposerContext } from "./LexicalComposerContext"; 3 | import { useCharacterLimit } from "./shared/useCharacterLimit"; 4 | import { Component, createMemo, createSignal, JSX, mergeProps } from "solid-js"; 5 | 6 | const CHARACTER_LIMIT = 5; 7 | let textEncoderInstance: null | TextEncoder = null; 8 | 9 | function textEncoder(): null | TextEncoder { 10 | if (window.TextEncoder === undefined) { 11 | return null; 12 | } 13 | 14 | if (textEncoderInstance === null) { 15 | textEncoderInstance = new window.TextEncoder(); 16 | } 17 | 18 | return textEncoderInstance; 19 | } 20 | 21 | function utf8Length(text: string) { 22 | const currentTextEncoder = textEncoder(); 23 | 24 | if (currentTextEncoder === null) { 25 | // http://stackoverflow.com/a/5515960/210370 26 | const m = encodeURIComponent(text).match(/%[89ABab]/g); 27 | return text.length + (m ? m.length : 0); 28 | } 29 | 30 | return currentTextEncoder.encode(text).length; 31 | } 32 | 33 | function DefaultRenderer(props: { remainingCharacters: number }) { 34 | return ( 35 | 40 | {props.remainingCharacters} 41 | 42 | ); 43 | } 44 | 45 | export function CharacterLimitPlugin(props: { 46 | charset: "UTF-8" | "UTF-16"; 47 | maxLength: number; 48 | renderer: Component<{ remainingCharacters: number }>; 49 | }): JSX.Element { 50 | props = mergeProps( 51 | { 52 | charset: "UTF-16", 53 | maxLength: CHARACTER_LIMIT, 54 | renderer: DefaultRenderer, 55 | }, 56 | props 57 | ); 58 | const [editor] = useLexicalComposerContext(); 59 | 60 | const [remainingCharacters, setRemainingCharacters] = createSignal( 61 | props.maxLength 62 | ); 63 | 64 | const characterLimitProps = createMemo(() => ({ 65 | remainingCharacters: setRemainingCharacters, 66 | strlen: (text: string) => { 67 | if (props.charset === "UTF-8") { 68 | return utf8Length(text); 69 | } else if (props.charset === "UTF-16") { 70 | return text.length; 71 | } else { 72 | throw new Error("Unrecognized charset"); 73 | } 74 | }, 75 | })); 76 | 77 | useCharacterLimit(editor, props.maxLength, characterLimitProps); 78 | 79 | return ( 80 | 84 | ); 85 | } 86 | -------------------------------------------------------------------------------- /lexical-solid/src/LexicalCheckListPlugin.tsx: -------------------------------------------------------------------------------- 1 | import { registerCheckList } from "@lexical/list"; 2 | import { useLexicalComposerContext } from "./LexicalComposerContext"; 3 | import { createEffect, onCleanup } from "solid-js"; 4 | 5 | export function CheckListPlugin(): null { 6 | const [editor] = useLexicalComposerContext(); 7 | 8 | createEffect(() => { 9 | onCleanup(registerCheckList(editor)); 10 | }); 11 | 12 | return null; 13 | } 14 | -------------------------------------------------------------------------------- /lexical-solid/src/LexicalClearEditorPlugin.tsx: -------------------------------------------------------------------------------- 1 | import { useLexicalComposerContext } from "./LexicalComposerContext"; 2 | import { 3 | $createParagraphNode, 4 | $getRoot, 5 | $getSelection, 6 | $isRangeSelection, 7 | CLEAR_EDITOR_COMMAND, 8 | COMMAND_PRIORITY_EDITOR, 9 | } from "lexical"; 10 | import { JSX, onCleanup } from "solid-js"; 11 | 12 | type Props = Readonly<{ 13 | onClear?: () => void; 14 | }>; 15 | 16 | export function ClearEditorPlugin(props: Props): JSX.Element { 17 | const [editor] = useLexicalComposerContext(); 18 | onCleanup( 19 | editor.registerCommand( 20 | CLEAR_EDITOR_COMMAND, 21 | (_payload) => { 22 | editor.update(() => { 23 | if (props.onClear == null) { 24 | const root = $getRoot(); 25 | const selection = $getSelection(); 26 | const paragraph = $createParagraphNode(); 27 | root.clear(); 28 | root.append(paragraph); 29 | if (selection !== null) { 30 | paragraph.select(); 31 | } 32 | if ($isRangeSelection(selection)) { 33 | selection.format = 0; 34 | } 35 | } else { 36 | props.onClear(); 37 | } 38 | }); 39 | return true; 40 | }, 41 | COMMAND_PRIORITY_EDITOR 42 | ) 43 | ); 44 | return null; 45 | } 46 | -------------------------------------------------------------------------------- /lexical-solid/src/LexicalClickableLinkPlugin.tsx: -------------------------------------------------------------------------------- 1 | import { $isLinkNode } from "@lexical/link"; 2 | import { useLexicalComposerContext } from "./LexicalComposerContext"; 3 | import { $findMatchingParent, isHTMLAnchorElement } from "@lexical/utils"; 4 | import { 5 | $getNearestNodeFromDOMNode, 6 | $getSelection, 7 | $isElementNode, 8 | $isRangeSelection, 9 | getNearestEditorFromDOMNode, 10 | isDOMNode, 11 | } from "lexical"; 12 | import { createEffect, mergeProps, onCleanup } from "solid-js"; 13 | 14 | function findMatchingDOM( 15 | startNode: Node, 16 | predicate: (node: Node) => node is T 17 | ): T | null { 18 | let node: Node | null = startNode; 19 | while (node != null) { 20 | if (predicate(node)) { 21 | return node; 22 | } 23 | node = node.parentNode; 24 | } 25 | return null; 26 | } 27 | 28 | export function LexicalClickableLinkPlugin(props: { 29 | newTab?: boolean; 30 | disabled?: boolean; 31 | }): null { 32 | const [editor] = useLexicalComposerContext(); 33 | props = mergeProps({ newTab: true, disabled: false }, props); 34 | 35 | createEffect(() => { 36 | const onClick = (event: MouseEvent) => { 37 | const target = event.target; 38 | if (!isDOMNode(target)) { 39 | return; 40 | } 41 | const nearestEditor = getNearestEditorFromDOMNode(target); 42 | 43 | if (nearestEditor === null) { 44 | return; 45 | } 46 | 47 | let url = null; 48 | let urlTarget = null; 49 | nearestEditor.update(() => { 50 | const clickedNode = $getNearestNodeFromDOMNode(target); 51 | if (clickedNode !== null) { 52 | const maybeLinkNode = $findMatchingParent( 53 | clickedNode, 54 | $isElementNode 55 | ); 56 | if (!props.disabled) { 57 | if ($isLinkNode(maybeLinkNode)) { 58 | url = maybeLinkNode.sanitizeUrl(maybeLinkNode.getURL()); 59 | urlTarget = maybeLinkNode.getTarget(); 60 | } else { 61 | const a = findMatchingDOM(target, isHTMLAnchorElement); 62 | if (a !== null) { 63 | url = a.href; 64 | urlTarget = a.target; 65 | } 66 | } 67 | } 68 | } 69 | }); 70 | 71 | if (url === null || url === "") { 72 | return; 73 | } 74 | 75 | // Allow user to select link text without following url 76 | const selection = editor.getEditorState().read($getSelection); 77 | if ($isRangeSelection(selection) && !selection.isCollapsed()) { 78 | event.preventDefault(); 79 | return; 80 | } 81 | 82 | const isMiddle = event.type === "auxclick" && event.button === 1; 83 | window.open( 84 | url, 85 | props.newTab || 86 | isMiddle || 87 | event.metaKey || 88 | event.ctrlKey || 89 | urlTarget === "_blank" 90 | ? "_blank" 91 | : "_self" 92 | ); 93 | event.preventDefault(); 94 | }; 95 | 96 | const onMouseUp = (event: MouseEvent) => { 97 | if (event.button === 1) { 98 | onClick(event); 99 | } 100 | }; 101 | 102 | onCleanup( 103 | editor.registerRootListener((rootElement, prevRootElement) => { 104 | if (prevRootElement !== null) { 105 | prevRootElement.removeEventListener("click", onClick); 106 | prevRootElement.removeEventListener("mouseup", onMouseUp); 107 | } 108 | if (rootElement !== null) { 109 | rootElement.addEventListener("click", onClick); 110 | rootElement.addEventListener("mouseup", onMouseUp); 111 | } 112 | }) 113 | ); 114 | }); 115 | 116 | return null; 117 | } 118 | -------------------------------------------------------------------------------- /lexical-solid/src/LexicalCollaborationContext.tsx: -------------------------------------------------------------------------------- 1 | import { Doc } from "yjs"; 2 | import { createContext, useContext } from "solid-js"; 3 | 4 | export type CollaborationContextType = { 5 | clientID: number; 6 | color: string; 7 | isCollabActive: boolean; 8 | name: string; 9 | yjsDocMap: Map; 10 | }; 11 | 12 | const entries = [ 13 | ["Cat", "rgb(125, 50, 0)"], 14 | ["Dog", "rgb(100, 0, 0)"], 15 | ["Rabbit", "rgb(150, 0, 0)"], 16 | ["Frog", "rgb(200, 0, 0)"], 17 | ["Fox", "rgb(200, 75, 0)"], 18 | ["Hedgehog", "rgb(0, 75, 0)"], 19 | ["Pigeon", "rgb(0, 125, 0)"], 20 | ["Squirrel", "rgb(75, 100, 0)"], 21 | ["Bear", "rgb(125, 100, 0)"], 22 | ["Tiger", "rgb(0, 0, 150)"], 23 | ["Leopard", "rgb(0, 0, 200)"], 24 | ["Zebra", "rgb(0, 0, 250)"], 25 | ["Wolf", "rgb(0, 100, 150)"], 26 | ["Owl", "rgb(0, 100, 100)"], 27 | ["Gull", "rgb(100, 0, 100)"], 28 | ["Squid", "rgb(150, 0, 150)"], 29 | ]; 30 | 31 | const randomEntry = entries[Math.floor(Math.random() * entries.length)]; 32 | export const CollaborationContext = createContext({ 33 | clientID: 0, 34 | color: randomEntry[1], 35 | isCollabActive: false, 36 | name: randomEntry[0], 37 | yjsDocMap: new Map(), 38 | }); 39 | 40 | export function useCollaborationContext( 41 | username?: string, 42 | color?: string 43 | ): CollaborationContextType { 44 | const collabContext = useContext(CollaborationContext); 45 | 46 | if (username != null) { 47 | collabContext.name = username; 48 | } 49 | 50 | if (color != null) { 51 | collabContext.color = color; 52 | } 53 | 54 | return collabContext; 55 | } 56 | -------------------------------------------------------------------------------- /lexical-solid/src/LexicalCollaborationPlugin.tsx: -------------------------------------------------------------------------------- 1 | import { Doc } from "yjs"; 2 | import { 3 | type CollaborationContextType, 4 | useCollaborationContext, 5 | } from "./LexicalCollaborationContext"; 6 | import { useLexicalComposerContext } from "./LexicalComposerContext"; 7 | import { 8 | Binding, 9 | createBinding, 10 | ExcludedProperties, 11 | Provider, 12 | } from "@lexical/yjs"; 13 | import { LexicalEditor } from "lexical"; 14 | import { InitialEditorStateType } from "./LexicalComposer"; 15 | import { 16 | CursorsContainerRef, 17 | useYjsCollaboration, 18 | useYjsFocusTracking, 19 | useYjsHistory, 20 | } from "./shared/useYjsCollaboration"; 21 | import { 22 | createEffect, 23 | createMemo, 24 | createRenderEffect, 25 | createSignal, 26 | JSX, 27 | on, 28 | onCleanup, 29 | Setter, 30 | Show, 31 | } from "solid-js"; 32 | 33 | type Props = { 34 | id: string; 35 | providerFactory: ( 36 | // eslint-disable-next-line no-shadow 37 | id: string, 38 | yjsDocMap: Map 39 | ) => Provider; 40 | shouldBootstrap: boolean; 41 | username?: string; 42 | cursorColor?: string; 43 | cursorsContainerRef?: CursorsContainerRef; 44 | initialEditorState?: InitialEditorStateType; 45 | excludedProperties?: ExcludedProperties; 46 | // `awarenessData` parameter allows arbitrary data to be added to the awareness. 47 | awarenessData?: object; 48 | }; 49 | 50 | export function CollaborationPlugin(props: Props): JSX.Element { 51 | const isBindingInitialized = { current: false }; 52 | const isProviderInitialized = { current: false }; 53 | 54 | const collabContext = useCollaborationContext( 55 | props.username, 56 | props.cursorColor 57 | ); 58 | 59 | const { yjsDocMap, name, color } = collabContext; 60 | 61 | const [editor] = useLexicalComposerContext(); 62 | 63 | createEffect(() => { 64 | collabContext.isCollabActive = true; 65 | 66 | return () => { 67 | // Resetting flag only when unmount top level editor collab plugin. Nested 68 | // editors (e.g. image caption) should unmount without affecting it 69 | if (editor._parentEditor == null) { 70 | collabContext.isCollabActive = false; 71 | } 72 | }; 73 | }, [collabContext, editor]); 74 | 75 | const [provider, setProvider] = createSignal(); 76 | 77 | createEffect( 78 | on( 79 | () => [props.id, props.providerFactory, yjsDocMap], 80 | () => { 81 | if (isProviderInitialized.current) { 82 | return; 83 | } 84 | 85 | isProviderInitialized.current = true; 86 | 87 | const newProvider = props.providerFactory(props.id, yjsDocMap); 88 | setProvider(newProvider); 89 | 90 | onCleanup(() => newProvider.disconnect()); 91 | } 92 | ) 93 | ); 94 | 95 | const [doc, setDoc] = createSignal(yjsDocMap.get(props.id)); 96 | const [binding, setBinding] = createSignal(); 97 | 98 | createEffect(() => { 99 | const p = provider(); 100 | if (!p) { 101 | return; 102 | } 103 | 104 | if (isBindingInitialized.current) { 105 | return; 106 | } 107 | 108 | isBindingInitialized.current = true; 109 | 110 | const newBinding = createBinding( 111 | editor, 112 | p, 113 | props.id, 114 | doc() || yjsDocMap.get(props.id), 115 | yjsDocMap, 116 | props.excludedProperties 117 | ); 118 | setBinding(newBinding); 119 | 120 | return () => { 121 | newBinding.root.destroy(newBinding); 122 | }; 123 | }); 124 | 125 | return ( 126 | 127 | 142 | 143 | ); 144 | } 145 | 146 | function YjsCollaborationCursors(props: { 147 | editor: LexicalEditor; 148 | id: string; 149 | provider: Provider; 150 | yjsDocMap: Map; 151 | name: string; 152 | color: string; 153 | shouldBootstrap: boolean; 154 | binding: Binding; 155 | setDoc: Setter; 156 | cursorsContainerRef?: CursorsContainerRef | undefined; 157 | initialEditorState?: InitialEditorStateType | undefined; 158 | awarenessData?: object; 159 | collabContext: CollaborationContextType; 160 | }) { 161 | const cursors = useYjsCollaboration( 162 | props.editor, 163 | props.id, 164 | () => props.provider, 165 | props.yjsDocMap, 166 | props.name, 167 | props.color, 168 | props.shouldBootstrap, 169 | () => props.binding, 170 | props.setDoc, 171 | props.cursorsContainerRef, 172 | props.initialEditorState, 173 | props.awarenessData 174 | ); 175 | 176 | createRenderEffect(() => { 177 | props.collabContext.clientID = props.binding.clientID; 178 | }) 179 | 180 | useYjsHistory(props.editor, () => props.binding); 181 | useYjsFocusTracking( 182 | props.editor, 183 | () => props.provider, 184 | props.name, 185 | props.color, 186 | props.awarenessData 187 | ); 188 | 189 | return <>{cursors}; 190 | } 191 | -------------------------------------------------------------------------------- /lexical-solid/src/LexicalComposer.tsx: -------------------------------------------------------------------------------- 1 | import { 2 | LexicalComposerContextType, 3 | createLexicalComposerContext, 4 | LexicalComposerContext, 5 | } from "./LexicalComposerContext"; 6 | import { 7 | $createParagraphNode, 8 | $getRoot, 9 | $getSelection, 10 | createEditor, 11 | EditorState, 12 | EditorThemeClasses, 13 | Klass, 14 | LexicalEditor, 15 | LexicalNode, 16 | LexicalNodeReplacement, 17 | HTMLConfig, 18 | } from "lexical"; 19 | import { JSX, ParentProps, onMount } from "solid-js"; 20 | 21 | const HISTORY_MERGE_OPTIONS = { tag: "history-merge" }; 22 | 23 | export type InitialEditorStateType = 24 | | null 25 | | string 26 | | EditorState 27 | | ((editor: LexicalEditor) => void); 28 | 29 | export type InitialConfigType = Readonly<{ 30 | namespace: string; 31 | nodes?: ReadonlyArray | LexicalNodeReplacement>; 32 | onError: (error: Error, editor: LexicalEditor) => void; 33 | editable?: boolean; 34 | theme?: EditorThemeClasses; 35 | editorState?: InitialEditorStateType; 36 | html?: HTMLConfig, 37 | }>; 38 | 39 | type Props = ParentProps<{ 40 | initialConfig: InitialConfigType; 41 | }>; 42 | 43 | export function LexicalComposer(props: Props): JSX.Element { 44 | const { 45 | editable, 46 | theme, 47 | namespace, 48 | nodes, 49 | onError, 50 | editorState: initialEditorState, 51 | html, 52 | } = props.initialConfig; 53 | 54 | const context: LexicalComposerContextType = createLexicalComposerContext( 55 | null, 56 | theme 57 | ); 58 | 59 | const editor = createEditor({ 60 | editable, 61 | html, 62 | namespace, 63 | nodes, 64 | onError: (error) => onError(error, editor), 65 | theme, 66 | }); 67 | initializeEditor(editor, initialEditorState); 68 | 69 | onMount(() => { 70 | const isEditable = props.initialConfig.editable; 71 | editor.setEditable(isEditable !== undefined ? isEditable : true); 72 | }); 73 | 74 | return ( 75 | 76 | {props.children} 77 | 78 | ); 79 | } 80 | 81 | function initializeEditor( 82 | editor: LexicalEditor, 83 | initialEditorState?: InitialEditorStateType 84 | ): void { 85 | if (initialEditorState === null) { 86 | return; 87 | } else if (initialEditorState === undefined) { 88 | editor.update(() => { 89 | const root = $getRoot(); 90 | if (root.isEmpty()) { 91 | const paragraph = $createParagraphNode(); 92 | root.append(paragraph); 93 | const activeElement = 94 | typeof window !== "undefined" ? document.activeElement : null; 95 | if ( 96 | $getSelection() !== null || 97 | (activeElement !== null && activeElement === editor.getRootElement()) 98 | ) { 99 | paragraph.select(); 100 | } 101 | } 102 | }, HISTORY_MERGE_OPTIONS); 103 | } else if (initialEditorState !== null) { 104 | switch (typeof initialEditorState) { 105 | case "string": { 106 | const parsedEditorState = editor.parseEditorState(initialEditorState); 107 | editor.setEditorState(parsedEditorState, HISTORY_MERGE_OPTIONS); 108 | break; 109 | } 110 | case "object": { 111 | editor.setEditorState(initialEditorState, HISTORY_MERGE_OPTIONS); 112 | break; 113 | } 114 | case "function": { 115 | editor.update(() => { 116 | const root = $getRoot(); 117 | if (root.isEmpty()) { 118 | initialEditorState(editor); 119 | } 120 | }, HISTORY_MERGE_OPTIONS); 121 | break; 122 | } 123 | } 124 | } 125 | } 126 | -------------------------------------------------------------------------------- /lexical-solid/src/LexicalComposerContext.tsx: -------------------------------------------------------------------------------- 1 | import { createContext, useContext } from "solid-js"; 2 | import type { EditorThemeClasses, LexicalEditor } from "lexical"; 3 | 4 | type LexicalComposerContextType = { 5 | getTheme: () => EditorThemeClasses | null; 6 | }; 7 | type LexicalComposerContextWithEditor = [ 8 | LexicalEditor, 9 | LexicalComposerContextType 10 | ]; 11 | 12 | function createLexicalComposerContext( 13 | parent: LexicalComposerContextWithEditor | null | undefined, 14 | theme: EditorThemeClasses | null | undefined 15 | ): LexicalComposerContextType { 16 | let parentContext: LexicalComposerContextType | null = null; 17 | 18 | if (parent != null) { 19 | parentContext = parent[1]; 20 | } 21 | 22 | function getTheme() { 23 | if (theme != null) { 24 | return theme; 25 | } 26 | return parentContext != null ? parentContext.getTheme() : null; 27 | } 28 | 29 | return { 30 | getTheme, 31 | }; 32 | } 33 | 34 | const LexicalComposerContext = createContext< 35 | LexicalComposerContextWithEditor | null | undefined 36 | >(null); 37 | 38 | const useLexicalComposerContext = 39 | (): LexicalComposerContextWithEditor => { 40 | const composerContext = useContext(LexicalComposerContext); 41 | if (!composerContext) { 42 | { 43 | throw Error( 44 | `useLexicalComposerContext: cannot find a LexicalComposerContext` 45 | ); 46 | } 47 | } 48 | return composerContext; 49 | }; 50 | 51 | export { 52 | LexicalComposerContext, 53 | createLexicalComposerContext, 54 | useLexicalComposerContext, 55 | } 56 | export type { 57 | LexicalComposerContextWithEditor, 58 | LexicalComposerContextType, 59 | } -------------------------------------------------------------------------------- /lexical-solid/src/LexicalContentEditable.tsx: -------------------------------------------------------------------------------- 1 | import type { Props as ElementProps } from "./shared/LexicalContentEditableElement"; 2 | import type { LexicalEditor } from "lexical"; 3 | import { useLexicalComposerContext } from "./LexicalComposerContext"; 4 | import { 5 | createEffect, 6 | createMemo, 7 | createSignal, 8 | JSX, 9 | onCleanup, 10 | Show, 11 | splitProps, 12 | } from "solid-js"; 13 | import { ContentEditableElement } from "./shared/LexicalContentEditableElement"; 14 | import { useCanShowPlaceholder } from "./shared/useCanShowPlaceholder"; 15 | 16 | export type ContentEditableProps = Omit & 17 | ( 18 | | { 19 | "aria-placeholder"?: void; 20 | placeholder?: null; 21 | } 22 | | { 23 | "aria-placeholder": string; 24 | placeholder: (isEditable: boolean) => null | JSX.Element; 25 | } 26 | ); 27 | 28 | /** 29 | * @deprecated This type has been renamed to `ContentEditableProps` to provide a clearer and more descriptive name. 30 | * For backward compatibility, this type is still exported as `Props`, but it is recommended to migrate to using `ContentEditableProps` instead. 31 | * 32 | * @note This alias is maintained for compatibility purposes but may be removed in future versions. 33 | * Please update your codebase to use `ContentEditableProps` to ensure long-term maintainability. 34 | */ 35 | export type Props = ContentEditableProps; 36 | 37 | export function ContentEditable(props: Props): JSX.Element { 38 | const [, rest] = splitProps(props, ["placeholder"]); 39 | const [editor] = useLexicalComposerContext(); 40 | 41 | return ( 42 | <> 43 | 44 | {props.placeholder != null && ( 45 | 46 | )} 47 | 48 | ); 49 | } 50 | 51 | function Placeholder(props: { 52 | editor: LexicalEditor; 53 | content: (isEditable: boolean) => null | JSX.Element; 54 | }): JSX.Element { 55 | const showPlaceholder = useCanShowPlaceholder(props.editor); 56 | 57 | const [isEditable, setEditable] = createSignal(props.editor.isEditable()); 58 | createEffect(() => { 59 | setEditable(props.editor.isEditable()); 60 | onCleanup( 61 | props.editor.registerEditableListener((currentIsEditable) => { 62 | setEditable(currentIsEditable); 63 | }) 64 | ); 65 | }); 66 | const placeholder = createMemo(() => props.content(isEditable())); 67 | 68 | if (placeholder === null) { 69 | return null; 70 | } 71 | return ( 72 | 73 |
{placeholder()}
74 |
75 | ); 76 | } 77 | -------------------------------------------------------------------------------- /lexical-solid/src/LexicalContextMenuPlugin.tsx: -------------------------------------------------------------------------------- 1 | import type { 2 | MenuRenderFn, 3 | MenuResolution, 4 | MutableRefObject, 5 | } from "./shared/LexicalMenu"; 6 | 7 | import { useLexicalComposerContext } from "./LexicalComposerContext"; 8 | import { 9 | COMMAND_PRIORITY_LOW, 10 | CommandListenerPriority, 11 | isDOMNode, 12 | LexicalNode, 13 | } from "lexical"; 14 | import { 15 | createEffect, 16 | createSignal, 17 | onCleanup, 18 | type JSX, 19 | Show, 20 | Accessor, 21 | } from "solid-js"; 22 | 23 | import { 24 | LexicalMenu, 25 | MenuOption, 26 | useMenuAnchorRef, 27 | } from "./shared/LexicalMenu"; 28 | import { calculateZoomLevel } from "@lexical/utils"; 29 | 30 | export type ContextMenuRenderFn = ( 31 | anchorElementRef: MutableRefObject, 32 | itemProps: { 33 | selectedIndex: Accessor; 34 | selectOptionAndCleanUp: (option: TOption) => void; 35 | setHighlightedIndex: (index: number) => void; 36 | options: Array; 37 | }, 38 | menuProps: { 39 | setMenuRef: (element: HTMLElement | null) => void; 40 | } 41 | ) => JSX.Element; 42 | 43 | export type LexicalContextMenuPluginProps = { 44 | onSelectOption: ( 45 | option: TOption, 46 | textNodeContainingQuery: LexicalNode | null, 47 | closeMenu: () => void, 48 | matchingString: string 49 | ) => void; 50 | options: Array; 51 | onClose?: () => void; 52 | onWillOpen?: (event: MouseEvent) => void; 53 | onOpen?: (resolution: MenuResolution) => void; 54 | menuRenderFn: ContextMenuRenderFn; 55 | anchorClassName?: string; 56 | commandPriority?: CommandListenerPriority; 57 | parent?: HTMLElement; 58 | }; 59 | 60 | const PRE_PORTAL_DIV_SIZE = 1; 61 | 62 | export function LexicalContextMenuPlugin( 63 | props: LexicalContextMenuPluginProps 64 | ): JSX.Element { 65 | const [editor] = useLexicalComposerContext(); 66 | const [resolution, setResolution] = createSignal(null); 67 | const menuRef: MutableRefObject = { current: null }; 68 | 69 | const anchorElementRef = useMenuAnchorRef( 70 | resolution, 71 | setResolution, 72 | props.anchorClassName, 73 | props.parent 74 | ); 75 | 76 | const closeNodeMenu = () => { 77 | if (props.onClose != null && resolution() !== null) { 78 | props.onClose(); 79 | } 80 | setResolution(null); 81 | }; 82 | 83 | const openNodeMenu = (res: MenuResolution) => { 84 | const onOpen = props.onOpen; 85 | if (onOpen != null && resolution() === null) { 86 | onOpen(res); 87 | } 88 | setResolution(res); 89 | }; 90 | 91 | const handleContextMenu = (event: MouseEvent) => { 92 | event.preventDefault(); 93 | const onWillOpen = props.onWillOpen; 94 | if (onWillOpen != null) { 95 | onWillOpen(event); 96 | } 97 | const zoom = calculateZoomLevel(event.target as Element); 98 | openNodeMenu({ 99 | getRect: () => 100 | new DOMRect( 101 | event.clientX / zoom, 102 | event.clientY / zoom, 103 | PRE_PORTAL_DIV_SIZE, 104 | PRE_PORTAL_DIV_SIZE 105 | ), 106 | }); 107 | }; 108 | const handleClick = (event: MouseEvent) => { 109 | if ( 110 | resolution() !== null && 111 | menuRef.current != null && 112 | isDOMNode(event.target) && 113 | !menuRef.current.contains(event.target) 114 | ) { 115 | closeNodeMenu(); 116 | } 117 | }; 118 | 119 | createEffect(() => { 120 | const editorElement = editor.getRootElement(); 121 | if (editorElement) { 122 | editorElement.addEventListener("contextmenu", handleContextMenu); 123 | onCleanup(() => 124 | editorElement.removeEventListener("contextmenu", handleContextMenu) 125 | ); 126 | } 127 | }); 128 | 129 | createEffect(() => { 130 | document.addEventListener("click", handleClick); 131 | onCleanup(() => document.removeEventListener("click", handleClick)); 132 | }); 133 | 134 | return ( 135 | 140 | 147 | props.menuRenderFn(anchorRef, itemProps, { 148 | setMenuRef: (ref) => { 149 | menuRef.current = ref; 150 | }, 151 | }) 152 | } 153 | onSelectOption={props.onSelectOption} 154 | commandPriority={props.commandPriority ?? COMMAND_PRIORITY_LOW} 155 | /> 156 | 157 | ); 158 | } 159 | 160 | export { MenuOption, MenuRenderFn, MenuResolution }; 161 | -------------------------------------------------------------------------------- /lexical-solid/src/LexicalDecoratorBlockNode.tsx: -------------------------------------------------------------------------------- 1 | import type { 2 | ElementFormatType, 3 | LexicalNode, 4 | LexicalUpdateJSON, 5 | NodeKey, 6 | SerializedLexicalNode, 7 | Spread, 8 | } from "lexical"; 9 | import type { JSX } from "solid-js"; 10 | 11 | import { DecoratorNode } from "lexical"; 12 | 13 | export type SerializedDecoratorBlockNode = Spread< 14 | { 15 | format: ElementFormatType; 16 | }, 17 | SerializedLexicalNode 18 | >; 19 | 20 | export class DecoratorBlockNode extends DecoratorNode { 21 | __format: ElementFormatType; 22 | 23 | constructor(format?: ElementFormatType, key?: NodeKey) { 24 | super(key); 25 | //@ts-ignore 26 | this.__format = format; 27 | } 28 | 29 | exportJSON(): SerializedDecoratorBlockNode { 30 | return { 31 | ...super.exportJSON(), 32 | format: this.__format || "", 33 | }; 34 | } 35 | 36 | updateFromJSON( 37 | serializedNode: LexicalUpdateJSON 38 | ): this { 39 | return super 40 | .updateFromJSON(serializedNode) 41 | .setFormat(serializedNode.format || ""); 42 | } 43 | 44 | canIndent(): false { 45 | return false; 46 | } 47 | 48 | createDOM(): HTMLElement { 49 | return document.createElement("div"); 50 | } 51 | 52 | updateDOM(): false { 53 | return false; 54 | } 55 | 56 | setFormat(format: ElementFormatType): this { 57 | const self = this.getWritable(); 58 | self.__format = format; 59 | return self; 60 | } 61 | 62 | isInline(): false { 63 | return false; 64 | } 65 | } 66 | export function $isDecoratorBlockNode( 67 | node?: LexicalNode 68 | ): node is DecoratorBlockNode { 69 | return node instanceof DecoratorBlockNode; 70 | } 71 | -------------------------------------------------------------------------------- /lexical-solid/src/LexicalEditorRefPlugin.tsx: -------------------------------------------------------------------------------- 1 | import { Setter } from "solid-js"; 2 | import { useLexicalComposerContext } from "./LexicalComposerContext"; 3 | import { LexicalEditor } from "lexical"; 4 | 5 | /** 6 | * 7 | * Use this plugin to access the editor instance outside of the 8 | * LexicalComposer. This can help with things like buttons or other 9 | * UI components that need to update or read EditorState but need to 10 | * be positioned outside the LexicalComposer in the React tree. 11 | */ 12 | export function EditorRefPlugin(props: { 13 | editorRef: Setter; 14 | }): null { 15 | const [editor] = useLexicalComposerContext(); 16 | props.editorRef(editor); 17 | return null; 18 | } 19 | -------------------------------------------------------------------------------- /lexical-solid/src/LexicalErrorBoundary.tsx: -------------------------------------------------------------------------------- 1 | import { ErrorBoundary, JSX } from "solid-js"; 2 | 3 | export type LexicalErrorBoundaryProps = { 4 | children: JSX.Element; 5 | onError: (err: any, reset: () => void) => JSX.Element; 6 | }; 7 | 8 | export function LexicalErrorBoundary( 9 | props: LexicalErrorBoundaryProps 10 | ): JSX.Element { 11 | const defaultErrorFallback = () => ( 12 |
19 | An error was thrown. 20 |
21 | ); 22 | 23 | return ( 24 | 25 | {props.children} 26 | 27 | ); 28 | } 29 | -------------------------------------------------------------------------------- /lexical-solid/src/LexicalHashTagPlugin.tsx: -------------------------------------------------------------------------------- 1 | import type { TextNode } from "lexical"; 2 | 3 | import { $createHashtagNode, HashtagNode } from "@lexical/hashtag"; 4 | import { useLexicalComposerContext } from "./LexicalComposerContext"; 5 | import { useLexicalTextEntity } from "./useLexicalTextEntity"; 6 | 7 | import { JSX, onMount } from "solid-js"; 8 | 9 | function getHashtagRegexStringChars(): Readonly<{ 10 | alpha: string; 11 | alphanumeric: string; 12 | hashChars: string; 13 | }> { 14 | // Latin accented characters 15 | // Excludes 0xd7 from the range 16 | // (the multiplication sign, confusable with "x"). 17 | // Also excludes 0xf7, the division sign 18 | const latinAccents = 19 | "\xc0-\xd6" + 20 | "\xd8-\xf6" + 21 | "\xf8-\xff" + 22 | "\u0100-\u024f" + 23 | "\u0253-\u0254" + 24 | "\u0256-\u0257" + 25 | "\u0259" + 26 | "\u025b" + 27 | "\u0263" + 28 | "\u0268" + 29 | "\u026f" + 30 | "\u0272" + 31 | "\u0289" + 32 | "\u028b" + 33 | "\u02bb" + 34 | "\u0300-\u036f" + 35 | "\u1e00-\u1eff"; 36 | 37 | // Cyrillic (Russian, Ukrainian, etc.) 38 | const nonLatinChars = 39 | "\u0400-\u04ff" + // Cyrillic 40 | "\u0500-\u0527" + // Cyrillic Supplement 41 | "\u2de0-\u2dff" + // Cyrillic Extended A 42 | "\ua640-\ua69f" + // Cyrillic Extended B 43 | "\u0591-\u05bf" + // Hebrew 44 | "\u05c1-\u05c2" + 45 | "\u05c4-\u05c5" + 46 | "\u05c7" + 47 | "\u05d0-\u05ea" + 48 | "\u05f0-\u05f4" + 49 | "\ufb12-\ufb28" + // Hebrew Presentation Forms 50 | "\ufb2a-\ufb36" + 51 | "\ufb38-\ufb3c" + 52 | "\ufb3e" + 53 | "\ufb40-\ufb41" + 54 | "\ufb43-\ufb44" + 55 | "\ufb46-\ufb4f" + 56 | "\u0610-\u061a" + // Arabic 57 | "\u0620-\u065f" + 58 | "\u066e-\u06d3" + 59 | "\u06d5-\u06dc" + 60 | "\u06de-\u06e8" + 61 | "\u06ea-\u06ef" + 62 | "\u06fa-\u06fc" + 63 | "\u06ff" + 64 | "\u0750-\u077f" + // Arabic Supplement 65 | "\u08a0" + // Arabic Extended A 66 | "\u08a2-\u08ac" + 67 | "\u08e4-\u08fe" + 68 | "\ufb50-\ufbb1" + // Arabic Pres. Forms A 69 | "\ufbd3-\ufd3d" + 70 | "\ufd50-\ufd8f" + 71 | "\ufd92-\ufdc7" + 72 | "\ufdf0-\ufdfb" + 73 | "\ufe70-\ufe74" + // Arabic Pres. Forms B 74 | "\ufe76-\ufefc" + 75 | "\u200c-\u200c" + // Zero-Width Non-Joiner 76 | "\u0e01-\u0e3a" + // Thai 77 | "\u0e40-\u0e4e" + // Hangul (Korean) 78 | "\u1100-\u11ff" + // Hangul Jamo 79 | "\u3130-\u3185" + // Hangul Compatibility Jamo 80 | "\uA960-\uA97F" + // Hangul Jamo Extended-A 81 | "\uAC00-\uD7AF" + // Hangul Syllables 82 | "\uD7B0-\uD7FF" + // Hangul Jamo Extended-B 83 | "\uFFA1-\uFFDC"; // Half-width Hangul 84 | 85 | const charCode = String.fromCharCode; 86 | 87 | const cjkChars = 88 | "\u30A1-\u30FA\u30FC-\u30FE" + // Katakana (full-width) 89 | "\uFF66-\uFF9F" + // Katakana (half-width) 90 | "\uFF10-\uFF19\uFF21-\uFF3A" + 91 | "\uFF41-\uFF5A" + // Latin (full-width) 92 | "\u3041-\u3096\u3099-\u309E" + // Hiragana 93 | "\u3400-\u4DBF" + // Kanji (CJK Extension A) 94 | "\u4E00-\u9FFF" + // Kanji (Unified) 95 | // Disabled as it breaks the Regex. 96 | // charCode(0x20000) + '-' + charCode(0x2A6DF) + // Kanji (CJK Extension B) 97 | charCode(0x2a700) + 98 | "-" + 99 | charCode(0x2b73f) + // Kanji (CJK Extension C) 100 | charCode(0x2b740) + 101 | "-" + 102 | charCode(0x2b81f) + // Kanji (CJK Extension D) 103 | charCode(0x2f800) + 104 | "-" + 105 | charCode(0x2fa1f) + 106 | "\u3003\u3005\u303B"; // Kanji (CJK supplement) 107 | 108 | const otherChars = latinAccents + nonLatinChars + cjkChars; 109 | 110 | // equivalent of \p{L} 111 | const unicodeLetters = 112 | "\u0041-\u005A\u0061-\u007A\u00AA\u00B5\u00BA\u00C0-\u00D6\u00D8-\u00F6" + 113 | "\u00F8-\u0241\u0250-\u02C1\u02C6-\u02D1\u02E0-\u02E4\u02EE\u037A\u0386" + 114 | "\u0388-\u038A\u038C\u038E-\u03A1\u03A3-\u03CE\u03D0-\u03F5\u03F7-\u0481" + 115 | "\u048A-\u04CE\u04D0-\u04F9\u0500-\u050F\u0531-\u0556\u0559\u0561-\u0587" + 116 | "\u05D0-\u05EA\u05F0-\u05F2\u0621-\u063A\u0640-\u064A\u066E-\u066F" + 117 | "\u0671-\u06D3\u06D5\u06E5-\u06E6\u06EE-\u06EF\u06FA-\u06FC\u06FF\u0710" + 118 | "\u0712-\u072F\u074D-\u076D\u0780-\u07A5\u07B1\u0904-\u0939\u093D\u0950" + 119 | "\u0958-\u0961\u097D\u0985-\u098C\u098F-\u0990\u0993-\u09A8\u09AA-\u09B0" + 120 | "\u09B2\u09B6-\u09B9\u09BD\u09CE\u09DC-\u09DD\u09DF-\u09E1\u09F0-\u09F1" + 121 | "\u0A05-\u0A0A\u0A0F-\u0A10\u0A13-\u0A28\u0A2A-\u0A30\u0A32-\u0A33" + 122 | "\u0A35-\u0A36\u0A38-\u0A39\u0A59-\u0A5C\u0A5E\u0A72-\u0A74\u0A85-\u0A8D" + 123 | "\u0A8F-\u0A91\u0A93-\u0AA8\u0AAA-\u0AB0\u0AB2-\u0AB3\u0AB5-\u0AB9\u0ABD" + 124 | "\u0AD0\u0AE0-\u0AE1\u0B05-\u0B0C\u0B0F-\u0B10\u0B13-\u0B28\u0B2A-\u0B30" + 125 | "\u0B32-\u0B33\u0B35-\u0B39\u0B3D\u0B5C-\u0B5D\u0B5F-\u0B61\u0B71\u0B83" + 126 | "\u0B85-\u0B8A\u0B8E-\u0B90\u0B92-\u0B95\u0B99-\u0B9A\u0B9C\u0B9E-\u0B9F" + 127 | "\u0BA3-\u0BA4\u0BA8-\u0BAA\u0BAE-\u0BB9\u0C05-\u0C0C\u0C0E-\u0C10" + 128 | "\u0C12-\u0C28\u0C2A-\u0C33\u0C35-\u0C39\u0C60-\u0C61\u0C85-\u0C8C" + 129 | "\u0C8E-\u0C90\u0C92-\u0CA8\u0CAA-\u0CB3\u0CB5-\u0CB9\u0CBD\u0CDE" + 130 | "\u0CE0-\u0CE1\u0D05-\u0D0C\u0D0E-\u0D10\u0D12-\u0D28\u0D2A-\u0D39" + 131 | "\u0D60-\u0D61\u0D85-\u0D96\u0D9A-\u0DB1\u0DB3-\u0DBB\u0DBD\u0DC0-\u0DC6" + 132 | "\u0E01-\u0E30\u0E32-\u0E33\u0E40-\u0E46\u0E81-\u0E82\u0E84\u0E87-\u0E88" + 133 | "\u0E8A\u0E8D\u0E94-\u0E97\u0E99-\u0E9F\u0EA1-\u0EA3\u0EA5\u0EA7" + 134 | "\u0EAA-\u0EAB\u0EAD-\u0EB0\u0EB2-\u0EB3\u0EBD\u0EC0-\u0EC4\u0EC6" + 135 | "\u0EDC-\u0EDD\u0F00\u0F40-\u0F47\u0F49-\u0F6A\u0F88-\u0F8B\u1000-\u1021" + 136 | "\u1023-\u1027\u1029-\u102A\u1050-\u1055\u10A0-\u10C5\u10D0-\u10FA\u10FC" + 137 | "\u1100-\u1159\u115F-\u11A2\u11A8-\u11F9\u1200-\u1248\u124A-\u124D" + 138 | "\u1250-\u1256\u1258\u125A-\u125D\u1260-\u1288\u128A-\u128D\u1290-\u12B0" + 139 | "\u12B2-\u12B5\u12B8-\u12BE\u12C0\u12C2-\u12C5\u12C8-\u12D6\u12D8-\u1310" + 140 | "\u1312-\u1315\u1318-\u135A\u1380-\u138F\u13A0-\u13F4\u1401-\u166C" + 141 | "\u166F-\u1676\u1681-\u169A\u16A0-\u16EA\u1700-\u170C\u170E-\u1711" + 142 | "\u1720-\u1731\u1740-\u1751\u1760-\u176C\u176E-\u1770\u1780-\u17B3\u17D7" + 143 | "\u17DC\u1820-\u1877\u1880-\u18A8\u1900-\u191C\u1950-\u196D\u1970-\u1974" + 144 | "\u1980-\u19A9\u19C1-\u19C7\u1A00-\u1A16\u1D00-\u1DBF\u1E00-\u1E9B" + 145 | "\u1EA0-\u1EF9\u1F00-\u1F15\u1F18-\u1F1D\u1F20-\u1F45\u1F48-\u1F4D" + 146 | "\u1F50-\u1F57\u1F59\u1F5B\u1F5D\u1F5F-\u1F7D\u1F80-\u1FB4\u1FB6-\u1FBC" + 147 | "\u1FBE\u1FC2-\u1FC4\u1FC6-\u1FCC\u1FD0-\u1FD3\u1FD6-\u1FDB\u1FE0-\u1FEC" + 148 | "\u1FF2-\u1FF4\u1FF6-\u1FFC\u2071\u207F\u2090-\u2094\u2102\u2107" + 149 | "\u210A-\u2113\u2115\u2119-\u211D\u2124\u2126\u2128\u212A-\u212D" + 150 | "\u212F-\u2131\u2133-\u2139\u213C-\u213F\u2145-\u2149\u2C00-\u2C2E" + 151 | "\u2C30-\u2C5E\u2C80-\u2CE4\u2D00-\u2D25\u2D30-\u2D65\u2D6F\u2D80-\u2D96" + 152 | "\u2DA0-\u2DA6\u2DA8-\u2DAE\u2DB0-\u2DB6\u2DB8-\u2DBE\u2DC0-\u2DC6" + 153 | "\u2DC8-\u2DCE\u2DD0-\u2DD6\u2DD8-\u2DDE\u3005-\u3006\u3031-\u3035" + 154 | "\u303B-\u303C\u3041-\u3096\u309D-\u309F\u30A1-\u30FA\u30FC-\u30FF" + 155 | "\u3105-\u312C\u3131-\u318E\u31A0-\u31B7\u31F0-\u31FF\u3400-\u4DB5" + 156 | "\u4E00-\u9FBB\uA000-\uA48C\uA800-\uA801\uA803-\uA805\uA807-\uA80A" + 157 | "\uA80C-\uA822\uAC00-\uD7A3\uF900-\uFA2D\uFA30-\uFA6A\uFA70-\uFAD9" + 158 | "\uFB00-\uFB06\uFB13-\uFB17\uFB1D\uFB1F-\uFB28\uFB2A-\uFB36\uFB38-\uFB3C" + 159 | "\uFB3E\uFB40-\uFB41\uFB43-\uFB44\uFB46-\uFBB1\uFBD3-\uFD3D\uFD50-\uFD8F" + 160 | "\uFD92-\uFDC7\uFDF0-\uFDFB\uFE70-\uFE74\uFE76-\uFEFC\uFF21-\uFF3A" + 161 | "\uFF41-\uFF5A\uFF66-\uFFBE\uFFC2-\uFFC7\uFFCA-\uFFCF\uFFD2-\uFFD7" + 162 | "\uFFDA-\uFFDC"; 163 | 164 | // equivalent of \p{Mn}\p{Mc} 165 | const unicodeAccents = 166 | "\u0300-\u036F\u0483-\u0486\u0591-\u05B9\u05BB-\u05BD\u05BF" + 167 | "\u05C1-\u05C2\u05C4-\u05C5\u05C7\u0610-\u0615\u064B-\u065E\u0670" + 168 | "\u06D6-\u06DC\u06DF-\u06E4\u06E7-\u06E8\u06EA-\u06ED\u0711\u0730-\u074A" + 169 | "\u07A6-\u07B0\u0901-\u0903\u093C\u093E-\u094D\u0951-\u0954\u0962-\u0963" + 170 | "\u0981-\u0983\u09BC\u09BE-\u09C4\u09C7-\u09C8\u09CB-\u09CD\u09D7" + 171 | "\u09E2-\u09E3\u0A01-\u0A03\u0A3C\u0A3E-\u0A42\u0A47-\u0A48\u0A4B-\u0A4D" + 172 | "\u0A70-\u0A71\u0A81-\u0A83\u0ABC\u0ABE-\u0AC5\u0AC7-\u0AC9\u0ACB-\u0ACD" + 173 | "\u0AE2-\u0AE3\u0B01-\u0B03\u0B3C\u0B3E-\u0B43\u0B47-\u0B48\u0B4B-\u0B4D" + 174 | "\u0B56-\u0B57\u0B82\u0BBE-\u0BC2\u0BC6-\u0BC8\u0BCA-\u0BCD\u0BD7" + 175 | "\u0C01-\u0C03\u0C3E-\u0C44\u0C46-\u0C48\u0C4A-\u0C4D\u0C55-\u0C56" + 176 | "\u0C82-\u0C83\u0CBC\u0CBE-\u0CC4\u0CC6-\u0CC8\u0CCA-\u0CCD\u0CD5-\u0CD6" + 177 | "\u0D02-\u0D03\u0D3E-\u0D43\u0D46-\u0D48\u0D4A-\u0D4D\u0D57\u0D82-\u0D83" + 178 | "\u0DCA\u0DCF-\u0DD4\u0DD6\u0DD8-\u0DDF\u0DF2-\u0DF3\u0E31\u0E34-\u0E3A" + 179 | "\u0E47-\u0E4E\u0EB1\u0EB4-\u0EB9\u0EBB-\u0EBC\u0EC8-\u0ECD\u0F18-\u0F19" + 180 | "\u0F35\u0F37\u0F39\u0F3E-\u0F3F\u0F71-\u0F84\u0F86-\u0F87\u0F90-\u0F97" + 181 | "\u0F99-\u0FBC\u0FC6\u102C-\u1032\u1036-\u1039\u1056-\u1059\u135F" + 182 | "\u1712-\u1714\u1732-\u1734\u1752-\u1753\u1772-\u1773\u17B6-\u17D3\u17DD" + 183 | "\u180B-\u180D\u18A9\u1920-\u192B\u1930-\u193B\u19B0-\u19C0\u19C8-\u19C9" + 184 | "\u1A17-\u1A1B\u1DC0-\u1DC3\u20D0-\u20DC\u20E1\u20E5-\u20EB\u302A-\u302F" + 185 | "\u3099-\u309A\uA802\uA806\uA80B\uA823-\uA827\uFB1E\uFE00-\uFE0F" + 186 | "\uFE20-\uFE23"; 187 | 188 | // equivalent of \p{Dn} 189 | const unicodeDigits = 190 | "\u0030-\u0039\u0660-\u0669\u06F0-\u06F9\u0966-\u096F\u09E6-\u09EF" + 191 | "\u0A66-\u0A6F\u0AE6-\u0AEF\u0B66-\u0B6F\u0BE6-\u0BEF\u0C66-\u0C6F" + 192 | "\u0CE6-\u0CEF\u0D66-\u0D6F\u0E50-\u0E59\u0ED0-\u0ED9\u0F20-\u0F29" + 193 | "\u1040-\u1049\u17E0-\u17E9\u1810-\u1819\u1946-\u194F\u19D0-\u19D9" + 194 | "\uFF10-\uFF19"; 195 | // An alpha char is a unicode chars excluding unicode combining marks 196 | // but including other chars, a hashtag must start with one of these, 197 | // it does not make sense to have a combining mark before a base character. 198 | const alpha = unicodeLetters + otherChars; 199 | // A numeric character is any with the number digit property, or 200 | // underscore. These characters can be included in hashtags, but a hashtag 201 | // cannot have only these characters. 202 | const numeric = unicodeDigits + "_"; 203 | // Alphanumeric char is any alpha char or a unicode char with decimal 204 | // number property \p{Nd} 205 | const alphanumeric = alpha + unicodeAccents + numeric; 206 | 207 | const hashChars = "#\\uFF03"; // normal '#' or full-width '#' 208 | 209 | return { 210 | alpha, 211 | alphanumeric, 212 | hashChars, 213 | }; 214 | } 215 | 216 | function getHashtagRegexString(): string { 217 | const { alpha, alphanumeric, hashChars } = getHashtagRegexStringChars(); 218 | 219 | const hashtagAlpha = "[" + alpha + "]"; 220 | const hashtagAlphanumeric = "[" + alphanumeric + "]"; 221 | const hashtagBoundary = "^|$|[^&/" + alphanumeric + "]"; 222 | const hashCharList = "[" + hashChars + "]"; 223 | 224 | // A hashtag contains characters, numbers and underscores, 225 | // but not all numbers. 226 | const hashtag = 227 | "(" + 228 | hashtagBoundary + 229 | ")(" + 230 | hashCharList + 231 | ")(" + 232 | hashtagAlphanumeric + 233 | "*" + 234 | hashtagAlpha + 235 | hashtagAlphanumeric + 236 | "*)"; 237 | 238 | return hashtag; 239 | } 240 | 241 | const REGEX = new RegExp(getHashtagRegexString(), "i"); 242 | 243 | export function HashtagPlugin(): JSX.Element { 244 | const [editor] = useLexicalComposerContext(); 245 | 246 | onMount(() => { 247 | if (!editor.hasNodes([HashtagNode])) { 248 | throw new Error("HashtagPlugin: HashtagNode not registered on editor"); 249 | } 250 | }); 251 | 252 | const $createHashtagNode_ = (textNode: TextNode): HashtagNode => { 253 | return $createHashtagNode(textNode.getTextContent()) as HashtagNode; 254 | }; 255 | 256 | const getHashtagMatch = (text: string) => { 257 | const matchArr = REGEX.exec(text); 258 | if (matchArr === null) { 259 | return null; 260 | } 261 | const hashtagLength = matchArr[3].length + 1; 262 | const startOffset = matchArr.index + matchArr[1].length; 263 | const endOffset = startOffset + hashtagLength; 264 | return { end: endOffset, start: startOffset }; 265 | }; 266 | 267 | useLexicalTextEntity( 268 | getHashtagMatch, 269 | HashtagNode, 270 | $createHashtagNode_ 271 | ); 272 | 273 | return null; 274 | } 275 | -------------------------------------------------------------------------------- /lexical-solid/src/LexicalHistoryPlugin.tsx: -------------------------------------------------------------------------------- 1 | import { JSX } from "solid-js"; 2 | import { useLexicalComposerContext } from "./LexicalComposerContext"; 3 | import { useHistory } from "./shared/useHistory"; 4 | import type { HistoryState, HistoryStateEntry } from "@lexical/history"; 5 | 6 | function HistoryPlugin(props: { 7 | delay?: number; 8 | externalHistoryState?: HistoryState; 9 | }): JSX.Element { 10 | const [editor] = useLexicalComposerContext(); 11 | useHistory( 12 | editor, 13 | () => props.externalHistoryState, 14 | () => props.delay 15 | ); 16 | return null; 17 | } 18 | 19 | export { createEmptyHistoryState } from "@lexical/history"; 20 | export { HistoryPlugin }; 21 | export type { HistoryStateEntry, HistoryState }; 22 | -------------------------------------------------------------------------------- /lexical-solid/src/LexicalHorizontalRuleNode.tsx: -------------------------------------------------------------------------------- 1 | import { 2 | $applyNodeReplacement, 3 | CLICK_COMMAND, 4 | COMMAND_PRIORITY_LOW, 5 | DOMConversionMap, 6 | DOMConversionOutput, 7 | DOMExportOutput, 8 | EditorConfig, 9 | LexicalCommand, 10 | LexicalNode, 11 | NodeKey, 12 | SerializedLexicalNode, 13 | } from "lexical"; 14 | 15 | import { createEffect, JSX, onCleanup } from "solid-js"; 16 | 17 | import { createCommand, DecoratorNode } from "lexical"; 18 | import { useLexicalComposerContext } from "./LexicalComposerContext"; 19 | import { useLexicalNodeSelection } from "./useLexicalNodeSelection"; 20 | import { 21 | addClassNamesToElement, 22 | mergeRegister, 23 | removeClassNamesFromElement, 24 | } from "@lexical/utils"; 25 | 26 | export type SerializedHorizontalRuleNode = SerializedLexicalNode; 27 | 28 | export const INSERT_HORIZONTAL_RULE_COMMAND: LexicalCommand = 29 | createCommand("INSERT_HORIZONTAL_RULE_COMMAND"); 30 | 31 | function HorizontalRuleComponent(props: { nodeKey: NodeKey }) { 32 | const [editor] = useLexicalComposerContext(); 33 | const [isSelected, setSelected, clearSelection] = useLexicalNodeSelection( 34 | props.nodeKey 35 | ); 36 | 37 | createEffect(() => { 38 | onCleanup( 39 | mergeRegister( 40 | editor.registerCommand( 41 | CLICK_COMMAND, 42 | (event: MouseEvent) => { 43 | const hrElem = editor.getElementByKey(props.nodeKey); 44 | 45 | if (event.target === hrElem) { 46 | if (!event.shiftKey) { 47 | clearSelection(); 48 | } 49 | setSelected(!isSelected); 50 | return true; 51 | } 52 | 53 | return false; 54 | }, 55 | COMMAND_PRIORITY_LOW 56 | ) 57 | ) 58 | ); 59 | }); 60 | 61 | createEffect(() => { 62 | const hrElem = editor.getElementByKey(props.nodeKey); 63 | const isSelectedClassName = editor._config.theme.hrSelected ?? "selected"; 64 | if (hrElem !== null) { 65 | if (isSelected()) { 66 | addClassNamesToElement(hrElem, isSelectedClassName); 67 | } else { 68 | removeClassNamesFromElement(hrElem, isSelectedClassName); 69 | } 70 | } 71 | }); 72 | 73 | return null; 74 | } 75 | 76 | export class HorizontalRuleNode extends DecoratorNode { 77 | static getType(): string { 78 | return "horizontalrule"; 79 | } 80 | 81 | static clone(node: HorizontalRuleNode): HorizontalRuleNode { 82 | return new HorizontalRuleNode(node.__key); 83 | } 84 | 85 | static importJSON( 86 | serializedNode: SerializedHorizontalRuleNode 87 | ): HorizontalRuleNode { 88 | return $createHorizontalRuleNode().updateFromJSON(serializedNode); 89 | } 90 | 91 | static importDOM(): DOMConversionMap | null { 92 | return { 93 | hr: () => ({ 94 | conversion: $convertHorizontalRuleElement, 95 | priority: 0, 96 | }), 97 | }; 98 | } 99 | 100 | exportDOM(): DOMExportOutput { 101 | return { element: document.createElement("hr") }; 102 | } 103 | 104 | createDOM(config: EditorConfig): HTMLElement { 105 | const element = document.createElement("hr"); 106 | addClassNamesToElement(element, config.theme.hr); 107 | return element; 108 | } 109 | 110 | getTextContent(): string { 111 | return "\n"; 112 | } 113 | 114 | isInline(): false { 115 | return false; 116 | } 117 | 118 | updateDOM(): boolean { 119 | return false; 120 | } 121 | 122 | decorate(): JSX.Element { 123 | return ; 124 | } 125 | } 126 | 127 | function $convertHorizontalRuleElement(): DOMConversionOutput { 128 | return { node: $createHorizontalRuleNode() }; 129 | } 130 | 131 | export function $createHorizontalRuleNode(): HorizontalRuleNode { 132 | return $applyNodeReplacement(new HorizontalRuleNode()); 133 | } 134 | 135 | export function $isHorizontalRuleNode( 136 | node: LexicalNode | undefined | null 137 | ): boolean { 138 | return node instanceof HorizontalRuleNode; 139 | } 140 | -------------------------------------------------------------------------------- /lexical-solid/src/LexicalHorizontalRulePlugin.tsx: -------------------------------------------------------------------------------- 1 | import { useLexicalComposerContext } from "./LexicalComposerContext"; 2 | import { 3 | $createHorizontalRuleNode, 4 | INSERT_HORIZONTAL_RULE_COMMAND, 5 | } from "./LexicalHorizontalRuleNode"; 6 | import { $insertNodeToNearestRoot } from "@lexical/utils"; 7 | import { 8 | $getSelection, 9 | $isRangeSelection, 10 | COMMAND_PRIORITY_EDITOR, 11 | } from "lexical"; 12 | import { onCleanup } from "solid-js"; 13 | 14 | export function HorizontalRulePlugin(): null { 15 | const [editor] = useLexicalComposerContext(); 16 | 17 | onCleanup( 18 | editor.registerCommand( 19 | INSERT_HORIZONTAL_RULE_COMMAND, 20 | (type) => { 21 | const selection = $getSelection(); 22 | 23 | if (!$isRangeSelection(selection)) { 24 | return false; 25 | } 26 | 27 | const focusNode = selection.focus.getNode(); 28 | 29 | if (focusNode !== null) { 30 | const horizontalRuleNode = $createHorizontalRuleNode(); 31 | $insertNodeToNearestRoot(horizontalRuleNode); 32 | } 33 | 34 | return true; 35 | }, 36 | COMMAND_PRIORITY_EDITOR 37 | ) 38 | ); 39 | 40 | return null; 41 | } 42 | -------------------------------------------------------------------------------- /lexical-solid/src/LexicalLinkPlugin.tsx: -------------------------------------------------------------------------------- 1 | import { 2 | $toggleLink, 3 | LinkAttributes, 4 | LinkNode, 5 | TOGGLE_LINK_COMMAND, 6 | } from "@lexical/link"; 7 | import { useLexicalComposerContext } from "./LexicalComposerContext"; 8 | import { mergeRegister, objectKlassEquals } from "@lexical/utils"; 9 | import { 10 | $getSelection, 11 | $isElementNode, 12 | $isRangeSelection, 13 | COMMAND_PRIORITY_LOW, 14 | PASTE_COMMAND, 15 | } from "lexical"; 16 | import { createEffect, on, onCleanup } from "solid-js"; 17 | 18 | type Props = { 19 | validateUrl?: (url: string) => boolean; 20 | attributes?: LinkAttributes; 21 | }; 22 | 23 | export function LinkPlugin(props: Props): null { 24 | const [editor] = useLexicalComposerContext(); 25 | 26 | createEffect( 27 | on( 28 | () => [editor, props.validateUrl], 29 | () => { 30 | if (!editor.hasNodes([LinkNode])) { 31 | throw new Error("LinkPlugin: LinkNode not registered on editor"); 32 | } 33 | const validateUrl = props.validateUrl; 34 | onCleanup( 35 | mergeRegister( 36 | editor.registerCommand( 37 | TOGGLE_LINK_COMMAND, 38 | (payload) => { 39 | if (payload === null) { 40 | $toggleLink(payload); 41 | return true; 42 | } else if (typeof payload === "string") { 43 | if (validateUrl === undefined || validateUrl(payload)) { 44 | $toggleLink(payload, props.attributes); 45 | return true; 46 | } 47 | return false; 48 | } else { 49 | const { url, target, rel, title } = payload; 50 | $toggleLink(url, { ...props.attributes, rel, target, title }); 51 | return true; 52 | } 53 | }, 54 | COMMAND_PRIORITY_LOW 55 | ), 56 | validateUrl !== undefined 57 | ? editor.registerCommand( 58 | PASTE_COMMAND, 59 | (event) => { 60 | const selection = $getSelection(); 61 | if ( 62 | !$isRangeSelection(selection) || 63 | selection.isCollapsed() || 64 | !objectKlassEquals(event, ClipboardEvent) 65 | ) { 66 | return false; 67 | } 68 | if (event.clipboardData === null) { 69 | return false; 70 | } 71 | const clipboardText = event.clipboardData.getData("text"); 72 | if (!validateUrl(clipboardText)) { 73 | return false; 74 | } 75 | // If we select nodes that are elements then avoid applying the link. 76 | if ( 77 | !selection.getNodes().some((node) => $isElementNode(node)) 78 | ) { 79 | editor.dispatchCommand(TOGGLE_LINK_COMMAND, { 80 | ...props.attributes, 81 | url: clipboardText, 82 | }); 83 | event.preventDefault(); 84 | return true; 85 | } 86 | return false; 87 | }, 88 | COMMAND_PRIORITY_LOW 89 | ) 90 | : () => { 91 | // Don't paste arbitrary text as a link when there's no validate function 92 | } 93 | ) 94 | ); 95 | } 96 | ) 97 | ); 98 | 99 | return null; 100 | } 101 | -------------------------------------------------------------------------------- /lexical-solid/src/LexicalListPlugin.tsx: -------------------------------------------------------------------------------- 1 | import { ListItemNode, registerListStrictIndentTransform } from "@lexical/list"; 2 | import { ListNode } from "@lexical/list"; 3 | import { createEffect, mergeProps, onCleanup } from "solid-js"; 4 | import { useLexicalComposerContext } from "./LexicalComposerContext"; 5 | 6 | import { useList } from "./shared/useList"; 7 | 8 | export interface ListPluginProps { 9 | /** 10 | * When `true`, enforces strict indentation rules for list items, ensuring consistent structure. 11 | * When `false` (default), indentation is more flexible. 12 | */ 13 | hasStrictIndent?: boolean; 14 | } 15 | 16 | export function ListPlugin(props: ListPluginProps): null { 17 | props = mergeProps({ hasStrictIndent: false }, props); 18 | const [editor] = useLexicalComposerContext(); 19 | 20 | createEffect(() => { 21 | if (!editor.hasNodes([ListNode, ListItemNode])) { 22 | throw new Error( 23 | "ListPlugin: ListNode and/or ListItemNode not registered on editor" 24 | ); 25 | } 26 | }); 27 | 28 | createEffect(() => { 29 | if (!props.hasStrictIndent) { 30 | return; 31 | } 32 | onCleanup(registerListStrictIndentTransform(editor)); 33 | }); 34 | 35 | useList(editor); 36 | 37 | return null; 38 | } 39 | -------------------------------------------------------------------------------- /lexical-solid/src/LexicalMarkdownShortcutPlugin.tsx: -------------------------------------------------------------------------------- 1 | import type { ElementTransformer, Transformer } from "@lexical/markdown"; 2 | import type { LexicalNode } from "lexical"; 3 | 4 | import { registerMarkdownShortcuts, TRANSFORMERS } from "@lexical/markdown"; 5 | import { useLexicalComposerContext } from "./LexicalComposerContext"; 6 | 7 | import { 8 | $createHorizontalRuleNode, 9 | $isHorizontalRuleNode, 10 | HorizontalRuleNode, 11 | } from "./LexicalHorizontalRuleNode"; 12 | import { createEffect, mergeProps, onCleanup } from "solid-js"; 13 | 14 | const HR: ElementTransformer = { 15 | dependencies: [HorizontalRuleNode], 16 | export: (node: LexicalNode) => { 17 | return $isHorizontalRuleNode(node) ? "***" : null; 18 | }, 19 | regExp: /^(---|\*\*\*|___)\s?$/, 20 | replace: (parentNode, _1, _2, isImport) => { 21 | const line = $createHorizontalRuleNode(); 22 | 23 | // TODO: Get rid of isImport flag 24 | if (isImport || parentNode.getNextSibling() != null) { 25 | parentNode.replace(line); 26 | } else { 27 | parentNode.insertBefore(line); 28 | } 29 | 30 | line.selectNext(); 31 | }, 32 | type: "element", 33 | }; 34 | 35 | export const DEFAULT_TRANSFORMERS = [HR, ...TRANSFORMERS]; 36 | 37 | export function LexicalMarkdownShortcutPlugin( 38 | props: Readonly<{ transformers?: Transformer[] }> 39 | ): null { 40 | props = mergeProps({ transformers: DEFAULT_TRANSFORMERS }, props); 41 | const [editor] = useLexicalComposerContext(); 42 | createEffect(() => { 43 | const cleanup = registerMarkdownShortcuts(editor, props.transformers); 44 | onCleanup(cleanup); 45 | }); 46 | return null; 47 | } 48 | -------------------------------------------------------------------------------- /lexical-solid/src/LexicalNestedComposer.tsx: -------------------------------------------------------------------------------- 1 | import { 2 | LexicalComposerContextType, 3 | createLexicalComposerContext, 4 | LexicalComposerContext, 5 | } from "./LexicalComposerContext"; 6 | import { useCollaborationContext } from "./LexicalCollaborationContext"; 7 | import { 8 | EditorThemeClasses, 9 | Klass, 10 | LexicalEditor, 11 | LexicalNode, 12 | LexicalNodeReplacement, 13 | } from "lexical"; 14 | import type { EditableListener, KlassConstructor, Transform } from "lexical"; 15 | import { createEffect, JSX, onCleanup, useContext } from "solid-js"; 16 | import warnOnlyOnce from "./shared/warnOnlyOnce"; 17 | 18 | function getTransformSetFromKlass( 19 | klass: KlassConstructor 20 | ): Set> { 21 | const transform = klass.transform(); 22 | return new Set(transform ? [transform] : []); 23 | } 24 | 25 | export interface LexicalNestedComposerProps { 26 | /** 27 | * Any children (e.g. plug-ins) for this editor. Note that the nested editor 28 | * does not inherit any plug-ins or registrations from those plug-ins (such 29 | * as transforms and command listeners that may be necessary for correct 30 | * operation of those nodes) from the parent editor. If you are using nodes 31 | * that require plug-ins they must also be instantiated here. 32 | */ 33 | children?: JSX.Element; 34 | /** 35 | * The nested editor, created outside of this component (typically in the 36 | * implementation of a LexicalNode) with {@link createEditor} 37 | */ 38 | initialEditor: LexicalEditor; 39 | /** 40 | * Optionally overwrite the theme of the initialEditor 41 | */ 42 | initialTheme?: EditorThemeClasses; 43 | /** 44 | * @deprecated This feature is not safe or correctly implemented and will be 45 | * removed in v0.32.0. The only correct time to configure the nodes is when 46 | * creating the initialEditor. 47 | * 48 | * @example 49 | * ```ts 50 | * // This is normally in the implementation of a LexicalNode that 51 | * // owns the nested editor 52 | * editor = createEditor({nodes: [], parentEditor: $getEditor()}); 53 | * ``` 54 | */ 55 | initialNodes?: ReadonlyArray | LexicalNodeReplacement>; 56 | /** 57 | * If this is not explicitly set to true, and the collab plugin is active, 58 | * rendering the children of this component will not happen until collab is ready. 59 | */ 60 | skipCollabChecks?: undefined | true; 61 | /** 62 | * If this is not explicitly set to true, the editable state of the nested 63 | * editor will automatically follow the parent editor's editable state. 64 | * When set to true, the nested editor is responsible for managing its own 65 | * editable state. 66 | * 67 | * Available since v0.29.0 68 | */ 69 | skipEditableListener?: undefined | true; 70 | } 71 | 72 | const initialNodesWarning = warnOnlyOnce( 73 | `LexicalNestedComposer initialNodes is deprecated and will be removed in v0.32.0, it has never worked correctly.\nYou can configure your editor's nodes with createEditor({nodes: [], parentEditor: $getEditor()})` 74 | ); 75 | const explicitNamespaceWarning = warnOnlyOnce( 76 | `LexicalNestedComposer initialEditor should explicitly initialize its namespace when the node configuration differs from the parentEditor. For backwards compatibility, the namespace will be initialized from parentEditor until v0.32.0, but this has always had incorrect copy/paste behavior when the configuration differed.\nYou can configure your editor's namespace with createEditor({namespace: 'nested-editor-namespace', nodes: [], parentEditor: $getEditor()}).` 77 | ); 78 | 79 | export function LexicalNestedComposer( 80 | props: LexicalNestedComposerProps 81 | ): JSX.Element { 82 | let wasCollabPreviouslyReadyRef = false; 83 | const parentContext = useContext(LexicalComposerContext); 84 | 85 | if (parentContext == null) { 86 | throw Error("Unexpected parent context null on a nested composer"); 87 | } 88 | 89 | const [parentEditor, { getTheme: getParentTheme }] = parentContext; 90 | 91 | const composerTheme: EditorThemeClasses | undefined = 92 | props.initialTheme || getParentTheme() || undefined; 93 | 94 | const context: LexicalComposerContextType = createLexicalComposerContext( 95 | parentContext, 96 | composerTheme 97 | ); 98 | 99 | const initialEditor = props.initialEditor; 100 | 101 | if (composerTheme !== undefined) { 102 | initialEditor._config.theme = composerTheme; 103 | } 104 | 105 | initialEditor._parentEditor = initialEditor._parentEditor || parentEditor; 106 | const createEditorArgs = initialEditor._createEditorArgs; 107 | const explicitNamespace = createEditorArgs && createEditorArgs.namespace; 108 | 109 | if (!props.initialNodes) { 110 | if (!(createEditorArgs && createEditorArgs.nodes)) { 111 | const parentNodes = (props.initialEditor._nodes = new Map( 112 | parentEditor._nodes 113 | )); 114 | if (!explicitNamespace) { 115 | // This is the only safe situation to inherit the parent's namespace 116 | initialEditor._config.namespace = parentEditor._config.namespace; 117 | } 118 | for (const [type, entry] of parentNodes) { 119 | initialEditor._nodes.set(type, { 120 | exportDOM: entry.exportDOM, 121 | klass: entry.klass, 122 | replace: entry.replace, 123 | replaceWithKlass: entry.replaceWithKlass, 124 | transforms: getTransformSetFromKlass(entry.klass), 125 | }); 126 | } 127 | } else if (!explicitNamespace) { 128 | initialNodesWarning(); 129 | if (!explicitNamespace) { 130 | explicitNamespaceWarning(); 131 | initialEditor._config.namespace = parentEditor._config.namespace; 132 | } 133 | } 134 | } else { 135 | for (let klass of props.initialNodes) { 136 | let replace = null; 137 | let replaceWithKlass = null; 138 | 139 | if (typeof klass !== "function") { 140 | const options = klass; 141 | klass = options.replace; 142 | replace = options.with; 143 | replaceWithKlass = options.withKlass || null; 144 | } 145 | const registeredKlass = props.initialEditor._nodes.get(klass.getType()); 146 | props.initialEditor._nodes.set(klass.getType(), { 147 | exportDOM: registeredKlass ? registeredKlass.exportDOM : undefined, 148 | klass, 149 | replace, 150 | replaceWithKlass, 151 | transforms: getTransformSetFromKlass(klass), 152 | }); 153 | } 154 | } 155 | 156 | // If collaboration is enabled, make sure we don't render the children until the collaboration subdocument is ready. 157 | const { isCollabActive, yjsDocMap } = useCollaborationContext(); 158 | 159 | const isCollabReady = () => 160 | props.skipCollabChecks || 161 | wasCollabPreviouslyReadyRef || 162 | yjsDocMap.has(props.initialEditor.getKey()); 163 | 164 | createEffect(() => { 165 | if (isCollabReady()) { 166 | wasCollabPreviouslyReadyRef = true; 167 | } 168 | }); 169 | 170 | // Update `isEditable` state of nested editor in response to the same change on parent editor. 171 | createEffect(() => { 172 | if (!props.skipEditableListener) { 173 | const editableListener: EditableListener = (editable) => 174 | initialEditor.setEditable(editable); 175 | editableListener(parentEditor.isEditable()); 176 | onCleanup(parentEditor.registerEditableListener(editableListener)); 177 | } 178 | }); 179 | 180 | return ( 181 | 182 | {!isCollabActive || isCollabReady() ? props.children : null} 183 | 184 | ); 185 | } 186 | -------------------------------------------------------------------------------- /lexical-solid/src/LexicalNodeEventPlugin.tsx: -------------------------------------------------------------------------------- 1 | import type { Klass, LexicalEditor, LexicalNode, NodeKey } from "lexical"; 2 | 3 | import { useLexicalComposerContext } from "./LexicalComposerContext"; 4 | import { $findMatchingParent } from "@lexical/utils"; 5 | import { $getNearestNodeFromDOMNode } from "lexical"; 6 | import { createEffect, untrack } from "solid-js"; 7 | 8 | const capturedEvents = new Set(["mouseenter", "mouseleave"]); 9 | 10 | export function NodeEventPlugin(props: { 11 | nodeType: Klass; 12 | eventType: string; 13 | eventListener: ( 14 | event: Event, 15 | editor: LexicalEditor, 16 | nodeKey: NodeKey 17 | ) => void; 18 | }): null { 19 | const [editor] = useLexicalComposerContext(); 20 | 21 | createEffect(() => { 22 | const eventType = untrack(() => props.eventType); 23 | const isCaptured = capturedEvents.has(eventType); 24 | 25 | const onEvent = (event: Event) => { 26 | editor.update(() => { 27 | const nearestNode = $getNearestNodeFromDOMNode(event.target as Element); 28 | if (nearestNode !== null) { 29 | const targetNode = isCaptured 30 | ? nearestNode instanceof props.nodeType 31 | ? nearestNode 32 | : null 33 | : $findMatchingParent( 34 | nearestNode, 35 | (node) => node instanceof props.nodeType 36 | ); 37 | if (targetNode !== null) { 38 | props.eventListener(event, editor, targetNode.getKey()); 39 | return; 40 | } 41 | } 42 | }); 43 | }; 44 | 45 | return editor.registerRootListener((rootElement, prevRootElement) => { 46 | if (rootElement) { 47 | rootElement.addEventListener(eventType, onEvent, isCaptured); 48 | } 49 | 50 | if (prevRootElement) { 51 | prevRootElement.removeEventListener(eventType, onEvent, isCaptured); 52 | } 53 | }); 54 | }); 55 | 56 | return null; 57 | } 58 | -------------------------------------------------------------------------------- /lexical-solid/src/LexicalNodeMenuPlugin.tsx: -------------------------------------------------------------------------------- 1 | import type { MenuRenderFn, MenuResolution } from "./shared/LexicalMenu"; 2 | 3 | import { useLexicalComposerContext } from "./LexicalComposerContext"; 4 | import { 5 | $getNodeByKey, 6 | COMMAND_PRIORITY_LOW, 7 | CommandListenerPriority, 8 | NodeKey, 9 | TextNode, 10 | } from "lexical"; 11 | import { 12 | type JSX, 13 | createSignal, 14 | createEffect, 15 | startTransition, 16 | Show, 17 | } from "solid-js"; 18 | 19 | import { 20 | LexicalMenu, 21 | MenuOption, 22 | useMenuAnchorRef, 23 | } from "./shared/LexicalMenu"; 24 | 25 | export type NodeMenuPluginProps = { 26 | onSelectOption: ( 27 | option: TOption, 28 | textNodeContainingQuery: TextNode | null, 29 | closeMenu: () => void, 30 | matchingString: string 31 | ) => void; 32 | options: Array; 33 | nodeKey: NodeKey | null; 34 | onClose?: () => void; 35 | onOpen?: (resolution: MenuResolution) => void; 36 | menuRenderFn: MenuRenderFn; 37 | anchorClassName?: string; 38 | commandPriority?: CommandListenerPriority; 39 | parent?: HTMLElement; 40 | }; 41 | 42 | export function LexicalNodeMenuPlugin( 43 | props: NodeMenuPluginProps 44 | ): JSX.Element | null { 45 | const [editor] = useLexicalComposerContext(); 46 | const [resolution, setResolution] = createSignal(null); 47 | const anchorElementRef = useMenuAnchorRef( 48 | resolution, 49 | setResolution, 50 | props.anchorClassName, 51 | props.parent 52 | ); 53 | 54 | const closeNodeMenu = () => { 55 | if (props.onClose != null && resolution() !== null) { 56 | props.onClose(); 57 | } 58 | setResolution(null); 59 | }; 60 | 61 | const openNodeMenu = (res: MenuResolution) => { 62 | if (props.onOpen != null && resolution() === null) { 63 | props.onOpen(res); 64 | } 65 | setResolution(res); 66 | }; 67 | 68 | const positionOrCloseMenu = () => { 69 | if (props.nodeKey) { 70 | editor.update(() => { 71 | const node = $getNodeByKey(props.nodeKey!); 72 | const domElement = editor.getElementByKey(props.nodeKey!); 73 | if (node != null && domElement != null) { 74 | if (resolution == null) { 75 | startTransition(() => 76 | openNodeMenu({ 77 | getRect: () => domElement.getBoundingClientRect(), 78 | }) 79 | ); 80 | } 81 | } 82 | }); 83 | } else if (props.nodeKey == null && resolution() != null) { 84 | closeNodeMenu(); 85 | } 86 | }; 87 | 88 | createEffect(positionOrCloseMenu); 89 | 90 | createEffect(() => { 91 | if (props.nodeKey != null) { 92 | return editor.registerUpdateListener(({ dirtyElements }) => { 93 | if (dirtyElements.get(props.nodeKey!)) { 94 | positionOrCloseMenu(); 95 | } 96 | }); 97 | } 98 | }); 99 | 100 | return ( 101 | 106 | 116 | 117 | ); 118 | } 119 | 120 | export { MenuOption, MenuRenderFn, MenuResolution }; 121 | -------------------------------------------------------------------------------- /lexical-solid/src/LexicalOnChangePlugin.tsx: -------------------------------------------------------------------------------- 1 | import type { EditorState, LexicalEditor } from "lexical"; 2 | import { createEffect, mergeProps, onCleanup } from "solid-js"; 3 | import { useLexicalComposerContext } from "./LexicalComposerContext"; 4 | 5 | export function OnChangePlugin(props: { 6 | ignoreHistoryMergeTagChange?: boolean; 7 | onChange?: ( 8 | editorState: EditorState, 9 | tags: Set, 10 | editor: LexicalEditor 11 | ) => void; 12 | ignoreSelectionChange?: boolean; 13 | }) { 14 | props = mergeProps( 15 | { 16 | ignoreSelectionChange: false, 17 | ignoreHistoryMergeTagChange: true, 18 | }, 19 | props 20 | ); 21 | const [editor] = useLexicalComposerContext(); 22 | createEffect(() => { 23 | if (props.onChange) { 24 | onCleanup( 25 | editor.registerUpdateListener( 26 | ({ 27 | editorState, 28 | dirtyElements, 29 | dirtyLeaves, 30 | prevEditorState, 31 | tags, 32 | }) => { 33 | if ( 34 | (props.ignoreSelectionChange && 35 | dirtyElements.size === 0 && 36 | dirtyLeaves.size === 0) || 37 | (props.ignoreHistoryMergeTagChange && 38 | tags.has("history-merge")) || 39 | prevEditorState.isEmpty() 40 | ) { 41 | return; 42 | } 43 | 44 | props.onChange!(editorState, tags, editor); 45 | } 46 | ) 47 | ); 48 | } 49 | }); 50 | return null; 51 | } 52 | -------------------------------------------------------------------------------- /lexical-solid/src/LexicalPlainTextPlugin.tsx: -------------------------------------------------------------------------------- 1 | import { useLexicalComposerContext } from "./LexicalComposerContext"; 2 | import { useLexicalEditable } from "./useLexicalEditable"; 3 | import { useCanShowPlaceholder } from "./shared/useCanShowPlaceholder"; 4 | import { ErrorBoundaryType, useDecorators } from "./shared/useDecorators"; 5 | import { usePlainTextSetup } from "./shared/usePlainTextSetup"; 6 | import { createMemo, JSX, Show, untrack } from "solid-js"; 7 | 8 | export function PlainTextPlugin(params: { 9 | contentEditable: JSX.Element; 10 | // TODO Remove. This property is now part of ContentEditable 11 | placeholder?: 12 | | ((isEditable: boolean) => null | JSX.Element) 13 | | null 14 | | JSX.Element; 15 | errorBoundary: ErrorBoundaryType; 16 | }): JSX.Element { 17 | const [editor] = useLexicalComposerContext(); 18 | const decorators = useDecorators(editor, params.errorBoundary); 19 | usePlainTextSetup(editor); 20 | 21 | return ( 22 | <> 23 | {params.contentEditable} 24 | 25 | {decorators} 26 | 27 | ); 28 | } 29 | 30 | type ContentFunction = (isEditable: boolean) => null | JSX.Element; 31 | 32 | // TODO remove 33 | function Placeholder(props: { 34 | content: ContentFunction | null | JSX.Element; 35 | }): null | JSX.Element { 36 | const [editor] = useLexicalComposerContext(); 37 | const showPlaceholder = useCanShowPlaceholder(editor); 38 | const editable = useLexicalEditable(); 39 | const content = createMemo(() => props.content); 40 | 41 | return ( 42 | 43 | 47 | {untrack(() => (content() as ContentFunction)(editable()))} 48 | 49 | 50 | ); 51 | } 52 | -------------------------------------------------------------------------------- /lexical-solid/src/LexicalRichTextPlugin.tsx: -------------------------------------------------------------------------------- 1 | import { useLexicalComposerContext } from "./LexicalComposerContext"; 2 | import { useLexicalEditable } from "./useLexicalEditable"; 3 | import { useCanShowPlaceholder } from "./shared/useCanShowPlaceholder"; 4 | import { ErrorBoundaryType, useDecorators } from "./shared/useDecorators"; 5 | import { useRichTextSetup } from "./shared/useRichTextSetup"; 6 | import { JSX, Show, createMemo } from "solid-js"; 7 | import { untrack } from "solid-js"; 8 | 9 | export function RichTextPlugin(props: { 10 | contentEditable: JSX.Element; 11 | // TODO Remove. This property is now part of ContentEditable 12 | placeholder?: 13 | | ((isEditable: boolean) => null | JSX.Element) 14 | | null 15 | | JSX.Element; 16 | errorBoundary: ErrorBoundaryType; 17 | }): JSX.Element { 18 | const [editor] = useLexicalComposerContext(); 19 | const decorators = useDecorators(editor, props.errorBoundary); 20 | useRichTextSetup(editor); 21 | 22 | return ( 23 | <> 24 | {props.contentEditable} 25 | 26 | {decorators} 27 | 28 | ); 29 | } 30 | 31 | type ContentFunction = (isEditable: boolean) => null | JSX.Element; 32 | 33 | // TODO remove 34 | function Placeholder(props: { 35 | content: ContentFunction | null | JSX.Element; 36 | }): JSX.Element { 37 | const [editor] = useLexicalComposerContext(); 38 | const showPlaceholder = useCanShowPlaceholder(editor); 39 | const editable = useLexicalEditable(); 40 | const content = createMemo(() => props.content); 41 | 42 | return ( 43 | 44 | 48 | {untrack(() => (content() as ContentFunction)(editable()))} 49 | 50 | 51 | ); 52 | } 53 | -------------------------------------------------------------------------------- /lexical-solid/src/LexicalSelectionAlwaysOnDisplay.tsx: -------------------------------------------------------------------------------- 1 | import { createEffect, onCleanup } from "solid-js"; 2 | import { useLexicalComposerContext } from "./LexicalComposerContext"; 3 | import { selectionAlwaysOnDisplay } from "@lexical/utils"; 4 | 5 | export function SelectionAlwaysOnDisplay(): null { 6 | const [editor] = useLexicalComposerContext(); 7 | createEffect(() => { 8 | onCleanup(selectionAlwaysOnDisplay(editor)); 9 | }); 10 | 11 | return null; 12 | } 13 | -------------------------------------------------------------------------------- /lexical-solid/src/LexicalTabIndentationPlugin.tsx: -------------------------------------------------------------------------------- 1 | import { useLexicalComposerContext } from "./LexicalComposerContext"; 2 | import { 3 | $createRangeSelection, 4 | $getSelection, 5 | $isBlockElementNode, 6 | $isRangeSelection, 7 | $normalizeSelection__EXPERIMENTAL, 8 | COMMAND_PRIORITY_CRITICAL, 9 | COMMAND_PRIORITY_EDITOR, 10 | INDENT_CONTENT_COMMAND, 11 | INSERT_TAB_COMMAND, 12 | KEY_TAB_COMMAND, 13 | OUTDENT_CONTENT_COMMAND, 14 | } from "lexical"; 15 | import { createEffect, onCleanup } from "solid-js"; 16 | import type { LexicalCommand, LexicalEditor, RangeSelection } from "lexical"; 17 | import { 18 | $filter, 19 | $getNearestBlockElementAncestorOrThrow, 20 | mergeRegister, 21 | } from "@lexical/utils"; 22 | 23 | function $indentOverTab(selection: RangeSelection): boolean { 24 | // const handled = new Set(); 25 | const nodes = selection.getNodes(); 26 | const canIndentBlockNodes = $filter(nodes, (node) => { 27 | if ($isBlockElementNode(node) && node.canIndent()) { 28 | return node; 29 | } 30 | return null; 31 | }); 32 | // 1. If selection spans across canIndent block nodes: indent 33 | if (canIndentBlockNodes.length > 0) { 34 | return true; 35 | } 36 | // 2. If first (anchor/focus) is at block start: indent 37 | const anchor = selection.anchor; 38 | const focus = selection.focus; 39 | const first = focus.isBefore(anchor) ? focus : anchor; 40 | const firstNode = first.getNode(); 41 | const firstBlock = $getNearestBlockElementAncestorOrThrow(firstNode); 42 | if (firstBlock.canIndent()) { 43 | const firstBlockKey = firstBlock.getKey(); 44 | let selectionAtStart = $createRangeSelection(); 45 | selectionAtStart.anchor.set(firstBlockKey, 0, "element"); 46 | selectionAtStart.focus.set(firstBlockKey, 0, "element"); 47 | selectionAtStart = $normalizeSelection__EXPERIMENTAL(selectionAtStart); 48 | if (selectionAtStart.anchor.is(first)) { 49 | return true; 50 | } 51 | } 52 | // 3. Else: tab 53 | return false; 54 | } 55 | 56 | export function registerTabIndentation( 57 | editor: LexicalEditor, 58 | maxIndent?: number 59 | ) { 60 | return mergeRegister( 61 | editor.registerCommand( 62 | KEY_TAB_COMMAND, 63 | (event) => { 64 | const selection = $getSelection(); 65 | if (!$isRangeSelection(selection)) { 66 | return false; 67 | } 68 | event.preventDefault(); 69 | const command: LexicalCommand = $indentOverTab(selection) 70 | ? event.shiftKey 71 | ? OUTDENT_CONTENT_COMMAND 72 | : INDENT_CONTENT_COMMAND 73 | : INSERT_TAB_COMMAND; 74 | return editor.dispatchCommand(command, undefined); 75 | }, 76 | COMMAND_PRIORITY_EDITOR 77 | ), 78 | 79 | editor.registerCommand( 80 | INDENT_CONTENT_COMMAND, 81 | () => { 82 | if (maxIndent == null) { 83 | return false; 84 | } 85 | 86 | const selection = $getSelection(); 87 | if (!$isRangeSelection(selection)) { 88 | return false; 89 | } 90 | 91 | const indents = selection 92 | .getNodes() 93 | .map((node) => 94 | $getNearestBlockElementAncestorOrThrow(node).getIndent() 95 | ); 96 | 97 | return Math.max(...indents) + 1 >= maxIndent; 98 | }, 99 | COMMAND_PRIORITY_CRITICAL 100 | ) 101 | ); 102 | } 103 | 104 | /** 105 | * This plugin adds the ability to indent content using the tab key. Generally, we don't 106 | * recommend using this plugin as it could negatively affect accessibility for keyboard 107 | * users, causing focus to become trapped within the editor. 108 | */ 109 | export function TabIndentationPlugin(props: { maxIdent?: number }): null { 110 | const [editor] = useLexicalComposerContext(); 111 | 112 | createEffect(() => { 113 | onCleanup(registerTabIndentation(editor, props.maxIdent)); 114 | }); 115 | 116 | return null; 117 | } 118 | -------------------------------------------------------------------------------- /lexical-solid/src/LexicalTableOfContentsPlugin.tsx: -------------------------------------------------------------------------------- 1 | import { useLexicalComposerContext } from "./LexicalComposerContext"; 2 | import { 3 | $isHeadingNode, 4 | HeadingNode, 5 | HeadingTagType, 6 | } from "@lexical/rich-text"; 7 | import { $getNextRightPreorderNode } from "@lexical/utils"; 8 | import { 9 | $getNodeByKey, 10 | $getRoot, 11 | $isElementNode, 12 | ElementNode, 13 | LexicalEditor, 14 | NodeKey, 15 | NodeMutation, 16 | TextNode, 17 | } from "lexical"; 18 | import { Accessor, JSX, createEffect, createSignal, onCleanup } from "solid-js"; 19 | 20 | export type TableOfContentsEntry = [ 21 | key: NodeKey, 22 | text: string, 23 | tag: HeadingTagType 24 | ]; 25 | 26 | function toEntry(heading: HeadingNode): TableOfContentsEntry { 27 | return [heading.getKey(), heading.getTextContent(), heading.getTag()]; 28 | } 29 | 30 | function $insertHeadingIntoTableOfContents( 31 | prevHeading: HeadingNode | null, 32 | newHeading: HeadingNode | null, 33 | currentTableOfContents: Array 34 | ): Array { 35 | if (newHeading === null) { 36 | return currentTableOfContents; 37 | } 38 | const newEntry: TableOfContentsEntry = toEntry(newHeading); 39 | let newTableOfContents: Array = []; 40 | if (prevHeading === null) { 41 | // check if key already exists 42 | if ( 43 | currentTableOfContents.length > 0 && 44 | currentTableOfContents[0][0] === newHeading.__key 45 | ) { 46 | return currentTableOfContents; 47 | } 48 | newTableOfContents = [newEntry, ...currentTableOfContents]; 49 | } else { 50 | for (let i = 0; i < currentTableOfContents.length; i++) { 51 | const key = currentTableOfContents[i][0]; 52 | newTableOfContents.push(currentTableOfContents[i]); 53 | if (key === prevHeading.getKey() && key !== newHeading.getKey()) { 54 | // check if key already exists 55 | if ( 56 | i + 1 < currentTableOfContents.length && 57 | currentTableOfContents[i + 1][0] === newHeading.__key 58 | ) { 59 | return currentTableOfContents; 60 | } 61 | newTableOfContents.push(newEntry); 62 | } 63 | } 64 | } 65 | return newTableOfContents; 66 | } 67 | 68 | function $deleteHeadingFromTableOfContents( 69 | key: NodeKey, 70 | currentTableOfContents: Array 71 | ): Array { 72 | const newTableOfContents = []; 73 | for (const heading of currentTableOfContents) { 74 | if (heading[0] !== key) { 75 | newTableOfContents.push(heading); 76 | } 77 | } 78 | return newTableOfContents; 79 | } 80 | 81 | function $updateHeadingInTableOfContents( 82 | heading: HeadingNode, 83 | currentTableOfContents: Array 84 | ): Array { 85 | const newTableOfContents: Array = []; 86 | for (const oldHeading of currentTableOfContents) { 87 | if (oldHeading[0] === heading.getKey()) { 88 | newTableOfContents.push(toEntry(heading)); 89 | } else { 90 | newTableOfContents.push(oldHeading); 91 | } 92 | } 93 | return newTableOfContents; 94 | } 95 | 96 | /** 97 | * Returns the updated table of contents, placing the given `heading` before the given `prevHeading`. If `prevHeading` 98 | * is undefined, `heading` is placed at the start of table of contents 99 | */ 100 | function $updateHeadingPosition( 101 | prevHeading: HeadingNode | null, 102 | heading: HeadingNode, 103 | currentTableOfContents: Array 104 | ): Array { 105 | const newTableOfContents: Array = []; 106 | const newEntry: TableOfContentsEntry = toEntry(heading); 107 | 108 | if (!prevHeading) { 109 | newTableOfContents.push(newEntry); 110 | } 111 | for (const oldHeading of currentTableOfContents) { 112 | if (oldHeading[0] === heading.getKey()) { 113 | continue; 114 | } 115 | newTableOfContents.push(oldHeading); 116 | if (prevHeading && oldHeading[0] === prevHeading.getKey()) { 117 | newTableOfContents.push(newEntry); 118 | } 119 | } 120 | 121 | return newTableOfContents; 122 | } 123 | 124 | function $getPreviousHeading(node: HeadingNode): HeadingNode | null { 125 | let prevHeading = $getNextRightPreorderNode(node); 126 | while (prevHeading !== null && !$isHeadingNode(prevHeading)) { 127 | prevHeading = $getNextRightPreorderNode(prevHeading); 128 | } 129 | return prevHeading; 130 | } 131 | 132 | type Props = { 133 | children: ( 134 | values: Accessor>, 135 | editor: LexicalEditor 136 | ) => JSX.Element; 137 | }; 138 | 139 | export function TableOfContentsPlugin({ children }: Props): JSX.Element { 140 | const [tableOfContents, setTableOfContents] = createSignal< 141 | Array 142 | >([]); 143 | const [editor] = useLexicalComposerContext(); 144 | createEffect(() => { 145 | // Set table of contents initial state 146 | let currentTableOfContents: Array = []; 147 | editor.getEditorState().read(() => { 148 | const updateCurrentTableOfContents = (node: ElementNode) => { 149 | for (const child of node.getChildren()) { 150 | if ($isHeadingNode(child)) { 151 | currentTableOfContents.push([ 152 | child.getKey(), 153 | child.getTextContent(), 154 | child.getTag(), 155 | ]); 156 | } else if ($isElementNode(child)) { 157 | updateCurrentTableOfContents(child); 158 | } 159 | } 160 | }; 161 | 162 | updateCurrentTableOfContents($getRoot()); 163 | setTableOfContents(currentTableOfContents); 164 | }); 165 | 166 | const removeRootUpdateListener = editor.registerUpdateListener( 167 | ({ editorState, dirtyElements }) => { 168 | editorState.read(() => { 169 | const updateChildHeadings = (node: ElementNode) => { 170 | for (const child of node.getChildren()) { 171 | if ($isHeadingNode(child)) { 172 | const prevHeading = $getPreviousHeading(child); 173 | currentTableOfContents = $updateHeadingPosition( 174 | prevHeading, 175 | child, 176 | currentTableOfContents 177 | ); 178 | setTableOfContents(currentTableOfContents); 179 | } else if ($isElementNode(child)) { 180 | updateChildHeadings(child); 181 | } 182 | } 183 | }; 184 | 185 | // If a node is changes, all child heading positions need to be updated 186 | $getRoot() 187 | .getChildren() 188 | .forEach((node) => { 189 | if ($isElementNode(node) && dirtyElements.get(node.__key)) { 190 | updateChildHeadings(node); 191 | } 192 | }); 193 | }); 194 | } 195 | ); 196 | 197 | // Listen to updates to heading mutations and update state 198 | const removeHeaderMutationListener = editor.registerMutationListener( 199 | HeadingNode, 200 | (mutatedNodes: Map) => { 201 | editor.getEditorState().read(() => { 202 | for (const [nodeKey, mutation] of mutatedNodes) { 203 | if (mutation === "created") { 204 | const newHeading = $getNodeByKey(nodeKey); 205 | if (newHeading !== null) { 206 | const prevHeading = $getPreviousHeading(newHeading); 207 | currentTableOfContents = $insertHeadingIntoTableOfContents( 208 | prevHeading, 209 | newHeading, 210 | currentTableOfContents 211 | ); 212 | } 213 | } else if (mutation === "destroyed") { 214 | currentTableOfContents = $deleteHeadingFromTableOfContents( 215 | nodeKey, 216 | currentTableOfContents 217 | ); 218 | } else if (mutation === "updated") { 219 | const newHeading = $getNodeByKey(nodeKey); 220 | if (newHeading !== null) { 221 | const prevHeading = $getPreviousHeading(newHeading); 222 | currentTableOfContents = $updateHeadingPosition( 223 | prevHeading, 224 | newHeading, 225 | currentTableOfContents 226 | ); 227 | } 228 | } 229 | } 230 | setTableOfContents(currentTableOfContents); 231 | }); 232 | }, 233 | // Initialization is handled separately 234 | { skipInitialization: true } 235 | ); 236 | 237 | // Listen to text node mutation updates 238 | const removeTextNodeMutationListener = editor.registerMutationListener( 239 | TextNode, 240 | (mutatedNodes: Map) => { 241 | editor.getEditorState().read(() => { 242 | for (const [nodeKey, mutation] of mutatedNodes) { 243 | if (mutation === "updated") { 244 | const currNode = $getNodeByKey(nodeKey); 245 | if (currNode !== null) { 246 | const parentNode = currNode.getParentOrThrow(); 247 | if ($isHeadingNode(parentNode)) { 248 | currentTableOfContents = $updateHeadingInTableOfContents( 249 | parentNode, 250 | currentTableOfContents 251 | ); 252 | setTableOfContents(currentTableOfContents); 253 | } 254 | } 255 | } 256 | } 257 | }); 258 | }, 259 | // Initialization is handled separately 260 | { skipInitialization: true } 261 | ); 262 | 263 | onCleanup(() => { 264 | removeHeaderMutationListener(); 265 | removeTextNodeMutationListener(); 266 | removeRootUpdateListener(); 267 | }); 268 | }); 269 | 270 | return children(tableOfContents, editor); 271 | } 272 | -------------------------------------------------------------------------------- /lexical-solid/src/LexicalTablePlugin.tsx: -------------------------------------------------------------------------------- 1 | import { 2 | TableCellNode, 3 | setScrollableTablesActive, 4 | registerTablePlugin, 5 | registerTableSelectionObserver, 6 | registerTableCellUnmergeTransform, 7 | } from "@lexical/table"; 8 | import { useLexicalComposerContext } from "./LexicalComposerContext"; 9 | import { 10 | onCleanup, 11 | onMount, 12 | JSX, 13 | mergeProps, 14 | createEffect, 15 | on, 16 | } from "solid-js"; 17 | 18 | export interface TablePluginProps { 19 | /** 20 | * When `false` (default `true`), merged cell support (colspan and rowspan) will be disabled and all 21 | * tables will be forced into a regular grid with 1x1 table cells. 22 | */ 23 | hasCellMerge?: boolean; 24 | /** 25 | * When `false` (default `true`), the background color of TableCellNode will always be removed. 26 | */ 27 | hasCellBackgroundColor?: boolean; 28 | /** 29 | * When `true` (default `true`), the tab key can be used to navigate table cells. 30 | */ 31 | hasTabHandler?: boolean; 32 | /** 33 | * When `true` (default `false`), tables will be wrapped in a `
` to enable horizontal scrolling 34 | */ 35 | hasHorizontalScroll?: boolean; 36 | } 37 | 38 | /** 39 | * A plugin to enable all of the features of Lexical's TableNode. 40 | * 41 | * @param mergedProps - See type for documentation 42 | * @returns An element to render in your LexicalComposer 43 | */ 44 | export function TablePlugin(props: TablePluginProps): JSX.Element | null { 45 | const mergedProps = mergeProps( 46 | { 47 | hasCellMerge: true, 48 | hasCellBackgroundColor: true, 49 | hasHorizontalScroll: false, 50 | }, 51 | props 52 | ); 53 | const [editor] = useLexicalComposerContext(); 54 | createEffect(() => { 55 | setScrollableTablesActive(editor, mergedProps.hasHorizontalScroll); 56 | }); 57 | 58 | onMount(() => onCleanup(registerTablePlugin(editor))); 59 | 60 | createEffect(() => 61 | onCleanup(registerTableSelectionObserver(editor, props.hasTabHandler)) 62 | ); 63 | 64 | // Unmerge cells when the feature isn't enabled 65 | createEffect(() => { 66 | if (!mergedProps.hasCellMerge) { 67 | onCleanup(registerTableCellUnmergeTransform(editor)); 68 | } 69 | }); 70 | 71 | // Remove cell background color when feature is disabled 72 | createEffect( 73 | on( 74 | () => [mergedProps.hasCellBackgroundColor, mergedProps.hasCellMerge], 75 | () => { 76 | if (mergedProps.hasCellBackgroundColor) { 77 | return; 78 | } 79 | onCleanup( 80 | editor.registerNodeTransform(TableCellNode, (node) => { 81 | if (node.getBackgroundColor() !== null) { 82 | node.setBackgroundColor(null); 83 | } 84 | }) 85 | ); 86 | } 87 | ) 88 | ); 89 | 90 | return null; 91 | } 92 | -------------------------------------------------------------------------------- /lexical-solid/src/LexicalTreeView.tsx: -------------------------------------------------------------------------------- 1 | import type { EditorState, LexicalEditor } from "lexical"; 2 | 3 | import { 4 | CustomPrintNodeFn, 5 | generateContent, 6 | TreeView as TreeViewCore, 7 | useLexicalCommandsLog, 8 | } from "./devtools-core"; 9 | import { mergeRegister } from "@lexical/utils"; 10 | import { JSX } from "solid-js/jsx-runtime"; 11 | import { createEffect, createSignal, onCleanup, onMount } from "solid-js"; 12 | 13 | /** 14 | * TreeView is a React component that provides a visual representation of 15 | * the Lexical editor's state and enables debugging features like time travel 16 | * and custom tree node rendering. 17 | * 18 | * @param {Object} props - The properties passed to the TreeView component. 19 | * @param {LexicalEditor} props.editor - The Lexical editor instance to be visualized and debugged. 20 | * @param {string} [props.treeTypeButtonClassName] - Custom class name for the tree type toggle button. 21 | * @param {string} [props.timeTravelButtonClassName] - Custom class name for the time travel toggle button. 22 | * @param {string} [props.timeTravelPanelButtonClassName] - Custom class name for buttons inside the time travel panel. 23 | * @param {string} [props.timeTravelPanelClassName] - Custom class name for the overall time travel panel container. 24 | * @param {string} [props.timeTravelPanelSliderClassName] - Custom class name for the time travel slider in the panel. 25 | * @param {string} [props.viewClassName] - Custom class name for the tree view container. 26 | * @param {CustomPrintNodeFn} [props.customPrintNode] - A function for customizing the display of nodes in the tree. 27 | * 28 | * @returns {JSX.Element} - A React element that visualizes the editor's state and supports debugging interactions. 29 | */ 30 | 31 | export function TreeView(props: { 32 | editor: LexicalEditor; 33 | treeTypeButtonClassName: string; 34 | timeTravelButtonClassName: string; 35 | timeTravelPanelButtonClassName: string; 36 | timeTravelPanelClassName: string; 37 | timeTravelPanelSliderClassName: string; 38 | viewClassName: string; 39 | customPrintNode?: CustomPrintNodeFn; 40 | }): JSX.Element { 41 | const [treeElementRef, setTreeElementRef] = 42 | createSignal(null); 43 | const [editorCurrentState, setEditorCurrentState] = createSignal( 44 | props.editor.getEditorState() 45 | ); 46 | 47 | const commandsLog = useLexicalCommandsLog(props.editor); 48 | 49 | onMount(() => { 50 | // Registers listeners to update the tree view when the editor state changes 51 | onCleanup( 52 | mergeRegister( 53 | props.editor.registerUpdateListener(({ editorState }) => { 54 | setEditorCurrentState(editorState); 55 | }), 56 | props.editor.registerEditableListener(() => { 57 | setEditorCurrentState(props.editor.getEditorState()); 58 | }) 59 | ) 60 | ); 61 | }); 62 | 63 | createEffect(() => { 64 | const element = treeElementRef(); 65 | 66 | if (element !== null) { 67 | // Assigns the editor instance to the tree view DOM element for internal tracking 68 | // @ts-ignore Internal field used by Lexical 69 | element.__lexicalEditor = props.editor; 70 | 71 | onCleanup(() => { 72 | // Cleans up the reference when the component is unmounted 73 | // @ts-ignore Internal field used by Lexical 74 | element.__lexicalEditor = null; 75 | }); 76 | } 77 | }); 78 | 79 | /** 80 | * Handles toggling the readonly state of the editor. 81 | * 82 | * @param {boolean} isReadonly - Whether the editor should be set to readonly. 83 | */ 84 | const handleEditorReadOnly = (isReadonly: boolean) => { 85 | const rootElement = props.editor.getRootElement(); 86 | if (rootElement == null) { 87 | return; 88 | } 89 | 90 | rootElement.contentEditable = isReadonly ? "false" : "true"; 91 | }; 92 | 93 | return ( 94 | props.editor.setEditorState(state)} 104 | generateContent={async function (exportDOM) { 105 | // Generates the content for the tree view, allowing customization with exportDOM and customPrintNode 106 | return generateContent( 107 | props.editor, 108 | commandsLog(), 109 | exportDOM, 110 | props.customPrintNode 111 | ); 112 | }} 113 | ref={setTreeElementRef} 114 | commandsLog={commandsLog()} 115 | /> 116 | ); 117 | } 118 | -------------------------------------------------------------------------------- /lexical-solid/src/LexicalTypeaheadMenuPlugin.tsx: -------------------------------------------------------------------------------- 1 | import { useLexicalComposerContext } from "./LexicalComposerContext"; 2 | import { 3 | MenuRenderFn, 4 | MenuResolution, 5 | MenuTextMatch, 6 | TriggerFn, 7 | useMenuAnchorRef, 8 | LexicalMenu, 9 | MenuOption, 10 | } from "./shared/LexicalMenu"; 11 | import { 12 | $getSelection, 13 | $isRangeSelection, 14 | $isTextNode, 15 | COMMAND_PRIORITY_LOW, 16 | CommandListenerPriority, 17 | createCommand, 18 | getDOMSelection, 19 | LexicalCommand, 20 | LexicalEditor, 21 | RangeSelection, 22 | TextNode, 23 | } from "lexical"; 24 | import { 25 | createEffect, 26 | on, 27 | createSignal, 28 | Show, 29 | JSX, 30 | startTransition, 31 | onCleanup, 32 | onMount, 33 | } from "solid-js"; 34 | 35 | export type QueryMatch = { 36 | leadOffset: number; 37 | matchingString: string; 38 | replaceableString: string; 39 | }; 40 | 41 | export type Resolution = { 42 | match: QueryMatch; 43 | getRect: () => DOMRect; 44 | }; 45 | 46 | export const PUNCTUATION = 47 | "\\.,\\+\\*\\?\\$\\@\\|#{}\\(\\)\\^\\-\\[\\]\\\\/!%'\"~=<>_:;"; 48 | 49 | const scrollIntoViewIfNeeded = (target: HTMLElement) => { 50 | const typeaheadContainerNode = document.getElementById("typeahead-menu"); 51 | if (!typeaheadContainerNode) return; 52 | 53 | const typeaheadRect = typeaheadContainerNode.getBoundingClientRect(); 54 | 55 | if (typeaheadRect.top + typeaheadRect.height > window.innerHeight) { 56 | typeaheadContainerNode.scrollIntoView({ 57 | block: "center", 58 | }); 59 | } 60 | 61 | if (typeaheadRect.top < 0) { 62 | typeaheadContainerNode.scrollIntoView({ 63 | block: "center", 64 | }); 65 | } 66 | 67 | target.scrollIntoView({ block: "nearest" }); 68 | }; 69 | 70 | function getTextUpToAnchor(selection: RangeSelection): string | null { 71 | const anchor = selection.anchor; 72 | if (anchor.type !== "text") { 73 | return null; 74 | } 75 | const anchorNode = anchor.getNode(); 76 | if (!anchorNode.isSimpleText()) { 77 | return null; 78 | } 79 | const anchorOffset = anchor.offset; 80 | return anchorNode.getTextContent().slice(0, anchorOffset); 81 | } 82 | 83 | function tryToPositionRange( 84 | leadOffset: number, 85 | range: Range, 86 | editorWindow: Window 87 | ): boolean { 88 | const domSelection = getDOMSelection(editorWindow); 89 | if (domSelection === null || !domSelection.isCollapsed) { 90 | return false; 91 | } 92 | const anchorNode = domSelection.anchorNode; 93 | const startOffset = leadOffset; 94 | const endOffset = domSelection.anchorOffset; 95 | 96 | if (anchorNode == null || endOffset == null) { 97 | return false; 98 | } 99 | 100 | try { 101 | range.setStart(anchorNode, startOffset); 102 | range.setEnd(anchorNode, endOffset); 103 | } catch (error) { 104 | return false; 105 | } 106 | 107 | return true; 108 | } 109 | 110 | function getQueryTextForSearch(editor: LexicalEditor): string | null { 111 | let text = null; 112 | editor.getEditorState().read(() => { 113 | const selection = $getSelection(); 114 | if (!$isRangeSelection(selection)) { 115 | return; 116 | } 117 | text = getTextUpToAnchor(selection); 118 | }); 119 | return text; 120 | } 121 | 122 | function isSelectionOnEntityBoundary( 123 | editor: LexicalEditor, 124 | offset: number 125 | ): boolean { 126 | if (offset !== 0) { 127 | return false; 128 | } 129 | return editor.getEditorState().read(() => { 130 | const selection = $getSelection(); 131 | if ($isRangeSelection(selection)) { 132 | const anchor = selection.anchor; 133 | const anchorNode = anchor.getNode(); 134 | const prevSibling = anchorNode.getPreviousSibling(); 135 | return $isTextNode(prevSibling) && prevSibling.isTextEntity(); 136 | } 137 | return false; 138 | }); 139 | } 140 | 141 | // Got from https://stackoverflow.com/a/42543908/2013580 142 | export function getScrollParent( 143 | element: HTMLElement, 144 | includeHidden: boolean 145 | ): HTMLElement | HTMLBodyElement { 146 | let style = getComputedStyle(element); 147 | const excludeStaticParent = style.position === "absolute"; 148 | const overflowRegex = includeHidden 149 | ? /(auto|scroll|hidden)/ 150 | : /(auto|scroll)/; 151 | if (style.position === "fixed") { 152 | return document.body; 153 | } 154 | for ( 155 | let parent: HTMLElement | null = element; 156 | (parent = parent.parentElement); 157 | 158 | ) { 159 | style = getComputedStyle(parent); 160 | if (excludeStaticParent && style.position === "static") { 161 | continue; 162 | } 163 | if ( 164 | overflowRegex.test(style.overflow + style.overflowY + style.overflowX) 165 | ) { 166 | return parent; 167 | } 168 | } 169 | return document.body; 170 | } 171 | 172 | export { useDynamicPositioning } from "./shared/LexicalMenu"; 173 | 174 | export const SCROLL_TYPEAHEAD_OPTION_INTO_VIEW_COMMAND: LexicalCommand<{ 175 | index: number; 176 | option: MenuOption; 177 | }> = createCommand("SCROLL_TYPEAHEAD_OPTION_INTO_VIEW_COMMAND"); 178 | 179 | export function useBasicTypeaheadTriggerMatch( 180 | trigger: string, 181 | { minLength = 1, maxLength = 75 }: { minLength?: number; maxLength?: number } 182 | ): TriggerFn { 183 | return (text: string) => { 184 | const validChars = "[^" + trigger + PUNCTUATION + "\\s]"; 185 | const TypeaheadTriggerRegex = new RegExp( 186 | "(^|\\s|\\()(" + 187 | "[" + 188 | trigger + 189 | "]" + 190 | "((?:" + 191 | validChars + 192 | "){0," + 193 | maxLength + 194 | "})" + 195 | ")$" 196 | ); 197 | const match = TypeaheadTriggerRegex.exec(text); 198 | if (match !== null) { 199 | const maybeLeadingWhitespace = match[1]; 200 | const matchingString = match[3]; 201 | if (matchingString.length >= minLength) { 202 | return { 203 | leadOffset: match.index + maybeLeadingWhitespace.length, 204 | matchingString, 205 | replaceableString: match[2], 206 | }; 207 | } 208 | } 209 | return null; 210 | }; 211 | } 212 | 213 | export type TypeaheadMenuPluginProps = { 214 | onQueryChange: (matchingString: string | null) => void; 215 | onSelectOption: ( 216 | option: TOption, 217 | textNodeContainingQuery: TextNode | null, 218 | closeMenu: () => void, 219 | matchingString: string 220 | ) => void; 221 | options: Array; 222 | menuRenderFn: MenuRenderFn; 223 | triggerFn: TriggerFn; 224 | onOpen?: (resolution: MenuResolution) => void; 225 | onClose?: () => void; 226 | anchorClassName?: string; 227 | commandPriority?: CommandListenerPriority; 228 | parent?: HTMLElement; 229 | preselectFirstItem?: boolean; 230 | }; 231 | 232 | export function LexicalTypeaheadMenuPlugin( 233 | props: TypeaheadMenuPluginProps 234 | ): JSX.Element | null { 235 | const [editor] = useLexicalComposerContext(); 236 | const [resolution, setResolution] = createSignal(null); 237 | const anchorElementRef = useMenuAnchorRef( 238 | resolution, 239 | setResolution, 240 | props.anchorClassName, 241 | props.parent 242 | ); 243 | 244 | const closeTypeahead = () => { 245 | if (props.onClose != null && resolution() !== null) { 246 | props.onClose(); 247 | } 248 | setResolution(null); 249 | }; 250 | 251 | const openTypeahead = (res: MenuResolution) => { 252 | if (props.onOpen != null && resolution() === null) { 253 | props.onOpen(res); 254 | } 255 | setResolution(res); 256 | }; 257 | 258 | createEffect( 259 | on(resolution, () => { 260 | const updateListener = () => { 261 | editor.getEditorState().read(() => { 262 | // Check if editor is in read-only mode 263 | if (!editor.isEditable()) { 264 | closeTypeahead(); 265 | return; 266 | } 267 | const editorWindow = editor._window || window; 268 | const range = editorWindow.document.createRange(); 269 | const selection = $getSelection(); 270 | const text = getQueryTextForSearch(editor); 271 | 272 | if ( 273 | !$isRangeSelection(selection) || 274 | !selection.isCollapsed() || 275 | text === null || 276 | range === null 277 | ) { 278 | closeTypeahead(); 279 | return; 280 | } 281 | 282 | const match = props.triggerFn(text, editor); 283 | props.onQueryChange(match ? match.matchingString : null); 284 | 285 | if ( 286 | match !== null && 287 | !isSelectionOnEntityBoundary(editor, match.leadOffset) 288 | ) { 289 | const isRangePositioned = tryToPositionRange( 290 | match.leadOffset, 291 | range, 292 | editorWindow 293 | ); 294 | if (isRangePositioned !== null) { 295 | startTransition(() => 296 | openTypeahead({ 297 | getRect: () => range.getBoundingClientRect(), 298 | match, 299 | }) 300 | ); 301 | return; 302 | } 303 | } 304 | closeTypeahead(); 305 | }); 306 | }; 307 | 308 | const removeUpdateListener = 309 | editor.registerUpdateListener(updateListener); 310 | 311 | onCleanup(() => { 312 | removeUpdateListener(); 313 | }); 314 | }) 315 | ); 316 | 317 | onMount(() => { 318 | onCleanup( 319 | editor.registerEditableListener((isEditable) => { 320 | if (!isEditable) { 321 | closeTypeahead(); 322 | } 323 | }) 324 | ); 325 | }); 326 | 327 | return ( 328 | 333 | 334 | close={closeTypeahead} 335 | resolution={resolution()!} 336 | editor={editor} 337 | anchorElementRef={anchorElementRef} 338 | options={props.options} 339 | menuRenderFn={props.menuRenderFn} 340 | onSelectOption={props.onSelectOption} 341 | shouldSplitNodeWithQuery={true} 342 | commandPriority={props.commandPriority ?? COMMAND_PRIORITY_LOW} 343 | preselectFirstItem={props.preselectFirstItem ?? true} 344 | /> 345 | 346 | ); 347 | } 348 | 349 | export { MenuOption, MenuRenderFn, MenuResolution, MenuTextMatch, TriggerFn }; 350 | -------------------------------------------------------------------------------- /lexical-solid/src/devtools-core/TreeView.tsx: -------------------------------------------------------------------------------- 1 | import type { EditorSetOptions, EditorState } from "lexical"; 2 | import { JSX, createEffect, createSignal } from "solid-js"; 3 | import type { LexicalCommandLog } from "./useLexicalCommandsLog"; 4 | 5 | const LARGE_EDITOR_STATE_SIZE = 1000; 6 | 7 | export const TreeView = (props: { 8 | editorState: EditorState; 9 | treeTypeButtonClassName?: string; 10 | timeTravelButtonClassName?: string; 11 | timeTravelPanelButtonClassName?: string; 12 | timeTravelPanelClassName?: string; 13 | timeTravelPanelSliderClassName?: string; 14 | viewClassName?: string; 15 | generateContent: (exportDOM: boolean) => Promise; 16 | setEditorState: (state: EditorState, options?: EditorSetOptions) => void; 17 | setEditorReadOnly: (isReadonly: boolean) => void; 18 | commandsLog?: LexicalCommandLog; 19 | ref: (_: HTMLPreElement) => void; 20 | }): JSX.Element => { 21 | const [timeStampedEditorStates, setTimeStampedEditorStates] = createSignal< 22 | Array<[number, EditorState]> 23 | >([]); 24 | const [content, setContent] = createSignal(""); 25 | const [timeTravelEnabled, setTimeTravelEnabled] = createSignal(false); 26 | const [showExportDOM, setShowExportDOM] = createSignal(false); 27 | let playingIndexRef = 0; 28 | let inputRef: HTMLInputElement | undefined; 29 | const [isPlaying, setIsPlaying] = createSignal(false); 30 | const [isLimited, setIsLimited] = createSignal(false); 31 | const [showLimited, setShowLimited] = createSignal(false); 32 | let lastEditorStateRef: EditorState | null | undefined = null; 33 | let lastCommandsLogRef: LexicalCommandLog | null | undefined = []; 34 | let lastGenerationID = 0; 35 | 36 | const generateTree = (exportDOM: boolean) => { 37 | const myID = ++lastGenerationID; 38 | props 39 | .generateContent(exportDOM) 40 | .then((treeText) => { 41 | if (myID === lastGenerationID) { 42 | setContent(treeText); 43 | } 44 | }) 45 | .catch((err) => { 46 | if (myID === lastGenerationID) { 47 | setContent( 48 | `Error rendering tree: ${err.message}\n\nStack:\n${err.stack}` 49 | ); 50 | } 51 | }); 52 | }; 53 | 54 | createEffect(() => { 55 | if ( 56 | !showLimited() && 57 | props.editorState._nodeMap.size > LARGE_EDITOR_STATE_SIZE 58 | ) { 59 | setIsLimited(true); 60 | if (!showLimited()) { 61 | return; 62 | } 63 | } 64 | 65 | // Update view when either editor state changes or new commands are logged 66 | const shouldUpdate = 67 | lastEditorStateRef !== props.editorState || 68 | lastCommandsLogRef !== props.commandsLog; 69 | 70 | if (shouldUpdate) { 71 | // Check if it's a real editor state change 72 | const isEditorStateChange = lastEditorStateRef !== props.editorState; 73 | lastEditorStateRef = props.editorState; 74 | lastCommandsLogRef = props.commandsLog; 75 | generateTree(showExportDOM()); 76 | 77 | // Only record in time travel if there was an actual editor state change 78 | if (!timeTravelEnabled() && isEditorStateChange) { 79 | setTimeStampedEditorStates((currentEditorStates) => [ 80 | ...currentEditorStates, 81 | [Date.now(), props.editorState], 82 | ]); 83 | } 84 | } 85 | }); 86 | 87 | const totalEditorStates = () => timeStampedEditorStates().length; 88 | 89 | createEffect(() => { 90 | if (isPlaying()) { 91 | let timeoutId: ReturnType; 92 | 93 | const play = () => { 94 | const currentIndex = playingIndexRef; 95 | 96 | if (currentIndex === totalEditorStates() - 1) { 97 | setIsPlaying(false); 98 | return; 99 | } 100 | 101 | const currentTime = timeStampedEditorStates()[currentIndex][0]; 102 | const nextTime = timeStampedEditorStates()[currentIndex + 1][0]; 103 | const timeDiff = nextTime - currentTime; 104 | timeoutId = setTimeout(() => { 105 | playingIndexRef++; 106 | const index = playingIndexRef; 107 | const input = inputRef; 108 | 109 | if (input != null) { 110 | input.value = String(index); 111 | } 112 | 113 | props.setEditorState(timeStampedEditorStates()[index][1]); 114 | play(); 115 | }, timeDiff); 116 | }; 117 | 118 | play(); 119 | 120 | return () => { 121 | clearTimeout(timeoutId); 122 | }; 123 | } 124 | }); 125 | 126 | const handleExportModeToggleClick = () => { 127 | generateTree(!showExportDOM()); 128 | setShowExportDOM(!showExportDOM()); 129 | }; 130 | 131 | return ( 132 |
133 | {!showLimited() && isLimited() ? ( 134 |
135 | 136 | Detected large EditorState, this can impact debugging performance. 137 | 138 | 152 |
153 | ) : null} 154 | {!showLimited() ? ( 155 | 162 | ) : null} 163 | {!timeTravelEnabled() && 164 | (showLimited() || !isLimited()) && 165 | totalEditorStates() > 2 && ( 166 | 177 | )} 178 | {(showLimited() || !isLimited()) && ( 179 |
{content()}
180 | )} 181 | {timeTravelEnabled() && (showLimited() || !isLimited()) && ( 182 |
183 | 195 | { 199 | const editorStateIndex = Number(event.target.value); 200 | const timeStampedEditorState = 201 | timeStampedEditorStates()[editorStateIndex]; 202 | 203 | if (timeStampedEditorState) { 204 | playingIndexRef = editorStateIndex; 205 | props.setEditorState(timeStampedEditorState[1]); 206 | } 207 | }} 208 | type="range" 209 | min="1" 210 | max={totalEditorStates() - 1} 211 | /> 212 | 232 |
233 | )} 234 |
235 | ); 236 | }; 237 | -------------------------------------------------------------------------------- /lexical-solid/src/devtools-core/index.ts: -------------------------------------------------------------------------------- 1 | export { CustomPrintNodeFn, generateContent } from "./generateContent"; 2 | export { TreeView } from "./TreeView"; 3 | export { 4 | LexicalCommandLog, 5 | registerLexicalCommandLogger, 6 | useLexicalCommandsLog, 7 | } from "./useLexicalCommandsLog"; 8 | -------------------------------------------------------------------------------- /lexical-solid/src/devtools-core/useLexicalCommandsLog.ts: -------------------------------------------------------------------------------- 1 | import type { LexicalEditor } from "lexical"; 2 | 3 | import { COMMAND_PRIORITY_CRITICAL, LexicalCommand } from "lexical"; 4 | import { Accessor, createSignal, onCleanup, onMount } from "solid-js"; 5 | 6 | export type LexicalCommandLog = ReadonlyArray< 7 | { index: number } & LexicalCommand & { payload: unknown } 8 | >; 9 | 10 | export function registerLexicalCommandLogger( 11 | editor: LexicalEditor, 12 | setLoggedCommands: ( 13 | v: (oldValue: LexicalCommandLog) => LexicalCommandLog 14 | ) => void 15 | ): () => void { 16 | const unregisterCommandListeners = new Set<() => void>(); 17 | let i = 0; 18 | for (const [command] of editor._commands) { 19 | unregisterCommandListeners.add( 20 | editor.registerCommand( 21 | command, 22 | (payload) => { 23 | setLoggedCommands((state) => { 24 | i += 1; 25 | const newState = [...state]; 26 | newState.push({ 27 | index: i, 28 | payload, 29 | type: command.type ? command.type : "UNKNOWN", 30 | }); 31 | 32 | if (newState.length > 10) { 33 | newState.shift(); 34 | } 35 | 36 | return newState; 37 | }); 38 | 39 | return false; 40 | }, 41 | COMMAND_PRIORITY_CRITICAL 42 | ) 43 | ); 44 | } 45 | 46 | return () => unregisterCommandListeners.forEach((unregister) => unregister()); 47 | } 48 | 49 | export function useLexicalCommandsLog( 50 | editor: LexicalEditor 51 | ): Accessor { 52 | const [loggedCommands, setLoggedCommands] = createSignal( 53 | [] 54 | ); 55 | 56 | onMount(() => 57 | onCleanup(registerLexicalCommandLogger(editor, setLoggedCommands)) 58 | ); 59 | 60 | return loggedCommands; 61 | } 62 | -------------------------------------------------------------------------------- /lexical-solid/src/index.tsx: -------------------------------------------------------------------------------- 1 | export * from "./LexicalAutoEmbedPlugin"; 2 | export * from "./LexicalAutoFocusPlugin"; 3 | export * from "./LexicalAutoLinkPlugin"; 4 | export * from "./LexicalBlockWithAlignableContents"; 5 | export * from "./LexicalCharacterLimitPlugin"; 6 | export * from "./LexicalCheckListPlugin"; 7 | export * from "./LexicalClearEditorPlugin"; 8 | export * from "./LexicalClickableLinkPlugin"; 9 | export * from "./LexicalCollaborationContext"; 10 | export * from "./LexicalCollaborationPlugin"; 11 | export * from "./LexicalComposer"; 12 | export * from "./LexicalComposerContext"; 13 | export * from "./LexicalContentEditable"; 14 | export * from "./LexicalContextMenuPlugin"; 15 | export * from "./LexicalDecoratorBlockNode"; 16 | export * from "./LexicalDraggableBlockPlugin"; 17 | export * from "./LexicalEditorRefPlugin"; 18 | export * from "./LexicalErrorBoundary"; 19 | export * from "./LexicalHashTagPlugin"; 20 | export * from "./LexicalHistoryPlugin"; 21 | export * from "./LexicalHorizontalRuleNode"; 22 | export * from "./LexicalHorizontalRulePlugin"; 23 | export * from "./LexicalLinkPlugin"; 24 | export * from "./LexicalListPlugin"; 25 | export * from "./LexicalMarkdownShortcutPlugin"; 26 | export * from "./LexicalNestedComposer"; 27 | export * from "./LexicalNodeEventPlugin"; 28 | export * from "./LexicalNodeMenuPlugin"; 29 | export * from "./LexicalOnChangePlugin"; 30 | export * from "./LexicalPlainTextPlugin"; 31 | export * from "./LexicalRichTextPlugin"; 32 | export * from "./LexicalSelectionAlwaysOnDisplay"; 33 | export * from "./LexicalTabIndentationPlugin"; 34 | export * from "./LexicalTableOfContentsPlugin"; 35 | export * from "./LexicalTablePlugin"; 36 | export * from "./LexicalTreeView"; 37 | export * from "./LexicalTypeaheadMenuPlugin"; 38 | export * from "./useLexicalEditable"; 39 | export * from "./useLexicalIsContentEmpty"; 40 | export * from "./useLexicalNodeSelection"; 41 | export * from "./useLexicalSubscription"; 42 | export * from "./useLexicalTextEntity"; 43 | -------------------------------------------------------------------------------- /lexical-solid/src/shared/LexicalContentEditableElement.tsx: -------------------------------------------------------------------------------- 1 | import type { LexicalEditor } from "lexical"; 2 | 3 | import { mergeRefs } from "./mergeRefs"; 4 | import { JSX } from "solid-js/jsx-runtime"; 5 | import { 6 | createEffect, 7 | createSignal, 8 | mergeProps, 9 | onCleanup, 10 | onMount, 11 | splitProps, 12 | } from "solid-js"; 13 | import { useLexicalComposerContext } from "../LexicalComposerContext"; 14 | 15 | export type ContentEditableElementProps = { 16 | editor: LexicalEditor; 17 | ariaActiveDescendant?: JSX.HTMLAttributes["aria-activedescendant"]; 18 | ariaAutoComplete?: JSX.HTMLAttributes["aria-autocomplete"]; 19 | ariaControls?: JSX.HTMLAttributes["aria-controls"]; 20 | ariaDescribedBy?: JSX.HTMLAttributes["aria-describedby"]; 21 | ariaErrorMessage?: JSX.HTMLAttributes["aria-errormessage"]; 22 | ariaExpanded?: JSX.HTMLAttributes["aria-expanded"]; 23 | ariaInvalid?: JSX.HTMLAttributes["aria-invalid"]; 24 | ariaLabel?: JSX.HTMLAttributes["aria-label"]; 25 | ariaLabelledBy?: JSX.HTMLAttributes["aria-labelledby"]; 26 | ariaMultiline?: JSX.HTMLAttributes["aria-multiline"]; 27 | ariaOwns?: JSX.HTMLAttributes["aria-owns"]; 28 | ariaRequired?: JSX.HTMLAttributes["aria-required"]; 29 | autoCapitalize?: HTMLDivElement["autocapitalize"]; 30 | "data-testid"?: string | null | undefined; 31 | } & Omit, "placeholder">; 32 | 33 | export function ContentEditableElement(props: ContentEditableElementProps): JSX.Element { 34 | props = mergeProps( 35 | { 36 | role: "textbox" as JSX.HTMLAttributes["role"], 37 | spellCheck: true, 38 | }, 39 | props 40 | ); 41 | const [, rest] = splitProps(props, [ 42 | "ariaActiveDescendant", 43 | "ariaAutoComplete", 44 | "ariaControls", 45 | "ariaDescribedBy", 46 | "ariaExpanded", 47 | "ariaLabel", 48 | "ariaLabelledBy", 49 | "ariaMultiline", 50 | "ariaOwns", 51 | "ariaRequired", 52 | "autoCapitalize", 53 | "class", 54 | "data-testid", 55 | "id", 56 | "ref", 57 | "role", 58 | "spellcheck", 59 | "style", 60 | "tabIndex", 61 | ]); 62 | const [editor] = useLexicalComposerContext(); 63 | const [isEditable, setEditable] = createSignal(editor.isEditable()); 64 | 65 | const handleRef = (rootElement: null | HTMLElement) => { 66 | // onMount is used here because we want to make sure `rootElement.ownerDocument.defaultView` is defined. 67 | onMount(() => { 68 | // defaultView is required for a root element. 69 | // In multi-window setups, the defaultView may not exist at certain points. 70 | if ( 71 | rootElement && 72 | rootElement.ownerDocument && 73 | rootElement.ownerDocument.defaultView 74 | ) { 75 | editor.setRootElement(rootElement); 76 | } else { 77 | editor.setRootElement(null); 78 | } 79 | }); 80 | }; 81 | 82 | createEffect(() => { 83 | setEditable(editor.isEditable()); 84 | onCleanup( 85 | editor.registerEditableListener((currentIsEditable) => { 86 | setEditable(currentIsEditable); 87 | }) 88 | ); 89 | }); 90 | 91 | return ( 92 |
void, handleRef)} 124 | role={isEditable() ? props.role : undefined} 125 | spellcheck={props.spellcheck} 126 | style={props.style} 127 | tabIndex={props.tabIndex} 128 | {...rest} 129 | /> 130 | ); 131 | } 132 | -------------------------------------------------------------------------------- /lexical-solid/src/shared/mergeRefs.ts: -------------------------------------------------------------------------------- 1 | export function mergeRefs( 2 | ...refs: Array<(arg: T) => void | undefined | null> 3 | ): (arg: T) => void { 4 | return (value) => { 5 | refs.forEach((ref) => ref(value)); 6 | }; 7 | } 8 | -------------------------------------------------------------------------------- /lexical-solid/src/shared/point.ts: -------------------------------------------------------------------------------- 1 | export class Point { 2 | private readonly _x: number; 3 | private readonly _y: number; 4 | 5 | constructor(x: number, y: number) { 6 | this._x = x; 7 | this._y = y; 8 | } 9 | 10 | get x(): number { 11 | return this._x; 12 | } 13 | 14 | get y(): number { 15 | return this._y; 16 | } 17 | 18 | public equals({ x, y }: Point): boolean { 19 | return this.x === x && this.y === y; 20 | } 21 | 22 | public calcDeltaXTo({ x }: Point): number { 23 | return this.x - x; 24 | } 25 | 26 | public calcDeltaYTo({ y }: Point): number { 27 | return this.y - y; 28 | } 29 | 30 | public calcHorizontalDistanceTo(point: Point): number { 31 | return Math.abs(this.calcDeltaXTo(point)); 32 | } 33 | 34 | public calcVerticalDistance(point: Point): number { 35 | return Math.abs(this.calcDeltaYTo(point)); 36 | } 37 | 38 | public calcDistanceTo(point: Point): number { 39 | return Math.sqrt( 40 | Math.pow(this.calcDeltaXTo(point), 2) + 41 | Math.pow(this.calcDeltaYTo(point), 2) 42 | ); 43 | } 44 | } 45 | 46 | export function isPoint(x: unknown): x is Point { 47 | return x instanceof Point; 48 | } 49 | -------------------------------------------------------------------------------- /lexical-solid/src/shared/rect.ts: -------------------------------------------------------------------------------- 1 | import { isPoint, Point } from "./point"; 2 | 3 | type ContainsPointReturn = { 4 | result: boolean; 5 | reason: { 6 | isOnTopSide: boolean; 7 | isOnBottomSide: boolean; 8 | isOnLeftSide: boolean; 9 | isOnRightSide: boolean; 10 | }; 11 | }; 12 | 13 | export class Rectangle { 14 | private readonly _left: number; 15 | private readonly _top: number; 16 | private readonly _right: number; 17 | private readonly _bottom: number; 18 | 19 | constructor(left: number, top: number, right: number, bottom: number) { 20 | const [physicTop, physicBottom] = 21 | top <= bottom ? [top, bottom] : [bottom, top]; 22 | 23 | const [physicLeft, physicRight] = 24 | left <= right ? [left, right] : [right, left]; 25 | 26 | this._top = physicTop; 27 | this._right = physicRight; 28 | this._left = physicLeft; 29 | this._bottom = physicBottom; 30 | } 31 | 32 | get top(): number { 33 | return this._top; 34 | } 35 | 36 | get right(): number { 37 | return this._right; 38 | } 39 | 40 | get bottom(): number { 41 | return this._bottom; 42 | } 43 | 44 | get left(): number { 45 | return this._left; 46 | } 47 | 48 | get width(): number { 49 | return Math.abs(this._left - this._right); 50 | } 51 | 52 | get height(): number { 53 | return Math.abs(this._bottom - this._top); 54 | } 55 | 56 | public equals({ top, left, bottom, right }: Rectangle): boolean { 57 | return ( 58 | top === this._top && 59 | bottom === this._bottom && 60 | left === this._left && 61 | right === this._right 62 | ); 63 | } 64 | 65 | public contains({ x, y }: Point): ContainsPointReturn; 66 | public contains({ top, left, bottom, right }: Rectangle): boolean; 67 | public contains(target: Point | Rectangle): boolean | ContainsPointReturn { 68 | if (isPoint(target)) { 69 | const { x, y } = target; 70 | 71 | const isOnTopSide = y < this._top; 72 | const isOnBottomSide = y > this._bottom; 73 | const isOnLeftSide = x < this._left; 74 | const isOnRightSide = x > this._right; 75 | 76 | const result = 77 | !isOnTopSide && !isOnBottomSide && !isOnLeftSide && !isOnRightSide; 78 | 79 | return { 80 | reason: { 81 | isOnBottomSide, 82 | isOnLeftSide, 83 | isOnRightSide, 84 | isOnTopSide, 85 | }, 86 | result, 87 | }; 88 | } else { 89 | const { top, left, bottom, right } = target; 90 | 91 | return ( 92 | top >= this._top && 93 | top <= this._bottom && 94 | bottom >= this._top && 95 | bottom <= this._bottom && 96 | left >= this._left && 97 | left <= this._right && 98 | right >= this._left && 99 | right <= this._right 100 | ); 101 | } 102 | } 103 | 104 | public intersectsWith(rect: Rectangle): boolean { 105 | const { left: x1, top: y1, width: w1, height: h1 } = rect; 106 | const { left: x2, top: y2, width: w2, height: h2 } = this; 107 | const maxX = x1 + w1 >= x2 + w2 ? x1 + w1 : x2 + w2; 108 | const maxY = y1 + h1 >= y2 + h2 ? y1 + h1 : y2 + h2; 109 | const minX = x1 <= x2 ? x1 : x2; 110 | const minY = y1 <= y2 ? y1 : y2; 111 | return maxX - minX <= w1 + w2 && maxY - minY <= h1 + h2; 112 | } 113 | 114 | public generateNewRect({ 115 | left = this.left, 116 | top = this.top, 117 | right = this.right, 118 | bottom = this.bottom, 119 | }): Rectangle { 120 | return new Rectangle(left, top, right, bottom); 121 | } 122 | 123 | static fromLTRB( 124 | left: number, 125 | top: number, 126 | right: number, 127 | bottom: number 128 | ): Rectangle { 129 | return new Rectangle(left, top, right, bottom); 130 | } 131 | 132 | static fromLWTH( 133 | left: number, 134 | width: number, 135 | top: number, 136 | height: number 137 | ): Rectangle { 138 | return new Rectangle(left, top, left + width, top + height); 139 | } 140 | 141 | static fromPoints(startPoint: Point, endPoint: Point): Rectangle { 142 | const { y: top, x: left } = startPoint; 143 | const { y: bottom, x: right } = endPoint; 144 | return Rectangle.fromLTRB(left, top, right, bottom); 145 | } 146 | 147 | static fromDOM(dom: HTMLElement): Rectangle { 148 | const { top, width, left, height } = dom.getBoundingClientRect(); 149 | return Rectangle.fromLWTH(left, width, top, height); 150 | } 151 | } 152 | -------------------------------------------------------------------------------- /lexical-solid/src/shared/useCanShowPlaceholder.tsx: -------------------------------------------------------------------------------- 1 | import { LexicalEditor } from "lexical"; 2 | import { $canShowPlaceholderCurry } from "@lexical/text"; 3 | import { mergeRegister } from "@lexical/utils"; 4 | import { Accessor, createSignal, onCleanup, onMount } from "solid-js"; 5 | 6 | function canShowPlaceholderFromCurrentEditorState( 7 | editor: LexicalEditor 8 | ): boolean { 9 | const currentCanShowPlaceholder = editor 10 | .getEditorState() 11 | .read($canShowPlaceholderCurry(editor.isComposing())); 12 | 13 | return currentCanShowPlaceholder; 14 | } 15 | 16 | export function useCanShowPlaceholder( 17 | editor: LexicalEditor 18 | ): Accessor { 19 | const [canShowPlaceholder, setCanShowPlaceholder] = createSignal( 20 | canShowPlaceholderFromCurrentEditorState(editor) 21 | ); 22 | 23 | onMount(() => { 24 | function resetCanShowPlaceholder() { 25 | const currentCanShowPlaceholder = 26 | canShowPlaceholderFromCurrentEditorState(editor); 27 | setCanShowPlaceholder(currentCanShowPlaceholder); 28 | } 29 | resetCanShowPlaceholder(); 30 | onCleanup( 31 | mergeRegister( 32 | editor.registerUpdateListener(() => { 33 | resetCanShowPlaceholder(); 34 | }), 35 | editor.registerEditableListener(() => { 36 | resetCanShowPlaceholder(); 37 | }) 38 | ) 39 | ); 40 | }); 41 | 42 | return canShowPlaceholder; 43 | } 44 | -------------------------------------------------------------------------------- /lexical-solid/src/shared/useCharacterLimit.tsx: -------------------------------------------------------------------------------- 1 | import { 2 | LexicalEditor, 3 | LexicalNode, 4 | $getSelection, 5 | $isLeafNode, 6 | $isRangeSelection, 7 | $isTextNode, 8 | $setSelection, 9 | DELETE_CHARACTER_COMMAND, 10 | COMMAND_PRIORITY_LOW, 11 | $isElementNode, 12 | } from "lexical"; 13 | import { 14 | $createOverflowNode, 15 | $isOverflowNode, 16 | OverflowNode, 17 | } from "@lexical/overflow"; 18 | import { $rootTextContent } from "@lexical/text"; 19 | import { $dfs, $unwrapNode, mergeRegister } from "@lexical/utils"; 20 | import { MaybeAccessor, resolve } from "../utils"; 21 | import { createEffect, onCleanup, onMount } from "solid-js"; 22 | 23 | type OptionalProps = { 24 | remainingCharacters?: (characters: number) => void; 25 | strlen?: (input: string) => number; 26 | }; 27 | 28 | export function useCharacterLimit( 29 | editor: LexicalEditor, 30 | maxCharacters: MaybeAccessor, 31 | optional: MaybeAccessor = Object.freeze({}) 32 | ): void { 33 | const defaultStrlen = (input: string) => input.length; // UTF-16 34 | const strlen = (input: string) => { 35 | return (resolve(optional).strlen || defaultStrlen)(input); 36 | }; 37 | const remainingCharacters = (characters: number) => { 38 | return resolve(optional).remainingCharacters?.(characters); 39 | }; 40 | 41 | onMount(() => { 42 | if (!editor.hasNodes([OverflowNode])) { 43 | throw new Error( 44 | "useCharacterLimit: OverflowNode not registered on editor" 45 | ); 46 | } 47 | }); 48 | 49 | createEffect(() => { 50 | let text = editor.getEditorState().read($rootTextContent); 51 | let lastComputedTextLength = 0; 52 | 53 | onCleanup( 54 | mergeRegister( 55 | editor.registerTextContentListener((currentText: string) => { 56 | text = currentText; 57 | }), 58 | editor.registerUpdateListener(({ dirtyLeaves, dirtyElements }) => { 59 | const isComposing = editor.isComposing(); 60 | const hasContentChanges = 61 | dirtyLeaves.size > 0 || dirtyElements.size > 0; 62 | 63 | if (isComposing || !hasContentChanges) { 64 | return; 65 | } 66 | 67 | const textLength = strlen(text); 68 | const textLengthAboveThreshold = 69 | textLength > resolve(maxCharacters) || 70 | (lastComputedTextLength !== null && 71 | lastComputedTextLength > resolve(maxCharacters)); 72 | const diff = resolve(maxCharacters) - textLength; 73 | 74 | remainingCharacters(diff); 75 | 76 | if (lastComputedTextLength === null || textLengthAboveThreshold) { 77 | const offset = findOffset(text, resolve(maxCharacters), strlen); 78 | editor.update( 79 | () => { 80 | $wrapOverflowedNodes(offset); 81 | }, 82 | { 83 | tag: "history-merge", 84 | } 85 | ); 86 | } 87 | 88 | lastComputedTextLength = textLength; 89 | }), 90 | editor.registerCommand( 91 | DELETE_CHARACTER_COMMAND, 92 | (isBackward) => { 93 | const selection = $getSelection(); 94 | if (!$isRangeSelection(selection)) { 95 | return false; 96 | } 97 | const anchorNode = selection.anchor.getNode(); 98 | const overflow = anchorNode.getParent(); 99 | const overflowParent = overflow ? overflow.getParent() : null; 100 | const parentNext = overflowParent 101 | ? overflowParent.getNextSibling() 102 | : null; 103 | selection.deleteCharacter(isBackward); 104 | if (overflowParent && overflowParent.isEmpty()) { 105 | overflowParent.remove(); 106 | } else if ($isElementNode(parentNext) && parentNext.isEmpty()) { 107 | parentNext.remove(); 108 | } 109 | return true; 110 | }, 111 | COMMAND_PRIORITY_LOW 112 | ) 113 | ) 114 | ); 115 | }); 116 | } 117 | 118 | function findOffset( 119 | text: string, 120 | maxCharacters: number, 121 | strlen: (input: string) => number 122 | ): number { 123 | const Segmenter = Intl.Segmenter; 124 | let offsetUtf16 = 0; 125 | let offset = 0; 126 | 127 | if (typeof Segmenter === "function") { 128 | const segmenter = new Segmenter(); 129 | const graphemes = segmenter.segment(text); 130 | 131 | for (const { segment: grapheme } of graphemes) { 132 | const nextOffset = offset + strlen(grapheme); 133 | 134 | if (nextOffset > maxCharacters) { 135 | break; 136 | } 137 | 138 | offset = nextOffset; 139 | offsetUtf16 += grapheme.length; 140 | } 141 | } else { 142 | const codepoints = Array.from(text); 143 | const codepointsLength = codepoints.length; 144 | 145 | for (let i = 0; i < codepointsLength; i++) { 146 | const codepoint = codepoints[i]; 147 | const nextOffset = offset + strlen(codepoint); 148 | 149 | if (nextOffset > maxCharacters) { 150 | break; 151 | } 152 | 153 | offset = nextOffset; 154 | offsetUtf16 += codepoint.length; 155 | } 156 | } 157 | 158 | return offsetUtf16; 159 | } 160 | 161 | function $wrapOverflowedNodes(offset: number): void { 162 | const dfsNodes = $dfs(); 163 | const dfsNodesLength = dfsNodes.length; 164 | let accumulatedLength = 0; 165 | 166 | for (let i = 0; i < dfsNodesLength; i += 1) { 167 | const { node } = dfsNodes[i]; 168 | 169 | if ($isOverflowNode(node)) { 170 | const previousLength = accumulatedLength; 171 | const nextLength = accumulatedLength + node.getTextContentSize(); 172 | 173 | if (nextLength <= offset) { 174 | const parent = node.getParent(); 175 | const previousSibling = node.getPreviousSibling(); 176 | const nextSibling = node.getNextSibling(); 177 | $unwrapNode(node); 178 | const selection = $getSelection(); 179 | 180 | // Restore selection when the overflow children are removed 181 | if ( 182 | $isRangeSelection(selection) && 183 | (!selection.anchor.getNode().isAttached() || 184 | !selection.focus.getNode().isAttached()) 185 | ) { 186 | if ($isTextNode(previousSibling)) { 187 | previousSibling.select(); 188 | } else if ($isTextNode(nextSibling)) { 189 | nextSibling.select(); 190 | } else if (parent !== null) { 191 | parent.select(); 192 | } 193 | } 194 | } else if (previousLength < offset) { 195 | const descendant = node.getFirstDescendant(); 196 | const descendantLength = 197 | descendant !== null ? descendant.getTextContentSize() : 0; 198 | const previousPlusDescendantLength = previousLength + descendantLength; 199 | // For simple text we can redimension the overflow into a smaller and more accurate 200 | // container 201 | const firstDescendantIsSimpleText = 202 | $isTextNode(descendant) && descendant.isSimpleText(); 203 | const firstDescendantDoesNotOverflow = 204 | previousPlusDescendantLength <= offset; 205 | 206 | if (firstDescendantIsSimpleText || firstDescendantDoesNotOverflow) { 207 | $unwrapNode(node); 208 | } 209 | } 210 | } else if ($isLeafNode(node)) { 211 | const previousAccumulatedLength = accumulatedLength; 212 | accumulatedLength += node.getTextContentSize(); 213 | 214 | if (accumulatedLength > offset && !$isOverflowNode(node.getParent())) { 215 | const previousSelection = $getSelection(); 216 | let overflowNode; 217 | 218 | // For simple text we can improve the limit accuracy by splitting the TextNode 219 | // on the split point 220 | if ( 221 | previousAccumulatedLength < offset && 222 | $isTextNode(node) && 223 | node.isSimpleText() 224 | ) { 225 | const [, overflowedText] = node.splitText( 226 | offset - previousAccumulatedLength 227 | ); 228 | overflowNode = $wrapNode(overflowedText); 229 | } else { 230 | overflowNode = $wrapNode(node); 231 | } 232 | 233 | if (previousSelection !== null) { 234 | $setSelection(previousSelection); 235 | } 236 | 237 | $mergePrevious(overflowNode); 238 | } 239 | } 240 | } 241 | } 242 | 243 | function $wrapNode(node: LexicalNode): OverflowNode { 244 | const overflowNode = $createOverflowNode(); 245 | node.replace(overflowNode); 246 | overflowNode.append(node); 247 | return overflowNode; 248 | } 249 | 250 | export function $mergePrevious(overflowNode: OverflowNode): void { 251 | const previousNode = overflowNode.getPreviousSibling(); 252 | 253 | if (!$isOverflowNode(previousNode)) { 254 | return; 255 | } 256 | 257 | const firstChild = overflowNode.getFirstChild(); 258 | const previousNodeChildren = previousNode.getChildren(); 259 | const previousNodeChildrenLength = previousNodeChildren.length; 260 | 261 | if (firstChild === null) { 262 | overflowNode.append(...previousNodeChildren); 263 | } else { 264 | for (let i = 0; i < previousNodeChildrenLength; i++) { 265 | firstChild.insertBefore(previousNodeChildren[i]); 266 | } 267 | } 268 | 269 | const selection = $getSelection(); 270 | 271 | if ($isRangeSelection(selection)) { 272 | const anchor = selection.anchor; 273 | const anchorNode = anchor.getNode(); 274 | const focus = selection.focus; 275 | const focusNode = anchor.getNode(); 276 | 277 | if (anchorNode.is(previousNode)) { 278 | anchor.set(overflowNode.getKey(), anchor.offset, "element"); 279 | } else if (anchorNode.is(overflowNode)) { 280 | anchor.set( 281 | overflowNode.getKey(), 282 | previousNodeChildrenLength + anchor.offset, 283 | "element" 284 | ); 285 | } 286 | 287 | if (focusNode.is(previousNode)) { 288 | focus.set(overflowNode.getKey(), focus.offset, "element"); 289 | } else if (focusNode.is(overflowNode)) { 290 | focus.set( 291 | overflowNode.getKey(), 292 | previousNodeChildrenLength + focus.offset, 293 | "element" 294 | ); 295 | } 296 | } 297 | 298 | previousNode.remove(); 299 | } 300 | -------------------------------------------------------------------------------- /lexical-solid/src/shared/useDecorators.tsx: -------------------------------------------------------------------------------- 1 | import { LexicalEditor, NodeKey } from "lexical"; 2 | import { 3 | createMemo, 4 | createSignal, 5 | onMount, 6 | JSX, 7 | Component, 8 | Accessor, 9 | createComponent, 10 | Suspense, 11 | onCleanup, 12 | } from "solid-js"; 13 | import { Dynamic, Portal } from "solid-js/web"; 14 | 15 | type ErrorBoundaryProps = { 16 | children: JSX.Element; 17 | onError: (err: any, reset: () => void) => JSX.Element; 18 | }; 19 | export type ErrorBoundaryType = Component; 20 | 21 | export function useDecorators( 22 | editor: LexicalEditor, 23 | ErrorBoundary: ErrorBoundaryType 24 | ): Accessor { 25 | const [decorators, setDecorators] = createSignal< 26 | Record JSX.Element> 27 | >(editor.getDecorators<() => JSX.Element>()); 28 | 29 | // Subscribe to changes 30 | onCleanup( 31 | editor.registerDecoratorListener<() => JSX.Element>((nextDecorators) => { 32 | setDecorators(nextDecorators); 33 | }) 34 | ); 35 | 36 | onMount(() => { 37 | // If the content editable mounts before the subscription is added, then 38 | // nothing will be rendered on initial pass. We can get around that by 39 | // ensuring that we set the value. 40 | setDecorators(editor.getDecorators<() => JSX.Element>()); 41 | }); 42 | 43 | // Return decorators defined as React Portals 44 | return createMemo(() => { 45 | const decoratedPortals = []; 46 | const decoratorKeys = Object.keys(decorators()); 47 | 48 | for (let i = 0; i < decoratorKeys.length; i++) { 49 | const nodeKey = decoratorKeys[i]; 50 | const decorator = ( 51 | editor._onError(error) as undefined} 53 | > 54 | 55 | 56 | 57 | 58 | ); 59 | const element = editor.getElementByKey(nodeKey); 60 | 61 | if (element !== null) { 62 | decoratedPortals.push( 63 | createComponent(Portal, { mount: element, children: decorator }) 64 | ); 65 | } 66 | } 67 | 68 | return decoratedPortals; 69 | }); 70 | } 71 | -------------------------------------------------------------------------------- /lexical-solid/src/shared/useHistory.tsx: -------------------------------------------------------------------------------- 1 | import { 2 | HistoryState, 3 | createEmptyHistoryState, 4 | registerHistory, 5 | } from "@lexical/history"; 6 | import { LexicalEditor } from "lexical"; 7 | import { createEffect, Accessor, onCleanup } from "solid-js"; 8 | 9 | export function useHistory( 10 | editor: LexicalEditor, 11 | externalHistoryState: Accessor, 12 | delay: Accessor | undefined 13 | ) { 14 | const historyState = () => 15 | externalHistoryState() || createEmptyHistoryState(); 16 | 17 | createEffect(() => { 18 | onCleanup( 19 | registerHistory(editor, historyState(), (delay && delay()) ?? 1000) 20 | ); 21 | }); 22 | } 23 | -------------------------------------------------------------------------------- /lexical-solid/src/shared/useList.tsx: -------------------------------------------------------------------------------- 1 | import type { LexicalEditor } from "lexical"; 2 | 3 | import { registerList } from "@lexical/list"; 4 | import { onCleanup, onMount } from "solid-js"; 5 | 6 | export function useList(editor: LexicalEditor): void { 7 | onMount(() => onCleanup(registerList(editor))); 8 | } 9 | -------------------------------------------------------------------------------- /lexical-solid/src/shared/usePlainTextSetup.tsx: -------------------------------------------------------------------------------- 1 | import { onCleanup } from "solid-js"; 2 | import { LexicalEditor } from "lexical"; 3 | import { registerDragonSupport } from "@lexical/dragon"; 4 | import { registerPlainText } from "@lexical/plain-text"; 5 | import { mergeRegister } from "@lexical/utils"; 6 | import { isServer } from "solid-js/web"; 7 | 8 | export function usePlainTextSetup(editor: LexicalEditor) { 9 | if (!isServer) { 10 | onCleanup( 11 | mergeRegister(registerPlainText(editor), registerDragonSupport(editor)) // We only do this for init 12 | ); 13 | } 14 | } 15 | -------------------------------------------------------------------------------- /lexical-solid/src/shared/useRichTextSetup.tsx: -------------------------------------------------------------------------------- 1 | import { onCleanup } from "solid-js"; 2 | import { LexicalEditor } from "lexical"; 3 | import { isServer } from "solid-js/web"; 4 | import { registerRichText } from "@lexical/rich-text"; 5 | import { mergeRegister } from "@lexical/utils"; 6 | import { registerDragonSupport } from "@lexical/dragon"; 7 | 8 | export function useRichTextSetup(editor: LexicalEditor) { 9 | if (!isServer) { 10 | onCleanup( 11 | mergeRegister(registerRichText(editor), registerDragonSupport(editor)) // We only do this for init 12 | ); 13 | } 14 | } 15 | -------------------------------------------------------------------------------- /lexical-solid/src/shared/useYjsCollaboration.tsx: -------------------------------------------------------------------------------- 1 | import { 2 | Binding, 3 | Provider, 4 | CONNECTED_COMMAND, 5 | createBinding, 6 | createUndoManager, 7 | initLocalState, 8 | setLocalStateFocus, 9 | syncCursorPositions, 10 | syncLexicalUpdateToYjs, 11 | syncYjsChangesToLexical, 12 | TOGGLE_CONNECT_COMMAND, 13 | SyncCursorPositionsFn, 14 | } from "@lexical/yjs"; 15 | import { 16 | LexicalEditor, 17 | $createParagraphNode, 18 | $getRoot, 19 | $getSelection, 20 | BLUR_COMMAND, 21 | CAN_UNDO_COMMAND, 22 | CAN_REDO_COMMAND, 23 | COMMAND_PRIORITY_EDITOR, 24 | FOCUS_COMMAND, 25 | REDO_COMMAND, 26 | UNDO_COMMAND, 27 | } from "lexical"; 28 | import { Doc, Transaction, YEvent, UndoManager } from "yjs"; 29 | import { mergeRegister } from "@lexical/utils"; 30 | import { InitialEditorStateType } from "../LexicalComposer"; 31 | import { JSX, Setter } from "solid-js"; 32 | import { Portal } from "solid-js/web"; 33 | import { 34 | Accessor, 35 | createEffect, 36 | createMemo, 37 | createSignal, 38 | on, 39 | onCleanup, 40 | onMount, 41 | } from "solid-js"; 42 | 43 | export type CursorsContainerRef = Accessor; 44 | 45 | export function useYjsCollaboration( 46 | editor: LexicalEditor, 47 | id: string, 48 | provider: Accessor, 49 | docMap: Map, 50 | name: string, 51 | color: string, 52 | shouldBootstrap: boolean, 53 | binding: Accessor, 54 | setDoc: Setter, 55 | cursorsContainerRef?: CursorsContainerRef, 56 | initialEditorState?: InitialEditorStateType, 57 | awarenessData?: object, 58 | syncCursorPositionsFn: SyncCursorPositionsFn = syncCursorPositions 59 | ): Accessor { 60 | let isReloadingDoc: boolean = false; 61 | 62 | const connect = () => provider().connect(); 63 | 64 | const disconnect = () => { 65 | try { 66 | provider().disconnect(); 67 | } catch (e) { 68 | // Do nothing 69 | } 70 | }; 71 | 72 | createEffect( 73 | on(binding, () => { 74 | const { root } = binding(); 75 | const { awareness } = provider(); 76 | 77 | const onStatus = ({ status }: { status: string }) => { 78 | editor.dispatchCommand(CONNECTED_COMMAND, status === "connected"); 79 | }; 80 | 81 | const onSync = (isSynced: boolean) => { 82 | if ( 83 | shouldBootstrap && 84 | isSynced && 85 | root.isEmpty() && 86 | root._xmlText._length === 0 && 87 | isReloadingDoc === false 88 | ) { 89 | initializeEditor(editor, initialEditorState); 90 | } 91 | 92 | isReloadingDoc = false; 93 | }; 94 | 95 | const onAwarenessUpdate = () => { 96 | syncCursorPositionsFn(binding(), provider()); 97 | }; 98 | 99 | const onYjsTreeChanges = ( 100 | // The below `any` type is taken directly from the vendor types for YJS. 101 | // eslint-disable-next-line @typescript-eslint/no-explicit-any 102 | events: Array>, 103 | transaction: Transaction 104 | ) => { 105 | const origin = transaction.origin; 106 | if (origin !== binding()) { 107 | const isFromUndoManager = origin instanceof UndoManager; 108 | syncYjsChangesToLexical( 109 | binding(), 110 | provider(), 111 | events, 112 | isFromUndoManager, 113 | syncCursorPositionsFn 114 | ); 115 | } 116 | }; 117 | 118 | initLocalState( 119 | provider(), 120 | name, 121 | color, 122 | document.activeElement === editor.getRootElement(), 123 | awarenessData || {} 124 | ); 125 | 126 | const onProviderDocReload = (ydoc: Doc) => { 127 | clearEditorSkipCollab(editor, binding()); 128 | setDoc(ydoc); 129 | docMap.set(id, ydoc); 130 | isReloadingDoc = true; 131 | }; 132 | 133 | provider().on("reload", onProviderDocReload); 134 | provider().on("status", onStatus); 135 | provider().on("sync", onSync); 136 | awareness.on("update", onAwarenessUpdate); 137 | // This updates the local editor state when we receive updates from other clients 138 | root.getSharedType().observeDeep(onYjsTreeChanges); 139 | const removeListener = editor.registerUpdateListener( 140 | ({ 141 | prevEditorState, 142 | editorState, 143 | dirtyLeaves, 144 | dirtyElements, 145 | normalizedNodes, 146 | tags, 147 | }) => { 148 | if (tags.has("skip-collab") === false) { 149 | syncLexicalUpdateToYjs( 150 | binding(), 151 | provider(), 152 | prevEditorState, 153 | editorState, 154 | dirtyElements, 155 | dirtyLeaves, 156 | normalizedNodes, 157 | tags 158 | ); 159 | } 160 | } 161 | ); 162 | const connectionPromise = connect(); 163 | 164 | onCleanup(() => { 165 | if (isReloadingDoc === false) { 166 | if (connectionPromise) { 167 | connectionPromise.then(disconnect); 168 | } else { 169 | disconnect(); 170 | } 171 | } 172 | 173 | provider().off("sync", onSync); 174 | provider().off("status", onStatus); 175 | provider().off("reload", onProviderDocReload); 176 | awareness.off("update", onAwarenessUpdate); 177 | root.getSharedType().unobserveDeep(onYjsTreeChanges); 178 | docMap.delete(id); 179 | removeListener(); 180 | }); 181 | }) 182 | ); 183 | const cursorsContainer = createMemo(() => { 184 | const ref = (element: null | HTMLElement) => { 185 | binding().cursorsContainer = element; 186 | }; 187 | 188 | const mountPoint = 189 | (cursorsContainerRef && cursorsContainerRef()) || document.body; 190 | 191 | return ( 192 | 193 |
194 | 195 | ); 196 | }); 197 | 198 | onCleanup( 199 | editor.registerCommand( 200 | TOGGLE_CONNECT_COMMAND, 201 | (payload) => { 202 | const shouldConnect = payload; 203 | if (shouldConnect) { 204 | // eslint-disable-next-line no-console 205 | console.log("Collaboration connected!"); 206 | connect(); 207 | } else { 208 | // eslint-disable-next-line no-console 209 | console.log("Collaboration disconnected!"); 210 | disconnect(); 211 | } 212 | 213 | return true; 214 | }, 215 | COMMAND_PRIORITY_EDITOR 216 | ) 217 | ); 218 | 219 | return cursorsContainer; 220 | } 221 | 222 | export function useYjsFocusTracking( 223 | editor: LexicalEditor, 224 | provider: Accessor, 225 | name: string, 226 | color: string, 227 | awarenessData?: object 228 | ) { 229 | onCleanup( 230 | mergeRegister( 231 | editor.registerCommand( 232 | FOCUS_COMMAND, 233 | () => { 234 | setLocalStateFocus( 235 | provider(), 236 | name, 237 | color, 238 | true, 239 | awarenessData || {} 240 | ); 241 | return false; 242 | }, 243 | COMMAND_PRIORITY_EDITOR 244 | ), 245 | editor.registerCommand( 246 | BLUR_COMMAND, 247 | () => { 248 | setLocalStateFocus( 249 | provider(), 250 | name, 251 | color, 252 | false, 253 | awarenessData || {} 254 | ); 255 | return false; 256 | }, 257 | COMMAND_PRIORITY_EDITOR 258 | ) 259 | ) 260 | ); 261 | } 262 | 263 | export function useYjsHistory( 264 | editor: LexicalEditor, 265 | binding: Accessor 266 | ): () => void { 267 | const undoManager = createMemo(() => 268 | createUndoManager(binding(), binding().root.getSharedType()) 269 | ); 270 | 271 | onMount(() => { 272 | const undo = () => { 273 | undoManager().undo(); 274 | }; 275 | 276 | const redo = () => { 277 | undoManager().redo(); 278 | }; 279 | 280 | onCleanup( 281 | mergeRegister( 282 | editor.registerCommand( 283 | UNDO_COMMAND, 284 | () => { 285 | undo(); 286 | return true; 287 | }, 288 | COMMAND_PRIORITY_EDITOR 289 | ), 290 | editor.registerCommand( 291 | REDO_COMMAND, 292 | () => { 293 | redo(); 294 | return true; 295 | }, 296 | COMMAND_PRIORITY_EDITOR 297 | ) 298 | ) 299 | ); 300 | }); 301 | const clearHistory = () => { 302 | undoManager().clear(); 303 | }; 304 | 305 | // Exposing undo and redo states 306 | createEffect(() => { 307 | const updateUndoRedoStates = () => { 308 | editor.dispatchCommand( 309 | CAN_UNDO_COMMAND, 310 | undoManager().undoStack.length > 0 311 | ); 312 | editor.dispatchCommand( 313 | CAN_REDO_COMMAND, 314 | undoManager().redoStack.length > 0 315 | ); 316 | }; 317 | undoManager().on("stack-item-added", updateUndoRedoStates); 318 | undoManager().on("stack-item-popped", updateUndoRedoStates); 319 | undoManager().on("stack-cleared", updateUndoRedoStates); 320 | onCleanup(() => { 321 | undoManager().off("stack-item-added", updateUndoRedoStates); 322 | undoManager().off("stack-item-popped", updateUndoRedoStates); 323 | undoManager().off("stack-cleared", updateUndoRedoStates); 324 | }); 325 | }); 326 | 327 | return clearHistory; 328 | } 329 | 330 | function initializeEditor( 331 | editor: LexicalEditor, 332 | initialEditorState?: InitialEditorStateType 333 | ): void { 334 | editor.update( 335 | () => { 336 | const root = $getRoot(); 337 | 338 | if (root.isEmpty()) { 339 | if (initialEditorState) { 340 | switch (typeof initialEditorState) { 341 | case "string": { 342 | const parsedEditorState = 343 | editor.parseEditorState(initialEditorState); 344 | editor.setEditorState(parsedEditorState, { 345 | tag: "history-merge", 346 | }); 347 | break; 348 | } 349 | case "object": { 350 | editor.setEditorState(initialEditorState, { 351 | tag: "history-merge", 352 | }); 353 | break; 354 | } 355 | case "function": { 356 | editor.update( 357 | () => { 358 | const root1 = $getRoot(); 359 | if (root1.isEmpty()) { 360 | initialEditorState(editor); 361 | } 362 | }, 363 | { tag: "history-merge" } 364 | ); 365 | break; 366 | } 367 | } 368 | } else { 369 | const paragraph = $createParagraphNode(); 370 | root.append(paragraph); 371 | const { activeElement } = document; 372 | 373 | if ( 374 | $getSelection() !== null || 375 | (activeElement !== null && 376 | activeElement === editor.getRootElement()) 377 | ) { 378 | paragraph.select(); 379 | } 380 | } 381 | } 382 | }, 383 | { 384 | tag: "history-merge", 385 | } 386 | ); 387 | } 388 | 389 | function clearEditorSkipCollab(editor: LexicalEditor, binding: Binding) { 390 | // reset editor state 391 | editor.update( 392 | () => { 393 | const root = $getRoot(); 394 | root.clear(); 395 | root.select(); 396 | }, 397 | { 398 | tag: "skip-collab", 399 | } 400 | ); 401 | 402 | if (binding.cursors == null) { 403 | return; 404 | } 405 | 406 | const cursors = binding.cursors; 407 | 408 | if (cursors == null) { 409 | return; 410 | } 411 | const cursorsContainer = binding.cursorsContainer; 412 | 413 | if (cursorsContainer == null) { 414 | return; 415 | } 416 | 417 | // reset cursors in dom 418 | const cursorsArr = Array.from(cursors.values()); 419 | 420 | for (let i = 0; i < cursorsArr.length; i++) { 421 | const cursor = cursorsArr[i]; 422 | const selection = cursor.selection; 423 | 424 | if (selection && selection.selections != null) { 425 | const selections = selection.selections; 426 | 427 | for (let j = 0; j < selections.length; j++) { 428 | cursorsContainer.removeChild(selections[i]); 429 | } 430 | } 431 | } 432 | } 433 | -------------------------------------------------------------------------------- /lexical-solid/src/shared/warnOnlyOnce.ts: -------------------------------------------------------------------------------- 1 | import { DEV } from "solid-js"; 2 | 3 | export default function warnOnlyOnce(message: string): () => void { 4 | if (DEV) { 5 | let run = false; 6 | return () => { 7 | if (!run) { 8 | console.warn(message); 9 | } 10 | run = true; 11 | }; 12 | } else { 13 | return () => {}; 14 | } 15 | } 16 | -------------------------------------------------------------------------------- /lexical-solid/src/useLexicalEditable.tsx: -------------------------------------------------------------------------------- 1 | import { LexicalSubscription } from "./useLexicalSubscription"; 2 | import { useLexicalSubscription } from "./useLexicalSubscription"; 3 | import { LexicalEditor } from "lexical"; 4 | import { Accessor } from "solid-js"; 5 | 6 | function subscription(editor: LexicalEditor): LexicalSubscription { 7 | return { 8 | initialValueFn: () => editor.isEditable(), 9 | subscribe: (callback) => { 10 | return editor.registerEditableListener(callback); 11 | }, 12 | }; 13 | } 14 | 15 | /** 16 | * Get the current value for {@link LexicalEditor.isEditable} 17 | * using {@link useLexicalSubscription}. 18 | * You should prefer this over manually observing the value with 19 | * {@link LexicalEditor.registerEditableListener}, 20 | * which is a bit tricky to do correctly, particularly when using 21 | * React StrictMode (the default for development) or concurrency. 22 | */ 23 | export function useLexicalEditable(): Accessor { 24 | return useLexicalSubscription(subscription); 25 | } 26 | -------------------------------------------------------------------------------- /lexical-solid/src/useLexicalIsContentEmpty.tsx: -------------------------------------------------------------------------------- 1 | import type { LexicalEditor } from "lexical"; 2 | 3 | import { $isRootTextContentEmptyCurry } from "@lexical/text"; 4 | import { Accessor, createEffect, createSignal, onCleanup } from "solid-js"; 5 | import { MaybeAccessor, resolve } from "./utils"; 6 | 7 | export function useLexicalIsTextContentEmpty( 8 | editor: LexicalEditor, 9 | trim?: MaybeAccessor 10 | ): Accessor { 11 | const [isEmpty, setIsEmpty] = createSignal( 12 | editor 13 | .getEditorState() 14 | .read($isRootTextContentEmptyCurry(editor.isComposing(), resolve(trim))) 15 | ); 16 | 17 | createEffect(() => { 18 | const cleanup = editor.registerUpdateListener(({ editorState }) => { 19 | const isComposing = editor.isComposing(); 20 | const currentIsEmpty = editorState.read( 21 | $isRootTextContentEmptyCurry(isComposing, resolve(trim)) 22 | ); 23 | setIsEmpty(currentIsEmpty); 24 | }); 25 | onCleanup(cleanup); 26 | }); 27 | return isEmpty; 28 | } 29 | -------------------------------------------------------------------------------- /lexical-solid/src/useLexicalNodeSelection.tsx: -------------------------------------------------------------------------------- 1 | import type { LexicalEditor, NodeKey } from "lexical"; 2 | 3 | import { useLexicalComposerContext } from "./LexicalComposerContext"; 4 | import { 5 | $createNodeSelection, 6 | $getNodeByKey, 7 | $getSelection, 8 | $isNodeSelection, 9 | $setSelection, 10 | } from "lexical"; 11 | import { createSignal, onCleanup, onMount } from "solid-js"; 12 | 13 | /** 14 | * A helper function to determine if a specific node is selected in a Lexical editor. 15 | * 16 | * @param {LexicalEditor} editor - The LexicalEditor instance. 17 | * @param {NodeKey} key - The key of the node to check. 18 | * @returns {boolean} Whether the node is selected. 19 | */ 20 | 21 | function isNodeSelected(editor: LexicalEditor, key: NodeKey): boolean { 22 | return editor.getEditorState().read(() => { 23 | const node = $getNodeByKey(key); 24 | if (node === null) { 25 | return false; // Node doesn't exist, so it's not selected. 26 | } 27 | return node.isSelected(); // Check if the node is selected. 28 | }); 29 | } 30 | 31 | /** 32 | * A custom hook to manage the selection state of a specific node in a Lexical editor. 33 | * 34 | * This hook provides utilities to: 35 | * - Check if a node is selected. 36 | * - Update its selection state. 37 | * - Clear the selection. 38 | * 39 | * @param {NodeKey} key - The key of the node to track selection for. 40 | * @returns {[boolean, (selected: boolean) => void, () => void]} A tuple containing: 41 | * - `isSelected` (boolean): Whether the node is currently selected. 42 | * - `setSelected` (function): A function to set the selection state of the node. 43 | * - `clearSelected` (function): A function to clear the selection of the node. 44 | * 45 | */ 46 | 47 | export function useLexicalNodeSelection( 48 | key: NodeKey 49 | ): [() => boolean, (selected: boolean) => void, () => void] { 50 | const [editor] = useLexicalComposerContext(); 51 | // State to track whether the node is currently selected. 52 | const [isSelected, setIsSelected] = createSignal(isNodeSelected(editor, key)); 53 | 54 | onMount(() => { 55 | let isMounted = true; 56 | const unregister = editor.registerUpdateListener(() => { 57 | if (isMounted) { 58 | setIsSelected(isNodeSelected(editor, key)); 59 | } 60 | }); 61 | 62 | onCleanup(() => { 63 | // Prevent updates after component unmount. 64 | isMounted = false; 65 | unregister(); 66 | }); 67 | }); 68 | 69 | const setSelected = (selected: boolean) => { 70 | editor.update(() => { 71 | let selection = $getSelection(); 72 | if (!$isNodeSelection(selection)) { 73 | selection = $createNodeSelection(); 74 | $setSelection(selection); 75 | } 76 | if ($isNodeSelection(selection)) { 77 | if (selected) { 78 | selection.add(key); 79 | } else { 80 | selection.delete(key); 81 | } 82 | } 83 | }); 84 | }; 85 | 86 | const clearSelected = () => { 87 | editor.update(() => { 88 | const selection = $getSelection(); 89 | if ($isNodeSelection(selection)) { 90 | selection.clear(); 91 | } 92 | }); 93 | }; 94 | 95 | return [isSelected, setSelected, clearSelected]; 96 | } 97 | -------------------------------------------------------------------------------- /lexical-solid/src/useLexicalSubscription.tsx: -------------------------------------------------------------------------------- 1 | import { LexicalEditor } from "lexical"; 2 | import { useLexicalComposerContext } from "./LexicalComposerContext"; 3 | import { 4 | Accessor, 5 | createEffect, 6 | createMemo, 7 | createSignal, 8 | onCleanup, 9 | } from "solid-js"; 10 | 11 | export type LexicalSubscription = { 12 | initialValueFn: () => T; 13 | subscribe: (callback: (value: T) => void) => () => void; 14 | }; 15 | 16 | /** 17 | * Shortcut to Lexical subscriptions when values are used for render. 18 | * @param subscription - The function to create the {@link LexicalSubscription}. This function's identity must be stable (e.g. defined at module scope or with useCallback). 19 | */ 20 | export function useLexicalSubscription( 21 | subscription: (editor: LexicalEditor) => LexicalSubscription 22 | ): Accessor { 23 | const [editor] = useLexicalComposerContext(); 24 | const initializedSubscription = createMemo(() => subscription(editor)); 25 | const [value, setValue] = createSignal( 26 | initializedSubscription().initialValueFn() 27 | ); 28 | const valueRef = { current: value() }; 29 | createEffect(() => { 30 | const { initialValueFn, subscribe } = initializedSubscription(); 31 | const currentValue = initialValueFn(); 32 | if (valueRef.current !== currentValue) { 33 | valueRef.current = currentValue; 34 | setValue(() => currentValue); 35 | } 36 | 37 | onCleanup( 38 | subscribe((newValue: T) => { 39 | valueRef.current = newValue; 40 | setValue(() => newValue); 41 | }) 42 | ); 43 | }); 44 | 45 | return value; 46 | } 47 | -------------------------------------------------------------------------------- /lexical-solid/src/useLexicalTextEntity.tsx: -------------------------------------------------------------------------------- 1 | import type { EntityMatch } from "@lexical/text"; 2 | import type { Klass, TextNode } from "lexical"; 3 | 4 | import { useLexicalComposerContext } from "./LexicalComposerContext"; 5 | import { registerLexicalTextEntity } from "@lexical/text"; 6 | import { mergeRegister } from "@lexical/utils"; 7 | import { onCleanup } from "solid-js"; 8 | 9 | export function useLexicalTextEntity( 10 | getMatch: (text: string) => null | EntityMatch, 11 | targetNode: Klass, 12 | createNode: (textNode: TextNode) => T 13 | ): void { 14 | const [editor] = useLexicalComposerContext(); 15 | 16 | onCleanup( 17 | mergeRegister( 18 | ...registerLexicalTextEntity(editor, getMatch, targetNode, createNode) 19 | ) 20 | ); 21 | } 22 | -------------------------------------------------------------------------------- /lexical-solid/src/utils.tsx: -------------------------------------------------------------------------------- 1 | export type MaybeAccessor = T | (() => T); 2 | 3 | export function resolve(t: T | (() => T)): T { 4 | if (typeof t === "function") { 5 | //@ts-ignore 6 | return t(); 7 | } else { 8 | return t; 9 | } 10 | } -------------------------------------------------------------------------------- /lexical-solid/tsconfig.json: -------------------------------------------------------------------------------- 1 | { 2 | "compilerOptions": { 3 | "strict": true, 4 | "target": "ESNext", 5 | "module": "ESNext", 6 | "moduleResolution": "node", 7 | "allowSyntheticDefaultImports": true, 8 | "esModuleInterop": true, 9 | "jsx": "preserve", 10 | "jsxImportSource": "solid-js", 11 | "rootDir": "./src", 12 | "outDir": "./dist/source", 13 | "paths": { 14 | "lexical-solid/*": ["./src/*"], 15 | } 16 | } 17 | } 18 | -------------------------------------------------------------------------------- /package.json: -------------------------------------------------------------------------------- 1 | { 2 | "name": "lexical-solid-repo", 3 | "workspaces": [ 4 | "lexical-solid", 5 | "docs" 6 | ], 7 | "nohoist": [ 8 | "**/solid-start" 9 | ], 10 | "scripts": { 11 | "copy-readme": "copy README.md lexical-solid", 12 | "remove-readme-copy": "del \"lexical-solid\\README.md\"", 13 | "run-publish": "pnpm copy-readme && cd lexical-solid && pnpm build && npm publish && cd .. && pnpm run remove-readme-copy" 14 | } 15 | } -------------------------------------------------------------------------------- /pnpm-workspace.yaml: -------------------------------------------------------------------------------- 1 | packages: 2 | - 'example' 3 | - 'lexical-solid' 4 | --------------------------------------------------------------------------------