@37signals/lexxy 0.9.17 → 0.9.19-alpha.1

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.
package/dist/lexxy.esm.js CHANGED
@@ -1,7 +1,7 @@
1
1
  export { highlightCode, highlightElement } from './lexxy_helpers.esm.js';
2
2
  import DOMPurify from 'dompurify';
3
3
  import { getStyleObjectFromCSS, getCSSFromStyleObject, $getSelectionStyleValueForProperty, $ensureForwardRangeSelection, $isAtNodeEnd, $patchStyleText, $setBlocksType, $forEachSelectedTextNode } from '@lexical/selection';
4
- import { SKIP_DOM_SELECTION_TAG, CAN_UNDO_COMMAND, COMMAND_PRIORITY_LOW, CAN_REDO_COMMAND, $getSelection, $isRangeSelection, DecoratorNode, $createTextNode, $getRoot, $caretFromPoint, $setSelectionFromCaretRange, $getCaretRange, $normalizeCaret, $getChildCaret, $getCaretInDirection, $isParagraphNode, $isLineBreakNode, $createParagraphNode, $isElementNode, $isRootOrShadowRoot, $isRootNode, $createNodeSelection, $isDecoratorNode, $isTextNode, $getSiblingCaret, $rewindSiblingCaret, $splitAtPointCaretNext, $isChildCaret, $isTextPointCaret, $isExtendableTextPointCaret, $isSiblingCaret, $getCommonAncestor, $findMatchingParent, TextNode, createCommand, defineExtension, COMMAND_PRIORITY_EDITOR, $getEditor, $getNodeByKey, HISTORY_MERGE_TAG, SKIP_SCROLL_INTO_VIEW_TAG, $cloneWithProperties, $getNearestRootOrShadowRoot, $createRangeSelection, $setSelection, createState, COMMAND_PRIORITY_NORMAL, $getState, $setState, $hasUpdateTag, PASTE_TAG, FORMAT_TEXT_COMMAND, UNDO_COMMAND, REDO_COMMAND, KEY_ARROW_RIGHT_COMMAND, KEY_TAB_COMMAND, OUTDENT_CONTENT_COMMAND, INDENT_CONTENT_COMMAND, $isNodeSelection, KEY_ARROW_LEFT_COMMAND, KEY_ARROW_UP_COMMAND, KEY_ARROW_DOWN_COMMAND, DELETE_CHARACTER_COMMAND, SELECTION_CHANGE_COMMAND, CLICK_COMMAND, isDOMNode, $getNearestNodeFromDOMNode, $addUpdateTag, ElementNode, $splitNode, $getChildCaretAtIndex, $createLineBreakNode, SELECTION_INSERT_CLIPBOARD_NODES_COMMAND, PASTE_COMMAND, $onUpdate, ParagraphNode, RootNode, COMMAND_PRIORITY_HIGH, DRAGSTART_COMMAND, DROP_COMMAND, INSERT_PARAGRAPH_COMMAND, mergeRegister as mergeRegister$1, CLEAR_HISTORY_COMMAND, KEY_ENTER_COMMAND, COMMAND_PRIORITY_CRITICAL, KEY_SPACE_COMMAND, INPUT_COMMAND, KEY_BACKSPACE_COMMAND, KEY_DOWN_COMMAND } from 'lexical';
4
+ import { SKIP_DOM_SELECTION_TAG, CAN_UNDO_COMMAND, COMMAND_PRIORITY_LOW, CAN_REDO_COMMAND, $getSelection, $isRangeSelection, DecoratorNode, $createTextNode, $getRoot, $caretFromPoint, $setSelectionFromCaretRange, $getCaretRange, $normalizeCaret, $getChildCaret, $getCaretInDirection, $isParagraphNode, $isLineBreakNode, $createParagraphNode, $isElementNode, $isRootOrShadowRoot, $isRootNode, $createNodeSelection, $isDecoratorNode, $isTextNode, $getSiblingCaret, $rewindSiblingCaret, $splitAtPointCaretNext, $isChildCaret, $isTextPointCaret, $isExtendableTextPointCaret, $isSiblingCaret, $getCommonAncestor, $findMatchingParent, TextNode, createCommand, defineExtension, COMMAND_PRIORITY_EDITOR, $getEditor, $getNodeByKey, HISTORY_MERGE_TAG, SKIP_SCROLL_INTO_VIEW_TAG, $cloneWithProperties, $getNearestRootOrShadowRoot, $createRangeSelection, $setSelection, createState, COMMAND_PRIORITY_NORMAL, $getState, $setState, $hasUpdateTag, PASTE_TAG, FORMAT_TEXT_COMMAND, UNDO_COMMAND, REDO_COMMAND, KEY_ARROW_RIGHT_COMMAND, KEY_TAB_COMMAND, OUTDENT_CONTENT_COMMAND, INDENT_CONTENT_COMMAND, $isNodeSelection, KEY_ARROW_LEFT_COMMAND, KEY_ARROW_UP_COMMAND, KEY_ARROW_DOWN_COMMAND, DELETE_CHARACTER_COMMAND, SELECTION_CHANGE_COMMAND, CLICK_COMMAND, isDOMNode, $getNearestNodeFromDOMNode, $addUpdateTag, ElementNode, $splitNode, $getChildCaretAtIndex, $createLineBreakNode, $normalizeSelection__EXPERIMENTAL, SELECTION_INSERT_CLIPBOARD_NODES_COMMAND, PASTE_COMMAND, $onUpdate, ParagraphNode, RootNode, COMMAND_PRIORITY_HIGH, DRAGSTART_COMMAND, DROP_COMMAND, INSERT_PARAGRAPH_COMMAND, mergeRegister as mergeRegister$1, CLEAR_HISTORY_COMMAND, KEY_ENTER_COMMAND, COMMAND_PRIORITY_CRITICAL, KEY_SPACE_COMMAND, INPUT_COMMAND, KEY_BACKSPACE_COMMAND, KEY_DOWN_COMMAND } from 'lexical';
5
5
  import { LinkNode, $createAutoLinkNode, $toggleLink, $createLinkNode, $isLinkNode, AutoLinkNode } from '@lexical/link';
