@37signals/lexxy 0.9.12-beta → 0.9.14-beta

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/README.md CHANGED
@@ -2,9 +2,6 @@
2
2
 
3
3
  A modern rich text editor for Rails.
4
4
 
5
- > [!IMPORTANT]
6
- > This is a beta. It hasn't been battle-tested yet. Please try it out and report any issues you find.
7
-
8
5
  **[Try it out!](https://basecamp.github.io/lexxy/try-it)**
9
6
 
10
7
  ## Features
package/dist/lexxy.esm.js CHANGED
@@ -1,7 +1,7 @@
1
1
  export { highlightCode } 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, $isElementNode, $isRootOrShadowRoot, $isRootNode, $createNodeSelection, $isDecoratorNode, $isLineBreakNode, $isTextNode, $isParagraphNode, $splitNode, $getSiblingCaret, LineBreakNode, $createParagraphNode, $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, $getRoot, KEY_ARROW_LEFT_COMMAND, KEY_ARROW_UP_COMMAND, KEY_ARROW_DOWN_COMMAND, DELETE_CHARACTER_COMMAND, SELECTION_CHANGE_COMMAND, CLICK_COMMAND, isDOMNode, $getNearestNodeFromDOMNode, $addUpdateTag, ElementNode, $getChildCaretAtIndex, $createLineBreakNode, PASTE_COMMAND, SELECTION_INSERT_CLIPBOARD_NODES_COMMAND, ParagraphNode, RootNode, COMMAND_PRIORITY_HIGH, DRAGSTART_COMMAND, DROP_COMMAND, INSERT_PARAGRAPH_COMMAND, mergeRegister as mergeRegister$1, CLEAR_HISTORY_COMMAND, $onUpdate, KEY_ENTER_COMMAND, COMMAND_PRIORITY_CRITICAL, KEY_SPACE_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, $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, $getRoot, 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, PASTE_COMMAND, SELECTION_INSERT_CLIPBOARD_NODES_COMMAND, ParagraphNode, RootNode, COMMAND_PRIORITY_HIGH, DRAGSTART_COMMAND, DROP_COMMAND, INSERT_PARAGRAPH_COMMAND, mergeRegister as mergeRegister$1, CLEAR_HISTORY_COMMAND, $onUpdate, 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';
@@ -484,6 +484,14 @@ class LexicalToolbarElement extends HTMLElement {
484
484
  });
485
485
  }
486
486
 
487
+ closeDropdowns({ except } = {}) {
488
+ this.#dropdowns.forEach((dropdown) => {
489
+ if (dropdown !== except) {
490
+ dropdown.close({ focusEditor: false });
491
+ }
492
+ });
493
+ }
494
+
487
495
  #reconnect() {
488
496
  this.disconnectedCallback();
489
497
  this.connectedCallback();
@@ -611,14 +619,14 @@ class LexicalToolbarElement extends HTMLElement {
611
619
 
612
620
  this.#setButtonPressed("bold", isBold);
613
621
  this.#setButtonPressed("italic", isItalic);
622
+ this.#setButtonPressed("strikethrough", isStrikethrough);
623
+ this.#setButtonPressed("underline", isUnderline);
614
624
 
615
- this.#setButtonPressed("format", isInHeading || isStrikethrough || isUnderline);
625
+ this.#setButtonPressed("format", isInHeading);
616
626
  this.#setButtonPressed("paragraph", !isInHeading);
617
627
  this.#setButtonPressed("heading-large", headingTag === "h2");
618
628
  this.#setButtonPressed("heading-medium", headingTag === "h3");
619
629
  this.#setButtonPressed("heading-small", headingTag === "h4");
620
- this.#setButtonPressed("strikethrough", isStrikethrough);
621
- this.#setButtonPressed("underline", isUnderline);
622
630
 
623
631
  this.#setButtonPressed("lists", isInList);
624
632
  this.#setButtonPressed("unordered-list", isInList && listType === "bullet");
@@ -656,64 +664,38 @@ class LexicalToolbarElement extends HTMLElement {
656
664
  }
657
665
 
658
666
  #refreshOverflow() {
667
+ this.#hideOverflowMenuButton();
659
668
  this.#resetToolbarOverflow();
660
669
  this.#reindexToolbarItems();
661
670
  this.#compactMenu();
662
671
 
663
- const isOverflowing = this.#overflowMenu.children.length > 0;
672
+ const isOverflowing = this.#overflowMenuDropdown.children.length > 0;
664
673
 
665
674
  this.toggleAttribute("overflowing", isOverflowing);
666
-
667
- this.#overflow.style.display = isOverflowing ? "block" : "none";
668
- this.#overflow.setAttribute("nonce", getNonce());
669
-
670
- this.#overflowMenu.toggleAttribute("disabled", !isOverflowing);
671
- }
672
-
673
- // Separates layout reads from DOM writes to avoid forced reflows during init.
674
- // Measures every button's right edge in a single read pass, figures out which
675
- // buttons overflow using math, and then moves them in a single write pass.
676
- #compactMenu() {
677
- const buttons = this.#overflowButtons;
678
- if (buttons.length === 0) return
679
-
680
- const availableWidth = this.clientWidth;
681
- const buttonRightEdges = buttons.map(button => {
682
- const style = window.getComputedStyle(button);
683
- return button.offsetLeft + button.offsetWidth + parseFloat(style.marginRight)
684
- });
685
-
686
- let firstOverflowing = -1;
687
- for (let i = 0; i < buttons.length; i++) {
688
- if (buttonRightEdges[i] > availableWidth) {
689
- firstOverflowing = i;
690
- break
691
- }
692
- }
693
-
694
- if (firstOverflowing === -1) return
695
-
696
- // Move one extra button to reserve space for the overflow control, which is
697
- // `display: none` until we show it
698
- const overflowIndex = Math.max(0, firstOverflowing - 1);
699
- const overflowButtons = buttons.slice(overflowIndex).reverse();
700
- for (const button of overflowButtons) {
701
- this.#overflowMenu.prepend(button);
702
- button.role = "menuitem";
703
- }
675
+ this.#setOverflowMenuNonce();
676
+ this.#showOverflowMenuButton(isOverflowing);
704
677
  }
705
678
 
706
679
  #resetToolbarOverflow() {
707
- const items = Array.from(this.#overflowMenu.children);
680
+ const items = Array.from(this.#overflowMenuDropdown.children);
708
681
  items.sort((a, b) => this.#itemPosition(b) - this.#itemPosition(a));
709
682
 
710
683
  for (const item of items) {
711
- const nextItem = this.querySelector(`[data-position="${this.#itemPosition(item) + 1}"]`) ?? this.#overflow;
684
+ const nextItem = this.querySelector(`[data-position="${this.#itemPosition(item) + 1}"]`) ?? this.#overflowMenuButton;
712
685
  item.removeAttribute("role");
713
686
  this.insertBefore(item, nextItem);
714
687
  }
715
688
  }
716
689
 
690
+ #showOverflowMenuButton(show = true) {
691
+ this.#overflowMenuDropdown.toggleAttribute("disabled", !show);
692
+ this.#overflowMenuButton.style.display = show ? "block" : "none";
693
+ }
694
+
695
+ #hideOverflowMenuButton() {
696
+ this.#showOverflowMenuButton(false);
697
+ }
698
+
717
699
  #itemPosition(item) {
