lexical 0.45.0 → 0.45.1-dev.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -179,6 +179,14 @@ export interface LexicalPrivateDOM {
179
179
  __lexicalLastChildKind?: 'line-break' | 'decorator' | 'empty' | null | undefined;
180
180
  __lexicalDir?: 'ltr' | 'rtl' | null | undefined;
181
181
  __lexicalUnmanaged?: boolean | undefined;
182
+ /**
183
+ * When true, the DOM subtree owns its own window selection — analogous to
184
+ * a DecoratorNode subtree. Resolution logic that would otherwise force the
185
+ * Lexical selection back onto a managed position treats the caret as
186
+ * intentional and leaves it alone. Set via the `captureSelection` option
187
+ * on {@link setDOMUnmanaged}.
188
+ */
189
+ __lexicalCapturedSelection?: boolean | undefined;
182
190
  }
183
191
  export declare function $removeNode(nodeToRemove: LexicalNode, restoreSelection: boolean, preserveEmptyParent?: boolean): void;
184
192
  export type DOMConversionProp<T extends HTMLElement> = (node: T) => DOMConversion<T> | null;
@@ -13,6 +13,8 @@ export declare function isCurrentlyReadOnlyMode(): boolean;
13
13
  export declare function errorOnReadOnly(): void;
14
14
  export declare function errorOnInfiniteTransforms(): void;
15
15
  export declare function getActiveEditorState(): EditorState;
16
+ /** @internal */
17
+ export declare function $assumeActiveEditor(editor: LexicalEditor): void;
16
18
  export declare function getActiveEditor(): LexicalEditor;
17
19
  /**
18
20
  * Schedule a full reconcile of the active editor, so that every node is
@@ -30,7 +30,6 @@ export declare function getRegisteredNode(editor: LexicalEditor, nodeType: strin
30
30
  export declare const isArray: (arg: any) => arg is any[];
31
31
  /** @internal */
32
32
  export declare const scheduleMicroTask: (fn: () => void) => void;
33
- export declare function $isSelectionCapturedInDecorator(node: Node): boolean;
34
33
  export declare function isSelectionCapturedInDecoratorInput(anchorDOM: Node): boolean;
35
34
  export declare function isSelectionWithinEditor(editor: LexicalEditor, anchorDOM: null | Node, focusDOM: null | Node): boolean;
36
35
  /**
@@ -84,6 +83,7 @@ export declare function $getCompositionKey(): null | NodeKey;
84
83
  export declare function $getNodeByKey<T extends LexicalNode>(key: NodeKey, _editorState?: EditorState): T | null;
85
84
  export declare function $getNodeFromDOMNode(dom: Node, editorState?: EditorState): LexicalNode | null;
86
85
  export declare function setNodeKeyOnDOMNode(dom: Node, editor: LexicalEditor, key: NodeKey): void;
86
+ export declare function clearNodeKeyOnDOMNode(dom: Node, editor: LexicalEditor): void;
87
87
  export declare function getNodeKeyFromDOMNode(dom: Node, editor: LexicalEditor): NodeKey | undefined;
88
88
  export declare function $getNearestNodeFromDOMNode(startingDOM: Node, editorState?: EditorState): LexicalNode | null;
89
89
  export declare function cloneDecorators(editor: LexicalEditor): Record<NodeKey, unknown>;
@@ -408,15 +408,37 @@ export declare function $setDirectionFromDOM<T extends ElementNode>(node: T, dom
408
408
  * @returns The node, with its align format set when the source `style.textAlign` was valid.
409
409
  */
410
410
  export declare function $setFormatFromDOM<T extends ElementNode>(node: T, domNode: HTMLElement): T;
411
+ /**
412
+ * Options accepted by {@link setDOMUnmanaged}.
413
+ *
414
+ * @experimental
415
+ */
416
+ export interface SetDOMUnmanagedOptions {
417
+ /**
418
+ * When true, the marked subtree owns its own window selection — analogous
419
+ * to a DecoratorNode subtree. Selection resolution that would otherwise
420
+ * mark the selection dirty for a caret position inside unmanaged DOM
421
+ * leaves it alone, so the embedded interaction (custom input, focusable
422
+ * widget, etc.) can keep its native caret.
423
+ *
424
+ * Pass `false` to clear a previously-set marker; omit the field to leave
425
+ * `__lexicalCapturedSelection` untouched.
426
+ */
427
+ captureSelection?: boolean;
428
+ }
411
429
  /**
412
430
  * Mark this DOM element as unmanaged by lexical's mutation observer (like
413
431
  * decorator nodes are). Extensions that inject non-lexical decoration
414
432
  * elements into a node's DOM should mark them so the mutation observer
415
433
  * doesn't evict them as "unknown DOM children" during cleanup.
416
434
  *
435
+ * Pass `{captureSelection: true}` to additionally treat the subtree's
436
+ * window selection as decorator-like, so resolution does not force-sync
437
+ * the caret out of unmanaged DOM (see {@link isDOMCapturingSelection}).
438
+ *
417
439
  * @experimental
418
440
  */
