@37signals/lexxy 0.9.12-beta → 0.9.13-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/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();
@@ -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() {
@@ -1486,6 +1499,10 @@ class LexxyExtension {
1486
1499
 
1487
1500
  }
1488
1501
 
1502
+ setEditorValidity(flags, message) {
1503
+ this.editorElement.setElementValidity(this, flags, message);
1504
+ }
1505
+
1489
1506
  dispose() {
1490
1507
  }
1491
1508
  }
@@ -1638,40 +1655,166 @@ function isAttachmentSpacerTextNode(node, previousNode, index, childCount) {
1638
1655
  && previousNode instanceof CustomActionTextAttachmentNode
1639
1656
  }
1640
1657
 
1641
- function $splitParagraphsAtLineBreakBoundaries(selection) {
1658
+ function $splitSelectedParagraphsAtInnerLineBreaks(selection) {
1659
+ const topLevelElements = new Set();
1660
+ for (const node of selection.getNodes()) {
1661
+ const topLevel = node.getTopLevelElement();
1662
+ if (topLevel) topLevelElements.add(topLevel);
1663
+ }
1664
+
1665
+ for (const element of topLevelElements) {
1666
+ if (!$isParagraphNode(element)) continue
1667
+
1668
+ const children = element.getChildren();
1669
+ if (!children.some($isLineBreakNode)) continue
1670
+
1671
+ const groups = [ [] ];
1672
+ for (const child of children) {
1673
+ if ($isLineBreakNode(child)) {
1674
+ groups.push([]);
1675
+ child.remove();
1676
+ } else {
1677
+ groups[groups.length - 1].push(child);
1678
+ }
1679
+ }
1680
+
1681
+ for (const group of groups) {
1682
+ if (group.length === 0) continue
1683
+ const paragraph = $createParagraphNode();
1684
+ group.forEach(child => paragraph.append(child));
1685
+ element.insertBefore(paragraph);
1686
+ }
1687
+ if (groups.some(group => group.length > 0)) element.remove();
1688
+ }
1689
+ }
1690
+
1691
+ function $expandSelectionToLineBreaksAndSplitAtEdges(selection) {
1642
1692
  $ensureForwardRangeSelection(selection);
1643
1693
 
1644
- // Split focus first so the anchor split position stays valid.
1645
- $splitAtNearestLineBreak(selection.focus, "next");
1646
- $splitAtNearestLineBreak(selection.anchor, "previous");
1694
+ const focusCaret = $caretFromPoint(selection.focus, "next");
1695
+ const anchorCaret = $caretFromPoint(selection.anchor, "previous");
1696
+
1697
+ // A collapsed cursor adjacent to a <br> would claim it from both sides via
1698
+ // inward-edge; force outward-only walks so each side finds its own boundary.
1699
+ const skipInwardEdge = selection.isCollapsed();
1700
+ const focusBrCaret = $getCaretAtLineBreakBoundary(focusCaret, skipInwardEdge);
1701
+ let anchorBrCaret = $getCaretAtLineBreakBoundary(anchorCaret, skipInwardEdge);
1702
+
1703
+ if (focusBrCaret?.origin.is(anchorBrCaret?.origin)) {
1704
+ anchorBrCaret = null;
1705
+ }
1706
+
1707
+ // Splitting focus first keeps the anchor <br>'s position stable.
1708
+ const focusOuter = focusBrCaret && $splitAroundLineBreak(focusBrCaret);
1709
+ const anchorOuter = anchorBrCaret && $splitAroundLineBreak(anchorBrCaret);
1710
+
1711
+ const innerStart = anchorOuter?.getNextSibling() ?? selection.anchor.getNode().getTopLevelElement();
1712
+ const innerEnd = focusOuter?.getPreviousSibling() ?? selection.focus.getNode().getTopLevelElement();
1713
+ if (!innerStart || !innerEnd) return
1714
+
1715
+ $setSelectionFromCaretRange($getCaretRange(
1716
+ $normalizeCaret($getChildCaret(innerStart, "next")),
1717
+ $getCaretInDirection(
1718
+ $normalizeCaret($getChildCaret(innerEnd, "previous")),
1719
+ "next",
1720
+ ),
1721
+ ));
1647
1722
  }
1648
1723
 
1649
- function $splitAtNearestLineBreak(point, direction) {
1650
- const paragraph = point.getNode().getTopLevelElement();
1651
- if (!paragraph || !$isParagraphNode(paragraph)) return
1724
+ function $getCaretAtLineBreakBoundary(caret, skipInwardEdge = false) {
1725
+ const paragraph = caret.origin.getTopLevelElement();
1726
+ if (!paragraph || !$isParagraphNode(paragraph)) return null
1727
+
1728
+ const lineBreak = (skipInwardEdge ? null : $inwardEdgeLineBreak(caret, paragraph))
1729
+ ?? $outwardLineBreak(caret, paragraph);
1652
1730
 
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
1731
+ return lineBreak ? $getSiblingCaret(lineBreak, caret.direction) : null
1732
+ }
1657
1733
 
1658
- const lineBreak = lineBreakCaret.origin;
1659
- const isEdge = lineBreakCaret.getNodeAtCaret() === null;
1734
+ // Prefer a <br> the cursor is sitting flush against, except when a further <br>
1735
+ // also exists outward that one is the real paragraph break for this side.
1736
+ function $inwardEdgeLineBreak(caret, paragraph) {
1737
+ let candidateCaret;
1660
1738
 
1661
- if (!isEdge) {
1662
- $splitNode(paragraph, lineBreak.getIndexWithinParent());
1739
+ if (
1740
+ ($isChildCaret(caret) && caret.origin.is(paragraph)) ||
1741
+ ($isTextPointCaret(caret) && $isExtendableTextPointCaret(caret.getFlipped()))
1742
+ ) {
1743
+ candidateCaret = null;
1744
+ } else if ($isSiblingCaret(caret) && caret.getParentAtCaret().is(paragraph)) {
1745
+ candidateCaret = caret;
1746
+ } else {
1747
+ const childCaret = $paragraphChildCaretAtInwardEdge(caret, paragraph);
1748
+ candidateCaret = childCaret ? $rewindSiblingCaret(childCaret) : null;
1749
+ }
1750
+
1751
+ if (candidateCaret && $isLineBreakNode(candidateCaret.origin)) {
1752
+ return $candidateUnlessShadowed(candidateCaret)
1753
+ } else {
1754
+ return null
1663
1755
  }
1756
+ }
1664
1757
 
1665
- lineBreak.remove();
1758
+ function $candidateUnlessShadowed(candidateCaret) {
1759
+ const outward = candidateCaret.getNodeAtCaret();
1760
+ return $isLineBreakNode(outward) ? null : candidateCaret.origin
1666
1761
  }
1667
1762
 
1668
- function $caretAtNearestNodeOfType(node, klass, direction) {
1669
- for (const caret of $getSiblingCaret(node, direction)) {
1670
- if (caret.origin instanceof klass) return caret
1763
+ function $outwardLineBreak(caret, paragraph) {
1764
+ const startCaret = $outwardWalkStartCaret(caret, paragraph);
1765
+ if (!startCaret) return null
1766
+
1767
+ for (const { origin } of startCaret) {
1768
+ if (!origin.getParent().is(paragraph)) break
1769
+ if ($isLineBreakNode(origin)) return origin
1671
1770
  }
1672
1771
  return null
1673
1772
  }
1674
1773
 
1774
+ function $outwardWalkStartCaret(caret, paragraph) {
1775
+ if (caret.getParentAtCaret().is(paragraph)) {
1776
+ return caret
1777
+ } else {
1778
+ return $paragraphChildCaretContaining(caret, paragraph)
1779
+ }
1780
+ }
1781
+
1782
+ function $paragraphChildCaretContaining(caret, paragraph) {
1783
+ let cursor = caret.getSiblingCaret();
1784
+ while (cursor && !cursor.origin.getParent()?.is(paragraph)) {
1785
+ cursor = cursor.getParentCaret();
1786
+ }
1787
+ return cursor?.origin.getParent()?.is(paragraph) ? cursor : null
1788
+ }
1789
+
1790
+ // Only succeeds when the cursor is flush against the inward edge of every
1791
+ // ancestor between itself and the paragraph child.
1792
+ function $paragraphChildCaretAtInwardEdge(caret, paragraph) {
1793
+ let cursor = caret.getSiblingCaret();
1794
+ while (cursor && !cursor.origin.getParent()?.is(paragraph)) {
1795
+ if (cursor.getNodeAtCaret()) return null
1796
+ cursor = cursor.getParentCaret();
1797
+ }
1798
+ return cursor?.origin.getParent()?.is(paragraph) ? cursor : null
1799
+ }
1800
+
1801
+ function $splitAroundLineBreak(lineBreakCaret) {
1802
+ let outer = null;
1803
+
1804
+ if (lineBreakCaret.getNodeAtCaret() === null) {
1805
+ lineBreakCaret.origin.remove();
1806
+ } else {
1807
+ const lineBreak = lineBreakCaret.origin;
1808
+ const splitCaret = $getCaretInDirection($rewindSiblingCaret(lineBreakCaret), "next");
1809
+
1810
+ $splitAtPointCaretNext(splitCaret);
1811
+ outer = lineBreak.getTopLevelElement();
1812
+ lineBreak.remove();
1813
+ }
1814
+
1815
+ return outer
1816
+ }
1817
+
1675
1818
  // Payload: Record<nodeKey, { patch?, replace? }>
1676
1819
  // - patch: plain object, shallow-merged into the existing node's properties
1677
1820
  // - replace: a LexicalNode instance that replaces the node
@@ -3548,12 +3691,18 @@ class CommandDispatcher {
3548
3691
  #registerDragAndDropHandlers() {
3549
3692
  if (this.editorElement.supportsAttachments) {
3550
3693
  this.dragCounter = 0;
3551
- const root = this.editor.getRootElement();
3552
3694
  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))
3695
+ this.editor.registerRootListener((rootElement) => {
3696
+ if (rootElement) {
3697
+ const teardowns = [
3698
+ registerEventListener(rootElement, "dragover", this.#handleDragOver.bind(this)),
3699
+ registerEventListener(rootElement, "drop", this.#handleDrop.bind(this)),
3700
+ registerEventListener(rootElement, "dragenter", this.#handleDragEnter.bind(this)),
3701
+ registerEventListener(rootElement, "dragleave", this.#handleDragLeave.bind(this))
3702
+ ];
3703
+ return () => teardowns.forEach((teardown) => teardown())
3704
+ }
3705
+ })
3557
3706
  );
3558
3707
  }
3559
3708
  }
@@ -3723,32 +3872,6 @@ class Selection {
3723
3872
  return { node: null, offset: 0 }
3724
3873
  }
3725
3874
 
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
3875
  getFormat() {
3753
3876
  const selection = $getSelection();
3754
3877
  if (!$isRangeSelection(selection)) return {}
@@ -4013,46 +4136,59 @@ class Selection {
4013
4136
  return $isDecoratorNode(targetNode) && this.#selectInLexical(targetNode)
4014
4137
  }, COMMAND_PRIORITY_LOW));
4015
4138
 
4016
- const rootElement = this.editor.getRootElement();
4017
4139
  this.#listeners.track(
4018
- registerEventListener(rootElement, "lexxy:internal:move-to-next-line", () => this.#selectOrAppendNextLine())
4140
+ this.editor.registerRootListener((rootElement) => {
4141
+ if (rootElement) {
4142
+ return registerEventListener(rootElement, "lexxy:internal:move-to-next-line", () => this.#selectOrAppendNextLine())
4143
+ }
4144
+ })
4019
4145
  );
4020
4146
  }
4021
4147
 
4022
4148
  #containEditorFocus() {
4023
4149
  // Workaround for a bizarre Chrome bug where the cursor abandons the editor to focus on not-focusable elements
4024
4150
  // 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
- }
4151
+ this.#listeners.track(
4152
+ this.editor.registerRootListener((rootElement) => {
4153
+ if (rootElement) {
4154
+ const handler = (event) => this.#handleArrowKeyOnLexicalCursor(event);
4155
+ rootElement.addEventListener("keydown", handler, true);
4156
+ return () => rootElement.removeEventListener("keydown", handler, true)
4157
+ }
4158
+ })
4159
+ );
4160
+ }
4034
4161
 
4035
- if (!currentElement) {
4036
- event.preventDefault();
4037
- }
4162
+ #handleArrowKeyOnLexicalCursor(event) {
4163
+ if (event.key === "ArrowUp") {
4164
+ const lexicalCursor = this.editor.getRootElement().querySelector("[data-lexical-cursor]");
4165
+
4166
+ if (lexicalCursor) {
4167
+ let currentElement = lexicalCursor.previousElementSibling;
4168
+ while (currentElement && currentElement.hasAttribute("data-lexical-cursor")) {
4169
+ currentElement = currentElement.previousElementSibling;
4170
+ }
4171
+
4172
+ if (!currentElement) {
4173
+ event.preventDefault();
4038
4174
  }
4039
4175
  }
4176
+ }
4040
4177
 
4041
- if (event.key === "ArrowDown") {
4042
- const lexicalCursor = this.editor.getRootElement().querySelector("[data-lexical-cursor]");
4178
+ if (event.key === "ArrowDown") {
4179
+ const lexicalCursor = this.editor.getRootElement().querySelector("[data-lexical-cursor]");
4043
4180
 
4044
- if (lexicalCursor) {
4045
- let currentElement = lexicalCursor.nextElementSibling;
4046
- while (currentElement && currentElement.hasAttribute("data-lexical-cursor")) {
4047
- currentElement = currentElement.nextElementSibling;
4048
- }
4181
+ if (lexicalCursor) {
4182
+ let currentElement = lexicalCursor.nextElementSibling;
4183
+ while (currentElement && currentElement.hasAttribute("data-lexical-cursor")) {
4184
+ currentElement = currentElement.nextElementSibling;
4185
+ }
4049
4186
 
4050
- if (!currentElement) {
4051
- event.preventDefault();
4052
- }
4187
+ if (!currentElement) {
4188
+ event.preventDefault();
4053
4189
  }
4054
4190
  }
4055
- }, true);
4191
+ }
4056
4192
  }
4057
4193
 
4058
4194
  #syncSelectedClasses() {
@@ -5263,6 +5399,7 @@ class Contents {
5263
5399
  const selection = $getSelection();
5264
5400
  if (!$isRangeSelection(selection)) return
5265
5401
 
5402
+ $expandSelectionToLineBreaksAndSplitAtEdges(selection);
5266
5403
  $setBlocksType(selection, () => $createParagraphNode());
5267
5404
  }
5268
5405
 
@@ -5270,6 +5407,7 @@ class Contents {
5270
5407
  const selection = $getSelection();
5271
5408
  if (!$isRangeSelection(selection)) return
5272
5409
 
5410
+ $expandSelectionToLineBreaksAndSplitAtEdges(selection);
5273
5411
  $setBlocksType(selection, () => $createHeadingNode(tag));
5274
5412
  }
5275
5413
 
@@ -5311,10 +5449,14 @@ class Contents {
5311
5449
  if (allCode) {
5312
5450
  blockElements.forEach(node => this.#unwrapCodeBlock(node));
5313
5451
  } else {
5452
+ $expandSelectionToLineBreaksAndSplitAtEdges(selection);
5453
+ const elements = this.#blockLevelElementsInSelection(selection);
5454
+ if (elements.length === 0) return
5455
+
5314
5456
  const codeNode = $createCodeNode("plain");
5315
- blockElements.at(-1).insertAfter(codeNode);
5457
+ elements.at(-1).insertAfter(codeNode);
5316
5458
  codeNode.selectEnd();
5317
- this.insertAtCursor(...blockElements);
5459
+ this.insertAtCursor(...elements);
5318
5460
  }
5319
5461
  }
5320
5462
 
@@ -5333,8 +5475,7 @@ class Contents {
5333
5475
  } else {
5334
5476
  topLevelElements.filter($isQuoteNode).forEach(node => this.#unwrap(node));
5335
5477
 
5336
- $splitParagraphsAtLineBreakBoundaries(selection);
5337
-
5478
+ $expandSelectionToLineBreaksAndSplitAtEdges(selection);
5338
5479
  const elements = this.#topLevelElementsInSelection(selection);
5339
5480
  if (elements.length === 0) return
5340
5481
 
@@ -5602,45 +5743,8 @@ class Contents {
5602
5743
  const selection = $getSelection();
5603
5744
  if (!$isRangeSelection(selection)) return
5604
5745
 
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
- }
5746
+ $expandSelectionToLineBreaksAndSplitAtEdges(selection);
5747
+ $splitSelectedParagraphsAtInnerLineBreaks(selection);
5644
5748
  }
5645
5749
 
5646
5750
  #blockLevelElementsInSelection(selection) {
@@ -5905,8 +6009,12 @@ class Clipboard {
5905
6009
 
5906
6010
  #isOnlyURLPasted(clipboardData) {
5907
6011
  // Safari URLs are copied as a text/plain + text/uri-list object
6012
+ // App ShareSheet URLs are copied as solo text/uri-list object
5908
6013
  const types = Array.from(clipboardData.types);
5909
- return types.length === 2 && types.includes("text/uri-list") && types.includes("text/plain")
6014
+ return types.length
6015
+ && types.length <= 2
6016
+ && types.includes("text/uri-list")
6017
+ && (types.length < 2 || types.includes("text/plain"))
5910
6018
  }
5911
6019
 
5912
6020
  #isPastingIntoCodeBlock() {
@@ -6964,7 +7072,11 @@ class AttachmentDragAndDrop {
6964
7072
  const ATTACHMENT_ATTRIBUTES = [ "alt", "caption", "content", "content-type", "data-direct-upload-id",
6965
7073
  "data-sgid", "filename", "filesize", "height", "presentation", "previewable", "sgid", "url", "width" ];
6966
7074
 
7075
+ const UPLOADS_BUSY_MESSAGE = "Please wait for all files to upload";
7076
+
6967
7077
  class AttachmentsExtension extends LexxyExtension {
7078
+ #uploadsCount = 0
7079
+
6968
7080
  get enabled() {
6969
7081
  return this.editorElement.supportsAttachments
6970
7082
  }
@@ -6981,17 +7093,41 @@ class AttachmentsExtension extends LexxyExtension {
6981
7093
  ActionTextAttachmentUploadNode,
6982
7094
  ImageGalleryNode
6983
7095
  ],
6984
- register(editor) {
7096
+ register: (editor) => {
6985
7097
  const dragAndDrop = new AttachmentDragAndDrop(editor);
6986
7098
 
6987
7099
  return mergeRegister(
6988
7100
  editor.registerNodeTransform(ActionTextAttachmentNode, $extractAttachmentFromParagraph),
6989
7101
  editor.registerCommand(DELETE_CHARACTER_COMMAND, $collapseIntoGallery, COMMAND_PRIORITY_NORMAL),
7102
+ editor.registerMutationListener(ActionTextAttachmentUploadNode, this.#handleUploadMutations.bind(this)),
6990
7103
  () => dragAndDrop.destroy()
6991
7104
  )
6992
7105
  }
6993
7106
  })
6994
7107
  }
7108
+
7109
+ #handleUploadMutations(mutations) {
7110
+ const previousUploadsCount = this.#uploadsCount;
7111
+ for (const [ , mutation ] of mutations) {
7112
+ if (mutation === "created") {
7113
+ this.#uploadsCount++;
7114
+ } else if (mutation === "destroyed") {
7115
+ this.#uploadsCount--;
7116
+ }
7117
+ }
7118
+
7119
+ if (this.#uploadsCount !== previousUploadsCount) {
7120
+ this.#setUploadsValidity();
7121
+ }
7122
+ }
7123
+
7124
+ #setUploadsValidity() {
7125
+ if (this.#uploadsCount) {
7126
+ this.setEditorValidity({ customError: true }, UPLOADS_BUSY_MESSAGE);
7127
+ } else {
7128
+ this.setEditorValidity({});
7129
+ }
7130
+ }
6995
7131
  }
6996
7132
 
6997
7133
  // Decorator nodes can be wrapped in a Paragraph Node by Lexical when contained in a <div>
@@ -7399,12 +7535,16 @@ class LexicalEditorElement extends HTMLElement {
7399
7535
  static observedAttributes = [ "connected", "required" ]
7400
7536
 
7401
7537
  #initialValue = ""
7402
- #validationTextArea = document.createElement("textarea")
7403
- #editorInitializedRafId = null
7538
+ #initializeEventDispatched = false
7539
+ #editorInitializedDispatched = false
7540
+ #valueLoaded = false
7404
7541
  #listeners = new ListenerBin()
7405
7542
  #disposables = []
7406
7543
  #historyState = { undo: false, redo: false }
7407
7544
 
7545
+ #validity = new Map()
7546
+ #validationTextArea = document.createElement("textarea")
7547
+
7408
7548
  constructor() {
7409
7549
  super();
7410
7550
  this.internals = this.attachInternals();
@@ -7437,29 +7577,40 @@ class LexicalEditorElement extends HTMLElement {
7437
7577
 
7438
7578
  this.#initialize();
7439
7579
 
7440
- this.#scheduleEditorInitializedDispatch();
7441
7580
  this.toggleAttribute("connected", true);
7442
7581
 
7443
- this.#handleAutofocus();
7444
-
7445
- this.valueBeforeDisconnect = null;
7582
+ requestAnimationFrame(() => {
7583
+ this.#mountRoot();
7584
+ this.#handleAutofocus();
7585
+ this.#dispatchInitialize();
7586
+ });
7446
7587
  }
7447
7588
 
7448
7589
  disconnectedCallback() {
7449
- this.#cancelEditorInitializedDispatch();
7450
- this.valueBeforeDisconnect = this.value;
7590
+ this.#initializeEventDispatched = false;
7591
+ this.#editorInitializedDispatched = false;
7592
+ if (this.#valueLoaded) {
7593
+ this.valueBeforeDisconnect = this.value;
7594
+ } else {
7595
+ this.valueBeforeDisconnect = null;
7596
+ }
7597
+ this.#valueLoaded = false;
7451
7598
  this.#reset(); // Prevent hangs with Safari when morphing
7452
7599
  }
7453
7600
 
7454
7601
  attributeChangedCallback(name, oldValue, newValue) {
7455
- if (name === "connected" && this.isConnected && oldValue != null && oldValue !== newValue) {
7602
+ if (name === "connected") this.connectedChangedCallback(oldValue, newValue);
7603
+ if (name === "required") this.requiredChangedCallback(oldValue, newValue);
7604
+ }
7605
+
7606
+ connectedChangedCallback(oldValue, newValue) {
7607
+ if (this.isConnected && oldValue != null && oldValue !== newValue) {
7456
7608
  requestAnimationFrame(() => this.#reconnect());
7457
7609
  }
7610
+ }
7458
7611
 
7459
- if (name === "required" && this.isConnected) {
7460
- this.#validationTextArea.required = this.hasAttribute("required");
7461
- this.#setValidity();
7462
- }
7612
+ requiredChangedCallback() {
7613
+ if (this.isConnected) this.#requestValidityRefresh();
7463
7614
  }
7464
7615
 
7465
7616
  formResetCallback() {
@@ -7485,6 +7636,27 @@ class LexicalEditorElement extends HTMLElement {
7485
7636
  return this.getAttribute("name")
7486
7637
  }
7487
7638
 
7639
+ get required() {
7640
+ return this.hasAttribute("required")
7641
+ }
7642
+
7643
+ get validity() {
7644
+ return this.internals.validity
7645
+ }
7646
+
7647
+ checkValidity() {
7648
+ return this.internals.checkValidity()
7649
+ }
7650
+
7651
+ reportValidity() {
7652
+ return this.internals.reportValidity()
7653
+ }
7654
+
7655
+ setElementValidity(key, flags, message) {
7656
+ this.#validity.set(key, { flags, message });
7657
+ this.#requestValidityRefresh();
7658
+ }
7659
+
7488
7660
  get toolbarElement() {
7489
7661
  if (!this.#hasToolbar) return null
7490
7662
 
@@ -7580,7 +7752,7 @@ class LexicalEditorElement extends HTMLElement {
7580
7752
 
7581
7753
  if (!this.editor) return
7582
7754
 
7583
- this.#cancelEditorInitializedDispatch();
7755
+ this.#editorInitializedDispatched = true;
7584
7756
  this.#dispatchEditorInitialized();
7585
7757
  this.#dispatchAttributesChange();
7586
7758
  }
@@ -7635,6 +7807,7 @@ class LexicalEditorElement extends HTMLElement {
7635
7807
  }
7636
7808
 
7637
7809
  set value(html) {
7810
+ this.#valueLoaded = true;
7638
7811
  const editorHasFocus = this.#isContentFocused;
7639
7812
 
7640
7813
  this.editor.update(() => {
@@ -7731,11 +7904,17 @@ class LexicalEditorElement extends HTMLElement {
7731
7904
  ...this.extensions.lexicalExtensions
7732
7905
  );
7733
7906
 
7734
- editor.setRootElement(this.editorContentElement);
7735
-
7736
7907
  return editor
7737
7908
  }
7738
7909
 
7910
+ // Toggling editable around setRootElement skips Lexical's DOM-selection sync,
7911
+ // which would otherwise steal focus from elsewhere on the page.
7912
+ #mountRoot() {
7913
+ this.editor.setEditable(false);
7914
+ this.editor.setRootElement(this.editorContentElement);
7915
+ this.editor.setEditable(true);
7916
+ }
7917
+
7739
7918
  get #lexicalNodes() {
7740
7919
  const nodes = [ CustomActionTextAttachmentNode ];
7741
7920
 
@@ -7792,7 +7971,6 @@ class LexicalEditorElement extends HTMLElement {
7792
7971
 
7793
7972
  this.internals.setFormValue(html);
7794
7973
  this._internalFormValue = html;
7795
- this.#validationTextArea.value = this.isEmpty ? "" : html;
7796
7974
 
7797
7975
  if (changed) {
7798
7976
  dispatch(this, "lexxy:change");
@@ -7804,10 +7982,12 @@ class LexicalEditorElement extends HTMLElement {
7804
7982
  }
7805
7983
 
7806
7984
  #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 });
7985
+ if (!this.#valueLoaded) {
7986
+ const initialHtml = this.valueBeforeDisconnect || this.getAttribute("value") || "<p><br></p>";
7987
+ this.editor.update(() => {
7988
+ this.value = this.#initialValue = initialHtml;
7989
+ }, { tag: HISTORY_MERGE_TAG });
7990
+ }
7811
7991
  }
7812
7992
 
7813
7993
  #resetBeforeTurboCaches() {
@@ -7827,11 +8007,50 @@ class LexicalEditorElement extends HTMLElement {
7827
8007
  this.#clearCachedValues();
7828
8008
  this.#internalFormValue = this.value;
7829
8009
  this.#toggleEmptyStatus();
7830
- this.#setValidity();
8010
+ this.#requestValidityRefresh();
7831
8011
  this.#dispatchAttributesChange();
7832
8012
  }));
7833
8013
  }
7834
8014
 
8015
+ async #requestValidityRefresh() {
8016
+ await nextFrame();
8017
+
8018
+ if (this.isConnected) this.#refreshValidity();
8019
+ }
8020
+
8021
+ #refreshValidity() {
8022
+ this.#refreshInternalValidity();
8023
+ const { validity, message } = this.#calculateValidity();
8024
+ this.internals.setValidity(validity, message, this.editorContentElement);
8025
+ }
8026
+
8027
+ #refreshInternalValidity() {
8028
+ this.#validationTextArea.required = this.required && this.isBlank;
8029
+ const flags = this.#validationTextArea.validity;
8030
+ const message = this.#validationTextArea.validationMessage;
8031
+
8032
+ this.#validity.set(this, { flags, message });
8033
+ }
8034
+
8035
+ #calculateValidity() {
8036
+ const validity = {};
8037
+ const messages = [];
8038
+
8039
+ for (const { flags, message } of this.#validity.values()) {
8040
+ // internal TextArea's ValidityState can contain `valid: true`
8041
+ if (flags.valid === true) continue
8042
+
8043
+ for (const flag in flags) {
8044
+ if (flags[flag]) {
8045
+ validity[flag] = true;
8046
+ messages.push(message);
8047
+ }
8048
+ }
8049
+ }
8050
+
8051
+ return { validity, message: messages.join("\n") }
8052
+ }
8053
+
7835
8054
  #clearCachedValues() {
7836
8055
  this.cachedValue = null;
7837
8056
  this.cachedStringValue = null;
@@ -7987,14 +8206,6 @@ class LexicalEditorElement extends HTMLElement {
7987
8206
  this.classList.toggle("lexxy-editor--empty", this.isEmpty);
7988
8207
  }
7989
8208
 
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
8209
  #configureSanitizer() {
7999
8210
  setSanitizerConfig(this.#allowedElements);
8000
8211
  }
@@ -8058,22 +8269,18 @@ class LexicalEditorElement extends HTMLElement {
8058
8269
  });
8059
8270
  }
8060
8271
 
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
8272
+ #dispatchInitialize() {
8273
+ if (this.isConnected && this.adapter) {
8274
+ if (!this.#initializeEventDispatched) {
8275
+ this.#initializeEventDispatched = true;
8276
+ dispatch(this, "lexxy:initialize");
8277
+ }
8074
8278
 
8075
- cancelAnimationFrame(this.#editorInitializedRafId);
8076
- this.#editorInitializedRafId = null;
8279
+ if (!this.#editorInitializedDispatched) {
8280
+ this.#editorInitializedDispatched = true;
8281
+ this.#dispatchEditorInitialized();
8282
+ }
8283
+ }
8077
8284
  }
8078
8285
 
8079
8286
  get #resolvedHighlightColors() {
@@ -8124,8 +8331,8 @@ class LexicalEditorElement extends HTMLElement {
8124
8331
  }
8125
8332
 
8126
8333
  #reset() {
8127
- this.#cancelEditorInitializedDispatch();
8128
8334
  this.#dispose();
8335
+ this.#resetValidity();
8129
8336
  this.editorContentElement?.remove();
8130
8337
  this.editorContentElement = null;
8131
8338
 
@@ -8145,6 +8352,10 @@ class LexicalEditorElement extends HTMLElement {
8145
8352
  this.valueBeforeDisconnect = null;
8146
8353
  this.connectedCallback();
8147
8354
  }
8355
+
8356
+ #resetValidity() {
8357
+ this.#validity = new Map();
8358
+ }
8148
8359
  }
