@37signals/lexxy 0.9.11-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,10 +1,10 @@
1
1
  export { highlightCode } from './lexxy_helpers.esm.js';
2
2
  import DOMPurify from 'dompurify';
3
- import { getStyleObjectFromCSS, getCSSFromStyleObject, $ensureForwardRangeSelection, $isAtNodeEnd, $getSelectionStyleValueForProperty, $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';
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, $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
+ import { LinkNode, $createAutoLinkNode, $toggleLink, $createLinkNode, $isLinkNode, AutoLinkNode } from '@lexical/link';
5
6
  import { buildEditorFromExtensions } from '@lexical/extension';
6
7
  import { ListNode, ListItemNode, $getListDepth, INSERT_UNORDERED_LIST_COMMAND, INSERT_ORDERED_LIST_COMMAND, $isListItemNode, $isListNode, registerList } from '@lexical/list';
7
- import { LinkNode, $createAutoLinkNode, $toggleLink, $createLinkNode, $isLinkNode, AutoLinkNode } from '@lexical/link';
8
8
  import { $getNearestNodeOfType, $wrapNodeInElement, $lastToFirstIterator, $descendantsMatching, mergeRegister, $insertFirst, $unwrapAndFilterDescendants, $firstToLastIterator, $getNearestBlockElementAncestorOrThrow, IS_APPLE } from '@lexical/utils';
9
9
  import { registerPlainText } from '@lexical/plain-text';
10
10
  import { RichTextExtension, $isQuoteNode, $isHeadingNode, $createHeadingNode, $createQuoteNode, QuoteNode, HeadingNode, registerRichText } from '@lexical/rich-text';
@@ -128,7 +128,9 @@ class ListenerBin {
128
128
  function createElement(name, properties, content = "") {
129
129
  const element = document.createElement(name);
130
130
  for (const [ key, value ] of Object.entries(properties || {})) {
131
- if (key in element) {
131
+ if (key === "dataset") {
132
+ Object.entries(value).forEach(([ key, value ]) => (element.dataset[key] = value));
133
+ } else if (key in element) {
132
134
  element[key] = value;
133
135
  } else if (value !== null && value !== undefined) {
134
136
  element.setAttribute(key, value);
@@ -180,7 +182,19 @@ function extractPlainTextFromHtml(innerHtml = "") {
180
182
  }
181
183
 
182
184
  function isActiveAndVisible(element) {
183
- return element && !element.disabled && element.checkVisibility()
185
+ return element && !element.disabled && checkVisibility(element)
186
+ }
187
+
188
+ // no `checkVisibility` in Safari < 17.4
189
+ // https://developer.mozilla.org/en-US/docs/Web/API/Element/checkVisibility#browser_compatibility
190
+ function checkVisibility(element, options) {
191
+ if (element.checkVisibility) {
192
+ return element.checkVisibility(options)
193
+ } else {
194
+ // Will not work for body or a fixed position element child of the body
195
+ // which is OK since that doesn't apply in the toolbar where this is used
196
+ return Boolean(element.offsetParent)
197
+ }
184
198
  }
185
199
 
186
200
  function handleRollingTabIndex(elements, event) {
@@ -393,6 +407,7 @@ var ToolbarIcons = {
393
407
  class LexicalToolbarElement extends HTMLElement {
394
408
  static observedAttributes = [ "connected" ]
395
409
  #listeners = new ListenerBin()
410
+ #refreshToolbarAF = null
396
411
 
397
412
  constructor() {
398
413
  super();
@@ -403,7 +418,7 @@ class LexicalToolbarElement extends HTMLElement {
403
418
  }
404
419
 
405
420
  connectedCallback() {
406
- requestAnimationFrame(() => this.#refreshToolbarOverflow());
421
+ this.requestOverflowRefresh();
407
422
  this.setAttribute("role", "toolbar");
408
423
  this.#installResizeObserver();
409
424
  }
@@ -415,9 +430,12 @@ class LexicalToolbarElement extends HTMLElement {
415
430
  dispose() {
416
431
  this.#listeners.dispose();
417
432
 
433
+ cancelAnimationFrame(this.#refreshToolbarAF);
434
+
418
435
  this.editorElement = null;
419
436
  this.editor = null;
420
437
  this.selection = null;
438
+ this.#refreshToolbarAF = null;
421
439
 
422
440
  this.#createEditorPromise();
423
441
  }
@@ -443,10 +461,9 @@ class LexicalToolbarElement extends HTMLElement {
443
461
  this.#bindButtons();
444
462
  this.#bindHotkeys();
445
463
  this.#resetTabIndexValues();
446
- this.#setItemPositionValues();
447
464
  this.#monitorSelectionChanges();
448
465
  this.#monitorHistoryChanges();
449
- this.#refreshToolbarOverflow();
466
+ this.requestOverflowRefresh();
450
467
  this.#bindFocusListeners();
451
468
 
452
469
  this.resolveEditorPromise(editorElement);
@@ -458,6 +475,23 @@ class LexicalToolbarElement extends HTMLElement {
458
475
  return this.editorElement || await this.editorPromise
459
476
  }
460
477
 
478
+ requestOverflowRefresh() {
479
+ if (this.#refreshToolbarAF != null) return
480
+
481
+ this.#refreshToolbarAF = requestAnimationFrame(() => {
482
+ this.#refreshOverflow();
483
+ this.#refreshToolbarAF = null;
484
+ });
485
+ }
486
+
487
+ closeDropdowns({ except } = {}) {
488
+ this.#dropdowns.forEach((dropdown) => {
489
+ if (dropdown !== except) {
490
+ dropdown.close({ focusEditor: false });
491
+ }
492
+ });
493
+ }
494
+
461
495
  #reconnect() {
462
496
  this.disconnectedCallback();
463
497
  this.connectedCallback();
@@ -472,7 +506,7 @@ class LexicalToolbarElement extends HTMLElement {
472
506
  }
473
507
 
474
508
  #installResizeObserver() {
475
- const resizeObserver = new ResizeObserver(() => this.#refreshToolbarOverflow());
509
+ const resizeObserver = new ResizeObserver(() => this.requestOverflowRefresh());
476
510
  resizeObserver.observe(this);
477
511
  this.#listeners.track(() => resizeObserver.disconnect());
478
512
  }
@@ -539,30 +573,29 @@ class LexicalToolbarElement extends HTMLElement {
539
573
  }
540
574
 
541
575
  #handleEditorFocus = () => {
542
- const firstVisible = this.#focusableItems.find(isActiveAndVisible);
576
+ const firstVisible = this.#buttons.find(isActiveAndVisible);
543
577
  if (firstVisible) firstVisible.tabIndex = 0;
544
578
  }
545
579
 
546
580
  #handleEditorBlur = () => {
547
581
  this.#resetTabIndexValues();
548
- this.#closeDropdowns();
549
582
  }
550
583
 
551
584
  #handleKeydown = (event) => {
552
- handleRollingTabIndex(this.#focusableItems, event);
585
+ handleRollingTabIndex(this.#buttons, event);
553
586
  }
554
587
 
555
588
  #resetTabIndexValues() {
556
- this.#focusableItems.forEach((button) => {
589
+ this.#buttons.forEach((button) => {
557
590
  button.tabIndex = -1;
558
591
  });
559
592
  }
560
593
 
561
594
  #monitorSelectionChanges() {
562
- this.#listeners.track(this.editor.registerUpdateListener(() => {
563
- this.editor.getEditorState().read(() => {
595
+ this.#listeners.track(this.editor.registerUpdateListener(({ editorState }) => {
596
+ editorState.read(() => {
564
597
  this.#updateButtonStates();
565
- this.#closeDropdowns();
598
+ this.closeDropdowns();
566
599
  });
567
600
  }));
568
601
  }
@@ -630,96 +663,106 @@ class LexicalToolbarElement extends HTMLElement {
630
663
  }
631
664
  }
632
665
 
633
- #refreshToolbarOverflow = () => {
666
+ #refreshOverflow() {
667
+ this.#hideOverflowMenuButton();
634
668
  this.#resetToolbarOverflow();
669
+ this.#reindexToolbarItems();
635
670
  this.#compactMenu();
636
671
 
637
- this.#overflow.style.display = this.#overflowMenu.children.length ? "block" : "none";
638
- this.#overflow.setAttribute("nonce", getNonce());
672
+ const isOverflowing = this.#overflowMenuDropdown.children.length > 0;
639
673
 
640
- const isOverflowing = this.#overflowMenu.children.length > 0;
641
674
  this.toggleAttribute("overflowing", isOverflowing);
642
- this.#overflowMenu.toggleAttribute("disabled", !isOverflowing);
643
- }
644
-
645
- // Separates layout reads from DOM writes to avoid forced reflows during init.
646
- // Measures every button's right edge in a single read pass, figures out which
647
- // buttons overflow using math, and then moves them in a single write pass.
648
- // The previous implementation interleaved `scrollWidth`/`clientWidth` reads with
649
- // `prepend()` writes inside a loop, forcing one full browser reflow per button.
650
- #compactMenu() {
651
- const buttons = this.#buttons;
652
- if (buttons.length === 0) return
653
-
654
- const availableWidth = this.clientWidth + 1; // +1 for Safari zoom rounding
655
- const buttonRightEdges = buttons.map(button => button.offsetLeft + button.offsetWidth);
656
-
657
- let firstOverflowing = -1;
658
- for (let i = 0; i < buttons.length; i++) {
659
- if (buttonRightEdges[i] > availableWidth) {
660
- firstOverflowing = i;
661
- break
662
- }
663
- }
664
-
665
- if (firstOverflowing === -1) return
666
-
667
- // Move one extra button to reserve space for the overflow control, which is
668
- // `display: none` until we show it — matching the previous implementation's
669
- // "move one more after it stops overflowing" behaviour.
670
- const overflowIndex = Math.max(0, firstOverflowing - 1);
671
- const overflowButtons = buttons.slice(overflowIndex).reverse();
672
- for (const button of overflowButtons) {
673
- this.#overflowMenu.prepend(button);
674
- }
675
+ this.#setOverflowMenuNonce();
676
+ this.#showOverflowMenuButton(isOverflowing);
675
677
  }
676
678
 
677
679
  #resetToolbarOverflow() {
678
- const items = Array.from(this.#overflowMenu.children);
680
+ const items = Array.from(this.#overflowMenuDropdown.children);
679
681
  items.sort((a, b) => this.#itemPosition(b) - this.#itemPosition(a));
680
682
 
681
683
  for (const item of items) {
682
- 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;
685
+ item.removeAttribute("role");
683
686
  this.insertBefore(item, nextItem);
684
687
  }
685
688
  }
686
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
+
687
699
  #itemPosition(item) {
688
700
  return parseInt(item.dataset.position ?? "999")
689
701
  }
690
702
 
691
- #setItemPositionValues() {
703
+ #reindexToolbarItems() {
692
704
  this.#toolbarItems.forEach((item, index) => {
693
- if (item.dataset.position === undefined) {
694
- item.dataset.position = index;
695
- }
705
+ item.dataset.position = index;
696
706
  });
697
707
  }
698
708
 
699
- #closeDropdowns() {
700
- this.#dropdowns.forEach((details) => {
701
- details.open = false;
702
- });
703
- }
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
746
+ }
704
747
 
705
748
  get #dropdowns() {
706
- return this.querySelectorAll("details")
749
+ return this.querySelectorAll(":scope .lexxy-editor__toolbar-dropdown")
707
750
  }
708
751
 
709
- get #overflow() {
752
+ get #overflowMenuButton() {
710
753
  return this.querySelector(".lexxy-editor__toolbar-overflow")
711
754
  }
712
755
 
713
- get #overflowMenu() {
714
- return this.querySelector(".lexxy-editor__toolbar-overflow-menu")
756
+ get #overflowMenuDropdown() {
757
+ return this.#overflowMenuButton?.querySelector(":scope > [data-dropdown-panel]")
715
758
  }
716
759
 
717
- get #buttons() {
718
- 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])"))
719
762
  }
720
763
 
721
- get #focusableItems() {
722
- return Array.from(this.querySelectorAll(":scope button, :scope > details > summary"))
764
+ get #buttons() {
765
+ return Array.from(this.querySelectorAll(":scope button"))
723
766
  }
724
767
 
725
768
  get #toolbarItems() {
@@ -727,6 +770,8 @@ class LexicalToolbarElement extends HTMLElement {
727
770
  }
728
771
 
729
772
  static get defaultTemplate() {
773
+ const linkInputId = generateDomId("lexxy-link-url");
774
+
730
775
  return `
731
776
  <button class="lexxy-editor__toolbar-button" type="button" name="image" data-command="uploadImage" data-prevent-overflow="true" title="Add images and video">
732
777
  ${ToolbarIcons.image}
@@ -744,59 +789,59 @@ class LexicalToolbarElement extends HTMLElement {
744
789
  ${ToolbarIcons.italic}
745
790
  </button>
746
791
 
747
- <details class="lexxy-editor__toolbar-dropdown lexxy-editor__toolbar-dropdown--chevron" name="lexxy-dropdown">
748
- <summary class="lexxy-editor__toolbar-button" name="format" title="Text formatting">
792
+ <lexxy-toolbar-dropdown class="lexxy-editor__toolbar-dropdown">
793
+ <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">
749
794
  ${ToolbarIcons.heading}
750
- </summary>
751
- <div class="lexxy-editor__toolbar-dropdown-list">
752
- <button type="button" name="paragraph" data-command="setFormatParagraph" title="Paragraph">
795
+ </button>
796
+ <div data-dropdown-panel role="menu" class="lexxy-editor__toolbar-dropdown-list" hidden>
797
+ <button type="button" name="paragraph" data-command="setFormatParagraph" title="Paragraph" role="menuitem">
753
798
  ${ToolbarIcons.paragraph} <span>Normal</span>
754
799
  </button>
755
- <button type="button" name="heading-large" data-command="setFormatHeadingLarge" title="Large heading">
800
+ <button type="button" name="heading-large" data-command="setFormatHeadingLarge" title="Large heading" role="menuitem">
756
801
  ${ToolbarIcons.h2} <span>Large Heading</span>
757
802
  </button>
758
- <button type="button" name="heading-medium" data-command="setFormatHeadingMedium" title="Medium heading">
803
+ <button type="button" name="heading-medium" data-command="setFormatHeadingMedium" title="Medium heading" role="menuitem">
759
804
  ${ToolbarIcons.h3} <span>Medium Heading</span>
760
805
  </button>
761
- <button class="lexxy-editor__toolbar-group-end" type="button" name="heading-small" data-command="setFormatHeadingSmall" title="Small heading">
806
+ <button class="lexxy-editor__toolbar-group-end" type="button" name="heading-small" data-command="setFormatHeadingSmall" title="Small heading" role="menuitem">
762
807
  ${ToolbarIcons.h4} <span>Small Heading</span>
763
808
  </button>
764
809
  <div class="lexxy-editor__toolbar-separator" role="separator"></div>
765
- <button type="button" name="strikethrough" data-command="strikethrough" title="Strikethrough">
810
+ <button type="button" name="strikethrough" data-command="strikethrough" title="Strikethrough" role="menuitem">
766
811
  ${ToolbarIcons.strikethrough} <span>Strikethrough</span>
767
812
  </button>
768
- <button type="button" name="underline" data-command="underline" title="Underline">
813
+ <button type="button" name="underline" data-command="underline" title="Underline" role="menuitem">
769
814
  ${ToolbarIcons.underline} <span>Underline</span>
770
815
  </button>
771
816
  <div class="lexxy-editor__toolbar-separator" role="separator"></div>
772
- <button type="button" name="clear-formatting" data-command="clearFormatting" title="Clear formatting">
817
+ <button type="button" name="clear-formatting" data-command="clearFormatting" title="Clear formatting" role="menuitem">
773
818
  ${ToolbarIcons.clearFormatting} <span>Clear formatting</span>
774
819
  </button>
775
820
  </div>
776
- </details>
821
+ </lexxy-toolbar-dropdown>
777
822
 
778
- <details class="lexxy-editor__toolbar-dropdown lexxy-editor__toolbar-dropdown--chevron" name="lexxy-dropdown">
779
- <summary class="lexxy-editor__toolbar-button" name="highlight" title="Color highlight">
823
+ <lexxy-highlight-dropdown class="lexxy-editor__toolbar-dropdown lexxy-editor__toolbar-dropdown--highlight">
824
+ <button data-dropdown-trigger class="lexxy-editor__toolbar-button lexxy-editor__toolbar-button--chevron" type="button" name="highlight" title="Color highlight" aria-haspopup="menu" aria-expanded="false">
780
825
  ${ToolbarIcons.highlight}
781
- </summary>
782
- <lexxy-highlight-dropdown class="lexxy-editor__toolbar-dropdown-content">
826
+ </button>
827
+ <div data-dropdown-panel role="menu" hidden>
783
828
  <div class="lexxy-highlight-colors"></div>
784
- <button data-command="removeHighlight" class="lexxy-editor__toolbar-button lexxy-editor__toolbar-dropdown-reset">Remove all coloring</button>
785
- </lexxy-highlight-dropdown>
786
- </details>
829
+ <button data-command="removeHighlight" type="button" class="lexxy-editor__toolbar-button lexxy-editor__toolbar-dropdown-reset" role="menuitem">Remove all coloring</button>
830
+ </div>
831
+ </lexxy-highlight-dropdown>
787
832
 
788
- <details class="lexxy-editor__toolbar-dropdown" name="lexxy-dropdown">
789
- <summary class="lexxy-editor__toolbar-button lexxy-editor__toolbar-group-end" name="link" title="Link" data-hotkey="cmd+k ctrl+k">
833
+ <lexxy-link-dropdown class="lexxy-editor__toolbar-dropdown lexxy-editor__toolbar-dropdown--link">
834
+ <button data-dropdown-trigger class="lexxy-editor__toolbar-button lexxy-editor__toolbar-group-end" type="button" name="link" title="Link" data-hotkey="cmd+k ctrl+k" aria-haspopup="dialog" aria-expanded="false">
790
835
  ${ToolbarIcons.link}
791
- </summary>
792
- <lexxy-link-dropdown class="lexxy-editor__toolbar-dropdown-content">
793
- <input type="url" placeholder="Enter a URL…" class="input">
836
+ </button>
837
+ <div data-dropdown-panel role="dialog" aria-label="Link" hidden>
838
+ <input type="url" placeholder="Enter a URL…" class="input" id="${linkInputId}">
794
839
  <div class="lexxy-editor__toolbar-dropdown-actions">
795
840
  <button type="button" class="lexxy-editor__toolbar-button" value="link">Link</button>
796
841
  <button type="button" class="lexxy-editor__toolbar-button" value="unlink">Unlink</button>
797
842
  </div>
798
- </lexxy-link-dropdown>
799
- </details>
843
+ </div>
844
+ </lexxy-link-dropdown>
800
845
 
801
846
  <button class="lexxy-editor__toolbar-button" type="button" name="quote" data-command="insertQuoteBlock" title="Quote">
802
847
  ${ToolbarIcons.quote}
@@ -821,9 +866,7 @@ class LexicalToolbarElement extends HTMLElement {
821
866
  ${ToolbarIcons.hr}
822
867
  </button>
823
868
 
824
- <div class="lexxy-editor__toolbar-spacer" role="separator"></div>
825
-
826
- <button class="lexxy-editor__toolbar-button" type="button" name="undo" data-command="undo" title="Undo" disabled aria-disabled="true">
869
+ <button class="lexxy-editor__toolbar-button lexxy-editor__toolbar-button--push-right" type="button" name="undo" data-command="undo" title="Undo" disabled aria-disabled="true">
827
870
  ${ToolbarIcons.undo}
828
871
  </button>
829
872
 
@@ -831,601 +874,1043 @@ class LexicalToolbarElement extends HTMLElement {
831
874
  ${ToolbarIcons.redo}
832
875
  </button>
833
876
 
834
- <details class="lexxy-editor__toolbar-dropdown lexxy-editor__toolbar-overflow" name="lexxy-dropdown">
835
- <summary class="lexxy-editor__toolbar-button" aria-label="Show more toolbar buttons">${ToolbarIcons.overflow}</summary>
836
- <div class="lexxy-editor__toolbar-dropdown-content lexxy-editor__toolbar-overflow-menu" aria-label="More toolbar buttons"></div>
837
- </details>
877
+ <lexxy-toolbar-dropdown class="lexxy-editor__toolbar-dropdown lexxy-editor__toolbar-button--push-right lexxy-editor__toolbar-overflow">
878
+ <button data-dropdown-trigger class="lexxy-editor__toolbar-button" type="button" aria-haspopup="menu" aria-expanded="false" aria-label="Show more toolbar buttons">
879
+ ${ToolbarIcons.overflow}
880
+ </button>
881
+ <div data-dropdown-panel role="menu" class="lexxy-editor__toolbar-overflow-menu" aria-label="More toolbar buttons" hidden></div>
882
+ </lexxy-toolbar-dropdown>
838
883
  `
839
884
  }