419
- export declare function setDOMUnmanaged(elementDom: HTMLElement & LexicalPrivateDOM): void;
441
+ export declare function setDOMUnmanaged(elementDom: HTMLElement & LexicalPrivateDOM, options?: SetDOMUnmanagedOptions): void;
420
442
  /**
421
443
  * True if this DOM node was marked with {@link setDOMUnmanaged}.
422
444
  *
@@ -424,13 +446,27 @@ export declare function setDOMUnmanaged(elementDom: HTMLElement & LexicalPrivate
424
446
  */
425
447
  export declare function isDOMUnmanaged(elementDom: Node & LexicalPrivateDOM): boolean;
426
448
  /**
427
- * @internal
449
+ * True if the DOM node sits inside a subtree marked with
450
+ * `{captureSelection: true}` via {@link setDOMUnmanaged}. Walks ancestors
451
+ * so any descendant of a marked subtree (e.g. an `<input>` inside a marked
452
+ * `<div>`) reports as captured too.
453
+ *
454
+ * The walk aborts at the first DOM node that corresponds to a Lexical
455
+ * node in `editor` — that boundary is the implicit owner of the subtree's
456
+ * selection, so a captureSelection marker above it (in non-Lexical
457
+ * scaffolding around the editor) does not leak in.
458
+ *
459
+ * DecoratorNode DOM is marked with `setDOMUnmanaged({captureSelection:
460
+ * true})` by the reconciler, so decorator subtrees also report as
461
+ * captured here.
462
+ *
463
+ * @experimental
428
464
  */
429
- export declare function hasOwnStaticMethod(klass: Klass<LexicalNode>, k: keyof Klass<LexicalNode>): boolean;
465
+ export declare function isDOMCapturingSelection(elementDom: Node & LexicalPrivateDOM, editor: LexicalEditor): boolean;
430
466
  /**
431
467
  * @internal
432
468
  */
433
- export declare function hasOwnExportDOM(klass: Klass<LexicalNode>): boolean;
469
+ export declare function hasOwnStaticMethod(klass: Klass<LexicalNode>, k: keyof Klass<LexicalNode>): boolean;
434
470
  /** @internal */
