diff --git a/.flowconfig b/.flowconfig index 4dcf210f332..6386030942e 100644 --- a/.flowconfig +++ b/.flowconfig @@ -56,6 +56,7 @@ module.name_mapper='^@lexical/overflow$' -> '/packages/lexical-ove module.name_mapper='^@lexical/plain-text$' -> '/packages/lexical-plain-text/flow/LexicalPlainText.js.flow' module.name_mapper='^@lexical/react/ExtensionComponent$' -> '/packages/lexical-react/flow/LexicalExtensionComponent.js.flow' module.name_mapper='^@lexical/react/LexicalAutoEmbedPlugin$' -> '/packages/lexical-react/flow/LexicalAutoEmbedPlugin.js.flow' +module.name_mapper='^@lexical/react/LexicalAutoEmbedPluginUtils$' -> '/packages/lexical-react/flow/LexicalAutoEmbedPluginUtils.js.flow' module.name_mapper='^@lexical/react/LexicalAutoFocusPlugin$' -> '/packages/lexical-react/flow/LexicalAutoFocusPlugin.js.flow' module.name_mapper='^@lexical/react/LexicalAutoLinkPlugin$' -> '/packages/lexical-react/flow/LexicalAutoLinkPlugin.js.flow' module.name_mapper='^@lexical/react/LexicalBlockWithAlignableContents$' -> '/packages/lexical-react/flow/LexicalBlockWithAlignableContents.js.flow' @@ -64,6 +65,7 @@ module.name_mapper='^@lexical/react/LexicalCheckListPlugin$' -> '/ module.name_mapper='^@lexical/react/LexicalClearEditorPlugin$' -> '/packages/lexical-react/flow/LexicalClearEditorPlugin.js.flow' module.name_mapper='^@lexical/react/LexicalClickableLinkPlugin$' -> '/packages/lexical-react/flow/LexicalClickableLinkPlugin.js.flow' module.name_mapper='^@lexical/react/LexicalCollaborationContext$' -> '/packages/lexical-react/flow/LexicalCollaborationContext.js.flow' +module.name_mapper='^@lexical/react/LexicalCollaborationContextUtils$' -> '/packages/lexical-react/flow/LexicalCollaborationContextUtils.js.flow' module.name_mapper='^@lexical/react/LexicalCollaborationPlugin$' -> '/packages/lexical-react/flow/LexicalCollaborationPlugin.js.flow' module.name_mapper='^@lexical/react/LexicalComposer$' -> '/packages/lexical-react/flow/LexicalComposer.js.flow' module.name_mapper='^@lexical/react/LexicalComposerContext$' -> '/packages/lexical-react/flow/LexicalComposerContext.js.flow' @@ -94,6 +96,7 @@ module.name_mapper='^@lexical/react/LexicalTableOfContentsPlugin$' -> ' '/packages/lexical-react/flow/LexicalTablePlugin.js.flow' module.name_mapper='^@lexical/react/LexicalTreeView$' -> '/packages/lexical-react/flow/LexicalTreeView.js.flow' module.name_mapper='^@lexical/react/LexicalTypeaheadMenuPlugin$' -> '/packages/lexical-react/flow/LexicalTypeaheadMenuPlugin.js.flow' +module.name_mapper='^@lexical/react/LexicalTypeaheadMenuPluginUtils$' -> '/packages/lexical-react/flow/LexicalTypeaheadMenuPluginUtils.js.flow' module.name_mapper='^@lexical/react/ReactExtension$' -> '/packages/lexical-react/flow/LexicalReactExtension.js.flow' module.name_mapper='^@lexical/react/ReactPluginHostExtension$' -> '/packages/lexical-react/flow/LexicalReactPluginHostExtension.js.flow' module.name_mapper='^@lexical/react/ReactProviderExtension$' -> '/packages/lexical-react/flow/LexicalReactProviderExtension.js.flow' diff --git a/dev-examples/hmr/README.md b/dev-examples/hmr/README.md new file mode 100644 index 00000000000..8c106b47550 --- /dev/null +++ b/dev-examples/hmr/README.md @@ -0,0 +1,17 @@ +# Lexical HMR Example + +A minimal [Vite](https://vitejs.dev/) + React app that demonstrates +`HMRExtension` from `@lexical/extension`. Editor content, the editable flag, +and undo/redo history are preserved across hot module reloads. + +## Running + +From the repository root: + +```sh +pnpm install +pnpm -C dev-examples/hmr dev +``` + +Then open the printed URL, type something, and edit `src/App.tsx`. The editor +state survives the reload. diff --git a/dev-examples/hmr/index.html b/dev-examples/hmr/index.html new file mode 100644 index 00000000000..da83ad8eb6f --- /dev/null +++ b/dev-examples/hmr/index.html @@ -0,0 +1,12 @@ + + + + + + Lexical HMR Example + + +
+ + + diff --git a/dev-examples/hmr/package.json b/dev-examples/hmr/package.json new file mode 100644 index 00000000000..8c4355addfa --- /dev/null +++ b/dev-examples/hmr/package.json @@ -0,0 +1,33 @@ +{ + "name": "@lexical/dev-hmr-example", + "private": true, + "version": "0.0.0", + "type": "module", + "scripts": { + "dev": "vite", + "build": "tsc && vite build", + "preview": "vite preview" + }, + "dependencies": { + "@lexical/extension": "workspace:*", + "@lexical/history": "workspace:*", + "@lexical/react": "workspace:*", + "@lexical/rich-text": "workspace:*", + "lexical": "workspace:*", + "react": "^19.1.0", + "react-dom": "^19.1.0" + }, + "devDependencies": { + "@types/react": "^19.1.2", + "@types/react-dom": "^19.1.2", + "@vitejs/plugin-react": "^6.0.2", + "typescript": "^6.0.3", + "vite": "^8.0.16" + }, + "optionalDependencies": { + "@rollup/rollup-linux-x64-gnu": "4.61.0", + "@rollup/rollup-darwin-arm64": "4.61.0", + "@rollup/rollup-win32-x64-msvc": "4.61.0", + "@rollup/wasm-node": "4.61.0" + } +} diff --git a/dev-examples/hmr/src/App.tsx b/dev-examples/hmr/src/App.tsx new file mode 100644 index 00000000000..d4ef9061fd4 --- /dev/null +++ b/dev-examples/hmr/src/App.tsx @@ -0,0 +1,48 @@ +/** + * 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 {AutoFocusExtension, HMRExtension} from '@lexical/extension'; +import {HistoryExtension} from '@lexical/history'; +import {ContentEditable} from '@lexical/react/LexicalContentEditable'; +import {LexicalExtensionComposer} from '@lexical/react/LexicalExtensionComposer'; +import {RichTextExtension} from '@lexical/rich-text'; +import {configExtension, defineExtension} from 'lexical'; + +const extension = defineExtension({ + dependencies: [ + RichTextExtension, + HistoryExtension, + AutoFocusExtension, + configExtension(HMRExtension, {hot: import.meta.hot ?? null}), + ], + name: '@lexical/examples/hmr', + namespace: 'HMR Demo', +}); + +const placeholder = 'Type here — edits survive HMR reloads…'; + +export default function App(): JSX.Element { + return ( +
+

Lexical HMR Example

+

+ Edit src/App.tsx and save. The editor content, editable + flag, and undo history are preserved across hot module reloads. +

+ +
+ {placeholder}
} + /> +
+ + + ); +} diff --git a/dev-examples/hmr/src/main.tsx b/dev-examples/hmr/src/main.tsx new file mode 100644 index 00000000000..fdea44ca506 --- /dev/null +++ b/dev-examples/hmr/src/main.tsx @@ -0,0 +1,17 @@ +/** + * 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 'react'; +import ReactDOM from 'react-dom/client'; + +import App from './App.tsx'; + +ReactDOM.createRoot(document.getElementById('root')!).render( + + + , +); diff --git a/dev-examples/hmr/src/vite-env.d.ts b/dev-examples/hmr/src/vite-env.d.ts new file mode 100644 index 00000000000..c63a585ee73 --- /dev/null +++ b/dev-examples/hmr/src/vite-env.d.ts @@ -0,0 +1,9 @@ +/** + * 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. + * + */ + +/// diff --git a/dev-examples/hmr/tsconfig.json b/dev-examples/hmr/tsconfig.json new file mode 100644 index 00000000000..6615c9009f7 --- /dev/null +++ b/dev-examples/hmr/tsconfig.json @@ -0,0 +1,23 @@ +{ + "extends": "../../tsconfig.json", + "compilerOptions": { + "useDefineForClassFields": true, + "skipLibCheck": true, + + /* Bundler mode */ + "moduleResolution": "bundler", + "allowImportingTsExtensions": true, + "resolveJsonModule": true, + "isolatedModules": true, + "noEmit": true, + "jsx": "react-jsx", + + /* Linting */ + "strict": true, + "noFallthroughCasesInSwitch": true, + + "types": ["node"] + }, + "include": ["src", "../../libdefs/globals.d.ts"], + "references": [{"path": "./tsconfig.node.json"}] +} diff --git a/dev-examples/hmr/tsconfig.node.json b/dev-examples/hmr/tsconfig.node.json new file mode 100644 index 00000000000..97ede7ee6f2 --- /dev/null +++ b/dev-examples/hmr/tsconfig.node.json @@ -0,0 +1,11 @@ +{ + "compilerOptions": { + "composite": true, + "skipLibCheck": true, + "module": "ESNext", + "moduleResolution": "bundler", + "allowSyntheticDefaultImports": true, + "strict": true + }, + "include": ["vite.config.ts"] +} diff --git a/dev-examples/hmr/vite.config.ts b/dev-examples/hmr/vite.config.ts new file mode 100644 index 00000000000..9ddfafc572c --- /dev/null +++ b/dev-examples/hmr/vite.config.ts @@ -0,0 +1,17 @@ +/** + * 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'; + +import lexicalMonorepoPlugin from '../../scripts/vite/lexicalMonorepoPlugin'; + +export default defineConfig({ + plugins: [react(), lexicalMonorepoPlugin()], + preview: {port: 4328, strictPort: true}, + server: {port: 4328, strictPort: true}, +}); diff --git a/packages/lexical-devtools/tsconfig.json b/packages/lexical-devtools/tsconfig.json index 0242f8fc2bf..d5d98f5c891 100644 --- a/packages/lexical-devtools/tsconfig.json +++ b/packages/lexical-devtools/tsconfig.json @@ -60,6 +60,9 @@ "@lexical/react/LexicalAutoEmbedPlugin": [ "../lexical-react/src/LexicalAutoEmbedPlugin.tsx" ], + "@lexical/react/LexicalAutoEmbedPluginUtils": [ + "../lexical-react/src/LexicalAutoEmbedPluginUtils.ts" + ], "@lexical/react/LexicalAutoFocusPlugin": [ "../lexical-react/src/LexicalAutoFocusPlugin.ts" ], @@ -84,6 +87,9 @@ "@lexical/react/LexicalCollaborationContext": [ "../lexical-react/src/LexicalCollaborationContext.tsx" ], + "@lexical/react/LexicalCollaborationContextUtils": [ + "../lexical-react/src/LexicalCollaborationContextUtils.ts" + ], "@lexical/react/LexicalCollaborationPlugin": [ "../lexical-react/src/LexicalCollaborationPlugin.tsx" ], @@ -174,6 +180,9 @@ "@lexical/react/LexicalTypeaheadMenuPlugin": [ "../lexical-react/src/LexicalTypeaheadMenuPlugin.tsx" ], + "@lexical/react/LexicalTypeaheadMenuPluginUtils": [ + "../lexical-react/src/LexicalTypeaheadMenuPluginUtils.ts" + ], "@lexical/react/ReactExtension": [ "../lexical-react/src/ReactExtension.tsx" ], diff --git a/packages/lexical-extension/src/HMRExtension.ts b/packages/lexical-extension/src/HMRExtension.ts new file mode 100644 index 00000000000..090e2c29c0b --- /dev/null +++ b/packages/lexical-extension/src/HMRExtension.ts @@ -0,0 +1,175 @@ +/** + * 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 { + HistoryExtension, + HistoryState, + HistoryStateEntry, +} from '@lexical/history'; + +import { + defineExtension, + type EditorState, + HISTORY_MERGE_TAG, + type LexicalEditor, + type LexicalNode, + safeCast, +} from 'lexical'; + +import {EditorStateExtension} from './EditorStateExtension'; +import {getPeerDependencyFromEditor} from './getPeerDependencyFromEditor'; +import {effect} from './signals'; +import {WatchEditableExtension} from './WatchEditableExtension'; + +const __DEV__ = process.env.NODE_ENV !== 'production'; + +/** + * Minimal interface for bundler HMR contexts. Satisfied by Vite's + * `ViteHotContext` and similar bundler HMR contexts. + */ +export interface HotContext { + readonly data: Record; +} + +/** Configuration for {@link HMRExtension}. */ +export interface HMRConfig { + /** + * The bundler's HMR context, typically `import.meta.hot`. Pass `null` + * in production or when HMR is not available. + */ + hot: HotContext | null; +} + +const HMR_KEY = 'lexicalHMR'; +const HISTORY_EXTENSION_NAME = '@lexical/history/History'; + +interface HMRSavedState { + editable: boolean; + editorState: EditorState; + historyState: HistoryState | null; +} + +function getSavedHMRState(hot: HotContext): HMRSavedState | undefined { + return hot.data[HMR_KEY] as HMRSavedState | undefined; +} + +function swapNodePrototypes( + nodeMap: ReadonlyMap, + editor: LexicalEditor, +): void { + for (const node of nodeMap.values()) { + const registered = editor._nodes.get(node.__type); + if (registered) { + Object.setPrototypeOf(node, registered.klass.prototype); + } else if (__DEV__) { + console.warn( + `HMR: Node type "${node.__type}" is not registered in the new editor. Its prototype was not updated.`, + ); + } + } +} + +function updateHistoryEntries( + historyState: HistoryState, + editor: LexicalEditor, +): void { + const update = (entry: HistoryStateEntry) => { + entry.editor = editor; + swapNodePrototypes(entry.editorState._nodeMap, editor); + }; + if (historyState.current) { + update(historyState.current); + } + for (const entry of historyState.undoStack) { + update(entry); + } + for (const entry of historyState.redoStack) { + update(entry); + } +} + +/** + * Preserves editor state, editability, and undo history across Hot Module + * Replacement (HMR) cycles. When `HistoryExtension` is present as a peer, + * undo/redo stacks are preserved as well. + * + * @example + * ```ts + * import {buildEditorFromExtensions, configExtension, HMRExtension} from '@lexical/extension'; + * import {RichTextExtension} from '@lexical/rich-text'; + * import {HistoryExtension} from '@lexical/history'; + * + * const editor = buildEditorFromExtensions({ + * name: '[root]', + * dependencies: [ + * RichTextExtension, + * HistoryExtension, + * configExtension(HMRExtension, {hot: import.meta.hot ?? null}), + * ], + * }); + * ``` + */ +export const HMRExtension = /* @__PURE__ */ defineExtension({ + afterRegistration(editor, {hot}, state) { + if (!hot) { + return () => {}; + } + + const saved = getSavedHMRState(hot); + if (saved) { + try { + editor.setEditable(saved.editable); + + if (!saved.editorState.isEmpty()) { + swapNodePrototypes(saved.editorState._nodeMap, editor); + editor.setEditorState(saved.editorState, {tag: HISTORY_MERGE_TAG}); + + if (saved.historyState) { + const historyPeer = getPeerDependencyFromEditor< + typeof HistoryExtension + >(editor, HISTORY_EXTENSION_NAME); + if (historyPeer) { + updateHistoryEntries(saved.historyState, editor); + historyPeer.output.historyState.value = saved.historyState; + } + } + } + } catch (e) { + if (__DEV__) { + console.warn( + 'HMR: Could not restore previous editor state. Starting fresh.', + e, + ); + } + } + } + + const editorStateSignal = state.getDependency(EditorStateExtension).output; + const editableSignal = state.getDependency(WatchEditableExtension).output; + const historyPeer = getPeerDependencyFromEditor( + editor, + HISTORY_EXTENSION_NAME, + ); + return effect(() => { + const editorState = editorStateSignal.value; + const editable = editableSignal.value; + const prev = getSavedHMRState(hot); + hot.data[HMR_KEY] = safeCast({ + editable, + editorState: + editorState.isEmpty() && prev ? prev.editorState : editorState, + historyState: historyPeer + ? historyPeer.output.historyState.value + : null, + }); + }); + }, + config: /* @__PURE__ */ safeCast({hot: null}), + dependencies: [EditorStateExtension, WatchEditableExtension], + name: '@lexical/extension/HMR', +}); diff --git a/packages/lexical-extension/src/__tests__/unit/HMRExtension.test.ts b/packages/lexical-extension/src/__tests__/unit/HMRExtension.test.ts new file mode 100644 index 00000000000..0719febc2f9 --- /dev/null +++ b/packages/lexical-extension/src/__tests__/unit/HMRExtension.test.ts @@ -0,0 +1,203 @@ +/** + * 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 { + buildEditorFromExtensions, + configExtension, + HMRExtension, + type HotContext, +} from '@lexical/extension'; +import {HistoryExtension} from '@lexical/history'; +import { + $createParagraphNode, + $createTextNode, + $getRoot, + defineExtension, + HISTORY_PUSH_TAG, + REDO_COMMAND, + UNDO_COMMAND, +} from 'lexical'; +import {describe, expect, test} from 'vitest'; + +function createMockHotContext(): HotContext { + return {data: {}}; +} + +function $setupContent(text: string) { + $getRoot() + .clear() + .append($createParagraphNode().append($createTextNode(text))); +} + +function createEditor(hot: HotContext | null) { + return buildEditorFromExtensions( + defineExtension({ + $initialEditorState: () => $setupContent('initial'), + dependencies: [HistoryExtension, configExtension(HMRExtension, {hot})], + name: 'hmr-test', + }), + ); +} + +describe('HMRExtension', () => { + test('preserves editor content through HMR cycle', () => { + const hot = createMockHotContext(); + + { + using editor = createEditor(hot); + editor.update(() => $setupContent('hello HMR'), {discrete: true}); + editor.read(() => { + expect($getRoot().getTextContent()).toBe('hello HMR'); + }); + } + + { + using editor = createEditor(hot); + editor.read(() => { + expect($getRoot().getTextContent()).toBe('hello HMR'); + }); + } + }); + + test('preserves editable flag through HMR cycle', () => { + const hot = createMockHotContext(); + + { + using editor = createEditor(hot); + editor.setEditable(false); + } + + { + using editor = createEditor(hot); + expect(editor.isEditable()).toBe(false); + } + }); + + test('preserves undo and redo history through HMR cycle', () => { + const hot = createMockHotContext(); + + { + using editor = createEditor(hot); + editor.update(() => $setupContent('first'), {discrete: true}); + editor.update(() => $setupContent('second'), { + discrete: true, + tag: HISTORY_PUSH_TAG, + }); + } + + { + using editor = createEditor(hot); + editor.read(() => { + expect($getRoot().getTextContent()).toBe('second'); + }); + editor.dispatchCommand(UNDO_COMMAND, undefined); + editor.read(() => { + expect($getRoot().getTextContent()).toBe('first'); + }); + editor.dispatchCommand(REDO_COMMAND, undefined); + editor.read(() => { + expect($getRoot().getTextContent()).toBe('second'); + }); + } + }); + + test('preserves state through multiple HMR cycles', () => { + const hot = createMockHotContext(); + + { + using editor = createEditor(hot); + editor.update(() => $setupContent('cycle-1'), {discrete: true}); + } + + { + using editor = createEditor(hot); + editor.read(() => { + expect($getRoot().getTextContent()).toBe('cycle-1'); + }); + editor.update(() => $setupContent('cycle-2'), {discrete: true}); + } + + { + using editor = createEditor(hot); + editor.read(() => { + expect($getRoot().getTextContent()).toBe('cycle-2'); + }); + } + }); + + test('does nothing when hot is null', () => { + using editor = createEditor(null); + editor.read(() => { + expect($getRoot().getTextContent()).toBe('initial'); + }); + }); + + test('starts fresh when saved state is corrupted', () => { + const hot = createMockHotContext(); + // Must match HMR_KEY in HMRExtension.ts + hot.data.lexicalHMR = { + editable: true, + editorState: 'corrupt', + historyState: null, + }; + + using editor = createEditor(hot); + editor.read(() => { + expect($getRoot().getTextContent()).toBe('initial'); + }); + }); + + test('uses initial state when saved editorState is empty', () => { + const hot = createMockHotContext(); + + const bareEditor = buildEditorFromExtensions( + defineExtension({name: 'empty-state-source'}), + ); + const emptyState = bareEditor.getEditorState(); + bareEditor.dispose(); + + // Must match HMR_KEY in HMRExtension.ts + hot.data.lexicalHMR = { + editable: false, + editorState: emptyState, + historyState: null, + }; + + using editor = createEditor(hot); + expect(editor.isEditable()).toBe(false); + editor.read(() => { + expect($getRoot().getTextContent()).toBe('initial'); + }); + }); + + test('works without HistoryExtension', () => { + const hot = createMockHotContext(); + + function createEditorNoHistory(hotCtx: HotContext) { + return buildEditorFromExtensions( + defineExtension({ + $initialEditorState: () => $setupContent('initial'), + dependencies: [configExtension(HMRExtension, {hot: hotCtx})], + name: 'hmr-no-history-test', + }), + ); + } + + { + using editor = createEditorNoHistory(hot); + editor.update(() => $setupContent('no history'), {discrete: true}); + } + + { + using editor = createEditorNoHistory(hot); + editor.read(() => { + expect($getRoot().getTextContent()).toBe('no history'); + }); + } + }); +}); diff --git a/packages/lexical-extension/src/index.ts b/packages/lexical-extension/src/index.ts index 8a6e7b2ecb2..92429d30f5b 100644 --- a/packages/lexical-extension/src/index.ts +++ b/packages/lexical-extension/src/index.ts @@ -39,6 +39,7 @@ export { getPeerDependencyFromEditor, getPeerDependencyFromEditorOrThrow, } from './getPeerDependencyFromEditor'; +export {type HMRConfig, HMRExtension, type HotContext} from './HMRExtension'; export { $createHorizontalRuleNode, $isHorizontalRuleNode, diff --git a/packages/lexical-react/flow/LexicalAutoEmbedPluginUtils.js.flow b/packages/lexical-react/flow/LexicalAutoEmbedPluginUtils.js.flow new file mode 100644 index 00000000000..311c8d6640b --- /dev/null +++ b/packages/lexical-react/flow/LexicalAutoEmbedPluginUtils.js.flow @@ -0,0 +1,12 @@ +/** + * 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. + * + * @flow strict + */ + +/** + * LexicalAutoEmbedPluginUtils + */ diff --git a/packages/lexical-react/flow/LexicalCollaborationContextUtils.js.flow b/packages/lexical-react/flow/LexicalCollaborationContextUtils.js.flow new file mode 100644 index 00000000000..d3df724ee7e --- /dev/null +++ b/packages/lexical-react/flow/LexicalCollaborationContextUtils.js.flow @@ -0,0 +1,12 @@ +/** + * 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. + * + * @flow strict + */ + +/** + * LexicalCollaborationContextUtils + */ diff --git a/packages/lexical-react/flow/LexicalTypeaheadMenuPluginUtils.js.flow b/packages/lexical-react/flow/LexicalTypeaheadMenuPluginUtils.js.flow new file mode 100644 index 00000000000..c0792eb4dc3 --- /dev/null +++ b/packages/lexical-react/flow/LexicalTypeaheadMenuPluginUtils.js.flow @@ -0,0 +1,12 @@ +/** + * 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. + * + * @flow strict + */ + +/** + * LexicalTypeaheadMenuPluginUtils + */ diff --git a/packages/lexical-react/package.json b/packages/lexical-react/package.json index 2e831db919f..5ade2fdbc2c 100644 --- a/packages/lexical-react/package.json +++ b/packages/lexical-react/package.json @@ -123,6 +123,42 @@ "default": "./dist/LexicalAutoEmbedPlugin.js" } }, + "./LexicalAutoEmbedPluginUtils": { + "source": "./src/LexicalAutoEmbedPluginUtils.ts", + "import": { + "types@<5.2": "./dist/typescript-too-old.d.ts", + "types": "./dist/LexicalAutoEmbedPluginUtils.d.ts", + "development": "./dist/LexicalAutoEmbedPluginUtils.dev.mjs", + "production": "./dist/LexicalAutoEmbedPluginUtils.prod.mjs", + "node": "./dist/LexicalAutoEmbedPluginUtils.node.mjs", + "default": "./dist/LexicalAutoEmbedPluginUtils.mjs" + }, + "require": { + "types@<5.2": "./dist/typescript-too-old.d.ts", + "types": "./dist/LexicalAutoEmbedPluginUtils.d.ts", + "development": "./dist/LexicalAutoEmbedPluginUtils.dev.js", + "production": "./dist/LexicalAutoEmbedPluginUtils.prod.js", + "default": "./dist/LexicalAutoEmbedPluginUtils.js" + } + }, + "./LexicalAutoEmbedPluginUtils.js": { + "source": "./src/LexicalAutoEmbedPluginUtils.ts", + "import": { + "types@<5.2": "./dist/typescript-too-old.d.ts", + "types": "./dist/LexicalAutoEmbedPluginUtils.d.ts", + "development": "./dist/LexicalAutoEmbedPluginUtils.dev.mjs", + "production": "./dist/LexicalAutoEmbedPluginUtils.prod.mjs", + "node": "./dist/LexicalAutoEmbedPluginUtils.node.mjs", + "default": "./dist/LexicalAutoEmbedPluginUtils.mjs" + }, + "require": { + "types@<5.2": "./dist/typescript-too-old.d.ts", + "types": "./dist/LexicalAutoEmbedPluginUtils.d.ts", + "development": "./dist/LexicalAutoEmbedPluginUtils.dev.js", + "production": "./dist/LexicalAutoEmbedPluginUtils.prod.js", + "default": "./dist/LexicalAutoEmbedPluginUtils.js" + } + }, "./LexicalAutoFocusPlugin": { "source": "./src/LexicalAutoFocusPlugin.ts", "import": { @@ -411,6 +447,42 @@ "default": "./dist/LexicalCollaborationContext.js" } }, + "./LexicalCollaborationContextUtils": { + "source": "./src/LexicalCollaborationContextUtils.ts", + "import": { + "types@<5.2": "./dist/typescript-too-old.d.ts", + "types": "./dist/LexicalCollaborationContextUtils.d.ts", + "development": "./dist/LexicalCollaborationContextUtils.dev.mjs", + "production": "./dist/LexicalCollaborationContextUtils.prod.mjs", + "node": "./dist/LexicalCollaborationContextUtils.node.mjs", + "default": "./dist/LexicalCollaborationContextUtils.mjs" + }, + "require": { + "types@<5.2": "./dist/typescript-too-old.d.ts", + "types": "./dist/LexicalCollaborationContextUtils.d.ts", + "development": "./dist/LexicalCollaborationContextUtils.dev.js", + "production": "./dist/LexicalCollaborationContextUtils.prod.js", + "default": "./dist/LexicalCollaborationContextUtils.js" + } + }, + "./LexicalCollaborationContextUtils.js": { + "source": "./src/LexicalCollaborationContextUtils.ts", + "import": { + "types@<5.2": "./dist/typescript-too-old.d.ts", + "types": "./dist/LexicalCollaborationContextUtils.d.ts", + "development": "./dist/LexicalCollaborationContextUtils.dev.mjs", + "production": "./dist/LexicalCollaborationContextUtils.prod.mjs", + "node": "./dist/LexicalCollaborationContextUtils.node.mjs", + "default": "./dist/LexicalCollaborationContextUtils.mjs" + }, + "require": { + "types@<5.2": "./dist/typescript-too-old.d.ts", + "types": "./dist/LexicalCollaborationContextUtils.d.ts", + "development": "./dist/LexicalCollaborationContextUtils.dev.js", + "production": "./dist/LexicalCollaborationContextUtils.prod.js", + "default": "./dist/LexicalCollaborationContextUtils.js" + } + }, "./LexicalCollaborationPlugin": { "source": "./src/LexicalCollaborationPlugin.tsx", "import": { @@ -1491,6 +1563,42 @@ "default": "./dist/LexicalTypeaheadMenuPlugin.js" } }, + "./LexicalTypeaheadMenuPluginUtils": { + "source": "./src/LexicalTypeaheadMenuPluginUtils.ts", + "import": { + "types@<5.2": "./dist/typescript-too-old.d.ts", + "types": "./dist/LexicalTypeaheadMenuPluginUtils.d.ts", + "development": "./dist/LexicalTypeaheadMenuPluginUtils.dev.mjs", + "production": "./dist/LexicalTypeaheadMenuPluginUtils.prod.mjs", + "node": "./dist/LexicalTypeaheadMenuPluginUtils.node.mjs", + "default": "./dist/LexicalTypeaheadMenuPluginUtils.mjs" + }, + "require": { + "types@<5.2": "./dist/typescript-too-old.d.ts", + "types": "./dist/LexicalTypeaheadMenuPluginUtils.d.ts", + "development": "./dist/LexicalTypeaheadMenuPluginUtils.dev.js", + "production": "./dist/LexicalTypeaheadMenuPluginUtils.prod.js", + "default": "./dist/LexicalTypeaheadMenuPluginUtils.js" + } + }, + "./LexicalTypeaheadMenuPluginUtils.js": { + "source": "./src/LexicalTypeaheadMenuPluginUtils.ts", + "import": { + "types@<5.2": "./dist/typescript-too-old.d.ts", + "types": "./dist/LexicalTypeaheadMenuPluginUtils.d.ts", + "development": "./dist/LexicalTypeaheadMenuPluginUtils.dev.mjs", + "production": "./dist/LexicalTypeaheadMenuPluginUtils.prod.mjs", + "node": "./dist/LexicalTypeaheadMenuPluginUtils.node.mjs", + "default": "./dist/LexicalTypeaheadMenuPluginUtils.mjs" + }, + "require": { + "types@<5.2": "./dist/typescript-too-old.d.ts", + "types": "./dist/LexicalTypeaheadMenuPluginUtils.d.ts", + "development": "./dist/LexicalTypeaheadMenuPluginUtils.dev.js", + "production": "./dist/LexicalTypeaheadMenuPluginUtils.prod.js", + "default": "./dist/LexicalTypeaheadMenuPluginUtils.js" + } + }, "./ReactExtension": { "source": "./src/ReactExtension.tsx", "import": { diff --git a/packages/lexical-react/src/LexicalAutoEmbedPlugin.tsx b/packages/lexical-react/src/LexicalAutoEmbedPlugin.tsx index 3da78b5759c..ea366f94d4b 100644 --- a/packages/lexical-react/src/LexicalAutoEmbedPlugin.tsx +++ b/packages/lexical-react/src/LexicalAutoEmbedPlugin.tsx @@ -10,7 +10,6 @@ import {$isLinkNode, AutoLinkNode, LinkNode} from '@lexical/link'; import {useLexicalComposerContext} from '@lexical/react/LexicalComposerContext'; import { LexicalNodeMenuPlugin, - MenuOption, type MenuRenderFn, } from '@lexical/react/LexicalNodeMenuPlugin'; import { @@ -19,10 +18,6 @@ import { COMMAND_PRIORITY_EDITOR, COMMAND_PRIORITY_LOW, type CommandListenerPriority, - createCommand, - type LexicalCommand, - type LexicalEditor, - type LexicalNode, mergeRegister, type MutationListener, type NodeKey, @@ -31,71 +26,19 @@ import { } from 'lexical'; import {type JSX, useCallback, useEffect, useMemo, useState} from 'react'; -/** - * The result of matching a URL for an embed: the matched `url`, an `id` - * identifying the embedded resource, and optional provider-specific `data`. - */ -export type EmbedMatchResult = { - url: string; - id: string; - data?: TEmbedMatchResult; -}; - -/** - * Describes a kind of embed (for example YouTube, a tweet, or Google Maps) that - * {@link LexicalAutoEmbedPlugin} can detect and insert. Each config has a `type` - * identifier, a `parseUrl` function that decides whether a URL matches and - * extracts its data, and an `insertNode` function that inserts the corresponding - * Lexical node. - */ -export interface EmbedConfig< - TEmbedMatchResultData = unknown, - TEmbedMatchResult = EmbedMatchResult, -> { - // Used to identify this config e.g. youtube, tweet, google-maps. - type: string; - // Determine if a given URL is a match and return url data. - parseUrl: ( - text: string, - ) => Promise | TEmbedMatchResult | null; - // Create the Lexical embed node from the url data. - insertNode: (editor: LexicalEditor, result: TEmbedMatchResult) => void; -} - -/** - * A general-purpose regular expression for detecting URLs, provided as a - * convenience for implementing an {@link EmbedConfig}'s `parseUrl`. - */ -export const URL_MATCHER = - /((https?:\/\/(www\.)?)|(www\.))[-a-zA-Z0-9@:%._+~#=]{1,256}\.[a-zA-Z0-9()]{1,6}\b([-a-zA-Z0-9()@:%_+.~#?&//=]*)/; - -/** - * Command dispatched to start inserting an embed. Its payload is the `type` of - * the {@link EmbedConfig} to use; {@link LexicalAutoEmbedPlugin} listens for it - * and runs that config's URL detection flow. - */ -export const INSERT_EMBED_COMMAND: LexicalCommand = - /* @__PURE__ */ createCommand('INSERT_EMBED_COMMAND'); +import { + type AutoEmbedOption, + type EmbedConfig, + INSERT_EMBED_COMMAND, +} from './LexicalAutoEmbedPluginUtils'; -/** - * A {@link MenuOption} for the auto-embed menu, pairing a display `title` with - * an `onSelect` callback invoked when the user chooses to embed the detected - * URL. - */ -export class AutoEmbedOption extends MenuOption { - title: string; - onSelect: (targetNode: LexicalNode | null) => void; - constructor( - title: string, - options: { - onSelect: (targetNode: LexicalNode | null) => void; - }, - ) { - super(title); - this.title = title; - this.onSelect = options.onSelect.bind(this); - } -} +export { + AutoEmbedOption, + type EmbedConfig, + type EmbedMatchResult, + INSERT_EMBED_COMMAND, + URL_MATCHER, +} from './LexicalAutoEmbedPluginUtils'; type LexicalAutoEmbedPluginProps = { /** diff --git a/packages/lexical-react/src/LexicalAutoEmbedPluginUtils.ts b/packages/lexical-react/src/LexicalAutoEmbedPluginUtils.ts new file mode 100644 index 00000000000..46b30c7ad68 --- /dev/null +++ b/packages/lexical-react/src/LexicalAutoEmbedPluginUtils.ts @@ -0,0 +1,80 @@ +/** + * 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 {MenuOption} from '@lexical/react/LexicalNodeMenuPlugin'; +import { + createCommand, + type LexicalCommand, + type LexicalEditor, + type LexicalNode, +} from 'lexical'; + +/** + * The result of matching a URL for an embed: the matched `url`, an `id` + * identifying the embedded resource, and optional provider-specific `data`. + */ +export type EmbedMatchResult = { + url: string; + id: string; + data?: TEmbedMatchResult; +}; + +/** + * Describes a kind of embed (for example YouTube, a tweet, or Google Maps) that + * {@link LexicalAutoEmbedPlugin} can detect and insert. Each config has a `type` + * identifier, a `parseUrl` function that decides whether a URL matches and + * extracts its data, and an `insertNode` function that inserts the corresponding + * Lexical node. + */ +export interface EmbedConfig< + TEmbedMatchResultData = unknown, + TEmbedMatchResult = EmbedMatchResult, +> { + type: string; + // Determine if a given URL is a match and return url data. + parseUrl: ( + text: string, + ) => Promise | TEmbedMatchResult | null; + // Create the Lexical embed node from the url data. + insertNode: (editor: LexicalEditor, result: TEmbedMatchResult) => void; +} + +/** + * A general-purpose regular expression for detecting URLs, provided as a + * convenience for implementing an {@link EmbedConfig}'s `parseUrl`. + */ +export const URL_MATCHER = + /((https?:\/\/(www\.)?)|(www\.))[-a-zA-Z0-9@:%._+~#=]{1,256}\.[a-zA-Z0-9()]{1,6}\b([-a-zA-Z0-9()@:%_+.~#?&//=]*)/; + +/** + * Command dispatched to start inserting an embed. Its payload is the `type` of + * the {@link EmbedConfig} to use; {@link LexicalAutoEmbedPlugin} listens for it + * and runs that config's URL detection flow. + */ +export const INSERT_EMBED_COMMAND: LexicalCommand = + /* @__PURE__ */ createCommand('INSERT_EMBED_COMMAND'); + +/** + * A {@link MenuOption} for the auto-embed menu, pairing a display `title` with + * an `onSelect` callback invoked when the user chooses to embed the detected + * URL. + */ +export class AutoEmbedOption extends MenuOption { + title: string; + onSelect: (targetNode: LexicalNode | null) => void; + constructor( + title: string, + options: { + onSelect: (targetNode: LexicalNode | null) => void; + }, + ) { + super(title); + this.title = title; + this.onSelect = options.onSelect.bind(this); + } +} diff --git a/packages/lexical-react/src/LexicalCollaborationContext.tsx b/packages/lexical-react/src/LexicalCollaborationContext.tsx index ae7dfc17bda..fd7e489e45a 100644 --- a/packages/lexical-react/src/LexicalCollaborationContext.tsx +++ b/packages/lexical-react/src/LexicalCollaborationContext.tsx @@ -6,64 +6,18 @@ * */ -import type {Doc} from 'yjs'; +import {useMemo} from 'react'; -import devInvariant from '@lexical/internal/devInvariant'; -import {createContext, useContext, useMemo} from 'react'; +import { + CollaborationContext, + newContext, +} from './LexicalCollaborationContextUtils'; -/** - * The value stored in the {@link CollaborationContext}: the local user's - * display `name` and cursor `color`, whether collaboration is currently active, - * and the map of Yjs documents shared by the editors under this provider. - */ -export type CollaborationContextType = { - color: string; - isCollabActive: boolean; - name: string; - yjsDocMap: Map; -}; - -const entries = [ - ['Cat', 'rgb(125, 50, 0)'], - ['Dog', 'rgb(100, 0, 0)'], - ['Rabbit', 'rgb(150, 0, 0)'], - ['Frog', 'rgb(200, 0, 0)'], - ['Fox', 'rgb(200, 75, 0)'], - ['Hedgehog', 'rgb(0, 75, 0)'], - ['Pigeon', 'rgb(0, 125, 0)'], - ['Squirrel', 'rgb(75, 100, 0)'], - ['Bear', 'rgb(125, 100, 0)'], - ['Tiger', 'rgb(0, 0, 150)'], - ['Leopard', 'rgb(0, 0, 200)'], - ['Zebra', 'rgb(0, 0, 250)'], - ['Wolf', 'rgb(0, 100, 150)'], - ['Owl', 'rgb(0, 100, 100)'], - ['Gull', 'rgb(100, 0, 100)'], - ['Squid', 'rgb(150, 0, 150)'], -]; - -const randomEntry = entries[Math.floor(Math.random() * entries.length)]; - -/** - * The React context that holds the shared {@link CollaborationContextType} for - * collaborative editors. Provide it with {@link LexicalCollaboration} and read - * it with {@link useCollaborationContext}. - */ -export const CollaborationContext = - createContext(null); - -function newContext() { - return { - color: randomEntry[1], - isCollabActive: false, - name: randomEntry[0], - yjsDocMap: new Map(), - }; -} - -// This is here to help the transition post-#7818, however should be removed in a future release as -// a shared context across editors is likely to lead to bugs. -const UNSAFE_GLOBAL_CONTEXT = newContext(); +export { + CollaborationContext, + type CollaborationContextType, + useCollaborationContext, +} from './LexicalCollaborationContextUtils'; /** * A provider component that creates a fresh {@link CollaborationContextType} @@ -82,35 +36,3 @@ export function LexicalCollaboration({children}: {children: React.ReactNode}) { ); } - -/** - * Reads the current {@link CollaborationContextType} from the nearest - * {@link LexicalCollaboration} provider. Optionally pass `username` and `color` - * to set the local user's display name and cursor color. - * - * @returns The active collaboration context. - */ -export function useCollaborationContext( - username?: string, - color?: string, -): CollaborationContextType { - let collabContext = useContext(CollaborationContext); - devInvariant( - collabContext != null, - 'useCollaborationContext: no context provider found', - ); - - collabContext = collabContext ?? UNSAFE_GLOBAL_CONTEXT; - - if (username != null) { - // eslint-disable-next-line react-hooks/immutability - collabContext.name = username; - } - - if (color != null) { - // eslint-disable-next-line react-hooks/immutability - collabContext.color = color; - } - - return collabContext; -} diff --git a/packages/lexical-react/src/LexicalCollaborationContextUtils.ts b/packages/lexical-react/src/LexicalCollaborationContextUtils.ts new file mode 100644 index 00000000000..6d5976cb053 --- /dev/null +++ b/packages/lexical-react/src/LexicalCollaborationContextUtils.ts @@ -0,0 +1,99 @@ +/** + * 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 {Doc} from 'yjs'; + +import devInvariant from '@lexical/internal/devInvariant'; +import {createContext, useContext} from 'react'; + +/** + * The value stored in the {@link CollaborationContext}: the local user's + * display `name` and cursor `color`, whether collaboration is currently active, + * and the map of Yjs documents shared by the editors under this provider. + */ +export type CollaborationContextType = { + color: string; + isCollabActive: boolean; + name: string; + yjsDocMap: Map; +}; + +const entries = [ + ['Cat', 'rgb(125, 50, 0)'], + ['Dog', 'rgb(100, 0, 0)'], + ['Rabbit', 'rgb(150, 0, 0)'], + ['Frog', 'rgb(200, 0, 0)'], + ['Fox', 'rgb(200, 75, 0)'], + ['Hedgehog', 'rgb(0, 75, 0)'], + ['Pigeon', 'rgb(0, 125, 0)'], + ['Squirrel', 'rgb(75, 100, 0)'], + ['Bear', 'rgb(125, 100, 0)'], + ['Tiger', 'rgb(0, 0, 150)'], + ['Leopard', 'rgb(0, 0, 200)'], + ['Zebra', 'rgb(0, 0, 250)'], + ['Wolf', 'rgb(0, 100, 150)'], + ['Owl', 'rgb(0, 100, 100)'], + ['Gull', 'rgb(100, 0, 100)'], + ['Squid', 'rgb(150, 0, 150)'], +]; + +const randomEntry = entries[Math.floor(Math.random() * entries.length)]; + +/** + * The React context that holds the shared {@link CollaborationContextType} for + * collaborative editors. Provide it with {@link LexicalCollaboration} and read + * it with {@link useCollaborationContext}. + */ +export const CollaborationContext = + createContext(null); + +/** @internal */ +export function newContext(): CollaborationContextType { + return { + color: randomEntry[1], + isCollabActive: false, + name: randomEntry[0], + yjsDocMap: new Map(), + }; +} + +// This is here to help the transition post-#7818, however should be removed in a future release as +// a shared context across editors is likely to lead to bugs. +const UNSAFE_GLOBAL_CONTEXT = newContext(); + +/** + * Reads the current {@link CollaborationContextType} from the nearest + * {@link LexicalCollaboration} provider. Optionally pass `username` and `color` + * to set the local user's display name and cursor color. + * + * @returns The active collaboration context. + */ +export function useCollaborationContext( + username?: string, + color?: string, +): CollaborationContextType { + let collabContext = useContext(CollaborationContext); + devInvariant( + collabContext != null, + 'useCollaborationContext: no context provider found', + ); + + collabContext = collabContext ?? UNSAFE_GLOBAL_CONTEXT; + + if (username != null) { + // eslint-disable-next-line react-hooks/immutability + collabContext.name = username; + } + + if (color != null) { + // eslint-disable-next-line react-hooks/immutability + collabContext.color = color; + } + + return collabContext; +} diff --git a/packages/lexical-react/src/LexicalTypeaheadMenuPlugin.tsx b/packages/lexical-react/src/LexicalTypeaheadMenuPlugin.tsx index 9f73963d428..83f2d44053d 100644 --- a/packages/lexical-react/src/LexicalTypeaheadMenuPlugin.tsx +++ b/packages/lexical-react/src/LexicalTypeaheadMenuPlugin.tsx @@ -7,17 +7,14 @@ */ import {useLexicalComposerContext} from '@lexical/react/LexicalComposerContext'; -import {getScrollParent as getScrollParent_} from '@lexical/utils'; import { $getSelection, $isRangeSelection, $isTextNode, COMMAND_PRIORITY_LOW, type CommandListenerPriority, - createCommand, getDOMSelection, getDOMSelectionPoints, - type LexicalCommand, type LexicalEditor, type RangeSelection, type TextNode, @@ -40,14 +37,6 @@ import { useMenuAnchorRef, } from './shared/LexicalMenu'; -/** - * The default set of punctuation characters (as a character-class fragment) - * that terminate a typeahead query. Used as the default `punctuation` option of - * {@link useBasicTypeaheadTriggerMatch}. - */ -export const PUNCTUATION = - '\\.,\\+\\*\\?\\$\\@\\|#{}\\(\\)\\^\\-\\[\\]\\\\/!%\'"~=<>_:;'; - function getTextUpToAnchor(selection: RangeSelection): string | null { const anchor = selection.anchor; if (anchor.type !== 'text') { @@ -121,76 +110,13 @@ function isSelectionOnEntityBoundary( }); } +export { + getScrollParent, + PUNCTUATION, + SCROLL_TYPEAHEAD_OPTION_INTO_VIEW_COMMAND, + useBasicTypeaheadTriggerMatch, +} from './LexicalTypeaheadMenuPluginUtils'; export {useDynamicPositioning} from './shared/LexicalMenu'; -/** @deprecated Moved to `@lexical/utils`. Import `getScrollParent` from there. */ -export const getScrollParent = getScrollParent_; - -/** - * Command dispatched while the typeahead menu is open to scroll the option at - * the given `index` into view. The default menu renderer listens for it; custom - * {@link MenuRenderFn}s can handle it to implement their own scrolling. - */ -export const SCROLL_TYPEAHEAD_OPTION_INTO_VIEW_COMMAND: LexicalCommand<{ - index: number; - option: MenuOption; -}> = /* @__PURE__ */ createCommand('SCROLL_TYPEAHEAD_OPTION_INTO_VIEW_COMMAND'); - -/** - * Builds a {@link TriggerFn} for the common case of a single-character - * `trigger` (such as `@` or `#`) followed by a query. The returned function - * matches when the trigger is preceded by whitespace or the start of the line - * and is followed by between `minLength` and `maxLength` non-`punctuation` - * characters (optionally allowing whitespace). - * - * @returns A memoized trigger function for {@link LexicalTypeaheadMenuPlugin}. - */ -export function useBasicTypeaheadTriggerMatch( - trigger: string, - { - minLength = 1, - maxLength = 75, - punctuation = PUNCTUATION, - allowWhitespace = false, - }: { - minLength?: number; - maxLength?: number; - punctuation?: string; - allowWhitespace?: boolean; - }, -): TriggerFn { - return useCallback( - (text: string) => { - const validCharsSuffix = allowWhitespace ? '' : '\\s'; - const validChars = '[^' + trigger + punctuation + validCharsSuffix + ']'; - const TypeaheadTriggerRegex = new RegExp( - '(^|\\s|\\()(' + - '[' + - trigger + - ']' + - '((?:' + - validChars + - '){0,' + - maxLength + - '})' + - ')$', - ); - const match = TypeaheadTriggerRegex.exec(text); - if (match !== null) { - const maybeLeadingWhitespace = match[1]; - const matchingString = match[3]; - if (matchingString.length >= minLength) { - return { - leadOffset: match.index + maybeLeadingWhitespace.length, - matchingString, - replaceableString: match[2], - }; - } - } - return null; - }, - [allowWhitespace, trigger, punctuation, maxLength, minLength], - ); -} /** * Props for the {@link LexicalTypeaheadMenuPlugin} component. diff --git a/packages/lexical-react/src/LexicalTypeaheadMenuPluginUtils.ts b/packages/lexical-react/src/LexicalTypeaheadMenuPluginUtils.ts new file mode 100644 index 00000000000..c49b7bd386c --- /dev/null +++ b/packages/lexical-react/src/LexicalTypeaheadMenuPluginUtils.ts @@ -0,0 +1,93 @@ +/** + * 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 {MenuOption, TriggerFn} from './shared/LexicalMenu'; + +import {getScrollParent as getScrollParent_} from '@lexical/utils'; +import {createCommand, type LexicalCommand} from 'lexical'; +import {useCallback} from 'react'; + +/** + * The default set of punctuation characters (as a character-class fragment) + * that terminate a typeahead query. Used as the default `punctuation` option of + * {@link useBasicTypeaheadTriggerMatch}. + */ +export const PUNCTUATION = + '\\.,\\+\\*\\?\\$\\@\\|#{}\\(\\)\\^\\-\\[\\]\\\\/!%\'"~=<>_:;'; + +/** + * Command dispatched while the typeahead menu is open to scroll the option at + * the given `index` into view. The built-in menu hook registers a default + * handler; custom implementations can register their own handler at a higher + * priority to override the scrolling behavior. + */ +export const SCROLL_TYPEAHEAD_OPTION_INTO_VIEW_COMMAND: LexicalCommand<{ + index: number; + option: MenuOption; +}> = /* @__PURE__ */ createCommand('SCROLL_TYPEAHEAD_OPTION_INTO_VIEW_COMMAND'); + +/** @deprecated Moved to `@lexical/utils`. Import `getScrollParent` from there. */ +export const getScrollParent = getScrollParent_; + +/** + * Builds a {@link TriggerFn} for the common case of a single-character + * `trigger` (such as `@` or `#`) followed by a query. The returned function + * matches when the trigger is preceded by whitespace, an open parenthesis, + * or the start of the line + * and is followed by between `minLength` and `maxLength` non-`punctuation` + * characters (optionally allowing whitespace). + * + * @returns A memoized trigger function for {@link LexicalTypeaheadMenuPlugin}. + */ +export function useBasicTypeaheadTriggerMatch( + trigger: string, + { + minLength = 1, + maxLength = 75, + punctuation = PUNCTUATION, + allowWhitespace = false, + }: { + minLength?: number; + maxLength?: number; + punctuation?: string; + allowWhitespace?: boolean; + }, +): TriggerFn { + return useCallback( + (text: string) => { + const validCharsSuffix = allowWhitespace ? '' : '\\s'; + const validChars = '[^' + trigger + punctuation + validCharsSuffix + ']'; + const TypeaheadTriggerRegex = new RegExp( + '(^|\\s|\\()(' + + '[' + + trigger + + ']' + + '((?:' + + validChars + + '){0,' + + maxLength + + '})' + + ')$', + ); + const match = TypeaheadTriggerRegex.exec(text); + if (match !== null) { + const maybeLeadingWhitespace = match[1]; + const matchingString = match[3]; + if (matchingString.length >= minLength) { + return { + leadOffset: match.index + maybeLeadingWhitespace.length, + matchingString, + replaceableString: match[2], + }; + } + } + return null; + }, + [allowWhitespace, trigger, punctuation, maxLength, minLength], + ); +} diff --git a/packages/lexical-website/docs/react/faq.md b/packages/lexical-website/docs/react/faq.md index b63a2295e5a..baf552ade59 100644 --- a/packages/lexical-website/docs/react/faq.md +++ b/packages/lexical-website/docs/react/faq.md @@ -58,13 +58,41 @@ The most common root causes of this issue are: precisely which tools (and even versions of those tools) that your project is using. -## Other complications when using dev mode with fast refresh (aka hot module replacement) - -Depending on precisely how the fast refresh implementation you're -using works, you may need to mark the files that create your editor or the -implementation of your LexicalNode subclasses as needing a full refresh. -When things seem broken in dev mode after changing a file, try refreshing the -page first. If that fixes the problem, then mark the file you're working on as -needing a full refresh. For example -[Next.js fast refresh](https://nextjs.org/docs/architecture/fast-refresh#tips) -has a `// @refresh reset` comment that can be used. +## Hot Module Replacement (HMR) + +During development, HMR re-executes modules on every code change. Because Lexical uses object identity for node class registration, command dispatch, and extension deduplication, a naive HMR cycle destroys the editor state and resets the document. + +### HMRExtension + +`@lexical/extension` exports an `HMRExtension` that preserves editor state, editable flag, and undo/redo history across HMR cycles. It works by saving the current state to the bundler's HMR data store and restoring it (with prototype swaps on all existing nodes) when the new editor instance is created. + +```ts +import {buildEditorFromExtensions, configExtension, HMRExtension} from '@lexical/extension'; +import {RichTextExtension} from '@lexical/rich-text'; +import {HistoryExtension} from '@lexical/history'; + +const editor = buildEditorFromExtensions({ + name: '[root]', + dependencies: [ + RichTextExtension, + HistoryExtension, + configExtension(HMRExtension, {hot: import.meta.hot ?? null}), + ], +}); +``` + +The `hot` config accepts any object with a `data: Record` property — this is satisfied by Vite's `import.meta.hot`, SvelteKit, and similar bundlers. Pass `null` in production or when HMR is not available; the extension becomes a no-op. + +When `HistoryExtension` is present as a peer, undo/redo stacks are preserved automatically. The extension does not declare `HistoryExtension` as a dependency — it detects it at runtime via peer dependency lookup. + +### Fast Refresh compatibility + +Vite (and similar tools) apply React Fast Refresh — state-preserving HMR for React components — only when a module exports nothing but React components. Modules that also export hooks, classes, commands, or constants fall back to a full remount, which discards component state. + +Several `@lexical/react` plugin modules split their non-component exports into companion `*Utils` files. Consumers that import non-component values directly from the `*Utils` module get more granular HMR boundaries, since changes to the component file don't invalidate those imports. The original module re-exports these for backwards compatibility. + +If you're building custom plugins, follow the same pattern: keep React components in one file and export hooks, constants, or classes from a separate file. + +### Fallback: `// @refresh reset` + +If a module can't be split (e.g. it defines both a component and tightly-coupled non-component logic), you can mark it for a full refresh using your framework's directive. For example, [Next.js fast refresh](https://nextjs.org/docs/architecture/fast-refresh#tips) supports a `// @refresh reset` comment at the top of the file. This forces a full remount of all components in the file on every change. diff --git a/packages/lexical-website/tsconfig.json b/packages/lexical-website/tsconfig.json index 0d738b86221..6582f703db6 100644 --- a/packages/lexical-website/tsconfig.json +++ b/packages/lexical-website/tsconfig.json @@ -71,6 +71,9 @@ "@lexical/react/LexicalAutoEmbedPlugin": [ "../lexical-react/src/LexicalAutoEmbedPlugin.tsx" ], + "@lexical/react/LexicalAutoEmbedPluginUtils": [ + "../lexical-react/src/LexicalAutoEmbedPluginUtils.ts" + ], "@lexical/react/LexicalAutoFocusPlugin": [ "../lexical-react/src/LexicalAutoFocusPlugin.ts" ], @@ -95,6 +98,9 @@ "@lexical/react/LexicalCollaborationContext": [ "../lexical-react/src/LexicalCollaborationContext.tsx" ], + "@lexical/react/LexicalCollaborationContextUtils": [ + "../lexical-react/src/LexicalCollaborationContextUtils.ts" + ], "@lexical/react/LexicalCollaborationPlugin": [ "../lexical-react/src/LexicalCollaborationPlugin.tsx" ], @@ -185,6 +191,9 @@ "@lexical/react/LexicalTypeaheadMenuPlugin": [ "../lexical-react/src/LexicalTypeaheadMenuPlugin.tsx" ], + "@lexical/react/LexicalTypeaheadMenuPluginUtils": [ + "../lexical-react/src/LexicalTypeaheadMenuPluginUtils.ts" + ], "@lexical/react/ReactExtension": [ "../lexical-react/src/ReactExtension.tsx" ], diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index 042a46dfccc..f3918ae915f 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -363,6 +363,59 @@ importers: specifier: 4.61.0 version: 4.61.0 + dev-examples/hmr: + dependencies: + '@lexical/extension': + specifier: workspace:* + version: link:../../packages/lexical-extension + '@lexical/history': + specifier: workspace:* + version: link:../../packages/lexical-history + '@lexical/react': + specifier: workspace:* + version: link:../../packages/lexical-react + '@lexical/rich-text': + specifier: workspace:* + version: link:../../packages/lexical-rich-text + lexical: + specifier: workspace:* + version: link:../../packages/lexical + react: + specifier: 19.2.5 + version: 19.2.5 + react-dom: + specifier: 19.2.5 + version: 19.2.5(react@19.2.5) + devDependencies: + '@types/react': + specifier: ^19.1.2 + version: 19.2.16 + '@types/react-dom': + specifier: ^19.1.2 + version: 19.2.3(@types/react@19.2.16) + '@vitejs/plugin-react': + specifier: ^6.0.2 + version: 6.0.2(vite@8.0.16(@types/node@25.9.1)(esbuild@0.27.7)(jiti@2.7.0)(terser@5.48.0)(yaml@2.9.0)) + typescript: + specifier: ^6.0.3 + version: 6.0.3 + vite: + specifier: ^8.0.16 + version: 8.0.16(@types/node@25.9.1)(esbuild@0.27.7)(jiti@2.7.0)(terser@5.48.0)(yaml@2.9.0) + optionalDependencies: + '@rollup/rollup-darwin-arm64': + specifier: 4.61.0 + version: 4.61.0 + '@rollup/rollup-linux-x64-gnu': + specifier: 4.61.0 + version: 4.61.0 + '@rollup/rollup-win32-x64-msvc': + specifier: 4.61.0 + version: 4.61.0 + '@rollup/wasm-node': + specifier: 4.61.0 + version: 4.61.0 + dev-examples/mdast-editor: dependencies: '@lexical/clipboard': diff --git a/tsconfig.json b/tsconfig.json index d957a49546b..d6995d743df 100644 --- a/tsconfig.json +++ b/tsconfig.json @@ -72,6 +72,9 @@ "@lexical/react/LexicalAutoEmbedPlugin": [ "./packages/lexical-react/src/LexicalAutoEmbedPlugin.tsx" ], + "@lexical/react/LexicalAutoEmbedPluginUtils": [ + "./packages/lexical-react/src/LexicalAutoEmbedPluginUtils.ts" + ], "@lexical/react/LexicalAutoFocusPlugin": [ "./packages/lexical-react/src/LexicalAutoFocusPlugin.ts" ], @@ -96,6 +99,9 @@ "@lexical/react/LexicalCollaborationContext": [ "./packages/lexical-react/src/LexicalCollaborationContext.tsx" ], + "@lexical/react/LexicalCollaborationContextUtils": [ + "./packages/lexical-react/src/LexicalCollaborationContextUtils.ts" + ], "@lexical/react/LexicalCollaborationPlugin": [ "./packages/lexical-react/src/LexicalCollaborationPlugin.tsx" ], @@ -186,6 +192,9 @@ "@lexical/react/LexicalTypeaheadMenuPlugin": [ "./packages/lexical-react/src/LexicalTypeaheadMenuPlugin.tsx" ], + "@lexical/react/LexicalTypeaheadMenuPluginUtils": [ + "./packages/lexical-react/src/LexicalTypeaheadMenuPluginUtils.ts" + ], "@lexical/react/ReactExtension": [ "./packages/lexical-react/src/ReactExtension.tsx" ], diff --git a/tsconfig.test.json b/tsconfig.test.json index eb0445bd197..5ba7415f965 100644 --- a/tsconfig.test.json +++ b/tsconfig.test.json @@ -62,6 +62,9 @@ "@lexical/react/LexicalAutoEmbedPlugin": [ "./packages/lexical-react/src/LexicalAutoEmbedPlugin.tsx" ], + "@lexical/react/LexicalAutoEmbedPluginUtils": [ + "./packages/lexical-react/src/LexicalAutoEmbedPluginUtils.ts" + ], "@lexical/react/LexicalAutoFocusPlugin": [ "./packages/lexical-react/src/LexicalAutoFocusPlugin.ts" ], @@ -86,6 +89,9 @@ "@lexical/react/LexicalCollaborationContext": [ "./packages/lexical-react/src/LexicalCollaborationContext.tsx" ], + "@lexical/react/LexicalCollaborationContextUtils": [ + "./packages/lexical-react/src/LexicalCollaborationContextUtils.ts" + ], "@lexical/react/LexicalCollaborationPlugin": [ "./packages/lexical-react/src/LexicalCollaborationPlugin.tsx" ], @@ -176,6 +182,9 @@ "@lexical/react/LexicalTypeaheadMenuPlugin": [ "./packages/lexical-react/src/LexicalTypeaheadMenuPlugin.tsx" ], + "@lexical/react/LexicalTypeaheadMenuPluginUtils": [ + "./packages/lexical-react/src/LexicalTypeaheadMenuPluginUtils.ts" + ], "@lexical/react/ReactExtension": [ "./packages/lexical-react/src/ReactExtension.tsx" ],