6
6
  import { buildEditorFromExtensions } from '@lexical/extension';
7
7
  import { ListNode, ListItemNode, $getListDepth, INSERT_UNORDERED_LIST_COMMAND, INSERT_ORDERED_LIST_COMMAND, $isListItemNode, $isListNode, registerList } from '@lexical/list';
@@ -4031,9 +4031,12 @@ class Selection {
4031
4031
  }
4032
4032
 
4033
4033
  get isOnPreviewableImage() {
4034
- const selection = $getSelection();
4035
- const firstNode = selection?.getNodes().at(0);
4036
- return $isActionTextAttachmentNode(firstNode) && firstNode.isPreviewableImage
4034
+ return this.previewableImageNode != null
4035
+ }
4036
+
4037
+ get previewableImageNode() {
4038
+ const firstNode = $getSelection()?.getNodes().at(0);
4039
+ return $isActionTextAttachmentNode(firstNode) && firstNode.isPreviewableImage ? firstNode : null
4037
4040
  }
4038
4041
 
4039
4042
  get isAtNodeStart() {
@@ -5008,7 +5011,10 @@ class GalleryUploader extends Uploader {
5008
5011
 
5009
5012
  #findOrCreateGallery() {
5010
5013
  if (this.selection.isOnPreviewableImage) {
5011
- this.#gallery = $findOrCreateGalleryForImage(this.#selectedNode);
5014
+ // Resolve from the previewable image itself (the selection's first node), not from
5015
+ // #selectedNode (the anchor) — those differ when the selection runs from an image
5016
+ // into following text, and the anchor text node can't join a gallery (returns null).
5017
+ this.#gallery = $findOrCreateGalleryForImage(this.selection.previewableImageNode);
5012
5018
  } else if (this.#selectionIsAfterGalleryEdge) {
5013
5019
  this.#gallery = $findOrCreateGalleryForImage(this.selection.nodeBeforeCursor);
5014
5020
  } else {
@@ -5345,7 +5351,8 @@ class NodeInserter {
5345
5351
  const INSERTERS = [
5346
5352
  CodeNodeInserter,
5347
5353
  ShadowRootNodeInserter,
5348
- NodeSelectionNodeInserter
5354
+ NodeSelectionNodeInserter,
5355
+ BlockContainerNodeInserter
5349
5356
  ];
5350
5357
  const Inserter = INSERTERS.find(inserter => inserter.handles(selection));
5351
5358
  return Inserter ? new Inserter(selection) : selection
@@ -5371,14 +5378,40 @@ class CodeNodeInserter extends NodeInserter {
5371
5378
 
5372
5379
  const caret = $getChildCaretAtIndex(codeNode, insertionIndex + 1, "previous");
5373
5380
 
5381
+ // Nodes that are already in the document come from the format-toggle path (existing
5382
+ // content converted into this code block). Brand-new nodes (dropped/pasted content)
5383
+ // were never attached.
5384
+ const existingNodes = new Set(nodes.filter(node => node.isAttached()));
5385
+ const trailingNodes = [];
5386
+
5374
5387
  for (const node of nodes) {
5375
- if (!node.isAttached()) continue
5376
- if (caret.getNodeAtCaret() && $isElementNode(node)) { caret.insert($createLineBreakNode()); }
5388
+ if (existingNodes.has(node)) {
5389
+ if (!node.isAttached()) continue // already pulled in when a converted ancestor was removed
5390
+ } else if (!this.#canJoinCodeBlock(node)) {
5391
+ trailingNodes.push(node); // e.g. a dropped attachment, which a code block can't hold
5392
+ continue
5393
+ }
5377
5394
 
5395
+ if (caret.getNodeAtCaret() && $isElementNode(node)) { caret.insert($createLineBreakNode()); }
5378
5396
  caret.insert(this.#convertNodeToCodeChild(node));
5379
5397
  }
5380
5398
 
5381
- caret.getNodeAtCaret().selectEnd();
5399
+ const lastTrailingNode = this.#insertAfterCodeBlock(codeNode, trailingNodes);
5400
+ const nodeToSelect = lastTrailingNode ?? caret.getNodeAtCaret();
5401
+ nodeToSelect?.selectEnd();
5402
+ }
5403
+
5404
+ #canJoinCodeBlock(node) {
5405
+ return $isTextNode(node) || $isLineBreakNode(node)
5406
+ }
5407
+
5408
+ #insertAfterCodeBlock(codeNode, nodes) {
5409
+ let previousNode = codeNode;
5410
+ for (const node of nodes) {
5411
+ previousNode.insertAfter(node);
5412
+ previousNode = node;
5413
+ }
5414
+ return nodes.at(-1)
5382
5415
  }
5383
5416
 
5384
5417
  #convertNodeToCodeChild(node) {
@@ -5394,7 +5427,7 @@ class CodeNodeInserter extends NodeInserter {
5394
5427
 
5395
5428
  class ShadowRootNodeInserter extends NodeInserter {
5396
5429
  static handles(selection) {
5397
- return $isShadowRoot(selection?.anchor.getNode())
5430
+ return $isShadowRoot(selection?.anchor?.getNode())
5398
5431
  }
5399
5432
 
5400
5433
  insertNodes(nodes) {
@@ -5423,6 +5456,31 @@ class NodeSelectionNodeInserter extends NodeInserter {
5423
5456
  }
5424
5457
  }
5425
5458
 
5459
+ // Lexical's RangeSelection.insertNodes requires every selection point to have a block
5460
+ // ancestor with inline children. An element point on a container of block nodes — e.g.
5461
+ // a quote holding paragraphs — has none, so Lexical throws invariant #211 or #212.
5462
+ // Descend such points to a leaf position before inserting.
5463
+ class BlockContainerNodeInserter extends NodeInserter {
5464
+ static handles(selection) {
5465
+ return $isRangeSelection(selection) &&
5466
+ [ selection.anchor, selection.focus ].some($isPointOnBlockContainer)
5467
+ }
5468
+
5469
+ insertNodes(nodes) {
5470
+ $normalizeSelection__EXPERIMENTAL(this.selection);
5471
+ this.selection.insertNodes(nodes);
5472
+ }
5473
+ }
5474
+
5475
+ function $isPointOnBlockContainer(point) {
5476
+ if (point.type === "element") {
5477
+ const firstChild = point.getNode().getFirstChild();
5478
+ return ($isElementNode(firstChild) || $isDecoratorNode(firstChild)) && !firstChild.isInline()
5479
+ } else {
5480
+ return false
5481
+ }
5482
+ }
5483
+
5426
5484
  class Contents {
5427
5485
  constructor(editorElement) {
5428
5486
  this.editorElement = editorElement;
@@ -5519,7 +5577,7 @@ class Contents {
5519
5577
  blockElements.forEach(node => this.#unwrapCodeBlock(node));
5520
5578
  } else {
5521
5579
  $expandSelectionToLineBreaksAndSplitAtEdges(selection);
5522
- const elements = this.#blockLevelElementsInSelection(selection);
5580
+ const elements = this.#outermostElements(this.#blockLevelElementsInSelection(selection));
5523
5581
  if (elements.length === 0) return
5524
5582
 
5525
5583
  const codeNode = $createCodeNode("plain");
@@ -5655,6 +5713,8 @@ class Contents {
5655
5713
  }
5656
5714
 
5657
5715
  uploadFiles(files, { selectLast } = {}) {
5716
+ if (!this.editorElement) return // Disposed (e.g. on turbo:before-cache); a late drop can still land here
5717
+
5658
5718
  if (!this.editorElement.supportsAttachments) {
5659
5719
  console.warn("This editor does not supports attachments (it's configured with [attachments=false])");
5660
5720
  return
@@ -5845,6 +5905,18 @@ class Contents {
5845
5905
  return Array.from(elements)
5846
5906
  }
5847
5907
 
5908
+ // Selections spanning nested structures (a quote and its inner paragraphs,
5909
+ // nested list items) yield both an element and its ancestor. Converting the
5910
+ // ancestor detaches its whole subtree — including a node freshly inserted
5911
+ // inside it — which can leave the selection on removed nodes (Lexical
5912
+ // invariant #19). The outermost elements already cover their descendants'
5913
+ // text content, so keep only those.
5914
+ #outermostElements(elements) {
5915
+ return elements.filter((element) => {
5916
+ return elements.every((other) => other === element || !element.getParents().includes(other))
5917
+ })
5918
+ }
5919
+
5848
5920
  #insertUploadNodes(nodes) {
5849
5921
  if (nodes.every($isActionTextAttachmentNode)) {
5850
5922
  const uploader = Uploader.for(this.editorElement, []);
@@ -7372,12 +7444,23 @@ class EarlyEscapeListItemNode extends ListItemNode {
7372
7444
  return super.insertNewAfter(selection, restoreSelection)
7373
7445
  }
7374
7446
 
7447
+ get #isInBlockquote() {
7448
+ return Boolean($getNearestNodeOfType(this, QuoteNode))
7449
+ }
7450
+
7375
7451
  #shouldEscape(selection) {
7376
- if (!$getNearestNodeOfType(this, QuoteNode)) return false
7377
- if ($isBlankNode(this)) return true
7452
+ if (this.#isInPasteOperation() || !this.#isInBlockquote) {
7453
+ return false
7454
+ } else if ($isBlankNode(this)) {
7455
+ return true
7456
+ } else {
7457
+ const paragraph = $getNearestNodeOfType(selection.anchor.getNode(), ParagraphNode);
7458
+ return paragraph && $isBlankNode(paragraph) && $isListItemNode(paragraph.getParent())
7459
+ }
7460
+ }
7378
7461
 
7379
- const paragraph = $getNearestNodeOfType(selection.anchor.getNode(), ParagraphNode);
7380
- return paragraph && $isBlankNode(paragraph) && $isListItemNode(paragraph.getParent())
7462
+ #isInPasteOperation() {
7463
+ return $hasUpdateTag(PASTE_TAG)
7381
7464
  }
7382
7465
 
7383
7466
  #escapeFromList() {
@@ -7425,7 +7508,7 @@ class FormatEscapeExtension extends LexxyExtension {
7425
7508
  }
7426
7509
 
7427
7510
  get allowedElements() {
7428
- return [ { tag: "li", attributes: [ "value" ] } ]
7511
+ return [ { tag: "ol", attributes: [ "start" ] }, { tag: "li", attributes: [ "value" ] } ]
7429
7512
  }
7430
7513
 
7431
7514
  get lexicalExtension() {
@@ -7898,8 +7981,8 @@ class LexicalEditorElement extends HTMLElement {
7898
7981
  return this.#historyState.redo
7899
7982
  }
7900
7983
 
7901
- #readSanitizedEditorValue(editor = this.editor) {
7902
- return editor?.read(() => {
7984
+ #readSanitizedEditorValue() {
7985
+ return this.editor?.read(() => {
7903
7986
  return sanitize($generateHtmlFromNodes(this.editor, null))
7904
7987
  }) ?? null
7905
7988
  }
@@ -7933,7 +8016,6 @@ class LexicalEditorElement extends HTMLElement {
7933
8016
  }
7934
8017
 
7935
8018
  #initialize() {
7936
- this.#synchronizeWithChanges();
7937
8019
  this.#registerComponents();
7938
8020
  this.#handleEnter();
7939
8021
  this.#registerFocusEvents();
@@ -7942,6 +8024,9 @@ class LexicalEditorElement extends HTMLElement {
7942
8024
  this.#attachDebugHooks();
7943
8025
  this.#attachToolbar();
7944
8026
  this.#resetBeforeTurboCaches();
8027
+
8028
+ this.#setInternalFormValue(this.value, { suppressEvent: true });
8029
+ this.#synchronizeWithChanges();
7945
8030
  }
7946
8031
 
7947
8032
  #registerFileAcceptFilter() {
@@ -7969,7 +8054,6 @@ class LexicalEditorElement extends HTMLElement {
7969
8054
  $initialEditorState: (editor) => {
7970
8055
  this.#configureSanitizer(editor);
7971
8056
  this.#loadInitialValue(editor);
7972
- this.#setInternalFormValue(this.#readSanitizedEditorValue(editor));
7973
8057
  },
7974
8058
  },
7975
8059
  ...this.extensions.lexicalExtensions
@@ -8037,13 +8121,13 @@ class LexicalEditorElement extends HTMLElement {
8037
8121
  return Array.from(this.attributes).filter(attribute => attribute.name.startsWith("aria-"))
8038
8122
  }
8039
8123
 
8040
- #setInternalFormValue(html) {
8041
- const changed = this.#previousInternalFormValue !== null && html !== this.#previousInternalFormValue;
8124
+ #setInternalFormValue(html, { suppressEvent = false } = {}) {
8125
+ const changed = html !== this.#previousInternalFormValue;
8042
8126
 
8043
8127
  this.internals.setFormValue(html);
8044
8128
  this.#previousInternalFormValue = html;
8045
8129
 
8046
- if (changed) {
8130
+ if (changed && !suppressEvent) {
8047
8131
  dispatch(this, "lexxy:change");
8048
8132
  }
8049
8133
  }
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@37signals/lexxy",
3
- "version": "0.9.17",
3
+ "version": "0.9.19-alpha.1",
4
4
  "description": "Lexxy - A modern rich text editor for Rails.",
5
5
  "module": "dist/lexxy.esm.js",
6
6
  "type": "module",
@@ -1,344 +0,0 @@
1
- import { $getRoot, $caretFromPoint, $setSelectionFromCaretRange, $getCaretRange, $normalizeCaret, $getChildCaret, $getCaretInDirection, $isParagraphNode, $isLineBreakNode, $createParagraphNode, $isElementNode, $isRootOrShadowRoot, $isRootNode, $createNodeSelection, $isDecoratorNode, $isTextNode, $getSiblingCaret, $rewindSiblingCaret, $splitAtPointCaretNext, $getSelection, $isChildCaret, $isTextPointCaret, $isExtendableTextPointCaret, $isSiblingCaret, $isRangeSelection, $getCommonAncestor, $findMatchingParent, TextNode } from 'lexical';
2
- export * from 'lexical';
3
- import { ListNode } from '@lexical/list';
4
- import { $getNearestNodeOfType, $wrapNodeInElement, $lastToFirstIterator } from '@lexical/utils';
5
- import { $ensureForwardRangeSelection, $isAtNodeEnd } from '@lexical/selection';
6
-
7
- /*** Only import from lexical packages in this file to prevent breaking npm package export chunking ***/
8
-
9
- function $containsRangeSelection(node, selection = $getSelection()) {
10
- if ($isRangeSelection(selection)) {
11
- const { commonAncestor } = $getCommonAncestor(selection.focus.getNode(), selection.anchor.getNode());
12
- return $findMatchingParent(commonAncestor, parent => parent.is(node))
13
- } else {
14
- return false
15
- }
16
- }
17
-
18
- function $createNodeSelectionWith(...nodes) {
19
- const selection = $createNodeSelection();
20
- nodes.forEach(node => selection.add(node.getKey()));
21
- return selection
22
- }
23
-
24
- function $isShadowRoot(node) {
25
- return $isElementNode(node) && $isRootOrShadowRoot(node) && !$isRootNode(node)
26
- }
27
-
28
- function $isSafeForRoot(node) {
29
- return ($isElementNode(node) || $isDecoratorNode(node)) && !node.isParentRequired()
30
- }
31
-
32
- function $makeSafeForRoot(node) {
33
- if ($isSafeForRoot(node)) {
34
- return node
35
- } else {
36
- return $wrapNodeInElement(node, () => node.createParentElementNode())
37
- }
38
- }
39
-
40
- function getListType(node) {
41
- const list = $getNearestNodeOfType(node, ListNode);
42
- return list?.getListType() ?? null
43
- }
44
-
45
- function isEditorFocused(editor) {
46
- const rootElement = editor.getRootElement();
47
- return rootElement !== null && rootElement.contains(document.activeElement)
48
- }
49
-
50
- function $isAtNodeEdge(point, atStart = null) {
51
- if (atStart === null) {
52
- return $isAtNodeEdge(point, true) || $isAtNodeEdge(point, false)
53
- } else {
54
- return atStart ? $isAtNodeStart(point) : $isAtNodeEnd(point)
55
- }
56
- }
57
-
58
- function $isAtNodeStart(point) {
59
- return point.offset === 0
60
- }
61
-
62
- function extendTextNodeConversion(conversionName, ...callbacks) {
63
- return extendConversion(TextNode, conversionName, (conversionOutput, element) => ({
64
- ...conversionOutput,
65
- forChild: (lexicalNode, parentNode) => {
66
- const originalForChild = conversionOutput?.forChild ?? (x => x);
67
- let childNode = originalForChild(lexicalNode, parentNode);
68
-
69
-
70
- if ($isTextNode(childNode)) {
71
- childNode = callbacks.reduce(
72
- (childNode, callback) => callback(childNode, element) ?? childNode,
73
- childNode
74
- );
75
- return childNode
76
- }
77
- }
78
- }))
79
- }
80
-
81
- function extendConversion(nodeKlass, conversionName, callback = (output => output)) {
82
- return (element) => {
83
- const converter = nodeKlass.importDOM()?.[conversionName]?.(element);
84
- if (!converter) return null
85
-
86
- const conversionOutput = converter.conversion(element);
87
- if (!conversionOutput) return conversionOutput
88
-
89
- return callback(conversionOutput, element) ?? conversionOutput
90
- }
91
- }
92
-
93
- function $isCursorOnLastLine(selection) {
94
- const anchorNode = selection.anchor.getNode();
95
- const elementNode = $isElementNode(anchorNode) ? anchorNode : anchorNode.getParentOrThrow();
96
- const children = elementNode.getChildren();
97
- if (children.length === 0) return true
98
-
99
- const lastChild = children[children.length - 1];
100
-
101
- if (anchorNode === elementNode.getLatest() && selection.anchor.offset === children.length) return true
102
- if (anchorNode === lastChild) return true
103
-
104
- const lastLineBreakIndex = children.findLastIndex(child => $isLineBreakNode(child));
105
- if (lastLineBreakIndex === -1) return true
106
-
107
- const anchorIndex = children.indexOf(anchorNode);
108
- return anchorIndex > lastLineBreakIndex
109
- }
110
-
111
- function $isBlankNode(node) {
112
- if (node.getTextContent().trim() !== "") return false
113
-
114
- const children = node.getChildren?.();
115
- if (!children || children.length === 0) return true
116
-
117
- return children.every(child => {
118
- if ($isLineBreakNode(child)) return true
119
- return $isBlankNode(child)
120
- })
121
- }
122
-
123
- function $trimTrailingBlankNodes(parent) {
124
- for (const child of $lastToFirstIterator(parent)) {
125
- if ($isBlankNode(child)) {
126
- child.remove();
127
- } else {
128
- break
129
- }
130
- }
131
- }
132
-
133
- // A list item is structurally empty if it contains no meaningful content.
134
- // Unlike getTextContent().trim() === "", this walks descendants to ensure
135
- // decorator nodes (mentions, attachments whose getTextContent() may return
136
- // invisible characters like \ufeff) are treated as non-empty content.
137
- function $isListItemStructurallyEmpty(listItem) {
138
- const children = listItem.getChildren();
139
- for (const child of children) {
140
- if ($isDecoratorNode(child)) return false
141
- if ($isLineBreakNode(child)) continue
142
- if ($isTextNode(child)) {
143
- if (child.getTextContent().trim() !== "") return false
144
- } else if ($isElementNode(child)) {
145
- if (child.getTextContent().trim() !== "") return false
146
- }
147
- }
148
- return true
149
- }
150
-
151
- // Returns the document text up to `offset` inside `targetNode`. Non-inline
152
- // element siblings are joined with `\n\n`, matching Lexical's own
153
- // ElementNode.getTextContent behavior.
154
- function $textBeforeOffset(targetNode, offset) {
155
- const parts = [];
156
- let done = false;
157
-
158
- function visit(node) {
159
- if (done) return
160
- if (node === targetNode) {
161
- parts.push(node.getTextContent().slice(0, offset));
162
- done = true;
163
- return
164
- }
165
- if ($isElementNode(node)) {
166
- const children = node.getChildren();
167
- for (let i = 0; i < children.length; i++) {
168
- visit(children[i]);
169
- if (done) return
170
- const child = children[i];
171
- if ($isElementNode(child) && !child.isInline() && i < children.length - 1) {
172
- parts.push("\n\n");
173
- }
174
- }
175
- } else {
176
- parts.push(node.getTextContent());
177
- }
178
- }
179
-
180
- visit($getRoot());
181
- return parts.join("")
182
- }
183
-
184
- function $splitSelectedParagraphsAtInnerLineBreaks(selection) {
185
- const topLevelElements = new Set();
186
- for (const node of selection.getNodes()) {
187
- const topLevel = node.getTopLevelElement();
188
- if (topLevel) topLevelElements.add(topLevel);
189
- }
190
-
191
- for (const element of topLevelElements) {
192
- if (!$isParagraphNode(element)) continue
193
-
194
- const children = element.getChildren();
195
- if (!children.some($isLineBreakNode)) continue
196
-
197
- const groups = [ [] ];
198
- for (const child of children) {
199
- if ($isLineBreakNode(child)) {
200
- groups.push([]);
201
- child.remove();
202
- } else {
203
- groups[groups.length - 1].push(child);
204
- }
205
- }
206
-
207
- for (const group of groups) {
208
- if (group.length === 0) continue
209
- const paragraph = $createParagraphNode();
210
- group.forEach(child => paragraph.append(child));
211
- element.insertBefore(paragraph);
212
- }
213
- if (groups.some(group => group.length > 0)) element.remove();
214
- }
215
- }
216
-
217
- function $expandSelectionToLineBreaksAndSplitAtEdges(selection) {
218
- $ensureForwardRangeSelection(selection);
219
-
220
- const focusCaret = $caretFromPoint(selection.focus, "next");
221
- const anchorCaret = $caretFromPoint(selection.anchor, "previous");
222
-
223
- // A collapsed cursor adjacent to a <br> would claim it from both sides via
224
- // inward-edge; force outward-only walks so each side finds its own boundary.
225
- const skipInwardEdge = selection.isCollapsed();
226
- const focusBrCaret = $getCaretAtLineBreakBoundary(focusCaret, skipInwardEdge);
227
- let anchorBrCaret = $getCaretAtLineBreakBoundary(anchorCaret, skipInwardEdge);
228
-
229
- if (focusBrCaret?.origin.is(anchorBrCaret?.origin)) {
230
- anchorBrCaret = null;
231
- }
232
-
233
- // Splitting focus first keeps the anchor <br>'s position stable.
234
- const focusOuter = focusBrCaret && $splitAroundLineBreak(focusBrCaret);
235
- const anchorOuter = anchorBrCaret && $splitAroundLineBreak(anchorBrCaret);
236
-
237
- const innerStart = anchorOuter?.getNextSibling() ?? selection.anchor.getNode().getTopLevelElement();
238
- const innerEnd = focusOuter?.getPreviousSibling() ?? selection.focus.getNode().getTopLevelElement();
239
- if (!innerStart || !innerEnd) return
240
-
241
- $setSelectionFromCaretRange($getCaretRange(
242
- $normalizeCaret($getChildCaret(innerStart, "next")),
243
- $getCaretInDirection(
244
- $normalizeCaret($getChildCaret(innerEnd, "previous")),
245
- "next",
246
- ),
247
- ));
248
- }
249
-
250
- function $getCaretAtLineBreakBoundary(caret, skipInwardEdge = false) {
251
- const paragraph = caret.origin.getTopLevelElement();
252
- if (!paragraph || !$isParagraphNode(paragraph)) return null
253
-
254
- const lineBreak = (skipInwardEdge ? null : $inwardEdgeLineBreak(caret, paragraph))
255
- ?? $outwardLineBreak(caret, paragraph);
256
-
257
- return lineBreak ? $getSiblingCaret(lineBreak, caret.direction) : null
258
- }
259
-
260
- // Prefer a <br> the cursor is sitting flush against, except when a further <br>
261
- // also exists outward — that one is the real paragraph break for this side.
262
- function $inwardEdgeLineBreak(caret, paragraph) {
263
- let candidateCaret;
264
-
265
- if (
266
- ($isChildCaret(caret) && caret.origin.is(paragraph)) ||
267
- ($isTextPointCaret(caret) && $isExtendableTextPointCaret(caret.getFlipped()))
268
- ) {
269
- candidateCaret = null;
270
- } else if ($isSiblingCaret(caret) && caret.getParentAtCaret().is(paragraph)) {
271
- candidateCaret = caret;
272
- } else {
273
- const childCaret = $paragraphChildCaretAtInwardEdge(caret, paragraph);
274
- candidateCaret = childCaret ? $rewindSiblingCaret(childCaret) : null;
275
- }
276
-
277
- if (candidateCaret && $isLineBreakNode(candidateCaret.origin)) {
278
- return $candidateUnlessShadowed(candidateCaret)
279
- } else {
280
- return null
281
- }
282
- }
283
-
284
- function $candidateUnlessShadowed(candidateCaret) {
285
- const outward = candidateCaret.getNodeAtCaret();
286
- return $isLineBreakNode(outward) ? null : candidateCaret.origin
287
- }
288
-
289
- function $outwardLineBreak(caret, paragraph) {
290
- const startCaret = $outwardWalkStartCaret(caret, paragraph);
291
- if (!startCaret) return null
292
-
293
- for (const { origin } of startCaret) {
294
- if (!origin.getParent().is(paragraph)) break
295
- if ($isLineBreakNode(origin)) return origin
296
- }
297
- return null
298
- }
299
-
300
- function $outwardWalkStartCaret(caret, paragraph) {
301
- if (caret.getParentAtCaret().is(paragraph)) {
302
- return caret
303
- } else {
304
- return $paragraphChildCaretContaining(caret, paragraph)
305
- }
306
- }
307
-
308
- function $paragraphChildCaretContaining(caret, paragraph) {
309
- let cursor = caret.getSiblingCaret();
310
- while (cursor && !cursor.origin.getParent()?.is(paragraph)) {
311
- cursor = cursor.getParentCaret();
312
- }
313
- return cursor?.origin.getParent()?.is(paragraph) ? cursor : null
314
- }
315
-
316
- // Only succeeds when the cursor is flush against the inward edge of every
317
- // ancestor between itself and the paragraph child.
318
- function $paragraphChildCaretAtInwardEdge(caret, paragraph) {
319
- let cursor = caret.getSiblingCaret();
320
- while (cursor && !cursor.origin.getParent()?.is(paragraph)) {
321
- if (cursor.getNodeAtCaret()) return null
322
- cursor = cursor.getParentCaret();
323
- }
324
- return cursor?.origin.getParent()?.is(paragraph) ? cursor : null
325
- }
326
-
327
- function $splitAroundLineBreak(lineBreakCaret) {
328
- let outer = null;
329
-
330
- if (lineBreakCaret.getNodeAtCaret() === null) {
331
- lineBreakCaret.origin.remove();
332
- } else {
333
- const lineBreak = lineBreakCaret.origin;
334
- const splitCaret = $getCaretInDirection($rewindSiblingCaret(lineBreakCaret), "next");
335
-
336
- $splitAtPointCaretNext(splitCaret);
337
- outer = lineBreak.getTopLevelElement();
338
- lineBreak.remove();
339
- }
340
-
341
- return outer
342
- }
343
-
344
- export { $containsRangeSelection, $createNodeSelectionWith, $expandSelectionToLineBreaksAndSplitAtEdges, $isAtNodeEdge, $isAtNodeStart, $isBlankNode, $isCursorOnLastLine, $isListItemStructurallyEmpty, $isSafeForRoot, $isShadowRoot, $makeSafeForRoot, $splitSelectedParagraphsAtInnerLineBreaks, $textBeforeOffset, $trimTrailingBlankNodes, extendConversion, extendTextNodeConversion, getListType, isEditorFocused };