8149
8360
 
8150
8361
  // Like $getRoot().getTextContent() but uses readable text for custom attachment nodes
@@ -8532,6 +8743,7 @@ class LexicalPromptElement extends HTMLElement {
8532
8743
 
8533
8744
  if (this.#doesSpaceSelect) {
8534
8745
  this.#popoverListeners.track(this.#editor.registerCommand(KEY_SPACE_COMMAND, this.#handleSelectedOption.bind(this), COMMAND_PRIORITY_CRITICAL));
8746
+ this.#popoverListeners.track(this.#editor.registerCommand(INPUT_COMMAND, this.#handleInputCommand.bind(this), COMMAND_PRIORITY_CRITICAL));
8535
8747
  }
8536
8748
 
8537
8749
  // Register arrow keys with CRITICAL priority to prevent Lexical's selection handlers from running
@@ -8565,16 +8777,12 @@ class LexicalPromptElement extends HTMLElement {
8565
8777
  return Array.from(this.popoverElement.querySelectorAll(".lexxy-prompt-menu__item"))
8566
8778
  }
8567
8779
 
8568
- #selectOption(listItem) {
8780
+ #selectOption(listItem, { scrollIntoView = false } = {}) {
8569
8781
  this.#clearListItemSelection();
8570
8782
  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
- });
8783
+ if (scrollIntoView) {
8784
+ listItem.scrollIntoView({ block: "nearest", container: "nearest", behavior: "smooth" });
8785
+ }
8578
8786
 