840
885
  }
841
886
 
842
- function deepMerge(target, source) {
843
- const result = { ...target, ...source };
844
- for (const [ key, value ] of Object.entries(source)) {
845
- if (arePlainHashes(target[key], value)) {
846
- result[key] = deepMerge(target[key], value);
847
- }
848
- }
849
-
850
- return result
851
- }
887
+ function debounce(fn, wait) {
888
+ let timeout;
852
889
 
853
- function arePlainHashes(...values) {
854
- return values.every(value => value && value.constructor == Object)
890
+ return (...args) => {
891
+ clearTimeout(timeout);
892
+ timeout = setTimeout(() => fn(...args), wait);
893
+ }
855
894
  }
856
895
 
857
- class Configuration {
858
- #tree = {}
896
+ function debounceAsync(fn, wait) {
897
+ let timeout;
859
898
 
860
- constructor(...configs) {
861
- this.merge(...configs);
862
- }
899
+ return (...args) => {
900
+ clearTimeout(timeout);
863
901
 
864
- merge(...configs) {
865
- return this.#tree = configs.reduce(deepMerge, this.#tree)
902
+ return new Promise((resolve, reject) => {
903
+ timeout = setTimeout(async () => {
904
+ try {
905
+ const result = await fn(...args);
906
+ resolve(result);
907
+ } catch (err) {
908
+ reject(err);
909
+ }
910
+ }, wait);
911
+ })
866
912
  }
913
+ }
867
914
 
868
- get(path) {
869
- const keys = path.split(".");
870
- return keys.reduce((node, key) => node[key], this.#tree)
871
- }
915
+ function delay(ms) {
916
+ return new Promise((resolve) => setTimeout(resolve, ms))
872
917
  }
873
918
 
874
- function range(from, to) {
875
- return [ ...Array(1 + to - from).keys() ].map(i => i + from)
919
+ function nextFrame() {
920
+ return new Promise(requestAnimationFrame)
876
921
  }
877
922
 
878
- const global = new Configuration({
879
- attachmentTagName: "action-text-attachment",
880
- attachmentContentTypeNamespace: "actiontext",
881
- authenticatedUploads: false,
882
- extensions: []
883
- });
923
+ class ToolbarDropdown extends HTMLElement {
924
+ #listeners = new ListenerBin()
884
925
 
885
- const presets = new Configuration({
886
- default: {
887
- attachments: true,
888
- markdown: true,
889
- multiLine: true,
890
- permittedAttachmentTypes: null,
891
- richText: true,
892
- toolbar: {
893
- upload: "both"
894
- },
895
- highlight: {
896
- buttons: {
897
- color: range(1, 9).map(n => `var(--highlight-${n})`),
898
- "background-color": range(1, 9).map(n => `var(--highlight-bg-${n})`),
899
- },
900
- permit: {
901
- color: [],
902
- "background-color": []
903
- }
904
- }
926
+ connectedCallback() {
927
+ this.#onToolbarEditor(() => {
928
+ this.#registerListeners();
929
+ this.editorReady();
930
+ });
905
931
  }
906
- });
907
932
 
908
- var Lexxy = {
909
- global,
910
- presets,
911
- configure({ global: newGlobal, ...newPresets }) {
912
- if (newGlobal) {
913
- global.merge(newGlobal);
914
- }
915
- presets.merge(newPresets);
933
+ disconnectedCallback() {
934
+ this.#listeners.dispose();
916
935
  }
917
- };
918
-
919
- function setSanitizerConfig(allowedTags) {
920
- DOMPurify.clearConfig();
921
- DOMPurify.setConfig(buildConfig(allowedTags));
922
- }
923
-
924
- function sanitize(html) {
925
- return DOMPurify.sanitize(html)
926
- }
927
-
928
- function bytesToHumanSize(bytes) {
929
- if (bytes === 0) return "0 B"
930
- const sizes = [ "B", "KB", "MB", "GB", "TB", "PB" ];
931
- const i = Math.floor(Math.log(bytes) / Math.log(1024));
932
- const value = bytes / Math.pow(1024, i);
933
- return `${ value.toFixed(2) } ${ sizes[i] }`
934
- }
935
936
 
936
- function extractFileName(string) {
937
- return string.split("/").pop()
938
- }
937
+ editorReady() {}
938
+ onOpen() {}
939
+ onClose() {}
939
940
 
940
- // The content attribute is raw HTML (matching Trix/ActionText). Older Lexxy
941
- // versions JSON-encoded it, so try JSON.parse first for backward compatibility.
942
- function parseAttachmentContent(content) {
943
- try {
944
- return JSON.parse(content)
945
- } catch {
946
- return content
941
+ get trigger() {
942
+ return this.querySelector(":scope > [data-dropdown-trigger]")
947
943
  }
948
- }
949
944
 
950
- class CustomActionTextAttachmentNode extends DecoratorNode {
951
- static getType() {
952
- return "custom_action_text_attachment"
945
+ get panel() {
946
+ return this.querySelector(":scope > [data-dropdown-panel]")
953
947
  }
954
948
 
955
- static clone(node) {
956
- return new CustomActionTextAttachmentNode({ ...node }, node.__key)
949
+ get toolbar() {
950
+ return this.closest("lexxy-toolbar")
957
951
  }
958
952
 
959
- static importJSON(serializedNode) {
960
- return new CustomActionTextAttachmentNode({ ...serializedNode })
953
+ get editorElement() {
954
+ return this.toolbar?.editorElement
961
955
  }
962
956
 
963
- static importDOM() {
964
- return {
965
- [this.TAG_NAME]: (element) => {
966
- if (!element.getAttribute("content")) {
967
- return null
968
- }
969
-
970
- return {
971
- conversion: (attachment) => {
972
- // Preserve initial space if present since Lexical removes it
973
- const nodes = [];
974
- const previousSibling = attachment.previousSibling;
975
- if (previousSibling && previousSibling.nodeType === Node.TEXT_NODE && /\s$/.test(previousSibling.textContent)) {
976
- nodes.push($createTextNode(" "));
977
- }
978
-
979
- const innerHtml = parseAttachmentContent(attachment.getAttribute("content"));
957
+ get editor() {
958
+ return this.toolbar?.editor
959
+ }
980
960
 
981
- nodes.push(new CustomActionTextAttachmentNode({
982
- sgid: attachment.getAttribute("sgid"),
983
- innerHtml,
984
- plainText: attachment.textContent.trim() || extractPlainTextFromHtml(innerHtml),
985
- contentType: attachment.getAttribute("content-type")
986
- }));
961
+ get isOpen() {
962
+ return this.panel.hidden === false
963
+ }
987
964
 
988
- const nextSibling = attachment.nextSibling;
989
- if (nextSibling && nextSibling.nodeType === Node.TEXT_NODE && /^\s/.test(nextSibling.textContent)) {
990
- nodes.push($createTextNode(" "));
991
- }
965
+ get isClosed() {
966
+ return !this.isOpen
967
+ }
992
968
 
993
- return { node: nodes }
994
- },
995
- priority: 2
996
- }
997
- }
998
- }
969
+ track(...listeners) {
970
+ this.#listeners.track(...listeners);
999
971
  }
1000
972
 
1001
- static get TAG_NAME() {
1002
- return Lexxy.global.get("attachmentTagName")
973
+ open() {
974
+ if (this.isOpen) return
975
+ this.trigger.setAttribute("aria-expanded", "true");
976
+ this.panel.hidden = false;
977
+ this.onOpen();
978
+ this.#focusFirstInteractive();
1003
979
  }
1004
980
 
1005
- constructor({ tagName, sgid, contentType, innerHtml, plainText }, key) {
1006
- super(key);
981
+ close({ focusEditor = true } = {}) {
982
+ if (focusEditor) this.editor?.focus();
1007
983
 
1008
- const contentTypeNamespace = Lexxy.global.get("attachmentContentTypeNamespace");
984
+ if (this.isClosed) return
985
+ this.trigger.setAttribute("aria-expanded", "false");
986
+ this.panel.hidden = true;
987
+ this.onClose();
988
+ }
1009
989
 
1010
- this.tagName = tagName || CustomActionTextAttachmentNode.TAG_NAME;
1011
- this.sgid = sgid;
1012
- this.contentType = contentType || `application/vnd.${contentTypeNamespace}.unknown`;
1013
- this.innerHtml = innerHtml;
1014
- this.plainText = plainText ?? extractPlainTextFromHtml(innerHtml);
990
+ #registerListeners() {
991
+ this.#listeners.track(
992
+ registerEventListener(this, "keydown", this.#handleKeyDown),
993
+ registerEventListener(this.trigger, "click", this.#handleTriggerClick)
994
+ );
1015
995
  }
1016
996
 
1017
- createDOM() {
1018
- const figure = createElement(this.tagName, { "content-type": this.contentType, "data-lexxy-decorator": true });
997
+ #handleTriggerClick = () => {
998
+ if (this.isOpen) {
999
+ this.close({ focusEditor: false });
1000
+ } else {
1001
+ this.toolbar?.closeDropdowns({ except: this });
1002
+ this.open();
1003
+ }
1004
+ }
1019
1005
 
1020
- figure.insertAdjacentHTML("beforeend", sanitize(this.innerHtml));
1006
+ async #onToolbarEditor(callback) {
1007
+ if (!this.toolbar) return
1021
1008
 
1022
- const deleteButton = createElement("lexxy-node-delete-button");
1023
- figure.appendChild(deleteButton);
1009
+ await this.toolbar.getEditorElement();
1010
+ if (this.isConnected && this.toolbar) callback();
1011
+ }
1024
1012
 
1025
- return figure
1013
+ #handleKeyDown = (event) => {
1014
+ if (event.key === "Escape") {
1015
+ event.stopPropagation();
1016
+ this.close();
1017
+ }
1026
1018
  }
1027
1019
 
1028
- updateDOM() {
1029
- return false
1020
+ async #focusFirstInteractive() {
1021
+ this.#interactiveElements[0]?.focus();
1022
+ await this.#resetTabIndexValues();
1030
1023
  }
1031
1024
 
1032
- getTextContent() {
1033
- return "\ufeff"
1025
+ async #resetTabIndexValues() {
1026
+ await nextFrame();
1027
+ this.#buttons.forEach((element, index) => {
1028
+ element.setAttribute("tabindex", index === 0 ? 0 : "-1");
1029
+ });
1034
1030
  }
1035
1031
 
1036
- getReadableTextContent() {
1037
- return this.plainText || `[${this.contentType}]`
1032
+ get #interactiveElements() {
1033
+ return Array.from(this.panel.querySelectorAll("button, input"))
1038
1034
  }
1039
1035
 
1040
- isInline() {
1041
- return true
1036
+ get #buttons() {
1037
+ return Array.from(this.panel.querySelectorAll("button"))
1042
1038
  }
1039
+ }
1043
1040
 
1044
- exportDOM() {
1045
- const attachment = createElement(this.tagName, {
1046
- sgid: this.sgid,
1047
- content: this.innerHtml,
1048
- "content-type": this.contentType
1049
- });
1041
+ const APPLY_HIGHLIGHT_SELECTOR = "button.lexxy-highlight-button";
1042
+ const REMOVE_HIGHLIGHT_SELECTOR = "[data-command='removeHighlight']";
1050
1043
 
1051
- return { element: attachment }
1044
+ // Use Symbol instead of null since $getSelectionStyleValueForProperty
1045
+ // responds differently for backward selections if null is the default
1046
+ // see https://github.com/facebook/lexical/issues/8013
1047
+ const NO_STYLE = Symbol("no_style");
1048
+
1049
+ class HighlightDropdown extends ToolbarDropdown {
1050
+ editorReady() {
1051
+ this.#setUpButtons();
1052
+ this.#registerButtonHandlers();
1052
1053
  }
1053
1054
 
1054
- exportJSON() {
1055
- return {
1056
- type: "custom_action_text_attachment",
1057
- version: 1,
1058
- tagName: this.tagName,
1059
- sgid: this.sgid,
1060
- contentType: this.contentType,
1061
- innerHtml: this.innerHtml,
1062
- plainText: this.plainText
1063
- }
1055
+ onOpen() {
1056
+ this.editor.getEditorState().read(() => {
1057
+ this.#updateColorButtonStates($getSelection());
1058
+ });
1064
1059
  }
1065
1060
 
1066
- decorate() {
1067
- return null
1061
+ #registerButtonHandlers() {
1062
+ this.#colorButtons.forEach(button => {
1063
+ this.track(registerEventListener(button, "click", this.#handleColorButtonClick));
1064
+ });
1068
1065
  }
1069
- }
1070
1066
 
1071
- function dasherize(value) {
1072
- return value.replace(/([A-Z])/g, (_, char) => `-${char.toLowerCase()}`)
1073
- }
1067
+ #setUpButtons() {
1068
+ this.#buttonContainer.innerHTML = "";
1074
1069
 
1075
- function isUrl(string) {
1076
- try {
1077
- new URL(string);
1078
- return true
1079
- } catch {
1080
- return false
1070
+ const colorGroups = this.editorElement.config.get("highlight.buttons");
1071
+
1072
+ this.#populateButtonGroup("color", colorGroups.color);
1073
+ this.#populateButtonGroup("background-color", colorGroups["background-color"]);
1074
+
1075
+ const maxNumberOfColors = Math.max(colorGroups.color.length, colorGroups["background-color"].length);
1076
+ this.panel.style.setProperty("--max-colors", maxNumberOfColors);
1081
1077
  }
1082
- }
1083
1078
 
1084
- function normalizeFilteredText(string) {
1085
- return string
1086
- .toLowerCase()
1087
- .normalize("NFD").replace(/[\u0300-\u036f]/g, "") // Remove diacritics
1088
- }
1079
+ #populateButtonGroup(attribute, values) {
1080
+ values.forEach((value, index) => {
1081
+ this.#buttonContainer.appendChild(this.#createButton(attribute, value, index));
1082
+ });
1083
+ }
1089
1084
 
1090
- function filterMatchPosition(text, potentialMatch) {
1091
- const normalizedText = normalizeFilteredText(text);
1092
- const normalizedMatch = normalizeFilteredText(potentialMatch);
1085
+ #createButton(attribute, value, index) {
1086
+ return createElement("button", {
1087
+ type: "button",
1088
+ dataset: { value, style: attribute },
1089
+ style: `${attribute}: ${value}`,
1090
+ class: "lexxy-editor__toolbar-button lexxy-highlight-button",
1091
+ name: `${attribute}-${index}`,
1092
+ role: "menuitem"
1093
+ })
1094
+ }
1093
1095
 
1094
- if (!normalizedMatch) return 0
1096
+ #handleColorButtonClick = (event) => {
1097
+ event.preventDefault();
1095
1098
 
1096
- const match = normalizedText.match(new RegExp(`(?:^|\\b)${escapeForRegExp(normalizedMatch)}`));
1097
- return match ? match.index : -1
1098
- }
1099
+ const button = event.target.closest(APPLY_HIGHLIGHT_SELECTOR);
1100
+ if (!button) return
1099
1101
 
1100
- function upcaseFirst(string) {
1101
- return string.charAt(0).toUpperCase() + string.slice(1)
1102
- }
1102
+ const { style, value } = button.dataset;
1103
1103
 
1104
- function escapeForRegExp(string) {
1105
- return string.replace(/[.*+?^${}()|[\]\\]/g, "\\$&")
1106
- }
1104
+ this.editor.dispatchCommand("toggleHighlight", { [style]: value });
1105
+ this.close();
1106
+ }
1107
1107
 
1108
- // Parses a value that may arrive as a boolean or as a string (e.g. from DOM
1109
- // getAttribute) into a proper boolean. Ensures "false" doesn't evaluate as truthy.
1110
- function parseBoolean(value) {
1111
- if (typeof value === "string") return value === "true"
1112
- return Boolean(value)
1113
- }
1108
+ #updateColorButtonStates(selection) {
1109
+ if (!$isRangeSelection(selection)) { return }
1114
1110
 
1115
- class LexxyExtension {
1116
- #editorElement
1111
+ // Use non-"" default, so "" indicates mixed highlighting
1112
+ const textColor = $getSelectionStyleValueForProperty(selection, "color", NO_STYLE);
1113
+ const backgroundColor = $getSelectionStyleValueForProperty(selection, "background-color", NO_STYLE);
1117
1114
 
1118
- constructor(editorElement) {
1119
- this.#editorElement = editorElement;
1120
- }
1115
+ this.#colorButtons.forEach(button => {
1116
+ const matchesSelection = button.dataset.value === textColor || button.dataset.value === backgroundColor;
1117
+ const next = matchesSelection.toString();
1118
+ if (button.getAttribute("aria-pressed") !== next) {
1119
+ button.setAttribute("aria-pressed", next);
1120
+ }
1121
+ });
1121
1122
 
1122
- get editorElement() {
1123
- return this.#editorElement
1123
+ const hasHighlight = textColor !== NO_STYLE || backgroundColor !== NO_STYLE;
1124
+ this.panel.querySelector(REMOVE_HIGHLIGHT_SELECTOR).disabled = !hasHighlight;
1124
1125
  }
1125
1126
 
1126
- get editorConfig() {
1127
- return this.#editorElement.config
1127
+ get #buttonContainer() {
1128
+ return this.panel.querySelector(".lexxy-highlight-colors")
1128
1129
  }
1129
1130
 
1130
- // optional: defaults to true
1131
- get enabled() {
1132
- return true
1131
+ get #colorButtons() {
1132
+ return Array.from(this.panel.querySelectorAll(APPLY_HIGHLIGHT_SELECTOR))
1133
1133
  }
1134
+ }
1134
1135
 
1135
- get lexicalExtension() {
1136
- return null
1137
- }
1136
+ class LinkDropdown extends ToolbarDropdown {
1137
+ editorReady() {
1138
+ this.input = this.panel.querySelector("input");
1138
1139
 
1139
- get allowedElements() {
1140
- return []
1140
+ this.track(
1141
+ registerEventListener(this.input, "keydown", this.#handleEnter),
1142
+ registerEventListener(this.linkButton, "click", this.#handleLink),
1143
+ registerEventListener(this.unlinkButton, "click", this.#handleUnlink)
1144
+ );
1141
1145
  }
1142
1146
 
1143
- initializeToolbar(_lexxyToolbar) {
1147
+ onOpen() {
1148
+ this.input.value = this.#selectedLinkUrl;
1149
+ this.input.required = true;
1150
+ }
1144
1151
 
1152
+ onClose() {
1153
+ this.input.required = false;
1145
1154
  }
1146
1155
 
1147
- dispose() {
1156
+ get linkButton() {
1157
+ return this.panel.querySelector("[value='link']")
1148
1158
  }
1149
- }
1150
1159
 
1151
- function $containsRangeSelection(node, selection = $getSelection()) {
1152
- if ($isRangeSelection(selection)) {
1153
- const { commonAncestor } = $getCommonAncestor(selection.focus.getNode(), selection.anchor.getNode());
1154
- return $findMatchingParent(commonAncestor, parent => parent.is(node))
1155
- } else {
1156
- return false
1160
+ get unlinkButton() {
1161
+ return this.panel.querySelector("[value='unlink']")
1157
1162
  }
1158
- }
1159
1163
 
1160
- function $createNodeSelectionWith(...nodes) {
1161
- const selection = $createNodeSelection();
1162
- nodes.forEach(node => selection.add(node.getKey()));
1163
- return selection
1164
- }
1164
+ #handleEnter = (event) => {
1165
+ if (event.key === "Enter") {
1166
+ event.preventDefault();
1167
+ event.stopPropagation();
1168
+ this.#handleLink(event);
1169
+ }
1170
+ }
1165
1171
 
1166
- function $isShadowRoot(node) {
1167
- return $isElementNode(node) && $isRootOrShadowRoot(node) && !$isRootNode(node)
1168
- }
1172
+ #handleLink = () => {
1173
+ if (!this.input.checkValidity()) {
1174
+ this.input.reportValidity();
1175
+ return
1176
+ }
1169
1177
 
1170
- function $makeSafeForRoot(node) {
1171
- if ($isTextNode(node)) {
1172
- return $wrapNodeInElement(node, $createParagraphNode)
1173
- } else if (node.isParentRequired()) {
1174
- const parent = node.createRequiredParent();
1175
- return $wrapNodeInElement(node, parent)
1176
- } else {
1177
- return node
1178
+ this.editor.dispatchCommand("link", this.input.value);
1179
+ this.close();
1178
1180
  }
1179
- }
1180
1181
 
1181
- function getListType(node) {
1182
- const list = $getNearestNodeOfType(node, ListNode);
1183
- return list?.getListType() ?? null
1184
- }
1182
+ #handleUnlink = () => {
1183
+ this.editor.dispatchCommand("unlink");
1184
+ this.close();
1185
+ }
1185
1186
 
1186
- function isEditorFocused(editor) {
1187
- const rootElement = editor.getRootElement();
1188
- return rootElement !== null && rootElement.contains(document.activeElement)
1187
+ get #selectedLinkUrl() {
1188
+ return this.editor.getEditorState().read(() => {
1189
+ const linkNode = this.editorElement.selection.nearestNodeOfType(LinkNode);
1190
+ return linkNode?.getURL() ?? ""
1191
+ })
1192
+ }
1189
1193
  }
1190
1194
 
1191
- function $isAtNodeEdge(point, atStart = null) {
1192
- if (atStart === null) {
1193
- return $isAtNodeEdge(point, true) || $isAtNodeEdge(point, false)
1194
- } else {
1195
- return atStart ? $isAtNodeStart(point) : $isAtNodeEnd(point)
1195
+ function deepMerge(target, source) {
1196
+ const result = { ...target, ...source };
1197
+ for (const [ key, value ] of Object.entries(source)) {
1198
+ if (arePlainHashes(target[key], value)) {
1199
+ result[key] = deepMerge(target[key], value);
1200
+ }
1196
1201
  }
1197
- }
1198
1202
 
1199
- function $isAtNodeStart(point) {
1200
- return point.offset === 0
1203
+ return result
1201
1204
  }
1202
1205
 
1203
- function extendTextNodeConversion(conversionName, ...callbacks) {
1204
- return extendConversion(TextNode, conversionName, (conversionOutput, element) => ({
1205
- ...conversionOutput,
1206
- forChild: (lexicalNode, parentNode) => {
1207
- const originalForChild = conversionOutput?.forChild ?? (x => x);
1208
- let childNode = originalForChild(lexicalNode, parentNode);
1209
-
1210
-
1211
- if ($isTextNode(childNode)) {
1212
- childNode = callbacks.reduce(
1213
- (childNode, callback) => callback(childNode, element) ?? childNode,
1214
- childNode
1215
- );
1216
- return childNode
1217
- }
1218
- }
1219
- }))
1206
+ function arePlainHashes(...values) {
1207
+ return values.every(value => value && value.constructor == Object)
1220
1208
  }
1221
1209
 
1222
- function extendConversion(nodeKlass, conversionName, callback = (output => output)) {
1223
- return (element) => {
1224
- const converter = nodeKlass.importDOM()?.[conversionName]?.(element);
1225
- if (!converter) return null
1210
+ class Configuration {
1211
+ #tree = {}
1226
1212
 
1227
- const conversionOutput = converter.conversion(element);
1228
- if (!conversionOutput) return conversionOutput
1213
+ constructor(...configs) {
1214
+ this.merge(...configs);
1215
+ }
1229
1216
 
1230
- return callback(conversionOutput, element) ?? conversionOutput
1217
+ merge(...configs) {
1218
+ return this.#tree = configs.reduce(deepMerge, this.#tree)
1231
1219
  }
1232
- }
1233
1220
 
1234
- function $isCursorOnLastLine(selection) {
1235
- const anchorNode = selection.anchor.getNode();
1236
- const elementNode = $isElementNode(anchorNode) ? anchorNode : anchorNode.getParentOrThrow();
1237
- const children = elementNode.getChildren();
1238
- if (children.length === 0) return true
1239
-
1240
- const lastChild = children[children.length - 1];
1241
-
1242
- if (anchorNode === elementNode.getLatest() && selection.anchor.offset === children.length) return true
1243
- if (anchorNode === lastChild) return true
1244
-
1245
- const lastLineBreakIndex = children.findLastIndex(child => $isLineBreakNode(child));
1246
- if (lastLineBreakIndex === -1) return true
1247
-
1248
- const anchorIndex = children.indexOf(anchorNode);
1249
- return anchorIndex > lastLineBreakIndex
1221
+ get(path) {
1222
+ const keys = path.split(".");
1223
+ return keys.reduce((node, key) => node[key], this.#tree)
1224
+ }
1250
1225
  }
1251
1226
 
1252
- function $isBlankNode(node) {
1253
- if (node.getTextContent().trim() !== "") return false
1254
-
1255
- const children = node.getChildren?.();
1256
- if (!children || children.length === 0) return true
1257
-
1258
- return children.every(child => {
1259
- if ($isLineBreakNode(child)) return true
1260
- return $isBlankNode(child)
1261
- })
1227
+ function range(from, to) {
1228
+ return [ ...Array(1 + to - from).keys() ].map(i => i + from)
1262
1229
  }
1263
1230
 
1264
- function $trimTrailingBlankNodes(parent) {
1265
- for (const child of $lastToFirstIterator(parent)) {
1266
- if ($isBlankNode(child)) {
1267
- child.remove();
1268
- } else {
1269
- break
1231
+ const global = new Configuration({
1232
+ attachmentTagName: "action-text-attachment",
1233
+ attachmentContentTypeNamespace: "actiontext",
1234
+ authenticatedUploads: false,
1235
+ extensions: []
1236
+ });
1237
+
1238
+ const presets = new Configuration({
1239
+ default: {
1240
+ attachments: true,
1241
+ markdown: true,
1242
+ multiLine: true,
1243
+ permittedAttachmentTypes: null,
1244
+ richText: true,
1245
+ toolbar: {
1246
+ upload: "both"
1247
+ },
1248
+ highlight: {
1249
+ buttons: {
1250
+ color: range(1, 9).map(n => `var(--highlight-${n})`),
1251
+ "background-color": range(1, 9).map(n => `var(--highlight-bg-${n})`),
1252
+ },
1253
+ permit: {
1254
+ color: [],
1255
+ "background-color": []
1256
+ }
1270
1257
  }
1271
1258
  }
1272
- }
1259
+ });
1273
1260
 
1274
- // A list item is structurally empty if it contains no meaningful content.
1275
- // Unlike getTextContent().trim() === "", this walks descendants to ensure
1276
- // decorator nodes (mentions, attachments whose getTextContent() may return
1277
- // invisible characters like \ufeff) are treated as non-empty content.
1278
- function $isListItemStructurallyEmpty(listItem) {
1279
- const children = listItem.getChildren();
1280
- for (const child of children) {
1281
- if ($isDecoratorNode(child)) return false
1282
- if ($isLineBreakNode(child)) continue
1283
- if ($isTextNode(child)) {
1284
- if (child.getTextContent().trim() !== "") return false
1285
- } else if ($isElementNode(child)) {
1286
- if (child.getTextContent().trim() !== "") return false
1261
+ var Lexxy = {
1262
+ global,
1263
+ presets,
1264
+ configure({ global: newGlobal, ...newPresets }) {
1265
+ if (newGlobal) {
1266
+ global.merge(newGlobal);
1287
1267
  }
1268
+ presets.merge(newPresets);
1288
1269
  }
1289
- return true
1270
+ };
1271
+
1272
+ function setSanitizerConfig(allowedTags) {
1273
+ DOMPurify.clearConfig();
1274
+ DOMPurify.setConfig(buildConfig(allowedTags));
1290
1275
  }
1291
1276
 
1292
- function isAttachmentSpacerTextNode(node, previousNode, index, childCount) {
1293
- return $isTextNode(node)
1294
- && node.getTextContent() === " "
1295
- && index === childCount - 1
1296
- && previousNode instanceof CustomActionTextAttachmentNode
1277
+ function sanitize(html) {
1278
+ return DOMPurify.sanitize(html)
1297
1279
  }
1298
1280
 
1299
- function $splitParagraphsAtLineBreakBoundaries(selection) {
1300
- $ensureForwardRangeSelection(selection);
1281
+ function bytesToHumanSize(bytes) {
1282
+ if (bytes === 0) return "0 B"
1283
+ const sizes = [ "B", "KB", "MB", "GB", "TB", "PB" ];
1284
+ const i = Math.floor(Math.log(bytes) / Math.log(1024));
1285
+ const value = bytes / Math.pow(1024, i);
1286
+ return `${ value.toFixed(2) } ${ sizes[i] }`
1287
+ }
1301
1288
 
1302
- // Split focus first so the anchor split position stays valid.
1303
- $splitAtNearestLineBreak(selection.focus, "next");
1304
- $splitAtNearestLineBreak(selection.anchor, "previous");
1289
+ function extractFileName(string) {
1290
+ return string.split("/").pop()
1305
1291
  }
1306
1292
 
1307
- function $splitAtNearestLineBreak(point, direction) {
1308
- const paragraph = point.getNode().getTopLevelElement();
1309
- if (!paragraph || !$isParagraphNode(paragraph)) return
1293
+ // The content attribute is raw HTML (matching Trix/ActionText). Older Lexxy
1294
+ // versions JSON-encoded it, so try JSON.parse first for backward compatibility.
1295
+ function parseAttachmentContent(content) {
1296
+ try {
1297
+ return JSON.parse(content)
1298
+ } catch {
1299
+ return content
1300
+ }
1301
+ }
1310
1302
 
1311
- const pointNode = point.getNode();
1312
- const selectionChild = pointNode.getParent().is(paragraph) ? pointNode : pointNode.getParentOrThrow();
1313
- const lineBreakCaret = $caretAtNearestNodeOfType(selectionChild, LineBreakNode, direction);
1314
- if (!lineBreakCaret) return
1303
+ function mimeTypeToExtension(mimeType) {
1304
+ if (!mimeType) return null
1315
1305
 
1316
- const lineBreak = lineBreakCaret.origin;
1317
- const isEdge = lineBreakCaret.getNodeAtCaret() === null;
1306
+ const extension = mimeType.split("/")[1];
1307
+ return extension
1308
+ }
1318
1309
 
1319
- if (!isEdge) {
1320
- $splitNode(paragraph, lineBreak.getIndexWithinParent());
1310
+ class CustomActionTextAttachmentNode extends DecoratorNode {
1311
+ static getType() {
1312
+ return "custom_action_text_attachment"
1321
1313
  }
1322
1314
 
1323
- lineBreak.remove();
1324
- }
1315
+ static clone(node) {
1316
+ return new CustomActionTextAttachmentNode({ ...node }, node.__key)
1317
+ }
1325
1318
 
1326
- function $caretAtNearestNodeOfType(node, klass, direction) {
1327
- for (const caret of $getSiblingCaret(node, direction)) {
1328
- if (caret.origin instanceof klass) return caret
1319
+ static importJSON(serializedNode) {
1320
+ return new CustomActionTextAttachmentNode({ ...serializedNode })
1329
1321
  }
1330
- return null
1331
- }
1332
1322
 
1333
- // Payload: Record<nodeKey, { patch?, replace? }>
1334
- // - patch: plain object, shallow-merged into the existing node's properties
1335
- // - replace: a LexicalNode instance that replaces the node
1336
- const REWRITE_HISTORY_COMMAND = createCommand("REWRITE_HISTORY_COMMAND");
1323
+ static importDOM() {
1324
+ return {
1325
+ [this.TAG_NAME]: (element) => {
1326
+ if (!element.getAttribute("content")) {
1327
+ return null
1328
+ }
1337
1329
 
1338
- class RewritableHistoryExtension extends LexxyExtension {
1339
- #historyState = null
1330
+ return {
1331
+ conversion: (attachment) => {
1332
+ // Preserve initial space if present since Lexical removes it
1333
+ const nodes = [];
1334
+ const previousSibling = attachment.previousSibling;
1335
+ if (previousSibling && previousSibling.nodeType === Node.TEXT_NODE && /\s$/.test(previousSibling.textContent)) {
1336
+ nodes.push($createTextNode(" "));
1337
+ }
1340
1338
 
1341
- get lexicalExtension() {
1342
- return defineExtension({
1343
- name: "lexxy/rewritable-history",
1344
- dependencies: [ HistoryExtension ],
1345
- register: (editor, _config, state) => {
1346
- const historyOutput = state.getDependency(HistoryExtension).output;
1347
- this.#historyState = historyOutput.historyState.value;
1339
+ const innerHtml = parseAttachmentContent(attachment.getAttribute("content"));
1348
1340
 
1349
- return editor.registerCommand(
1350
- REWRITE_HISTORY_COMMAND,
1351
- (rewrites) => this.#rewriteHistory(rewrites),
1352
- COMMAND_PRIORITY_EDITOR
1353
- )
1354
- }
1355
- })
1356
- }
1341
+ nodes.push(new CustomActionTextAttachmentNode({
1342
+ sgid: attachment.getAttribute("sgid"),
1343
+ innerHtml,
1344
+ plainText: attachment.textContent.trim() || extractPlainTextFromHtml(innerHtml),
1345
+ contentType: attachment.getAttribute("content-type")
1346
+ }));
1357
1347
 
1358
- get historyState() {
1359
- return this.#historyState
1360
- }
1348
+ const nextSibling = attachment.nextSibling;
1349
+ if (nextSibling && nextSibling.nodeType === Node.TEXT_NODE && /^\s/.test(nextSibling.textContent)) {
1350
+ nodes.push($createTextNode(" "));
1351
+ }
1361
1352
 
1362
- get #allHistoryEntries() {
1363
- const entries = Array.from(this.#historyState.undoStack);
1364
- if (this.#historyState.current) entries.push(this.#historyState.current);
1365
- return entries.concat(this.#historyState.redoStack)
1353
+ return { node: nodes }
1354
+ },
1355
+ priority: 2
1356
+ }
1357
+ }
1358
+ }
1366
1359
  }
1367
1360
 
1368
- #rewriteHistory(rewrites) {
1369
- this.#applyRewritesImmediatelyToCurrentState(rewrites);
1370
- this.#applyRewritesToHistory(rewrites);
1371
-
1372
- return true
1361
+ static get TAG_NAME() {
1362
+ return Lexxy.global.get("attachmentTagName")
1373
1363
  }
1374
1364
 
1375
- #applyRewritesImmediatelyToCurrentState(rewrites) {
1376
- $getEditor().update(() => {
1377
- for (const [ nodeKey, { patch, replace } ] of Object.entries(rewrites)) {
1378
- const node = $getNodeByKey(nodeKey);
1379
- if (!node) continue
1365
+ constructor({ tagName, sgid, contentType, innerHtml, plainText }, key) {
1366
+ super(key);
1380
1367
 
1381
- if (patch) Object.assign(node.getWritable(), patch);
1382
- if (replace) node.replace(replace);
1383
- }
1384
- }, { discrete: true, tag: this.#getBackgroundUpdateTags() });
1385
- }
1368
+ const contentTypeNamespace = Lexxy.global.get("attachmentContentTypeNamespace");
1386
1369
 
1387
- #applyRewritesToHistory(rewrites) {
1388
- const nodeKeys = Object.keys(rewrites);
1370
+ this.tagName = tagName || CustomActionTextAttachmentNode.TAG_NAME;
1371
+ this.sgid = sgid;
1372
+ this.contentType = contentType || `application/vnd.${contentTypeNamespace}.unknown`;
1373
+ this.innerHtml = innerHtml;
1374
+ this.plainText = plainText ?? extractPlainTextFromHtml(innerHtml);
1375
+ }
1389
1376
 
1390
- for (const entry of this.#allHistoryEntries) {
1391
- if (!this.#entryHasSomeKeys(entry, nodeKeys)) continue
1377
+ createDOM() {
1378
+ const figure = createElement(this.tagName, { "content-type": this.contentType, "data-lexxy-decorator": true });
1392
1379
 
1393
- const editorState = entry.editorState = safeCloneEditorState(entry.editorState);
1380
+ figure.insertAdjacentHTML("beforeend", sanitize(this.innerHtml));
1394
1381
 
1395
- for (const [ nodeKey, { patch, replace } ] of Object.entries(rewrites)) {
1396
- const node = editorState._nodeMap.get(nodeKey);
1397
- if (!node) continue
1382
+ const deleteButton = createElement("lexxy-node-delete-button");
1383
+ figure.appendChild(deleteButton);
1398
1384
 
1399
- if (patch) {
1400
- this.#patchNodeInEditorState(editorState, node, patch);
1401
- } else if (replace) {
1402
- this.#replaceNodeInEditorState(editorState, node, replace);
1403
- }
1404
- }
1405
- }
1385
+ return figure
1406
1386
  }
1407
1387
 
1408
- #entryHasSomeKeys(entry, nodeKeys) {
1409
- return nodeKeys.some(key => entry.editorState._nodeMap.has(key))
1388
+ updateDOM() {
1389
+ return false
1410
1390
  }
1411
1391
 
1412
- #getBackgroundUpdateTags() {
1413
- const tags = [ HISTORY_MERGE_TAG, SKIP_SCROLL_INTO_VIEW_TAG ];
1414
- if (!isEditorFocused(this.editorElement.editor)) { tags.push(SKIP_DOM_SELECTION_TAG); }
1415
- return tags
1392
+ getTextContent() {
1393
+ return "\ufeff"
1416
1394
  }
1417
1395
 
1418
- #patchNodeInEditorState(editorState, node, patch) {
1419
- editorState._nodeMap.set(node.__key, $cloneNodeWithPatch(node, patch));
1396
+ getReadableTextContent() {
1397
+ return this.plainText || `[${this.contentType}]`
1420
1398
  }
1421
1399
 
1422
- #replaceNodeInEditorState(editorState, node, replaceWith) {
1423
- editorState._nodeMap.set(node.__key, $cloneNodeAdoptingKeys(replaceWith, node));
1400
+ isInline() {
1401
+ return true
1424
1402
  }
1425
- }
1426
1403
 
1427
- function $cloneNodeWithPatch(node, patch) {
1428
- const clone = $cloneWithProperties(node);
1404
+ exportDOM() {
1405
+ const attachment = createElement(this.tagName, {
1406
+ sgid: this.sgid,
1407
+ content: this.innerHtml,
1408
+ "content-type": this.contentType
1409
+ });
1410
+
1411
+ return { element: attachment }
1412
+ }
1413
+
1414
+ exportJSON() {
1415
+ return {
1416
+ type: "custom_action_text_attachment",
1417
+ version: 1,
1418
+ tagName: this.tagName,
1419
+ sgid: this.sgid,
1420
+ contentType: this.contentType,
1421
+ innerHtml: this.innerHtml,
1422
+ plainText: this.plainText
1423
+ }
1424
+ }
1425
+
1426
+ decorate() {
1427
+ return null
1428
+ }
1429
+ }
1430
+
1431
+ function dasherize(value) {
1432
+ return value.replace(/([A-Z])/g, (_, char) => `-${char.toLowerCase()}`)
1433
+ }
1434
+
1435
+ function isAutolinkableURL(string) {
1436
+ return /^(?:[a-z0-9]+:\/\/|www\.)[^\s]+$/i.test(string)
1437
+ }
1438
+
1439
+ function normalizeFilteredText(string) {
1440
+ return string
1441
+ .toLowerCase()
1442
+ .normalize("NFD").replace(/[\u0300-\u036f]/g, "") // Remove diacritics
1443
+ }
1444
+
1445
+ function filterMatchPosition(text, potentialMatch) {
1446
+ const normalizedText = normalizeFilteredText(text);
1447
+ const normalizedMatch = normalizeFilteredText(potentialMatch);
1448
+
1449
+ if (!normalizedMatch) return 0
1450
+
1451
+ const match = normalizedText.match(new RegExp(`(?:^|\\b)${escapeForRegExp(normalizedMatch)}`));
1452
+ return match ? match.index : -1
1453
+ }
1454
+
1455
+ function upcaseFirst(string) {
1456
+ return string.charAt(0).toUpperCase() + string.slice(1)
1457
+ }
1458
+
1459
+ function escapeForRegExp(string) {
1460
+ return string.replace(/[.*+?^${}()|[\]\\]/g, "\\$&")
1461
+ }
1462
+
1463
+ // Parses a value that may arrive as a boolean or as a string (e.g. from DOM
1464
+ // getAttribute) into a proper boolean. Ensures "false" doesn't evaluate as truthy.
1465
+ function parseBoolean(value) {
1466
+ if (typeof value === "string") return value === "true"
1467
+ return Boolean(value)
1468
+ }
1469
+
1470
+ class LexxyExtension {
1471
+ #editorElement
1472
+
1473
+ constructor(editorElement) {
1474
+ this.#editorElement = editorElement;
1475
+ }
1476
+
1477
+ get editorElement() {
1478
+ return this.#editorElement
1479
+ }
1480
+
1481
+ get editorConfig() {
1482
+ return this.#editorElement.config
1483
+ }
1484
+
1485
+ // optional: defaults to true
1486
+ get enabled() {
1487
+ return true
1488
+ }
1489
+
1490
+ get lexicalExtension() {
1491
+ return null
1492
+ }
1493
+
1494
+ get allowedElements() {
1495
+ return []
1496
+ }
1497
+
1498
+ initializeToolbar(_lexxyToolbar) {
1499
+
1500
+ }
1501
+
1502
+ setEditorValidity(flags, message) {
1503
+ this.editorElement.setElementValidity(this, flags, message);
1504
+ }
1505
+
1506
+ dispose() {
1507
+ }
1508
+ }
1509
+
1510
+ function $containsRangeSelection(node, selection = $getSelection()) {
1511
+ if ($isRangeSelection(selection)) {
1512
+ const { commonAncestor } = $getCommonAncestor(selection.focus.getNode(), selection.anchor.getNode());
1513
+ return $findMatchingParent(commonAncestor, parent => parent.is(node))
1514
+ } else {
1515
+ return false
1516
+ }
1517
+ }
1518
+
1519
+ function $createNodeSelectionWith(...nodes) {
1520
+ const selection = $createNodeSelection();
1521
+ nodes.forEach(node => selection.add(node.getKey()));
1522
+ return selection
1523
+ }
1524
+
1525
+ function $isShadowRoot(node) {
1526
+ return $isElementNode(node) && $isRootOrShadowRoot(node) && !$isRootNode(node)
1527
+ }
1528
+
1529
+ function $makeSafeForRoot(node) {
1530
+ if ($isTextNode(node)) {
1531
+ return $wrapNodeInElement(node, $createParagraphNode)
1532
+ } else if (node.isParentRequired()) {
1533
+ const parent = node.createRequiredParent();
1534
+ return $wrapNodeInElement(node, parent)
1535
+ } else {
1536
+ return node
1537
+ }
1538
+ }
1539
+
1540
+ function getListType(node) {
1541
+ const list = $getNearestNodeOfType(node, ListNode);
1542
+ return list?.getListType() ?? null
1543
+ }
1544
+
1545
+ function isEditorFocused(editor) {
1546
+ const rootElement = editor.getRootElement();
1547
+ return rootElement !== null && rootElement.contains(document.activeElement)
1548
+ }
1549
+
1550
+ function $isAtNodeEdge(point, atStart = null) {
1551
+ if (atStart === null) {
1552
+ return $isAtNodeEdge(point, true) || $isAtNodeEdge(point, false)
1553
+ } else {
1554
+ return atStart ? $isAtNodeStart(point) : $isAtNodeEnd(point)
1555
+ }
1556
+ }
1557
+
1558
+ function $isAtNodeStart(point) {
1559
+ return point.offset === 0
1560
+ }
1561
+
1562
+ function extendTextNodeConversion(conversionName, ...callbacks) {
1563
+ return extendConversion(TextNode, conversionName, (conversionOutput, element) => ({
1564
+ ...conversionOutput,
1565
+ forChild: (lexicalNode, parentNode) => {
1566
+ const originalForChild = conversionOutput?.forChild ?? (x => x);
1567
+ let childNode = originalForChild(lexicalNode, parentNode);
1568
+
1569
+
1570
+ if ($isTextNode(childNode)) {
1571
+ childNode = callbacks.reduce(
1572
+ (childNode, callback) => callback(childNode, element) ?? childNode,
1573
+ childNode
1574
+ );
1575
+ return childNode
1576
+ }
1577
+ }
1578
+ }))
1579
+ }
1580
+
1581
+ function extendConversion(nodeKlass, conversionName, callback = (output => output)) {
1582
+ return (element) => {
1583
+ const converter = nodeKlass.importDOM()?.[conversionName]?.(element);
1584
+ if (!converter) return null
1585
+
1586
+ const conversionOutput = converter.conversion(element);
1587
+ if (!conversionOutput) return conversionOutput
1588
+
1589
+ return callback(conversionOutput, element) ?? conversionOutput
1590
+ }
1591
+ }
1592
+
1593
+ function $isCursorOnLastLine(selection) {
1594
+ const anchorNode = selection.anchor.getNode();
1595
+ const elementNode = $isElementNode(anchorNode) ? anchorNode : anchorNode.getParentOrThrow();
1596
+ const children = elementNode.getChildren();
1597
+ if (children.length === 0) return true
1598
+
1599
+ const lastChild = children[children.length - 1];
1600
+
1601
+ if (anchorNode === elementNode.getLatest() && selection.anchor.offset === children.length) return true
1602
+ if (anchorNode === lastChild) return true
1603
+
1604
+ const lastLineBreakIndex = children.findLastIndex(child => $isLineBreakNode(child));
1605
+ if (lastLineBreakIndex === -1) return true
1606
+
1607
+ const anchorIndex = children.indexOf(anchorNode);
1608
+ return anchorIndex > lastLineBreakIndex
1609
+ }
1610
+
1611
+ function $isBlankNode(node) {
1612
+ if (node.getTextContent().trim() !== "") return false
1613
+
1614
+ const children = node.getChildren?.();
1615
+ if (!children || children.length === 0) return true
1616
+
1617
+ return children.every(child => {
1618
+ if ($isLineBreakNode(child)) return true
1619
+ return $isBlankNode(child)
1620
+ })
1621
+ }
1622
+
1623
+ function $trimTrailingBlankNodes(parent) {
1624
+ for (const child of $lastToFirstIterator(parent)) {
1625
+ if ($isBlankNode(child)) {
1626
+ child.remove();
1627
+ } else {
1628
+ break
1629
+ }
1630
+ }
1631
+ }
1632
+
1633
+ // A list item is structurally empty if it contains no meaningful content.
1634
+ // Unlike getTextContent().trim() === "", this walks descendants to ensure
1635
+ // decorator nodes (mentions, attachments whose getTextContent() may return
1636
+ // invisible characters like \ufeff) are treated as non-empty content.
1637
+ function $isListItemStructurallyEmpty(listItem) {
1638
+ const children = listItem.getChildren();
1639
+ for (const child of children) {
1640
+ if ($isDecoratorNode(child)) return false
1641
+ if ($isLineBreakNode(child)) continue
1642
+ if ($isTextNode(child)) {
1643
+ if (child.getTextContent().trim() !== "") return false
1644
+ } else if ($isElementNode(child)) {
1645
+ if (child.getTextContent().trim() !== "") return false
1646
+ }
1647
+ }
1648
+ return true
1649
+ }
1650
+
1651
+ function isAttachmentSpacerTextNode(node, previousNode, index, childCount) {
1652
+ return $isTextNode(node)
1653
+ && node.getTextContent() === " "
1654
+ && index === childCount - 1
1655
+ && previousNode instanceof CustomActionTextAttachmentNode
1656
+ }
1657
+
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) {
1692
+ $ensureForwardRangeSelection(selection);
1693
+
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
+ ));
1722
+ }
1723
+
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);
1730
+
1731
+ return lineBreak ? $getSiblingCaret(lineBreak, caret.direction) : null
1732
+ }
1733
+
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;
1738
+
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
1755
+ }
1756
+ }
1757
+
1758
+ function $candidateUnlessShadowed(candidateCaret) {
1759
+ const outward = candidateCaret.getNodeAtCaret();
1760
+ return $isLineBreakNode(outward) ? null : candidateCaret.origin
1761
+ }
1762
+
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
1770
+ }
1771
+ return null
1772
+ }
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
+
1818
+ // Payload: Record<nodeKey, { patch?, replace? }>
1819
+ // - patch: plain object, shallow-merged into the existing node's properties
1820
+ // - replace: a LexicalNode instance that replaces the node
1821
+ const REWRITE_HISTORY_COMMAND = createCommand("REWRITE_HISTORY_COMMAND");
1822
+
1823
+ class RewritableHistoryExtension extends LexxyExtension {
1824
+ #historyState = null
1825
+
1826
+ get lexicalExtension() {
1827
+ return defineExtension({
1828
+ name: "lexxy/rewritable-history",
1829
+ dependencies: [ HistoryExtension ],
1830
+ register: (editor, _config, state) => {
1831
+ const historyOutput = state.getDependency(HistoryExtension).output;
1832
+ this.#historyState = historyOutput.historyState.value;
1833
+
1834
+ return editor.registerCommand(
1835
+ REWRITE_HISTORY_COMMAND,
1836
+ (rewrites) => this.#rewriteHistory(rewrites),
1837
+ COMMAND_PRIORITY_EDITOR
1838
+ )
1839
+ }
1840
+ })
1841
+ }
1842
+
1843
+ get historyState() {
1844
+ return this.#historyState
1845
+ }
1846
+
1847
+ get #allHistoryEntries() {
1848
+ const entries = Array.from(this.#historyState.undoStack);
1849
+ if (this.#historyState.current) entries.push(this.#historyState.current);
1850
+ return entries.concat(this.#historyState.redoStack)
1851
+ }
1852
+
1853
+ #rewriteHistory(rewrites) {
1854
+ this.#applyRewritesImmediatelyToCurrentState(rewrites);
1855
+ this.#applyRewritesToHistory(rewrites);
1856
+
1857
+ return true
1858
+ }
1859
+
1860
+ #applyRewritesImmediatelyToCurrentState(rewrites) {
1861
+ $getEditor().update(() => {
1862
+ for (const [ nodeKey, { patch, replace } ] of Object.entries(rewrites)) {
1863
+ const node = $getNodeByKey(nodeKey);
1864
+ if (!node) continue
1865
+
1866
+ if (patch) Object.assign(node.getWritable(), patch);
1867
+ if (replace) node.replace(replace);
1868
+ }
1869
+ }, { discrete: true, tag: this.#getBackgroundUpdateTags() });
1870
+ }
1871
+
1872
+ #applyRewritesToHistory(rewrites) {
1873
+ const nodeKeys = Object.keys(rewrites);
1874
+
1875
+ for (const entry of this.#allHistoryEntries) {
1876
+ if (!this.#entryHasSomeKeys(entry, nodeKeys)) continue
1877
+
1878
+ const editorState = entry.editorState = safeCloneEditorState(entry.editorState);
1879
+
1880
+ for (const [ nodeKey, { patch, replace } ] of Object.entries(rewrites)) {
1881
+ const node = editorState._nodeMap.get(nodeKey);
1882
+ if (!node) continue
1883
+
1884
+ if (patch) {
1885
+ this.#patchNodeInEditorState(editorState, node, patch);
1886
+ } else if (replace) {
1887
+ this.#replaceNodeInEditorState(editorState, node, replace);
1888
+ }
1889
+ }
1890
+ }
1891
+ }
1892
+
1893
+ #entryHasSomeKeys(entry, nodeKeys) {
1894
+ return nodeKeys.some(key => entry.editorState._nodeMap.has(key))
1895
+ }
1896
+
1897
+ #getBackgroundUpdateTags() {
1898
+ const tags = [ HISTORY_MERGE_TAG, SKIP_SCROLL_INTO_VIEW_TAG ];
1899
+ if (!isEditorFocused(this.editorElement.editor)) { tags.push(SKIP_DOM_SELECTION_TAG); }
1900
+ return tags
1901
+ }
1902
+
1903
+ #patchNodeInEditorState(editorState, node, patch) {
1904
+ editorState._nodeMap.set(node.__key, $cloneNodeWithPatch(node, patch));
1905
+ }
1906
+
1907
+ #replaceNodeInEditorState(editorState, node, replaceWith) {
1908
+ editorState._nodeMap.set(node.__key, $cloneNodeAdoptingKeys(replaceWith, node));
1909
+ }
1910
+ }
1911
+
1912
+ function $cloneNodeWithPatch(node, patch) {
1913
+ const clone = $cloneWithProperties(node);
1429
1914
  Object.assign(clone, patch);
1430
1915
  return clone
1431
1916
  }
@@ -1873,11 +2358,6 @@ function $isActionTextAttachmentNode(node) {
1873
2358
  return node instanceof ActionTextAttachmentNode
1874
2359
  }
1875
2360
 
1876
- function $generateFilteredNodesFromDOM(editorElement, doc) {
1877
- const nodes = $generateNodesFromDOM(editorElement.editor, doc);
1878
- return filterDisallowedAttachmentNodes(nodes, editorElement)
1879
- }
1880
-
1881
2361
  function filterDisallowedAttachmentNodes(nodes, editorElement) {
1882
2362
  return nodes
1883
2363
  .filter(node => !isDisallowedAttachment(node, editorElement))
@@ -1896,6 +2376,67 @@ function isDisallowedAttachment(node, editorElement) {
1896
2376
  !editorElement.permitsAttachmentContentType(node.contentType)
1897
2377
  }
1898
2378
 
2379
+ // Replaces inline `data:image/...` attachments with upload nodes that flow through the normal
2380
+ // file upload pipeline.
2381
+ //
2382
+ // Without this step, pasted-from-Google-Docs-style content lands in the editor with the entire
2383
+ // base64 image embedded in the attachment's `src`, which then persists into the saved HTML and
2384
+ // bloats the stored document.
2385
+ //
2386
+ // Each conversion dispatches the cancelable `lexxy:file-accept` event so the host's allowlist (and
2387
+ // any other listener) can refuse the synthesized File before it's accepted into the upload
2388
+ // pipeline; on refusal, the node is dropped silently — matching how `Contents#uploadFiles` handles
2389
+ // file-picker rejections.
2390
+ function $convertInlineImageDataURIs(nodes, editorElement) {
2391
+ const topLevel = nodes
2392
+ .map(node => isInlineImageDataURIAttachment(node) ? $tryCreateUploadFromDataURI(node, editorElement) : node)
2393
+ .filter(node => node !== null);
2394
+
2395
+ for (const node of topLevel) {
2396
+ for (const desc of $descendantsMatching([ node ], isInlineImageDataURIAttachment)) {
2397
+ const upload = $tryCreateUploadFromDataURI(desc, editorElement);
2398
+ if (upload) {
2399
+ desc.replace(upload);
2400
+ } else {
2401
+ desc.remove();
2402
+ }
2403
+ }
2404
+ }
2405
+
2406
+ return topLevel
2407
+ }
2408
+
2409
+ function isInlineImageDataURIAttachment(node) {
2410
+ return node instanceof ActionTextAttachmentNode &&
2411
+ /^data:image\/[^,]*;base64,/i.test(node.src ?? "")
2412
+ }
2413
+
2414
+ function $tryCreateUploadFromDataURI(node, editorElement) {
2415
+ const file = dataURIToFile(node.src);
2416
+ if (file && editorElement.acceptsFile(file)) {
2417
+ return editorElement.contents.$createUploadNode(file)
2418
+ }
2419
+ return null
2420
+ }
2421
+
2422
+ function dataURIToFile(dataURI) {
2423
+ try {
2424
+ const [ header, data ] = dataURI.split(",");
2425
+
2426
+ // https://datatracker.ietf.org/doc/html/rfc6838#section-4.2
2427
+ const mimeType = header.match(/^data:(image\/[A-Za-z0-9][A-Za-z0-9!#$&\-^_.+]*)/)?.[1];
2428
+ if (mimeType) {
2429
+ const bytes = Uint8Array.from(atob(data), (c) => c.charCodeAt(0));
2430
+ const extension = mimeTypeToExtension(mimeType) ?? "png";
2431
+ return new File([ bytes ], `pasted-image-${Date.now()}.${extension}`, { type: mimeType })
2432
+ } else {
2433
+ return null
2434
+ }
2435
+ } catch {
2436
+ return null
2437
+ }
2438
+ }
2439
+
1899
2440
  class HorizontalDividerNode extends DecoratorNode {
1900
2441
  static getType() {
1901
2442
  return "horizontal_divider"
@@ -3150,12 +3691,18 @@ class CommandDispatcher {
3150
3691
  #registerDragAndDropHandlers() {
3151
3692
  if (this.editorElement.supportsAttachments) {
3152
3693
  this.dragCounter = 0;
3153
- const root = this.editor.getRootElement();
3154
3694
  this.#listeners.track(
3155
- registerEventListener(root, "dragover", this.#handleDragOver.bind(this)),
3156
- registerEventListener(root, "drop", this.#handleDrop.bind(this)),
3157
- registerEventListener(root, "dragenter", this.#handleDragEnter.bind(this)),
3158
- 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
+ })
3159
3706
  );
3160
3707
  }
3161
3708
  }
@@ -3254,42 +3801,6 @@ function capitalize(str) {
3254
3801
  return str.charAt(0).toUpperCase() + str.slice(1)
3255
3802
  }
3256
3803
 
3257
- function debounce(fn, wait) {
3258
- let timeout;
3259
-
3260
- return (...args) => {
3261
- clearTimeout(timeout);
3262
- timeout = setTimeout(() => fn(...args), wait);
3263
- }
3264
- }
3265
-
3266
- function debounceAsync(fn, wait) {
3267
- let timeout;
3268
-
3269
- return (...args) => {
3270
- clearTimeout(timeout);
3271
-
3272
- return new Promise((resolve, reject) => {
3273
- timeout = setTimeout(async () => {
3274
- try {
3275
- const result = await fn(...args);
3276
- resolve(result);
3277
- } catch (err) {
3278
- reject(err);
3279
- }
3280
- }, wait);
3281
- })
3282
- }
3283
- }
3284
-
3285
- function delay(ms) {
3286
- return new Promise((resolve) => setTimeout(resolve, ms))
3287
- }
3288
-
3289
- function nextFrame() {
3290
- return new Promise(requestAnimationFrame)
3291
- }
3292
-
3293
3804
  class Selection {
3294
3805
  #listeners = new ListenerBin()
3295
3806
 
@@ -3361,32 +3872,6 @@ class Selection {
3361
3872
  return { node: null, offset: 0 }
3362
3873
  }
3363
3874
 
3364
- preservingSelection(fn) {
3365
- let selectionState = null;
3366
-
3367
- this.editor.getEditorState().read(() => {
3368
- const selection = $getSelection();
3369
- if (selection && $isRangeSelection(selection)) {
3370
- selectionState = {
3371
- anchor: { key: selection.anchor.key, offset: selection.anchor.offset },
3372
- focus: { key: selection.focus.key, offset: selection.focus.offset }
3373
- };
3374
- }
3375
- });
3376
-
3377
- fn();
3378
-
3379
- if (selectionState) {
3380
- this.editor.update(() => {
3381
- const selection = $getSelection();
3382
- if (selection && $isRangeSelection(selection)) {
3383
- selection.anchor.set(selectionState.anchor.key, selectionState.anchor.offset, "text");
3384
- selection.focus.set(selectionState.focus.key, selectionState.focus.offset, "text");
3385
- }
3386
- });
3387
- }
3388
- }
3389
-
3390
3875
  getFormat() {
3391
3876
  const selection = $getSelection();
3392
3877
  if (!$isRangeSelection(selection)) return {}
@@ -3651,46 +4136,59 @@ class Selection {
3651
4136
  return $isDecoratorNode(targetNode) && this.#selectInLexical(targetNode)
3652
4137
  }, COMMAND_PRIORITY_LOW));
3653
4138
 
3654
- const rootElement = this.editor.getRootElement();
3655
4139
  this.#listeners.track(
3656
- 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
+ })
3657
4145
  );
3658
4146
  }
3659
4147
 
3660
4148
  #containEditorFocus() {
3661
4149
  // Workaround for a bizarre Chrome bug where the cursor abandons the editor to focus on not-focusable elements
3662
4150
  // above when navigating UP/DOWN when Lexical shows its fake cursor on custom decorator nodes.
3663
- this.editorContentElement.addEventListener("keydown", (event) => {
3664
- if (event.key === "ArrowUp") {
3665
- const lexicalCursor = this.editor.getRootElement().querySelector("[data-lexical-cursor]");
3666
-
3667
- if (lexicalCursor) {
3668
- let currentElement = lexicalCursor.previousElementSibling;
3669
- while (currentElement && currentElement.hasAttribute("data-lexical-cursor")) {
3670
- currentElement = currentElement.previousElementSibling;
3671
- }
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
+ }
3672
4161
 
3673
- if (!currentElement) {
3674
- event.preventDefault();
3675
- }
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();
3676
4174
  }
3677
4175
  }
4176
+ }
3678
4177
 
3679
- if (event.key === "ArrowDown") {
3680
- const lexicalCursor = this.editor.getRootElement().querySelector("[data-lexical-cursor]");
4178
+ if (event.key === "ArrowDown") {
4179
+ const lexicalCursor = this.editor.getRootElement().querySelector("[data-lexical-cursor]");
3681
4180
 
3682
- if (lexicalCursor) {
3683
- let currentElement = lexicalCursor.nextElementSibling;
3684
- while (currentElement && currentElement.hasAttribute("data-lexical-cursor")) {
3685
- currentElement = currentElement.nextElementSibling;
3686
- }
4181
+ if (lexicalCursor) {
4182
+ let currentElement = lexicalCursor.nextElementSibling;
4183
+ while (currentElement && currentElement.hasAttribute("data-lexical-cursor")) {
4184
+ currentElement = currentElement.nextElementSibling;
4185
+ }
3687
4186
 
3688
- if (!currentElement) {
3689
- event.preventDefault();
3690
- }
4187
+ if (!currentElement) {
4188
+ event.preventDefault();
3691
4189
  }
3692
4190
  }
3693
- }, true);
4191
+ }
3694
4192
  }
3695
4193
 
3696
4194
  #syncSelectedClasses() {
@@ -4013,472 +4511,193 @@ class Selection {
4013
4511
  const parentElement = this.#getElementFromNode(anchorNode);
4014
4512
 
4015
4513
  if (parentElement instanceof HTMLElement) {
4016
- const computed = window.getComputedStyle(parentElement);
4017
- return parseFloat(computed.fontSize)
4018
- }
4019
-
4020
- return 0
4021
- }
4022
-
4023
- #getElementFromNode(node) {
4024
- return node?.nodeType === Node.TEXT_NODE ? node.parentElement : node
4025
- }
4026
-
4027
- #getCollapsedSelectionData() {
4028
- const selection = $getSelection();
4029
- if (!$isRangeSelection(selection) || !selection.isCollapsed()) {
4030
- return { anchorNode: null, offset: 0 }
4031
- }
4032
-
4033
- const { anchor } = selection;
4034
- return { anchorNode: anchor.getNode(), offset: anchor.offset }
4035
- }
4036
-
4037
- #getNodeAfterTextNode(anchorNode, offset) {
4038
- if (offset === anchorNode.getTextContentSize()) {
4039
- return this.#getNextNodeFromTextEnd(anchorNode)
4040
- }
4041
- return null
4042
- }
4043
-
4044
- #getNextNodeFromTextEnd(anchorNode) {
4045
- const nextSibling = anchorNode.getNextSibling();
4046
- if ($isDecoratorNode(nextSibling)) {
4047
- return nextSibling
4048
- }
4049
- if (nextSibling != null) {
4050
- return null
4051
- }
4052
- const parent = anchorNode.getParent();
4053
- return parent ? parent.getNextSibling() : null
4054
- }
4055
-
4056
- #getNodeAfterElementNode(anchorNode, offset) {
4057
- if (offset < anchorNode.getChildrenSize()) {
4058
- return anchorNode.getChildAtIndex(offset)
4059
- }
4060
- return this.#findNextSiblingUp(anchorNode)
4061
- }
4062
-
4063
- #getNodeBeforeTextNode(anchorNode, offset) {
4064
- if (offset === 0) {
4065
- return this.#getPreviousNodeFromTextStart(anchorNode)
4066
- }
4067
- return null
4068
- }
4069
-
4070
- #getPreviousNodeFromTextStart(anchorNode) {
4071
- const previousSibling = anchorNode.getPreviousSibling();
4072
- if ($isDecoratorNode(previousSibling)) {
4073
- return previousSibling
4074
- }
4075
- if (previousSibling != null) {
4076
- return null
4077
- }
4078
- const parent = anchorNode.getParent();
4079
- return parent ? parent.getPreviousSibling() : null
4080
- }
4081
-
4082
- #getNodeBeforeElementNode(anchorNode, offset) {
4083
- if (offset > 0) {
4084
- return anchorNode.getChildAtIndex(offset - 1)
4085
- }
4086
- return this.#findPreviousSiblingUp(anchorNode)
4087
- }
4088
-
4089
- #findNextSiblingUp(node) {
4090
- let current = node;
4091
- while (current && current.getNextSibling() == null) {
4092
- current = current.getParent();
4093
- }
4094
- return current ? current.getNextSibling() : null
4095
- }
4096
-
4097
- #findPreviousSiblingUp(node) {
4098
- let current = node;
4099
- while (current && current.getPreviousSibling() == null) {
4100
- current = current.getParent();
4101
- }
4102
- return current ? current.getPreviousSibling() : null
4103
- }
4104
-
4105
- #isCursorOnFirstVisualLineOfBlock(anchorNode) {
4106
- return this.#isCursorOnEdgeLineOfBlock(anchorNode, "first")
4107
- }
4108
-
4109
- #isCursorOnLastVisualLineOfBlock(anchorNode) {
4110
- return this.#isCursorOnEdgeLineOfBlock(anchorNode, "last")
4111
- }
4112
-
4113
- // Check whether the cursor sits on the first or last visual line of its
4114
- // top-level block by comparing the Y position of the cursor with the Y
4115
- // position of the block's start (first line) or end (last line).
4116
- #isCursorOnEdgeLineOfBlock(anchorNode, edge) {
4117
- const topLevelElement = anchorNode.getTopLevelElement();
4118
- if (!topLevelElement) return false
4119
-
4120
- const domElement = this.editor.getElementByKey(topLevelElement.getKey());
4121
- if (!domElement) return false
4122
-
4123
- const nativeSelection = window.getSelection();
4124
- if (!nativeSelection?.rangeCount) return false
4125
-
4126
- const cursorRect = this.#getReliableRectFromRange(nativeSelection.getRangeAt(0));
4127
- if (!cursorRect || this.#isRectUnreliable(cursorRect)) return false
4128
-
4129
- const edgeRect = this.#getEdgeCharRect(domElement, edge);
4130
- if (!edgeRect || this.#isRectUnreliable(edgeRect)) return false
4131
-
4132
- const tolerance = edgeRect.height > 0 ? edgeRect.height * 0.5 : 5;
4133
- return Math.abs(cursorRect.top - edgeRect.top) < tolerance
4134
- }
4135
-
4136
- // Get a reliable bounding rect for the first or last character in a DOM
4137
- // element by creating a non-collapsed range around it.
4138
- #getEdgeCharRect(element, edge) {
4139
- const walker = document.createTreeWalker(element, 4 /* NodeFilter.SHOW_TEXT */);
4140
- let textNode;
4141
-
4142
- if (edge === "first") {
4143
- textNode = walker.nextNode();
4144
- } else {
4145
- while (walker.nextNode()) textNode = walker.currentNode;
4146
- }
4147
-
4148
- if (!textNode || textNode.length === 0) return null
4149
-
4150
- const range = document.createRange();
4151
- if (edge === "first") {
4152
- range.setStart(textNode, 0);
4153
- range.setEnd(textNode, 1);
4154
- } else {
4155
- range.setStart(textNode, textNode.length - 1);
4156
- range.setEnd(textNode, textNode.length);
4157
- }
4158
-
4159
- return range.getBoundingClientRect()
4160
- }
4161
- }
4162
-
4163
- class EditorConfiguration {
4164
- #editorElement
4165
- #config
4166
-
4167
- constructor(editorElement) {
4168
- this.#editorElement = editorElement;
4169
- this.#config = new Configuration(
4170
- Lexxy.presets.get("default"),
4171
- Lexxy.presets.get(editorElement.preset),
4172
- this.#overrides
4173
- );
4174
- }
4175
-
4176
- get(path) {
4177
- return this.#config.get(path)
4178
- }
4179
-
4180
- get #overrides() {
4181
- const overrides = {};
4182
- for (const option of this.#defaultOptions) {
4183
- const attribute = dasherize(option);
4184
- if (this.#editorElement.hasAttribute(attribute)) {
4185
- overrides[option] = this.#parseAttribute(attribute);
4186
- }
4187
- }
4188
- return overrides
4189
- }
4190
-
4191
- get #defaultOptions() {
4192
- return Object.keys(Lexxy.presets.get("default"))
4193
- }
4194
-
4195
- #parseAttribute(attribute) {
4196
- const value = this.#editorElement.getAttribute(attribute);
4197
- try {
4198
- return JSON.parse(value)
4199
- } catch {
4200
- return value
4201
- }
4202
- }
4203
- }
4204
-
4205
- async function loadFileIntoImage(file, image) {
4206
- return new Promise((resolve) => {
4207
- const reader = new FileReader();
4208
-
4209
- image.addEventListener("load", () => {
4210
- resolve(image);
4211
- });
4212
-
4213
- reader.onload = (event) => {
4214
- image.src = event.target.result || null;
4215
- };
4216
-
4217
- reader.readAsDataURL(file);
4218
- })
4219
- }
4220
-
4221
- class ActionTextAttachmentUploadNode extends ActionTextAttachmentNode {
4222
- static getType() {
4223
- return "action_text_attachment_upload"
4224
- }
4225
-
4226
- static clone(node) {
4227
- return new ActionTextAttachmentUploadNode({ ...node }, node.__key)
4228
- }
4229
-
4230
- static importJSON(serializedNode) {
4231
- return new ActionTextAttachmentUploadNode({ ...serializedNode })
4232
- }
4233
-
4234
- // Should never run since this is a transient node. Defined to remove console warning.
4235
- static importDOM() {
4236
- return null
4237
- }
4238
-
4239
- constructor(node, key) {
4240
- const { file, uploadUrl, blobUrlTemplate, progress, width, height, uploadError, fileName, contentType } = node;
4241
- super({ ...node, contentType: file?.type ?? contentType }, key);
4242
- this.file = file ?? null;
4243
- this.fileName = file?.name ?? fileName;
4244
- this.uploadUrl = uploadUrl;
4245
- this.blobUrlTemplate = blobUrlTemplate;
4246
- this.progress = progress ?? null;
4247
- this.width = width;
4248
- this.height = height;
4249
- this.uploadError = uploadError;
4250
- }
4251
-
4252
- createDOM() {
4253
- if (this.uploadError) return this.createDOMForError()
4254
-
4255
- // This side-effect is trigged on DOM load to fire only once and avoid multiple
4256
- // uploads through cloning. The upload is guarded from restarting in case the
4257
- // node is reloaded from saved state such as from history.
4258
- this.#startUploadIfNeeded();
4259
-
4260
- // Bridge-managed uploads (uploadUrl is null) don't have file data to show
4261
- // an image preview, so always show the file icon during upload.
4262
- const canPreviewFile = this.isPreviewableAttachment && this.uploadUrl != null;
4263
- const figure = this.createAttachmentFigure(canPreviewFile);
4264
-
4265
- if (canPreviewFile) {
4266
- const img = figure.appendChild(this.#createDOMForImage());
4267
-
4268
- // load file locally to set dimensions and prevent vertical shifting
4269
- loadFileIntoImage(this.file, img).then(img => this.#setDimensionsFromImage(img));
4270
- } else {
4271
- figure.appendChild(this.#createDOMForFile());
4514
+ const computed = window.getComputedStyle(parentElement);
4515
+ return parseFloat(computed.fontSize)
4272
4516
  }
4273
4517
 
4274
- figure.appendChild(this.#createCaption());
4275
- figure.appendChild(this.#createProgressBar());
4276
-
4277
- return figure
4518
+ return 0
4278
4519
  }
4279
4520
 
4280
- updateDOM(prevNode, dom) {
4281
- if (this.uploadError !== prevNode.uploadError) return true
4521
+ #getElementFromNode(node) {
4522
+ return node?.nodeType === Node.TEXT_NODE ? node.parentElement : node
4523
+ }
4282
4524
 
4283
- if (prevNode.progress !== this.progress) {
4284
- const progress = dom.querySelector("progress");
4285
- progress.value = this.progress ?? 0;
4525
+ #getCollapsedSelectionData() {
4526
+ const selection = $getSelection();
4527
+ if (!$isRangeSelection(selection) || !selection.isCollapsed()) {
4528
+ return { anchorNode: null, offset: 0 }
4286
4529
  }
4287
4530
 
4288
- return false
4531
+ const { anchor } = selection;
4532
+ return { anchorNode: anchor.getNode(), offset: anchor.offset }
4289
4533
  }
4290
4534
 
4291
- exportDOM() {
4292
- return { element: null }
4535
+ #getNodeAfterTextNode(anchorNode, offset) {
4536
+ if (offset === anchorNode.getTextContentSize()) {
4537
+ return this.#getNextNodeFromTextEnd(anchorNode)
4538
+ }
4539
+ return null
4293
4540
  }
4294
4541
 
4295
- exportJSON() {
4296
- return {
4297
- ...super.exportJSON(),
4298
- type: "action_text_attachment_upload",
4299
- version: 1,
4300
- fileName: this.fileName,
4301
- contentType: this.contentType,
4302
- uploadUrl: this.uploadUrl,
4303
- blobUrlTemplate: this.blobUrlTemplate,
4304
- progress: this.progress,
4305
- width: this.width,
4306
- height: this.height,
4307
- uploadError: this.uploadError
4542
+ #getNextNodeFromTextEnd(anchorNode) {
4543
+ const nextSibling = anchorNode.getNextSibling();
4544
+ if ($isDecoratorNode(nextSibling)) {
4545
+ return nextSibling
4546
+ }
4547
+ if (nextSibling != null) {
4548
+ return null
4308
4549
  }
4550
+ const parent = anchorNode.getParent();
4551
+ return parent ? parent.getNextSibling() : null
4309
4552
  }
4310
4553
 
4311
- get #uploadStarted() {
4312
- return this.progress !== null
4554
+ #getNodeAfterElementNode(anchorNode, offset) {
4555
+ if (offset < anchorNode.getChildrenSize()) {
4556
+ return anchorNode.getChildAtIndex(offset)
4557
+ }
4558
+ return this.#findNextSiblingUp(anchorNode)
4313
4559
  }
4314
4560
 
4315
- #createDOMForImage() {
4316
- return createElement("img")
4561
+ #getNodeBeforeTextNode(anchorNode, offset) {
4562
+ if (offset === 0) {
4563
+ return this.#getPreviousNodeFromTextStart(anchorNode)
4564
+ }
4565
+ return null
4317
4566
  }
4318
4567
 
4319
- #createDOMForFile() {
4320
- const extension = this.#getFileExtension();
4321
- const span = createElement("span", { className: "attachment__icon", textContent: extension });
4322
- return span
4568
+ #getPreviousNodeFromTextStart(anchorNode) {
4569
+ const previousSibling = anchorNode.getPreviousSibling();
4570
+ if ($isDecoratorNode(previousSibling)) {
4571
+ return previousSibling
4572
+ }
4573
+ if (previousSibling != null) {
4574
+ return null
4575
+ }
4576
+ const parent = anchorNode.getParent();
4577
+ return parent ? parent.getPreviousSibling() : null
4323
4578
  }
4324
4579
 
4325
- #getFileExtension() {
4326
- return (this.fileName || "").split(".").pop().toLowerCase()
4580
+ #getNodeBeforeElementNode(anchorNode, offset) {
4581
+ if (offset > 0) {
4582
+ return anchorNode.getChildAtIndex(offset - 1)
4583
+ }
4584
+ return this.#findPreviousSiblingUp(anchorNode)
4327
4585
  }
4328
4586
 
4329
- #createCaption() {
4330
- const figcaption = createElement("figcaption", { className: "attachment__caption" });
4331
-
4332
- const nameSpan = createElement("span", { className: "attachment__name", textContent: this.caption || this.fileName || "" });
4333
- const sizeSpan = createElement("span", { className: "attachment__size", textContent: bytesToHumanSize(this.file?.size) });
4334
- figcaption.appendChild(nameSpan);
4335
- figcaption.appendChild(sizeSpan);
4336
-
4337
- return figcaption
4587
+ #findNextSiblingUp(node) {
4588
+ let current = node;
4589
+ while (current && current.getNextSibling() == null) {
4590
+ current = current.getParent();
4591
+ }
4592
+ return current ? current.getNextSibling() : null
4338
4593
  }
4339
4594
 
4340
- #createProgressBar() {
4341
- return createElement("progress", { value: this.progress ?? 0, max: 100 })
4595
+ #findPreviousSiblingUp(node) {
4596
+ let current = node;
4597
+ while (current && current.getPreviousSibling() == null) {
4598
+ current = current.getParent();
4599
+ }
4600
+ return current ? current.getPreviousSibling() : null
4342
4601
  }
4343
4602
 
4344
- #setDimensionsFromImage({ width, height }) {
4345
- if (this.#hasDimensions) return
4346
-
4347
- this.patchAndRewriteHistory({ width, height });
4603
+ #isCursorOnFirstVisualLineOfBlock(anchorNode) {
4604
+ return this.#isCursorOnEdgeLineOfBlock(anchorNode, "first")
4348
4605
  }
4349
4606
 
4350
- get #hasDimensions() {
4351
- return Boolean(this.width && this.height)
4607
+ #isCursorOnLastVisualLineOfBlock(anchorNode) {
4608
+ return this.#isCursorOnEdgeLineOfBlock(anchorNode, "last")
4352
4609
  }
4353
4610
 
4354
- async #startUploadIfNeeded() {
4355
- if (this.#uploadStarted) return
4356
- if (!this.uploadUrl) return // Bridge-managed upload skip DirectUpload
4611
+ // Check whether the cursor sits on the first or last visual line of its
4612
+ // top-level block by comparing the Y position of the cursor with the Y
4613
+ // position of the block's start (first line) or end (last line).
4614
+ #isCursorOnEdgeLineOfBlock(anchorNode, edge) {
4615
+ const topLevelElement = anchorNode.getTopLevelElement();
4616
+ if (!topLevelElement) return false
4357
4617
 
4358
- this.#setUploadStarted();
4618
+ const domElement = this.editor.getElementByKey(topLevelElement.getKey());
4619
+ if (!domElement) return false
4359
4620
 
4360
- const { DirectUpload } = await import('@rails/activestorage');
4621
+ const nativeSelection = window.getSelection();
4622
+ if (!nativeSelection?.rangeCount) return false
4361
4623
 
4362
- const upload = new DirectUpload(this.file, this.uploadUrl, this);
4363
- upload.delegate = this.#createUploadDelegate();
4624
+ const cursorRect = this.#getReliableRectFromRange(nativeSelection.getRangeAt(0));
4625
+ if (!cursorRect || this.#isRectUnreliable(cursorRect)) return false
4364
4626
 
4365
- this.#dispatchEvent("lexxy:upload-start", { file: this.file });
4627
+ const edgeRect = this.#getEdgeCharRect(domElement, edge);
4628
+ if (!edgeRect || this.#isRectUnreliable(edgeRect)) return false
4366
4629
 
4367
- upload.create((error, blob) => {
4368
- if (error) {
4369
- this.#dispatchEvent("lexxy:upload-end", { file: this.file, error });
4370
- this.#handleUploadError(error);
4371
- } else {
4372
- this.#dispatchEvent("lexxy:upload-end", { file: this.file, error: null });
4373
- this.editor.update(() => {
4374
- this.$showUploadedAttachment(blob);
4375
- });
4376
- }
4377
- });
4630
+ const tolerance = edgeRect.height > 0 ? edgeRect.height * 0.5 : 5;
4631
+ return Math.abs(cursorRect.top - edgeRect.top) < tolerance
4378
4632
  }
4379
4633
 
4380
- #createUploadDelegate() {
4381
- const shouldAuthenticateUploads = Lexxy.global.get("authenticatedUploads");
4382
-
4383
- return {
4384
- directUploadWillCreateBlobWithXHR: (request) => {
4385
- if (shouldAuthenticateUploads) request.withCredentials = true;
4386
- },
4387
- directUploadWillStoreFileWithXHR: (request) => {
4388
- if (shouldAuthenticateUploads) request.withCredentials = true;
4634
+ // Get a reliable bounding rect for the first or last character in a DOM
4635
+ // element by creating a non-collapsed range around it.
4636
+ #getEdgeCharRect(element, edge) {
4637
+ const walker = document.createTreeWalker(element, 4 /* NodeFilter.SHOW_TEXT */);
4638
+ let textNode;
4389
4639
 
4390
- const uploadProgressHandler = (event) => this.#handleUploadProgress(event, request);
4391
- request.upload.addEventListener("progress", uploadProgressHandler);
4392
- }
4640
+ if (edge === "first") {
4641
+ textNode = walker.nextNode();
4642
+ } else {
4643
+ while (walker.nextNode()) textNode = walker.currentNode;
4393
4644
  }
4394
- }
4395
4645
 
4396
- #setUploadStarted() {
4397
- this.#setProgress(1);
4398
- }
4646
+ if (!textNode || textNode.length === 0) return null
4399
4647
 
4400
- #handleUploadProgress(event, request) {
4401
- const progress = Math.round(event.loaded / event.total * 100);
4402
- try {
4403
- this.#setProgress(progress);
4404
- this.#dispatchEvent("lexxy:upload-progress", { file: this.file, progress });
4405
- } catch {
4406
- request.abort();
4648
+ const range = document.createRange();
4649
+ if (edge === "first") {
4650
+ range.setStart(textNode, 0);
4651
+ range.setEnd(textNode, 1);
4652
+ } else {
4653
+ range.setStart(textNode, textNode.length - 1);
4654
+ range.setEnd(textNode, textNode.length);
4407
4655
  }
4408
- }
4409
-
4410
- #setProgress(progress) {
4411
- this.patchAndRewriteHistory({ progress });
4412
- }
4413
-
4414
- #handleUploadError(error) {
4415
- console.warn(`Upload error for ${this.file?.name ?? "file"}: ${error}`);
4416
-
4417
- this.patchAndRewriteHistory({ uploadError: true });
4418
- }
4419
-
4420
- $showUploadedAttachment(blob) {
4421
- const previewSrc = this.isPreviewableImage && this.file ? URL.createObjectURL(this.file) : null;
4422
4656
 
4423
- const replacementNode = this.#toActionTextAttachmentNodeWith(blob, previewSrc);
4424
- this.replaceAndRewriteHistory(replacementNode);
4425
-
4426
- return replacementNode.getKey()
4427
- }
4428
-
4429
- #toActionTextAttachmentNodeWith(blob, previewSrc) {
4430
- const conversion = new AttachmentNodeConversion(this, blob, previewSrc);
4431
- return conversion.toAttachmentNode()
4432
- }
4433
-
4434
- #dispatchEvent(name, detail) {
4435
- const figure = this.editor.getElementByKey(this.getKey());
4436
- if (figure) dispatch(figure, name, detail);
4657
+ return range.getBoundingClientRect()
4437
4658
  }
4438
4659
  }
4439
4660
 
4440
- class AttachmentNodeConversion {
4441
- constructor(uploadNode, blob, previewSrc) {
4442
- this.uploadNode = uploadNode;
4443
- this.blob = blob;
4444
- this.previewSrc = previewSrc;
4445
- }
4446
-
4447
- toAttachmentNode() {
4448
- return new ActionTextAttachmentNode({
4449
- ...this.uploadNode,
4450
- ...this.#propertiesFromBlob,
4451
- src: this.#src,
4452
- previewSrc: this.previewSrc,
4453
- pendingPreview: this.blob.previewable && !this.uploadNode.isPreviewableImage
4454
- })
4455
- }
4661
+ class EditorConfiguration {
4662
+ #editorElement
4663
+ #config
4456
4664
 
4457
- get #propertiesFromBlob() {
4458
- const { blob } = this;
4459
- return {
4460
- sgid: blob.attachable_sgid,
4461
- altText: blob.filename,
4462
- contentType: blob.content_type,
4463
- fileName: blob.filename,
4464
- fileSize: blob.byte_size,
4465
- previewable: blob.previewable,
4466
- }
4665
+ constructor(editorElement) {
4666
+ this.#editorElement = editorElement;
4667
+ this.#config = new Configuration(
4668
+ Lexxy.presets.get("default"),
4669
+ Lexxy.presets.get(editorElement.preset),
4670
+ this.#overrides
4671
+ );
4467
4672
  }
4468
4673
 
4469
- get #src() {
4470
- return this.blob.previewable ? this.blob.url : this.#blobSrc
4674
+ get(path) {
4675
+ return this.#config.get(path)
4471
4676
  }
4472
4677
 
4473
- get #blobSrc() {
4474
- return this.uploadNode.blobUrlTemplate
4475
- .replace(":signed_id", this.blob.signed_id)
4476
- .replace(":filename", encodeURIComponent(this.blob.filename))
4678
+ get #overrides() {
4679
+ const overrides = {};
4680
+ for (const option of this.#defaultOptions) {
4681
+ const attribute = dasherize(option);
4682
+ if (this.#editorElement.hasAttribute(attribute)) {
4683
+ overrides[option] = this.#parseAttribute(attribute);
4684
+ }
4685
+ }
4686
+ return overrides
4477
4687
  }
4478
- }
4479
4688
 