435
471
  export declare function getStaticNodeConfig(klass: Klass<LexicalNode>): {
436
472
  ownNodeType: undefined | string;
package/dist/index.d.ts CHANGED
@@ -24,14 +24,14 @@ export { $getState, $getStateChange, $getWritableNodeState, $setState, type AnyS
24
24
  export { $normalizeSelection as $normalizeSelection__EXPERIMENTAL } from './LexicalNormalization';
25
25
  export type { BaseSelection, ElementPointType as ElementPoint, NodeSelection, Point, PointType, RangeSelection, TextPointType as TextPoint, } from './LexicalSelection';
26
26
  export { $createNodeSelection, $createPoint, $createRangeSelection, $createRangeSelectionFromDom, $generateNodesFromRawText, $getCharacterOffsets, $getPreviousSelection, $getSelection, $getTextContent, $insertNodes, $isBlockElementNode, $isNodeSelection, $isRangeSelection, $updateDOMSelection, type RawTextVisitor, tokenizeRawText, } from './LexicalSelection';
27
- export { $fullReconcile, $parseSerializedNode, isCurrentlyReadOnlyMode, } from './LexicalUpdates';
28
- export { $addUpdateTag, $applyNodeReplacement, $cloneWithProperties, $cloneWithPropertiesEphemeral, $copyNode, $create, $createChildrenArray, $findMatchingParent, $getAdjacentNode, $getDOMSlot, $getDOMTextNode, $getEditor, $getEditorDOMRenderConfig, $getNearestNodeFromDOMNode, $getNearestRootOrShadowRoot, $getNodeByKey, $getNodeByKeyOrThrow, $getNodeFromDOMNode, $getRoot, $hasAncestor, $hasUpdateTag, $isElementDOMSlot, $isInlineElementOrDecoratorNode, $isLeafNode, $isRootOrShadowRoot, $isTokenOrSegmented, $isTokenOrTab, $nodesOfType, $onUpdate, $selectAll, $setCompositionKey, $setDirectionFromDOM, $setFormatFromDOM, $setSelection, $splitNode, getDOMOwnerDocument, getDOMSelection, getDOMSelectionFromTarget, getDOMTextNode, getEditorPropertyFromDOMNode, getNearestEditorFromDOMNode, getRegisteredNode, getRegisteredNodeOrThrow, getStaticNodeConfig, getTextDirection, INTERNAL_$isBlock, isBlockDomNode, isDocumentFragment, isDOMDocumentNode, isDOMNode, isDOMTextNode, isDOMUnmanaged, isExactShortcutMatch, isHTMLAnchorElement, isHTMLElement, isHTMLTableCellElement, isHTMLTableRowElement, isInlineDomNode, isLexicalEditor, isModifierMatch, isSelectionCapturedInDecoratorInput, isSelectionWithinEditor, removeFromParent, resetRandomKey, setDOMUnmanaged, setNodeIndentFromDOM, toggleTextFormatType, } from './LexicalUtils';
27
+ export { $assumeActiveEditor, $fullReconcile, $parseSerializedNode, isCurrentlyReadOnlyMode, } from './LexicalUpdates';
28
+ export { $addUpdateTag, $applyNodeReplacement, $cloneWithProperties, $cloneWithPropertiesEphemeral, $copyNode, $create, $createChildrenArray, $findMatchingParent, $getAdjacentNode, $getDOMSlot, $getDOMTextNode, $getEditor, $getEditorDOMRenderConfig, $getNearestNodeFromDOMNode, $getNearestRootOrShadowRoot, $getNodeByKey, $getNodeByKeyOrThrow, $getNodeFromDOMNode, $getRoot, $hasAncestor, $hasUpdateTag, $isElementDOMSlot, $isInlineElementOrDecoratorNode, $isLeafNode, $isRootOrShadowRoot, $isTokenOrSegmented, $isTokenOrTab, $nodesOfType, $onUpdate, $selectAll, $setCompositionKey, $setDirectionFromDOM, $setFormatFromDOM, $setSelection, $splitNode, getDOMOwnerDocument, getDOMSelection, getDOMSelectionFromTarget, getDOMTextNode, getEditorPropertyFromDOMNode, getNearestEditorFromDOMNode, getRegisteredNode, getRegisteredNodeOrThrow, getStaticNodeConfig, getTextDirection, INTERNAL_$isBlock, isBlockDomNode, isDocumentFragment, isDOMCapturingSelection, isDOMDocumentNode, isDOMNode, isDOMTextNode, isDOMUnmanaged, isExactShortcutMatch, isHTMLAnchorElement, isHTMLElement, isHTMLTableCellElement, isHTMLTableRowElement, isInlineDomNode, isLexicalEditor, isModifierMatch, isSelectionCapturedInDecoratorInput, isSelectionWithinEditor, removeFromParent, resetRandomKey, setDOMUnmanaged, type SetDOMUnmanagedOptions, setNodeIndentFromDOM, toggleTextFormatType, } from './LexicalUtils';
29
29
  export { ArtificialNode__DO_NOT_USE } from './nodes/ArtificialNode';
30
30
  export { $isDecoratorNode, DecoratorNode } from './nodes/LexicalDecoratorNode';
31
31
  export type { ElementFormatType, SerializedElementNode, } from './nodes/LexicalElementNode';
32
32
  export { $isElementNode, ElementNode } from './nodes/LexicalElementNode';
33
33
  export type { SerializedLineBreakNode } from './nodes/LexicalLineBreakNode';
34
- export { $createLineBreakNode, $isLineBreakNode, LineBreakNode, } from './nodes/LexicalLineBreakNode';
34
+ export { $createLineBreakNode, $isLineBreakNode, isLastChildInBlockNode, isOnlyChildInBlockNode, LineBreakNode, } from './nodes/LexicalLineBreakNode';
35
35
  export type { SerializedParagraphNode } from './nodes/LexicalParagraphNode';
36
36
  export { $createParagraphNode, $isParagraphNode, ParagraphNode, } from './nodes/LexicalParagraphNode';
37
37
  export type { SerializedRootNode } from './nodes/LexicalRootNode';
@@ -25,3 +25,21 @@ export declare class LineBreakNode extends LexicalNode {
25
25
  }
26
26
  export declare function $createLineBreakNode(): LineBreakNode;
27
27
  export declare function $isLineBreakNode(node: LexicalNode | null | undefined): node is LineBreakNode;
28
+ /**
29
+ * True when `node` is the sole non-whitespace child of a block DOM
30
+ * element. Used by the LineBreak importer to drop stray `<br>` elements
31
+ * that the legacy `$generateNodesFromDOM` also skipped (matches the
32
+ * behavior of `LineBreakNode.importDOM`).
33
+ *
34
+ * @experimental
35
+ */
36
+ export declare function isOnlyChildInBlockNode(node: Node): boolean;
37
+ /**
38
+ * True when `node` is the trailing non-whitespace child of a block DOM
39
+ * element (excluding the only-child case). Used by the LineBreak
40
+ * importer to drop trailing `<br>` elements like the Apple-interchange
41
+ * clipboard artifact (matches `LineBreakNode.importDOM`).
42
+ *
43
+ * @experimental
44
+ */
45
+ export declare function isLastChildInBlockNode(node: Node): boolean;
package/package.json CHANGED
@@ -9,7 +9,7 @@
9
9
  "rich-text"
10
10
  ],
11
11
  "license": "MIT",
12
- "version": "0.45.0",
12
+ "version": "0.45.1-dev.0",
13
13
  "main": "./dist/Lexical.js",
14
14
  "types": "./dist/index.d.ts",
15
15
  "repository": {
@@ -51,6 +51,6 @@
51
51
  "LICENSE"
52
52
  ],
53
53
  "dependencies": {
54
- "@lexical/internal": "0.45.0"
54
+ "@lexical/internal": "0.45.1-dev.0"
55
55
  }
56
56
  }