718
700
  return parseInt(item.dataset.position ?? "999")
719
701
  }
@@ -724,28 +706,59 @@ class LexicalToolbarElement extends HTMLElement {
724
706
  });
725
707
  }
726
708
 
727
- closeDropdowns({ except } = {}) {
728
- this.#dropdowns.forEach((dropdown) => {
729
- if (dropdown !== except) {
730
- dropdown.close({ focusEditor: false });
731
- }
732
- });
709
+ #compactMenu() {
710
+ const overflowWidth = this.#getOverflowWidth();
711
+
712
+ if (overflowWidth > 0) {
713
+ this.#showOverflowMenuButton();
714
+ const gap = this.#getToolbarGap();
715
+ const spaceForOverflow = gap + this.#overflowMenuButton.offsetWidth;
716
+ this.#reclaimWidth(overflowWidth + spaceForOverflow, { gap });
717
+ }
718
+ }
719
+
720
+ #getOverflowWidth() {
721
+ return this.scrollWidth - this.clientWidth
722
+ }
723
+
724
+ #reclaimWidth(overflowWidth, { gap }) {
725
+ const buttons = this.#overflowableButtons;
726
+ const overflowButtons = [];
727
+ let recoveredWidth = 0;
728
+
729
+ while (recoveredWidth < overflowWidth && buttons.length) {
730
+ const button = buttons.pop();
731
+
732
+ overflowButtons.push(button);
733
+ button.role = "menuitem";
734
+ recoveredWidth += button.offsetWidth + gap;
735
+ }
736
+
737
+ this.#overflowMenuDropdown.append(...overflowButtons.reverse());
738
+ }
739
+
740
+ #setOverflowMenuNonce() {
741
+ this.#overflowMenuButton.setAttribute("nonce", getNonce());
742
+ }
743
+
744
+ #getToolbarGap() {
745
+ return parseFloat(window.getComputedStyle(this).columnGap) || 0
733
746
  }
734
747
 
735
748
  get #dropdowns() {
736
749
  return this.querySelectorAll(":scope .lexxy-editor__toolbar-dropdown")
737
750
  }
738
751
 
739
- get #overflow() {
752
+ get #overflowMenuButton() {
740
753
  return this.querySelector(".lexxy-editor__toolbar-overflow")
741
754
  }
742
755
 
743
- get #overflowMenu() {
744
- return this.#overflow?.querySelector(":scope > [data-dropdown-panel]")
756
+ get #overflowMenuDropdown() {
757
+ return this.#overflowMenuButton?.querySelector(":scope > [data-dropdown-panel]")
745
758
  }
746
759
 
747
- get #overflowButtons() {
748
- return Array.from(this.querySelectorAll(":scope > button:not([data-prevent-overflow='true'])"))
760
+ get #overflowableButtons() {
761
+ return Array.from(this.querySelectorAll(":scope > button:not([data-prevent-overflow])"))
749
762
  }
750
763
 
751
764
  get #buttons() {