4480
- function $createActionTextAttachmentUploadNode(...args) {
4481
- return new ActionTextAttachmentUploadNode(...args)
4689
+ get #defaultOptions() {
4690
+ return Object.keys(Lexxy.presets.get("default"))
4691
+ }
4692
+
4693
+ #parseAttribute(attribute) {
4694
+ const value = this.#editorElement.getAttribute(attribute);
4695
+ try {
4696
+ return JSON.parse(value)
4697
+ } catch {
4698
+ return value
4699
+ }
4700
+ }
4482
4701
  }
4483
4702
 
4484
4703
  class ImageGalleryNode extends ElementNode {
@@ -4670,119 +4889,385 @@ class Uploader {
4670
4889
  this.selection = editorElement.selection;
4671
4890
  }
4672
4891
 
4673
- get files() {
4674
- return Array.from(this.#files)
4892
+ get files() {
4893
+ return Array.from(this.#files)
4894
+ }
4895
+
4896
+ $uploadFiles() {
4897
+ this.$createUploadNodes();
4898
+ this.$insertUploadNodes();
4899
+ }
4900
+
4901
+ $createUploadNodes() {
4902
+ this.nodes = this.files.map(file => this.contents.$createUploadNode(file));
4903
+ }
4904
+
4905
+ $insertUploadNodes() {
4906
+ this.contents.insertAtCursor(...this.nodes);
4907
+ }
4908
+ }
4909
+
4910
+ class GalleryUploader extends Uploader {
4911
+ #gallery
4912
+
4913
+ static handle(editorElement, files) {
4914
+ return this.isMultipleImageUpload(files) || this.gallerySelection(editorElement.selection)
4915
+ }
4916
+
4917
+ static isMultipleImageUpload(files) {
4918
+ let imageFileCount = 0;
4919
+ for (const file of files) {
4920
+ if (isPreviewableImage(file.type)) imageFileCount++;
4921
+ if (imageFileCount > 1) return true
4922
+ }
4923
+ return false
4924
+ }
4925
+
4926
+ static gallerySelection(selection) {
4927
+ return selection.isOnPreviewableImage || this.selectionIsAfterGalleryEdge(selection)
4928
+ }
4929
+
4930
+ static selectionIsAfterGalleryEdge(selection) {
4931
+ return selection.isAtNodeStart && ImageGalleryNode.canCollapseWith(selection.nodeBeforeCursor)
4932
+ }
4933
+
4934
+ $insertUploadNodes() {
4935
+ this.#findOrCreateGallery();
4936
+ this.#insertImagesInGallery();
4937
+ this.#insertNonImagesAfterGallery();
4938
+ }
4939
+
4940
+ #findOrCreateGallery() {
4941
+ if (this.selection.isOnPreviewableImage) {
4942
+ this.#gallery = $findOrCreateGalleryForImage(this.#selectedNode);
4943
+ } else if (this.#selectionIsAfterGalleryEdge) {
4944
+ this.#gallery = $findOrCreateGalleryForImage(this.selection.nodeBeforeCursor);
4945
+ } else {
4946
+ this.#gallery = $createImageGalleryNode();
4947
+ this.contents.insertAtCursor(this.#gallery);
4948
+ }
4949
+ }
4950
+
4951
+ get #selectionIsAfterGalleryEdge() {
4952
+ return this.constructor.selectionIsAfterGalleryEdge(this.selection)
4953
+ }
4954
+
4955
+ get #selectedNode() {
4956
+ const { node } = this.selection.selectedNodeWithOffset();
4957
+ return node
4958
+ }
4959
+
4960
+ get #galleryInsertPosition() {
4961
+ if (this.#selectionIsAfterGalleryEdge) return this.#gallery.getChildrenSize()
4962
+
4963
+ const anchor = $getSelection()?.anchor;
4964
+ const galleryHasElementSelection = anchor?.getNode().is(this.#gallery);
4965
+ if (galleryHasElementSelection) return anchor.offset
4966
+
4967
+ const selectedNode = this.#selectedNode;
4968
+ const childIndex = this.#gallery.isParentOf(selectedNode) && selectedNode.getIndexWithinParent();
4969
+ return childIndex !== false ? (childIndex + 1) : 0
4970
+ }
4971
+
4972
+ get #imageNodes() {
4973
+ return this.nodes.filter(node => ImageGalleryNode.isValidChild(node))
4974
+ }
4975
+
4976
+ get #nonImageNodes() {
4977
+ return this.nodes.filter(node => !ImageGalleryNode.isValidChild(node))
4978
+ }
4979
+
4980
+ #insertImagesInGallery() {
4981
+ this.#gallery.splice(this.#galleryInsertPosition, 0, this.#imageNodes);
4982
+ }
4983
+
4984
+ #insertNonImagesAfterGallery() {
4985
+ let beforeNode = this.#gallery;
4986
+
4987
+ for (const node of this.#nonImageNodes) {
4988
+ beforeNode.insertAfter(node);
4989
+ beforeNode = node;
4990
+ }
4991
+ }
4992
+ }
4993
+
4994
+ async function loadFileIntoImage(file, image) {
4995
+ return new Promise((resolve) => {
4996
+ const reader = new FileReader();
4997
+
4998
+ image.addEventListener("load", () => {
4999
+ resolve(image);
5000
+ });
5001
+
5002
+ reader.onload = (event) => {
5003
+ image.src = event.target.result || null;
5004
+ };
5005
+
5006
+ reader.readAsDataURL(file);
5007
+ })
5008
+ }
5009
+
5010
+ class ActionTextAttachmentUploadNode extends ActionTextAttachmentNode {
5011
+ static getType() {
5012
+ return "action_text_attachment_upload"
5013
+ }
5014
+
5015
+ static clone(node) {
5016
+ return new ActionTextAttachmentUploadNode({ ...node }, node.__key)
5017
+ }
5018
+
5019
+ static importJSON(serializedNode) {
5020
+ return new ActionTextAttachmentUploadNode({ ...serializedNode })
5021
+ }
5022
+
5023
+ // Should never run since this is a transient node. Defined to remove console warning.
5024
+ static importDOM() {
5025
+ return null
5026
+ }
5027
+
5028
+ constructor(node, key) {
5029
+ const { file, uploadUrl, blobUrlTemplate, progress, width, height, uploadError, fileName, contentType } = node;
5030
+ super({ ...node, contentType: file?.type ?? contentType }, key);
5031
+ this.file = file ?? null;
5032
+ this.fileName = file?.name ?? fileName;
5033
+ this.uploadUrl = uploadUrl;
5034
+ this.blobUrlTemplate = blobUrlTemplate;
5035
+ this.progress = progress ?? null;
5036
+ this.width = width;
5037
+ this.height = height;
5038
+ this.uploadError = uploadError;
5039
+ }
5040
+
5041
+ createDOM() {
5042
+ if (this.uploadError) return this.createDOMForError()
5043
+
5044
+ // This side-effect is trigged on DOM load to fire only once and avoid multiple
5045
+ // uploads through cloning. The upload is guarded from restarting in case the
5046
+ // node is reloaded from saved state such as from history.
5047
+ this.#startUploadIfNeeded();
5048
+
5049
+ // Bridge-managed uploads (uploadUrl is null) don't have file data to show
5050
+ // an image preview, so always show the file icon during upload.
5051
+ const canPreviewFile = this.isPreviewableAttachment && this.uploadUrl != null;
5052
+ const figure = this.createAttachmentFigure(canPreviewFile);
5053
+
5054
+ if (canPreviewFile) {
5055
+ const img = figure.appendChild(this.#createDOMForImage());
5056
+
5057
+ // load file locally to set dimensions and prevent vertical shifting
5058
+ loadFileIntoImage(this.file, img).then(img => this.#setDimensionsFromImage(img));
5059
+ } else {
5060
+ figure.appendChild(this.#createDOMForFile());
5061
+ }
5062
+
5063
+ figure.appendChild(this.#createCaption());
5064
+ figure.appendChild(this.#createProgressBar());
5065
+
5066
+ return figure
5067
+ }
5068
+
5069
+ updateDOM(prevNode, dom) {
5070
+ if (this.uploadError !== prevNode.uploadError) return true
5071
+
5072
+ if (prevNode.progress !== this.progress) {
5073
+ const progress = dom.querySelector("progress");
5074
+ progress.value = this.progress ?? 0;
5075
+ }
5076
+
5077
+ return false
5078
+ }
5079
+
5080
+ exportDOM() {
5081
+ return { element: null }
5082
+ }
5083
+
5084
+ exportJSON() {
5085
+ return {
5086
+ ...super.exportJSON(),
5087
+ type: "action_text_attachment_upload",
5088
+ version: 1,
5089
+ fileName: this.fileName,
5090
+ contentType: this.contentType,
5091
+ uploadUrl: this.uploadUrl,
5092
+ blobUrlTemplate: this.blobUrlTemplate,
5093
+ progress: this.progress,
5094
+ width: this.width,
5095
+ height: this.height,
5096
+ uploadError: this.uploadError
5097
+ }
5098
+ }
5099
+
5100
+ get #uploadStarted() {
5101
+ return this.progress !== null
5102
+ }
5103
+
5104
+ #createDOMForImage() {
5105
+ return createElement("img")
5106
+ }
5107
+
5108
+ #createDOMForFile() {
5109
+ const extension = this.#getFileExtension();
5110
+ const span = createElement("span", { className: "attachment__icon", textContent: extension });
5111
+ return span
5112
+ }
5113
+
5114
+ #getFileExtension() {
5115
+ return (this.fileName || "").split(".").pop().toLowerCase()
4675
5116
  }
4676
5117
 
4677
- $uploadFiles() {
4678
- this.$createUploadNodes();
4679
- this.$insertUploadNodes();
4680
- }
5118
+ #createCaption() {
5119
+ const figcaption = createElement("figcaption", { className: "attachment__caption" });
4681
5120
 
4682
- $createUploadNodes() {
4683
- this.nodes = this.files.map(file =>
4684
- $createActionTextAttachmentUploadNode({
4685
- ...this.#nodeUrlProperties,
4686
- file: file,
4687
- contentType: file.type
4688
- })
4689
- );
4690
- }
5121
+ const nameSpan = createElement("span", { className: "attachment__name", textContent: this.caption || this.fileName || "" });
5122
+ const sizeSpan = createElement("span", { className: "attachment__size", textContent: bytesToHumanSize(this.file?.size) });
5123
+ figcaption.appendChild(nameSpan);
5124
+ figcaption.appendChild(sizeSpan);
4691
5125
 
4692
- $insertUploadNodes() {
4693
- this.contents.insertAtCursor(...this.nodes);
5126
+ return figcaption
4694
5127
  }
4695
5128
 
4696
- get #nodeUrlProperties() {
4697
- return {
4698
- uploadUrl: this.editorElement.directUploadUrl,
4699
- blobUrlTemplate: this.editorElement.blobUrlTemplate
4700
- }
5129
+ #createProgressBar() {
5130
+ return createElement("progress", { value: this.progress ?? 0, max: 100 })
4701
5131
  }
4702
- }
4703
5132
 