@@ -54,6 +54,7 @@ import {
54
54
  $addUpdateTag,
55
55
  $onUpdate,
56
56
  $setSelection,
57
+ clearNodeKeyOnDOMNode,
57
58
  createUID,
58
59
  dispatchCommand,
59
60
  getCachedClassNameArray,
@@ -62,9 +63,9 @@ import {
62
63
  getDOMSelection,
63
64
  getRegisteredNode,
64
65
  getStaticNodeConfig,
65
- hasOwnExportDOM,
66
66
  hasOwnStaticMethod,
67
67
  markNodesWithTypesAsDirty,
68
+ setNodeKeyOnDOMNode,
68
69
  } from './LexicalUtils';
69
70
  import {ArtificialNode__DO_NOT_USE} from './nodes/ArtificialNode';
70
71
  import {LineBreakNode} from './nodes/LexicalLineBreakNode';
@@ -664,11 +665,19 @@ export function resetEditor(
664
665
  // Remove all the DOM nodes from the root element
665
666
  if (prevRootElement !== null) {
666
667
  prevRootElement.textContent = '';
668
+ clearNodeKeyOnDOMNode(prevRootElement, editor);
667
669
  }
668
670
 
669
671
  if (nextRootElement !== null) {
670
672
  nextRootElement.textContent = '';
671
673
  keyNodeMap.set('root', nextRootElement);
674
+ // Stash __lexicalKey_${editor._key} = 'root' on the root element so it
675
+ // participates in the unified key lookup (selection resolution in
676
+ // $internalResolveSelectionPoint, mutation handling in
677
+ // $getNearestManagedNodePairFromDOMNode, $getNodeFromDOM, and
678
+ // $getNearestNodeFromDOMNode) instead of requiring a dedicated
679
+ // editor.getRootElement() carveout at each call site.
680
+ setNodeKeyOnDOMNode(nextRootElement, editor, 'root');
672
681
  }
673
682
  }
674
683
 
@@ -854,14 +863,6 @@ export function createEditor(editorConfig?: CreateEditorArgs): LexicalEditor {
854
863
  console.warn(`${name} must implement static "${method}" method`);
855
864
  }
856
865
  });
