├── .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 |
7 |
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 | {
140 | setShowLimited(true);
141 | }}
142 | style={{
143 | background: "transparent",
144 | border: "1px solid white",
145 | color: "white",
146 | cursor: "pointer",
147 | padding: "5px",
148 | }}
149 | >
150 | Show full tree
151 |
152 |
153 | ) : null}
154 | {!showLimited() ? (
155 |
handleExportModeToggleClick()}
157 | class={props.treeTypeButtonClassName}
158 | type="button"
159 | >
160 | {showExportDOM() ? "Tree" : "Export DOM"}
161 |
162 | ) : null}
163 | {!timeTravelEnabled() &&
164 | (showLimited() || !isLimited()) &&
165 | totalEditorStates() > 2 && (
166 |
{
168 | props.setEditorReadOnly(true);
169 | playingIndexRef = totalEditorStates() - 1;
170 | setTimeTravelEnabled(true);
171 | }}
172 | class={props.timeTravelButtonClassName}
173 | type="button"
174 | >
175 | Time Travel
176 |
177 | )}
178 | {(showLimited() || !isLimited()) && (
179 |
{content()}
180 | )}
181 | {timeTravelEnabled() && (showLimited() || !isLimited()) && (
182 |
183 | {
186 | if (playingIndexRef === totalEditorStates() - 1) {
187 | playingIndexRef = 1;
188 | }
189 | setIsPlaying(!isPlaying());
190 | }}
191 | type="button"
192 | >
193 | {isPlaying() ? "Pause" : "Play"}
194 |
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 | {
215 | props.setEditorReadOnly(false);
216 | const index = timeStampedEditorStates().length - 1;
217 | const timeStampedEditorState = timeStampedEditorStates()[index];
218 | props.setEditorState(timeStampedEditorState[1]);
219 | const input = inputRef;
220 |
221 | if (input != null) {
222 | input.value = String(index);
223 | }
224 |
225 | setTimeTravelEnabled(false);
226 | setIsPlaying(false);
227 | }}
228 | type="button"
229 | >
230 | Exit
231 |
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 |
--------------------------------------------------------------------------------