4704
- class GalleryUploader extends Uploader {
4705
- #gallery
5133
+ #setDimensionsFromImage({ width, height }) {
5134
+ if (this.#hasDimensions) return
4706
5135
 
4707
- static handle(editorElement, files) {
4708
- return this.isMultipleImageUpload(files) || this.gallerySelection(editorElement.selection)
5136
+ this.patchAndRewriteHistory({ width, height });
4709
5137
  }
4710
5138
 
4711
- static isMultipleImageUpload(files) {
4712
- let imageFileCount = 0;
4713
- for (const file of files) {
4714
- if (isPreviewableImage(file.type)) imageFileCount++;
4715
- if (imageFileCount > 1) return true
4716
- }
4717
- return false
5139
+ get #hasDimensions() {
5140
+ return Boolean(this.width && this.height)
4718
5141
  }
4719
5142
 
4720
- static gallerySelection(selection) {
4721
- return selection.isOnPreviewableImage || this.selectionIsAfterGalleryEdge(selection)
5143
+ async #startUploadIfNeeded() {
5144
+ if (this.#uploadStarted) return
5145
+ if (!this.uploadUrl) return // Bridge-managed upload — skip DirectUpload
5146
+
5147
+ this.#setUploadStarted();
5148
+
5149
+ const { DirectUpload } = await import('@rails/activestorage');
5150
+
5151
+ const upload = new DirectUpload(this.file, this.uploadUrl, this);
5152
+ upload.delegate = this.#createUploadDelegate();
5153
+
5154
+ this.#dispatchEvent("lexxy:upload-start", { file: this.file });
5155
+
5156
+ upload.create((error, blob) => {
5157
+ if (error) {
5158
+ this.#dispatchEvent("lexxy:upload-end", { file: this.file, error });
5159
+ this.#handleUploadError(error);
5160
+ } else {
5161
+ this.#dispatchEvent("lexxy:upload-end", { file: this.file, error: null });
5162
+ this.editor.update(() => {
5163
+ this.$showUploadedAttachment(blob);
5164
+ });
5165
+ }
5166
+ });
4722
5167
  }
4723
5168
 
4724
- static selectionIsAfterGalleryEdge(selection) {
4725
- return selection.isAtNodeStart && ImageGalleryNode.canCollapseWith(selection.nodeBeforeCursor)
5169
+ #createUploadDelegate() {
5170
+ const shouldAuthenticateUploads = Lexxy.global.get("authenticatedUploads");
5171
+
5172
+ return {
5173
+ directUploadWillCreateBlobWithXHR: (request) => {
5174
+ if (shouldAuthenticateUploads) request.withCredentials = true;
5175
+ },
5176
+ directUploadWillStoreFileWithXHR: (request) => {
5177
+ if (shouldAuthenticateUploads) request.withCredentials = true;
5178
+
5179
+ const uploadProgressHandler = (event) => this.#handleUploadProgress(event, request);
5180
+ request.upload.addEventListener("progress", uploadProgressHandler);
5181
+ }
5182
+ }
4726
5183
  }
4727
5184
 
4728
- $insertUploadNodes() {
4729
- this.#findOrCreateGallery();
4730
- this.#insertImagesInGallery();
4731
- this.#insertNonImagesAfterGallery();
5185
+ #setUploadStarted() {
5186
+ this.#setProgress(1);
4732
5187
  }
4733
5188
 
4734
- #findOrCreateGallery() {
4735
- if (this.selection.isOnPreviewableImage) {
4736
- this.#gallery = $findOrCreateGalleryForImage(this.#selectedNode);
4737
- } else if (this.#selectionIsAfterGalleryEdge) {
4738
- this.#gallery = $findOrCreateGalleryForImage(this.selection.nodeBeforeCursor);
4739
- } else {
4740
- this.#gallery = $createImageGalleryNode();
4741
- this.contents.insertAtCursor(this.#gallery);
5189
+ #handleUploadProgress(event, request) {
5190
+ const progress = Math.round(event.loaded / event.total * 100);
5191
+ try {
5192
+ this.#setProgress(progress);
5193
+ this.#dispatchEvent("lexxy:upload-progress", { file: this.file, progress });
5194
+ } catch {
5195
+ request.abort();
4742
5196
  }
4743
5197
  }
4744
5198
 
4745
- get #selectionIsAfterGalleryEdge() {
4746
- return this.constructor.selectionIsAfterGalleryEdge(this.selection)
5199
+ #setProgress(progress) {
5200
+ this.patchAndRewriteHistory({ progress });
4747
5201
  }
4748
5202
 
4749
- get #selectedNode() {
4750
- const { node } = this.selection.selectedNodeWithOffset();
4751
- return node
5203
+ #handleUploadError(error) {
5204
+ console.warn(`Upload error for ${this.file?.name ?? "file"}: ${error}`);
5205
+
5206
+ this.patchAndRewriteHistory({ uploadError: true });
4752
5207
  }
4753
5208
 
4754
- get #galleryInsertPosition() {
4755
- if (this.#selectionIsAfterGalleryEdge) return this.#gallery.getChildrenSize()
5209
+ $showUploadedAttachment(blob) {
5210
+ const previewSrc = this.isPreviewableImage && this.file ? URL.createObjectURL(this.file) : null;
4756
5211
 
4757
- const anchor = $getSelection()?.anchor;
4758
- const galleryHasElementSelection = anchor?.getNode().is(this.#gallery);
4759
- if (galleryHasElementSelection) return anchor.offset
5212
+ const replacementNode = this.#toActionTextAttachmentNodeWith(blob, previewSrc);
5213
+ this.replaceAndRewriteHistory(replacementNode);
4760
5214
 
4761
- const selectedNode = this.#selectedNode;
4762
- const childIndex = this.#gallery.isParentOf(selectedNode) && selectedNode.getIndexWithinParent();
4763
- return childIndex !== false ? (childIndex + 1) : 0
5215
+ return replacementNode.getKey()
4764
5216
  }
4765
5217
 
4766
- get #imageNodes() {
4767
- return this.nodes.filter(node => ImageGalleryNode.isValidChild(node))
5218
+ #toActionTextAttachmentNodeWith(blob, previewSrc) {
5219
+ const conversion = new AttachmentNodeConversion(this, blob, previewSrc);
5220
+ return conversion.toAttachmentNode()
4768
5221
  }
4769
5222
 
4770
- get #nonImageNodes() {
4771
- return this.nodes.filter(node => !ImageGalleryNode.isValidChild(node))
5223
+ #dispatchEvent(name, detail) {
5224
+ const figure = this.editor.getElementByKey(this.getKey());
5225
+ if (figure) dispatch(figure, name, detail);
4772
5226
  }
5227
+ }
4773
5228
 
4774
- #insertImagesInGallery() {
4775
- this.#gallery.splice(this.#galleryInsertPosition, 0, this.#imageNodes);
5229
+ class AttachmentNodeConversion {
5230
+ constructor(uploadNode, blob, previewSrc) {
5231
+ this.uploadNode = uploadNode;
5232
+ this.blob = blob;
5233
+ this.previewSrc = previewSrc;
4776
5234
  }
4777
5235
 
4778
- #insertNonImagesAfterGallery() {
4779
- let beforeNode = this.#gallery;
5236
+ toAttachmentNode() {
5237
+ return new ActionTextAttachmentNode({
5238
+ ...this.uploadNode,
5239
+ ...this.#propertiesFromBlob,
5240
+ src: this.#src,
5241
+ previewSrc: this.previewSrc,
5242
+ pendingPreview: this.blob.previewable && !this.uploadNode.isPreviewableImage
5243
+ })
5244
+ }
4780
5245
 
4781
- for (const node of this.#nonImageNodes) {
4782
- beforeNode.insertAfter(node);
4783
- beforeNode = node;
5246
+ get #propertiesFromBlob() {
5247
+ const { blob } = this;
5248
+ return {
5249
+ sgid: blob.attachable_sgid,
5250
+ altText: blob.filename,
5251
+ contentType: blob.content_type,
5252
+ fileName: blob.filename,
5253
+ fileSize: blob.byte_size,
5254
+ previewable: blob.previewable,
4784
5255
  }
4785
5256
  }
5257
+
5258
+ get #src() {
5259
+ return this.blob.previewable ? this.blob.url : this.#blobSrc
5260
+ }
5261
+
5262
+ get #blobSrc() {
5263
+ return this.uploadNode.blobUrlTemplate
5264
+ .replace(":signed_id", this.blob.signed_id)
5265
+ .replace(":filename", encodeURIComponent(this.blob.filename))
5266
+ }
5267
+ }
5268
+
5269
+ function $createActionTextAttachmentUploadNode(...args) {
5270
+ return new ActionTextAttachmentUploadNode(...args)
4786
5271
  }