@@ -776,6 +789,14 @@ class LexicalToolbarElement extends HTMLElement {
776
789
  ${ToolbarIcons.italic}
777
790
  </button>
778
791
 
792
+ <button class="lexxy-editor__toolbar-button" type="button" name="strikethrough" data-command="strikethrough" title="Strikethrough">
793
+ ${ToolbarIcons.strikethrough}
794
+ </button>
795
+
796
+ <button class="lexxy-editor__toolbar-button lexxy-editor__toolbar-group-end" type="button" name="underline" data-command="underline" title="Underline">
797
+ ${ToolbarIcons.underline}
798
+ </button>
799
+
779
800
  <lexxy-toolbar-dropdown class="lexxy-editor__toolbar-dropdown">
780
801
  <button data-dropdown-trigger class="lexxy-editor__toolbar-button lexxy-editor__toolbar-button--chevron" type="button" name="format" title="Text formatting" aria-haspopup="menu" aria-expanded="false">
781
802
  ${ToolbarIcons.heading}
@@ -794,13 +815,6 @@ class LexicalToolbarElement extends HTMLElement {
794
815
  ${ToolbarIcons.h4} <span>Small Heading</span>
795
816
  </button>
796
817
  <div class="lexxy-editor__toolbar-separator" role="separator"></div>
797
- <button type="button" name="strikethrough" data-command="strikethrough" title="Strikethrough" role="menuitem">
798
- ${ToolbarIcons.strikethrough} <span>Strikethrough</span>
799
- </button>
800
- <button type="button" name="underline" data-command="underline" title="Underline" role="menuitem">
801
- ${ToolbarIcons.underline} <span>Underline</span>
802
- </button>
803
- <div class="lexxy-editor__toolbar-separator" role="separator"></div>
804
818
  <button type="button" name="clear-formatting" data-command="clearFormatting" title="Clear formatting" role="menuitem">
805
819
  ${ToolbarIcons.clearFormatting} <span>Clear formatting</span>
806
820
  </button>
@@ -1486,6 +1500,10 @@ class LexxyExtension {
1486
1500
 
1487
1501
  }
1488
1502
 
1503
+ setEditorValidity(flags, message) {
1504
+ this.editorElement.setElementValidity(this, flags, message);
1505
+ }
1506
+
1489
1507
  dispose() {
1490
1508
  }
1491
1509
  }
@@ -1638,40 +1656,166 @@ function isAttachmentSpacerTextNode(node, previousNode, index, childCount) {
1638
1656
  && previousNode instanceof CustomActionTextAttachmentNode
1639
1657
  }
1640
1658
 
1641
- function $splitParagraphsAtLineBreakBoundaries(selection) {
1659
+ function $splitSelectedParagraphsAtInnerLineBreaks(selection) {
1660
+ const topLevelElements = new Set();
1661
+ for (const node of selection.getNodes()) {
1662
+ const topLevel = node.getTopLevelElement();
1663
+ if (topLevel) topLevelElements.add(topLevel);
1664
+ }
1665
+
1666
+ for (const element of topLevelElements) {
1667
+ if (!$isParagraphNode(element)) continue
1668
+
1669
+ const children = element.getChildren();
1670
+ if (!children.some($isLineBreakNode)) continue
1671
+
1672
+ const groups = [ [] ];
1673
+ for (const child of children) {
1674
+ if ($isLineBreakNode(child)) {
1675
+ groups.push([]);
1676
+ child.remove();
1677
+ } else {
1678
+ groups[groups.length - 1].push(child);
1679
+ }
1680
+ }
1681
+
1682
+ for (const group of groups) {
1683
+ if (group.length === 0) continue
1684
+ const paragraph = $createParagraphNode();
1685
+ group.forEach(child => paragraph.append(child));
1686
+ element.insertBefore(paragraph);
1687
+ }
1688
+ if (groups.some(group => group.length > 0)) element.remove();
1689
+ }
1690
+ }
1691
+
1692
+ function $expandSelectionToLineBreaksAndSplitAtEdges(selection) {
1642
1693
  $ensureForwardRangeSelection(selection);
1643
1694
 
1644
- // Split focus first so the anchor split position stays valid.
1645
- $splitAtNearestLineBreak(selection.focus, "next");
1646
- $splitAtNearestLineBreak(selection.anchor, "previous");
1695
+ const focusCaret = $caretFromPoint(selection.focus, "next");
1696
+ const anchorCaret = $caretFromPoint(selection.anchor, "previous");
1697
+
1698
+ // A collapsed cursor adjacent to a <br> would claim it from both sides via
1699
+ // inward-edge; force outward-only walks so each side finds its own boundary.
1700
+ const skipInwardEdge = selection.isCollapsed();
1701
+ const focusBrCaret = $getCaretAtLineBreakBoundary(focusCaret, skipInwardEdge);
1702
+ let anchorBrCaret = $getCaretAtLineBreakBoundary(anchorCaret, skipInwardEdge);
1703
+
1704
+ if (focusBrCaret?.origin.is(anchorBrCaret?.origin)) {
1705
+ anchorBrCaret = null;
1706
+ }
1707
+
1708
+ // Splitting focus first keeps the anchor <br>'s position stable.
1709
+ const focusOuter = focusBrCaret && $splitAroundLineBreak(focusBrCaret);
1710
+ const anchorOuter = anchorBrCaret && $splitAroundLineBreak(anchorBrCaret);
1711
+
1712
+ const innerStart = anchorOuter?.getNextSibling() ?? selection.anchor.getNode().getTopLevelElement();
1713
+ const innerEnd = focusOuter?.getPreviousSibling() ?? selection.focus.getNode().getTopLevelElement();
1714
+ if (!innerStart || !innerEnd) return
1715
+
1716
+ $setSelectionFromCaretRange($getCaretRange(
1717
+ $normalizeCaret($getChildCaret(innerStart, "next")),
1718
+ $getCaretInDirection(
1719
+ $normalizeCaret($getChildCaret(innerEnd, "previous")),
1720
+ "next",
1721
+ ),
1722
+ ));
1647
1723
  }
1648
1724
 
1649
- function $splitAtNearestLineBreak(point, direction) {
1650
- const paragraph = point.getNode().getTopLevelElement();
1651
- if (!paragraph || !$isParagraphNode(paragraph)) return
1725
+ function $getCaretAtLineBreakBoundary(caret, skipInwardEdge = false) {
1726
+ const paragraph = caret.origin.getTopLevelElement();
1727
+ if (!paragraph || !$isParagraphNode(paragraph)) return null
1652
1728
 
1653
- const pointNode = point.getNode();
1654
- const selectionChild = pointNode.getParent().is(paragraph) ? pointNode : pointNode.getParentOrThrow();
1655
- const lineBreakCaret = $caretAtNearestNodeOfType(selectionChild, LineBreakNode, direction);
1656
- if (!lineBreakCaret) return
1729
+ const lineBreak = (skipInwardEdge ? null : $inwardEdgeLineBreak(caret, paragraph))
1730
+ ?? $outwardLineBreak(caret, paragraph);
1657
1731
 
1658
- const lineBreak = lineBreakCaret.origin;
1659
- const isEdge = lineBreakCaret.getNodeAtCaret() === null;
1732
+ return lineBreak ? $getSiblingCaret(lineBreak, caret.direction) : null
1733
+ }
1734
+
1735
+ // Prefer a <br> the cursor is sitting flush against, except when a further <br>
1736
+ // also exists outward — that one is the real paragraph break for this side.
1737
+ function $inwardEdgeLineBreak(caret, paragraph) {
1738
+ let candidateCaret;
1660
1739
 
1661
- if (!isEdge) {
1662
- $splitNode(paragraph, lineBreak.getIndexWithinParent());
1740
+ if (
1741
+ ($isChildCaret(caret) && caret.origin.is(paragraph)) ||
1742
+ ($isTextPointCaret(caret) && $isExtendableTextPointCaret(caret.getFlipped()))
1743
+ ) {
1744
+ candidateCaret = null;
1745
+ } else if ($isSiblingCaret(caret) && caret.getParentAtCaret().is(paragraph)) {
1746
+ candidateCaret = caret;
1747
+ } else {
1748
+ const childCaret = $paragraphChildCaretAtInwardEdge(caret, paragraph);
1749
+ candidateCaret = childCaret ? $rewindSiblingCaret(childCaret) : null;
1663
1750
  }
1664
1751
 
1665
- lineBreak.remove();
1752
+ if (candidateCaret && $isLineBreakNode(candidateCaret.origin)) {
1753
+ return $candidateUnlessShadowed(candidateCaret)
1754
+ } else {
1755
+ return null
1756
+ }
1666
1757
  }
1667
1758
 
1668
- function $caretAtNearestNodeOfType(node, klass, direction) {
1669
- for (const caret of $getSiblingCaret(node, direction)) {
1670
- if (caret.origin instanceof klass) return caret
1759
+ function $candidateUnlessShadowed(candidateCaret) {
1760
+ const outward = candidateCaret.getNodeAtCaret();
1761
+ return $isLineBreakNode(outward) ? null : candidateCaret.origin
1762
+ }
1763
+
1764
+ function $outwardLineBreak(caret, paragraph) {
1765
+ const startCaret = $outwardWalkStartCaret(caret, paragraph);
1766
+ if (!startCaret) return null
1767
+
1768
+ for (const { origin } of startCaret) {
1769
+ if (!origin.getParent().is(paragraph)) break
1770
+ if ($isLineBreakNode(origin)) return origin
1671
1771
  }
1672
1772
  return null
1673
1773
  }
1674
1774
 
1775
+ function $outwardWalkStartCaret(caret, paragraph) {
1776
+ if (caret.getParentAtCaret().is(paragraph)) {
1777
+ return caret
1778
+ } else {
1779
+ return $paragraphChildCaretContaining(caret, paragraph)
1780
+ }
1781
+ }
1782
+
1783
+ function $paragraphChildCaretContaining(caret, paragraph) {
1784
+ let cursor = caret.getSiblingCaret();
1785
+ while (cursor && !cursor.origin.getParent()?.is(paragraph)) {
1786
+ cursor = cursor.getParentCaret();
1787
+ }
1788
+ return cursor?.origin.getParent()?.is(paragraph) ? cursor : null
1789
+ }
1790
+
1791
+ // Only succeeds when the cursor is flush against the inward edge of every
1792
+ // ancestor between itself and the paragraph child.
1793
+ function $paragraphChildCaretAtInwardEdge(caret, paragraph) {
1794
+ let cursor = caret.getSiblingCaret();
1795
+ while (cursor && !cursor.origin.getParent()?.is(paragraph)) {
1796
+ if (cursor.getNodeAtCaret()) return null
1797
+ cursor = cursor.getParentCaret();
1798
+ }
1799
+ return cursor?.origin.getParent()?.is(paragraph) ? cursor : null
1800
+ }
1801
+
1802
+ function $splitAroundLineBreak(lineBreakCaret) {
1803
+ let outer = null;
1804
+
1805
+ if (lineBreakCaret.getNodeAtCaret() === null) {
1806
+ lineBreakCaret.origin.remove();
1807
+ } else {
1808
+ const lineBreak = lineBreakCaret.origin;
1809
+ const splitCaret = $getCaretInDirection($rewindSiblingCaret(lineBreakCaret), "next");
1810
+
1811
+ $splitAtPointCaretNext(splitCaret);
1812
+ outer = lineBreak.getTopLevelElement();
1813
+ lineBreak.remove();
1814
+ }
1815
+
1816
+ return outer
1817
+ }
1818
+
1675
1819
  // Payload: Record<nodeKey, { patch?, replace? }>
1676
1820
  // - patch: plain object, shallow-merged into the existing node's properties
1677
1821
  // - replace: a LexicalNode instance that replaces the node
@@ -3548,12 +3692,18 @@ class CommandDispatcher {
3548
3692
  #registerDragAndDropHandlers() {
3549
3693
  if (this.editorElement.supportsAttachments) {
3550
3694
  this.dragCounter = 0;
3551
- const root = this.editor.getRootElement();
3552
3695
  this.#listeners.track(
3553
- registerEventListener(root, "dragover", this.#handleDragOver.bind(this)),
3554
- registerEventListener(root, "drop", this.#handleDrop.bind(this)),
3555
- registerEventListener(root, "dragenter", this.#handleDragEnter.bind(this)),
3556
- registerEventListener(root, "dragleave", this.#handleDragLeave.bind(this))
3696
+ this.editor.registerRootListener((rootElement) => {
3697
+ if (rootElement) {
3698
+ const teardowns = [
3699
+ registerEventListener(rootElement, "dragover", this.#handleDragOver.bind(this)),
3700
+ registerEventListener(rootElement, "drop", this.#handleDrop.bind(this)),
3701
+ registerEventListener(rootElement, "dragenter", this.#handleDragEnter.bind(this)),
3702
+ registerEventListener(rootElement, "dragleave", this.#handleDragLeave.bind(this))
3703
+ ];
3704
+ return () => teardowns.forEach((teardown) => teardown())
3705
+ }
3706
+ })
3557
3707
  );
3558
3708
  }
3559
3709
  }
@@ -3723,32 +3873,6 @@ class Selection {
3723
3873
  return { node: null, offset: 0 }
3724
3874
  }
3725
3875
 
3726
- preservingSelection(fn) {
3727
- let selectionState = null;
3728
-
3729
- this.editor.getEditorState().read(() => {
3730
- const selection = $getSelection();
3731
- if (selection && $isRangeSelection(selection)) {
3732
- selectionState = {
3733
- anchor: { key: selection.anchor.key, offset: selection.anchor.offset },
3734
- focus: { key: selection.focus.key, offset: selection.focus.offset }
3735
- };
3736
- }
3737
- });
3738
-
3739
- fn();
3740
-
3741
- if (selectionState) {
3742
- this.editor.update(() => {
3743
- const selection = $getSelection();
3744
- if (selection && $isRangeSelection(selection)) {
3745
- selection.anchor.set(selectionState.anchor.key, selectionState.anchor.offset, "text");
3746
- selection.focus.set(selectionState.focus.key, selectionState.focus.offset, "text");
3747
- }
3748
- });
3749
- }
3750
- }
3751
-
3752
3876
  getFormat() {
3753
3877
  const selection = $getSelection();
3754
3878
  if (!$isRangeSelection(selection)) return {}
@@ -4013,46 +4137,59 @@ class Selection {
4013
4137
  return $isDecoratorNode(targetNode) && this.#selectInLexical(targetNode)
4014
4138
  }, COMMAND_PRIORITY_LOW));
4015
4139
 
4016
- const rootElement = this.editor.getRootElement();
4017
4140
  this.#listeners.track(
4018
- registerEventListener(rootElement, "lexxy:internal:move-to-next-line", () => this.#selectOrAppendNextLine())
4141
+ this.editor.registerRootListener((rootElement) => {
4142
+ if (rootElement) {
4143
+ return registerEventListener(rootElement, "lexxy:internal:move-to-next-line", () => this.#selectOrAppendNextLine())
4144
+ }
4145
+ })
4019
4146
  );
4020
4147
  }
4021
4148
 
4022
4149
  #containEditorFocus() {
4023
4150
  // Workaround for a bizarre Chrome bug where the cursor abandons the editor to focus on not-focusable elements
4024
4151
  // above when navigating UP/DOWN when Lexical shows its fake cursor on custom decorator nodes.
4025
- this.editorContentElement.addEventListener("keydown", (event) => {
4026
- if (event.key === "ArrowUp") {
4027
- const lexicalCursor = this.editor.getRootElement().querySelector("[data-lexical-cursor]");
4028
-
4029
- if (lexicalCursor) {
4030
- let currentElement = lexicalCursor.previousElementSibling;
4031
- while (currentElement && currentElement.hasAttribute("data-lexical-cursor")) {
4032
- currentElement = currentElement.previousElementSibling;
4033
- }
4152
+ this.#listeners.track(
4153
+ this.editor.registerRootListener((rootElement) => {
4154
+ if (rootElement) {
4155
+ const handler = (event) => this.#handleArrowKeyOnLexicalCursor(event);
4156
+ rootElement.addEventListener("keydown", handler, true);
4157
+ return () => rootElement.removeEventListener("keydown", handler, true)
4158
+ }
4159
+ })
4160
+ );
4161
+ }
4034
4162
 
4035
- if (!currentElement) {
4036
- event.preventDefault();
4037
- }
4163
+ #handleArrowKeyOnLexicalCursor(event) {
4164
+ if (event.key === "ArrowUp") {
4165
+ const lexicalCursor = this.editor.getRootElement().querySelector("[data-lexical-cursor]");
4166
+
4167
+ if (lexicalCursor) {
4168
+ let currentElement = lexicalCursor.previousElementSibling;
4169
+ while (currentElement && currentElement.hasAttribute("data-lexical-cursor")) {
4170
+ currentElement = currentElement.previousElementSibling;
4171
+ }
4172
+
4173
+ if (!currentElement) {
4174
+ event.preventDefault();
4038
4175
  }
4039
4176
  }
4177
+ }
4040
4178
 
4041
- if (event.key === "ArrowDown") {
4042
- const lexicalCursor = this.editor.getRootElement().querySelector("[data-lexical-cursor]");
4179
+ if (event.key === "ArrowDown") {
4180
+ const lexicalCursor = this.editor.getRootElement().querySelector("[data-lexical-cursor]");
4043
4181
 
4044
- if (lexicalCursor) {
4045
- let currentElement = lexicalCursor.nextElementSibling;
4046
- while (currentElement && currentElement.hasAttribute("data-lexical-cursor")) {
4047
- currentElement = currentElement.nextElementSibling;
4048
- }
4182
+ if (lexicalCursor) {
4183
+ let currentElement = lexicalCursor.nextElementSibling;
4184
+ while (currentElement && currentElement.hasAttribute("data-lexical-cursor")) {
4185
+ currentElement = currentElement.nextElementSibling;
4186
+ }
4049
4187
 
4050
- if (!currentElement) {
4051
- event.preventDefault();
4052
- }
4188
+ if (!currentElement) {
4189
+ event.preventDefault();
4053
4190
  }
4054
4191
  }
4055
- }, true);
4192
+ }
4056
4193
  }
4057
4194
 
4058
4195
  #syncSelectedClasses() {
@@ -5263,6 +5400,7 @@ class Contents {
5263
5400
  const selection = $getSelection();
5264
5401
  if (!$isRangeSelection(selection)) return
5265
5402
 
5403
+ $expandSelectionToLineBreaksAndSplitAtEdges(selection);
5266
5404
  $setBlocksType(selection, () => $createParagraphNode());
5267
5405
  }
5268
5406
 
@@ -5270,6 +5408,7 @@ class Contents {
5270
5408
  const selection = $getSelection();
5271
5409
  if (!$isRangeSelection(selection)) return
5272
5410
 
5411
+ $expandSelectionToLineBreaksAndSplitAtEdges(selection);
5273
5412
  $setBlocksType(selection, () => $createHeadingNode(tag));
5274
5413
  }
5275
5414
 
@@ -5311,10 +5450,14 @@ class Contents {
5311
5450
  if (allCode) {
5312
5451
  blockElements.forEach(node => this.#unwrapCodeBlock(node));
5313
5452
  } else {
5453
+ $expandSelectionToLineBreaksAndSplitAtEdges(selection);
5454
+ const elements = this.#blockLevelElementsInSelection(selection);
5455
+ if (elements.length === 0) return
5456
+
5314
5457
  const codeNode = $createCodeNode("plain");
5315
- blockElements.at(-1).insertAfter(codeNode);
5458
+ elements.at(-1).insertAfter(codeNode);
5316
5459
  codeNode.selectEnd();
5317
- this.insertAtCursor(...blockElements);
5460
+ this.insertAtCursor(...elements);
5318
5461
  }
5319
5462
  }
5320
5463
 
@@ -5333,8 +5476,7 @@ class Contents {
5333
5476
  } else {
5334
5477
  topLevelElements.filter($isQuoteNode).forEach(node => this.#unwrap(node));
5335
5478
 
5336
- $splitParagraphsAtLineBreakBoundaries(selection);
5337
-
5479
+ $expandSelectionToLineBreaksAndSplitAtEdges(selection);
5338
5480
  const elements = this.#topLevelElementsInSelection(selection);
5339
5481
  if (elements.length === 0) return
5340
5482
 
@@ -5602,45 +5744,8 @@ class Contents {
5602
5744
  const selection = $getSelection();
5603
5745
  if (!$isRangeSelection(selection)) return
5604
5746
 
5605
- this.#splitParagraphsAtLineBreaks(selection);
5606
- }
5607
-
5608
- #splitParagraphsAtLineBreaks(selection) {
5609
- const anchorTopLevel = selection.anchor.getNode().getTopLevelElement();
5610
- const focusTopLevel = selection.focus.getNode().getTopLevelElement();
5611
- const topLevelElements = this.#topLevelElementsInSelection(selection);
5612
-
5613
- for (const element of topLevelElements) {
5614
- if (!$isParagraphNode(element)) continue
5615
-
5616
- const children = element.getChildren();
5617
- if (!children.some($isLineBreakNode)) continue
5618
-
5619
- // Check whether this paragraph needs splitting: skip only if neither
5620
- // selection endpoint is inside it (meaning it's a middle paragraph
5621
- // fully between anchor and focus with no partial lines to split off).
5622
- // Compare top-level elements so endpoints inside nested inline nodes
5623
- // (e.g. text inside a LinkNode) are still recognized.
5624
- if (element !== anchorTopLevel && element !== focusTopLevel) continue
5625
-
5626
- const groups = [ [] ];
5627
- for (const child of children) {
5628
- if ($isLineBreakNode(child)) {
5629
- groups.push([]);
5630
- child.remove();
5631
- } else {
5632
- groups[groups.length - 1].push(child);
5633
- }
5634
- }
5635
-
5636
- for (const group of groups) {
5637
- if (group.length === 0) continue
5638
- const paragraph = $createParagraphNode();
5639
- group.forEach(child => paragraph.append(child));
5640
- element.insertBefore(paragraph);
5641
- }
5642
- if (groups.some(group => group.length > 0)) element.remove();
5643
- }
5747
+ $expandSelectionToLineBreaksAndSplitAtEdges(selection);
5748
+ $splitSelectedParagraphsAtInnerLineBreaks(selection);
5644
5749
  }
5645
5750
 
5646
5751
  #blockLevelElementsInSelection(selection) {
@@ -5905,8 +6010,12 @@ class Clipboard {
5905
6010
 
5906
6011
  #isOnlyURLPasted(clipboardData) {
5907
6012
  // Safari URLs are copied as a text/plain + text/uri-list object
6013
+ // App ShareSheet URLs are copied as solo text/uri-list object
5908
6014
  const types = Array.from(clipboardData.types);
5909
- return types.length === 2 && types.includes("text/uri-list") && types.includes("text/plain")
6015
+ return types.length
6016
+ && types.length <= 2
6017
+ && types.includes("text/uri-list")
6018
+ && (types.length < 2 || types.includes("text/plain"))
5910
6019
  }
5911
6020
 
5912
6021
  #isPastingIntoCodeBlock() {
@@ -6964,7 +7073,11 @@ class AttachmentDragAndDrop {
6964
7073
  const ATTACHMENT_ATTRIBUTES = [ "alt", "caption", "content", "content-type", "data-direct-upload-id",
6965
7074
  "data-sgid", "filename", "filesize", "height", "presentation", "previewable", "sgid", "url", "width" ];
6966
7075
 
7076
+ const UPLOADS_BUSY_MESSAGE = "Please wait for all files to upload";
7077
+
6967
7078
  class AttachmentsExtension extends LexxyExtension {
7079
+ #uploadsCount = 0
7080
+
6968
7081
  get enabled() {
6969
7082
  return this.editorElement.supportsAttachments
6970
7083
  }
@@ -6981,17 +7094,41 @@ class AttachmentsExtension extends LexxyExtension {
6981
7094
  ActionTextAttachmentUploadNode,
6982
7095
  ImageGalleryNode
6983
7096
  ],
6984
- register(editor) {
7097
+ register: (editor) => {
6985
7098
  const dragAndDrop = new AttachmentDragAndDrop(editor);
6986
7099
 
6987
7100
  return mergeRegister(
6988
7101
  editor.registerNodeTransform(ActionTextAttachmentNode, $extractAttachmentFromParagraph),
6989
7102
  editor.registerCommand(DELETE_CHARACTER_COMMAND, $collapseIntoGallery, COMMAND_PRIORITY_NORMAL),
7103
+ editor.registerMutationListener(ActionTextAttachmentUploadNode, this.#handleUploadMutations.bind(this)),
6990
7104
  () => dragAndDrop.destroy()
6991
7105
  )
6992
7106
  }
6993
7107
  })
6994
7108
  }
7109
+
7110
+ #handleUploadMutations(mutations) {
7111
+ const previousUploadsCount = this.#uploadsCount;
7112
+ for (const [ , mutation ] of mutations) {
7113
+ if (mutation === "created") {
7114
+ this.#uploadsCount++;
7115
+ } else if (mutation === "destroyed") {
7116
+ this.#uploadsCount--;
7117
+ }
7118
+ }
7119
+
7120
+ if (this.#uploadsCount !== previousUploadsCount) {
7121
+ this.#setUploadsValidity();
7122
+ }
7123
+ }
7124
+
7125
+ #setUploadsValidity() {
7126
+ if (this.#uploadsCount) {
7127
+ this.setEditorValidity({ customError: true }, UPLOADS_BUSY_MESSAGE);
7128
+ } else {
7129
+ this.setEditorValidity({});
7130
+ }
7131
+ }
6995
7132
  }
6996
7133
 
6997
7134
  // Decorator nodes can be wrapped in a Paragraph Node by Lexical when contained in a <div>
@@ -7399,12 +7536,16 @@ class LexicalEditorElement extends HTMLElement {
7399
7536
  static observedAttributes = [ "connected", "required" ]
7400
7537
 
7401
7538
  #initialValue = ""
7402
- #validationTextArea = document.createElement("textarea")
7403
- #editorInitializedRafId = null
7539
+ #initializeEventDispatched = false
7540
+ #editorInitializedDispatched = false
7541
+ #valueLoaded = false
7404
7542
  #listeners = new ListenerBin()
7405
7543
  #disposables = []
7406
7544
  #historyState = { undo: false, redo: false }
7407
7545
 
7546
+ #validity = new Map()
7547
+ #validationTextArea = document.createElement("textarea")
7548
+
7408
7549
  constructor() {
7409
7550
  super();
7410
7551
  this.internals = this.attachInternals();
@@ -7437,29 +7578,40 @@ class LexicalEditorElement extends HTMLElement {
7437
7578
 
7438
7579
  this.#initialize();
7439
7580
 
7440
- this.#scheduleEditorInitializedDispatch();
7441
7581
  this.toggleAttribute("connected", true);
7442
7582
 
7443
- this.#handleAutofocus();
7444
-
7445
- this.valueBeforeDisconnect = null;
7583
+ requestAnimationFrame(() => {
7584
+ this.#mountRoot();
7585
+ this.#handleAutofocus();
7586
+ this.#dispatchInitialize();
7587
+ });
7446
7588
  }
7447
7589
 
7448
7590
  disconnectedCallback() {
7449
- this.#cancelEditorInitializedDispatch();
7450
- this.valueBeforeDisconnect = this.value;
7591
+ this.#initializeEventDispatched = false;
7592
+ this.#editorInitializedDispatched = false;
7593
+ if (this.#valueLoaded) {
7594
+ this.valueBeforeDisconnect = this.value;
7595
+ } else {
7596
+ this.valueBeforeDisconnect = null;
7597
+ }
7598
+ this.#valueLoaded = false;
7451
7599
  this.#reset(); // Prevent hangs with Safari when morphing
7452
7600
  }
7453
7601
 
7454
7602
  attributeChangedCallback(name, oldValue, newValue) {
7455
- if (name === "connected" && this.isConnected && oldValue != null && oldValue !== newValue) {
7603
+ if (name === "connected") this.connectedChangedCallback(oldValue, newValue);
7604
+ if (name === "required") this.requiredChangedCallback(oldValue, newValue);
7605
+ }
7606
+
7607
+ connectedChangedCallback(oldValue, newValue) {
7608
+ if (this.isConnected && oldValue != null && oldValue !== newValue) {
7456
7609
  requestAnimationFrame(() => this.#reconnect());
7457
7610
  }
7611
+ }
7458
7612
 
7459
- if (name === "required" && this.isConnected) {
7460
- this.#validationTextArea.required = this.hasAttribute("required");
7461
- this.#setValidity();
7462
- }
7613
+ requiredChangedCallback() {
7614
+ if (this.isConnected) this.#requestValidityRefresh();
7463
7615
  }
7464
7616
 
7465
7617
  formResetCallback() {
@@ -7485,6 +7637,27 @@ class LexicalEditorElement extends HTMLElement {
7485
7637
  return this.getAttribute("name")
7486
7638
  }
7487
7639
 
7640
+ get required() {
7641
+ return this.hasAttribute("required")
7642
+ }
7643
+
7644
+ get validity() {
7645
+ return this.internals.validity
7646
+ }
7647
+
7648
+ checkValidity() {
7649
+ return this.internals.checkValidity()
7650
+ }
7651
+
7652
+ reportValidity() {
7653
+ return this.internals.reportValidity()
7654
+ }
7655
+
7656
+ setElementValidity(key, flags, message) {
7657
+ this.#validity.set(key, { flags, message });
7658
+ this.#requestValidityRefresh();
7659
+ }
7660
+
7488
7661
  get toolbarElement() {
7489
7662
  if (!this.#hasToolbar) return null
7490
7663
 
@@ -7580,7 +7753,7 @@ class LexicalEditorElement extends HTMLElement {
7580
7753
 
7581
7754
  if (!this.editor) return
7582
7755
 
7583
- this.#cancelEditorInitializedDispatch();
7756
+ this.#editorInitializedDispatched = true;
7584
7757
  this.#dispatchEditorInitialized();
7585
7758
  this.#dispatchAttributesChange();
7586
7759
  }
@@ -7635,6 +7808,7 @@ class LexicalEditorElement extends HTMLElement {
7635
7808
  }
7636
7809
 
7637
7810
  set value(html) {
7811
+ this.#valueLoaded = true;
7638
7812
  const editorHasFocus = this.#isContentFocused;
7639
7813
 
7640
7814
  this.editor.update(() => {
@@ -7731,11 +7905,17 @@ class LexicalEditorElement extends HTMLElement {
7731
7905
  ...this.extensions.lexicalExtensions
7732
7906
  );
7733
7907
 
7734
- editor.setRootElement(this.editorContentElement);
7735
-
7736
7908
  return editor
7737
7909
  }
7738
7910
 
7911
+ // Toggling editable around setRootElement skips Lexical's DOM-selection sync,
7912
+ // which would otherwise steal focus from elsewhere on the page.
7913
+ #mountRoot() {
7914
+ this.editor.setEditable(false);
7915
+ this.editor.setRootElement(this.editorContentElement);
7916
+ this.editor.setEditable(true);
7917
+ }
7918
+
7739
7919
  get #lexicalNodes() {
7740
7920
  const nodes = [ CustomActionTextAttachmentNode ];
7741
7921
 
@@ -7792,7 +7972,6 @@ class LexicalEditorElement extends HTMLElement {
7792
7972
 
7793
7973
  this.internals.setFormValue(html);
7794
7974
  this._internalFormValue = html;
7795
- this.#validationTextArea.value = this.isEmpty ? "" : html;
7796
7975
 
7797
7976
  if (changed) {
7798
7977
  dispatch(this, "lexxy:change");
@@ -7804,10 +7983,12 @@ class LexicalEditorElement extends HTMLElement {
7804
7983
  }
7805
7984
 
7806
7985
  #loadInitialValue() {
7807
- const initialHtml = this.valueBeforeDisconnect || this.getAttribute("value") || "<p><br></p>";
7808
- this.editor.update(() => {
7809
- this.value = this.#initialValue = initialHtml;
7810
- }, { tag: HISTORY_MERGE_TAG });
7986
+ if (!this.#valueLoaded) {
7987
+ const initialHtml = this.valueBeforeDisconnect || this.getAttribute("value") || "<p><br></p>";
7988
+ this.editor.update(() => {
7989
+ this.value = this.#initialValue = initialHtml;
7990
+ }, { tag: HISTORY_MERGE_TAG });
7991
+ }
7811
7992
  }
7812
7993
 
7813
7994
  #resetBeforeTurboCaches() {
@@ -7827,11 +8008,50 @@ class LexicalEditorElement extends HTMLElement {
7827
8008
  this.#clearCachedValues();
7828
8009
  this.#internalFormValue = this.value;
7829
8010
  this.#toggleEmptyStatus();
7830
- this.#setValidity();
8011
+ this.#requestValidityRefresh();
7831
8012
  this.#dispatchAttributesChange();
7832
8013
  }));
7833
8014
  }
7834
8015
 
8016
+ async #requestValidityRefresh() {
8017
+ await nextFrame();
8018
+
8019
+ if (this.isConnected) this.#refreshValidity();
8020
+ }
8021
+
8022
+ #refreshValidity() {
8023
+ this.#refreshInternalValidity();
8024
+ const { validity, message } = this.#calculateValidity();
8025
+ this.internals.setValidity(validity, message, this.editorContentElement);
8026
+ }
8027
+
8028
+ #refreshInternalValidity() {
8029
+ this.#validationTextArea.required = this.required && this.isBlank;
8030
+ const flags = this.#validationTextArea.validity;
8031
+ const message = this.#validationTextArea.validationMessage;
8032
+
8033
+ this.#validity.set(this, { flags, message });
8034
+ }
8035
+
8036
+ #calculateValidity() {
8037
+ const validity = {};
8038
+ const messages = [];
8039
+
8040
+ for (const { flags, message } of this.#validity.values()) {
8041
+ // internal TextArea's ValidityState can contain `valid: true`
8042
+ if (flags.valid === true) continue
8043
+
8044
+ for (const flag in flags) {
8045
+ if (flags[flag]) {
8046
+ validity[flag] = true;
8047
+ messages.push(message);
8048
+ }
8049
+ }
8050
+ }
8051
+
8052
+ return { validity, message: messages.join("\n") }
8053
+ }
8054
+
7835
8055
  #clearCachedValues() {
7836
8056
  this.cachedValue = null;
7837
8057
  this.cachedStringValue = null;
@@ -7987,14 +8207,6 @@ class LexicalEditorElement extends HTMLElement {
7987
8207
  this.classList.toggle("lexxy-editor--empty", this.isEmpty);
7988
8208
  }
7989
8209
 
7990
- #setValidity() {
7991
- if (this.#validationTextArea.validity.valid) {
7992
- this.internals.setValidity({});
7993
- } else {
7994
- this.internals.setValidity(this.#validationTextArea.validity, this.#validationTextArea.validationMessage, this.editorContentElement);
7995
- }
7996
- }
7997
-
7998
8210
  #configureSanitizer() {
7999
8211
  setSanitizerConfig(this.#allowedElements);
8000
8212
  }
@@ -8058,22 +8270,18 @@ class LexicalEditorElement extends HTMLElement {
8058
8270
  });
8059
8271
  }
8060
8272
 
8061
- #scheduleEditorInitializedDispatch() {
8062
- this.#cancelEditorInitializedDispatch();
8063
- this.#editorInitializedRafId = requestAnimationFrame(() => {
8064
- this.#editorInitializedRafId = null;
8065
- if (!this.isConnected || !this.adapter) return
8066
-
8067
- dispatch(this, "lexxy:initialize");
8068
- this.#dispatchEditorInitialized();
8069
- });
8070
- }
8071
-
8072
- #cancelEditorInitializedDispatch() {
8073
- if (this.#editorInitializedRafId == null) return
8273
+ #dispatchInitialize() {
8274
+ if (this.isConnected && this.adapter) {
8275
+ if (!this.#initializeEventDispatched) {
8276
+ this.#initializeEventDispatched = true;
8277
+ dispatch(this, "lexxy:initialize");
8278
+ }
8074
8279
 
8075
- cancelAnimationFrame(this.#editorInitializedRafId);
8076
- this.#editorInitializedRafId = null;
8280
+ if (!this.#editorInitializedDispatched) {
8281
+ this.#editorInitializedDispatched = true;
8282
+ this.#dispatchEditorInitialized();
8283
+ }
8284
+ }
8077
8285
  }
8078
8286
 
8079
8287
  get #resolvedHighlightColors() {
@@ -8124,8 +8332,8 @@ class LexicalEditorElement extends HTMLElement {
8124
8332
  }
8125
8333
 
8126
8334
  #reset() {
8127
- this.#cancelEditorInitializedDispatch();
8128
8335
  this.#dispose();
8336
+ this.#resetValidity();
8129
8337
  this.editorContentElement?.remove();
8130
8338
  this.editorContentElement = null;
8131
8339
 
@@ -8145,6 +8353,10 @@ class LexicalEditorElement extends HTMLElement {
8145
8353
  this.valueBeforeDisconnect = null;
8146
8354
  this.connectedCallback();
8147
8355
  }
8356
+
8357
+ #resetValidity() {
8358
+ this.#validity = new Map();
8359
+ }
8148
8360
  }
8149
8361
 
8150
8362
  // Like $getRoot().getTextContent() but uses readable text for custom attachment nodes
@@ -8532,6 +8744,7 @@ class LexicalPromptElement extends HTMLElement {
8532
8744
 
8533
8745
  if (this.#doesSpaceSelect) {
8534
8746
  this.#popoverListeners.track(this.#editor.registerCommand(KEY_SPACE_COMMAND, this.#handleSelectedOption.bind(this), COMMAND_PRIORITY_CRITICAL));
8747
+ this.#popoverListeners.track(this.#editor.registerCommand(INPUT_COMMAND, this.#handleInputCommand.bind(this), COMMAND_PRIORITY_CRITICAL));
8535
8748
  }
8536
8749
 
8537
8750
  // Register arrow keys with CRITICAL priority to prevent Lexical's selection handlers from running
@@ -8565,16 +8778,12 @@ class LexicalPromptElement extends HTMLElement {
8565
8778
  return Array.from(this.popoverElement.querySelectorAll(".lexxy-prompt-menu__item"))
8566
8779
  }
8567
8780
 
8568
- #selectOption(listItem) {
8781
+ #selectOption(listItem, { scrollIntoView = false } = {}) {
8569
8782
  this.#clearListItemSelection();
8570
8783
  listItem.toggleAttribute("aria-selected", true);
8571
- listItem.scrollIntoView({ block: "nearest", behavior: "smooth" });
8572
- listItem.focus();
8573
-
8574
- // Preserve selection to prevent cursor jump
8575
- this.#selection.preservingSelection(() => {
8576
- this.#editorElement.focus();
8577
- });
8784
+ if (scrollIntoView) {
8785
+ listItem.scrollIntoView({ block: "nearest", container: "nearest", behavior: "smooth" });
8786
+ }
8578
8787
 
8579
8788
  this.#setEditorAssociationAttribute("aria-controls", this.popoverElement.id);
8580
8789
  this.#setEditorAssociationAttribute("aria-activedescendant", listItem.id);
@@ -8718,17 +8927,22 @@ class LexicalPromptElement extends HTMLElement {
8718
8927
  }
8719
8928
  });
8720
8929
  }
8721
- // Arrow keys are now handled via Lexical commands with HIGH priority
8930
+ // Arrow keys are handled via Lexical commands
8931
+ }
8932
+
8933
+ // Android Mobile keyboard doesn't trigger KEY_SPACE_COMMAND
8934
+ #handleInputCommand(event) {
8935
+ if (event.inputType === "insertText" && event.data === " ") return this.#handleSelectedOption(event)
8722
8936
  }
8723
8937
 
8724
8938
  #moveSelectionDown() {
8725
8939
  const nextIndex = this.#selectedIndex + 1;
8726
- if (nextIndex < this.#listItemElements.length) this.#selectOption(this.#listItemElements[nextIndex]);
8940
+ if (nextIndex < this.#listItemElements.length) this.#selectOption(this.#listItemElements[nextIndex], { scrollIntoView: true });
8727
8941
  }
8728
8942
 
8729
8943
  #moveSelectionUp() {
8730
8944
  const previousIndex = this.#selectedIndex - 1;
8731
- if (previousIndex >= 0) this.#selectOption(this.#listItemElements[previousIndex]);
8945
+ if (previousIndex >= 0) this.#selectOption(this.#listItemElements[previousIndex], { scrollIntoView: true });
8732
8946
  }
8733
8947
 
8734
8948
  get #selectedIndex() {
@@ -9147,6 +9361,8 @@ class TableController {
9147
9361
 
9148
9362
  this.currentCellKey = cellNode?.getKey() ?? null;
9149
9363
  this.currentTableNodeKey = tableNode?.getKey() ?? null;
9364
+
9365
+ return tableNode
9150
9366
  }
9151
9367
 
9152
9368
  executeTableCommand(command, customIndex = null) {
@@ -9649,9 +9865,8 @@ class TableTools extends HTMLElement {
9649
9865
 
9650
9866
  #monitorForTableSelection() {
9651
9867
  this.#listeners.track(this.#editor.registerUpdateListener(() => {
9652
- this.tableController.updateSelectedTable();
9868
+ const tableNode = this.#editor.getRootElement() && this.tableController.updateSelectedTable();
9653
9869
 
9654
- const tableNode = this.tableController.currentTableNode;
9655
9870
  if (tableNode) {
9656
9871
  this.#show();
9657
9872
  } else {
@@ -12,7 +12,10 @@
12
12
  hyphens: auto;
13
13
  margin-block: 0 var(--lexxy-content-margin);
14
14
  overflow-wrap: break-word;
15
- text-wrap: balance;
15
+
16
+ @supports (text-wrap: balance) {
17
+ text-wrap: balance;
18
+ }
16
19
  }
17
20
 
18
21
  h1 { font-size: 2rem; }
@@ -35,7 +38,10 @@
35
38
 
36
39
  &:not(lexxy-editor &) {
37
40
  overflow-wrap: break-word;
38
- text-wrap: pretty;
41
+
42
+ @supports (text-wrap: pretty) {
43
+ text-wrap: pretty;
44
+ }
39
45
  }
40
46
  }
41
47
 
@@ -114,11 +120,14 @@
114
120
  hyphens: none;
115
121
  margin-block: 0 var(--lexxy-content-margin);
116
122
  overflow-x: auto;
123
+ overflow-wrap: break-word;
117
124
  padding: 1ch;
118
125
  tab-size: 2;
119
- text-wrap: nowrap;
120
126
  white-space: pre;
121
- word-break: break-word;
127
+
128
+ @supports (text-wrap: nowrap) {
129
+ text-wrap: nowrap;
130
+ }
122
131
  }
123
132
  }
124
133
 
@@ -275,8 +284,11 @@
275
284
 
276
285
  *:is(code, pre) {
277
286
  hyphens: auto;
278
- text-wrap: wrap;
279
287
  white-space: pre-wrap;
288
+
289
+ @supports (text-wrap: wrap) {
290
+ text-wrap: wrap;
291
+ }
280
292
  }
281
293
  }
282
294
  }
