@37signals/lexxy 0.9.19-alpha.1 → 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 +316 -89
  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, $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';
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)
@@ -4440,6 +4501,9 @@ class Selection {
4440
4501
  // - First item (no previous sibling): convert to a paragraph above the
4441
4502
  // list, matching the standard "unwrap list formatting" behavior that
4442
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.
4443
4507
  //
4444
4508
  // When the empty item is the last/only one in the list, we return false
4445
4509
  // and let Lexical's default (convert to paragraph) provide the standard
@@ -4458,19 +4522,22 @@ class Selection {
4458
4522
  if (!nextSibling) return false
4459
4523
 
4460
4524
  const previousSibling = listItem.getPreviousSibling();
4525
+ const listNode = $getNearestNodeOfType(listItem, ListNode);
4526
+ if (!listNode) return false
4527
+
4461
4528
  if (previousSibling) {
4462
4529
  previousSibling.selectEnd();
4463
4530
  listItem.remove();
4464
- 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();
4465
4539
  }
4466
4540
 
4467
- const listNode = $getNearestNodeOfType(listItem, ListNode);
4468
- if (!listNode) return false
4469
-
4470
- const paragraph = $createParagraphNode();
4471
- listNode.insertBefore(paragraph);
4472
- listItem.remove();
4473
- paragraph.selectStart();
4474
4541
  return true
4475
4542
  }
4476
4543
 
@@ -5346,24 +5413,13 @@ function $createActionTextAttachmentUploadNode(...args) {
5346
5413
  return new ActionTextAttachmentUploadNode(...args)
5347
5414
  }
5348
5415
 
5349
- class NodeInserter {
5350
- static for(selection) {
5351
- const INSERTERS = [
5352
- CodeNodeInserter,
5353
- ShadowRootNodeInserter,
5354
- NodeSelectionNodeInserter,
5355
- BlockContainerNodeInserter
5356
- ];
5357
- const Inserter = INSERTERS.find(inserter => inserter.handles(selection));
5358
- return Inserter ? new Inserter(selection) : selection
5359
- }
5360
-
5416
+ class BaseNodeInserter {
5361
5417
  constructor(selection) {
5362
5418
  this.selection = selection;
5363
5419
  }
5364
5420
  }
5365
5421
 
5366
- class CodeNodeInserter extends NodeInserter {
5422
+ class CodeNodeInserter extends BaseNodeInserter {
5367
5423
  static handles(selection) {
5368
5424
  return $getNearestNodeOfType(selection.anchor?.getNode(), CodeNode)
5369
5425
  }
@@ -5422,10 +5478,9 @@ class CodeNodeInserter extends NodeInserter {
5422
5478
  return $createTextNode(node.getTextContent())
5423
5479
  }
5424
5480
  }
5425
-
5426
5481
  }
5427
5482
 
5428
- class ShadowRootNodeInserter extends NodeInserter {
5483
+ class ShadowRootNodeInserter extends BaseNodeInserter {
5429
5484
  static handles(selection) {
5430
5485
  return $isShadowRoot(selection?.anchor?.getNode())
5431
5486
  }
@@ -5439,31 +5494,106 @@ class ShadowRootNodeInserter extends NodeInserter {
5439
5494
  }
5440
5495
  }
5441
5496
 
5442
- class NodeSelectionNodeInserter extends NodeInserter {
5497
+ class NodeSelectionNodeInserter extends BaseNodeInserter {
5443
5498
  static handles(selection) {
5444
5499
  return $isNodeSelection(selection)
5445
5500
  }
5446
5501
 
5447
5502
  insertNodes(nodes) {
5448
- const selectedNodes = this.selection.getNodes();
5449
-
5450
5503
  // Overrides Lexical's default behavior of _removing_ the currently selected nodes
5451
5504
  // https://github.com/facebook/lexical/blob/v0.38.2/packages/lexical/src/LexicalSelection.ts#L412
5452
- let lastNode = selectedNodes.at(-1);
5505
+ let lastNode = this.selection.getNodes().at(-1);
5506
+
5453
5507
  for (const node of nodes) {
5454
- 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);
5455
5512
  }
5456
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
+ }
5457
5588
  }
5458
5589
 
5459
5590
  // Lexical's RangeSelection.insertNodes requires every selection point to have a block
5460
5591
  // ancestor with inline children. An element point on a container of block nodes — e.g.
5461
5592
  // a quote holding paragraphs — has none, so Lexical throws invariant #211 or #212.
5462
5593
  // Descend such points to a leaf position before inserting.
5463
- class BlockContainerNodeInserter extends NodeInserter {
5594
+ class BlockContainerNodeInserter extends BaseNodeInserter {
5464
5595
  static handles(selection) {
5465
- return $isRangeSelection(selection) &&
5466
- [ selection.anchor, selection.focus ].some($isPointOnBlockContainer)
5596
+ return $hasPointOnBlockContainer(selection)
5467
5597
  }
5468
5598
 
5469
5599
  insertNodes(nodes) {
@@ -5472,12 +5602,66 @@ class BlockContainerNodeInserter extends NodeInserter {
5472
5602
  }
5473
5603
  }
5474
5604
 
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
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
+ }
5664
+ }
5481
5665
  }
5482
5666
  }
5483
5667
 
@@ -5512,7 +5696,7 @@ class Contents {
5512
5696
 
5513
5697
  insertAtCursor(...nodes) {
5514
5698
  const selection = $getSelection() ?? $getRoot().selectEnd();
5515
- const inserter = NodeInserter.for(selection);
5699
+ const inserter = BaseNodeInserter.for(selection);
5516
5700
 
5517
5701
  inserter.insertNodes(nodes);
5518
5702
  }
@@ -5526,7 +5710,7 @@ class Contents {
5526
5710
  const selection = $getSelection();
5527
5711
  if (!$isRangeSelection(selection)) return
5528
5712
 
5529
- $expandSelectionToLineBreaksAndSplitAtEdges(selection);
5713
+ $expandSelectionToLineBreaksAndSplitAtEdges(selection, (node) => $getNearestBlockElementAncestorOrThrow(node));
5530
5714
  $setBlocksType(selection, () => $createParagraphNode());
5531
5715
  }
5532
5716
 
@@ -5539,13 +5723,11 @@ class Contents {
5539
5723
  }
5540
5724
 
5541
5725
  applyUnorderedListFormat() {
5542
- this.#splitParagraphsAtLineBreaksUnlessInsideList();
5543
- this.editor.dispatchCommand(INSERT_UNORDERED_LIST_COMMAND, undefined);
5726
+ this.#applyListFormat("bullet", INSERT_UNORDERED_LIST_COMMAND);
5544
5727
  }
5545
5728
 
5546
5729
  applyOrderedListFormat() {
5547
- this.#splitParagraphsAtLineBreaksUnlessInsideList();
5548
- this.editor.dispatchCommand(INSERT_ORDERED_LIST_COMMAND, undefined);
5730
+ this.#applyListFormat("number", INSERT_ORDERED_LIST_COMMAND);
5549
5731
  }
5550
5732
 
5551
5733
  clearFormatting() {
@@ -5633,7 +5815,7 @@ class Contents {
5633
5815
 
5634
5816
  const selection = $getSelection();
5635
5817
  if ($isRangeSelection(selection)) {
5636
- selection.insertNodes([ linkNode ]);
5818
+ BaseNodeInserter.for(selection).insertNodes([ linkNode ]);
5637
5819
  linkNodeKey = linkNode.getKey();
5638
5820
  }
5639
5821
  });
@@ -5828,8 +6010,7 @@ class Contents {
5828
6010
  }
5829
6011
 
5830
6012
  #formatPastedDOM(doc) {
5831
- this.#unwrapPlaceholderAnchors(doc);
5832
- this.#stripTableCellColorStyles(doc);
6013
+ new PastedContentFormatter(doc).format();
5833
6014
  }
5834
6015
 
5835
6016
  #dispatchPastedNodesCommand(nodes) {
@@ -5877,6 +6058,45 @@ class Contents {
5877
6058
  codeNode.remove();
5878
6059
  }
5879
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
+
5880
6100
  #splitParagraphsAtLineBreaksUnlessInsideList() {
5881
6101
  if (this.selection.isInsideList) return
5882
6102
 
@@ -5957,30 +6177,6 @@ class Contents {
5957
6177
  node.remove();
5958
6178
  }
5959
6179
 
5960
- // Anchors with non-meaningful hrefs (e.g. "#", "") appear in content copied
5961
- // from rendered views where mentions and interactive elements are wrapped in
5962
- // <a href="#"> tags. Unwrap them so their text content pastes as plain text
5963
- // and real links are preserved.
5964
- #unwrapPlaceholderAnchors(doc) {
5965
- for (const anchor of doc.querySelectorAll("a")) {
5966
- const href = anchor.getAttribute("href") || "";
5967
- if (href === "" || href === "#") {
5968
- anchor.replaceWith(...anchor.childNodes);
5969
- }
5970
- }
5971
- }
5972
-
5973
- // Table cells copied from a page inherit the source theme's inline color
5974
- // styles (e.g. dark-mode backgrounds). Strip them so pasted tables adopt
5975
- // the current theme instead of carrying stale colors.
5976
- #stripTableCellColorStyles(doc) {
5977
- for (const cell of doc.querySelectorAll("td, th")) {
5978
- cell.style.removeProperty("background-color");
5979
- cell.style.removeProperty("background");
5980
- cell.style.removeProperty("color");
5981
- }
5982
- }
5983
-
5984
6180
  #getTextAnchorData() {