4787
5272
 
4788
5273
  class NodeInserter {
@@ -4891,7 +5376,7 @@ class Contents {
4891
5376
  this.editor.update(() => {
4892
5377
  if ($hasUpdateTag(PASTE_TAG)) this.#stripTableCellColorStyles(doc);
4893
5378
 
4894
- const nodes = $generateFilteredNodesFromDOM(this.editorElement, doc);
5379
+ const nodes = this.editorElement.$generateNodesFromDOM(doc);
4895
5380
  if (!this.#insertUploadNodes(nodes)) {
4896
5381
  this.insertAtCursor(...nodes);
4897
5382
  }
@@ -4914,6 +5399,7 @@ class Contents {
4914
5399
  const selection = $getSelection();
4915
5400
  if (!$isRangeSelection(selection)) return
4916
5401
 
5402
+ $expandSelectionToLineBreaksAndSplitAtEdges(selection);
4917
5403
  $setBlocksType(selection, () => $createParagraphNode());
4918
5404
  }
4919
5405
 
@@ -4921,6 +5407,7 @@ class Contents {
4921
5407
  const selection = $getSelection();
4922
5408
  if (!$isRangeSelection(selection)) return
4923
5409
 
5410
+ $expandSelectionToLineBreaksAndSplitAtEdges(selection);
4924
5411
  $setBlocksType(selection, () => $createHeadingNode(tag));
4925
5412
  }
4926
5413
 
@@ -4962,10 +5449,14 @@ class Contents {
4962
5449
  if (allCode) {
4963
5450
  blockElements.forEach(node => this.#unwrapCodeBlock(node));
4964
5451
  } else {
5452
+ $expandSelectionToLineBreaksAndSplitAtEdges(selection);
5453
+ const elements = this.#blockLevelElementsInSelection(selection);
5454
+ if (elements.length === 0) return
5455
+
4965
5456
  const codeNode = $createCodeNode("plain");
4966
- blockElements.at(-1).insertAfter(codeNode);
5457
+ elements.at(-1).insertAfter(codeNode);
4967
5458
  codeNode.selectEnd();
4968
- this.insertAtCursor(...blockElements);
5459
+ this.insertAtCursor(...elements);
4969
5460
  }
4970
5461
  }
4971
5462
 
@@ -4984,8 +5475,7 @@ class Contents {
4984
5475
  } else {
4985
5476
  topLevelElements.filter($isQuoteNode).forEach(node => this.#unwrap(node));
4986
5477
 
4987
- $splitParagraphsAtLineBreakBoundaries(selection);
4988
-
5478
+ $expandSelectionToLineBreaksAndSplitAtEdges(selection);
4989
5479
  const elements = this.#topLevelElementsInSelection(selection);
4990
5480
  if (elements.length === 0) return
4991
5481
 
@@ -5100,7 +5590,7 @@ class Contents {
5100
5590
  console.warn("This editor does not supports attachments (it's configured with [attachments=false])");
5101
5591
  return
5102
5592
  }
5103
- const validFiles = Array.from(files).filter(this.#shouldUploadFile.bind(this));
5593
+ const validFiles = Array.from(files).filter(file => this.editorElement.acceptsFile(file));
5104
5594
 
5105
5595
  this.editor.update(() => {
5106
5596
  const uploader = Uploader.for(this.editorElement, validFiles);
@@ -5114,6 +5604,15 @@ class Contents {
5114
5604
  });
5115
5605
  }
5116
5606
 
5607
+ $createUploadNode(file) {
5608
+ return $createActionTextAttachmentUploadNode({
5609
+ file,
5610
+ uploadUrl: this.editorElement.directUploadUrl,
5611
+ blobUrlTemplate: this.editorElement.blobUrlTemplate,
5612
+ contentType: file.type,
5613
+ })
5614
+ }
5615
+
5117
5616
  insertPendingAttachment(file) {
5118
5617
  if (!this.editorElement.supportsAttachments) return null
5119
5618
 
@@ -5244,45 +5743,8 @@ class Contents {
5244
5743
  const selection = $getSelection();
5245
5744
  if (!$isRangeSelection(selection)) return
5246
5745
 
5247
- this.#splitParagraphsAtLineBreaks(selection);
5248
- }
5249
-
5250
- #splitParagraphsAtLineBreaks(selection) {
5251
- const anchorTopLevel = selection.anchor.getNode().getTopLevelElement();
5252
- const focusTopLevel = selection.focus.getNode().getTopLevelElement();
5253
- const topLevelElements = this.#topLevelElementsInSelection(selection);
5254
-
5255
- for (const element of topLevelElements) {
5256
- if (!$isParagraphNode(element)) continue
5257
-
5258
- const children = element.getChildren();
5259
- if (!children.some($isLineBreakNode)) continue
5260
-
5261
- // Check whether this paragraph needs splitting: skip only if neither
5262
- // selection endpoint is inside it (meaning it's a middle paragraph
5263
- // fully between anchor and focus with no partial lines to split off).
5264
- // Compare top-level elements so endpoints inside nested inline nodes
5265
- // (e.g. text inside a LinkNode) are still recognized.
5266
- if (element !== anchorTopLevel && element !== focusTopLevel) continue
5267
-
5268
- const groups = [ [] ];
5269
- for (const child of children) {
5270
- if ($isLineBreakNode(child)) {
5271
- groups.push([]);
5272
- child.remove();
5273
- } else {
5274
- groups[groups.length - 1].push(child);
5275
- }
5276
- }
5277
-
5278
- for (const group of groups) {
5279
- if (group.length === 0) continue
5280
- const paragraph = $createParagraphNode();
5281
- group.forEach(child => paragraph.append(child));
5282
- element.insertBefore(paragraph);
5283
- }
5284
- if (groups.some(group => group.length > 0)) element.remove();
5285
- }
5746
+ $expandSelectionToLineBreaksAndSplitAtEdges(selection);
5747
+ $splitSelectedParagraphsAtInnerLineBreaks(selection);
5286
5748
  }
5287
5749
 
5288
5750
  #blockLevelElementsInSelection(selection) {
@@ -5452,14 +5914,10 @@ class Contents {
5452
5914
  }
5453
5915
 
5454
5916
  #createHtmlNodeWith(html) {
5455
- const htmlNodes = $generateFilteredNodesFromDOM(this.editorElement, parseHtml(html));
5917
+ const htmlNodes = this.editorElement.$generateNodesFromDOM(parseHtml(html));
5456
5918
  return htmlNodes[0] || $createParagraphNode()
5457
5919
  }
5458
5920
 
5459
- #shouldUploadFile(file) {
5460
- return dispatch(this.editorElement, "lexxy:file-accept", { file }, true)
5461
- }
5462
-
5463
5921
  // When the selection anchor is on a shadow root (e.g. a table cell), Lexical's
5464
5922
  // insertNodes can't find a block parent and fails silently. Normalize the
5465
5923
  // selection to point inside the shadow root's content instead.
@@ -5551,8 +6009,12 @@ class Clipboard {
5551
6009
 
5552
6010
  #isOnlyURLPasted(clipboardData) {
5553
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
5554
6013
  const types = Array.from(clipboardData.types);
5555
- 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"))
5556
6018
  }
5557
6019
 
5558
6020
  #isPastingIntoCodeBlock() {
@@ -5589,9 +6051,9 @@ class Clipboard {
5589
6051
  #pastePlainText(clipboardData) {
5590
6052
  const item = clipboardData.items[0];
5591
6053
  item.getAsString((text) => {
5592
- if (isUrl(text) && this.contents.hasSelectedText()) {
6054
+ if (isAutolinkableURL(text) && this.contents.hasSelectedText()) {
5593
6055
  this.contents.createLinkWithSelectedText(text);
5594
- } else if (isUrl(text)) {
6056
+ } else if (isAutolinkableURL(text)) {
5595
6057
  const nodeKey = this.contents.createLink(text);
5596
6058
  this.#dispatchLinkInsertEvent(nodeKey, { url: text });
5597
6059
  } else if (this.editorElement.supportsMarkdown) {
@@ -5751,6 +6213,7 @@ class Extensions {
5751
6213
 
5752
6214
  this.#clearPreviousExtensionToolbarButtons(toolbar);
5753
6215
  this.#addExtensionToolbarButtons(toolbar);
6216
+ toolbar.requestOverflowRefresh();
5754
6217
  }
5755
6218
 
5756
6219
  dispose() {
@@ -6609,7 +7072,11 @@ class AttachmentDragAndDrop {
6609
7072
  const ATTACHMENT_ATTRIBUTES = [ "alt", "caption", "content", "content-type", "data-direct-upload-id",
6610
7073
  "data-sgid", "filename", "filesize", "height", "presentation", "previewable", "sgid", "url", "width" ];
6611
7074
 
7075
+ const UPLOADS_BUSY_MESSAGE = "Please wait for all files to upload";
7076
+
6612
7077
  class AttachmentsExtension extends LexxyExtension {
7078
+ #uploadsCount = 0
7079
+
6613
7080
  get enabled() {
6614
7081
  return this.editorElement.supportsAttachments
6615
7082
  }
@@ -6626,17 +7093,41 @@ class AttachmentsExtension extends LexxyExtension {
6626
7093
  ActionTextAttachmentUploadNode,
6627
7094
  ImageGalleryNode
6628
7095
  ],
6629
- register(editor) {
7096
+ register: (editor) => {
6630
7097
  const dragAndDrop = new AttachmentDragAndDrop(editor);
6631
7098
 
6632
7099
  return mergeRegister(
6633
7100
  editor.registerNodeTransform(ActionTextAttachmentNode, $extractAttachmentFromParagraph),
6634
7101
  editor.registerCommand(DELETE_CHARACTER_COMMAND, $collapseIntoGallery, COMMAND_PRIORITY_NORMAL),
7102
+ editor.registerMutationListener(ActionTextAttachmentUploadNode, this.#handleUploadMutations.bind(this)),
6635
7103
  () => dragAndDrop.destroy()
6636
7104
  )
6637
7105
  }
6638
7106
  })
6639
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
+ }
6640
7131
  }
6641
7132
 
6642
7133
  // Decorator nodes can be wrapped in a Paragraph Node by Lexical when contained in a <div>
@@ -7004,6 +7495,38 @@ function $openLink(target) {
7004
7495
  }
7005
7496
  }
7006
7497
 
7498
+ class PreventLexicalTripleClickExtension extends LexxyExtension {
7499
+ get lexicalExtension() {
7500
+ return defineExtension({
7501
+ name: "lexxy/prevent-lexical-triple-click",
7502
+ register: (editor) => editor.registerRootListener((rootElement) => {
7503
+ if (rootElement) {
7504
+ return registerEventListener(
7505
+ rootElement,
7506
+ "click",
7507
+ this.#handleTripleClick.bind(this),
7508
+ { capture: true }
7509
+ )
7510
+ }
7511
+ })
7512
+ })
7513
+ }
7514
+
7515
+ // Stop propagation of the triple-click to prevent Lexical's handler from running.
7516
+ //
7517
+ // Lexical's onClick handler implements a triple-click handler that is trivial/anemic/naïve. The
7518
+ // intention of the change, made in facebook/lexical#4512, seems to be to deal with browsers'
7519
+ // "overselection" behavior, where a triple-click selection might end at offset 0 of the following
7520
+ // block, which can cause issues when transforming the selection. But the implementation breaks
7521
+ // many common real-world use cases and Lexxy does not demonstrate the behavior it's intended to
7522
+ // work around (in headers or tables).
7523
+ #handleTripleClick(event) {
7524
+ if (event.detail === 3) {
7525
+ event.stopPropagation();
7526
+ }
7527
+ }
7528
+ }
7529
+
7007
7530
  class LexicalEditorElement extends HTMLElement {
7008
7531
  static formAssociated = true
7009
7532
  static debug = false
@@ -7012,12 +7535,16 @@ class LexicalEditorElement extends HTMLElement {
7012
7535
  static observedAttributes = [ "connected", "required" ]
7013
7536
 
7014
7537
  #initialValue = ""
7015
- #validationTextArea = document.createElement("textarea")
7016
- #editorInitializedRafId = null
7538
+ #initializeEventDispatched = false
7539
+ #editorInitializedDispatched = false
7540
+ #valueLoaded = false
7017
7541
  #listeners = new ListenerBin()
7018
7542
  #disposables = []
7019
7543
  #historyState = { undo: false, redo: false }
7020
7544
 
7545
+ #validity = new Map()
7546
+ #validationTextArea = document.createElement("textarea")
7547
+
7021
7548
  constructor() {
7022
7549
  super();
7023
7550
  this.internals = this.attachInternals();
@@ -7050,29 +7577,40 @@ class LexicalEditorElement extends HTMLElement {
7050
7577
 
7051
7578
  this.#initialize();
7052
7579
 
7053
- this.#scheduleEditorInitializedDispatch();
7054
7580
  this.toggleAttribute("connected", true);
7055
7581
 
7056
- this.#handleAutofocus();
7057
-
7058
- this.valueBeforeDisconnect = null;
7582
+ requestAnimationFrame(() => {
7583
+ this.#mountRoot();
7584
+ this.#handleAutofocus();
7585
+ this.#dispatchInitialize();
7586
+ });
7059
7587
  }
7060
7588
 
7061
7589
  disconnectedCallback() {
7062
- this.#cancelEditorInitializedDispatch();
7063
- 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;
7064
7598
  this.#reset(); // Prevent hangs with Safari when morphing
7065
7599
  }
7066
7600
 
7067
7601
  attributeChangedCallback(name, oldValue, newValue) {
7068
- 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) {
7069
7608
  requestAnimationFrame(() => this.#reconnect());
7070
7609
  }
7610
+ }
7071
7611
 
7072
- if (name === "required" && this.isConnected) {
7073
- this.#validationTextArea.required = this.hasAttribute("required");
7074
- this.#setValidity();
7075
- }
7612
+ requiredChangedCallback() {
7613
+ if (this.isConnected) this.#requestValidityRefresh();
7076
7614
  }
7077
7615
 
7078
7616
  formResetCallback() {
@@ -7098,6 +7636,27 @@ class LexicalEditorElement extends HTMLElement {
7098
7636
  return this.getAttribute("name")
7099
7637
  }
7100
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
+
7101
7660
  get toolbarElement() {
7102
7661
  if (!this.#hasToolbar) return null
7103
7662
 
@@ -7114,7 +7673,8 @@ class LexicalEditorElement extends HTMLElement {
7114
7673
  RewritableHistoryExtension,
7115
7674
  AttachmentsExtension,
7116
7675
  FormatEscapeExtension,
7117
- LinkOpenerExtension
7676
+ LinkOpenerExtension,
7677
+ PreventLexicalTripleClickExtension
7118
7678
  ]
7119
7679
  }
7120
7680
 
@@ -7145,6 +7705,16 @@ class LexicalEditorElement extends HTMLElement {
7145
7705
  }
7146
7706
  }
7147
7707
 
7708
+ acceptsFile(file) {
7709
+ return dispatch(this, "lexxy:file-accept", { file }, true)
7710
+ }
7711
+
7712
+ $generateNodesFromDOM(doc) {
7713
+ let nodes = $generateNodesFromDOM(this.editor, doc);
7714
+ if ($hasUpdateTag(PASTE_TAG)) nodes = $convertInlineImageDataURIs(nodes, this);
7715
+ return filterDisallowedAttachmentNodes(nodes, this)
7716
+ }
7717
+
7148
7718
  get isEmpty() {
7149
7719
  return [ "<p><br></p>", "<p></p>", "" ].includes(this.value.trim())
7150
7720
  }
@@ -7182,7 +7752,7 @@ class LexicalEditorElement extends HTMLElement {
7182
7752
 
7183
7753
  if (!this.editor) return
7184
7754
 
7185
- this.#cancelEditorInitializedDispatch();
7755
+ this.#editorInitializedDispatched = true;
7186
7756
  this.#dispatchEditorInitialized();
7187
7757
  this.#dispatchAttributesChange();
7188
7758
  }
@@ -7237,6 +7807,7 @@ class LexicalEditorElement extends HTMLElement {
7237
7807
  }
7238
7808
 
7239
7809
  set value(html) {
7810
+ this.#valueLoaded = true;
7240
7811
  const editorHasFocus = this.#isContentFocused;
7241
7812
 
7242
7813
  this.editor.update(() => {
@@ -7267,7 +7838,7 @@ class LexicalEditorElement extends HTMLElement {
7267
7838
 
7268
7839
  #parseHtmlIntoLexicalNodes(html) {
7269
7840
  if (!html) html = "<p></p>";
7270
- const nodes = $generateFilteredNodesFromDOM(this, parseHtml(`${html}`));
7841
+ const nodes = this.$generateNodesFromDOM(parseHtml(`${html}`));
7271
7842
 
7272
7843
  return nodes
7273
7844
  .filter(this.#isNotWhitespaceOnlyNode)
@@ -7333,11 +7904,17 @@ class LexicalEditorElement extends HTMLElement {
7333
7904
  ...this.extensions.lexicalExtensions
7334
7905
  );
7335
7906
 
7336
- editor.setRootElement(this.editorContentElement);
7337
-
7338
7907
  return editor
7339
7908
  }
7340
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
+
7341
7918
  get #lexicalNodes() {
7342
7919
  const nodes = [ CustomActionTextAttachmentNode ];
7343
7920
 
@@ -7394,7 +7971,6 @@ class LexicalEditorElement extends HTMLElement {
7394
7971
 
7395
7972
  this.internals.setFormValue(html);
7396
7973
  this._internalFormValue = html;
7397
- this.#validationTextArea.value = this.isEmpty ? "" : html;
7398
7974
 
7399
7975
  if (changed) {
7400
7976
  dispatch(this, "lexxy:change");
@@ -7406,10 +7982,12 @@ class LexicalEditorElement extends HTMLElement {
7406
7982
  }
7407
7983
 
7408
7984
  #loadInitialValue() {
7409
- const initialHtml = this.valueBeforeDisconnect || this.getAttribute("value") || "<p><br></p>";
7410
- this.editor.update(() => {
7411
- this.value = this.#initialValue = initialHtml;
7412
- }, { 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
+ }
7413
7991
  }
7414
7992
 
7415
7993
  #resetBeforeTurboCaches() {
@@ -7429,11 +8007,50 @@ class LexicalEditorElement extends HTMLElement {
7429
8007
  this.#clearCachedValues();
7430
8008
  this.#internalFormValue = this.value;
7431
8009
  this.#toggleEmptyStatus();
7432
- this.#setValidity();
8010
+ this.#requestValidityRefresh();
7433
8011
  this.#dispatchAttributesChange();
7434
8012
  }));
7435
8013
  }
7436
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
+
7437
8054
  #clearCachedValues() {
7438
8055
  this.cachedValue = null;
7439
8056
  this.cachedStringValue = null;
@@ -7589,14 +8206,6 @@ class LexicalEditorElement extends HTMLElement {
7589
8206
  this.classList.toggle("lexxy-editor--empty", this.isEmpty);
7590
8207
  }
7591
8208
 
7592
- #setValidity() {
7593
- if (this.#validationTextArea.validity.valid) {
7594
- this.internals.setValidity({});
7595
- } else {
7596
- this.internals.setValidity(this.#validationTextArea.validity, this.#validationTextArea.validationMessage, this.editorContentElement);
7597
- }
7598
- }
7599
-
7600
8209
  #configureSanitizer() {
7601
8210
  setSanitizerConfig(this.#allowedElements);
7602
8211
  }
@@ -7660,22 +8269,18 @@ class LexicalEditorElement extends HTMLElement {
7660
8269
  });
7661
8270
  }
7662
8271
 
7663
- #scheduleEditorInitializedDispatch() {
7664
- this.#cancelEditorInitializedDispatch();
7665
- this.#editorInitializedRafId = requestAnimationFrame(() => {
7666
- this.#editorInitializedRafId = null;
7667
- if (!this.isConnected || !this.adapter) return
7668
-
7669
- dispatch(this, "lexxy:initialize");
7670
- this.#dispatchEditorInitialized();
7671
- });
7672
- }
7673
-
7674
- #cancelEditorInitializedDispatch() {
7675
- 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
+ }
7676
8278
 
7677
- cancelAnimationFrame(this.#editorInitializedRafId);
7678
- this.#editorInitializedRafId = null;
8279
+ if (!this.#editorInitializedDispatched) {
8280
+ this.#editorInitializedDispatched = true;
8281
+ this.#dispatchEditorInitialized();
8282
+ }
8283
+ }
7679
8284
  }
7680
8285
 
7681
8286
  get #resolvedHighlightColors() {
@@ -7726,8 +8331,8 @@ class LexicalEditorElement extends HTMLElement {
7726
8331
  }
7727
8332
 
7728
8333
  #reset() {
7729
- this.#cancelEditorInitializedDispatch();
7730
8334
  this.#dispose();
8335
+ this.#resetValidity();
7731
8336
  this.editorContentElement?.remove();
7732
8337
  this.editorContentElement = null;
7733
8338
 
@@ -7747,6 +8352,10 @@ class LexicalEditorElement extends HTMLElement {
7747
8352
  this.valueBeforeDisconnect = null;
7748
8353
  this.connectedCallback();
7749
8354
  }
8355
+
8356
+ #resetValidity() {
8357
+ this.#validity = new Map();
8358
+ }
7750
8359
  }
7751
8360
 
7752
8361
  // Like $getRoot().getTextContent() but uses readable text for custom attachment nodes
@@ -7776,256 +8385,6 @@ function $getReadableTextContent(node) {
7776
8385
  return node.getTextContent()
7777
8386
  }
7778
8387
 
7779
- class ToolbarDropdown extends HTMLElement {
7780
- #listeners = new ListenerBin()
7781
-
7782
- connectedCallback() {
7783
- this.container = this.closest("details");
7784
-
7785
- this.#listeners.track(
7786
- registerEventListener(this.container, "toggle", this.#handleToggle),
7787
- registerEventListener(this.container, "keydown", this.#handleKeyDown)
7788
- );
7789
-
7790
- this.#onToolbarEditor(this.initialize.bind(this));
7791
- }
7792
-
7793
- disconnectedCallback() {
7794
- this.#listeners.dispose();
7795
- }
7796
-
7797
- get toolbar() {
7798
- return this.closest("lexxy-toolbar")
7799
- }
7800
-
7801
- get editorElement() {
7802
- return this.toolbar.editorElement
7803
- }
7804
-
7805
- get editor() {
7806
- return this.toolbar.editor
7807
- }
7808
-
7809
- track(...listeners) {
7810
- this.#listeners.track(...listeners);
7811
- }
7812
-
7813
- initialize() {
7814
- // Any post-editor initialization
7815
- }
7816
-
7817
- close() {
7818
- this.editor.focus();
7819
- this.container.open = false;
7820
- }
7821
-
7822
- async #onToolbarEditor(callback) {
7823
- await this.toolbar.editorElement;
7824
- callback();
7825
- }
7826
-
7827
- #handleToggle = () => {
7828
- if (this.container.open) {
7829
- this.#handleOpen();
7830
- }
7831
- }
7832
-
7833
- async #handleOpen() {
7834
- this.#interactiveElements[0].focus();
7835
- this.#resetTabIndexValues();
7836
- }
7837
-
7838
- #handleKeyDown = (event) => {
7839
- if (event.key === "Escape") {
7840
- event.stopPropagation();
7841
- this.close();
7842
- }
7843
- }
7844
-
7845
- async #resetTabIndexValues() {
7846
- await nextFrame();
7847
- this.#buttons.forEach((element, index) => {
7848
- element.setAttribute("tabindex", index === 0 ? 0 : "-1");
7849
- });
7850
- }
7851
-
7852
- get #interactiveElements() {
7853
- return Array.from(this.querySelectorAll("button, input"))
7854
- }
7855
-
7856
- get #buttons() {
7857
- return Array.from(this.querySelectorAll("button"))
7858
- }
7859
- }
7860
-
7861
- class LinkDropdown extends ToolbarDropdown {
7862
- connectedCallback() {
7863
- super.connectedCallback();
7864
-
7865
- this.input = this.querySelector("input");
7866
-
7867
- this.track(
7868
- registerEventListener(this.container, "toggle", this.#handleToggle),
7869
- registerEventListener(this.input, "keydown", this.#handleEnter),
7870
- registerEventListener(this.linkButton, "click", this.#handleLink),
7871
- registerEventListener(this.unlinkButton, "click", this.#handleUnlink)
7872
- );
7873
- }
7874
-
7875
- get linkButton() {
7876
- return this.querySelector("[value='link']")
7877
- }
7878
-
7879
- get unlinkButton() {
7880
- return this.querySelector("[value='unlink']")
7881
- }
7882
-
7883
- #handleToggle = ({ newState }) => {
7884
- this.input.value = this.#selectedLinkUrl;
7885
- this.input.required = newState === "open";
7886
- }
7887
-
7888
- #handleEnter = (event) => {
7889
- if (event.key === "Enter") {
7890
- event.preventDefault();
7891
- event.stopPropagation();
7892
- this.#handleLink(event);
7893
- }
7894
- }
7895
-
7896
- #handleLink = () => {
7897
- if (!this.input.checkValidity()) {
7898
- this.input.reportValidity();
7899
- return
7900
- }
7901
-
7902
- this.editor.dispatchCommand("link", this.input.value);
7903
- this.close();
7904
- }
7905
-
7906
- #handleUnlink = () => {
7907
- this.editor.dispatchCommand("unlink");
7908
- this.close();
7909
- }
7910
-
7911
- get #selectedLinkUrl() {
7912
- return this.editor.getEditorState().read(() => {
7913
- const linkNode = this.editorElement.selection.nearestNodeOfType(LinkNode);
7914
- return linkNode?.getURL() ?? ""
7915
- })
7916
- }
7917
- }
7918
-
7919
- const APPLY_HIGHLIGHT_SELECTOR = "button.lexxy-highlight-button";
7920
- const REMOVE_HIGHLIGHT_SELECTOR = "[data-command='removeHighlight']";
7921
-
7922
- // Use Symbol instead of null since $getSelectionStyleValueForProperty
7923
- // responds differently for backward selections if null is the default
7924
- // see https://github.com/facebook/lexical/issues/8013
7925
- const NO_STYLE = Symbol("no_style");
7926
-
7927
- class HighlightDropdown extends ToolbarDropdown {
7928
- initialize() {
7929
- this.#setUpButtons();
7930
- this.#registerButtonHandlers();
7931
- }
7932
-
7933
- connectedCallback() {
7934
- super.connectedCallback();
7935
- this.track(registerEventListener(this.container, "toggle", this.#handleToggle));
7936
- }
7937
-
7938
- #registerButtonHandlers() {
7939
- this.#colorButtons.forEach(button => {
7940
- this.track(registerEventListener(button, "click", this.#handleColorButtonClick));
7941
- });
7942
- this.track(registerEventListener(this.querySelector(REMOVE_HIGHLIGHT_SELECTOR), "click", this.#handleRemoveHighlightClick));
7943
- }
7944
-
7945
- #setUpButtons() {
7946
- this.#buttonContainer.innerHTML = "";
7947
-
7948
- const colorGroups = this.editorElement.config.get("highlight.buttons");
7949
-
7950
- this.#populateButtonGroup("color", colorGroups.color);
7951
- this.#populateButtonGroup("background-color", colorGroups["background-color"]);
7952
-
7953
- const maxNumberOfColors = Math.max(colorGroups.color.length, colorGroups["background-color"].length);
7954
- this.style.setProperty("--max-colors", maxNumberOfColors);
7955
- }
7956
-
7957
- #populateButtonGroup(attribute, values) {
7958
- values.forEach((value, index) => {
7959
- this.#buttonContainer.appendChild(this.#createButton(attribute, value, index));
7960
- });
7961
- }
7962
-
7963
- #createButton(attribute, value, index) {
7964
- const button = document.createElement("button");
7965
- button.dataset.style = attribute;
7966
- button.style.setProperty(attribute, value);
7967
- button.dataset.value = value;
7968
- button.classList.add("lexxy-editor__toolbar-button", "lexxy-highlight-button");
7969
- button.name = attribute + "-" + index;
7970
- return button
7971
- }
7972
-
7973
- #handleToggle = ({ newState }) => {
7974
- if (newState === "open") {
7975
- this.editor.getEditorState().read(() => {
7976
- this.#updateColorButtonStates($getSelection());
7977
- });
7978
- }
7979
- }
7980
-
7981
- #handleColorButtonClick = (event) => {
7982
- event.preventDefault();
7983
-
7984
- const button = event.target.closest(APPLY_HIGHLIGHT_SELECTOR);
7985
- if (!button) return
7986
-
7987
- const attribute = button.dataset.style;
7988
- const value = button.dataset.value;
7989
-
7990
- this.editor.dispatchCommand("toggleHighlight", { [attribute]: value });
7991
- this.close();
7992
- }
7993
-
7994
- #handleRemoveHighlightClick = (event) => {
7995
- event.preventDefault();
7996
-
7997
- this.editor.dispatchCommand("removeHighlight");
7998
- this.close();
7999
- }
8000
-
8001
- #updateColorButtonStates(selection) {
8002
- if (!$isRangeSelection(selection)) { return }
8003
-
8004
- // Use non-"" default, so "" indicates mixed highlighting
8005
- const textColor = $getSelectionStyleValueForProperty(selection, "color", NO_STYLE);
8006
- const backgroundColor = $getSelectionStyleValueForProperty(selection, "background-color", NO_STYLE);
8007
-
8008
- this.#colorButtons.forEach(button => {
8009
- const matchesSelection = button.dataset.value === textColor || button.dataset.value === backgroundColor;
8010
- const next = matchesSelection.toString();
8011
- if (button.getAttribute("aria-pressed") !== next) {
8012
- button.setAttribute("aria-pressed", next);
8013
- }
8014
- });
8015
-
8016
- const hasHighlight = textColor !== NO_STYLE || backgroundColor !== NO_STYLE;
8017
- this.querySelector(REMOVE_HIGHLIGHT_SELECTOR).disabled = !hasHighlight;
8018
- }
8019
-
8020
- get #buttonContainer() {
8021
- return this.querySelector(".lexxy-highlight-colors")
8022
- }
8023
-
8024
- get #colorButtons() {
8025
- return Array.from(this.querySelectorAll(APPLY_HIGHLIGHT_SELECTOR))
8026
- }
8027
- }
8028
-
8029
8388
  class BaseSource {
8030
8389
  // Template method to override
8031
8390
  async buildListItems(filter = "") {
@@ -8384,6 +8743,7 @@ class LexicalPromptElement extends HTMLElement {
8384
8743
 
8385
8744
  if (this.#doesSpaceSelect) {
8386
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));
8387
8747
  }
8388
8748
 
8389
8749
  // Register arrow keys with CRITICAL priority to prevent Lexical's selection handlers from running
@@ -8417,16 +8777,12 @@ class LexicalPromptElement extends HTMLElement {
8417
8777
  return Array.from(this.popoverElement.querySelectorAll(".lexxy-prompt-menu__item"))
8418
8778
  }
8419
8779
 
8420
- #selectOption(listItem) {
8780
+ #selectOption(listItem, { scrollIntoView = false } = {}) {
8421
8781
  this.#clearListItemSelection();
8422
8782
  listItem.toggleAttribute("aria-selected", true);
8423
- listItem.scrollIntoView({ block: "nearest", behavior: "smooth" });
8424
- listItem.focus();
8425
-
8426
- // Preserve selection to prevent cursor jump
8427
- this.#selection.preservingSelection(() => {
8428
- this.#editorElement.focus();
8429
- });
8783
+ if (scrollIntoView) {
8784
+ listItem.scrollIntoView({ block: "nearest", container: "nearest", behavior: "smooth" });
8785
+ }
8430
8786
 