@@ -338,7 +350,11 @@
338
350
  display: block;
339
351
  margin-inline: auto;
340
352
  max-inline-size: 100%;
341
- user-select: none;
353
+ -webkit-user-select: none;
354
+
355
+ @supports (user-select: none) {
356
+ user-select: none;
357
+ }
342
358
  }
343
359
 
344
360
  > a {
@@ -166,6 +166,7 @@
166
166
  }
167
167
 
168
168
  &.lexxy-content__table--selection {
169
+ /* eslint-disable-next-line css/use-baseline */
169
170
  ::selection {
170
171
  background: transparent;
171
172
  }
@@ -366,9 +367,12 @@
366
367
  inline-size: 100%;
367
368
  max-inline-size: 100%;
368
369
  padding: 0;
369
- resize: none;
370
370
  text-align: center;
371
371
 
372
+ @supports (resize: none) {
373
+ resize: none;
374
+ }
375
+
372
376
  &:focus {
373
377
  background: var(--lexxy-color-canvas);
374
378
  outline: 0;
@@ -497,7 +501,11 @@
497
501
  fill: currentColor;
498
502
  grid-area: 1/1;
499
503
  inline-size: var(--lexxy-toolbar-icon-size);
500
- user-select: none;
504
+ -webkit-user-select: none;
505
+
506
+ @supports (user-select: none) {
507
+ user-select: none;
508
+ }
501
509
  }
502
510
 
503
511
  &.lexxy-editor__toolbar-group-end {
@@ -528,9 +536,12 @@
528
536
  /* Dropdowns */
529
537
 
530
538
  :where(.lexxy-editor__toolbar-dropdown) {
531
- user-select: none;
532
539
  -webkit-user-select: none;
533
540
 
541
+ @supports (user-select: none) {
542
+ user-select: none;
543
+ }
544
+
534
545
  .lexxy-editor__toolbar-button {
535
546
  color: currentColor;
536
547
 
@@ -806,9 +817,12 @@
806
817
  line-height: inherit;
807
818
  min-block-size: var(--button-size);
808
819
  min-inline-size: var(--button-size);
809
- user-select: none;
810
820
  -webkit-user-select: none;
811
821
 
822
+ @supports (user-select: none) {
823
+ user-select: none;
824
+ }
825
+
812
826
  @media(any-hover: hover) {
813
827
  &:hover:not([aria-disabled="true"]),
814
828
  [open] &:is(summary) {
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@37signals/lexxy",
3
- "version": "0.9.12-beta",
3
+ "version": "0.9.14-beta",
4
4
  "description": "Lexxy - A modern rich text editor for Rails.",
5
5
  "module": "dist/lexxy.esm.js",
6
6
  "type": "module",