8579
8787
  this.#setEditorAssociationAttribute("aria-controls", this.popoverElement.id);
8580
8788
  this.#setEditorAssociationAttribute("aria-activedescendant", listItem.id);
@@ -8718,17 +8926,22 @@ class LexicalPromptElement extends HTMLElement {
8718
8926
  }
8719
8927
  });
8720
8928
  }
8721
- // Arrow keys are now handled via Lexical commands with HIGH priority
8929
+ // Arrow keys are handled via Lexical commands
8930
+ }
8931
+
8932
+ // Android Mobile keyboard doesn't trigger KEY_SPACE_COMMAND
8933
+ #handleInputCommand(event) {
8934
+ if (event.inputType === "insertText" && event.data === " ") return this.#handleSelectedOption(event)
8722
8935
  }
8723
8936
 
8724
8937
  #moveSelectionDown() {
8725
8938
  const nextIndex = this.#selectedIndex + 1;
8726
- if (nextIndex < this.#listItemElements.length) this.#selectOption(this.#listItemElements[nextIndex]);
8939
+ if (nextIndex < this.#listItemElements.length) this.#selectOption(this.#listItemElements[nextIndex], { scrollIntoView: true });
8727
8940
  }
8728
8941
 
8729
8942
  #moveSelectionUp() {
8730
8943
  const previousIndex = this.#selectedIndex - 1;
8731
- if (previousIndex >= 0) this.#selectOption(this.#listItemElements[previousIndex]);
8944
+ if (previousIndex >= 0) this.#selectOption(this.#listItemElements[previousIndex], { scrollIntoView: true });
8732
8945
  }
8733
8946
 
8734
8947
  get #selectedIndex() {
@@ -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.13-beta",
4
4
  "description": "Lexxy - A modern rich text editor for Rails.",
5
5
  "module": "dist/lexxy.esm.js",
6
6
  "type": "module",