[lexical][lexical-website] Documentation: Start on NodeState docs and examples (#7294)

This commit is contained in:
Bob Ippolito
2025-05-07 12:30:45 -07:00
committed by GitHub
parent a51b69d2b0
commit 85a885a766
75 changed files with 12206 additions and 138 deletions

View File

@ -0,0 +1,9 @@
# Node Replacement Example
Here we have simplest Lexical setup in rich text configuration (`@lexical/rich-text`) with history (`@lexical/history`) and accessibility (`@lexical/dragon`) features enabled.
It also implements a CustomParagraphNode via node replacement.
**Run it locally:** `npm i && npm run dev`
[![Open in StackBlitz](https://developer.stackblitz.com/img/open_in_stackblitz.svg)](https://stackblitz.com/github/facebook/lexical/tree/main/examples/node-replacement?file=src/main.tsx)

View File

@ -0,0 +1,12 @@
<!DOCTYPE html>
<html lang="en">
<head>
<meta charset="UTF-8" />
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
<title>Lexical React Example</title>
</head>
<body>
<div id="root"></div>
<script src="/src/main.tsx" type="module"></script>
</body>
</html>

3379
examples/node-replacement/package-lock.json generated Normal file

File diff suppressed because it is too large Load Diff

View File

@ -0,0 +1,26 @@
{
"name": "@lexical/node-replacement-example",
"private": true,
"version": "0.31.0",
"type": "module",
"scripts": {
"dev": "vite",
"monorepo:dev": "vite -c vite.config.monorepo.ts",
"build": "tsc && vite build",
"preview": "vite preview"
},
"dependencies": {
"@lexical/react": "0.31.0",
"lexical": "0.31.0",
"react": "^18.2.0",
"react-dom": "^18.2.0"
},
"devDependencies": {
"@types/react": "^18.2.59",
"@types/react-dom": "^18.2.19",
"@vitejs/plugin-react": "^4.2.1",
"cross-env": "^7.0.3",
"typescript": "^5.2.2",
"vite": "^5.2.11"
}
}

View File

@ -0,0 +1,5 @@
Bootstrap Icons
https://icons.getbootstrap.com
Licensed under MIT license
https://github.com/twbs/icons/blob/main/LICENSE.md

View File

@ -0,0 +1,4 @@
<svg xmlns="http://www.w3.org/2000/svg" width="16" height="16" fill="currentColor" class="bi bi-arrow-clockwise" viewBox="0 0 16 16">
<path fill-rule="evenodd" d="M8 3a5 5 0 1 0 4.546 2.914.5.5 0 0 1 .908-.417A6 6 0 1 1 8 2v1z"/>
<path d="M8 4.466V.534a.25.25 0 0 1 .41-.192l2.36 1.966c.12.1.12.284 0 .384L8.41 4.658A.25.25 0 0 1 8 4.466z"/>
</svg>

After

Width:  |  Height:  |  Size: 352 B

View File

@ -0,0 +1,4 @@
<svg xmlns="http://www.w3.org/2000/svg" width="16" height="16" fill="currentColor" class="bi bi-arrow-counterclockwise" viewBox="0 0 16 16">
<path fill-rule="evenodd" d="M8 3a5 5 0 1 1-4.546 2.914.5.5 0 0 0-.908-.417A6 6 0 1 0 8 2v1z"/>
<path d="M8 4.466V.534a.25.25 0 0 0-.41-.192L5.23 2.308a.25.25 0 0 0 0 .384l2.36 1.966A.25.25 0 0 0 8 4.466z"/>
</svg>

After

Width:  |  Height:  |  Size: 359 B

View File

@ -0,0 +1,5 @@
<svg xmlns="http://www.w3.org/2000/svg" width="16" height="16" fill="currentColor" class="bi bi-journal-text" viewBox="0 0 16 16">
<path d="M5 10.5a.5.5 0 0 1 .5-.5h2a.5.5 0 0 1 0 1h-2a.5.5 0 0 1-.5-.5zm0-2a.5.5 0 0 1 .5-.5h5a.5.5 0 0 1 0 1h-5a.5.5 0 0 1-.5-.5zm0-2a.5.5 0 0 1 .5-.5h5a.5.5 0 0 1 0 1h-5a.5.5 0 0 1-.5-.5zm0-2a.5.5 0 0 1 .5-.5h5a.5.5 0 0 1 0 1h-5a.5.5 0 0 1-.5-.5z"/>
<path d="M3 0h10a2 2 0 0 1 2 2v12a2 2 0 0 1-2 2H3a2 2 0 0 1-2-2v-1h1v1a1 1 0 0 0 1 1h10a1 1 0 0 0 1-1V2a1 1 0 0 0-1-1H3a1 1 0 0 0-1 1v1H1V2a2 2 0 0 1 2-2z"/>
<path d="M1 5v-.5a.5.5 0 0 1 1 0V5h.5a.5.5 0 0 1 0 1h-2a.5.5 0 0 1 0-1H1zm0 3v-.5a.5.5 0 0 1 1 0V8h.5a.5.5 0 0 1 0 1h-2a.5.5 0 0 1 0-1H1zm0 3v-.5a.5.5 0 0 1 1 0v.5h.5a.5.5 0 0 1 0 1h-2a.5.5 0 0 1 0-1H1z"/>
</svg>

After

Width:  |  Height:  |  Size: 759 B

View File

@ -0,0 +1,3 @@
<svg xmlns="http://www.w3.org/2000/svg" width="16" height="16" fill="currentColor" class="bi bi-justify" viewBox="0 0 16 16">
<path fill-rule="evenodd" d="M2 12.5a.5.5 0 0 1 .5-.5h11a.5.5 0 0 1 0 1h-11a.5.5 0 0 1-.5-.5zm0-3a.5.5 0 0 1 .5-.5h11a.5.5 0 0 1 0 1h-11a.5.5 0 0 1-.5-.5zm0-3a.5.5 0 0 1 .5-.5h11a.5.5 0 0 1 0 1h-11a.5.5 0 0 1-.5-.5zm0-3a.5.5 0 0 1 .5-.5h11a.5.5 0 0 1 0 1h-11a.5.5 0 0 1-.5-.5z"/>
</svg>

After

Width:  |  Height:  |  Size: 414 B

View File

@ -0,0 +1,3 @@
<svg xmlns="http://www.w3.org/2000/svg" width="16" height="16" fill="currentColor" class="bi bi-text-center" viewBox="0 0 16 16">
<path fill-rule="evenodd" d="M4 12.5a.5.5 0 0 1 .5-.5h7a.5.5 0 0 1 0 1h-7a.5.5 0 0 1-.5-.5zm-2-3a.5.5 0 0 1 .5-.5h11a.5.5 0 0 1 0 1h-11a.5.5 0 0 1-.5-.5zm2-3a.5.5 0 0 1 .5-.5h7a.5.5 0 0 1 0 1h-7a.5.5 0 0 1-.5-.5zm-2-3a.5.5 0 0 1 .5-.5h11a.5.5 0 0 1 0 1h-11a.5.5 0 0 1-.5-.5z"/>
</svg>

After

Width:  |  Height:  |  Size: 416 B

View File

@ -0,0 +1,3 @@
<svg xmlns="http://www.w3.org/2000/svg" width="16" height="16" fill="currentColor" class="bi bi-text-left" viewBox="0 0 16 16">
<path fill-rule="evenodd" d="M2 12.5a.5.5 0 0 1 .5-.5h7a.5.5 0 0 1 0 1h-7a.5.5 0 0 1-.5-.5zm0-3a.5.5 0 0 1 .5-.5h11a.5.5 0 0 1 0 1h-11a.5.5 0 0 1-.5-.5zm0-3a.5.5 0 0 1 .5-.5h7a.5.5 0 0 1 0 1h-7a.5.5 0 0 1-.5-.5zm0-3a.5.5 0 0 1 .5-.5h11a.5.5 0 0 1 0 1h-11a.5.5 0 0 1-.5-.5z"/>
</svg>

After

Width:  |  Height:  |  Size: 412 B

View File

@ -0,0 +1,3 @@
<svg xmlns="http://www.w3.org/2000/svg" width="16" height="16" fill="currentColor" class="bi bi-text-paragraph" viewBox="0 0 16 16">
<path fill-rule="evenodd" d="M2 12.5a.5.5 0 0 1 .5-.5h7a.5.5 0 0 1 0 1h-7a.5.5 0 0 1-.5-.5zm0-3a.5.5 0 0 1 .5-.5h11a.5.5 0 0 1 0 1h-11a.5.5 0 0 1-.5-.5zm0-3a.5.5 0 0 1 .5-.5h11a.5.5 0 0 1 0 1h-11a.5.5 0 0 1-.5-.5zm4-3a.5.5 0 0 1 .5-.5h7a.5.5 0 0 1 0 1h-7a.5.5 0 0 1-.5-.5z"/>
</svg>

After

Width:  |  Height:  |  Size: 417 B

View File

@ -0,0 +1,3 @@
<svg xmlns="http://www.w3.org/2000/svg" width="16" height="16" fill="currentColor" class="bi bi-text-right" viewBox="0 0 16 16">
<path fill-rule="evenodd" d="M6 12.5a.5.5 0 0 1 .5-.5h7a.5.5 0 0 1 0 1h-7a.5.5 0 0 1-.5-.5zm-4-3a.5.5 0 0 1 .5-.5h11a.5.5 0 0 1 0 1h-11a.5.5 0 0 1-.5-.5zm4-3a.5.5 0 0 1 .5-.5h7a.5.5 0 0 1 0 1h-7a.5.5 0 0 1-.5-.5zm-4-3a.5.5 0 0 1 .5-.5h11a.5.5 0 0 1 0 1h-11a.5.5 0 0 1-.5-.5z"/>
</svg>

After

Width:  |  Height:  |  Size: 415 B

View File

@ -0,0 +1,3 @@
<svg xmlns="http://www.w3.org/2000/svg" width="16" height="16" fill="currentColor" class="bi bi-type-bold" viewBox="0 0 16 16">
<path d="M8.21 13c2.106 0 3.412-1.087 3.412-2.823 0-1.306-.984-2.283-2.324-2.386v-.055a2.176 2.176 0 0 0 1.852-2.14c0-1.51-1.162-2.46-3.014-2.46H3.843V13H8.21zM5.908 4.674h1.696c.963 0 1.517.451 1.517 1.244 0 .834-.629 1.32-1.73 1.32H5.908V4.673zm0 6.788V8.598h1.73c1.217 0 1.88.492 1.88 1.415 0 .943-.643 1.449-1.832 1.449H5.907z"/>
</svg>

After

Width:  |  Height:  |  Size: 470 B

View File

@ -0,0 +1,3 @@
<svg xmlns="http://www.w3.org/2000/svg" width="16" height="16" fill="currentColor" class="bi bi-type-italic" viewBox="0 0 16 16">
<path d="M7.991 11.674 9.53 4.455c.123-.595.246-.71 1.347-.807l.11-.52H7.211l-.11.52c1.06.096 1.128.212 1.005.807L6.57 11.674c-.123.595-.246.71-1.346.806l-.11.52h3.774l.11-.52c-1.06-.095-1.129-.211-1.006-.806z"/>
</svg>

After

Width:  |  Height:  |  Size: 351 B

View File

@ -0,0 +1,3 @@
<svg xmlns="http://www.w3.org/2000/svg" width="16" height="16" fill="currentColor" class="bi bi-type-strikethrough" viewBox="0 0 16 16">
<path d="M6.333 5.686c0 .31.083.581.27.814H5.166a2.776 2.776 0 0 1-.099-.76c0-1.627 1.436-2.768 3.48-2.768 1.969 0 3.39 1.175 3.445 2.85h-1.23c-.11-1.08-.964-1.743-2.25-1.743-1.23 0-2.18.602-2.18 1.607zm2.194 7.478c-2.153 0-3.589-1.107-3.705-2.81h1.23c.144 1.06 1.129 1.703 2.544 1.703 1.34 0 2.31-.705 2.31-1.675 0-.827-.547-1.374-1.914-1.675L8.046 8.5H1v-1h14v1h-3.504c.468.437.675.994.675 1.697 0 1.826-1.436 2.967-3.644 2.967z"/>
</svg>

After

Width:  |  Height:  |  Size: 579 B

View File

@ -0,0 +1,3 @@
<svg xmlns="http://www.w3.org/2000/svg" width="16" height="16" fill="currentColor" class="bi bi-type-underline" viewBox="0 0 16 16">
<path d="M5.313 3.136h-1.23V9.54c0 2.105 1.47 3.623 3.917 3.623s3.917-1.518 3.917-3.623V3.136h-1.23v6.323c0 1.49-.978 2.57-2.687 2.57-1.709 0-2.687-1.08-2.687-2.57V3.136zM12.5 15h-9v-1h9v1z"/>
</svg>

After

Width:  |  Height:  |  Size: 334 B

View File

@ -0,0 +1,73 @@
/**
* Copyright (c) Meta Platforms, Inc. and affiliates.
*
* This source code is licensed under the MIT license found in the
* LICENSE file in the root directory of this source tree.
*
*/
import {AutoFocusPlugin} from '@lexical/react/LexicalAutoFocusPlugin';
import {
InitialConfigType,
LexicalComposer,
} from '@lexical/react/LexicalComposer';
import {ContentEditable} from '@lexical/react/LexicalContentEditable';
import {LexicalErrorBoundary} from '@lexical/react/LexicalErrorBoundary';
import {HistoryPlugin} from '@lexical/react/LexicalHistoryPlugin';
import {RichTextPlugin} from '@lexical/react/LexicalRichTextPlugin';
import {ParagraphNode, TextNode} from 'lexical';
import ExampleTheme from './ExampleTheme';
import {
$createCustomParagraphNode,
CustomParagraphNode,
} from './nodes/CustomParagraphNode';
import ToolbarPlugin from './plugins/ToolbarPlugin';
import TreeViewPlugin from './plugins/TreeViewPlugin';
const placeholder = 'Enter some rich text...';
const editorConfig: InitialConfigType = {
namespace: 'Node Replacement Demo',
nodes: [
ParagraphNode,
TextNode,
CustomParagraphNode,
{
replace: ParagraphNode,
with: () => $createCustomParagraphNode(),
withKlass: CustomParagraphNode,
},
],
onError(error: Error) {
throw error;
},
theme: ExampleTheme,
};
export default function App() {
return (
<LexicalComposer initialConfig={editorConfig}>
<div className="editor-container">
<ToolbarPlugin />
<div className="editor-inner">
<RichTextPlugin
contentEditable={
<ContentEditable
className="editor-input"
aria-placeholder={placeholder}
placeholder={
<div className="editor-placeholder">{placeholder}</div>
}
/>
}
ErrorBoundary={LexicalErrorBoundary}
/>
<HistoryPlugin />
<AutoFocusPlugin />
<TreeViewPlugin />
</div>
</div>
</LexicalComposer>
);
}

View File

@ -0,0 +1,43 @@
/**
* Copyright (c) Meta Platforms, Inc. and affiliates.
*
* This source code is licensed under the MIT license found in the
* LICENSE file in the root directory of this source tree.
*
*/
export default {
code: 'editor-code',
heading: {
h1: 'editor-heading-h1',
h2: 'editor-heading-h2',
h3: 'editor-heading-h3',
h4: 'editor-heading-h4',
h5: 'editor-heading-h5',
},
image: 'editor-image',
link: 'editor-link',
list: {
listitem: 'editor-listitem',
nested: {
listitem: 'editor-nested-listitem',
},
ol: 'editor-list-ol',
ul: 'editor-list-ul',
},
ltr: 'ltr',
paragraph: 'editor-paragraph',
placeholder: 'editor-placeholder',
quote: 'editor-quote',
rtl: 'rtl',
text: {
bold: 'editor-text-bold',
code: 'editor-text-code',
hashtag: 'editor-text-hashtag',
italic: 'editor-text-italic',
overflowed: 'editor-text-overflowed',
strikethrough: 'editor-text-strikethrough',
underline: 'editor-text-underline',
underlineStrikethrough: 'editor-text-underlineStrikethrough',
},
};

View File

@ -0,0 +1,22 @@
/**
* Copyright (c) Meta Platforms, Inc. and affiliates.
*
* This source code is licensed under the MIT license found in the
* LICENSE file in the root directory of this source tree.
*
*/
import './styles.css';
import React from 'react';
import ReactDOM from 'react-dom/client';
import App from './App.tsx';
ReactDOM.createRoot(document.getElementById('root')!).render(
<React.StrictMode>
<div className="App">
<h1>React.js Node Replacement Example</h1>
<App />
</div>
</React.StrictMode>,
);

View File

@ -0,0 +1,37 @@
/**
* Copyright (c) Meta Platforms, Inc. and affiliates.
*
* This source code is licensed under the MIT license found in the
* LICENSE file in the root directory of this source tree.
*
*/
import {
$applyNodeReplacement,
EditorConfig,
ParagraphNode,
SerializedParagraphNode,
} from 'lexical';
export class CustomParagraphNode extends ParagraphNode {
static getType() {
return 'custom-paragraph';
}
static clone(node: CustomParagraphNode): CustomParagraphNode {
return new CustomParagraphNode(node.__key);
}
static importJSON(json: SerializedParagraphNode): CustomParagraphNode {
return $createCustomParagraphNode().updateFromJSON(json);
}
createDOM(config: EditorConfig) {
const el = super.createDOM(config);
// Normally this sort of thing would be done with the theme, this is for
// demonstration purposes only
el.style.border = '1px dashed black';
el.style.background = 'linear-gradient(to top, #f7f8f8, #acbb78)';
return el;
}
}
export function $createCustomParagraphNode() {
return $applyNodeReplacement(new CustomParagraphNode());
}

View File

@ -0,0 +1,172 @@
/**
* Copyright (c) Meta Platforms, Inc. and affiliates.
*
* This source code is licensed under the MIT license found in the
* LICENSE file in the root directory of this source tree.
*
*/
import {useLexicalComposerContext} from '@lexical/react/LexicalComposerContext';
import {mergeRegister} from '@lexical/utils';
import {
$getSelection,
$isRangeSelection,
CAN_REDO_COMMAND,
CAN_UNDO_COMMAND,
FORMAT_ELEMENT_COMMAND,
FORMAT_TEXT_COMMAND,
REDO_COMMAND,
SELECTION_CHANGE_COMMAND,
UNDO_COMMAND,
} from 'lexical';
import {useCallback, useEffect, useRef, useState} from 'react';
const LowPriority = 1;
function Divider() {
return <div className="divider" />;
}
export default function ToolbarPlugin() {
const [editor] = useLexicalComposerContext();
const toolbarRef = useRef(null);
const [canUndo, setCanUndo] = useState(false);
const [canRedo, setCanRedo] = useState(false);
const [isBold, setIsBold] = useState(false);
const [isItalic, setIsItalic] = useState(false);
const [isUnderline, setIsUnderline] = useState(false);
const [isStrikethrough, setIsStrikethrough] = useState(false);
const $updateToolbar = useCallback(() => {
const selection = $getSelection();
if ($isRangeSelection(selection)) {
// Update text format
setIsBold(selection.hasFormat('bold'));
setIsItalic(selection.hasFormat('italic'));
setIsUnderline(selection.hasFormat('underline'));
setIsStrikethrough(selection.hasFormat('strikethrough'));
}
}, []);
useEffect(() => {
return mergeRegister(
editor.registerUpdateListener(({editorState}) => {
editorState.read(() => {
$updateToolbar();
});
}),
editor.registerCommand(
SELECTION_CHANGE_COMMAND,
(_payload, _newEditor) => {
$updateToolbar();
return false;
},
LowPriority,
),
editor.registerCommand(
CAN_UNDO_COMMAND,
(payload) => {
setCanUndo(payload);
return false;
},
LowPriority,
),
editor.registerCommand(
CAN_REDO_COMMAND,
(payload) => {
setCanRedo(payload);
return false;
},
LowPriority,
),
);
}, [editor, $updateToolbar]);
return (
<div className="toolbar" ref={toolbarRef}>
<button
disabled={!canUndo}
onClick={() => {
editor.dispatchCommand(UNDO_COMMAND, undefined);
}}
className="toolbar-item spaced"
aria-label="Undo">
<i className="format undo" />
</button>
<button
disabled={!canRedo}
onClick={() => {
editor.dispatchCommand(REDO_COMMAND, undefined);
}}
className="toolbar-item"
aria-label="Redo">
<i className="format redo" />
</button>
<Divider />
<button
onClick={() => {
editor.dispatchCommand(FORMAT_TEXT_COMMAND, 'bold');
}}
className={'toolbar-item spaced ' + (isBold ? 'active' : '')}
aria-label="Format Bold">
<i className="format bold" />
</button>
<button
onClick={() => {
editor.dispatchCommand(FORMAT_TEXT_COMMAND, 'italic');
}}
className={'toolbar-item spaced ' + (isItalic ? 'active' : '')}
aria-label="Format Italics">
<i className="format italic" />
</button>
<button
onClick={() => {
editor.dispatchCommand(FORMAT_TEXT_COMMAND, 'underline');
}}
className={'toolbar-item spaced ' + (isUnderline ? 'active' : '')}
aria-label="Format Underline">
<i className="format underline" />
</button>
<button
onClick={() => {
editor.dispatchCommand(FORMAT_TEXT_COMMAND, 'strikethrough');
}}
className={'toolbar-item spaced ' + (isStrikethrough ? 'active' : '')}
aria-label="Format Strikethrough">
<i className="format strikethrough" />
</button>
<Divider />
<button
onClick={() => {
editor.dispatchCommand(FORMAT_ELEMENT_COMMAND, 'left');
}}
className="toolbar-item spaced"
aria-label="Left Align">
<i className="format left-align" />
</button>
<button
onClick={() => {
editor.dispatchCommand(FORMAT_ELEMENT_COMMAND, 'center');
}}
className="toolbar-item spaced"
aria-label="Center Align">
<i className="format center-align" />
</button>
<button
onClick={() => {
editor.dispatchCommand(FORMAT_ELEMENT_COMMAND, 'right');
}}
className="toolbar-item spaced"
aria-label="Right Align">
<i className="format right-align" />
</button>
<button
onClick={() => {
editor.dispatchCommand(FORMAT_ELEMENT_COMMAND, 'justify');
}}
className="toolbar-item"
aria-label="Justify Align">
<i className="format justify-align" />
</button>{' '}
</div>
);
}

View File

@ -0,0 +1,27 @@
/**
* Copyright (c) Meta Platforms, Inc. and affiliates.
*
* This source code is licensed under the MIT license found in the
* LICENSE file in the root directory of this source tree.
*
*/
import type {JSX} from 'react';
import {useLexicalComposerContext} from '@lexical/react/LexicalComposerContext';
import {TreeView} from '@lexical/react/LexicalTreeView';
export default function TreeViewPlugin(): JSX.Element {
const [editor] = useLexicalComposerContext();
return (
<TreeView
viewClassName="tree-view-output"
treeTypeButtonClassName="debug-treetype-button"
timeTravelPanelClassName="debug-timetravel-panel"
timeTravelButtonClassName="debug-timetravel-button"
timeTravelPanelSliderClassName="debug-timetravel-panel-slider"
timeTravelPanelButtonClassName="debug-timetravel-panel-button"
editor={editor}
/>
);
}

View File

@ -0,0 +1,25 @@
/**
* Copyright (c) Meta Platforms, Inc. and affiliates.
*
* This source code is licensed under the MIT license found in the
* LICENSE file in the root directory of this source tree.
*
*/
const MIN_ALLOWED_FONT_SIZE = 8;
const MAX_ALLOWED_FONT_SIZE = 72;
export const parseAllowedFontSize = (input: string): string => {
const match = input.match(/^(\d+(?:\.\d+)?)px$/);
if (match) {
const n = Number(match[1]);
if (n >= MIN_ALLOWED_FONT_SIZE && n <= MAX_ALLOWED_FONT_SIZE) {
return input;
}
}
return '';
};
export function parseAllowedColor(input: string) {
return /^rgb\(\d+, \d+, \d+\)$/.test(input) ? input : '';
}

View File

@ -0,0 +1,450 @@
/**
* Copyright (c) Meta Platforms, Inc. and affiliates.
*
* This source code is licensed under the MIT license found in the
* LICENSE file in the root directory of this source tree.
*
*/
body {
margin: 0;
background: #eee;
font-family: system-ui, -apple-system, BlinkMacSystemFont, '.SFNSText-Regular',
sans-serif;
font-weight: 500;
-webkit-font-smoothing: antialiased;
-moz-osx-font-smoothing: grayscale;
}
.other h2 {
font-size: 18px;
color: #444;
margin-bottom: 7px;
}
.other a {
color: #777;
text-decoration: underline;
font-size: 14px;
}
.other ul {
padding: 0;
margin: 0;
list-style-type: none;
}
.App {
font-family: sans-serif;
text-align: center;
}
h1 {
font-size: 24px;
color: #333;
}
.ltr {
text-align: left;
}
.rtl {
text-align: right;
}
.editor-container {
margin: 20px auto 20px auto;
border-radius: 2px;
max-width: 600px;
color: #000;
position: relative;
line-height: 20px;
font-weight: 400;
text-align: left;
border-top-left-radius: 10px;
border-top-right-radius: 10px;
}
.editor-inner {
background: #fff;
position: relative;
}
.editor-input {
min-height: 150px;
resize: none;
font-size: 15px;
caret-color: rgb(5, 5, 5);
position: relative;
tab-size: 1;
outline: 0;
padding: 15px 10px;
caret-color: #444;
}
.editor-placeholder {
color: #999;
overflow: hidden;
position: absolute;
text-overflow: ellipsis;
top: 15px;
left: 10px;
font-size: 15px;
user-select: none;
display: inline-block;
pointer-events: none;
}
.editor-text-bold {
font-weight: bold;
}
.editor-text-italic {
font-style: italic;
}
.editor-text-underline {
text-decoration: underline;
}
.editor-text-strikethrough {
text-decoration: line-through;
}
.editor-text-underlineStrikethrough {
text-decoration: underline line-through;
}
.editor-text-code {
background-color: rgb(240, 242, 245);
padding: 1px 0.25rem;
font-family: Menlo, Consolas, Monaco, monospace;
font-size: 94%;
}
.editor-link {
color: rgb(33, 111, 219);
text-decoration: none;
}
.tree-view-output {
display: block;
background: #222;
color: #fff;
padding: 5px;
font-size: 12px;
white-space: pre-wrap;
margin: 1px auto 10px auto;
max-height: 250px;
position: relative;
border-bottom-left-radius: 10px;
border-bottom-right-radius: 10px;
overflow: auto;
line-height: 14px;
}
.editor-code {
background-color: rgb(240, 242, 245);
font-family: Menlo, Consolas, Monaco, monospace;
display: block;
padding: 8px 8px 8px 52px;
line-height: 1.53;
font-size: 13px;
margin: 0;
margin-top: 8px;
margin-bottom: 8px;
tab-size: 2;
/* white-space: pre; */
overflow-x: auto;
position: relative;
}
.editor-code:before {
content: attr(data-gutter);
position: absolute;
background-color: #eee;
left: 0;
top: 0;
border-right: 1px solid #ccc;
padding: 8px;
color: #777;
white-space: pre-wrap;
text-align: right;
min-width: 25px;
}
.editor-code:after {
content: attr(data-highlight-language);
top: 0;
right: 3px;
padding: 3px;
font-size: 10px;
text-transform: uppercase;
position: absolute;
color: rgba(0, 0, 0, 0.5);
}
.editor-tokenComment {
color: slategray;
}
.editor-tokenPunctuation {
color: #999;
}
.editor-tokenProperty {
color: #905;
}
.editor-tokenSelector {
color: #690;
}
.editor-tokenOperator {
color: #9a6e3a;
}
.editor-tokenAttr {
color: #07a;
}
.editor-tokenVariable {
color: #e90;
}
.editor-tokenFunction {
color: #dd4a68;
}
.editor-paragraph {
margin: 0;
margin-bottom: 8px;
position: relative;
}
.editor-paragraph:last-child {
margin-bottom: 0;
}
.editor-heading-h1 {
font-size: 24px;
color: rgb(5, 5, 5);
font-weight: 400;
margin: 0;
margin-bottom: 12px;
padding: 0;
}
.editor-heading-h2 {
font-size: 15px;
color: rgb(101, 103, 107);
font-weight: 700;
margin: 0;
margin-top: 10px;
padding: 0;
text-transform: uppercase;
}
.editor-quote {
margin: 0;
margin-left: 20px;
font-size: 15px;
color: rgb(101, 103, 107);
border-left-color: rgb(206, 208, 212);
border-left-width: 4px;
border-left-style: solid;
padding-left: 16px;
}
.editor-list-ol {
padding: 0;
margin: 0;
margin-left: 16px;
}
.editor-list-ul {
padding: 0;
margin: 0;
margin-left: 16px;
}
.editor-listitem {
margin: 8px 32px 8px 32px;
}
.editor-nested-listitem {
list-style-type: none;
}
pre::-webkit-scrollbar {
background: transparent;
width: 10px;
}
pre::-webkit-scrollbar-thumb {
background: #999;
}
.debug-timetravel-panel {
overflow: hidden;
padding: 0 0 10px 0;
margin: auto;
display: flex;
}
.debug-timetravel-panel-slider {
padding: 0;
flex: 8;
}
.debug-timetravel-panel-button {
padding: 0;
border: 0;
background: none;
flex: 1;
color: #fff;
font-size: 12px;
}
.debug-timetravel-panel-button:hover {
text-decoration: underline;
}
.debug-timetravel-button {
border: 0;
padding: 0;
font-size: 12px;
top: 10px;
right: 15px;
position: absolute;
background: none;
color: #fff;
}
.debug-timetravel-button:hover {
text-decoration: underline;
}
.toolbar {
display: flex;
margin-bottom: 1px;
background: #fff;
padding: 4px;
border-top-left-radius: 10px;
border-top-right-radius: 10px;
vertical-align: middle;
}
.toolbar button.toolbar-item {
border: 0;
display: flex;
background: none;
border-radius: 10px;
padding: 8px;
cursor: pointer;
vertical-align: middle;
}
.toolbar button.toolbar-item:disabled {
cursor: not-allowed;
}
.toolbar button.toolbar-item.spaced {
margin-right: 2px;
}
.toolbar button.toolbar-item i.format {
background-size: contain;
display: inline-block;
height: 18px;
width: 18px;
margin-top: 2px;
vertical-align: -0.25em;
display: flex;
opacity: 0.6;
}
.toolbar button.toolbar-item:disabled i.format {
opacity: 0.2;
}
.toolbar button.toolbar-item.active {
background-color: rgba(223, 232, 250, 0.3);
}
.toolbar button.toolbar-item.active i {
opacity: 1;
}
.toolbar .toolbar-item:hover:not([disabled]) {
background-color: #eee;
}
.toolbar .divider {
width: 1px;
background-color: #eee;
margin: 0 4px;
}
.toolbar .toolbar-item .text {
display: flex;
line-height: 20px;
width: 200px;
vertical-align: middle;
font-size: 14px;
color: #777;
text-overflow: ellipsis;
width: 70px;
overflow: hidden;
height: 20px;
text-align: left;
}
.toolbar .toolbar-item .icon {
display: flex;
width: 20px;
height: 20px;
user-select: none;
margin-right: 8px;
line-height: 16px;
background-size: contain;
}
i.undo {
background-image: url(icons/arrow-counterclockwise.svg);
}
i.redo {
background-image: url(icons/arrow-clockwise.svg);
}
i.bold {
background-image: url(icons/type-bold.svg);
}
i.italic {
background-image: url(icons/type-italic.svg);
}
i.underline {
background-image: url(icons/type-underline.svg);
}
i.strikethrough {
background-image: url(icons/type-strikethrough.svg);
}
i.left-align {
background-image: url(icons/text-left.svg);
}
i.center-align {
background-image: url(icons/text-center.svg);
}
i.right-align {
background-image: url(icons/text-right.svg);
}
i.justify-align {
background-image: url(icons/justify.svg);
}

View File

@ -0,0 +1 @@
/// <reference types="vite/client" />

View File

@ -0,0 +1,25 @@
{
"compilerOptions": {
"target": "ES2020",
"useDefineForClassFields": true,
"lib": ["ES2020", "DOM", "DOM.Iterable"],
"module": "ESNext",
"skipLibCheck": true,
/* Bundler mode */
"moduleResolution": "bundler",
"allowImportingTsExtensions": true,
"resolveJsonModule": true,
"isolatedModules": true,
"noEmit": true,
"jsx": "react-jsx",
/* Linting */
"strict": true,
"noUnusedLocals": true,
"noUnusedParameters": true,
"noFallthroughCasesInSwitch": true
},
"include": ["src"],
"references": [{"path": "./tsconfig.node.json"}]
}

View File

@ -0,0 +1,11 @@
{
"compilerOptions": {
"composite": true,
"skipLibCheck": true,
"module": "ESNext",
"moduleResolution": "bundler",
"allowSyntheticDefaultImports": true,
"strict": true
},
"include": ["vite.config.ts"]
}

View File

@ -0,0 +1,15 @@
/**
* Copyright (c) Meta Platforms, Inc. and affiliates.
*
* This source code is licensed under the MIT license found in the
* LICENSE file in the root directory of this source tree.
*
*/
import {mergeConfig} from 'vite';
import lexicalMonorepoPlugin from '../../packages/shared/lexicalMonorepoPlugin';
import config from './vite.config';
export default mergeConfig(config, {
plugins: [lexicalMonorepoPlugin()],
});

View File

@ -0,0 +1,14 @@
/**
* Copyright (c) Meta Platforms, Inc. and affiliates.
*
* This source code is licensed under the MIT license found in the
* LICENSE file in the root directory of this source tree.
*
*/
import react from '@vitejs/plugin-react';
import {defineConfig} from 'vite';
// https://vitejs.dev/config/
export default defineConfig({
plugins: [react()],
});

View File

@ -0,0 +1,8 @@
# Node State Style example
Here we have an example that demonstrates how NodeState can be used with a
mutation listener to override behavior of any node.
**Run it locally:** `npm i && npm run dev`
[![Open in StackBlitz](https://developer.stackblitz.com/img/open_in_stackblitz.svg)](https://stackblitz.com/github/facebook/lexical/tree/main/examples/node-state-style?file=src/main.tsx)

View File

@ -0,0 +1,12 @@
<!DOCTYPE html>
<html lang="en">
<head>
<meta charset="UTF-8" />
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
<title>Lexical Node State Example</title>
</head>
<body>
<div id="root"></div>
<script src="/src/main.tsx" type="module"></script>
</body>
</html>

3654
examples/node-state-style/package-lock.json generated Normal file

File diff suppressed because it is too large Load Diff

View File

@ -0,0 +1,38 @@
{
"name": "@lexical/node-state-style-example",
"private": true,
"version": "0.31.0",
"type": "module",
"scripts": {
"dev": "vite",
"monorepo:dev": "vite -c vite.config.monorepo.ts",
"build": "tsc && vite build",
"preview": "vite preview"
},
"dependencies": {
"@ark-ui/react": "^5.6.0",
"@lexical/clipboard": "0.31.0",
"@lexical/html": "0.31.0",
"@lexical/react": "0.31.0",
"@lexical/selection": "0.31.0",
"@lexical/utils": "0.31.0",
"@shikijs/langs": "^3.3.0",
"@shikijs/themes": "^3.3.0",
"inline-style-parser": "^0.2.4",
"lexical": "0.31.0",
"lucide-react": "^0.503.0",
"prettier": "^3.5.3",
"react": "^19.1.0",
"react-dom": "^19.1.0",
"shiki": "^3.3.0"
},
"devDependencies": {
"@types/react": "^19.1.2",
"@types/react-dom": "^19.1.2",
"@vitejs/plugin-react": "^4.4.1",
"cross-env": "^7.0.3",
"csstype": "^3.1.3",
"typescript": "^5.8.3",
"vite": "^6.3.2"
}
}

View File

@ -0,0 +1,114 @@
/**
* Copyright (c) Meta Platforms, Inc. and affiliates.
*
* This source code is licensed under the MIT license found in the
* LICENSE file in the root directory of this source tree.
*
*/
import {Tabs} from '@ark-ui/react/tabs';
import {AutoFocusPlugin} from '@lexical/react/LexicalAutoFocusPlugin';
import {
InitialConfigType,
LexicalComposer,
} from '@lexical/react/LexicalComposer';
import {useLexicalComposerContext} from '@lexical/react/LexicalComposerContext';
import {ContentEditable} from '@lexical/react/LexicalContentEditable';
import {LexicalErrorBoundary} from '@lexical/react/LexicalErrorBoundary';
import {HistoryPlugin} from '@lexical/react/LexicalHistoryPlugin';
import {RichTextPlugin} from '@lexical/react/LexicalRichTextPlugin';
import {
DOMExportOutput,
DOMExportOutputMap,
Klass,
LexicalEditor,
LexicalNode,
ParagraphNode,
TextNode,
} from 'lexical';
import {useEffect} from 'react';
import ExampleTheme from './ExampleTheme';
import {ShikiViewPlugin} from './plugins/ShikiViewPlugin';
import {StyleViewPlugin} from './plugins/StyleViewPlugin';
import {ToolbarPlugin} from './plugins/ToolbarPlugin';
import {
$exportNodeStyle,
constructStyleImportMap,
registerStyleState,
} from './styleState';
const placeholder = 'Enter some rich text...';
const exportMap: DOMExportOutputMap = new Map<
Klass<LexicalNode>,
(editor: LexicalEditor, target: LexicalNode) => DOMExportOutput
>([[TextNode, $exportNodeStyle]]);
const editorConfig: InitialConfigType = {
html: {
export: exportMap,
import: constructStyleImportMap(),
},
namespace: 'NodeState Demo',
nodes: [ParagraphNode, TextNode],
onError(error: Error) {
throw error;
},
theme: ExampleTheme,
};
function StyleStatePlugin() {
const [editor] = useLexicalComposerContext();
useEffect(() => registerStyleState(editor), [editor]);
return null;
}
export default function App() {
return (
<LexicalComposer initialConfig={editorConfig}>
<div className="editor-container">
<ToolbarPlugin />
<div className="editor-inner">
<RichTextPlugin
contentEditable={
<ContentEditable
className="editor-input"
aria-placeholder={placeholder}
placeholder={
<div className="editor-placeholder">{placeholder}</div>
}
/>
}
ErrorBoundary={LexicalErrorBoundary}
/>
<HistoryPlugin />
<AutoFocusPlugin />
<StyleStatePlugin />
</div>
</div>
<Tabs.Root
lazyMount={true}
unmountOnExit={true}
defaultValue="style"
style={{margin: '0 10px'}}>
<Tabs.List
style={{display: 'flex', justifyContent: 'center', margin: '10px 0'}}>
<Tabs.Trigger value="style">Style Tree</Tabs.Trigger>
<Tabs.Trigger value="html">HTML</Tabs.Trigger>
<Tabs.Trigger value="json">JSON</Tabs.Trigger>
<Tabs.Indicator />
</Tabs.List>
<Tabs.Content value="style">
<StyleViewPlugin />
</Tabs.Content>
<Tabs.Content value="html">
<ShikiViewPlugin lang="html" />
</Tabs.Content>
<Tabs.Content value="json">
<ShikiViewPlugin lang="json" />
</Tabs.Content>
</Tabs.Root>
</LexicalComposer>
);
}

View File

@ -0,0 +1,43 @@
/**
* Copyright (c) Meta Platforms, Inc. and affiliates.
*
* This source code is licensed under the MIT license found in the
* LICENSE file in the root directory of this source tree.
*
*/
export default {
code: 'editor-code',
heading: {
h1: 'editor-heading-h1',
h2: 'editor-heading-h2',
h3: 'editor-heading-h3',
h4: 'editor-heading-h4',
h5: 'editor-heading-h5',
},
image: 'editor-image',
link: 'editor-link',
list: {
listitem: 'editor-listitem',
nested: {
listitem: 'editor-nested-listitem',
},
ol: 'editor-list-ol',
ul: 'editor-list-ul',
},
ltr: 'ltr',
paragraph: 'editor-paragraph',
placeholder: 'editor-placeholder',
quote: 'editor-quote',
rtl: 'rtl',
text: {
bold: 'editor-text-bold',
code: 'editor-text-code',
hashtag: 'editor-text-hashtag',
italic: 'editor-text-italic',
overflowed: 'editor-text-overflowed',
strikethrough: 'editor-text-strikethrough',
underline: 'editor-text-underline',
underlineStrikethrough: 'editor-text-underlineStrikethrough',
},
};

View File

@ -0,0 +1,5 @@
Bootstrap Icons
https://icons.getbootstrap.com
Licensed under MIT license
https://github.com/twbs/icons/blob/main/LICENSE.md

View File

@ -0,0 +1,4 @@
<svg xmlns="http://www.w3.org/2000/svg" width="16" height="16" fill="currentColor" class="bi bi-arrow-clockwise" viewBox="0 0 16 16">
<path fill-rule="evenodd" d="M8 3a5 5 0 1 0 4.546 2.914.5.5 0 0 1 .908-.417A6 6 0 1 1 8 2v1z"/>
<path d="M8 4.466V.534a.25.25 0 0 1 .41-.192l2.36 1.966c.12.1.12.284 0 .384L8.41 4.658A.25.25 0 0 1 8 4.466z"/>
</svg>

After

Width:  |  Height:  |  Size: 352 B

View File

@ -0,0 +1,4 @@
<svg xmlns="http://www.w3.org/2000/svg" width="16" height="16" fill="currentColor" class="bi bi-arrow-counterclockwise" viewBox="0 0 16 16">
<path fill-rule="evenodd" d="M8 3a5 5 0 1 1-4.546 2.914.5.5 0 0 0-.908-.417A6 6 0 1 0 8 2v1z"/>
<path d="M8 4.466V.534a.25.25 0 0 0-.41-.192L5.23 2.308a.25.25 0 0 0 0 .384l2.36 1.966A.25.25 0 0 0 8 4.466z"/>
</svg>

After

Width:  |  Height:  |  Size: 359 B

View File

@ -0,0 +1,3 @@
<svg xmlns="http://www.w3.org/2000/svg" width="16" height="16" fill="currentColor" class="bi bi-text-paragraph" viewBox="0 0 16 16">
<path fill-rule="evenodd" d="M2 12.5a.5.5 0 0 1 .5-.5h7a.5.5 0 0 1 0 1h-7a.5.5 0 0 1-.5-.5zm0-3a.5.5 0 0 1 .5-.5h11a.5.5 0 0 1 0 1h-11a.5.5 0 0 1-.5-.5zm0-3a.5.5 0 0 1 .5-.5h11a.5.5 0 0 1 0 1h-11a.5.5 0 0 1-.5-.5zm4-3a.5.5 0 0 1 .5-.5h7a.5.5 0 0 1 0 1h-7a.5.5 0 0 1-.5-.5z"/>
</svg>

After

Width:  |  Height:  |  Size: 417 B

View File

@ -0,0 +1,3 @@
<svg xmlns="http://www.w3.org/2000/svg" width="16" height="16" fill="currentColor" class="bi bi-type-bold" viewBox="0 0 16 16">
<path d="M8.21 13c2.106 0 3.412-1.087 3.412-2.823 0-1.306-.984-2.283-2.324-2.386v-.055a2.176 2.176 0 0 0 1.852-2.14c0-1.51-1.162-2.46-3.014-2.46H3.843V13H8.21zM5.908 4.674h1.696c.963 0 1.517.451 1.517 1.244 0 .834-.629 1.32-1.73 1.32H5.908V4.673zm0 6.788V8.598h1.73c1.217 0 1.88.492 1.88 1.415 0 .943-.643 1.449-1.832 1.449H5.907z"/>
</svg>

After

Width:  |  Height:  |  Size: 470 B

View File

@ -0,0 +1,3 @@
<svg xmlns="http://www.w3.org/2000/svg" width="16" height="16" fill="currentColor" class="bi bi-type-italic" viewBox="0 0 16 16">
<path d="M7.991 11.674 9.53 4.455c.123-.595.246-.71 1.347-.807l.11-.52H7.211l-.11.52c1.06.096 1.128.212 1.005.807L6.57 11.674c-.123.595-.246.71-1.346.806l-.11.52h3.774l.11-.52c-1.06-.095-1.129-.211-1.006-.806z"/>
</svg>

After

Width:  |  Height:  |  Size: 351 B

View File

@ -0,0 +1,3 @@
<svg xmlns="http://www.w3.org/2000/svg" width="16" height="16" fill="currentColor" class="bi bi-type-strikethrough" viewBox="0 0 16 16">
<path d="M6.333 5.686c0 .31.083.581.27.814H5.166a2.776 2.776 0 0 1-.099-.76c0-1.627 1.436-2.768 3.48-2.768 1.969 0 3.39 1.175 3.445 2.85h-1.23c-.11-1.08-.964-1.743-2.25-1.743-1.23 0-2.18.602-2.18 1.607zm2.194 7.478c-2.153 0-3.589-1.107-3.705-2.81h1.23c.144 1.06 1.129 1.703 2.544 1.703 1.34 0 2.31-.705 2.31-1.675 0-.827-.547-1.374-1.914-1.675L8.046 8.5H1v-1h14v1h-3.504c.468.437.675.994.675 1.697 0 1.826-1.436 2.967-3.644 2.967z"/>
</svg>

After

Width:  |  Height:  |  Size: 579 B

View File

@ -0,0 +1,3 @@
<svg xmlns="http://www.w3.org/2000/svg" width="16" height="16" fill="currentColor" class="bi bi-type-underline" viewBox="0 0 16 16">
<path d="M5.313 3.136h-1.23V9.54c0 2.105 1.47 3.623 3.917 3.623s3.917-1.518 3.917-3.623V3.136h-1.23v6.323c0 1.49-.978 2.57-2.687 2.57-1.709 0-2.687-1.08-2.687-2.57V3.136zM12.5 15h-9v-1h9v1z"/>
</svg>

After

Width:  |  Height:  |  Size: 334 B

View File

@ -0,0 +1,22 @@
/**
* Copyright (c) Meta Platforms, Inc. and affiliates.
*
* This source code is licensed under the MIT license found in the
* LICENSE file in the root directory of this source tree.
*
*/
import './styles.css';
import React from 'react';
import ReactDOM from 'react-dom/client';
import App from './App.tsx';
ReactDOM.createRoot(document.getElementById('root')!).render(
<React.StrictMode>
<div className="App">
<h1>NodeState Style Example</h1>
<App />
</div>
</React.StrictMode>,
);

View File

@ -0,0 +1,5 @@
.shiki-view-plugin > pre.shiki {
margin: 0;
padding: 10px;
white-space: pre-wrap;
}

View File

@ -0,0 +1,92 @@
/**
* Copyright (c) Meta Platforms, Inc. and affiliates.
*
* This source code is licensed under the MIT license found in the
* LICENSE file in the root directory of this source tree.
*
*/
import './ShikiViewPlugin.css';
import {$generateHtmlFromNodes} from '@lexical/html';
import {useLexicalComposerContext} from '@lexical/react/LexicalComposerContext';
import {EditorState, LexicalEditor} from 'lexical';
import * as prettier from 'prettier';
import {useEffect, useMemo, useState} from 'react';
import {createHighlighterCore} from 'shiki/core';
import {createJavaScriptRegexEngine} from 'shiki/engine/javascript';
const jsEngine = createJavaScriptRegexEngine({target: 'ES2024'});
const shikiPromise = createHighlighterCore({
engine: jsEngine,
langs: [import('@shikijs/langs/html'), import('@shikijs/langs/json')],
themes: [import('@shikijs/themes/nord')],
});
const prettierPlugins = [
import('prettier/plugins/babel'),
import('prettier/plugins/estree'),
import('prettier/plugins/html'),
];
function editorHTML(editor: LexicalEditor, editorState: EditorState): string {
return editorState.read(() => $generateHtmlFromNodes(editor, null), {editor});
}
function editorJSON(_editor: LexicalEditor, editorState: EditorState): string {
return JSON.stringify(editorState.toJSON(), null, 2);
}
const langs = {
html: editorHTML,
json: editorJSON,
} as const;
export interface ShikiViewPluginProps {
lang: keyof typeof langs;
}
export function ShikiViewPlugin({lang}: ShikiViewPluginProps) {
const [editor] = useLexicalComposerContext();
const [editorState, setEditorState] = useState(() => editor.getEditorState());
useEffect(
() =>
editor.registerUpdateListener((payload) =>
setEditorState(payload.editorState),
),
[editor],
);
const rawCode = useMemo(
() => langs[lang](editor, editorState),
[lang, editor, editorState],
);
const htmlPromise = useMemo(
() =>
(async () => {
const prettified = await prettier.format(rawCode, {
parser: lang,
plugins: (
await Promise.all(prettierPlugins)
).map((mod) => mod.default),
});
return (await shikiPromise).codeToHtml(prettified, {
lang,
theme: 'nord',
});
})(),
[lang, rawCode],
);
const [html, setHtml] = useState('');
useEffect(() => {
let canceled = false;
htmlPromise.then((formatted) => canceled || setHtml(formatted));
return () => {
canceled = true;
};
}, [htmlPromise]);
return (
<div
className="shiki-view-plugin"
dangerouslySetInnerHTML={{__html: html}}
/>
);
}

View File

@ -0,0 +1,205 @@
/* tree-view */
[data-scope='tree-view'][data-part='tree'] {
width: 240px;
}
[data-scope='tree-view'][data-part='item'],
[data-scope='tree-view'][data-part='branch-control'] {
user-select: none;
--padding-inline: 16px;
padding-inline-start: calc(var(--depth) * var(--padding-inline));
padding-inline-end: var(--padding-inline);
display: flex;
align-items: center;
gap: 8px;
border-radius: 2px;
& svg {
width: 16px;
height: 16px;
opacity: 0.5;
position: relative;
top: 3px;
}
&:hover {
background: rgb(243, 243, 243);
}
&[data-selected] {
background: rgb(226, 226, 226);
}
&:focus {
outline: 1px solid rgb(148, 148, 148);
outline-offset: -1px;
}
}
[data-scope='tree-view'][data-part='item-text'],
[data-scope='tree-view'][data-part='branch-text'] {
flex: 1;
}
[data-scope='tree-view'][data-part='branch-content'] {
position: relative;
isolation: isolate;
}
[data-scope='tree-view'][data-part='branch-indent-guide'] {
position: absolute;
content: '';
border-left: 1px solid rgb(226, 226, 226);
height: 100%;
translate: calc(var(--depth) * 1.25rem);
z-index: 0;
}
[data-scope='tree-view'][data-part='branch-indicator'] {
display: flex;
/* align-items: center; */
&[data-state='open'] svg {
transform: rotate(90deg);
}
}
@keyframes slideDown {
from {
opacity: 0.01;
height: 0;
}
to {
opacity: 1;
height: var(--height);
}
}
@keyframes slideUp {
from {
opacity: 1;
height: var(--height);
}
to {
opacity: 0.01;
height: 0;
}
}
[data-scope='tree-view'][data-part='branch-content'] {
overflow: hidden;
max-width: 400px;
}
[data-scope='tree-view'][data-part='branch-content'][data-state='open'] {
animation: slideDown 250ms cubic-bezier(0, 0, 0.38, 0.9);
}
[data-scope='tree-view'][data-part='branch-content'][data-state='closed'] {
animation: slideUp 200ms cubic-bezier(0, 0, 0.38, 0.9);
}
/* splitter */
[data-scope='splitter'][data-part='root'] {
gap: 4px;
}
[data-scope='splitter'][data-part='root'][data-orientation='horizontal'] {
min-height: 300px;
}
[data-scope='splitter'][data-part='root'][data-orientation='vertical'] {
min-height: 300px;
}
[data-scope='splitter'][data-part='panel'] {
display: flex;
/* align-items: center; */
/* justify-content: center; */
border: 1px solid lightgray;
overflow: auto;
padding: 10px;
}
[data-scope='splitter'][data-part='panel']:has(
[data-scope='splitter'][data-part='panel']
) {
border: none;
}
[data-scope='splitter'][data-part='resize-trigger'][data-orientation='vertical'] {
min-height: 12px;
}
.style-view-node-button-delete {
padding: 0;
display: inline-flex;
position: absolute;
top: 0.125em;
left: 0;
border: none;
}
.style-view-node-button-delete svg {
height: 1em;
width: 1em;
}
.style-view-node-button {
cursor: pointer;
}
.style-view-node-text-contents::before,
.style-view-node-text-contents::after {
content: '"';
}
.style-view-node-text-contents {
max-width: 75ch;
text-overflow: ellipsis;
white-space: nowrap;
}
.style-view-text-pane {
font-family: monospace;
}
.style-view-entry {
position: relative;
padding-left: 4ch;
text-indent: -2ch;
}
.style-view-key {
color: #0288d1;
}
.style-view-style-heading {
color: #616161;
}
.style-view-value,
.style-view-value > p {
display: inline;
}
.style-view-value:focus-visible {
outline: 1px auto rgba(20, 20, 20, 0.2);
outline-offset: 4px;
}
.style-view-actions {
padding-left: 2ch;
}
[data-scope='combobox'][data-part='item-group'] {
padding: 2px;
}
[data-scope='combobox'][data-part='content'] {
border: 1px solid #000;
background-color: #fff;
}
[data-scope='combobox'][data-part='item'] {
cursor: pointer;
padding: 0 0.5rem;
}
[data-scope='combobox'][data-part='item'][data-highlighted] {
background-color: rgba(0, 0, 0, 0.2);
}

View File

@ -0,0 +1,840 @@
/**
* Copyright (c) Meta Platforms, Inc. and affiliates.
*
* This source code is licensed under the MIT license found in the
* LICENSE file in the root directory of this source tree.
*
*/
import './StyleViewPlugin.css';
import {
Combobox,
createListCollection,
useCombobox,
} from '@ark-ui/react/combobox';
import {Portal} from '@ark-ui/react/portal';
import {Splitter, useSplitter} from '@ark-ui/react/splitter';
import {
createTreeCollection,
TreeCollection,
TreeView,
useTreeView,
UseTreeViewReturn,
} from '@ark-ui/react/tree-view';
import {LexicalComposer} from '@lexical/react/LexicalComposer';
import {useLexicalComposerContext} from '@lexical/react/LexicalComposerContext';
import {ContentEditable} from '@lexical/react/LexicalContentEditable';
import {LexicalErrorBoundary} from '@lexical/react/LexicalErrorBoundary';
import {PlainTextPlugin} from '@lexical/react/LexicalPlainTextPlugin';
import {$getAdjacentCaret, mergeRegister} from '@lexical/utils';
import {type SelectionDetails} from '@zag-js/combobox';
import {
$addUpdateTag,
$createLineBreakNode,
$createParagraphNode,
$createTabNode,
$createTextNode,
$getCaretRange,
$getChildCaret,
$getNodeByKey,
$getPreviousSelection,
$getRoot,
$getSelection,
$getSiblingCaret,
$isDecoratorNode,
$isElementNode,
$isLineBreakNode,
$isParagraphNode,
$isRangeSelection,
$isRootNode,
$isTabNode,
$isTextNode,
$normalizeCaret,
$setSelection,
$setSelectionFromCaretRange,
BLUR_COMMAND,
COMMAND_PRIORITY_LOW,
type EditorState,
ElementNode,
KEY_DOWN_COMMAND,
LexicalEditor,
LexicalNode,
NodeCaret,
NodeKey,
} from 'lexical';
import {
ChevronRightIcon,
CircleXIcon,
CodeXmlIcon,
CornerDownLeftIcon,
FolderIcon,
FolderRoot,
TextIcon,
} from 'lucide-react';
import React, {
createContext,
Fragment,
type JSX,
KeyboardEventHandler,
use,
useCallback,
useEffect,
useMemo,
useReducer,
useRef,
useState,
} from 'react';
import {
$removeStyleProperty,
$setStyleProperty,
getStyleObjectDirect,
StyleObject,
styleObjectToArray,
} from '../styleState';
const SKIP_DOM_SELECTION_TAG = 'skip-dom-selection';
const SKIP_SCROLL_INTO_VIEW_TAG = 'skip-scroll-into-view';
function $preserveSelection(): void {
const selection = $getSelection();
if (!selection) {
const prevSelection = $getPreviousSelection();
if (prevSelection) {
$setSelection(prevSelection.clone());
}
}
}
const EditorStateContext = createContext<undefined | EditorState>(undefined);
function useEditorState() {
const editorState = use(EditorStateContext);
if (editorState === undefined) {
throw new Error('Missing EditorStateContext');
}
return editorState;
}
const NodeTreeViewContext = createContext<
undefined | UseTreeViewReturn<NodeKey>
>(undefined);
function useNodeTreeViewContext() {
const ctx = use(NodeTreeViewContext);
if (ctx === undefined) {
throw new Error('Missing NodeTreeViewContext');
}
return ctx;
}
export function StyleViewPlugin(): JSX.Element {
const [editor] = useLexicalComposerContext();
const [editorState, setEditorState] = useState(() => editor.getEditorState());
useEffect(
() =>
editor.registerUpdateListener(() => {
setEditorState(editor.getEditorState());
}),
[editor],
);
return (
<EditorStateContext.Provider value={editorState}>
<LexicalTreeView />
</EditorStateContext.Provider>
);
}
function NodeLabel({node}: {node: LexicalNode}) {
const [editor] = useLexicalComposerContext();
const key = node.getKey();
const type = node.getType();
const reactLabel = (
<>
<button
className="style-view-node-button"
title={`Select ${type} node`}
onClick={(event) => {
event.preventDefault();
editor.update(() => {
const caretRange = $isElementNode(node)
? $getCaretRange(
$getChildCaret(node, 'previous').getFlipped(),
$getChildCaret(node, 'next'),
)
: $getCaretRange(
$normalizeCaret(
$getSiblingCaret(node, 'previous').getFlipped(),
),
$normalizeCaret($getSiblingCaret(node, 'next')),
);
$setSelectionFromCaretRange(caretRange);
});
}}>
({key})
</button>{' '}
{type}
</>
);
if ($isTextNode(node)) {
const text = node.__text;
return (
<>
{reactLabel}{' '}
<span className="style-view-node-text-contents" title={text}>
{text}
</span>
</>
);
}
return reactLabel;
}
function describeNode(node: LexicalNode): [string, React.ReactNode] {
return [`(${node.getKey()}) ${node.getType()}`, <NodeLabel node={node} />];
}
function LexicalNodeTreeViewItem(props: TreeView.NodeProviderProps<NodeKey>) {
const id = props.node;
const editorState = useEditorState();
const node =
typeof id === 'string'
? editorState.read(() => $getNodeByKey(id, editorState))
: null;
const indexPathString = JSON.stringify(props.indexPath);
return useMemo(() => {
if (!node) {
return null;
}
const indexPath = JSON.parse(indexPathString);
const [_ariaLabel, label] = describeNode(node);
const nextNode = node.__next && (
<LexicalNodeTreeViewItem
node={node.__next}
key={node.__next}
indexPath={[...indexPath.slice(0, -1), (indexPath.at(-1) || 0) + 1]}
/>
);
const icon = $isRootNode(node) ? (
<FolderRoot />
) : $isElementNode(node) ? (
<FolderIcon />
) : $isTextNode(node) ? (
<TextIcon />
) : $isDecoratorNode(node) ? (
<CodeXmlIcon />
) : $isLineBreakNode(node) ? (
<CornerDownLeftIcon />
) : null;
let content: React.ReactNode;
if ($isElementNode(node)) {
content = (
<TreeView.Branch>
<TreeView.BranchControl>
<TreeView.BranchText>
{icon} {label}
</TreeView.BranchText>
<TreeView.BranchIndicator>
{node.__first ? <ChevronRightIcon /> : null}
</TreeView.BranchIndicator>
</TreeView.BranchControl>
<TreeView.BranchContent>
<TreeView.BranchIndentGuide />
{node.__first ? (
<LexicalNodeTreeViewItem
node={node.__first}
key={node.__first}
indexPath={[...indexPath, 0]}
/>
) : null}
</TreeView.BranchContent>
</TreeView.Branch>
);
} else {
content = (
<TreeView.Item>
<TreeView.ItemText>
{icon} {label}
</TreeView.ItemText>
</TreeView.Item>
);
}
return (
<Fragment>
<TreeView.NodeProvider key={id} node={id} indexPath={indexPath}>
{content}
</TreeView.NodeProvider>
{nextNode}
</Fragment>
);
}, [id, node, indexPathString]);
}
function getSelectedNodeKey(
api: UseTreeViewReturn<NodeKey>,
): undefined | NodeKey {
return api.selectedValue.at(0);
}
interface SelectedNodeStateAction {
panelNodeKey: undefined | NodeKey;
editorState: EditorState;
}
interface SelectedNodeState extends SelectedNodeStateAction {
panelNodeKey: NodeKey;
selectionNodeKey: NodeKey | null;
panelNode: LexicalNode | null;
cached: React.ReactNode;
}
interface InitialSelectedNodeState extends SelectedNodeStateAction {
selectionNodeKey?: undefined;
panelNode?: undefined;
cached?: undefined;
}
interface StyleValueEditorProps {
ref?: React.Ref<LexicalEditor>;
prop: keyof StyleObject;
value: string;
onChange: (prop: keyof StyleObject, value: string) => void;
}
type ParsedChunk = '\n' | '\r\n' | '\t' | string;
function parseRawText(text: string): ParsedChunk[] {
return text.split(/(\r?\n|\t)/);
}
function $patchNodes<T extends ElementNode>(
parent: T,
nodes: LexicalNode[],
): T {
const childrenSize = parent.getChildrenSize();
if (
childrenSize === nodes.length &&
parent.getChildren().every((node, i) => node === nodes[i])
) {
// no-op, do not mark as dirty
return parent;
}
return $getChildCaret(parent, 'next').splice(childrenSize, nodes).origin;
}
function $patchParsedText<T extends ElementNode>(
parent: T,
chunks: readonly ParsedChunk[],
): T {
let caret: null | NodeCaret<'next'> = $getChildCaret(parent, 'next');
const nodes: LexicalNode[] = [];
for (const chunk of chunks) {
caret = $getAdjacentCaret(caret);
const node = caret ? caret.origin : null;
if (chunk === '\r\n' || chunk === '\n') {
nodes.push($isLineBreakNode(node) ? node : $createLineBreakNode());
} else if (chunk === '\t') {
nodes.push($isTabNode(node) ? node : $createTabNode());
} else if (chunk) {
nodes.push(
$isTextNode(node)
? node.getTextContent() === chunk
? node
: node.setTextContent(chunk)
: $createTextNode(chunk),
);
}
}
return $patchNodes(parent, nodes);
}
function $patchParsedTextAtRoot(chunks: readonly ParsedChunk[]): void {
const root = $getRoot();
const firstNode = root.getFirstChild();
const p = $isParagraphNode(firstNode) ? firstNode : $createParagraphNode();
$getChildCaret(root, 'next').splice(root.getChildrenSize(), [p]);
$patchParsedText(p, chunks);
}
function StyleValuePlugin(props: StyleValueEditorProps) {
const [editor] = useLexicalComposerContext();
const {prop, onChange, ref} = props;
const valueRef = useRef(props.value);
useEffect(() => {
const setRef =
typeof ref === 'function'
? ref
: ref
? (value: LexicalEditor | null) => {
ref.current = value;
}
: () => {};
setRef(editor);
return () => {
setRef(null);
};
}, [editor, ref]);
useEffect(() => {
valueRef.current = props.value;
}, [props.value]);
useEffect(() => {
let timer: undefined | ReturnType<typeof setTimeout>;
function clearTimer() {
if (timer) {
clearTimeout(timer);
timer = undefined;
}
}
function handleInput() {
clearTimer();
setTimeout(handleFlush, 300);
}
function handleFlush() {
clearTimer();
const value = editor
.getEditorState()
.read(() => $getRoot().getTextContent());
if (valueRef.current !== value) {
onChange(prop, value);
}
}
return mergeRegister(
editor.registerUpdateListener((payload) => {
if (payload.editorState !== payload.prevEditorState) {
handleInput();
}
}),
editor.registerCommand(
BLUR_COMMAND,
() => {
handleFlush();
return true;
},
COMMAND_PRIORITY_LOW,
),
editor.registerCommand(
KEY_DOWN_COMMAND,
(e) => {
if (e.key === 'Enter') {
e.preventDefault();
editor.blur();
return true;
}
return false;
},
COMMAND_PRIORITY_LOW,
),
);
}, [editor, prop, onChange]);
return (
<PlainTextPlugin
contentEditable={
<ContentEditable
className="style-view-value"
contentEditable="plaintext-only"
/>
}
ErrorBoundary={LexicalErrorBoundary}
/>
);
}
function StyleValueEditor(props: StyleValueEditorProps) {
return (
<LexicalComposer
initialConfig={{
editable: true,
editorState: () => {
$patchParsedTextAtRoot(parseRawText(props.value));
},
namespace: 'style-view-value',
onError: (err) => {
throw err;
},
}}>
<StyleValuePlugin {...props} />
</LexicalComposer>
);
}
function LexicalTextSelectionPaneContents({node}: {node: LexicalNode}) {
const [editor] = useLexicalComposerContext();
const [registeredNodes] = useState(
() => new Map<keyof StyleObject, LexicalEditor>(),
);
const styles = getStyleObjectDirect(node);
const focusPropertyRef = useRef('');
const nodeRef = useRef(node);
useEffect(() => {
nodeRef.current = node;
}, [node]);
const {handleChange, handleAddProperty} = useMemo(() => {
// eslint-disable-next-line no-shadow
const handleAddProperty = (prop: keyof StyleObject) => {
const reg = registeredNodes.get(prop);
if (reg) {
reg.focus();
} else {
focusPropertyRef.current = prop;
editor.update(
() => {
$setStyleProperty(nodeRef.current, prop, '');
},
{tag: [SKIP_DOM_SELECTION_TAG, SKIP_SCROLL_INTO_VIEW_TAG]},
);
}
};
// eslint-disable-next-line no-shadow
const handleChange = (
prop: keyof StyleObject,
textContent: string | null,
) => {
editor.update(
() => {
$preserveSelection();
$addUpdateTag(SKIP_DOM_SELECTION_TAG);
$addUpdateTag(SKIP_SCROLL_INTO_VIEW_TAG);
$setStyleProperty(nodeRef.current, prop, textContent || undefined);
},
{tag: [SKIP_DOM_SELECTION_TAG, SKIP_SCROLL_INTO_VIEW_TAG]},
);
};
return {handleAddProperty, handleChange};
}, [editor, registeredNodes]);
const rows = useMemo(
() =>
styleObjectToArray(styles).map(([k, v]) => (
<div key={k} className="style-view-entry">
<button
className="style-view-node-button style-view-node-button-delete"
title={`Remove ${k} style`}
onClick={(e) => {
e.preventDefault();
editor.update(
() => {
$removeStyleProperty(nodeRef.current, k);
},
{tag: [SKIP_DOM_SELECTION_TAG, SKIP_SCROLL_INTO_VIEW_TAG]},
);
}}>
<CircleXIcon />
</button>
<span className="style-view-key">{k}: </span>
<StyleValueEditor
prop={k}
value={v || ''}
onChange={handleChange}
ref={(el) => {
if (el === null) {
registeredNodes.delete(k);
return;
}
if (focusPropertyRef.current === k) {
el.focus();
focusPropertyRef.current = '';
}
registeredNodes.set(k, el);
}}
/>
</div>
)),
[editor, registeredNodes, styles, handleChange],
);
return (
<div>
<span>{describeNode(node)[1]}</span>
<div>
<div>
<span className="style-view-style-heading">style</span> {'{'}
</div>
{rows}
<div className="style-view-actions">
<CSSPropertyComboBox onAddProperty={handleAddProperty} />
</div>
<div>{'}'}</div>
</div>
</div>
);
}
function initTextSelectionPaneReducer(action: SelectedNodeStateAction) {
return textSelectionPaneReducer(action, action);
}
function textSelectionPaneReducer(
state: InitialSelectedNodeState | SelectedNodeState,
action: SelectedNodeStateAction,
): SelectedNodeState {
return action.editorState.read(() => {
const selection = $getSelection();
const selectionNodeKey = $isRangeSelection(selection)
? selection.focus.key
: null;
const {
// selectionNodeKey: prevSelectionNodeKey = null,
panelNode: prevPanelNode = null,
cached: prevCached = null,
} = state;
let panelNodeKey =
selectionNodeKey || action.panelNodeKey || state.panelNodeKey;
if (selectionNodeKey) {
panelNodeKey = selectionNodeKey;
} else if (!panelNodeKey) {
panelNodeKey = 'root';
}
const panelNode = $getNodeByKey(panelNodeKey);
if (panelNode === prevPanelNode && state.cached) {
return state;
}
const cached =
panelNode === prevPanelNode && prevCached ? (
prevCached
) : panelNode === null ? (
<span>Node {panelNodeKey} no longer in the document</span>
) : (
<LexicalTextSelectionPaneContents key={panelNodeKey} node={panelNode} />
);
return {...action, cached, panelNode, panelNodeKey, selectionNodeKey};
});
}
function getSuggestedStyleKeys(): readonly (keyof StyleObject)[] {
const keys = new Set<keyof StyleObject>();
if (typeof document !== 'undefined') {
const {style} = document.body;
for (const k in style) {
if (typeof style[k] === 'string') {
const kebab = k
.replace(/[A-Z]/g, (s) => '-' + s.toLowerCase())
.replace(/^(webkit|moz|ms|o)-/, '-$1-')
.replace(/^css-/, '');
keys.add(kebab as keyof StyleObject);
}
}
}
return [...keys].sort();
}
function isNotVendorProperty(item: string): boolean {
return !item.startsWith('-');
}
function useSuggestedStylesCombobox(props: CSSPropertyComboBoxProps) {
const initialItems = useMemo(getSuggestedStyleKeys, []);
const [items, setItems] = useState(() =>
initialItems.filter(isNotVendorProperty).join('\n'),
);
const collection = useMemo(
() => createListCollection({items: items.split(/\n/g)}),
[items],
);
const handleInputValueChange = (
details: Combobox.InputValueChangeDetails,
) => {
const search = details.inputValue.toLowerCase();
setItems(
initialItems
.filter(
search
? (item) => item.toLowerCase().startsWith(search)
: isNotVendorProperty,
)
.join('\n'),
);
};
const handleSelect = (details: SelectionDetails) => {
props.onAddProperty(details.itemValue as keyof StyleObject);
combobox.setInputValue('');
};
const combobox = useCombobox({
allowCustomValue: true,
collection: collection,
inputBehavior: 'autocomplete',
onInputValueChange: handleInputValueChange,
onSelect: handleSelect,
placeholder: 'Add CSS Property',
positioning: {
placement: 'bottom-start',
sameWidth: false,
},
});
return combobox;
}
interface CSSPropertyComboBoxProps {
onAddProperty: (property: keyof StyleObject) => void;
}
const CSSPropertyComboBox = (props: CSSPropertyComboBoxProps) => {
const {onAddProperty} = props;
const combobox = useSuggestedStylesCombobox(props);
const handleKeydown = useCallback<KeyboardEventHandler<HTMLInputElement>>(
(event) => {
const {inputValue} = combobox;
if (event.key === 'Enter') {
event.preventDefault();
if (inputValue) {
onAddProperty(inputValue as keyof StyleObject);
combobox.setInputValue('');
}
} else if (event.key === 'Tab') {
event.preventDefault();
if (inputValue) {
const [autocomplete] = combobox.collection.items;
if (autocomplete) {
onAddProperty(autocomplete as keyof StyleObject);
combobox.setInputValue('');
}
}
}
},
[combobox, onAddProperty],
);
return (
<>
<Combobox.RootProvider value={combobox} lazyMount={true}>
<Combobox.Control>
<Combobox.Input onKeyDown={handleKeydown} />
</Combobox.Control>
<Portal>
<Combobox.Positioner>
<Combobox.Content>
<Combobox.ItemGroup>
{combobox.collection.items.map((item) => (
<Combobox.Item key={item} item={item}>
<Combobox.ItemText>{item}</Combobox.ItemText>
<Combobox.ItemIndicator></Combobox.ItemIndicator>
</Combobox.Item>
))}
</Combobox.ItemGroup>
</Combobox.Content>
</Combobox.Positioner>
</Portal>
</Combobox.RootProvider>
</>
);
};
function LexicalTextSelectionPane() {
const editorState = useEditorState();
const api = useNodeTreeViewContext();
const panelNodeKey = getSelectedNodeKey(api);
const [state, dispatch] = useReducer(
textSelectionPaneReducer,
{editorState, panelNodeKey},
initTextSelectionPaneReducer,
);
useEffect(() => {
dispatch({editorState, panelNodeKey});
}, [panelNodeKey, editorState]);
return state.cached || null;
}
function LexicalTreeView() {
const collectionState = useEditorCollectionState();
const {collection, focusNodeKey} = collectionState;
const [editor] = useLexicalComposerContext();
const editorRef = useRef(editor);
useEffect(() => {
editorRef.current = editor;
}, [editor]);
const treeView = useTreeView({
collection,
defaultExpandedValue: ['root'],
});
useEffect(() => {
if (
focusNodeKey !== null &&
!treeView.expandedValue.includes(focusNodeKey)
) {
treeView.expand([focusNodeKey]);
}
}, [treeView, focusNodeKey]);
const splitter = useSplitter({
defaultSize: [50, 50],
panels: [{id: 'tree'}, {id: 'node'}],
});
return (
<nav className="style-view-text-pane" aria-label="Lexical Nodes">
<NodeTreeViewContext.Provider value={treeView}>
<Splitter.RootProvider value={splitter}>
<Splitter.Panel id="tree">
<TreeView.RootProvider value={treeView}>
<TreeView.Tree>
<LexicalNodeTreeViewItem node="root" indexPath={[]} />
</TreeView.Tree>
</TreeView.RootProvider>
</Splitter.Panel>
<Splitter.ResizeTrigger id="tree:node" />
<Splitter.Panel id="node">
<LexicalTextSelectionPane />
</Splitter.Panel>
</Splitter.RootProvider>
</NodeTreeViewContext.Provider>
</nav>
);
}
interface EditorCollectionState {
editor: LexicalEditor;
editorState: EditorState;
collection: TreeCollection<NodeKey>;
focusNodeKey: null | NodeKey;
}
function nextFocusNodeKey(state: EditorCollectionState): null | NodeKey {
return state.editorState.read(() => {
const selection = $getSelection();
return selection && $isRangeSelection(selection)
? selection.focus.getNode().getKey()
: null;
});
}
function initEditorCollection(
state: Omit<EditorCollectionState, 'collection' | 'focusNodeKey'> &
Partial<Pick<EditorCollectionState, 'collection' | 'focusNodeKey'>>,
): EditorCollectionState {
return Object.assign(state, {
collection: createTreeCollection<NodeKey>({
isNodeDisabled: () => false,
nodeToChildren: (nodeKey) =>
state.editorState.read(() => {
const node = $getNodeByKey(nodeKey);
return $isElementNode(node) ? node.getChildrenKeys() : [];
}),
nodeToString: (nodeKey) => nodeKey,
nodeToValue: (nodeKey) => nodeKey,
rootNode: 'root',
}),
focusNodeKey: null,
});
}
function editorCollectionReducer(
state: EditorCollectionState,
action: Partial<EditorCollectionState>,
) {
let nextState = {...state, ...action};
if (action.editor && action.editor !== state.editor) {
nextState = initEditorCollection(nextState);
}
nextState.focusNodeKey = nextFocusNodeKey(nextState);
return nextState;
}
function useEditorCollectionState() {
const [editor] = useLexicalComposerContext();
const editorState = useEditorState();
const [state, dispatch] = useReducer(
editorCollectionReducer,
{editor, editorState},
initEditorCollection,
);
useEffect(() => {
dispatch({editor, editorState});
}, [editor, editorState]);
return state;
}

View File

@ -0,0 +1,162 @@
/**
* Copyright (c) Meta Platforms, Inc. and affiliates.
*
* This source code is licensed under the MIT license found in the
* LICENSE file in the root directory of this source tree.
*
*/
import {useLexicalComposerContext} from '@lexical/react/LexicalComposerContext';
import {mergeRegister} from '@lexical/utils';
import {
$getSelection,
$isRangeSelection,
CAN_REDO_COMMAND,
CAN_UNDO_COMMAND,
COMMAND_PRIORITY_LOW,
FORMAT_TEXT_COMMAND,
REDO_COMMAND,
SELECTION_CHANGE_COMMAND,
UNDO_COMMAND,
} from 'lexical';
import {useCallback, useEffect, useRef, useState} from 'react';
import {
$selectionHasStyle,
NO_STYLE,
PATCH_TEXT_STYLE_COMMAND,
} from '../styleState';
function Divider() {
return <div className="divider" />;
}
export function ToolbarPlugin() {
const [editor] = useLexicalComposerContext();
const toolbarRef = useRef(null);
const [canUndo, setCanUndo] = useState(false);
const [canRedo, setCanRedo] = useState(false);
const [isBold, setIsBold] = useState(false);
const [isItalic, setIsItalic] = useState(false);
const [isUnderline, setIsUnderline] = useState(false);
const [isStrikethrough, setIsStrikethrough] = useState(false);
const [isStyled, setIsStyled] = useState(false);
const $updateToolbar = useCallback(() => {
const selection = $getSelection();
setIsStyled($selectionHasStyle());
if ($isRangeSelection(selection)) {
// Update text format
setIsBold(selection.hasFormat('bold'));
setIsItalic(selection.hasFormat('italic'));
setIsUnderline(selection.hasFormat('underline'));
setIsStrikethrough(selection.hasFormat('strikethrough'));
}
}, []);
useEffect(() => {
return mergeRegister(
editor.registerUpdateListener(({editorState}) => {
editorState.read(() => {
$updateToolbar();
});
}),
editor.registerCommand(
SELECTION_CHANGE_COMMAND,
(_payload, _newEditor) => {
$updateToolbar();
return false;
},
COMMAND_PRIORITY_LOW,
),
editor.registerCommand(
CAN_UNDO_COMMAND,
(payload) => {
setCanUndo(payload);
return false;
},
COMMAND_PRIORITY_LOW,
),
editor.registerCommand(
CAN_REDO_COMMAND,
(payload) => {
setCanRedo(payload);
return false;
},
COMMAND_PRIORITY_LOW,
),
);
}, [editor, $updateToolbar]);
return (
<div className="toolbar" ref={toolbarRef}>
<button
disabled={!canUndo}
onClick={() => {
editor.dispatchCommand(UNDO_COMMAND, undefined);
}}
className="toolbar-item spaced"
aria-label="Undo">
<i className="format undo" />
</button>
<button
disabled={!canRedo}
onClick={() => {
editor.dispatchCommand(REDO_COMMAND, undefined);
}}
className="toolbar-item"
aria-label="Redo">
<i className="format redo" />
</button>
<Divider />
<button
onClick={() => {
editor.dispatchCommand(FORMAT_TEXT_COMMAND, 'bold');
}}
className={'toolbar-item spaced ' + (isBold ? 'active' : '')}
aria-label="Format Bold">
<i className="format bold" />
</button>
<button
onClick={() => {
editor.dispatchCommand(FORMAT_TEXT_COMMAND, 'italic');
}}
className={'toolbar-item spaced ' + (isItalic ? 'active' : '')}
aria-label="Format Italics">
<i className="format italic" />
</button>
<button
onClick={() => {
editor.dispatchCommand(FORMAT_TEXT_COMMAND, 'underline');
}}
className={'toolbar-item spaced ' + (isUnderline ? 'active' : '')}
aria-label="Format Underline">
<i className="format underline" />
</button>
<button
onClick={() => {
editor.dispatchCommand(FORMAT_TEXT_COMMAND, 'strikethrough');
}}
className={'toolbar-item spaced ' + (isStrikethrough ? 'active' : '')}
aria-label="Format Strikethrough">
<i className="format strikethrough" />
</button>
<Divider />
<button
onClick={() => {
editor.dispatchCommand(
PATCH_TEXT_STYLE_COMMAND,
isStyled
? () => NO_STYLE
: {
'text-shadow':
'1px 1px 2px red, 0 0 1em blue, 0 0 0.2em blue',
},
);
}}
className="toolbar-item spaced"
aria-label="Toggle Text Style">
<i className={'text-shadow ' + (isStyled ? 'active' : '')} />
</button>
</div>
);
}

View File

@ -0,0 +1,468 @@
/**
* Copyright (c) Meta Platforms, Inc. and affiliates.
*
* This source code is licensed under the MIT license found in the
* LICENSE file in the root directory of this source tree.
*
*/
import type {PropertiesHyphenFallback} from 'csstype';
import {$forEachSelectedTextNode} from '@lexical/selection';
import {mergeRegister} from '@lexical/utils';
import InlineStyleParser from 'inline-style-parser';
import {
$caretRangeFromSelection,
$getNodeByKey,
$getPreviousSelection,
$getSelection,
$getState,
$isRangeSelection,
$isTextNode,
$setSelection,
$setState,
COMMAND_PRIORITY_EDITOR,
createCommand,
createState,
DOMConversionMap,
DOMExportOutput,
isDocumentFragment,
isHTMLElement,
LexicalEditor,
LexicalNode,
RootNode,
TextNode,
ValueOrUpdater,
} from 'lexical';
/**
* Creates an object containing all the styles and their values provided in the CSS string.
* @param css - The CSS string of styles and their values.
* @returns The styleObject containing all the styles and their values.
*/
export function getStyleObjectFromRawCSS(css: string): StyleObject {
let styleObject: undefined | Record<string, string>;
for (const token of InlineStyleParser(css, {silent: true})) {
if (token.type === 'declaration' && token.value) {
styleObject = styleObject || {};
styleObject[token.property] = token.value;
}
}
return styleObject || NO_STYLE;
}
// eslint-disable-next-line @typescript-eslint/ban-types
export type Prettify<T> = {[K in keyof T]: T[K]} & {};
export type StyleObject = Prettify<{
[K in keyof PropertiesHyphenFallback]?:
| undefined
// This is simplified to not deal with arrays or numbers.
// This is an example after all!
| Extract<PropertiesHyphenFallback[K], string>;
}>;
export type StyleTuple = Exclude<
{
[K in keyof StyleObject]: [K, null | Exclude<StyleObject[K], undefined>];
}[keyof StyleObject],
undefined
>;
export const NO_STYLE: StyleObject = Object.freeze({});
function parse(v: unknown): StyleObject {
return typeof v === 'string' ? getStyleObjectFromRawCSS(v) : NO_STYLE;
}
function unparse(style: StyleObject): string {
const styles: string[] = [];
for (const [k, v] of Object.entries(style)) {
if (k && v) {
styles.push(`${k}: ${v};`);
}
}
return styles.sort().join(' ');
}
function isEqualValue(
a: StyleObject[keyof StyleObject],
b: StyleObject[keyof StyleObject],
): boolean {
return a === b || (!a && !b);
}
function isEqual(a: StyleObject, b: StyleObject): boolean {
if (a === b) {
return true;
}
for (const k in a) {
if (
!(
k in b &&
isEqualValue(a[k as keyof StyleObject], b[k as keyof StyleObject])
)
) {
return false;
}
}
for (const k in b) {
if (!(k in a)) {
return false;
}
}
return true;
}
export const styleState = createState('style', {
isEqual,
parse,
unparse,
});
export function $getStyleProperty<Prop extends keyof StyleObject>(
node: LexicalNode,
prop: Prop,
): undefined | StyleObject[Prop] {
return $getStyleObject(node)[prop];
}
// eslint-disable-next-line @lexical/rules-of-lexical
export function getStyleObjectDirect(node: LexicalNode): StyleObject {
return $getState(node, styleState, 'direct');
}
export function $getStyleObject(node: LexicalNode): StyleObject {
return $getState(node, styleState);
}
export function $setStyleObject<T extends LexicalNode>(
node: T,
valueOrUpdater: ValueOrUpdater<StyleObject>,
): T {
return $setState(node, styleState, valueOrUpdater);
}
export function $removeStyleProperty<
T extends LexicalNode,
Prop extends keyof StyleObject,
>(node: T, prop: Prop): T {
return $setStyleObject(node, (prevStyle) => {
if (prop in prevStyle) {
const {[prop]: _ignore, ...nextStyle} = prevStyle;
return nextStyle;
}
return prevStyle;
});
}
export function $setStyleProperty<
T extends LexicalNode,
Prop extends keyof StyleObject,
>(node: T, prop: Prop, value: ValueOrUpdater<StyleObject[Prop]>): T {
return $setStyleObject(node, (prevStyle) => {
const prevValue = prevStyle[prop];
const nextValue = typeof value === 'function' ? value(prevValue) : value;
return prevValue === nextValue
? prevStyle
: {...prevStyle, [prop]: nextValue};
});
}
export function applyStyle(
element: HTMLElement,
styleObject: StyleObject,
): void {
for (const k_ in styleObject) {
const k = k_ as keyof StyleObject;
element.style.setProperty(k, styleObject[k] ?? null);
}
}
export function diffStyleObjects(
prevStyles: StyleObject,
nextStyles: StyleObject,
): StyleObject {
let styleDiff: undefined | Record<string, string | undefined>;
if (prevStyles !== nextStyles) {
for (const k_ in nextStyles) {
const k = k_ as keyof StyleObject;
const nextV = nextStyles[k];
const prevV = prevStyles[k];
if (!isEqualValue(nextV, prevV)) {
styleDiff = styleDiff || {};
styleDiff[k] = nextV;
}
}
for (const k in prevStyles) {
if (!(k in nextStyles)) {
styleDiff = styleDiff || {};
styleDiff[k] = undefined;
}
}
}
return styleDiff || NO_STYLE;
}
export function mergeStyleObjects(
prevStyles: StyleObject,
nextStyles: StyleObject,
): StyleObject {
return prevStyles === NO_STYLE || prevStyles === nextStyles
? nextStyles
: {...prevStyles, ...nextStyles};
}
export function styleObjectToArray(styleObject: StyleObject): StyleTuple[] {
const entries: StyleTuple[] = [];
for (const k_ in styleObject) {
const k = k_ as keyof StyleObject;
entries.push([k, styleObject[k] ?? null] as StyleTuple);
}
entries.sort(([a], [b]) => a.localeCompare(b));
return entries;
}
export const PATCH_TEXT_STYLE_COMMAND = createCommand<
StyleObject | ((prevStyles: StyleObject) => StyleObject)
>('PATCH_TEXT_STYLE_COMMAND');
function $nodeHasStyle(node: LexicalNode): boolean {
return !isEqual(NO_STYLE, $getStyleObject(node));
}
export function $selectionHasStyle(): boolean {
const selection = $getSelection();
if ($isRangeSelection(selection)) {
const caretRange = $caretRangeFromSelection(selection);
for (const slice of caretRange.getTextSlices()) {
if (slice && $nodeHasStyle(slice.caret.origin)) {
return true;
}
}
for (const caret of caretRange.iterNodeCarets('root')) {
if ($isTextNode(caret.origin) && $nodeHasStyle(caret.origin)) {
return true;
}
}
}
return false;
}
export function $patchSelectedTextStyle(
styleObjectOrCallback:
| StyleObject
| ((prevStyles: StyleObject) => StyleObject),
): boolean {
let selection = $getSelection();
if (!selection) {
const prevSelection = $getPreviousSelection();
if (!prevSelection) {
return false;
}
selection = prevSelection.clone();
$setSelection(selection);
}
const styleCallback =
typeof styleObjectOrCallback === 'function'
? styleObjectOrCallback
: (prevStyles: StyleObject) =>
mergeStyleObjects(prevStyles, styleObjectOrCallback);
if ($isRangeSelection(selection) && selection.isCollapsed()) {
const node = selection.focus.getNode();
if ($isTextNode(node)) {
$setStyleObject(node, styleCallback);
}
} else {
$forEachSelectedTextNode((node) => $setStyleObject(node, styleCallback));
}
return true;
}
const PREV_STYLE_STATE = Symbol.for('styleState');
interface HTMLElementWithManagedStyle extends HTMLElement {
// Store the last reconciled style object directly on the DOM
// so we don't have to track the previous DOM
// which can happen even when nodeMutation is 'updated'
[PREV_STYLE_STATE]?: StyleObject;
}
interface LexicalNodeWithUnknownStyle extends LexicalNode {
// This property exists on all TextNode and ElementNode
// and likely also some DecoratorNode by convention.
// We use it as a heuristic to see if the style has likely
// been overwritten to see if we should apply a diff
// or all styles.
__style?: unknown;
}
function styleStringChanged(
node: LexicalNodeWithUnknownStyle,
prevNode: LexicalNodeWithUnknownStyle,
): boolean {
return typeof node.__style === 'string' && prevNode.__style !== node.__style;
}
function getPreviousStyleObject(
node: LexicalNode,
prevNode: null | LexicalNode,
dom: HTMLElementWithManagedStyle,
): StyleObject {
const prevStyleObject = dom[PREV_STYLE_STATE];
return prevStyleObject && prevNode && !styleStringChanged(node, prevNode)
? prevStyleObject
: NO_STYLE;
}
// This applies the style to the DOM of any node
function makeStyleUpdateListener(editor: LexicalEditor): () => void {
return mergeRegister(
editor.registerMutationListener(RootNode, () => {
// UpdateListener will only get the mutatedNodes payload when
// at least one MutationListener is registered
}),
editor.registerUpdateListener((payload) => {
const {prevEditorState, mutatedNodes} = payload;
editor.getEditorState().read(
() => {
if (mutatedNodes) {
for (const nodes of mutatedNodes.values()) {
for (const [nodeKey, nodeMutation] of nodes) {
if (nodeMutation === 'destroyed') {
continue;
}
const node = $getNodeByKey(nodeKey);
const dom: null | HTMLElementWithManagedStyle =
editor.getElementByKey(nodeKey);
if (!dom || !node) {
return;
}
const prevNode = $getNodeByKey(nodeKey, prevEditorState);
const prevStyleObject = getPreviousStyleObject(
node,
prevNode,
dom,
);
const nextStyleObject = $getStyleObject(node);
dom[PREV_STYLE_STATE] = nextStyleObject;
applyStyle(
dom,
diffStyleObjects(prevStyleObject, nextStyleObject),
);
}
}
}
},
{editor},
);
}),
);
}
// TODO https://github.com/facebook/lexical/issues/7259
// there should be a better way to do this, this does not compose with other exportDOM overrides
export function $exportNodeStyle(
editor: LexicalEditor,
target: LexicalNode,
): DOMExportOutput {
const output = target.exportDOM(editor);
const style = $getStyleObject(target);
if (style === NO_STYLE) {
return output;
}
return {
...output,
after: (generatedElement) => {
const el = output.after
? output.after(generatedElement)
: generatedElement;
if (isHTMLElement(el)) {
applyStyle(el, style);
} else if (isDocumentFragment(el)) {
// Work around a bug in the type
return el as unknown as ReturnType<
NonNullable<DOMExportOutput['after']>
>;
}
return el;
},
};
}
const IGNORE_STYLES: Set<keyof StyleObject> = new Set([
'font-weight',
'text-decoration',
'font-style',
'vertical-align',
]);
export type StyleMapping = (input: StyleObject) => StyleObject;
// TODO there's no reasonable way to hook into importDOM/exportDOM from a plug-in https://github.com/facebook/lexical/issues/7259
export function constructStyleImportMap(
styleMapping: StyleMapping = (input) => input,
): DOMConversionMap {
const importMap: DOMConversionMap = {};
// Wrap all TextNode importers with a function that also imports
// styles that are not otherwise imported
for (const [tag, fn] of Object.entries(TextNode.importDOM() || {})) {
importMap[tag] = (importNode) => {
const importer = fn(importNode);
if (!importer) {
return null;
}
return {
...importer,
conversion: (element) => {
const output = importer.conversion(element);
if (
output === null ||
output.forChild === undefined ||
output.after !== undefined ||
output.node !== null ||
!element.hasAttribute('style')
) {
return output;
}
let extraStyles: undefined | Record<string, string>;
for (const k of element.style) {
if (IGNORE_STYLES.has(k as keyof StyleObject)) {
continue;
}
extraStyles = extraStyles || {};
extraStyles[k] = element.style.getPropertyValue(k);
}
if (extraStyles) {
const {forChild} = output;
return {
...output,
forChild: (child, parent) => {
const node = forChild(child, parent);
return $isTextNode(node)
? $setStyleObject(
node,
styleMapping(extraStyles as StyleObject),
)
: node;
},
};
}
return output;
},
};
};
}
return importMap;
}
export function registerStyleState(editor: LexicalEditor): () => void {
return mergeRegister(
editor.registerCommand(
PATCH_TEXT_STYLE_COMMAND,
$patchSelectedTextStyle,
COMMAND_PRIORITY_EDITOR,
),
makeStyleUpdateListener(editor),
);
}

View File

@ -0,0 +1,446 @@
/**
* Copyright (c) Meta Platforms, Inc. and affiliates.
*
* This source code is licensed under the MIT license found in the
* LICENSE file in the root directory of this source tree.
*
*/
body {
margin: 0;
background: #eee;
font-family: system-ui, -apple-system, BlinkMacSystemFont, '.SFNSText-Regular',
sans-serif;
font-weight: 500;
-webkit-font-smoothing: antialiased;
-moz-osx-font-smoothing: grayscale;
}
.other h2 {
font-size: 18px;
color: #444;
margin-bottom: 7px;
}
.other a {
color: #777;
text-decoration: underline;
font-size: 14px;
}
.other ul {
padding: 0;
margin: 0;
list-style-type: none;
}
.App {
font-family: sans-serif;
}
.App > h1 {
text-align: center;
}
h1 {
font-size: 24px;
color: #333;
}
.ltr {
text-align: left;
}
.rtl {
text-align: right;
}
.editor-container {
margin: 20px auto 20px auto;
border-radius: 2px;
max-width: 600px;
color: #000;
position: relative;
line-height: 20px;
font-weight: 400;
text-align: left;
border-top-left-radius: 10px;
border-top-right-radius: 10px;
}
.editor-inner {
background: #fff;
position: relative;
}
.editor-input {
min-height: 150px;
resize: none;
font-size: 15px;
caret-color: rgb(5, 5, 5);
position: relative;
tab-size: 1;
outline: 0;
padding: 15px 10px;
caret-color: #444;
}
.editor-placeholder {
color: #999;
overflow: hidden;
position: absolute;
text-overflow: ellipsis;
top: 15px;
left: 10px;
font-size: 15px;
user-select: none;
display: inline-block;
pointer-events: none;
}
.editor-text-bold {
font-weight: bold;
}
.editor-text-italic {
font-style: italic;
}
.editor-text-underline {
text-decoration: underline;
}
.editor-text-strikethrough {
text-decoration: line-through;
}
.editor-text-underlineStrikethrough {
text-decoration: underline line-through;
}
.editor-text-code {
background-color: rgb(240, 242, 245);
padding: 1px 0.25rem;
font-family: Menlo, Consolas, Monaco, monospace;
font-size: 94%;
}
.editor-link {
color: rgb(33, 111, 219);
text-decoration: none;
}
.tree-view-output {
display: block;
background: #222;
color: #fff;
padding: 5px;
font-size: 12px;
white-space: pre-wrap;
margin: 1px auto 10px auto;
max-height: 250px;
position: relative;
border-bottom-left-radius: 10px;
border-bottom-right-radius: 10px;
overflow: auto;
line-height: 14px;
}
.editor-code {
background-color: rgb(240, 242, 245);
font-family: Menlo, Consolas, Monaco, monospace;
display: block;
padding: 8px 8px 8px 52px;
line-height: 1.53;
font-size: 13px;
margin: 0;
margin-top: 8px;
margin-bottom: 8px;
tab-size: 2;
/* white-space: pre; */
overflow-x: auto;
position: relative;
}
.editor-code:before {
content: attr(data-gutter);
position: absolute;
background-color: #eee;
left: 0;
top: 0;
border-right: 1px solid #ccc;
padding: 8px;
color: #777;
white-space: pre-wrap;
text-align: right;
min-width: 25px;
}
.editor-code:after {
content: attr(data-highlight-language);
top: 0;
right: 3px;
padding: 3px;
font-size: 10px;
text-transform: uppercase;
position: absolute;
color: rgba(0, 0, 0, 0.5);
}
.editor-tokenComment {
color: slategray;
}
.editor-tokenPunctuation {
color: #999;
}
.editor-tokenProperty {
color: #905;
}
.editor-tokenSelector {
color: #690;
}
.editor-tokenOperator {
color: #9a6e3a;
}
.editor-tokenAttr {
color: #07a;
}
.editor-tokenVariable {
color: #e90;
}
.editor-tokenFunction {
color: #dd4a68;
}
.editor-paragraph {
margin: 0;
margin-bottom: 8px;
position: relative;
}
.editor-paragraph:last-child {
margin-bottom: 0;
}
.editor-heading-h1 {
font-size: 24px;
color: rgb(5, 5, 5);
font-weight: 400;
margin: 0;
margin-bottom: 12px;
padding: 0;
}
.editor-heading-h2 {
font-size: 15px;
color: rgb(101, 103, 107);
font-weight: 700;
margin: 0;
margin-top: 10px;
padding: 0;
text-transform: uppercase;
}
.editor-quote {
margin: 0;
margin-left: 20px;
font-size: 15px;
color: rgb(101, 103, 107);
border-left-color: rgb(206, 208, 212);
border-left-width: 4px;
border-left-style: solid;
padding-left: 16px;
}
.editor-list-ol {
padding: 0;
margin: 0;
margin-left: 16px;
}
.editor-list-ul {
padding: 0;
margin: 0;
margin-left: 16px;
}
.editor-listitem {
margin: 8px 32px 8px 32px;
}
.editor-nested-listitem {
list-style-type: none;
}
pre::-webkit-scrollbar {
background: transparent;
width: 10px;
}
pre::-webkit-scrollbar-thumb {
background: #999;
}
.debug-timetravel-panel {
overflow: hidden;
padding: 0 0 10px 0;
margin: auto;
display: flex;
}
.debug-timetravel-panel-slider {
padding: 0;
flex: 8;
}
.debug-timetravel-panel-button {
padding: 0;
border: 0;
background: none;
flex: 1;
color: #fff;
font-size: 12px;
}
.debug-timetravel-panel-button:hover {
text-decoration: underline;
}
.debug-timetravel-button {
border: 0;
padding: 0;
font-size: 12px;
top: 10px;
right: 15px;
position: absolute;
background: none;
color: #fff;
}
.debug-timetravel-button:hover {
text-decoration: underline;
}
.toolbar {
display: flex;
margin-bottom: 1px;
background: #fff;
padding: 4px;
border-top-left-radius: 10px;
border-top-right-radius: 10px;
vertical-align: middle;
}
.toolbar button.toolbar-item {
border: 0;
display: flex;
background: none;
border-radius: 10px;
padding: 8px;
cursor: pointer;
vertical-align: middle;
}
.toolbar button.toolbar-item:disabled {
cursor: not-allowed;
}
.toolbar button.toolbar-item.spaced {
margin-right: 2px;
}
.toolbar button.toolbar-item i.format {
background-size: contain;
display: inline-block;
height: 18px;
width: 18px;
margin-top: 2px;
vertical-align: -0.25em;
display: flex;
opacity: 0.6;
}
.toolbar button.toolbar-item:disabled i.format {
opacity: 0.2;
}
.toolbar button.toolbar-item.active {
background-color: rgba(223, 232, 250, 0.3);
}
.toolbar button.toolbar-item.active i {
opacity: 1;
}
.toolbar .toolbar-item:hover:not([disabled]) {
background-color: #eee;
}
.toolbar .divider {
width: 1px;
background-color: #eee;
margin: 0 4px;
}
.toolbar .toolbar-item .text {
display: flex;
line-height: 20px;
width: 200px;
vertical-align: middle;
font-size: 14px;
color: #777;
text-overflow: ellipsis;
width: 70px;
overflow: hidden;
height: 20px;
text-align: left;
}
.toolbar .toolbar-item .icon {
display: flex;
width: 20px;
height: 20px;
user-select: none;
margin-right: 8px;
line-height: 16px;
background-size: contain;
}
i.undo {
background-image: url(./icons/arrow-counterclockwise.svg);
}
i.redo {
background-image: url(./icons/arrow-clockwise.svg);
}
i.bold {
background-image: url(./icons/type-bold.svg);
}
i.italic {
background-image: url(./icons/type-italic.svg);
}
i.underline {
background-image: url(./icons/type-underline.svg);
}
i.strikethrough {
background-image: url(./icons/type-strikethrough.svg);
}
i.text-shadow::before {
content: '✨';
filter: contrast(0);
}
i.text-shadow.active::before {
filter: contrast(1);
text-shadow: 1px solid black;
}

View File

@ -0,0 +1 @@
/// <reference types="vite/client" />

View File

@ -0,0 +1,25 @@
{
"compilerOptions": {
"target": "ES2020",
"useDefineForClassFields": true,
"lib": ["ES2020", "DOM", "DOM.Iterable"],
"module": "ESNext",
"skipLibCheck": true,
/* Bundler mode */
"moduleResolution": "bundler",
"allowImportingTsExtensions": true,
"resolveJsonModule": true,
"isolatedModules": true,
"noEmit": true,
"jsx": "react-jsx",
/* Linting */
"strict": true,
"noUnusedLocals": true,
"noUnusedParameters": true,
"noFallthroughCasesInSwitch": true
},
"include": ["src"],
"references": [{"path": "./tsconfig.node.json"}]
}

View File

@ -0,0 +1,11 @@
{
"compilerOptions": {
"composite": true,
"skipLibCheck": true,
"module": "ESNext",
"moduleResolution": "bundler",
"allowSyntheticDefaultImports": true,
"strict": true
},
"include": ["vite.config.ts"]
}

View File

@ -0,0 +1,15 @@
/**
* Copyright (c) Meta Platforms, Inc. and affiliates.
*
* This source code is licensed under the MIT license found in the
* LICENSE file in the root directory of this source tree.
*
*/
import {mergeConfig} from 'vite';
import lexicalMonorepoPlugin from '../../packages/shared/lexicalMonorepoPlugin';
import config from './vite.config';
export default mergeConfig(config, {
plugins: [lexicalMonorepoPlugin()],
});

View File

@ -0,0 +1,14 @@
/**
* Copyright (c) Meta Platforms, Inc. and affiliates.
*
* This source code is licensed under the MIT license found in the
* LICENSE file in the root directory of this source tree.
*
*/
import react from '@vitejs/plugin-react';
import {defineConfig} from 'vite';
// https://vitejs.dev/config/
export default defineConfig({
plugins: [react()],
});

1279
package-lock.json generated

File diff suppressed because it is too large Load Diff

View File

@ -33,7 +33,7 @@ export class AutocompleteNode extends TextNode {
* - Ensures max one Autocomplete node per session.
* - Ensure that when collaboration is enabled, this node is not shown in
* other sessions.
* See https://github.com/facebook/lexical/blob/master/packages/lexical-playground/src/plugins/AutocompletePlugin/index.tsx#L39
* See https://github.com/facebook/lexical/blob/main/packages/lexical-playground/src/plugins/AutocompletePlugin/index.tsx
*/
__uuid: string;

View File

@ -106,10 +106,10 @@ Source code: [examples/react-rich-collab](https://github.com/facebook/lexical/tr
[Lexical Playground](https://playground.lexical.dev/) features set of the collaboration enabled plugins that integrate with primary document via `useCollaborationContext()` hook. Notable mentions:
- [`CommentPlugin`](https://github.com/facebook/lexical/tree/v0.14.5/packages/lexical-playground/src/plugins/CommentPlugin) - features use of the separate provider and Yjs room to sync comments.
- [`ImageComponent`](https://github.com/facebook/lexical/blob/v0.14.5/packages/lexical-playground/src/nodes/ImageComponent.tsx#L390) - features use of the `LexicalNestedComposer` paired with `CollaborationPlugin`.
- [`PollOptionComponent`](https://github.com/facebook/lexical/blob/v0.14.5/packages/lexical-playground/src/nodes/PollComponent.tsx#L78) - showcases poll implementation using `clientID` from Yjs context.
- [`StickyPlugin`](https://github.com/facebook/lexical/tree/v0.14.5/packages/lexical-playground/src/plugins/StickyPlugin) - features use of the `LexicalNestedComposer` paired with `CollaborationPlugin` as well as sticky note position real-time sync.
- [`CommentPlugin`](https://github.com/facebook/lexical/tree/main/packages/lexical-playground/src/plugins/CommentPlugin) - features use of the separate provider and Yjs room to sync comments.
- [`ImageComponent`](https://github.com/facebook/lexical/blob/main/packages/lexical-playground/src/nodes/ImageComponent.tsx) - features use of the `LexicalNestedComposer` paired with `CollaborationPlugin`.
- [`PollOptionComponent`](https://github.com/facebook/lexical/blob/main/packages/lexical-playground/src/nodes/PollComponent.tsx) - showcases poll implementation using `clientID` from Yjs context.
- [`StickyPlugin`](https://github.com/facebook/lexical/tree/main/packages/lexical-playground/src/plugins/StickyPlugin) - features use of the `LexicalNestedComposer` paired with `CollaborationPlugin` as well as sticky note position real-time sync.
:::note

View File

@ -55,7 +55,7 @@ const formatBulletList = () => {
};
```
Which is later handled in [`useList`](https://github.com/facebook/lexical/blob/1f62ace08e15d55515f3750840133efecd6d7d01/packages/lexical-react/src/shared/useList.js#L65) to insert the list into the editor.
Which is later handled in [`registerList`](https://github.com/facebook/lexical/blob/main/packages/lexical-list/src/index.ts) to insert the list into the editor.
```js
editor.registerCommand(
@ -104,7 +104,7 @@ useEffect(() => {
And as seen above and below, `registerCommand`'s callback can return `true` to signal to the other listeners that the command has been handled and propagation will be stopped.
Here's a simplified example of handling a `KEY_TAB_COMMAND` from the [`RichTextPlugin`](https://github.com/facebook/lexical/blob/76b28f4e2b70f1194cc8148dcc30c9f9ec61f811/packages/lexical-rich-text/src/index.js#L625), which is used to dispatch a `OUTDENT_CONTENT_COMMAND` or `INDENT_CONTENT_COMMAND`.
Here's a simplified example of handling a `KEY_TAB_COMMAND` from the [`TabIndentationPlugin`](https://github.com/facebook/lexical/blob/main/packages/lexical-react/src/LexicalTabIndentationPlugin.tsx), which is used to dispatch a `OUTDENT_CONTENT_COMMAND` or `INDENT_CONTENT_COMMAND`.
```js
editor.registerCommand(
@ -120,4 +120,4 @@ editor.registerCommand(
);
```
Note that the same `KEY_TAB_COMMAND` command is registered by [`LexicalTableSelectionHelpers.js`](https://github.com/facebook/lexical/blob/1f62ace08e15d55515f3750840133efecd6d7d01/packages/lexical-table/src/LexicalTableSelectionHelpers.js#L733), which handles moving focus to the next or previous cell within a `Table`, but the priority is the highest it can be (`4`) because this behavior is very important.
Note that the same `KEY_TAB_COMMAND` command is registered by [`LexicalTableSelectionHelpers.ts`](https://github.com/facebook/lexical/blob/main/packages/lexical-table/src/LexicalTableSelectionHelpers.ts), which handles moving focus to the next or previous cell within a `TableNode`, but the priority is the highest it can be (`COMMAND_PRIORITY_CRITICAL`) because this behavior is very important.

View File

@ -49,13 +49,38 @@ node.__key = 'custom-key';
## How Lexical Uses Keys
### Diagram
The dotted outlines show nodes that are re-used in a zero-copy fashion from one EditorState to the next
```mermaid
graph TD
A[EditorState] --> B[Node Map]
B -->|key1| C[Node 1]
B -->|key2| D[Node 2]
E[Update] -->|same key| F[New Version]
G[Create] -->|new key| H[New Node]
graph TB
subgraph s0["Initial State"]
direction TB
m0["NodeMap (v0)"]
m0 -->|Key A| n0a["A (v0)"]
m0 -->|Key B| n0b["B (v0)"]
end
subgraph s1["Create Node C"]
direction TB
style n1a stroke-dasharray: 5 5
style n1b stroke-dasharray: 5 5
m1["NodeMap (v1)"]
m1 -->|Key A| n1a["A (v0)"]
m1 -->|Key B| n1b["B (v0)"]
m1 -->|Key C| n1c["C (v0)"]
end
subgraph s2["Update Node A"]
direction TB
style n2a stroke-dasharray: 5 0
style n2b stroke-dasharray: 5 5
style n2c stroke-dasharray: 5 5
m2["NodeMap (v1)"]
m2 -->|Key A| n2a["A (v1)"]
m2 -->|Key B| n2b["B (v0)"]
m2 -->|Key C| n2c["C (v0)"]
end
s0 -.-> s1 -.-> s2
```
### Node Map Structure

View File

@ -1,61 +1,26 @@
# Node Customization
# Node Replacement
Originally the only way to customize nodes was using the node replacement API. Recently we have introduced a second way with the `state` property which has some advantages described below.
Node Replacement allow you to replace all instances of a given node in your editor with instances of a subclass.
## Node State (Experimental)
## Use Case
The advantages of using state over the replacement API are:
1. Easier (less boilerplate)
2. Composable (multiple plugins extending the same node causes failures)
3. Allows metadata: useful for adding things to the RootNode.
:::note
```ts
// IMPLEMENTATION
const colorState = createState('color', {
parse: (value: unknown) => (typeof value === 'string' ? value : undefined),
});
In earlier versions of this documentation, "Node Replacement" was called "Node Overrides".
We've changed the name to match the terms used in the implementation.
// USAGE
const textNode = $createTextNode();
$setState(textNode, colorState, 'blue');
const textColor = $getState(textNode, colorState) // -> "blue"
```
:::
Inside state, you can use any serializable json value. For advanced use cases
with values that are not primitive values like string, number, boolean, null
you may want or need to implement more than just the parse method in the
value configuration.
:::tip
While this is still experimental, the API is subject to change and the
documentation will primarily be API documentation.
If your use case only requires adding ad-hoc data to existing nodes, you may be able to use the [NodeState](/docs/concepts/node-state) API instead of subclassing and node replacement.
### Important
:::
We recommend that you use prefixes with low collision probability when defining
state that will be applied to node classes that you don't fully control. It is
a runtime error in dev mode when two distinct separate StateConfig with the
same key are used on the same node.
Some of the most commonly used Lexical Nodes are owned and maintained by the core library. For example, ParagraphNode, HeadingNode, QuoteNode, List(Item)Node etc - these are all provided by Lexical packages, which provides an easier out-of-the-box experience for some editor features, but makes it difficult to override their behavior. For instance, if you wanted to change the behavior of ListNode, you would typically extend the class and override the methods. However, how would you tell Lexical to use *your* ListNode subclass in the ListPlugin instead of using the core ListNode? That's where Node Replacement can help.
For example, if you are making a plugin called `awesome-lexical`, you could do:
```ts
const color = createState('awesome-lexical-color', /** your parse fn */)
const bgColor = createState('awesome-lexical-bg-color', /** your parse fn */)
// Or you can add all your state inside an object:
type AwesomeLexical = {
color?: string;
bgColor?: string;
padding?: number
}
const awesomeLexical = createState('awesome-lexical', /** your parse fn which returns AwesomeLexical type */)
```
# Node Overrides / Node Replacements
Some of the most commonly used Lexical Nodes are owned and maintained by the core library. For example, ParagraphNode, HeadingNode, QuoteNode, List(Item)Node etc - these are all provided by Lexical packages, which provides an easier out-of-the-box experience for some editor features, but makes it difficult to override their behavior. For instance, if you wanted to change the behavior of ListNode, you would typically extend the class and override the methods. However, how would you tell Lexical to use *your* ListNode subclass in the ListPlugin instead of using the core ListNode? That's where Node Overrides can help.
Node Overrides allow you to replace all instances of a given node in your editor with instances of a different node class. This can be done through the nodes array in the Editor config:
Node Replacement allow you to replace all instances of a given node in your editor with instances of a different node class. This can be done through the nodes array in the Editor config:
```ts
const editorConfig = {
@ -66,7 +31,7 @@ const editorConfig = {
{
replace: ParagraphNode,
with: (node: ParagraphNode) => {
return new CustomParagraphNode();
return $createCustomParagraphNode();
},
withKlass: CustomParagraphNode,
}
@ -80,9 +45,10 @@ In the snippet above,
Once this is done, Lexical will replace all ParagraphNode instances with CustomParagraphNode instances. One important use case for this feature is overriding the serialization behavior of core nodes. Check out the full example below.
<iframe src="https://codesandbox.io/embed/ecstatic-maxwell-kw5utu?fontsize=14&hidenavigation=1&module=/src/Editor.js,/src/plugins/CollapsiblePlugin.ts,/src/nodes/CollapsibleContainerNode.ts&theme=dark&view=split"
style={{width:'100%', height:'700px', border:0, borderRadius:'4px', overflow:'hidden'}}
title="lexical-collapsible-container-plugin-example"
allow="accelerometer; ambient-light-sensor; camera; encrypted-media; geolocation; gyroscope; hid; microphone; midi; payment; usb; vr; xr-spatial-tracking"
sandbox="allow-forms allow-modals allow-popups allow-popups-to-escape-sandbox allow-presentation allow-same-origin allow-scripts"
></iframe>
## Node Replacement Example
This example demonstrates using Node Replacement to replace all `ParagraphNode` with a `CustomParagraphNode` that overrides `createDOM`.
[![Open in StackBlitz](https://developer.stackblitz.com/img/open_in_stackblitz.svg)](https://stackblitz.com/github/facebook/lexical/tree/main/examples/node-replacement?file=src/main.tsx)
<iframe width="100%" height="600" src="https://stackblitz.com/github/facebook/lexical/tree/main/examples/node-replacement?embed=1&file=src%2FApp.tsx&terminalHeight=0&ctl=1&showSidebar=0&devtoolsheight=0&view=preview" sandbox="allow-forms allow-modals allow-popups allow-popups-to-escape-sandbox allow-presentation allow-same-origin allow-scripts" title="Node Replacement Example"></iframe>

View File

@ -0,0 +1,216 @@
# NodeState
The NodeState API introduced in v0.26.0 allows arbitrary state to be added
ad-hoc to any node in a way that participates with reconciliation, history,
and JSON serialization.
## Use Case
NodeState allows your application to define keys that can be stored on
any node with automatic JSON support, you can even add state to the root
node to store document-level metadata.
:::tip
You can even add node state to the RootNode to store document-level metadata,
which wasn't possible at all before!
:::
With a combination of NodeState and other APIs such as
[Listeners](https://lexical.dev/docs/concepts/listeners) or
[Transforms](https://lexical.dev/docs/concepts/transforms) you can
likely shape the editor to meet your needs without having to do much
[Node Customization](https://lexical.dev/docs/concepts/node-replacement).
Even when you are subclassing nodes, using NodeState instead of additional
properties to store the node's data can be [more efficient](#efficiency)
and will save you from writing a lot of boilerplate in the constructor,
updateFromJSON, and exportJSON.
## Stability
🧪 This API is experimental, and may evolve without a long deprecation
period. See also [Capabilities](#capabilities) for notes on what it
can and can not do out of the box today.
## Usage
### createState
[createState](https://lexical.dev/docs/api/modules/lexical#createstate)
creates a
[StateConfig](https://lexical.dev/docs/api/classes/lexical.StateConfig)
which defines the key and configuration for your NodeState value.
The key must be locally unique, two distinct StateConfig must not have the
same string key if they are to be used on the same node.
Typical usage will look something like this:
```ts
const questionState = createState('question', {
parse: (v) => (typeof v === 'string' ? v : ''),
});
```
The required `parse` function serves two purposes:
- It provides a type-safe and runtime-safe way to parse values that were
serialized to JSON
- When called with `undefined` (or any invalid value) it should return some
default value (which may be `undefined` or `null` or any other value you
choose)
In this case, the question must be a string, and the default is an
empty string.
See the
[createState](https://lexical.dev/docs/api/modules/lexical#createstate)
API documentation for more details, there are other optional settings
that you may want to define particularly if the value is not a primitive
value such as boolean, string, number, null, or undefined.
:::tip
We recommend building a library of small reusable parse functions for the data
types that you use, or a library that can be used to generate them such as
[zod](https://zod.dev/),
[ArkType](https://arktype.io/),
[Effect](https://effect.website/),
[Valibot](https://valibot.dev/),
etc. especially when working with non-primitive data types.
:::
### $getState
[$getState](https://lexical.dev/docs/api/modules/lexical#getstate) gets the
NodeState value from the given node, or the default if that key was never
set on the node.
```ts
const question = $getValue(pollNode, questionState);
```
See also
[$getStateChange](https://lexical.dev/docs/api/modules/lexical#getstatechange)
if you need an efficient way to determine if the state has changed on two
versions of the same node (typcially used in updateDOM, but may be useful in
an update listener or mutation listener).
### $setState
[$setState](https://lexical.dev/docs/api/modules/lexical#setstate) sets the
NodeState value on the given node.
```ts
const question = $setValue(
pollNode,
questionState,
'Are you planning to use NodeState?',
);
```
:::tip
The last argument is a ValueOrUpdater, just like with React's useState
setters. If you use an updater function and the value does not change,
the node and its NodeState *won't* be marked dirty.
:::
## Serialization
The NodeState for a node, if any values are set to non-default values, is
serialized to a record under a single
[NODE_STATE_KEY](https://lexical.dev/docs/api/modules/lexical#node_state_key)
which is equal to `'$'`. In the future, it is expected that nodes will be
able to declare required state and lift those values to the top-level of
their serialized nodes
(see [#7260](https://github.com/facebook/lexical/issues/7260)).
```json
{
"type": "poll",
"$": {
"question": "Are you planning to use NodeState?",
}
}
```
:::tip
By default, it is assumed that your parsed values are JSON serializable,
but for advanced use cases you may use values such as Date, Map, or Set
that need to be transformed before JSON serialization. See the
[StateValueConfig](https://lexical.dev/docs/api/interfaces/lexical.StateValueConfig)
API documentation.
:::
## Efficiency
NodeState uses a copy-on-write scheme to manage each node's state. If
none of the state has changed, then the NodeState instance will be
shared across multiple versions of that node.
:::info
In a given reconciliation cycle, the first time a Lexical node is marked dirty
via `getWritable` will create a new instance of that node. All properties
of the previous version are set on the new instance. NodeState is stored
as a single property, and no copying of the internal state is done
until the NodeState itself is marked writable.
:::
When serializing to JSON, each key will only be stored if the value
is not equal to the default value. This can save quite a lot of space
and bandwidth.
Parsing and serialization is only done at network boundaries, when
integrating with JSON or Yjs. When a value changes from an external
source, it is only parsed once the first time it is read.
Values that do not come from external sources are not parsed, and
values that are not used are never parsed.
## Capabilities
Current:
- Allows you to define and add state to any node
- Serializes that state automatically in the node's JSON, supporting
versioning and copy+paste
- Works with the reconciler, TextNodes with differing state will not
be implicitly merged
- @lexical/yjs support, NodeState will be automatically synchronized
like any other property
- NodeState values that are not used will simply pass-through, making
it a bit easier for situations where multiple configurations are used
on the same data (e.g. older and newer versions of your editor,
a different set of plugins based on context, etc.).
Future:
- Does not yet have a pre-registration system for nodes to declare
required state, the first release focuses only on ad-hoc usage
(see [#7260](https://github.com/facebook/lexical/issues/7260)).
- Does not yet integrate directly with importDOM, createDOM or
exportDOM (see [#7259](https://github.com/facebook/lexical/issues/7259))
- Does not yet support direct integration with Yjs, e.g.
you can not store a Y.Map as a NodeState value
(see [#7293](https://github.com/facebook/lexical/issues/7293))
- There isn't yet an easy way to listen for updates to NodeState
without registering listeners for every class
(see [#7321](https://github.com/facebook/lexical/pull/7321))
- Similarly, there isn't the equivalent of a node transform for
NodeState. Transforms must be registered on individual node
classes.
## Node State Style Example
This example demonstrates an advanced use case of storing a style object on TextNode using NodeState.
[![Open in StackBlitz](https://developer.stackblitz.com/img/open_in_stackblitz.svg)](https://stackblitz.com/github/facebook/lexical/tree/main/examples/node-state-style?file=src/main.tsx)
<iframe width="100%" height="600" src="https://stackblitz.com/github/facebook/lexical/tree/main/examples/node-state-style?embed=1&file=src%2FApp.tsx&terminalHeight=0&ctl=1&showSidebar=0&devtoolsheight=0&view=preview" sandbox="allow-forms allow-modals allow-popups allow-popups-to-escape-sandbox allow-presentation allow-same-origin allow-scripts" title="Node State Style Example"></iframe>

View File

@ -12,7 +12,7 @@ is extended internally to create Lexical's five base nodes:
- `TextNode`
- `DecoratorNode`
Of these nodes, three of them are exposed from the `lexical` package, making them ideal to be extended:
Of these base nodes, three of them can be extended to create new types of nodes:
- `ElementNode`
- `TextNode`
@ -21,13 +21,15 @@ Of these nodes, three of them are exposed from the `lexical` package, making the
### [`RootNode`](https://github.com/facebook/lexical/blob/main/packages/lexical/src/nodes/LexicalRootNode.ts)
There is only ever a single `RootNode` in an `EditorState` and it is always at the top and it represents the
`contenteditable` itself. This means that the `RootNode` does not have a parent or siblings.
`contenteditable` itself. This means that the `RootNode` does not have a parent or siblings. It can not be
subclassed or replaced.
- To get the text content of the entire editor, you should use `rootNode.getTextContent()`.
- To avoid selection issues, Lexical forbids insertion of text nodes directly into a `RootNode`.
#### Semantics and Use Cases
The `RootNode` has specific characteristics and restrictions to maintain editor integrity:
Unlike other `ElementNode` subclasses, the `RootNode` has specific characteristics and restrictions to maintain editor integrity:
1. **Non-extensibility**
The `RootNode` cannot be subclassed or replaced with a custom implementation. It is designed as a fixed part of the editor architecture.
@ -36,10 +38,10 @@ The `RootNode` has specific characteristics and restrictions to maintain editor
The `RootNode` does not participate in mutation listeners. Instead, use a root-level or update listener to observe changes at the document level.
3. **Compatibility with Node Transforms**
While the `RootNode` is not "part of the document" in the traditional sense, it can still appear to be in some cases, such as during serialization or when applying node transforms.
While the `RootNode` is not "part of the document" in the traditional sense, it can still appear to be in some cases, such as during serialization or when applying node transforms. A node transform on the `RootNode` will be called at the end of *every* node transform cycle. This is useful in cases where you need something like an update listener that occurs before the editor state is reconciled.
4. **Document-Level Metadata**
If you are attempting to use the `RootNode` for document-level metadata (e.g., undo/redo support), consider alternative designs. Currently, Lexical does not provide direct facilities for this use case, but solutions like creating a shadow root under the `RootNode` might work.
If you are attempting to use the `RootNode` for document-level metadata (e.g., undo/redo support), use the [NodeState](/docs/concepts/node-state) API.
By design, the `RootNode` serves as a container for the editor's content rather than an active part of the document's logical structure. This approach simplifies operations like serialization and keeps the focus on content nodes.
@ -50,14 +52,14 @@ You should never have `'\n'` in your text nodes, instead you should use the `Lin
### [`ElementNode`](https://github.com/facebook/lexical/blob/main/packages/lexical/src/nodes/LexicalElementNode.ts)
Used as parent for other nodes, can be block level (`ParagraphNode`, `HeadingNode`) and inline (`LinkNode`).
Used as parent for other nodes, can be block level (`ParagraphNode`, `HeadingNode`) or inline (`LinkNode`).
Has various methods which define its behaviour that can be overridden during extension (`isInline`, `canBeEmpty`, `canInsertTextBefore` and more)
### [`TextNode`](https://github.com/facebook/lexical/blob/main/packages/lexical/src/nodes/LexicalTextNode.ts)
Leaf type of node that contains text. It also includes few text-specific properties:
- `format` any combination of `bold`, `italic`, `underline`, `strikethrough`, `code`, `subscript` and `superscript`
- `format` any combination of `bold`, `italic`, `underline`, `strikethrough`, `code`, `highlight`, `subscript` and `superscript`
- `mode`
- `token` - acts as immutable node, can't change its content and is deleted all at once
- `segmented` - its content deleted by segments (one word at a time), it is editable although node becomes non-segmented once its content is updated
@ -70,6 +72,12 @@ can output components from React, vanilla js or other frameworks.
## Node Properties
:::tip
If you're using Lexical v0.26.0 or later, you should consider using the [NodeState](/docs/concepts/node-state) API instead of defining properties directly on your subclasses. NodeState features automatic support for `afterCloneFrom`, `exportJSON`, and `updateFromJSON` requiring much less boilerplate and some additional benefits. You may find that you do not need a subclass at all in some situations, since your NodeState can be applied ad-hoc to any node.
:::
Lexical nodes can have properties. It's important that these properties are JSON serializable too, so you should never
be assigning a property to a node that is a function, Symbol, Map, Set, or any other object that has a different prototype
than the built-ins. `null`, `undefined`, `number`, `string`, `boolean`, `{}` and `[]` are all types of property that can be
@ -84,23 +92,27 @@ If you are adding a property that you expect to be modifiable or accessible, the
and `set*()` methods on your node for this property. Inside these methods, you'll need to invoke some very important methods
that ensure consistency with Lexical's internal immutable system. These methods are `getWritable()` and `getLatest()`.
We recommend that your constructor should always support a zero-argument instantiation in order to better support collab and
to reduce the amount of boilerplate required. You can always define your `$create*` functions with required arguments.
```js
import type {NodeKey} from 'lexical';
class MyCustomNode extends SomeOtherNode {
__foo: string;
constructor(foo: string, key?: NodeKey) {
constructor(foo: string = '', key?: NodeKey) {
super(key);
this.__foo = foo;
}
setFoo(foo: string) {
setFoo(foo: string): this {
// getWritable() creates a clone of the node
// if needed, to ensure we don't try and mutate
// a stale version of this node.
const self = this.getWritable();
self.__foo = foo;
return self;
}
getFoo(): string {
@ -112,7 +124,7 @@ class MyCustomNode extends SomeOtherNode {
}
```
Lastly, all nodes should have both a `static getType()` method and a `static clone()` method.
Lastly, all nodes should have `static getType()`, `static clone()`, and `static importJSON()` methods.
Lexical uses the type to be able to reconstruct a node back with its associated class prototype
during deserialization (important for copy + paste!). Lexical uses cloning to ensure consistency
between creation of new `EditorState` snapshots.
@ -120,6 +132,10 @@ between creation of new `EditorState` snapshots.
Expanding on the example above with these methods:
```js
interface SerializedCustomNode extends SerializedLexicalNode {
foo?: string;
}
class MyCustomNode extends SomeOtherNode {
__foo: string;
@ -133,17 +149,42 @@ class MyCustomNode extends SomeOtherNode {
return new MyCustomNode(node.__foo, node.__key);
}
constructor(foo: string, key?: NodeKey) {
static importJSON(
serializedNode: LexicalUpdateJSON<SerializedMyCustomNode>
): MyCustomNode {
return new MyCustomNode().updateFromJSON(serializedNode);
}
constructor(foo: string = '', key?: NodeKey) {
super(key);
this.__foo = foo;
}
setFoo(foo: string) {
updateFromJSON(
serializedNode: LexicalUpdateJSON<SerializedMyCustomNode>
): this {
const self = super.updateFromJSON(serializedNode);
return typeof serializedNode.foo === 'string'
? self.setFoo(serializedNode.foo)
: self;
}
exportJSON(): SerializedMyCustomNode {
const serializedNode: SerializedMyCustomNode = super.exportJSON();
const foo = this.getFoo();
if (foo !== '') {
serializedNode.foo = foo;
}
return serializedNode;
}
setFoo(foo: string): this {
// getWritable() creates a clone of the node
// if needed, to ensure we don't try and mutate
// a stale version of this node.
const self = this.getWritable();
self.__foo = foo;
return self;
}
getFoo(): string {
@ -197,10 +238,12 @@ are that of your custom node. Here's how you might do this for the above example
```js
export function $createCustomParagraphNode(): CustomParagraph {
return new CustomParagraph();
return $applyNodeReplacement(new CustomParagraph());
}
export function $isCustomParagraphNode(node: LexicalNode | null | undefined): node is CustomParagraph {
export function $isCustomParagraphNode(
node: LexicalNode | null | undefined
): node is CustomParagraph {
return node instanceof CustomParagraph;
}
```
@ -244,10 +287,12 @@ export class ColoredNode extends TextNode {
}
export function $createColoredNode(text: string, color: string): ColoredNode {
return new ColoredNode(text, color);
return $applyNodeReplacement(new ColoredNode(text, color));
}
export function $isColoredNode(node: LexicalNode | null | undefined): node is ColoredNode {
export function $isColoredNode(
node: LexicalNode | null | undefined
): node is ColoredNode {
return node instanceof ColoredNode;
}
```
@ -285,7 +330,7 @@ export class VideoNode extends DecoratorNode<ReactNode> {
}
export function $createVideoNode(id: string): VideoNode {
return new VideoNode(id);
return $applyNodeReplacement(new VideoNode(id));
}
export function $isVideoNode(

View File

@ -32,7 +32,7 @@ Lexical as a framework provides 2 ways to customize appearance of it's content:
- [`ElementNode`](/docs/concepts/nodes#elementnode) used as parent for other nodes, can be block level or inline.
- [`TextNode`](/docs/concepts/nodes#textnode) - leaf type (_so it can't have child elements_) of node that contains text.
- [`DecoratorNode`](/docs/concepts/nodes#decoratornode) - useful to insert arbitrary view (component) inside the editor.
- Via [Node Overrides](/docs/concepts/node-replacement) useful if you want to augment behavior of the built in nodes, such as `ParagraphNode`.
- Via [Node Replacement](/docs/concepts/node-replacement) useful if you want to augment behavior of the built in nodes, such as `ParagraphNode`.
As in our case we don't expect `EmojiNode` to have any child nodes nor we aim to insert arbitrary component the best choice for us is to proceed with [`TextNode`](/docs/concepts/nodes#textnode) extension.

View File

@ -72,4 +72,4 @@ TwitterPlugin is just a React component that accesses the Lexical editor via Rea
You can see how [TwitterPlugin is used in the playground](https://github.com/facebook/lexical/blob/0775ab929e65723433626fa8c25900941e7f232f/packages/lexical-playground/src/Editor.tsx#L137). It's added as a child of a LexicalComposer component, which does the job of providing the Context necessary for access to the editor instance. To actually trigger this command callback and insert a [TweetNode](https://github.com/facebook/lexical/blob/b0fa38615c03f1c4fc7c8c5ea26412b723770e55/packages/lexical-playground/src/nodes/TweetNode.tsx#L212), we have a [button](https://github.com/facebook/lexical/blob/b0fa38615c03f1c4fc7c8c5ea26412b723770e55/packages/lexical-playground/src/plugins/ToolbarPlugin.tsx#L534) that "dispatches" the Tweet command we registered in the plugin.
While the TwitterPlugin registers a command that inserts a custom node, this is only one example of what can be done with a plugin. To get a better idea of what's possible, take a look at the [plugins defined in the playground](https://github.com/facebook/lexical/tree/0775ab929e65723433626fa8c25900941e7f232f/packages/lexical-playground/src/plugins).
While the TwitterPlugin registers a command that inserts a custom node, this is only one example of what can be done with a plugin. To get a better idea of what's possible, take a look at the [plugins defined in the playground](https://github.com/facebook/lexical/tree/main/packages/lexical-playground/src/plugins).

View File

@ -231,6 +231,7 @@ const config = {
},
markdown: {
mermaid: true,
preprocessor: ({fileContent}) =>
fileContent.replaceAll(
'https://stackblitz.com/github/facebook/lexical/tree/main/',
@ -285,7 +286,6 @@ const config = {
},
],
].filter((plugin) => plugin != null),
presets: [
[
require.resolve('docusaurus-plugin-internaldocs-fb/docusaurus-preset'),
@ -452,6 +452,8 @@ const config = {
},
}),
themes: ['@docusaurus/theme-mermaid'],
title: TITLE,
url: 'https://lexical.dev',
};

View File

@ -18,6 +18,7 @@
"@docusaurus/faster": "^3.6.0",
"@docusaurus/plugin-client-redirects": "^3.6.0",
"@docusaurus/preset-classic": "^3.6.0",
"@docusaurus/theme-mermaid": "^3.6.0",
"@docusaurus/theme-search-algolia": "^3.6.0",
"@mdx-js/react": "^3.0.1",
"@radix-ui/react-tabs": "^1.0.4",
@ -25,6 +26,7 @@
"docusaurus-plugin-internaldocs-fb": "1.19.0",
"docusaurus-plugin-typedoc": "^0.22.0",
"fs-extra": "^10.0.0",
"mermaid": "^11.6.0",
"prism-react-renderer": "^2.3.1",
"react": "^18.2.0",
"react-dom": "^18.2.0",

View File

@ -42,6 +42,7 @@ const sidebars = {
'concepts/editor-state',
'concepts/nodes',
'concepts/node-replacement',
'concepts/node-state',
'concepts/key-management',
'concepts/node-cloning',
'concepts/listeners',

View File

@ -31,7 +31,7 @@ import {
updateEditor,
updateEditorSync,
} from './LexicalUpdates';
import {HISTORY_MERGE_TAG} from './LexicalUpdateTags';
import {FOCUS_TAG, HISTORY_MERGE_TAG, UpdateTag} from './LexicalUpdateTags';
import {
$addUpdateTag,
$onUpdate,
@ -88,7 +88,7 @@ export type TextNodeThemeClasses = {
export type EditorUpdateOptions = {
onUpdate?: () => void;
skipTransforms?: true;
tag?: string | Array<string>;
tag?: UpdateTag | UpdateTag[];
discrete?: true;
/** @internal */
event?: undefined | UIEvent | Event | null;
@ -257,7 +257,10 @@ export interface UpdateListenerPayload {
* The Map of LexicalNode constructors to a `Map<NodeKey, NodeMutation>`,
* this is useful when you have a mutation listener type use cases that
* should apply to all or most nodes. Will be null if no DOM was mutated,
* such as when only the selection changed.
* such as when only the selection changed. Note that this will be empty
* unless at least one MutationListener is explicitly registered
* (any MutationListener is sufficient to compute the mutatedNodes Map
* for all nodes).
*
* Added in v0.28.0
*/
@ -653,7 +656,7 @@ export class LexicalEditor {
/** @internal */
_normalizedNodes: Set<NodeKey>;
/** @internal */
_updateTags: Set<string>;
_updateTags: Set<UpdateTag>;
/** @internal */
_observer: null | MutationObserver;
/** @internal */
@ -1319,7 +1322,7 @@ export class LexicalEditor {
root.selectEnd();
}
}
$addUpdateTag('focus');
$addUpdateTag(FOCUS_TAG);
$onUpdate(() => {
rootElement.removeAttribute('autocapitalize');
if (callbackFn) {

View File

@ -192,7 +192,7 @@ export type DOMExportOutputMap = Map<
export type DOMExportOutput = {
after?: (
generatedElement: HTMLElement | DocumentFragment | Text | null | undefined,
) => HTMLElement | Text | null | undefined;
) => HTMLElement | DocumentFragment | Text | null | undefined;
element: HTMLElement | DocumentFragment | Text | null;
};

View File

@ -51,3 +51,24 @@ export const SKIP_SCROLL_INTO_VIEW_TAG = 'skip-scroll-into-view';
* This is useful when you want to make updates without changing the selection or focus
*/
export const SKIP_DOM_SELECTION_TAG = 'skip-dom-selection';
/**
* The update was triggered by editor.focus()
*/
export const FOCUS_TAG = 'focus';
/**
* The set of known update tags to help with TypeScript suggestions.
*/
export type UpdateTag =
| typeof COLLABORATION_TAG
| typeof FOCUS_TAG
| typeof HISTORIC_TAG
| typeof HISTORY_MERGE_TAG
| typeof HISTORY_PUSH_TAG
| typeof PASTE_TAG
| typeof SKIP_COLLAB_TAG
| typeof SKIP_DOM_SELECTION_TAG
| typeof SKIP_SCROLL_INTO_VIEW_TAG
// eslint-disable-next-line @typescript-eslint/ban-types
| (string & {});

View File

@ -53,6 +53,7 @@ import {
ElementNode,
HISTORY_MERGE_TAG,
LineBreakNode,
UpdateTag,
} from '.';
import {
COMPOSITION_SUFFIX,
@ -1336,12 +1337,12 @@ export function scrollIntoViewIfNeeded(
}
}
export function $hasUpdateTag(tag: string): boolean {
export function $hasUpdateTag(tag: UpdateTag): boolean {
const editor = getActiveEditor();
return editor._updateTags.has(tag);
}
export function $addUpdateTag(tag: string): void {
export function $addUpdateTag(tag: UpdateTag): void {
errorOnReadOnly();
const editor = getActiveEditor();
editor._updateTags.add(tag);

View File

@ -147,6 +147,7 @@ export type {
LexicalNodeReplacement,
MutationListener,
NodeMutation,
RootListener,
SerializedEditor,
Spread,
Transform,
@ -310,4 +311,5 @@ export {
SKIP_COLLAB_TAG,
SKIP_DOM_SELECTION_TAG,
SKIP_SCROLL_INTO_VIEW_TAG,
type UpdateTag,
} from './LexicalUpdateTags';