[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()],
|
||||
});
|