857
- if (
858
- !hasOwnStaticMethod(klass, 'importDOM') &&
859
- hasOwnExportDOM(klass)
860
- ) {
861
- console.warn(
862
- `${name} should implement "importDOM" if using a custom "exportDOM" method to ensure HTML serialization (important for copy & paste) works as expected`,
863
- );
864
- }
865
866
  if (!hasOwnStaticMethod(klass, 'importJSON')) {
866
867
  console.warn(
867
868
  `${name} should implement "importJSON" method to ensure JSON and default HTML serialization works as expected`,
@@ -100,7 +100,6 @@ import {
100
100
  $getAdjacentNode,
101
101
  $getDOMTextNode,
102
102
  $getNodeByKey,
103
- $isSelectionCapturedInDecorator,
104
103
  $isTokenOrSegmented,
105
104
  $isTokenOrTab,
106
105
  $setSelection,
@@ -127,6 +126,7 @@ import {
127
126
  isDeleteLineForward,
128
127
  isDeleteWordBackward,
129
128
  isDeleteWordForward,
129
+ isDOMCapturingSelection,
130
130
  isDOMNode,
131
131
  isDOMTextNode,
132
132
  isEscape,
@@ -560,7 +560,7 @@ function onPointerDown(event: PointerEvent, editor: LexicalEditor) {
560
560
  updateEditorSync(editor, () => {
561
561
  // Drag & drop should not recompute selection until mouse up; otherwise the initially
562
562
  // selected content is lost.
563
- if (!$isSelectionCapturedInDecorator(target)) {
563
+ if (!isDOMCapturingSelection(target, editor)) {
564
564
  isSelectionChangeFromMouseDown = true;
565
565
  }
566
566
  });
@@ -1082,14 +1082,13 @@ function onInput(event: InputEvent, editor: LexicalEditor): void {
1082
1082
  }
1083
1083
 
1084
1084
  function $handleInput(event: InputEvent): boolean {
1085
+ const editor = getActiveEditor();
1085
1086
  if (
1086
1087
  isHTMLElement(event.target) &&
1087
- $isSelectionCapturedInDecorator(event.target)
1088
+ isDOMCapturingSelection(event.target, editor)
1088
1089
  ) {
1089
1090
  return true;
1090
1091
  }
1091
-
1092
- const editor = getActiveEditor();
1093
1092
  const selection = $getSelection();
1094
1093
  const data = event.data;
1095
1094
  const targetRange = getTargetRange(event);
@@ -29,7 +29,6 @@ import {
29
29
  getNodeKeyFromDOMNode,
30
30
  getParentElement,
31
31
  getWindow,
32
- internalGetRoot,
33
32
  isDOMTextNode,
34
33
  isDOMUnmanaged,
35
34
  isFirefoxClipboardEvents,
@@ -118,7 +117,6 @@ function $getNearestManagedNodePairFromDOMNode(
118
117
  startingDOM: Node,
119
118
  editor: LexicalEditor,
120
119
  editorState: EditorState,
121
- rootElement: HTMLElement | null,
122
120
  ): [HTMLElement, LexicalNode] | undefined {
123
121
  for (
124
122
  let dom: Node | null = startingDOM;
@@ -134,8 +132,6 @@ function $getNearestManagedNodePairFromDOMNode(
134
132
  ? undefined
135
133
  : [dom, node];
136
134
  }
137
- } else if (dom === rootElement) {
138
- return [rootElement, internalGetRoot(editorState)];
139
135
  }
140
136
  }
141
137
  }
@@ -152,7 +148,6 @@ function flushMutations(
152
148
  updateEditorSync(editor, () => {
153
149
  const selection = $getSelection() || getLastSelection(editor);
154
150
  const badDOMTargets = new Map<HTMLElement, LexicalNode>();
155
- const rootElement = editor.getRootElement();
156
151
  // We use the current editor state, as that reflects what is
157
152
  // actually "on screen".
158
153
  const currentEditorState = editor._editorState;
@@ -168,7 +163,6 @@ function flushMutations(
168
163
  targetDOM,
169
164
  editor,
170
165
  currentEditorState,
171
- rootElement,
172
166
  );
173
167
  if (!pair) {
174
168
  continue;
@@ -272,6 +272,14 @@ export interface LexicalPrivateDOM {
272
272
  | undefined;
273
273
  __lexicalDir?: 'ltr' | 'rtl' | null | undefined;
274
274
  __lexicalUnmanaged?: boolean | undefined;
275
+ /**
276
+ * When true, the DOM subtree owns its own window selection — analogous to
277
+ * a DecoratorNode subtree. Resolution logic that would otherwise force the
278
+ * Lexical selection back onto a managed position treats the caret as
279
+ * intentional and leaves it alone. Set via the `captureSelection` option
280
+ * on {@link setDOMUnmanaged}.
281
+ */
282
+ __lexicalCapturedSelection?: boolean | undefined;
275
283
  }
276
284
 
277
285
  export function $removeNode(
@@ -51,6 +51,7 @@ import {
51
51
  $isRootOrShadowRoot,
52
52
  cloneDecorators,
53
53
  getElementByKeyOrThrow,
54
+ setDOMUnmanaged,
54
55
  setMutatedNode,
55
56
  setNodeKeyOnDOMNode,
56
57
  } from './LexicalUtils';
@@ -458,6 +459,13 @@ function $createNode(key: NodeKey, slot: ElementDOMSlot | null): HTMLElement {
458
459
  dom.setAttribute('data-lexical-text', 'true');
459
460
  } else if ($isDecoratorNode(node)) {
460
461
  dom.setAttribute('data-lexical-decorator', 'true');
462
+ // DecoratorNode DOM is selection-captured: window selection inside
463
+ // a decorator subtree (e.g. an embedded input) is owned by the
464
+ // decorator, not by Lexical's caret management. Marking it via
465
+ // setDOMUnmanaged unifies the decorator case with extension-owned
466
+ // unmanaged subtrees so callers only need isDOMCapturingSelection /
467
+ // isDOMUnmanaged.
468
+ setDOMUnmanaged(dom, {captureSelection: true});
461
469
  }
462
470
 
463
471
  if ($isElementNode(node)) {
@@ -80,7 +80,6 @@ import {
80
80
  $getRoot,
81
81
  $hasAncestor,
82
82
  $isRootOrShadowRoot,
83
- $isSelectionCapturedInDecorator,
84
83
  $isTokenOrSegmented,
85
84
  $isTokenOrTab,
86
85
  $setCompositionKey,
@@ -90,6 +89,7 @@ import {
90
89
  getNodeKeyFromDOMNode,
91
90
  getWindow,
92
91
  INTERNAL_$isBlock,
92
+ isDOMCapturingSelection,
93
93
  isHTMLElement,
94
94
  isSelectionCapturedInDecoratorInput,
95
95
  isSelectionWithinEditor,
@@ -2322,8 +2322,7 @@ function $internalResolveSelectionPoint(
2322
2322
  }
2323
2323
  if (
2324
2324
  getNodeKeyFromDOMNode(dom, editor) === undefined &&
2325
- dom !== editor.getRootElement() &&
2326
- !$isSelectionCapturedInDecorator(dom)
2325
+ !isDOMCapturingSelection(dom, editor)
2327
2326
  ) {
2328
2327
  // The DOM caret is sitting on a node that has no Lexical key
2329
2328
  // (e.g. <col> inside an unmanaged <colgroup>, or any unmanaged
@@ -2334,14 +2333,14 @@ function $internalResolveSelectionPoint(
2334
2333
  // selection dirty so the reconciler writes a valid DOM caret back
2335
2334
  // at the resolved Lexical position.
2336
2335
  //
2337
- // Exceptions where the DOM caret is intentionally somewhere
2338
- // Lexical doesn't own and we should NOT force-sync it:
2339
- // - the editor root element (tracked separately in
2340
- // _keyToDOMMap as 'root'; has no __lexicalKey_* attribute);
2341
- // - anything inside a DecoratorNode subtree (the decorator owns
2342
- // its own DOM and may manage its own selection — for inputs
2343
- // isSelectionCapturedInDecoratorInput rejects earlier, but
2344
- // non-input decorator content also shouldn't be force-synced).
2336
+ // Exclusions split across the two guard clauses:
2337
+ // - The first clause (`key !== undefined`) covers any DOM node
2338
+ // with a `__lexicalKey_*` attribute Lexical-managed elements
2339
+ // and the editor root (stashed in `resetEditor`).
2340
+ // - `isDOMCapturingSelection` covers DecoratorNode subtrees (which
2341
+ // own their own DOM) and subtrees marked via
2342
+ // `setDOMUnmanaged(dom, {captureSelection: true})`
2343
+ // extension-owned widgets that keep a native caret.
2345
2344
  //
2346
2345
  // Void elements that ARE Lexical nodes (LineBreakNode <br>,
2347
2346
  // empty decorator containers, etc.) have keys, so this check
@@ -115,6 +115,18 @@ export function getActiveEditorState(): EditorState {
115
115
  return activeEditorState;
116
116
  }
117
117
 
118
+ /** @internal */
119
+ export function $assumeActiveEditor(editor: LexicalEditor): void {
120
+ // Throw if called outside of an update
121
+ if (getActiveEditorState() !== null && activeEditor === null) {
122
+ activeEditor = editor;
123
+ }
124
+ invariant(
125
+ activeEditor === editor,
126
+ 'The given editor argument does not match $getEditor() in this context. Use editor.getEditorState().read(..., {editor}) if this cross-editor call is intentional.',
127
+ );
128
+ }
129
+
118
130
  export function getActiveEditor(): LexicalEditor {
119
131
  if (activeEditor === null) {
120
132
  invariant(
@@ -159,10 +159,6 @@ export const scheduleMicroTask: (fn: () => void) => void =
159
159
  Promise.resolve().then(fn);
160
160
  };
161
161
 
162
- export function $isSelectionCapturedInDecorator(node: Node): boolean {
163
- return $isDecoratorNode($getNearestNodeFromDOMNode(node));
164
- }
165
-
166
162
  export function isSelectionCapturedInDecoratorInput(anchorDOM: Node): boolean {
167
163
  const activeElement = document.activeElement;
168
164
 
@@ -571,6 +567,11 @@ export function setNodeKeyOnDOMNode(
571
567
  (dom as Node & Record<typeof prop, NodeKey | undefined>)[prop] = key;
572
568
  }
573
569
 
570
+ export function clearNodeKeyOnDOMNode(dom: Node, editor: LexicalEditor) {
571
+ const prop = `__lexicalKey_${editor._key}`;
572
+ delete (dom as Node & Record<typeof prop, NodeKey | undefined>)[prop];
573
+ }
574
+
574
575
  export function getNodeKeyFromDOMNode(
575
576
  dom: Node,
576
577
  editor: LexicalEditor,
@@ -683,10 +684,6 @@ export function $getNodeFromDOM(dom: Node): null | LexicalNode {
683
684
  const editor = getActiveEditor();
684
685
  const nodeKey = getNodeKeyFromDOMTree(dom, editor);
685
686
  if (nodeKey === null) {
686
- const rootElement = editor.getRootElement();
687
- if (dom === rootElement) {
688
- return $getNodeByKey('root');
689
- }
690
687
  return null;
691
688
  }
692
689
  return $getNodeByKey(nodeKey);
@@ -2265,18 +2262,45 @@ export function $setFormatFromDOM<T extends ElementNode>(
2265
2262
  : node;
2266
2263
  }
2267
2264
 
2265
+ /**
2266
+ * Options accepted by {@link setDOMUnmanaged}.
2267
+ *
2268
+ * @experimental
2269
+ */
2270
+ export interface SetDOMUnmanagedOptions {
2271
+ /**
2272
+ * When true, the marked subtree owns its own window selection — analogous
2273
+ * to a DecoratorNode subtree. Selection resolution that would otherwise
2274
+ * mark the selection dirty for a caret position inside unmanaged DOM
2275
+ * leaves it alone, so the embedded interaction (custom input, focusable
2276
+ * widget, etc.) can keep its native caret.
2277
+ *
2278
+ * Pass `false` to clear a previously-set marker; omit the field to leave
2279
+ * `__lexicalCapturedSelection` untouched.
2280
+ */
2281
+ captureSelection?: boolean;
2282
+ }
2283
+
2268
2284
  /**
2269
2285
  * Mark this DOM element as unmanaged by lexical's mutation observer (like
2270
2286
  * decorator nodes are). Extensions that inject non-lexical decoration
2271
2287
  * elements into a node's DOM should mark them so the mutation observer
2272
2288
  * doesn't evict them as "unknown DOM children" during cleanup.
2273
2289
  *
2290
+ * Pass `{captureSelection: true}` to additionally treat the subtree's
2291
+ * window selection as decorator-like, so resolution does not force-sync
2292
+ * the caret out of unmanaged DOM (see {@link isDOMCapturingSelection}).
2293
+ *
2274
2294
  * @experimental
2275
2295
  */
2276
2296
  export function setDOMUnmanaged(
2277
2297
  elementDom: HTMLElement & LexicalPrivateDOM,
2298
+ options?: SetDOMUnmanagedOptions,
2278
2299
  ): void {
2279
2300
  elementDom.__lexicalUnmanaged = true;
2301
+ if (options && options.captureSelection !== undefined) {
2302
+ elementDom.__lexicalCapturedSelection = options.captureSelection;
2303
+ }
2280
2304
  }
2281
2305
 
2282
2306
  /**
@@ -2288,6 +2312,40 @@ export function isDOMUnmanaged(elementDom: Node & LexicalPrivateDOM): boolean {
2288
2312
  return elementDom.__lexicalUnmanaged === true;
2289
2313
  }
2290
2314
 
2315
+ /**
2316
+ * True if the DOM node sits inside a subtree marked with
2317
+ * `{captureSelection: true}` via {@link setDOMUnmanaged}. Walks ancestors
2318
+ * so any descendant of a marked subtree (e.g. an `<input>` inside a marked
2319
+ * `<div>`) reports as captured too.
2320
+ *
2321
+ * The walk aborts at the first DOM node that corresponds to a Lexical
2322
+ * node in `editor` — that boundary is the implicit owner of the subtree's
2323
+ * selection, so a captureSelection marker above it (in non-Lexical
2324
+ * scaffolding around the editor) does not leak in.
2325
+ *
2326
+ * DecoratorNode DOM is marked with `setDOMUnmanaged({captureSelection:
2327
+ * true})` by the reconciler, so decorator subtrees also report as
2328
+ * captured here.
2329
+ *
2330
+ * @experimental
2331
+ */
2332
+ export function isDOMCapturingSelection(
2333
+ elementDom: Node & LexicalPrivateDOM,
2334
+ editor: LexicalEditor,
2335
+ ): boolean {
2336
+ let dom: (Node & LexicalPrivateDOM) | null = elementDom;
2337
+ while (dom != null) {
2338
+ if (dom.__lexicalCapturedSelection === true) {
2339
+ return true;
2340
+ }
2341
+ if (getNodeKeyFromDOMNode(dom, editor) !== undefined) {
2342
+ return false;
2343
+ }
2344
+ dom = getParentElement(dom);
2345
+ }
2346
+ return false;
2347
+ }
2348
+
2291
2349
  /**
2292
2350
  * @internal
2293
2351
  *
@@ -2307,13 +2365,6 @@ export function hasOwnStaticMethod(
2307
2365
  return hasOwn(klass, k) && klass[k] !== LexicalNode[k];
2308
2366
  }
2309
2367
 
2310
- /**
2311
- * @internal
2312
- */
2313
- export function hasOwnExportDOM(klass: Klass<LexicalNode>) {
2314
- return hasOwn(klass.prototype, 'exportDOM');
2315
- }
2316
-
2317
2368
  /** @internal */
2318
2369
  function isAbstractNodeClass(klass: Klass<LexicalNode>): boolean {
2319
2370
  if (!(klass === LexicalNode || klass.prototype instanceof LexicalNode)) {
package/src/index.ts CHANGED
@@ -255,6 +255,7 @@ export {
255
255
  tokenizeRawText,
256
256
  } from './LexicalSelection';
257
257
  export {
258
+ $assumeActiveEditor,
258
259
  $fullReconcile,
259
260
  $parseSerializedNode,
260
261
  isCurrentlyReadOnlyMode,
@@ -308,6 +309,7 @@ export {
308
309
  INTERNAL_$isBlock,
309
310
  isBlockDomNode,
310
311
  isDocumentFragment,
312
+ isDOMCapturingSelection,
311
313
  isDOMDocumentNode,
312
314
  isDOMNode,
313
315
  isDOMTextNode,
@@ -325,6 +327,7 @@ export {
325
327
  removeFromParent,
326
328
  resetRandomKey,
327
329
  setDOMUnmanaged,
330
+ type SetDOMUnmanagedOptions,
328
331
  setNodeIndentFromDOM,
329
332
  toggleTextFormatType,
330
333
  } from './LexicalUtils';
@@ -339,6 +342,8 @@ export type {SerializedLineBreakNode} from './nodes/LexicalLineBreakNode';
339
342
  export {
340
343
  $createLineBreakNode,
341
344
  $isLineBreakNode,
345
+ isLastChildInBlockNode,
346
+ isOnlyChildInBlockNode,
342
347
  LineBreakNode,
343
348
  } from './nodes/LexicalLineBreakNode';
344
349
  export type {SerializedParagraphNode} from './nodes/LexicalParagraphNode';
@@ -90,7 +90,15 @@ export function $isLineBreakNode(
90
90
  return node instanceof LineBreakNode;
91
91
  }
92
92
 
93
- function isOnlyChildInBlockNode(node: Node): boolean {
93
+ /**
94
+ * True when `node` is the sole non-whitespace child of a block DOM
95
+ * element. Used by the LineBreak importer to drop stray `<br>` elements
96
+ * that the legacy `$generateNodesFromDOM` also skipped (matches the
97
+ * behavior of `LineBreakNode.importDOM`).
98
+ *
99
+ * @experimental
100
+ */
101
+ export function isOnlyChildInBlockNode(node: Node): boolean {
94
102
  const parentElement = node.parentElement;
95
103
  if (parentElement !== null && isBlockDomNode(parentElement)) {
96
104
  const firstChild = parentElement.firstChild!;
@@ -111,7 +119,15 @@ function isOnlyChildInBlockNode(node: Node): boolean {
111
119
  return false;
112
120
  }
113
121
 
114
- function isLastChildInBlockNode(node: Node): boolean {
122
+ /**
123
+ * True when `node` is the trailing non-whitespace child of a block DOM
124
+ * element (excluding the only-child case). Used by the LineBreak
125
+ * importer to drop trailing `<br>` elements like the Apple-interchange
126
+ * clipboard artifact (matches `LineBreakNode.importDOM`).
127
+ *
128
+ * @experimental
129
+ */
130
+ export function isLastChildInBlockNode(node: Node): boolean {
115
131
  const parentElement = node.parentElement;
116
132
  if (parentElement !== null && isBlockDomNode(parentElement)) {
117
133
  // check if node is first child, because only child dont count
@@ -53,6 +53,7 @@ import {
53
53
  import {LexicalNode} from '../LexicalNode';
54
54
  import {$cloneNodeState} from '../LexicalNodeState';
55
55
  import {
56
+ $generateNodesFromRawText,
56
57
  $getSelection,
57
58
  $internalMakeRangeSelection,
58
59
  $isRangeSelection,
@@ -74,8 +75,6 @@ import {
74
75
  toggleTextFormatType,
75
76
  } from '../LexicalUtils';
76
77
  import {setDOMStyleFromCSS} from '../utils/setDOMStyle';
77
- import {$createLineBreakNode} from './LexicalLineBreakNode';
78
- import {$createTabNode} from './LexicalTabNode';
79
78
 
80
79
  export type SerializedTextNode = Spread<
81
80
  {
@@ -1262,20 +1261,7 @@ function $convertTextDOMNode(domNode: Node): DOMConversionOutput {
1262
1261
  let textContent = domNode_.textContent || '';
1263
1262
  // No collapse and preserve segment break for pre, pre-wrap and pre-line
1264
1263
  if (findParentPreDOMNode(domNode_) !== null) {
1265
- const parts = textContent.split(/(\r?\n|\t)/);
1266
- const nodes: Array<LexicalNode> = [];
1267
- const length = parts.length;
1268
- for (let i = 0; i < length; i++) {
1269
- const part = parts[i];
1270
- if (part === '\n' || part === '\r\n') {
1271
- nodes.push($createLineBreakNode());
1272
- } else if (part === '\t') {
1273
- nodes.push($createTabNode());
1274
- } else if (part !== '') {
1275
- nodes.push($createTextNode(part));
1276
- }
1277
- }
1278
- return {node: nodes};
1264
+ return {node: $generateNodesFromRawText(textContent)};
1279
1265
  }
1280
1266
  textContent = textContent.replace(/\r/g, '').replace(/[ \t\n]+/g, ' ');
1281
1267
  if (textContent === '') {