@37signals/lexxy 0.9.18 → 0.9.19-alpha.2

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.
Files changed (2) hide show
  1. package/dist/lexxy.esm.js +403 -93
  2. package/package.json +4 -3
package/dist/lexxy.esm.js CHANGED
@@ -1,13 +1,13 @@
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, $normalizeSelection__EXPERIMENTAL, $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, INSERT_LINE_BREAK_COMMAND, COMMAND_PRIORITY_HIGH, INSERT_PARAGRAPH_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, DRAGSTART_COMMAND, DROP_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
- import { ListNode, ListItemNode, $getListDepth, INSERT_UNORDERED_LIST_COMMAND, INSERT_ORDERED_LIST_COMMAND, $isListItemNode, $isListNode, registerList } from '@lexical/list';
7
+ import { ListNode, ListItemNode, $getListDepth, $isListNode, $isListItemNode, INSERT_UNORDERED_LIST_COMMAND, INSERT_ORDERED_LIST_COMMAND, $createListNode, $createListItemNode, registerList } from '@lexical/list';
8
8
  import { $getNearestNodeOfType, $wrapNodeInElement, $lastToFirstIterator, $descendantsMatching, mergeRegister, $insertNodeToNearestRoot, $insertFirst, $unwrapAndFilterDescendants, $firstToLastIterator, $getNearestBlockElementAncestorOrThrow, IS_APPLE } from '@lexical/utils';
9
9
  import { registerPlainText } from '@lexical/plain-text';
10
- import { RichTextExtension, $isQuoteNode, $isHeadingNode, $createHeadingNode, $createQuoteNode, QuoteNode, HeadingNode, registerRichText } from '@lexical/rich-text';
10
+ import { RichTextExtension, $isQuoteNode, QuoteNode, $isHeadingNode, $createHeadingNode, $createQuoteNode, HeadingNode, registerRichText } from '@lexical/rich-text';
11
11
  import { $generateNodesFromDOM, $generateHtmlFromNodes } from '@lexical/html';
12
12
  import { HistoryExtension } from '@lexical/history';
13
13
  import { $isCodeNode, CodeHighlightNode, CodeNode, $createCodeNode, $isCodeHighlightNode, $createCodeHighlightNode, normalizeCodeLang, registerCodeHighlighting, CODE_LANGUAGE_FRIENDLY_NAME_MAP } from '@lexical/code';
@@ -1534,8 +1534,12 @@ function $isSafeForRoot(node) {
1534
1534
  function $makeSafeForRoot(node) {
1535
1535
  if ($isSafeForRoot(node)) {
1536
1536
  return node
1537
- } else {
1537
+ } else if (node.getParent()) {
1538
1538
  return $wrapNodeInElement(node, () => node.createParentElementNode())
1539
+ } else {
1540
+ // Detached nodes (e.g. clipboard nodes being inserted) can't be `replace`d in place,
1541
+ // so append them into a fresh required parent instead.
1542
+ return node.createParentElementNode().append(node)
1539
1543
  }
1540
1544
  }
1541
1545
 
@@ -1723,7 +1727,7 @@ function $splitSelectedParagraphsAtInnerLineBreaks(selection) {
1723
1727
  }
1724
1728
  }
1725
1729
 