5985
6181
  const selection = $getSelection();
5986
6182
  if (!selection || !selection.isCollapsed()) return { anchorNode: null, offset: 0 }
@@ -6226,7 +6422,7 @@ class Clipboard {
6226
6422
  }
6227
6423
 
6228
6424
  const linkNode = $createLinkNode(url).append($createTextNode(url));
6229
- selection.insertNodes([ linkNode ]);
6425
+ BaseNodeInserter.for(selection).insertNodes([ linkNode ]);
6230
6426
 
6231
6427
  $onUpdate(() => this.#dispatchLinkInsertEvent(linkNode.getKey(), { url }));
6232
6428
  }
@@ -7692,7 +7888,7 @@ class LexicalEditorElement extends HTMLElement {
7692
7888
  static debug = false
7693
7889
  static commands = [ "bold", "italic", "strikethrough" ]
7694
7890
 
7695
- static observedAttributes = [ "connected", "required" ]
7891
+ static observedAttributes = [ "autocapitalize", "connected", "required" ]
7696
7892
 
7697
7893
  #initialValue = ""
7698
7894
  #previousInternalFormValue = null
@@ -7759,8 +7955,15 @@ class LexicalEditorElement extends HTMLElement {
7759
7955
  }
7760
7956
 
7761
7957
  attributeChangedCallback(name, oldValue, newValue) {
7762
- if (name === "connected") this.connectedChangedCallback(oldValue, newValue);
7763
- 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
+ }
7764
7967
  }