8431
8787
  this.#setEditorAssociationAttribute("aria-controls", this.popoverElement.id);
8432
8788
  this.#setEditorAssociationAttribute("aria-activedescendant", listItem.id);
@@ -8545,7 +8901,7 @@ class LexicalPromptElement extends HTMLElement {
8545
8901
 
8546
8902
  #showEmptyResults() {
8547
8903
  this.popoverElement.classList.add("lexxy-prompt-menu--empty");
8548
- const el = createElement("li", { innerHTML: this.#emptyResultsMessage });
8904
+ const el = createElement("li", { textContent: this.#emptyResultsMessage });
8549
8905
  el.classList.add("lexxy-prompt-menu__item--empty");
8550
8906
  this.popoverElement.append(el);
8551
8907
  }
@@ -8570,17 +8926,22 @@ class LexicalPromptElement extends HTMLElement {
8570
8926
  }
8571
8927
  });
8572
8928
  }
8573
- // 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)
8574
8935
  }
8575
8936
 
8576
8937
  #moveSelectionDown() {
8577
8938
  const nextIndex = this.#selectedIndex + 1;
8578
- if (nextIndex < this.#listItemElements.length) this.#selectOption(this.#listItemElements[nextIndex]);
8939
+ if (nextIndex < this.#listItemElements.length) this.#selectOption(this.#listItemElements[nextIndex], { scrollIntoView: true });
8579
8940
  }
8580
8941
 
8581
8942
  #moveSelectionUp() {
8582
8943
  const previousIndex = this.#selectedIndex - 1;
8583
- if (previousIndex >= 0) this.#selectOption(this.#listItemElements[previousIndex]);
8944
+ if (previousIndex >= 0) this.#selectOption(this.#listItemElements[previousIndex], { scrollIntoView: true });
8584
8945
  }
