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

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

View File

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

View File

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

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

File diff suppressed because it is too large Load Diff

View File

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

View File

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

View File

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

After

Width:  |  Height:  |  Size: 352 B

View File

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

After

Width:  |  Height:  |  Size: 359 B

View File

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

After

Width:  |  Height:  |  Size: 759 B

View File

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

After

Width:  |  Height:  |  Size: 414 B

View File

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

After

Width:  |  Height:  |  Size: 416 B

View File

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

After

Width:  |  Height:  |  Size: 412 B

View File

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

After

Width:  |  Height:  |  Size: 417 B

View File

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

After

Width:  |  Height:  |  Size: 415 B

View File

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

After

Width:  |  Height:  |  Size: 470 B

View File

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

After

Width:  |  Height:  |  Size: 351 B

View File

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

After

Width:  |  Height:  |  Size: 579 B

View File

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

After

Width:  |  Height:  |  Size: 334 B

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

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

File diff suppressed because it is too large Load Diff

View File

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

View File

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

View File

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

View File

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

View File

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

After

Width:  |  Height:  |  Size: 352 B

View File

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

After

Width:  |  Height:  |  Size: 359 B

View File

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

After

Width:  |  Height:  |  Size: 417 B

View File

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

After

Width:  |  Height:  |  Size: 470 B

View File

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

After

Width:  |  Height:  |  Size: 351 B

View File

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

After

Width:  |  Height:  |  Size: 579 B

View File

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

After

Width:  |  Height:  |  Size: 334 B

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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