7765
7968
 
7766
7969
  connectedChangedCallback(oldValue, newValue) {
@@ -8092,25 +8295,33 @@ class LexicalEditorElement extends HTMLElement {
8092
8295
 
8093
8296
  #createEditorContentElement() {
8094
8297
  const editorContentElement = createElement("div", {
8298
+ id: `${this.id}-content`,
8095
8299
  classList: "lexxy-editor__content",
8096
8300
  contenteditable: true,
8097
- autocapitalize: "none",
8098
8301
  role: "textbox",
8099
8302
  "aria-multiline": true,
8100
8303
  "aria-label": this.#labelText,
8101
8304
  placeholder: this.getAttribute("placeholder")
8102
8305
  });
8103
- editorContentElement.id = `${this.id}-content`;
8306
+
8104
8307
  this.#ariaAttributes.forEach(attribute => editorContentElement.setAttribute(attribute.name, attribute.value));
8105
8308
 
8106
- if (this.getAttribute("tabindex")) {
8107
- editorContentElement.setAttribute("tabindex", this.getAttribute("tabindex"));
8108
- 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);
8109
8320
  } else {
8110
- editorContentElement.setAttribute("tabindex", 0);
8321
+ element.removeAttribute(qualifiedName);
8111
8322
  }
8112
8323
 
8113
- return editorContentElement
8324
+ if (removeSource) this.removeAttribute(qualifiedName);
8114
8325
  }
8115
8326
 
8116
8327
  get #labelText() {
@@ -8727,6 +8938,7 @@ class LexicalPromptElement extends HTMLElement {
8727
8938
  this.source = this.#createSource();
8728
8939
 
8729
8940
  this.#addTriggerListener();
8941
+ this.#removePopoverBeforeTurboCaches();
8730
8942
  this.toggleAttribute("connected", true);
8731
8943
  }
8732
8944
 
@@ -8734,7 +8946,7 @@ class LexicalPromptElement extends HTMLElement {
8734
8946
  this.#popoverListeners.dispose();
8735
8947
  this.#globalListeners.dispose();
8736
8948
  this.source = null;
8737
- this.popoverElement = null;
8949
+ this.#removePopover();
8738
8950
  }
8739
8951
 
8740
8952
 
@@ -9030,6 +9242,21 @@ class LexicalPromptElement extends HTMLElement {
9030
9242
  this.#addTriggerListener();
9031
9243
  }
9032
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
+
9033
9260
  #filterOptions = async () => {
9034
9261
  if (this.initialPrompt) {
9035
9262
  this.initialPrompt = false;
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@37signals/lexxy",
3
- "version": "0.9.19-alpha.1",
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",