8585
8946
 
8586
8947
  get #selectedIndex() {
@@ -8627,7 +8988,7 @@ class LexicalPromptElement extends HTMLElement {
8627
8988
  }
8628
8989
 
8629
8990
  #buildEditableTextNodes(template) {
8630
- return $generateFilteredNodesFromDOM(this.#editorElement, parseHtml(`${template.innerHTML}`))
8991
+ return this.#editorElement.$generateNodesFromDOM(parseHtml(`${template.innerHTML}`))
8631
8992
  }
8632
8993
 
8633
8994
  #insertTemplatesAsAttachments(templates, stringToReplace, fallbackSgid = null) {
@@ -9606,14 +9967,19 @@ class TableTools extends HTMLElement {
9606
9967
 
9607
9968
  function defineElements() {
9608
9969
  const elements = {
9970
+ // Toolbar must be registered BEFORE Editor
9609
9971
  "lexxy-toolbar": LexicalToolbarElement,
9610
- "lexxy-editor": LexicalEditorElement,
9611
- "lexxy-link-dropdown": LinkDropdown,
9972
+ "lexxy-toolbar-dropdown": ToolbarDropdown,
9612
9973
  "lexxy-highlight-dropdown": HighlightDropdown,
9974
+ "lexxy-link-dropdown": LinkDropdown,
9975
+
9976
+ "lexxy-editor": LexicalEditorElement,
9977
+
9978
+ // Prompt must be registered AFTER Editor
9613
9979
  "lexxy-prompt": LexicalPromptElement,
9614
9980
  "lexxy-code-language-picker": CodeLanguagePicker,
9615
9981
  "lexxy-node-delete-button": NodeDeleteButton,
9616
- "lexxy-table-tools": TableTools,
9982
+ "lexxy-table-tools": TableTools
9617
9983
  };
9618
9984
 
9619
9985
  Object.entries(elements).forEach(([ name, element ]) => {