[lexical][lexical-website] Documentation: Start on NodeState docs and examples (#7294)
9
examples/node-replacement/README.md
Normal 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`
|
||||
|
||||
[](https://stackblitz.com/github/facebook/lexical/tree/main/examples/node-replacement?file=src/main.tsx)
|
12
examples/node-replacement/index.html
Normal 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
26
examples/node-replacement/package.json
Normal 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"
|
||||
}
|
||||
}
|
5
examples/node-replacement/public/icons/LICENSE.md
Normal file
@ -0,0 +1,5 @@
|
||||
Bootstrap Icons
|
||||
https://icons.getbootstrap.com
|
||||
|
||||
Licensed under MIT license
|
||||
https://github.com/twbs/icons/blob/main/LICENSE.md
|
@ -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 |
@ -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 |
5
examples/node-replacement/public/icons/journal-text.svg
Normal 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 |
3
examples/node-replacement/public/icons/justify.svg
Normal 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 |
3
examples/node-replacement/public/icons/text-center.svg
Normal 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 |
3
examples/node-replacement/public/icons/text-left.svg
Normal 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 |
@ -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 |
3
examples/node-replacement/public/icons/text-right.svg
Normal 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 |
3
examples/node-replacement/public/icons/type-bold.svg
Normal 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 |
3
examples/node-replacement/public/icons/type-italic.svg
Normal 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 |
@ -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 |
@ -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 |
73
examples/node-replacement/src/App.tsx
Normal 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>
|
||||
);
|
||||
}
|
43
examples/node-replacement/src/ExampleTheme.ts
Normal 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',
|
||||
},
|
||||
};
|
22
examples/node-replacement/src/main.tsx
Normal 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>,
|
||||
);
|
37
examples/node-replacement/src/nodes/CustomParagraphNode.ts
Normal 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());
|
||||
}
|
172
examples/node-replacement/src/plugins/ToolbarPlugin.tsx
Normal 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>
|
||||
);
|
||||
}
|
27
examples/node-replacement/src/plugins/TreeViewPlugin.tsx
Normal 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}
|
||||
/>
|
||||
);
|
||||
}
|
25
examples/node-replacement/src/styleConfig.ts
Normal 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 : '';
|
||||
}
|
450
examples/node-replacement/src/styles.css
Normal 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);
|
||||
}
|
1
examples/node-replacement/src/vite-env.d.ts
vendored
Normal file
@ -0,0 +1 @@
|
||||
/// <reference types="vite/client" />
|
25
examples/node-replacement/tsconfig.json
Normal 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"}]
|
||||
}
|
11
examples/node-replacement/tsconfig.node.json
Normal file
@ -0,0 +1,11 @@
|
||||
{
|
||||
"compilerOptions": {
|
||||
"composite": true,
|
||||
"skipLibCheck": true,
|
||||
"module": "ESNext",
|
||||
"moduleResolution": "bundler",
|
||||
"allowSyntheticDefaultImports": true,
|
||||
"strict": true
|
||||
},
|
||||
"include": ["vite.config.ts"]
|
||||
}
|
15
examples/node-replacement/vite.config.monorepo.ts
Normal 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()],
|
||||
});
|
14
examples/node-replacement/vite.config.ts
Normal 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()],
|
||||
});
|
8
examples/node-state-style/README.md
Normal 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`
|
||||
|
||||
[](https://stackblitz.com/github/facebook/lexical/tree/main/examples/node-state-style?file=src/main.tsx)
|
12
examples/node-state-style/index.html
Normal 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
38
examples/node-state-style/package.json
Normal 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"
|
||||
}
|
||||
}
|
114
examples/node-state-style/src/App.tsx
Normal 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>
|
||||
);
|
||||
}
|
43
examples/node-state-style/src/ExampleTheme.ts
Normal 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',
|
||||
},
|
||||
};
|
5
examples/node-state-style/src/icons/LICENSE.md
Normal file
@ -0,0 +1,5 @@
|
||||
Bootstrap Icons
|
||||
https://icons.getbootstrap.com
|
||||
|
||||
Licensed under MIT license
|
||||
https://github.com/twbs/icons/blob/main/LICENSE.md
|
4
examples/node-state-style/src/icons/arrow-clockwise.svg
Normal 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 |
@ -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 |
3
examples/node-state-style/src/icons/text-paragraph.svg
Normal 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 |
3
examples/node-state-style/src/icons/type-bold.svg
Normal 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 |
3
examples/node-state-style/src/icons/type-italic.svg
Normal 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 |
@ -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 |
3
examples/node-state-style/src/icons/type-underline.svg
Normal 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 |
22
examples/node-state-style/src/main.tsx
Normal 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>,
|
||||
);
|
@ -0,0 +1,5 @@
|
||||
.shiki-view-plugin > pre.shiki {
|
||||
margin: 0;
|
||||
padding: 10px;
|
||||
white-space: pre-wrap;
|
||||
}
|
92
examples/node-state-style/src/plugins/ShikiViewPlugin.tsx
Normal 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}}
|
||||
/>
|
||||
);
|
||||
}
|
205
examples/node-state-style/src/plugins/StyleViewPlugin.css
Normal 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);
|
||||
}
|
840
examples/node-state-style/src/plugins/StyleViewPlugin.tsx
Normal 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;
|
||||
}
|
162
examples/node-state-style/src/plugins/ToolbarPlugin.tsx
Normal 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>
|
||||
);
|
||||
}
|
468
examples/node-state-style/src/styleState.ts
Normal 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),
|
||||
);
|
||||
}
|
446
examples/node-state-style/src/styles.css
Normal 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;
|
||||
}
|
1
examples/node-state-style/src/vite-env.d.ts
vendored
Normal file
@ -0,0 +1 @@
|
||||
/// <reference types="vite/client" />
|
25
examples/node-state-style/tsconfig.json
Normal 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"}]
|
||||
}
|
11
examples/node-state-style/tsconfig.node.json
Normal file
@ -0,0 +1,11 @@
|
||||
{
|
||||
"compilerOptions": {
|
||||
"composite": true,
|
||||
"skipLibCheck": true,
|
||||
"module": "ESNext",
|
||||
"moduleResolution": "bundler",
|
||||
"allowSyntheticDefaultImports": true,
|
||||
"strict": true
|
||||
},
|
||||
"include": ["vite.config.ts"]
|
||||
}
|
15
examples/node-state-style/vite.config.monorepo.ts
Normal 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()],
|
||||
});
|
14
examples/node-state-style/vite.config.ts
Normal 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
@ -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;
|
||||
|
||||
|
@ -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
|
||||
|
||||
|
@ -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.
|
||||
|
@ -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
|
||||
|
@ -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`.
|
||||
|
||||
[](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>
|
||||
|
216
packages/lexical-website/docs/concepts/node-state.md
Normal 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.
|
||||
|
||||
[](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>
|
@ -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(
|
||||
|
@ -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.
|
||||
|
||||
|
@ -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).
|
||||
|
@ -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',
|
||||
};
|
||||
|
@ -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",
|
||||
|
@ -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',
|
||||
|
@ -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) {
|
||||
|
@ -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;
|
||||
};
|
||||
|
||||
|
@ -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 & {});
|
||||
|
@ -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);
|
||||
|
@ -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';
|
||||
|