1726
- function $expandSelectionToLineBreaksAndSplitAtEdges(selection) {
1730
+ function $expandSelectionToLineBreaksAndSplitAtEdges(selection, fallbackAncestor = (node) => node.getTopLevelElement()) {
1727
1731
  $ensureForwardRangeSelection(selection);
1728
1732
 
1729
1733
  const focusCaret = $caretFromPoint(selection.focus, "next");
@@ -1743,8 +1747,8 @@ function $expandSelectionToLineBreaksAndSplitAtEdges(selection) {
1743
1747
  const focusOuter = focusBrCaret && $splitAroundLineBreak(focusBrCaret);
1744
1748
  const anchorOuter = anchorBrCaret && $splitAroundLineBreak(anchorBrCaret);
1745
1749
 
1746
- const innerStart = anchorOuter?.getNextSibling() ?? selection.anchor.getNode().getTopLevelElement();
1747
- const innerEnd = focusOuter?.getPreviousSibling() ?? selection.focus.getNode().getTopLevelElement();
1750
+ const innerStart = anchorOuter?.getNextSibling() ?? fallbackAncestor(selection.anchor.getNode());
1751
+ const innerEnd = focusOuter?.getPreviousSibling() ?? fallbackAncestor(selection.focus.getNode());
1748
1752
  if (!innerStart || !innerEnd) return
1749
1753
 
1750
1754
  $setSelectionFromCaretRange($getCaretRange(
@@ -1850,6 +1854,49 @@ function $splitAroundLineBreak(lineBreakCaret) {
1850
1854
  return outer
1851
1855
  }
1852
1856
 
1857
+ // Lexical's RangeSelection.insertNodes/insertLineBreak require every selection point to have a
1858
+ // block ancestor with inline children. An element point on a container of block nodes — e.g. a
1859
+ // quote holding paragraphs — has none, so Lexical throws invariant #211 or #212. This detects
1860
+ // such a point so callers can descend it to a leaf before inserting.
1861
+ function $isPointOnBlockContainer(point) {
1862
+ if (point.type !== "element") return false
1863
+
1864
+ const firstChild = point.getNode().getFirstChild();
1865
+ return ($isElementNode(firstChild) || $isDecoratorNode(firstChild)) && !firstChild.isInline()
1866
+ }
1867
+
1868
+ function $hasPointOnBlockContainer(selection) {
1869
+ return $isRangeSelection(selection) &&
1870
+ [ selection.anchor, selection.focus ].some($isPointOnBlockContainer)
1871
+ }
1872
+
1873
+ // Descend any block-container element point in the selection to a leaf position, so a subsequent
1874
+ // Lexical insert (insertNodes, insertLineBreak, INSERT_PARAGRAPH) doesn't throw invariant #211/#212.
1875
+ function $normalizeBlockContainerSelection(selection = $getSelection()) {
1876
+ if (!$hasPointOnBlockContainer(selection)) return false
1877
+
1878
+ $normalizeSelection__EXPERIMENTAL(selection);
1879
+ return true
1880
+ }
1881
+
1882
+ function $consecutiveSiblingGroups(blocks) {
1883
+ const ordered = [ ...blocks ].sort((a, b) => a.getIndexWithinParent() - b.getIndexWithinParent());
1884
+ const groups = [];
1885
+
1886
+ for (const block of ordered) {
1887
+ const lastGroup = groups.at(-1);
1888
+ const previous = lastGroup?.at(-1);
1889
+
1890
+ if (previous && previous.getParent().is(block.getParent()) && previous.getNextSibling()?.is(block)) {
1891
+ lastGroup.push(block);
1892
+ } else {
1893
+ groups.push([ block ]);
1894
+ }
1895
+ }
1896
+
1897
+ return groups
1898
+ }
1899
+
1853
1900
  // Payload: Record<nodeKey, { patch?, replace? }>
1854
1901
  // - patch: plain object, shallow-merged into the existing node's properties
1855
1902
  // - replace: a LexicalNode instance that replaces the node
@@ -3741,6 +3788,16 @@ class CommandDispatcher {
3741
3788
  #registerKeyboardCommands() {
3742
3789
  this.#registerCommandHandler(KEY_ARROW_RIGHT_COMMAND, COMMAND_PRIORITY_NORMAL, this.#handleArrowRightKey.bind(this));
3743
3790
  this.#registerCommandHandler(KEY_TAB_COMMAND, COMMAND_PRIORITY_NORMAL, this.#handleTabKey.bind(this));
3791
+
3792
+ // Run before Lexical's built-in insert handlers to descend an element point on a
3793
+ // block container to a leaf, avoiding error #211 on Enter / Shift+Enter in a quote.
3794
+ this.#registerCommandHandler(INSERT_LINE_BREAK_COMMAND, COMMAND_PRIORITY_HIGH, this.#normalizeBlockContainerSelection.bind(this));
3795
+ this.#registerCommandHandler(INSERT_PARAGRAPH_COMMAND, COMMAND_PRIORITY_HIGH, this.#normalizeBlockContainerSelection.bind(this));
3796
+ }
3797
+
3798
+ #normalizeBlockContainerSelection() {
3799
+ $normalizeBlockContainerSelection();
3800
+ return false
3744
3801
  }
3745
3802
 
3746
3803
  #handleArrowRightKey(event) {
@@ -4013,6 +4070,10 @@ class Selection {
4013
4070
  return this.nearestNodeOfType(ListItemNode)
4014
4071
  }
4015
4072
 
4073
+ get isInsideBlockQuote() {
4074
+ return this.nearestNodeOfType(QuoteNode)
4075
+ }
4076
+
4016
4077
  get isIndentedList() {
4017
4078
  const closestListNode = this.nearestNodeOfType(ListNode);
4018
4079
  return closestListNode && ($getListDepth(closestListNode) > 1)
@@ -4031,9 +4092,12 @@ class Selection {
4031
4092
  }
4032
4093
 
4033
4094
  get isOnPreviewableImage() {
4034
- const selection = $getSelection();
4035
- const firstNode = selection?.getNodes().at(0);
4036
- return $isActionTextAttachmentNode(firstNode) && firstNode.isPreviewableImage
4095
+ return this.previewableImageNode != null
4096
+ }
4097
+
4098
+ get previewableImageNode() {
4099
+ const firstNode = $getSelection()?.getNodes().at(0);
4100
+ return $isActionTextAttachmentNode(firstNode) && firstNode.isPreviewableImage ? firstNode : null
4037
4101
  }
4038
4102
 
4039
4103
  get isAtNodeStart() {
@@ -4437,6 +4501,9 @@ class Selection {
4437
4501
  // - First item (no previous sibling): convert to a paragraph above the
4438
4502
  // list, matching the standard "unwrap list formatting" behavior that
4439
4503
  // users expect from pressing backspace at the start of a list item.
4504
+ // Inside a blockquote we instead just remove the empty item and move
4505
+ // the cursor into the next one — stranding a paragraph there would
4506
+ // leave the blank line the user is trying to close.
4440
4507
  //
4441
4508
  // When the empty item is the last/only one in the list, we return false
4442
4509
  // and let Lexical's default (convert to paragraph) provide the standard
@@ -4455,19 +4522,22 @@ class Selection {
4455
4522
  if (!nextSibling) return false
4456
4523
 
4457
4524
  const previousSibling = listItem.getPreviousSibling();
4525
+ const listNode = $getNearestNodeOfType(listItem, ListNode);
4526
+ if (!listNode) return false
4527
+
4458
4528
  if (previousSibling) {
4459
4529
  previousSibling.selectEnd();
4460
4530
  listItem.remove();
4461
- return true
4531
+ } else if ($isQuoteNode(listNode.getParent())) {
4532
+ nextSibling.selectStart();
4533
+ listItem.remove();
4534
+ } else {
4535
+ const paragraph = $createParagraphNode();
4536
+ listNode.insertBefore(paragraph);
4537
+ listItem.remove();
4538
+ paragraph.selectStart();
4462
4539
  }
4463
4540
 
4464
- const listNode = $getNearestNodeOfType(listItem, ListNode);
4465
- if (!listNode) return false
4466
-
4467
- const paragraph = $createParagraphNode();
4468
- listNode.insertBefore(paragraph);
4469
- listItem.remove();
4470
- paragraph.selectStart();
4471
4541
  return true
4472
4542
  }
4473
4543
 
@@ -5008,7 +5078,10 @@ class GalleryUploader extends Uploader {
5008
5078
 
5009
5079
  #findOrCreateGallery() {
5010
5080
  if (this.selection.isOnPreviewableImage) {
5011
- this.#gallery = $findOrCreateGalleryForImage(this.#selectedNode);
5081
+ // Resolve from the previewable image itself (the selection's first node), not from
5082
+ // #selectedNode (the anchor) — those differ when the selection runs from an image
5083
+ // into following text, and the anchor text node can't join a gallery (returns null).
5084
+ this.#gallery = $findOrCreateGalleryForImage(this.selection.previewableImageNode);
5012
5085
  } else if (this.#selectionIsAfterGalleryEdge) {
5013
5086
  this.#gallery = $findOrCreateGalleryForImage(this.selection.nodeBeforeCursor);
5014
5087
  } else {
@@ -5340,23 +5413,13 @@ function $createActionTextAttachmentUploadNode(...args) {
5340
5413
  return new ActionTextAttachmentUploadNode(...args)
5341
5414
  }
5342
5415
 
5343
- class NodeInserter {
5344
- static for(selection) {
5345
- const INSERTERS = [
5346
- CodeNodeInserter,
5347
- ShadowRootNodeInserter,
5348
- NodeSelectionNodeInserter
5349
- ];
5350
- const Inserter = INSERTERS.find(inserter => inserter.handles(selection));
5351
- return Inserter ? new Inserter(selection) : selection
5352
- }
5353
-
5416
+ class BaseNodeInserter {
5354
5417
  constructor(selection) {
5355
5418
  this.selection = selection;
5356
5419
  }
5357
5420
  }
5358
5421
 
5359
- class CodeNodeInserter extends NodeInserter {
5422
+ class CodeNodeInserter extends BaseNodeInserter {
5360
5423
  static handles(selection) {
5361
5424
  return $getNearestNodeOfType(selection.anchor?.getNode(), CodeNode)
5362
5425
  }
@@ -5371,14 +5434,40 @@ class CodeNodeInserter extends NodeInserter {
5371
5434
 
5372
5435
  const caret = $getChildCaretAtIndex(codeNode, insertionIndex + 1, "previous");
5373
5436
 
5437
+ // Nodes that are already in the document come from the format-toggle path (existing
5438
+ // content converted into this code block). Brand-new nodes (dropped/pasted content)
5439
+ // were never attached.
5440
+ const existingNodes = new Set(nodes.filter(node => node.isAttached()));
5441
+ const trailingNodes = [];
5442
+
5374
5443
  for (const node of nodes) {
5375
- if (!node.isAttached()) continue
5376
- if (caret.getNodeAtCaret() && $isElementNode(node)) { caret.insert($createLineBreakNode()); }
5444
+ if (existingNodes.has(node)) {
5445
+ if (!node.isAttached()) continue // already pulled in when a converted ancestor was removed
5446
+ } else if (!this.#canJoinCodeBlock(node)) {
5447
+ trailingNodes.push(node); // e.g. a dropped attachment, which a code block can't hold
5448
+ continue
5449
+ }
5377
5450
 
5451
+ if (caret.getNodeAtCaret() && $isElementNode(node)) { caret.insert($createLineBreakNode()); }
5378
5452
  caret.insert(this.#convertNodeToCodeChild(node));
5379
5453
  }
5380
5454
 
5381
- caret.getNodeAtCaret().selectEnd();
5455
+ const lastTrailingNode = this.#insertAfterCodeBlock(codeNode, trailingNodes);
5456
+ const nodeToSelect = lastTrailingNode ?? caret.getNodeAtCaret();
5457
+ nodeToSelect?.selectEnd();
5458
+ }
5459
+
5460
+ #canJoinCodeBlock(node) {
5461
+ return $isTextNode(node) || $isLineBreakNode(node)
5462
+ }
5463
+
5464
+ #insertAfterCodeBlock(codeNode, nodes) {
5465
+ let previousNode = codeNode;
5466
+ for (const node of nodes) {
5467
+ previousNode.insertAfter(node);
5468
+ previousNode = node;
5469
+ }
5470
+ return nodes.at(-1)
5382
5471
  }
5383
5472
 
5384
5473
  #convertNodeToCodeChild(node) {
@@ -5389,12 +5478,11 @@ class CodeNodeInserter extends NodeInserter {
5389
5478
  return $createTextNode(node.getTextContent())
5390
5479
  }
5391
5480
  }
5392
-
5393
5481
  }
5394
5482
 
5395
- class ShadowRootNodeInserter extends NodeInserter {
5483
+ class ShadowRootNodeInserter extends BaseNodeInserter {
5396
5484
  static handles(selection) {
5397
- return $isShadowRoot(selection?.anchor.getNode())
5485
+ return $isShadowRoot(selection?.anchor?.getNode())
5398
5486
  }
5399
5487
 
5400
5488
  insertNodes(nodes) {
@@ -5406,19 +5494,173 @@ class ShadowRootNodeInserter extends NodeInserter {
5406
5494
  }
5407
5495
  }
5408
5496
 
5409
- class NodeSelectionNodeInserter extends NodeInserter {
5497
+ class NodeSelectionNodeInserter extends BaseNodeInserter {
5410
5498
  static handles(selection) {
5411
5499
  return $isNodeSelection(selection)
5412
5500
  }
5413
5501
 
5414
5502
  insertNodes(nodes) {
5415
- const selectedNodes = this.selection.getNodes();
5416
-
5417
5503
  // Overrides Lexical's default behavior of _removing_ the currently selected nodes
5418
5504
  // https://github.com/facebook/lexical/blob/v0.38.2/packages/lexical/src/LexicalSelection.ts#L412
5419
- let lastNode = selectedNodes.at(-1);
5505
+ let lastNode = this.selection.getNodes().at(-1);
5506
+
5420
5507
  for (const node of nodes) {
5421
- lastNode = lastNode.insertAfter(node);
5508
+ // Inserting after a top-level node would make this one a root child. Inline nodes
5509
+ // can't live there (Lexical error #99), so wrap them in their required parent first.
5510
+ const nodeToInsert = this.#insertsIntoRoot(lastNode) ? $makeSafeForRoot(node) : node;
5511
+ lastNode = lastNode.insertAfter(nodeToInsert);
5512
+ }
5513
+ }
5514
+
5515
+ #insertsIntoRoot(node) {
5516
+ return node.is(node.getTopLevelElement())
5517
+ }
5518
+ }
5519
+
5520
+ // A list item can only hold inline content, so a block node (such as an image
5521
+ // attachment) dropped into one corrupts the list: Lexical lifts it into the
5522
+ // wrong list item and orphans an empty bullet. Block nodes belong at the top
5523
+ // level instead, splitting the list around the cursor. Inline content keeps
5524
+ // Lexical's default behavior and stays within the list item.
5525
+ class ListItemNodeInserter extends BaseNodeInserter {
5526
+ static handles(selection) {
5527
+ return $isRangeSelection(selection) &&
5528
+ $getNearestNodeOfType(selection.anchor.getNode(), ListItemNode)
5529
+ }
5530
+
5531
+ insertNodes(nodes) {
5532
+ if (nodes.some(node => this.#isBlockDecorator(node))) {
5533
+ this.#insertAroundList(nodes);
5534
+ } else {
5535
+ this.selection.insertNodes(nodes);
5536
+ }
5537
+ }
5538
+
5539
+ #insertAroundList(nodes) {
5540
+ if (!this.selection.isCollapsed()) { this.selection.removeText(); }
5541
+
5542
+ // Break out of any nesting to the outermost list. Splitting an inner list
5543
+ // would leave the block stranded inside an ancestor list item, which is the
5544
+ // very corruption we are avoiding. The block must land at the list's level.
5545
+ const anchorNode = this.selection.anchor.getNode();
5546
+ const outerList = this.#outermostList(anchorNode);
5547
+ const topItem = this.#topLevelItemFor(anchorNode, outerList);
5548
+
5549
+ // A blank top-level bullet is just the insertion point (e.g. the user pressed
5550
+ // Enter to leave the list); break out of it entirely. A bullet with content —
5551
+ // including one wrapping a nested list — splits so its content stays in the list.
5552
+ const splitAfterItem = $isBlankNode(topItem) ? topItem.getPreviousSibling() : topItem;
5553
+ const splitIndex = splitAfterItem ? splitAfterItem.getIndexWithinParent() + 1 : 0;
5554
+ const [ listBefore, listAfter ] = $splitNode(outerList, splitIndex);
5555
+ if ($isBlankNode(topItem)) { topItem.remove(); }
5556
+
5557
+ let anchor = listBefore ?? listAfter;
5558
+ for (const node of nodes) {
5559
+ anchor.insertAfter(node);
5560
+ anchor = node;
5561
+ }
5562
+
5563
+ this.#removeEmptyList(listBefore);
5564
+ this.#removeEmptyList(listAfter);
5565
+ nodes.at(-1).selectNext();
5566
+ }
5567
+
5568
+ #outermostList(node) {
5569
+ return [ node, ...node.getParents() ].reverse().find($isListNode)
5570
+ }
5571
+
5572
+ #topLevelItemFor(node, outerList) {
5573
+ return [ node, ...node.getParents() ].find(ancestor =>
5574
+ $isListItemNode(ancestor) && ancestor.getParent()?.is(outerList)
5575
+ )
5576
+ }
5577
+
5578
+ #removeEmptyList(list) {
5579
+ if ($isListNode(list) && list.isEmpty()) list.remove();
5580
+ }
5581
+
5582
+ // Only block decorator nodes (image/file attachments) are intercepted. A list
5583
+ // item cannot hold them, so they must break out. Pasted element blocks
5584
+ // (paragraphs, quotes) keep Lexical's own list-escape semantics.
5585
+ #isBlockDecorator(node) {
5586
+ return $isDecoratorNode(node) && !node.isInline()
5587
+ }
5588
+ }
5589
+
5590
+ // Lexical's RangeSelection.insertNodes requires every selection point to have a block
5591
+ // ancestor with inline children. An element point on a container of block nodes — e.g.
5592
+ // a quote holding paragraphs — has none, so Lexical throws invariant #211 or #212.
5593
+ // Descend such points to a leaf position before inserting.
5594
+ class BlockContainerNodeInserter extends BaseNodeInserter {
5595
+ static handles(selection) {
5596
+ return $hasPointOnBlockContainer(selection)
5597
+ }
5598
+
5599
+ insertNodes(nodes) {
5600
+ $normalizeSelection__EXPERIMENTAL(this.selection);
5601
+ this.selection.insertNodes(nodes);
5602
+ }
5603
+ }
5604
+
5605
+ const INSERTERS = [
5606
+ CodeNodeInserter,
5607
+ ShadowRootNodeInserter,
5608
+ NodeSelectionNodeInserter,
5609
+ ListItemNodeInserter,
5610
+ BlockContainerNodeInserter
5611
+ ];
5612
+
5613
+ // Defined here rather than on the base class so the base can stay free of any
5614
+ // dependency on its subclasses (they import the base), avoiding an import cycle.
5615
+ BaseNodeInserter.for = (selection) => {
5616
+ const inserterClass = INSERTERS.find(inserter => inserter.handles(selection));
5617
+ return inserterClass ? new inserterClass(selection) : selection
5618
+ };
5619
+
5620
+ class PastedContentFormatter {
5621
+ constructor(doc) {
5622
+ this.doc = doc;
5623
+ }
5624
+
5625
+ format() {
5626
+ this.#unwrapPlaceholderAnchors();
5627
+ this.#stripTableCellColorStyles();
5628
+ this.#stripStrayListChildren();
5629
+ return this.doc
5630
+ }
5631
+
5632
+ // Anchors with non-meaningful hrefs (e.g. "#", "") appear in content copied
5633
+ // from rendered views where mentions and interactive elements are wrapped in
5634
+ // <a href="#"> tags. Unwrap them so their text content pastes as plain text
5635
+ // and real links are preserved.
5636
+ #unwrapPlaceholderAnchors() {
5637
+ for (const anchor of this.doc.querySelectorAll("a")) {
5638
+ const href = anchor.getAttribute("href") || "";
5639
+ if (href === "" || href === "#") {
5640
+ anchor.replaceWith(...anchor.childNodes);
5641
+ }
5642
+ }
5643
+ }
5644
+
5645
+ // Table cells copied from a page inherit the source theme's inline color
5646
+ // styles (e.g. dark-mode backgrounds). Strip them so pasted tables adopt
5647
+ // the current theme instead of carrying stale colors.
5648
+ #stripTableCellColorStyles() {
5649
+ for (const cell of this.doc.querySelectorAll("td, th")) {
5650
+ cell.style.removeProperty("background-color");
5651
+ cell.style.removeProperty("background");
5652
+ cell.style.removeProperty("color");
5653
+ }
5654
+ }
5655
+
5656
+ // Only <li> is a valid child of a list; drop stray <br>/whitespace so the
5657
+ // import doesn't wrap them into an empty leading item.
5658
+ #stripStrayListChildren() {
5659
+ for (const list of this.doc.querySelectorAll("ol, ul")) {
5660
+ for (const child of Array.from(list.childNodes)) {
5661
+ if (child.nodeType === Node.ELEMENT_NODE && child.tagName === "LI") continue
5662
+ list.removeChild(child);
5663
+ }
5422
5664
  }
5423
5665
  }
5424
5666
  }
@@ -5454,7 +5696,7 @@ class Contents {
5454
5696
 
5455
5697
  insertAtCursor(...nodes) {
5456
5698
  const selection = $getSelection() ?? $getRoot().selectEnd();
5457
- const inserter = NodeInserter.for(selection);
5699
+ const inserter = BaseNodeInserter.for(selection);
5458
5700
 
5459
5701
  inserter.insertNodes(nodes);
5460
5702
  }
@@ -5468,7 +5710,7 @@ class Contents {
5468
5710
  const selection = $getSelection();
5469
5711
  if (!$isRangeSelection(selection)) return
5470
5712
 
5471
- $expandSelectionToLineBreaksAndSplitAtEdges(selection);
5713
+ $expandSelectionToLineBreaksAndSplitAtEdges(selection, (node) => $getNearestBlockElementAncestorOrThrow(node));
5472
5714
  $setBlocksType(selection, () => $createParagraphNode());
5473
5715
  }
5474
5716
 
@@ -5481,13 +5723,11 @@ class Contents {
5481
5723
  }
5482
5724
 
5483
5725
  applyUnorderedListFormat() {
5484
- this.#splitParagraphsAtLineBreaksUnlessInsideList();
5485
- this.editor.dispatchCommand(INSERT_UNORDERED_LIST_COMMAND, undefined);
5726
+ this.#applyListFormat("bullet", INSERT_UNORDERED_LIST_COMMAND);
5486
5727
  }
5487
5728
 
5488
5729
  applyOrderedListFormat() {
5489
- this.#splitParagraphsAtLineBreaksUnlessInsideList();
5490
- this.editor.dispatchCommand(INSERT_ORDERED_LIST_COMMAND, undefined);
5730
+ this.#applyListFormat("number", INSERT_ORDERED_LIST_COMMAND);
5491
5731
  }
5492
5732
 
5493
5733
  clearFormatting() {
@@ -5519,7 +5759,7 @@ class Contents {
5519
5759
  blockElements.forEach(node => this.#unwrapCodeBlock(node));
5520
5760
  } else {
5521
5761
  $expandSelectionToLineBreaksAndSplitAtEdges(selection);
5522
- const elements = this.#blockLevelElementsInSelection(selection);
5762
+ const elements = this.#outermostElements(this.#blockLevelElementsInSelection(selection));
5523
5763
  if (elements.length === 0) return
5524
5764
 
5525
5765
  const codeNode = $createCodeNode("plain");
@@ -5575,7 +5815,7 @@ class Contents {
5575
5815
 
5576
5816
  const selection = $getSelection();
5577
5817
  if ($isRangeSelection(selection)) {
5578
- selection.insertNodes([ linkNode ]);
5818
+ BaseNodeInserter.for(selection).insertNodes([ linkNode ]);
5579
5819
  linkNodeKey = linkNode.getKey();
5580
5820
  }
5581
5821
  });
@@ -5655,6 +5895,8 @@ class Contents {
5655
5895
  }
5656
5896
 
5657
5897
  uploadFiles(files, { selectLast } = {}) {
5898
+ if (!this.editorElement) return // Disposed (e.g. on turbo:before-cache); a late drop can still land here
5899
+
5658
5900
  if (!this.editorElement.supportsAttachments) {
5659
5901
  console.warn("This editor does not supports attachments (it's configured with [attachments=false])");
5660
5902
  return
@@ -5768,8 +6010,7 @@ class Contents {
5768
6010
  }
5769
6011
 
5770
6012
  #formatPastedDOM(doc) {
5771
- this.#unwrapPlaceholderAnchors(doc);
5772
- this.#stripTableCellColorStyles(doc);
6013
+ new PastedContentFormatter(doc).format();
5773
6014
  }
5774
6015
 
5775
6016
  #dispatchPastedNodesCommand(nodes) {
@@ -5817,6 +6058,45 @@ class Contents {
5817
6058
  codeNode.remove();
5818
6059
  }
5819
6060
 
6061
+ #applyListFormat(listType, command) {
6062
+ if (this.selection.isInsideBlockQuote) {
6063
+ this.#insertListInsideQuote(listType);
6064
+ } else {
6065
+ this.#splitParagraphsAtLineBreaksUnlessInsideList();
6066
+ this.editor.dispatchCommand(command);
6067
+ }
6068
+ }
6069
+
6070
+ #insertListInsideQuote(listType) {
6071
+ for (const group of $consecutiveSiblingGroups(this.#quotedBlocksInSelection())) {
6072
+ this.#wrapBlocksInList(group, listType);
6073
+ }
6074
+ }
6075
+
6076
+ #quotedBlocksInSelection() {
6077
+ const selection = $getSelection();
6078
+ if (!$isRangeSelection(selection)) return []
6079
+
6080
+ const blocks = this.#outermostElements(this.#blockLevelElementsInSelection(selection));
6081
+ return blocks.filter((block) => $isQuoteNode(block.getParent()))
6082
+ }
6083
+
6084
+ #wrapBlocksInList(blocks, listType) {
6085
+ const list = $createListNode(listType);
6086
+ blocks[0].insertBefore(list);
6087
+
6088
+ for (const block of blocks) {
6089
+ const listItem = $createListItemNode();
6090
+ if ($isListNode(block)) {
6091
+ listItem.append(...block.getChildren().flatMap((item) => item.getChildren()));
6092
+ } else {
6093
+ listItem.append(...block.getChildren());
6094
+ }
6095
+ list.append(listItem);
6096
+ block.remove();
6097
+ }
6098
+ }
6099
+
5820
6100
  #splitParagraphsAtLineBreaksUnlessInsideList() {
5821
6101
  if (this.selection.isInsideList) return
5822
6102
 
@@ -5845,6 +6125,18 @@ class Contents {
5845
6125
  return Array.from(elements)
5846
6126
  }
5847
6127
 
6128
+ // Selections spanning nested structures (a quote and its inner paragraphs,
6129
+ // nested list items) yield both an element and its ancestor. Converting the
6130
+ // ancestor detaches its whole subtree — including a node freshly inserted
6131
+ // inside it — which can leave the selection on removed nodes (Lexical
6132
+ // invariant #19). The outermost elements already cover their descendants'
6133
+ // text content, so keep only those.
6134
+ #outermostElements(elements) {
6135
+ return elements.filter((element) => {
6136
+ return elements.every((other) => other === element || !element.getParents().includes(other))
6137
+ })
6138
+ }
6139
+
5848
6140
  #insertUploadNodes(nodes) {
5849
6141
  if (nodes.every($isActionTextAttachmentNode)) {
5850
6142
  const uploader = Uploader.for(this.editorElement, []);
@@ -5885,30 +6177,6 @@ class Contents {
5885
6177
  node.remove();
5886
6178
  }
5887
6179
 
5888
- // Anchors with non-meaningful hrefs (e.g. "#", "") appear in content copied
5889
- // from rendered views where mentions and interactive elements are wrapped in
5890
- // <a href="#"> tags. Unwrap them so their text content pastes as plain text
5891
- // and real links are preserved.
5892
- #unwrapPlaceholderAnchors(doc) {
5893
- for (const anchor of doc.querySelectorAll("a")) {
5894
- const href = anchor.getAttribute("href") || "";
5895
- if (href === "" || href === "#") {
5896
- anchor.replaceWith(...anchor.childNodes);
5897
- }
5898
- }
5899
- }
5900
-
5901
- // Table cells copied from a page inherit the source theme's inline color
5902
- // styles (e.g. dark-mode backgrounds). Strip them so pasted tables adopt
5903
- // the current theme instead of carrying stale colors.
5904
- #stripTableCellColorStyles(doc) {
5905
- for (const cell of doc.querySelectorAll("td, th")) {
5906
- cell.style.removeProperty("background-color");
5907
- cell.style.removeProperty("background");
5908
- cell.style.removeProperty("color");
5909
- }
5910
- }
5911
-
5912
6180
  #getTextAnchorData() {
5913
6181
  const selection = $getSelection();
5914
6182
  if (!selection || !selection.isCollapsed()) return { anchorNode: null, offset: 0 }
@@ -6154,7 +6422,7 @@ class Clipboard {
6154
6422
  }
6155
6423
 
6156
6424
  const linkNode = $createLinkNode(url).append($createTextNode(url));
6157
- selection.insertNodes([ linkNode ]);
6425
+ BaseNodeInserter.for(selection).insertNodes([ linkNode ]);
6158
6426
 
6159
6427
  $onUpdate(() => this.#dispatchLinkInsertEvent(linkNode.getKey(), { url }));
6160
6428
  }
@@ -7372,12 +7640,23 @@ class EarlyEscapeListItemNode extends ListItemNode {
7372
7640
  return super.insertNewAfter(selection, restoreSelection)
7373
7641
  }
7374
7642
 
7643
+ get #isInBlockquote() {
7644
+ return Boolean($getNearestNodeOfType(this, QuoteNode))
7645
+ }
7646
+
7375
7647
  #shouldEscape(selection) {
7376
- if (!$getNearestNodeOfType(this, QuoteNode)) return false
7377
- if ($isBlankNode(this)) return true
7648
+ if (this.#isInPasteOperation() || !this.#isInBlockquote) {
7649
+ return false
7650
+ } else if ($isBlankNode(this)) {
7651
+ return true
7652
+ } else {
7653
+ const paragraph = $getNearestNodeOfType(selection.anchor.getNode(), ParagraphNode);
7654
+ return paragraph && $isBlankNode(paragraph) && $isListItemNode(paragraph.getParent())
7655
+ }
7656
+ }
7378
7657
 
7379
- const paragraph = $getNearestNodeOfType(selection.anchor.getNode(), ParagraphNode);
7380
- return paragraph && $isBlankNode(paragraph) && $isListItemNode(paragraph.getParent())
7658
+ #isInPasteOperation() {
7659
+ return $hasUpdateTag(PASTE_TAG)
7381
7660
  }
7382
7661
 
7383
7662
  #escapeFromList() {
@@ -7425,7 +7704,7 @@ class FormatEscapeExtension extends LexxyExtension {
7425
7704
  }
7426
7705
 
7427
7706
  get allowedElements() {
7428
- return [ { tag: "li", attributes: [ "value" ] } ]
7707
+ return [ { tag: "ol", attributes: [ "start" ] }, { tag: "li", attributes: [ "value" ] } ]
7429
7708
  }
7430
7709
 
7431
7710
  get lexicalExtension() {
@@ -7609,7 +7888,7 @@ class LexicalEditorElement extends HTMLElement {
7609
7888
  static debug = false
7610
7889
  static commands = [ "bold", "italic", "strikethrough" ]
7611
7890
 
7612
- static observedAttributes = [ "connected", "required" ]
7891
+ static observedAttributes = [ "autocapitalize", "connected", "required" ]
7613
7892
 
7614
7893
  #initialValue = ""
7615
7894
  #previousInternalFormValue = null
@@ -7676,8 +7955,15 @@ class LexicalEditorElement extends HTMLElement {
7676
7955
  }
7677
7956
 
7678
7957
  attributeChangedCallback(name, oldValue, newValue) {
7679
- if (name === "connected") this.connectedChangedCallback(oldValue, newValue);
7680
- if (name === "required") this.requiredChangedCallback(oldValue, newValue);
7958
+ if (typeof this[`${name}ChangedCallback`] === "function") {
7959
+ this[`${name}ChangedCallback`](oldValue, newValue);
7960
+ }
7961
+ }
7962
+
7963
+ autocapitalizeChangedCallback() {
7964
+ if (this.editorContentElement) {
7965
+ this.#transferAttributeToContentEditable(this.editorContentElement, "autocapitalize");
7966
+ }
7681
7967
  }
7682
7968
 
7683
7969
  connectedChangedCallback(oldValue, newValue) {
@@ -8009,25 +8295,33 @@ class LexicalEditorElement extends HTMLElement {
8009
8295
 
8010
8296
  #createEditorContentElement() {
8011
8297
  const editorContentElement = createElement("div", {
8298
+ id: `${this.id}-content`,
8012
8299
  classList: "lexxy-editor__content",
8013
8300
  contenteditable: true,
8014
- autocapitalize: "none",
8015
8301
  role: "textbox",
8016
8302
  "aria-multiline": true,
8017
8303
  "aria-label": this.#labelText,
8018
8304
  placeholder: this.getAttribute("placeholder")
8019
8305
  });
8020
- editorContentElement.id = `${this.id}-content`;
8306
+
8021
8307
  this.#ariaAttributes.forEach(attribute => editorContentElement.setAttribute(attribute.name, attribute.value));
8022
8308
 
8023
- if (this.getAttribute("tabindex")) {
8024
- editorContentElement.setAttribute("tabindex", this.getAttribute("tabindex"));
8025
- this.removeAttribute("tabindex");
8309
+ this.#transferAttributeToContentEditable(editorContentElement, "autocapitalize");
8310
+ this.#transferAttributeToContentEditable(editorContentElement, "tabindex", { defaultValue: 0, removeSource: true });
8311
+
8312
+ return editorContentElement
8313
+ }
8314
+
8315
+ #transferAttributeToContentEditable(element, qualifiedName, { defaultValue = null, removeSource = false } = {}) {
8316
+ if (this.hasAttribute(qualifiedName)) {
8317
+ element.setAttribute(qualifiedName, this.getAttribute(qualifiedName));
8318
+ } else if (defaultValue !== null) {
8319
+ element.setAttribute(qualifiedName, defaultValue);
8026
8320
  } else {
8027
- editorContentElement.setAttribute("tabindex", 0);
8321
+ element.removeAttribute(qualifiedName);
8028
8322
  }
8029
8323
 
8030
- return editorContentElement
8324
+ if (removeSource) this.removeAttribute(qualifiedName);
8031
8325
  }
8032
8326
 
8033
8327
  get #labelText() {
@@ -8644,6 +8938,7 @@ class LexicalPromptElement extends HTMLElement {
8644
8938
  this.source = this.#createSource();
8645
8939
 
8646
8940
  this.#addTriggerListener();
8941
+ this.#removePopoverBeforeTurboCaches();
8647
8942
  this.toggleAttribute("connected", true);
8648
8943
  }
8649
8944
 
@@ -8651,7 +8946,7 @@ class LexicalPromptElement extends HTMLElement {
8651
8946
  this.#popoverListeners.dispose();
8652
8947
  this.#globalListeners.dispose();
8653
8948
  this.source = null;
8654
- this.popoverElement = null;
8949
+ this.#removePopover();
8655
8950
  }
8656
8951
 
8657
8952
 
@@ -8947,6 +9242,21 @@ class LexicalPromptElement extends HTMLElement {
8947
9242
  this.#addTriggerListener();
8948
9243
  }
8949
9244
 
9245
+ // The popover is appended to the <lexxy-editor> subtree, so Turbo serializes it
9246
+ // into the page cache. Removing it before caching prevents an orphaned, unmanaged
9247
+ // popover from being restored on history back/forward.
9248
+ #removePopoverBeforeTurboCaches() {
9249
+ this.#globalListeners.track(
9250
+ registerEventListener(document, "turbo:before-cache", () => this.#removePopover())
9251
+ );
9252
+ }
9253
+
9254
+ #removePopover() {
9255
+ this.#popoverListeners.dispose();
9256
+ this.popoverElement?.remove();
9257
+ this.popoverElement = null;
9258
+ }
9259
+
8950
9260
  #filterOptions = async () => {
8951
9261
  if (this.initialPrompt) {
8952
9262
  this.initialPrompt = false;
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@37signals/lexxy",
3
- "version": "0.9.18",
3
+ "version": "0.9.19-alpha.2",
4
4
  "description": "Lexxy - A modern rich text editor for Rails.",
5
5
  "module": "dist/lexxy.esm.js",
6
6
  "type": "module",
@@ -28,8 +28,8 @@
28
28
  "@rollup/plugin-node-resolve": "^16.0.1",
29
29
  "@rollup/plugin-terser": "^0.4.4",
30
30
  "eslint": "^10.3.0",
31
- "globals": "^17.6.0",
32
31
  "eslint-plugin-compat": "^7.0.2",
32
+ "globals": "^17.6.0",
33
33
  "jsdom": "^27.3.0",
34
34
  "rollup": "^4.44.1",
35
35
  "rollup-plugin-copy": "^3.5.0",
@@ -52,7 +52,8 @@
52
52
  "test:browser:headed": "npx playwright test --config test/browser/playwright.config.js --headed",
53
53
  "test:browser:debug": "npx playwright test --config test/browser/playwright.config.js --debug",
54
54
  "prerelease": "yarn build:npm",
55
- "release": "yarn build:npm && yarn publish"
55
+ "release": "yarn build:npm && yarn publish",
56
+ "release:alpha": "yarn build:npm && yarn publish --tag alpha"
56
57
  },
57
58
  "dependencies": {
58
59
  "@lexical/clipboard": "^0.44.0",