@37signals/lexxy 0.9.10-beta → 0.9.12-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,11 +1,10 @@
1
- import { isActiveAndVisible, extractPlainTextFromHtml, createElement, createAttachmentFigure, isPreviewableImage, dispatch, parseHtml, addBlockSpacing, generateDomId } from './lexxy_helpers.esm.js';
2
1
  export { highlightCode } from './lexxy_helpers.esm.js';
3
2
  import DOMPurify from 'dompurify';
4
- import { getStyleObjectFromCSS, getCSSFromStyleObject, $ensureForwardRangeSelection, $isAtNodeEnd, $getSelectionStyleValueForProperty, $patchStyleText, $setBlocksType, $forEachSelectedTextNode } from '@lexical/selection';
5
- 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, 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, PASTE_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, ParagraphNode, RootNode, COMMAND_PRIORITY_HIGH, DRAGSTART_COMMAND, DROP_COMMAND, INSERT_PARAGRAPH_COMMAND, mergeRegister as mergeRegister$1, $findMatchingParent, 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, $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';
5
+ import { LinkNode, $createAutoLinkNode, $toggleLink, $createLinkNode, $isLinkNode, AutoLinkNode } from '@lexical/link';
6
6
  import { buildEditorFromExtensions } from '@lexical/extension';
7
7
  import { ListNode, ListItemNode, $getListDepth, INSERT_UNORDERED_LIST_COMMAND, INSERT_ORDERED_LIST_COMMAND, $isListItemNode, $isListNode, registerList } from '@lexical/list';
8
- import { LinkNode, $createAutoLinkNode, $toggleLink, $createLinkNode, $isLinkNode, AutoLinkNode } from '@lexical/link';
9
8
  import { $getNearestNodeOfType, $wrapNodeInElement, $lastToFirstIterator, $descendantsMatching, mergeRegister, $insertFirst, $unwrapAndFilterDescendants, $firstToLastIterator, $getNearestBlockElementAncestorOrThrow, IS_APPLE } from '@lexical/utils';
10
9
  import { registerPlainText } from '@lexical/plain-text';
11
10
  import { RichTextExtension, $isQuoteNode, $isHeadingNode, $createHeadingNode, $createQuoteNode, QuoteNode, HeadingNode, registerRichText } from '@lexical/rich-text';
@@ -126,6 +125,78 @@ class ListenerBin {
126
125
  }
127
126
  }
128
127
 
128
+ function createElement(name, properties, content = "") {
129
+ const element = document.createElement(name);
130
+ for (const [ key, value ] of Object.entries(properties || {})) {
131
+ if (key === "dataset") {
132
+ Object.entries(value).forEach(([ key, value ]) => (element.dataset[key] = value));
133
+ } else if (key in element) {
134
+ element[key] = value;
135
+ } else if (value !== null && value !== undefined) {
136
+ element.setAttribute(key, value);
137
+ }
138
+ }
139
+ if (content) {
140
+ element.innerHTML = content;
141
+ }
142
+ return element
143
+ }
144
+
145
+ function parseHtml(html) {
146
+ const parser = new DOMParser();
147
+ return parser.parseFromString(html, "text/html")
148
+ }
149
+
150
+ function createAttachmentFigure(contentType, isPreviewable, fileName) {
151
+ const extension = fileName ? fileName.split(".").pop().toLowerCase() : "unknown";
152
+ return createElement("figure", {
153
+ className: `attachment attachment--${isPreviewable ? "preview" : "file"} attachment--${extension}`,
154
+ "data-content-type": contentType
155
+ })
156
+ }
157
+
158
+ function isPreviewableImage(contentType) {
159
+ return contentType.startsWith("image/") && !contentType.includes("svg")
160
+ }
161
+
162
+ function dispatch(element, eventName, detail = null, cancelable = false) {
163
+ return element.dispatchEvent(new CustomEvent(eventName, { bubbles: true, detail, cancelable }))
164
+ }
165
+
166
+ function addBlockSpacing(doc) {
167
+ const blocks = doc.querySelectorAll("body > :not(h1, h2, h3, h4, h5, h6) + *");
168
+ for (const block of blocks) {
169
+ const spacer = doc.createElement("p");
170
+ spacer.appendChild(doc.createElement("br"));
171
+ block.before(spacer);
172
+ }
173
+ }
174
+
175
+ function generateDomId(prefix) {
176
+ const randomPart = Math.random().toString(36).slice(2, 10);
177
+ return `${prefix}-${randomPart}`
178
+ }
179
+
180
+ function extractPlainTextFromHtml(innerHtml = "") {
181
+ return parseHtml(innerHtml).body.textContent.trim()
182
+ }
183
+
184
+ function isActiveAndVisible(element) {
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
+ }
198
+ }
199
+
129
200
  function handleRollingTabIndex(elements, event) {
130
201
  const previousActiveElement = document.activeElement;
131
202
 
@@ -336,6 +407,7 @@ var ToolbarIcons = {
336
407
  class LexicalToolbarElement extends HTMLElement {
337
408
  static observedAttributes = [ "connected" ]
338
409
  #listeners = new ListenerBin()
410
+ #refreshToolbarAF = null
339
411
 
340
412
  constructor() {
341
413
  super();
@@ -346,7 +418,7 @@ class LexicalToolbarElement extends HTMLElement {
346
418
  }
347
419
 
348
420
  connectedCallback() {
349
- requestAnimationFrame(() => this.#refreshToolbarOverflow());
421
+ this.requestOverflowRefresh();
350
422
  this.setAttribute("role", "toolbar");
351
423
  this.#installResizeObserver();
352
424
  }
@@ -358,9 +430,12 @@ class LexicalToolbarElement extends HTMLElement {
358
430
  dispose() {
359
431
  this.#listeners.dispose();
360
432
 
433
+ cancelAnimationFrame(this.#refreshToolbarAF);
434
+
361
435
  this.editorElement = null;
362
436
  this.editor = null;
363
437
  this.selection = null;
438
+ this.#refreshToolbarAF = null;
364
439
 
365
440
  this.#createEditorPromise();
366
441
  }
@@ -386,10 +461,9 @@ class LexicalToolbarElement extends HTMLElement {
386
461
  this.#bindButtons();
387
462
  this.#bindHotkeys();
388
463
  this.#resetTabIndexValues();
389
- this.#setItemPositionValues();
390
464
  this.#monitorSelectionChanges();
391
465
  this.#monitorHistoryChanges();
392
- this.#refreshToolbarOverflow();
466
+ this.requestOverflowRefresh();
393
467
  this.#bindFocusListeners();
394
468
 
395
469
  this.resolveEditorPromise(editorElement);
@@ -401,6 +475,15 @@ class LexicalToolbarElement extends HTMLElement {
401
475
  return this.editorElement || await this.editorPromise
402
476
  }
403
477
 
478
+ requestOverflowRefresh() {
479
+ if (this.#refreshToolbarAF != null) return
480
+
481
+ this.#refreshToolbarAF = requestAnimationFrame(() => {
482
+ this.#refreshOverflow();
483
+ this.#refreshToolbarAF = null;
484
+ });
485
+ }
486
+
404
487
  #reconnect() {
405
488
  this.disconnectedCallback();
406
489
  this.connectedCallback();
@@ -415,7 +498,7 @@ class LexicalToolbarElement extends HTMLElement {
415
498
  }
416
499
 
417
500
  #installResizeObserver() {
418
- const resizeObserver = new ResizeObserver(() => this.#refreshToolbarOverflow());
501
+ const resizeObserver = new ResizeObserver(() => this.requestOverflowRefresh());
419
502
  resizeObserver.observe(this);
420
503
  this.#listeners.track(() => resizeObserver.disconnect());
421
504
  }
@@ -482,30 +565,29 @@ class LexicalToolbarElement extends HTMLElement {
482
565
  }
483
566
 
484
567
  #handleEditorFocus = () => {
485
- const firstVisible = this.#focusableItems.find(isActiveAndVisible);
568
+ const firstVisible = this.#buttons.find(isActiveAndVisible);
486
569
  if (firstVisible) firstVisible.tabIndex = 0;
487
570
  }
488
571
 
489
572
  #handleEditorBlur = () => {
490
573
  this.#resetTabIndexValues();
491
- this.#closeDropdowns();
492
574
  }
493
575
 
494
576
  #handleKeydown = (event) => {
495
- handleRollingTabIndex(this.#focusableItems, event);
577
+ handleRollingTabIndex(this.#buttons, event);
496
578
  }
497
579
 
498
580
  #resetTabIndexValues() {
499
- this.#focusableItems.forEach((button) => {
581
+ this.#buttons.forEach((button) => {
500
582
  button.tabIndex = -1;
501
583
  });
502
584
  }
503
585
 
504
586
  #monitorSelectionChanges() {
505
- this.#listeners.track(this.editor.registerUpdateListener(() => {
506
- this.editor.getEditorState().read(() => {
587
+ this.#listeners.track(this.editor.registerUpdateListener(({ editorState }) => {
588
+ editorState.read(() => {
507
589
  this.#updateButtonStates();
508
- this.#closeDropdowns();
590
+ this.closeDropdowns();
509
591
  });
510
592
  }));
511
593
  }
@@ -553,41 +635,53 @@ class LexicalToolbarElement extends HTMLElement {
553
635
  #setButtonPressed(name, isPressed) {
554
636
  const button = this.querySelector(`[name="${name}"]`);
555
637
  if (button) {
556
- button.setAttribute("aria-pressed", isPressed.toString());
638
+ const next = isPressed.toString();
639
+ if (button.getAttribute("aria-pressed") !== next) {
640
+ button.setAttribute("aria-pressed", next);
641
+ }
557
642
  }
558
643
  }
559
644
 
560
645
  #setButtonDisabled(name, isDisabled) {
561
646
  const button = this.querySelector(`[name="${name}"]`);
562
647
  if (button) {
563
- button.disabled = isDisabled;
564
- button.setAttribute("aria-disabled", isDisabled.toString());
648
+ if (button.disabled !== isDisabled) {
649
+ button.disabled = isDisabled;
650
+ }
651
+ const next = isDisabled.toString();
652
+ if (button.getAttribute("aria-disabled") !== next) {
653
+ button.setAttribute("aria-disabled", next);
654
+ }
565
655
  }
566
656
  }
567
657
 
568
- #refreshToolbarOverflow = () => {
658
+ #refreshOverflow() {
569
659
  this.#resetToolbarOverflow();
660
+ this.#reindexToolbarItems();
570
661
  this.#compactMenu();
571
662
 
572
- this.#overflow.style.display = this.#overflowMenu.children.length ? "block" : "none";
573
- this.#overflow.setAttribute("nonce", getNonce());
574
-
575
663
  const isOverflowing = this.#overflowMenu.children.length > 0;
664
+
576
665
  this.toggleAttribute("overflowing", isOverflowing);
666
+
667
+ this.#overflow.style.display = isOverflowing ? "block" : "none";
668
+ this.#overflow.setAttribute("nonce", getNonce());
669
+
577
670
  this.#overflowMenu.toggleAttribute("disabled", !isOverflowing);
578
671
  }
579
672
 
580
673
  // Separates layout reads from DOM writes to avoid forced reflows during init.
581
674
  // Measures every button's right edge in a single read pass, figures out which
582
675
  // buttons overflow using math, and then moves them in a single write pass.
583
- // The previous implementation interleaved `scrollWidth`/`clientWidth` reads with
584
- // `prepend()` writes inside a loop, forcing one full browser reflow per button.
585
676
  #compactMenu() {
586
- const buttons = this.#buttons;
677
+ const buttons = this.#overflowButtons;
587
678
  if (buttons.length === 0) return
588
679
 
589
- const availableWidth = this.clientWidth + 1; // +1 for Safari zoom rounding
590
- const buttonRightEdges = buttons.map(button => button.offsetLeft + button.offsetWidth);
680
+ const availableWidth = this.clientWidth;
681
+ const buttonRightEdges = buttons.map(button => {
682
+ const style = window.getComputedStyle(button);
683
+ return button.offsetLeft + button.offsetWidth + parseFloat(style.marginRight)
684
+ });
591
685
 
592
686
  let firstOverflowing = -1;
593
687
  for (let i = 0; i < buttons.length; i++) {
@@ -600,12 +694,12 @@ class LexicalToolbarElement extends HTMLElement {
600
694
  if (firstOverflowing === -1) return
601
695
 
602
696
  // Move one extra button to reserve space for the overflow control, which is
603
- // `display: none` until we show it — matching the previous implementation's
604
- // "move one more after it stops overflowing" behaviour.
697
+ // `display: none` until we show it
605
698
  const overflowIndex = Math.max(0, firstOverflowing - 1);
606
699
  const overflowButtons = buttons.slice(overflowIndex).reverse();
607
700
  for (const button of overflowButtons) {
608
701
  this.#overflowMenu.prepend(button);
702
+ button.role = "menuitem";
609
703
  }
610
704
  }
611
705
 
@@ -615,6 +709,7 @@ class LexicalToolbarElement extends HTMLElement {
615
709
 
616
710
  for (const item of items) {
617
711
  const nextItem = this.querySelector(`[data-position="${this.#itemPosition(item) + 1}"]`) ?? this.#overflow;
712
+ item.removeAttribute("role");
618
713
  this.insertBefore(item, nextItem);
619
714
  }
620
715
  }
@@ -623,22 +718,22 @@ class LexicalToolbarElement extends HTMLElement {
623
718
  return parseInt(item.dataset.position ?? "999")
624
719
  }
625
720
 
626
- #setItemPositionValues() {
721
+ #reindexToolbarItems() {
627
722
  this.#toolbarItems.forEach((item, index) => {
628
- if (item.dataset.position === undefined) {
629
- item.dataset.position = index;
630
- }
723
+ item.dataset.position = index;
631
724
  });
632
725
  }
633
726
 
634
- #closeDropdowns() {
635
- this.#dropdowns.forEach((details) => {
636
- details.open = false;
637
- });
638
- }
727
+ closeDropdowns({ except } = {}) {
728
+ this.#dropdowns.forEach((dropdown) => {
729
+ if (dropdown !== except) {
730
+ dropdown.close({ focusEditor: false });
731
+ }
732
+ });
733
+ }
639
734
 
640
735
  get #dropdowns() {
641
- return this.querySelectorAll("details")
736
+ return this.querySelectorAll(":scope .lexxy-editor__toolbar-dropdown")
642
737
  }
643
738
 
644
739
  get #overflow() {
@@ -646,15 +741,15 @@ class LexicalToolbarElement extends HTMLElement {
646
741
  }
647
742
 
648
743
  get #overflowMenu() {
649
- return this.querySelector(".lexxy-editor__toolbar-overflow-menu")
744
+ return this.#overflow?.querySelector(":scope > [data-dropdown-panel]")
650
745
  }
651
746
 
652
- get #buttons() {
747
+ get #overflowButtons() {
653
748
  return Array.from(this.querySelectorAll(":scope > button:not([data-prevent-overflow='true'])"))
654
749
  }
655
750
 
656
- get #focusableItems() {
657
- return Array.from(this.querySelectorAll(":scope button, :scope > details > summary"))
751
+ get #buttons() {
752
+ return Array.from(this.querySelectorAll(":scope button"))
658
753
  }
659
754
 
660
755
  get #toolbarItems() {
@@ -662,6 +757,8 @@ class LexicalToolbarElement extends HTMLElement {
662
757
  }
663
758
 
664
759
  static get defaultTemplate() {
760
+ const linkInputId = generateDomId("lexxy-link-url");
761
+
665
762
  return `
666
763
  <button class="lexxy-editor__toolbar-button" type="button" name="image" data-command="uploadImage" data-prevent-overflow="true" title="Add images and video">
667
764
  ${ToolbarIcons.image}
@@ -679,59 +776,59 @@ class LexicalToolbarElement extends HTMLElement {
679
776
  ${ToolbarIcons.italic}
680
777
  </button>
681
778
 
682
- <details class="lexxy-editor__toolbar-dropdown lexxy-editor__toolbar-dropdown--chevron" name="lexxy-dropdown">
683
- <summary class="lexxy-editor__toolbar-button" name="format" title="Text formatting">
779
+ <lexxy-toolbar-dropdown class="lexxy-editor__toolbar-dropdown">
780
+ <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">
684
781
  ${ToolbarIcons.heading}
685
- </summary>
686
- <div class="lexxy-editor__toolbar-dropdown-list">
687
- <button type="button" name="paragraph" data-command="setFormatParagraph" title="Paragraph">
782
+ </button>
783
+ <div data-dropdown-panel role="menu" class="lexxy-editor__toolbar-dropdown-list" hidden>
784
+ <button type="button" name="paragraph" data-command="setFormatParagraph" title="Paragraph" role="menuitem">
688
785
  ${ToolbarIcons.paragraph} <span>Normal</span>
689
786
  </button>
690
- <button type="button" name="heading-large" data-command="setFormatHeadingLarge" title="Large heading">
787
+ <button type="button" name="heading-large" data-command="setFormatHeadingLarge" title="Large heading" role="menuitem">
691
788
  ${ToolbarIcons.h2} <span>Large Heading</span>
692
789
  </button>
693
- <button type="button" name="heading-medium" data-command="setFormatHeadingMedium" title="Medium heading">
790
+ <button type="button" name="heading-medium" data-command="setFormatHeadingMedium" title="Medium heading" role="menuitem">
694
791
  ${ToolbarIcons.h3} <span>Medium Heading</span>
695
792
  </button>
696
- <button class="lexxy-editor__toolbar-group-end" type="button" name="heading-small" data-command="setFormatHeadingSmall" title="Small heading">
793
+ <button class="lexxy-editor__toolbar-group-end" type="button" name="heading-small" data-command="setFormatHeadingSmall" title="Small heading" role="menuitem">
697
794
  ${ToolbarIcons.h4} <span>Small Heading</span>
698
795
  </button>
699
796
  <div class="lexxy-editor__toolbar-separator" role="separator"></div>
700
- <button type="button" name="strikethrough" data-command="strikethrough" title="Strikethrough">
797
+ <button type="button" name="strikethrough" data-command="strikethrough" title="Strikethrough" role="menuitem">
701
798
  ${ToolbarIcons.strikethrough} <span>Strikethrough</span>
702
799
  </button>
703
- <button type="button" name="underline" data-command="underline" title="Underline">
800
+ <button type="button" name="underline" data-command="underline" title="Underline" role="menuitem">
704
801
  ${ToolbarIcons.underline} <span>Underline</span>
705
802
  </button>
706
803
  <div class="lexxy-editor__toolbar-separator" role="separator"></div>
707
- <button type="button" name="clear-formatting" data-command="clearFormatting" title="Clear formatting">
804
+ <button type="button" name="clear-formatting" data-command="clearFormatting" title="Clear formatting" role="menuitem">
708
805
  ${ToolbarIcons.clearFormatting} <span>Clear formatting</span>
709
806
  </button>
710
807
  </div>
711
- </details>
808
+ </lexxy-toolbar-dropdown>
712
809
 
713
- <details class="lexxy-editor__toolbar-dropdown lexxy-editor__toolbar-dropdown--chevron" name="lexxy-dropdown">
714
- <summary class="lexxy-editor__toolbar-button" name="highlight" title="Color highlight">
810
+ <lexxy-highlight-dropdown class="lexxy-editor__toolbar-dropdown lexxy-editor__toolbar-dropdown--highlight">
811
+ <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">
715
812
  ${ToolbarIcons.highlight}
716
- </summary>
717
- <lexxy-highlight-dropdown class="lexxy-editor__toolbar-dropdown-content">
813
+ </button>
814
+ <div data-dropdown-panel role="menu" hidden>
718
815
  <div class="lexxy-highlight-colors"></div>
719
- <button data-command="removeHighlight" class="lexxy-editor__toolbar-button lexxy-editor__toolbar-dropdown-reset">Remove all coloring</button>
720
- </lexxy-highlight-dropdown>
721
- </details>
816
+ <button data-command="removeHighlight" type="button" class="lexxy-editor__toolbar-button lexxy-editor__toolbar-dropdown-reset" role="menuitem">Remove all coloring</button>
817
+ </div>
818
+ </lexxy-highlight-dropdown>
722
819
 
723
- <details class="lexxy-editor__toolbar-dropdown" name="lexxy-dropdown">
724
- <summary class="lexxy-editor__toolbar-button lexxy-editor__toolbar-group-end" name="link" title="Link" data-hotkey="cmd+k ctrl+k">
820
+ <lexxy-link-dropdown class="lexxy-editor__toolbar-dropdown lexxy-editor__toolbar-dropdown--link">
821
+ <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">
725
822
  ${ToolbarIcons.link}
726
- </summary>
727
- <lexxy-link-dropdown class="lexxy-editor__toolbar-dropdown-content">
728
- <input type="url" placeholder="Enter a URL…" class="input">
823
+ </button>
824
+ <div data-dropdown-panel role="dialog" aria-label="Link" hidden>
825
+ <input type="url" placeholder="Enter a URL…" class="input" id="${linkInputId}">
729
826
  <div class="lexxy-editor__toolbar-dropdown-actions">
730
827
  <button type="button" class="lexxy-editor__toolbar-button" value="link">Link</button>
731
828
  <button type="button" class="lexxy-editor__toolbar-button" value="unlink">Unlink</button>
732
829
  </div>
733
- </lexxy-link-dropdown>
734
- </details>
830
+ </div>
831
+ </lexxy-link-dropdown>
735
832
 
736
833
  <button class="lexxy-editor__toolbar-button" type="button" name="quote" data-command="insertQuoteBlock" title="Quote">
737
834
  ${ToolbarIcons.quote}
@@ -756,9 +853,7 @@ class LexicalToolbarElement extends HTMLElement {
756
853
  ${ToolbarIcons.hr}
757
854
  </button>
758
855
 
759
- <div class="lexxy-editor__toolbar-spacer" role="separator"></div>
760
-
761
- <button class="lexxy-editor__toolbar-button" type="button" name="undo" data-command="undo" title="Undo" disabled aria-disabled="true">
856
+ <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">
762
857
  ${ToolbarIcons.undo}
763
858
  </button>
764
859
 
@@ -766,399 +861,723 @@ class LexicalToolbarElement extends HTMLElement {
766
861
  ${ToolbarIcons.redo}
767
862
  </button>
768
863
 
769
- <details class="lexxy-editor__toolbar-dropdown lexxy-editor__toolbar-overflow" name="lexxy-dropdown">
770
- <summary class="lexxy-editor__toolbar-button" aria-label="Show more toolbar buttons">${ToolbarIcons.overflow}</summary>
771
- <div class="lexxy-editor__toolbar-dropdown-content lexxy-editor__toolbar-overflow-menu" aria-label="More toolbar buttons"></div>
772
- </details>
864
+ <lexxy-toolbar-dropdown class="lexxy-editor__toolbar-dropdown lexxy-editor__toolbar-button--push-right lexxy-editor__toolbar-overflow">
865
+ <button data-dropdown-trigger class="lexxy-editor__toolbar-button" type="button" aria-haspopup="menu" aria-expanded="false" aria-label="Show more toolbar buttons">
866
+ ${ToolbarIcons.overflow}
867
+ </button>
868
+ <div data-dropdown-panel role="menu" class="lexxy-editor__toolbar-overflow-menu" aria-label="More toolbar buttons" hidden></div>
869
+ </lexxy-toolbar-dropdown>
773
870
  `
774
871
  }
775
872
  }
776
873
 
777
- function deepMerge(target, source) {
778
- const result = { ...target, ...source };
779
- for (const [ key, value ] of Object.entries(source)) {
780
- if (arePlainHashes(target[key], value)) {
781
- result[key] = deepMerge(target[key], value);
782
- }
783
- }
784
-
785
- return result
786
- }
874
+ function debounce(fn, wait) {
875
+ let timeout;
787
876
 
788
- function arePlainHashes(...values) {
789
- return values.every(value => value && value.constructor == Object)
877
+ return (...args) => {
878
+ clearTimeout(timeout);
879
+ timeout = setTimeout(() => fn(...args), wait);
880
+ }
790
881
  }
791
882
 
792
- class Configuration {
793
- #tree = {}
883
+ function debounceAsync(fn, wait) {
884
+ let timeout;
794
885
 
795
- constructor(...configs) {
796
- this.merge(...configs);
797
- }
886
+ return (...args) => {
887
+ clearTimeout(timeout);
798
888
 
799
- merge(...configs) {
800
- return this.#tree = configs.reduce(deepMerge, this.#tree)
889
+ return new Promise((resolve, reject) => {
890
+ timeout = setTimeout(async () => {
891
+ try {
892
+ const result = await fn(...args);
893
+ resolve(result);
894
+ } catch (err) {
895
+ reject(err);
896
+ }
897
+ }, wait);
898
+ })
801
899
  }
900
+ }
802
901
 
803
- get(path) {
804
- const keys = path.split(".");
805
- return keys.reduce((node, key) => node[key], this.#tree)
806
- }
902
+ function delay(ms) {
903
+ return new Promise((resolve) => setTimeout(resolve, ms))
807
904
  }
808
905
 
809
- function range(from, to) {
810
- return [ ...Array(1 + to - from).keys() ].map(i => i + from)
906
+ function nextFrame() {
907
+ return new Promise(requestAnimationFrame)
811
908
  }
812
909
 
813
- const global = new Configuration({
814
- attachmentTagName: "action-text-attachment",
815
- attachmentContentTypeNamespace: "actiontext",
816
- authenticatedUploads: false,
817
- extensions: []
818
- });
910
+ class ToolbarDropdown extends HTMLElement {
911
+ #listeners = new ListenerBin()
819
912
 
820
- const presets = new Configuration({
821
- default: {
822
- attachments: true,
823
- markdown: true,
824
- multiLine: true,
825
- permittedAttachmentTypes: null,
826
- richText: true,
827
- toolbar: {
828
- upload: "both"
829
- },
830
- highlight: {
831
- buttons: {
832
- color: range(1, 9).map(n => `var(--highlight-${n})`),
833
- "background-color": range(1, 9).map(n => `var(--highlight-bg-${n})`),
834
- },
835
- permit: {
836
- color: [],
837
- "background-color": []
838
- }
839
- }
913
+ connectedCallback() {
914
+ this.#onToolbarEditor(() => {
915
+ this.#registerListeners();
916
+ this.editorReady();
917
+ });
840
918
  }
841
- });
842
919
 
843
- var Lexxy = {
844
- global,
845
- presets,
846
- configure({ global: newGlobal, ...newPresets }) {
847
- if (newGlobal) {
848
- global.merge(newGlobal);
849
- }
850
- presets.merge(newPresets);
920
+ disconnectedCallback() {
921
+ this.#listeners.dispose();
851
922
  }
852
- };
853
923
 
854
- function setSanitizerConfig(allowedTags) {
855
- DOMPurify.clearConfig();
856
- DOMPurify.setConfig(buildConfig(allowedTags));
857
- }
924
+ editorReady() {}
925
+ onOpen() {}
926
+ onClose() {}
858
927
 
859
- function sanitize(html) {
860
- return DOMPurify.sanitize(html)
861
- }
928
+ get trigger() {
929
+ return this.querySelector(":scope > [data-dropdown-trigger]")
930
+ }
862
931
 
863
- function bytesToHumanSize(bytes) {
864
- if (bytes === 0) return "0 B"
865
- const sizes = [ "B", "KB", "MB", "GB", "TB", "PB" ];
866
- const i = Math.floor(Math.log(bytes) / Math.log(1024));
867
- const value = bytes / Math.pow(1024, i);
868
- return `${ value.toFixed(2) } ${ sizes[i] }`
869
- }
932
+ get panel() {
933
+ return this.querySelector(":scope > [data-dropdown-panel]")
934
+ }
870
935
 
871
- function extractFileName(string) {
872
- return string.split("/").pop()
873
- }
936
+ get toolbar() {
937
+ return this.closest("lexxy-toolbar")
938
+ }
874
939
 
875
- // The content attribute is raw HTML (matching Trix/ActionText). Older Lexxy
876
- // versions JSON-encoded it, so try JSON.parse first for backward compatibility.
877
- function parseAttachmentContent(content) {
878
- try {
879
- return JSON.parse(content)
880
- } catch {
881
- return content
940
+ get editorElement() {
941
+ return this.toolbar?.editorElement
882
942
  }
883
- }
884
943
 
885
- class CustomActionTextAttachmentNode extends DecoratorNode {
886
- static getType() {
887
- return "custom_action_text_attachment"
944
+ get editor() {
945
+ return this.toolbar?.editor
888
946
  }
889
947
 
890
- static clone(node) {
891
- return new CustomActionTextAttachmentNode({ ...node }, node.__key)
948
+ get isOpen() {
949
+ return this.panel.hidden === false
892
950
  }
893
951
 
894
- static importJSON(serializedNode) {
895
- return new CustomActionTextAttachmentNode({ ...serializedNode })
952
+ get isClosed() {
953
+ return !this.isOpen
896
954
  }
897
955
 
898
- static importDOM() {
899
- return {
900
- [this.TAG_NAME]: (element) => {
901
- if (!element.getAttribute("content")) {
902
- return null
903
- }
956
+ track(...listeners) {
957
+ this.#listeners.track(...listeners);
958
+ }
904
959
 
905
- return {
906
- conversion: (attachment) => {
907
- // Preserve initial space if present since Lexical removes it
908
- const nodes = [];
909
- const previousSibling = attachment.previousSibling;
910
- if (previousSibling && previousSibling.nodeType === Node.TEXT_NODE && /\s$/.test(previousSibling.textContent)) {
911
- nodes.push($createTextNode(" "));
912
- }
960
+ open() {
961
+ if (this.isOpen) return
962
+ this.trigger.setAttribute("aria-expanded", "true");
963
+ this.panel.hidden = false;
964
+ this.onOpen();
965
+ this.#focusFirstInteractive();
966
+ }
913
967
 
914
- const innerHtml = parseAttachmentContent(attachment.getAttribute("content"));
968
+ close({ focusEditor = true } = {}) {
969
+ if (focusEditor) this.editor?.focus();
915
970
 
916
- nodes.push(new CustomActionTextAttachmentNode({
917
- sgid: attachment.getAttribute("sgid"),
918
- innerHtml,
919
- plainText: attachment.textContent.trim() || extractPlainTextFromHtml(innerHtml),
920
- contentType: attachment.getAttribute("content-type")
921
- }));
971
+ if (this.isClosed) return
972
+ this.trigger.setAttribute("aria-expanded", "false");
973
+ this.panel.hidden = true;
974
+ this.onClose();
975
+ }
922
976
 
923
- const nextSibling = attachment.nextSibling;
924
- if (nextSibling && nextSibling.nodeType === Node.TEXT_NODE && /^\s/.test(nextSibling.textContent)) {
925
- nodes.push($createTextNode(" "));
926
- }
977
+ #registerListeners() {
978
+ this.#listeners.track(
979
+ registerEventListener(this, "keydown", this.#handleKeyDown),
980
+ registerEventListener(this.trigger, "click", this.#handleTriggerClick)
981
+ );
982
+ }
927
983
 
928
- return { node: nodes }
929
- },
930
- priority: 2
931
- }
932
- }
984
+ #handleTriggerClick = () => {
985
+ if (this.isOpen) {
986
+ this.close({ focusEditor: false });
987
+ } else {
988
+ this.toolbar?.closeDropdowns({ except: this });
989
+ this.open();
933
990
  }
934
991
  }
935
992
 
936
- static get TAG_NAME() {
937
- return Lexxy.global.get("attachmentTagName")
993
+ async #onToolbarEditor(callback) {
994
+ if (!this.toolbar) return
995
+
996
+ await this.toolbar.getEditorElement();
997
+ if (this.isConnected && this.toolbar) callback();
938
998
  }
939
999
 
940
- constructor({ tagName, sgid, contentType, innerHtml, plainText }, key) {
941
- super(key);
1000
+ #handleKeyDown = (event) => {
1001
+ if (event.key === "Escape") {
1002
+ event.stopPropagation();
1003
+ this.close();
1004
+ }
1005
+ }
942
1006
 
943
- const contentTypeNamespace = Lexxy.global.get("attachmentContentTypeNamespace");
1007
+ async #focusFirstInteractive() {
1008
+ this.#interactiveElements[0]?.focus();
1009
+ await this.#resetTabIndexValues();
1010
+ }
944
1011
 
945
- this.tagName = tagName || CustomActionTextAttachmentNode.TAG_NAME;
946
- this.sgid = sgid;
947
- this.contentType = contentType || `application/vnd.${contentTypeNamespace}.unknown`;
948
- this.innerHtml = innerHtml;
949
- this.plainText = plainText ?? extractPlainTextFromHtml(innerHtml);
1012
+ async #resetTabIndexValues() {
1013
+ await nextFrame();
1014
+ this.#buttons.forEach((element, index) => {
1015
+ element.setAttribute("tabindex", index === 0 ? 0 : "-1");
1016
+ });
950
1017
  }
951
1018
 
952
- createDOM() {
953
- const figure = createElement(this.tagName, { "content-type": this.contentType, "data-lexxy-decorator": true });
1019
+ get #interactiveElements() {
1020
+ return Array.from(this.panel.querySelectorAll("button, input"))
1021
+ }
954
1022
 
955
- figure.insertAdjacentHTML("beforeend", sanitize(this.innerHtml));
1023
+ get #buttons() {
1024
+ return Array.from(this.panel.querySelectorAll("button"))
1025
+ }
1026
+ }
956
1027
 
957
- const deleteButton = createElement("lexxy-node-delete-button");
958
- figure.appendChild(deleteButton);
1028
+ const APPLY_HIGHLIGHT_SELECTOR = "button.lexxy-highlight-button";
1029
+ const REMOVE_HIGHLIGHT_SELECTOR = "[data-command='removeHighlight']";
959
1030
 
960
- return figure
961
- }
1031
+ // Use Symbol instead of null since $getSelectionStyleValueForProperty
1032
+ // responds differently for backward selections if null is the default
1033
+ // see https://github.com/facebook/lexical/issues/8013
1034
+ const NO_STYLE = Symbol("no_style");
962
1035
 
963
- updateDOM() {
964
- return false
1036
+ class HighlightDropdown extends ToolbarDropdown {
1037
+ editorReady() {
1038
+ this.#setUpButtons();
1039
+ this.#registerButtonHandlers();
965
1040
  }
966
1041
 
967
- getTextContent() {
968
- return "\ufeff"
1042
+ onOpen() {
1043
+ this.editor.getEditorState().read(() => {
1044
+ this.#updateColorButtonStates($getSelection());
1045
+ });
969
1046
  }
970
1047
 
971
- getReadableTextContent() {
972
- return this.plainText || `[${this.contentType}]`
1048
+ #registerButtonHandlers() {
1049
+ this.#colorButtons.forEach(button => {
1050
+ this.track(registerEventListener(button, "click", this.#handleColorButtonClick));
1051
+ });
973
1052
  }
974
1053
 
975
- isInline() {
976
- return true
977
- }
1054
+ #setUpButtons() {
1055
+ this.#buttonContainer.innerHTML = "";
978
1056
 
979
- exportDOM() {
980
- const attachment = createElement(this.tagName, {
981
- sgid: this.sgid,
982
- content: this.innerHtml,
983
- "content-type": this.contentType
984
- });
1057
+ const colorGroups = this.editorElement.config.get("highlight.buttons");
985
1058
 
986
- return { element: attachment }
987
- }
1059
+ this.#populateButtonGroup("color", colorGroups.color);
1060
+ this.#populateButtonGroup("background-color", colorGroups["background-color"]);
988
1061
 
989
- exportJSON() {
990
- return {
991
- type: "custom_action_text_attachment",
992
- version: 1,
993
- tagName: this.tagName,
994
- sgid: this.sgid,
995
- contentType: this.contentType,
996
- innerHtml: this.innerHtml,
997
- plainText: this.plainText
998
- }
1062
+ const maxNumberOfColors = Math.max(colorGroups.color.length, colorGroups["background-color"].length);
1063
+ this.panel.style.setProperty("--max-colors", maxNumberOfColors);
999
1064
  }
1000
1065
 
1001
- decorate() {
1002
- return null
1066
+ #populateButtonGroup(attribute, values) {
1067
+ values.forEach((value, index) => {
1068
+ this.#buttonContainer.appendChild(this.#createButton(attribute, value, index));
1069
+ });
1003
1070
  }
1004
- }
1005
-
1006
- function dasherize(value) {
1007
- return value.replace(/([A-Z])/g, (_, char) => `-${char.toLowerCase()}`)
1008
- }
1009
1071
 
1010
- function isUrl(string) {
1011
- try {
1012
- new URL(string);
1013
- return true
1014
- } catch {
1015
- return false
1072
+ #createButton(attribute, value, index) {
1073
+ return createElement("button", {
1074
+ type: "button",
1075
+ dataset: { value, style: attribute },
1076
+ style: `${attribute}: ${value}`,
1077
+ class: "lexxy-editor__toolbar-button lexxy-highlight-button",
1078
+ name: `${attribute}-${index}`,
1079
+ role: "menuitem"
1080
+ })
1016
1081
  }
1017
- }
1018
-
1019
- function normalizeFilteredText(string) {
1020
- return string
1021
- .toLowerCase()
1022
- .normalize("NFD").replace(/[\u0300-\u036f]/g, "") // Remove diacritics
1023
- }
1024
1082
 
1025
- function filterMatchPosition(text, potentialMatch) {
1026
- const normalizedText = normalizeFilteredText(text);
1027
- const normalizedMatch = normalizeFilteredText(potentialMatch);
1083
+ #handleColorButtonClick = (event) => {
1084
+ event.preventDefault();
1028
1085
 
1029
- if (!normalizedMatch) return 0
1086
+ const button = event.target.closest(APPLY_HIGHLIGHT_SELECTOR);
1087
+ if (!button) return
1030
1088
 
1031
- const match = normalizedText.match(new RegExp(`(?:^|\\b)${escapeForRegExp(normalizedMatch)}`));
1032
- return match ? match.index : -1
1033
- }
1089
+ const { style, value } = button.dataset;
1034
1090
 
1035
- function upcaseFirst(string) {
1036
- return string.charAt(0).toUpperCase() + string.slice(1)
1037
- }
1091
+ this.editor.dispatchCommand("toggleHighlight", { [style]: value });
1092
+ this.close();
1093
+ }
1038
1094
 
1039
- function escapeForRegExp(string) {
1040
- return string.replace(/[.*+?^${}()|[\]\\]/g, "\\$&")
1041
- }
1095
+ #updateColorButtonStates(selection) {
1096
+ if (!$isRangeSelection(selection)) { return }
1042
1097
 
1043
- // Parses a value that may arrive as a boolean or as a string (e.g. from DOM
1044
- // getAttribute) into a proper boolean. Ensures "false" doesn't evaluate as truthy.
1045
- function parseBoolean(value) {
1046
- if (typeof value === "string") return value === "true"
1047
- return Boolean(value)
1048
- }
1098
+ // Use non-"" default, so "" indicates mixed highlighting
1099
+ const textColor = $getSelectionStyleValueForProperty(selection, "color", NO_STYLE);
1100
+ const backgroundColor = $getSelectionStyleValueForProperty(selection, "background-color", NO_STYLE);
1049
1101
 
1050
- class LexxyExtension {
1051
- #editorElement
1102
+ this.#colorButtons.forEach(button => {
1103
+ const matchesSelection = button.dataset.value === textColor || button.dataset.value === backgroundColor;
1104
+ const next = matchesSelection.toString();
1105
+ if (button.getAttribute("aria-pressed") !== next) {
1106
+ button.setAttribute("aria-pressed", next);
1107
+ }
1108
+ });
1052
1109
 
1053
- constructor(editorElement) {
1054
- this.#editorElement = editorElement;
1110
+ const hasHighlight = textColor !== NO_STYLE || backgroundColor !== NO_STYLE;
1111
+ this.panel.querySelector(REMOVE_HIGHLIGHT_SELECTOR).disabled = !hasHighlight;
1055
1112
  }
1056
1113
 
1057
- get editorElement() {
1058
- return this.#editorElement
1114
+ get #buttonContainer() {
1115
+ return this.panel.querySelector(".lexxy-highlight-colors")
1059
1116
  }
1060
1117
 
1061
- get editorConfig() {
1062
- return this.#editorElement.config
1118
+ get #colorButtons() {
1119
+ return Array.from(this.panel.querySelectorAll(APPLY_HIGHLIGHT_SELECTOR))
1063
1120
  }
1121
+ }
1064
1122
 
1065
- // optional: defaults to true
1066
- get enabled() {
1067
- return true
1123
+ class LinkDropdown extends ToolbarDropdown {
1124
+ editorReady() {
1125
+ this.input = this.panel.querySelector("input");
1126
+
1127
+ this.track(
1128
+ registerEventListener(this.input, "keydown", this.#handleEnter),
1129
+ registerEventListener(this.linkButton, "click", this.#handleLink),
1130
+ registerEventListener(this.unlinkButton, "click", this.#handleUnlink)
1131
+ );
1068
1132
  }
1069
1133
 
1070
- get lexicalExtension() {
1071
- return null
1134
+ onOpen() {
1135
+ this.input.value = this.#selectedLinkUrl;
1136
+ this.input.required = true;
1072
1137
  }
1073
1138
 
1074
- get allowedElements() {
1075
- return []
1139
+ onClose() {
1140
+ this.input.required = false;
1076
1141
  }
1077
1142
 
1078
- initializeToolbar(_lexxyToolbar) {
1143
+ get linkButton() {
1144
+ return this.panel.querySelector("[value='link']")
1145
+ }
1079
1146
 
1147
+ get unlinkButton() {
1148
+ return this.panel.querySelector("[value='unlink']")
1080
1149
  }
1081
- }
1082
1150
 
1083
- function $createNodeSelectionWith(...nodes) {
1084
- const selection = $createNodeSelection();
1085
- nodes.forEach(node => selection.add(node.getKey()));
1086
- return selection
1087
- }
1151
+ #handleEnter = (event) => {
1152
+ if (event.key === "Enter") {
1153
+ event.preventDefault();
1154
+ event.stopPropagation();
1155
+ this.#handleLink(event);
1156
+ }
1157
+ }
1088
1158
 
1089
- function $isShadowRoot(node) {
1090
- return $isElementNode(node) && $isRootOrShadowRoot(node) && !$isRootNode(node)
1091
- }
1159
+ #handleLink = () => {
1160
+ if (!this.input.checkValidity()) {
1161
+ this.input.reportValidity();
1162
+ return
1163
+ }
1092
1164
 
1093
- function $makeSafeForRoot(node) {
1094
- if ($isTextNode(node)) {
1095
- return $wrapNodeInElement(node, $createParagraphNode)
1096
- } else if (node.isParentRequired()) {
1097
- const parent = node.createRequiredParent();
1098
- return $wrapNodeInElement(node, parent)
1099
- } else {
1100
- return node
1165
+ this.editor.dispatchCommand("link", this.input.value);
1166
+ this.close();
1101
1167
  }
1102
- }
1103
1168
 
1104
- function getListType(node) {
1105
- const list = $getNearestNodeOfType(node, ListNode);
1106
- return list?.getListType() ?? null
1107
- }
1169
+ #handleUnlink = () => {
1170
+ this.editor.dispatchCommand("unlink");
1171
+ this.close();
1172
+ }
1108
1173
 
1109
- function isEditorFocused(editor) {
1110
- const rootElement = editor.getRootElement();
1111
- return rootElement !== null && rootElement.contains(document.activeElement)
1174
+ get #selectedLinkUrl() {
1175
+ return this.editor.getEditorState().read(() => {
1176
+ const linkNode = this.editorElement.selection.nearestNodeOfType(LinkNode);
1177
+ return linkNode?.getURL() ?? ""
1178
+ })
1179
+ }
1112
1180
  }
1113
1181
 
1114
- function $isAtNodeEdge(point, atStart = null) {
1115
- if (atStart === null) {
1116
- return $isAtNodeEdge(point, true) || $isAtNodeEdge(point, false)
1117
- } else {
1118
- return atStart ? $isAtNodeStart(point) : $isAtNodeEnd(point)
1182
+ function deepMerge(target, source) {
1183
+ const result = { ...target, ...source };
1184
+ for (const [ key, value ] of Object.entries(source)) {
1185
+ if (arePlainHashes(target[key], value)) {
1186
+ result[key] = deepMerge(target[key], value);
1187
+ }
1119
1188
  }
1120
- }
1121
1189
 
1122
- function $isAtNodeStart(point) {
1123
- return point.offset === 0
1190
+ return result
1124
1191
  }
1125
1192
 
1126
- function extendTextNodeConversion(conversionName, ...callbacks) {
1127
- return extendConversion(TextNode, conversionName, (conversionOutput, element) => ({
1128
- ...conversionOutput,
1129
- forChild: (lexicalNode, parentNode) => {
1130
- const originalForChild = conversionOutput?.forChild ?? (x => x);
1131
- let childNode = originalForChild(lexicalNode, parentNode);
1132
-
1133
-
1134
- if ($isTextNode(childNode)) {
1135
- childNode = callbacks.reduce(
1136
- (childNode, callback) => callback(childNode, element) ?? childNode,
1137
- childNode
1138
- );
1139
- return childNode
1140
- }
1141
- }
1142
- }))
1193
+ function arePlainHashes(...values) {
1194
+ return values.every(value => value && value.constructor == Object)
1143
1195
  }
1144
1196
 
1145
- function extendConversion(nodeKlass, conversionName, callback = (output => output)) {
1146
- return (element) => {
1147
- const converter = nodeKlass.importDOM()?.[conversionName]?.(element);
1148
- if (!converter) return null
1197
+ class Configuration {
1198
+ #tree = {}
1149
1199
 
1150
- const conversionOutput = converter.conversion(element);
1151
- if (!conversionOutput) return conversionOutput
1200
+ constructor(...configs) {
1201
+ this.merge(...configs);
1202
+ }
1152
1203
 
1153
- return callback(conversionOutput, element) ?? conversionOutput
1204
+ merge(...configs) {
1205
+ return this.#tree = configs.reduce(deepMerge, this.#tree)
1206
+ }
1207
+
1208
+ get(path) {
1209
+ const keys = path.split(".");
1210
+ return keys.reduce((node, key) => node[key], this.#tree)
1154
1211
  }
1155
1212
  }
1156
1213
 
1157
- function $isCursorOnLastLine(selection) {
1158
- const anchorNode = selection.anchor.getNode();
1159
- const elementNode = $isElementNode(anchorNode) ? anchorNode : anchorNode.getParentOrThrow();
1160
- const children = elementNode.getChildren();
1161
- if (children.length === 0) return true
1214
+ function range(from, to) {
1215
+ return [ ...Array(1 + to - from).keys() ].map(i => i + from)
1216
+ }
1217
+
1218
+ const global = new Configuration({
1219
+ attachmentTagName: "action-text-attachment",
1220
+ attachmentContentTypeNamespace: "actiontext",
1221
+ authenticatedUploads: false,
1222
+ extensions: []
1223
+ });
1224
+
1225
+ const presets = new Configuration({
1226
+ default: {
1227
+ attachments: true,
1228
+ markdown: true,
1229
+ multiLine: true,
1230
+ permittedAttachmentTypes: null,
1231
+ richText: true,
1232
+ toolbar: {
1233
+ upload: "both"
1234
+ },
1235
+ highlight: {
1236
+ buttons: {
1237
+ color: range(1, 9).map(n => `var(--highlight-${n})`),
1238
+ "background-color": range(1, 9).map(n => `var(--highlight-bg-${n})`),
1239
+ },
1240
+ permit: {
1241
+ color: [],
1242
+ "background-color": []
1243
+ }
1244
+ }
1245
+ }
1246
+ });
1247
+
1248
+ var Lexxy = {
1249
+ global,
1250
+ presets,
1251
+ configure({ global: newGlobal, ...newPresets }) {
1252
+ if (newGlobal) {
1253
+ global.merge(newGlobal);
1254
+ }
1255
+ presets.merge(newPresets);
1256
+ }
1257
+ };
1258
+
1259
+ function setSanitizerConfig(allowedTags) {
1260
+ DOMPurify.clearConfig();
1261
+ DOMPurify.setConfig(buildConfig(allowedTags));
1262
+ }
1263
+
1264
+ function sanitize(html) {
1265
+ return DOMPurify.sanitize(html)
1266
+ }
1267
+
1268
+ function bytesToHumanSize(bytes) {
1269
+ if (bytes === 0) return "0 B"
1270
+ const sizes = [ "B", "KB", "MB", "GB", "TB", "PB" ];
1271
+ const i = Math.floor(Math.log(bytes) / Math.log(1024));
1272
+ const value = bytes / Math.pow(1024, i);
1273
+ return `${ value.toFixed(2) } ${ sizes[i] }`
1274
+ }
1275
+
1276
+ function extractFileName(string) {
1277
+ return string.split("/").pop()
1278
+ }
1279
+
1280
+ // The content attribute is raw HTML (matching Trix/ActionText). Older Lexxy
1281
+ // versions JSON-encoded it, so try JSON.parse first for backward compatibility.
1282
+ function parseAttachmentContent(content) {
1283
+ try {
1284
+ return JSON.parse(content)
1285
+ } catch {
1286
+ return content
1287
+ }
1288
+ }
1289
+
1290
+ function mimeTypeToExtension(mimeType) {
1291
+ if (!mimeType) return null
1292
+
1293
+ const extension = mimeType.split("/")[1];
1294
+ return extension
1295
+ }
1296
+
1297
+ class CustomActionTextAttachmentNode extends DecoratorNode {
1298
+ static getType() {
1299
+ return "custom_action_text_attachment"
1300
+ }
1301
+
1302
+ static clone(node) {
1303
+ return new CustomActionTextAttachmentNode({ ...node }, node.__key)
1304
+ }
1305
+
1306
+ static importJSON(serializedNode) {
1307
+ return new CustomActionTextAttachmentNode({ ...serializedNode })
1308
+ }
1309
+
1310
+ static importDOM() {
1311
+ return {
1312
+ [this.TAG_NAME]: (element) => {
1313
+ if (!element.getAttribute("content")) {
1314
+ return null
1315
+ }
1316
+
1317
+ return {
1318
+ conversion: (attachment) => {
1319
+ // Preserve initial space if present since Lexical removes it
1320
+ const nodes = [];
1321
+ const previousSibling = attachment.previousSibling;
1322
+ if (previousSibling && previousSibling.nodeType === Node.TEXT_NODE && /\s$/.test(previousSibling.textContent)) {
1323
+ nodes.push($createTextNode(" "));
1324
+ }
1325
+
1326
+ const innerHtml = parseAttachmentContent(attachment.getAttribute("content"));
1327
+
1328
+ nodes.push(new CustomActionTextAttachmentNode({
1329
+ sgid: attachment.getAttribute("sgid"),
1330
+ innerHtml,
1331
+ plainText: attachment.textContent.trim() || extractPlainTextFromHtml(innerHtml),
1332
+ contentType: attachment.getAttribute("content-type")
1333
+ }));
1334
+
1335
+ const nextSibling = attachment.nextSibling;
1336
+ if (nextSibling && nextSibling.nodeType === Node.TEXT_NODE && /^\s/.test(nextSibling.textContent)) {
1337
+ nodes.push($createTextNode(" "));
1338
+ }
1339
+
1340
+ return { node: nodes }
1341
+ },
1342
+ priority: 2
1343
+ }
1344
+ }
1345
+ }
1346
+ }
1347
+
1348
+ static get TAG_NAME() {
1349
+ return Lexxy.global.get("attachmentTagName")
1350
+ }
1351
+
1352
+ constructor({ tagName, sgid, contentType, innerHtml, plainText }, key) {
1353
+ super(key);
1354
+
1355
+ const contentTypeNamespace = Lexxy.global.get("attachmentContentTypeNamespace");
1356
+
1357
+ this.tagName = tagName || CustomActionTextAttachmentNode.TAG_NAME;
1358
+ this.sgid = sgid;
1359
+ this.contentType = contentType || `application/vnd.${contentTypeNamespace}.unknown`;
1360
+ this.innerHtml = innerHtml;
1361
+ this.plainText = plainText ?? extractPlainTextFromHtml(innerHtml);
1362
+ }
1363
+
1364
+ createDOM() {
1365
+ const figure = createElement(this.tagName, { "content-type": this.contentType, "data-lexxy-decorator": true });
1366
+
1367
+ figure.insertAdjacentHTML("beforeend", sanitize(this.innerHtml));
1368
+
1369
+ const deleteButton = createElement("lexxy-node-delete-button");
1370
+ figure.appendChild(deleteButton);
1371
+
1372
+ return figure
1373
+ }
1374
+
1375
+ updateDOM() {
1376
+ return false
1377
+ }
1378
+
1379
+ getTextContent() {
1380
+ return "\ufeff"
1381
+ }
1382
+
1383
+ getReadableTextContent() {
1384
+ return this.plainText || `[${this.contentType}]`
1385
+ }
1386
+
1387
+ isInline() {
1388
+ return true
1389
+ }
1390
+
1391
+ exportDOM() {
1392
+ const attachment = createElement(this.tagName, {
1393
+ sgid: this.sgid,
1394
+ content: this.innerHtml,
1395
+ "content-type": this.contentType
1396
+ });
1397
+
1398
+ return { element: attachment }
1399
+ }
1400
+
1401
+ exportJSON() {
1402
+ return {
1403
+ type: "custom_action_text_attachment",
1404
+ version: 1,
1405
+ tagName: this.tagName,
1406
+ sgid: this.sgid,
1407
+ contentType: this.contentType,
1408
+ innerHtml: this.innerHtml,
1409
+ plainText: this.plainText
1410
+ }
1411
+ }
1412
+
1413
+ decorate() {
1414
+ return null
1415
+ }
1416
+ }
1417
+
1418
+ function dasherize(value) {
1419
+ return value.replace(/([A-Z])/g, (_, char) => `-${char.toLowerCase()}`)
1420
+ }
1421
+
1422
+ function isAutolinkableURL(string) {
1423
+ return /^(?:[a-z0-9]+:\/\/|www\.)[^\s]+$/i.test(string)
1424
+ }
1425
+
1426
+ function normalizeFilteredText(string) {
1427
+ return string
1428
+ .toLowerCase()
1429
+ .normalize("NFD").replace(/[\u0300-\u036f]/g, "") // Remove diacritics
1430
+ }
1431
+
1432
+ function filterMatchPosition(text, potentialMatch) {
1433
+ const normalizedText = normalizeFilteredText(text);
1434
+ const normalizedMatch = normalizeFilteredText(potentialMatch);
1435
+
1436
+ if (!normalizedMatch) return 0
1437
+
1438
+ const match = normalizedText.match(new RegExp(`(?:^|\\b)${escapeForRegExp(normalizedMatch)}`));
1439
+ return match ? match.index : -1
1440
+ }
1441
+
1442
+ function upcaseFirst(string) {
1443
+ return string.charAt(0).toUpperCase() + string.slice(1)
1444
+ }
1445
+
1446
+ function escapeForRegExp(string) {
1447
+ return string.replace(/[.*+?^${}()|[\]\\]/g, "\\$&")
1448
+ }
1449
+
1450
+ // Parses a value that may arrive as a boolean or as a string (e.g. from DOM
1451
+ // getAttribute) into a proper boolean. Ensures "false" doesn't evaluate as truthy.
1452
+ function parseBoolean(value) {
1453
+ if (typeof value === "string") return value === "true"
1454
+ return Boolean(value)
1455
+ }
1456
+
1457
+ class LexxyExtension {
1458
+ #editorElement
1459
+
1460
+ constructor(editorElement) {
1461
+ this.#editorElement = editorElement;
1462
+ }
1463
+
1464
+ get editorElement() {
1465
+ return this.#editorElement
1466
+ }
1467
+
1468
+ get editorConfig() {
1469
+ return this.#editorElement.config
1470
+ }
1471
+
1472
+ // optional: defaults to true
1473
+ get enabled() {
1474
+ return true
1475
+ }
1476
+
1477
+ get lexicalExtension() {
1478
+ return null
1479
+ }
1480
+
1481
+ get allowedElements() {
1482
+ return []
1483
+ }
1484
+
1485
+ initializeToolbar(_lexxyToolbar) {
1486
+
1487
+ }
1488
+
1489
+ dispose() {
1490
+ }
1491
+ }
1492
+
1493
+ function $containsRangeSelection(node, selection = $getSelection()) {
1494
+ if ($isRangeSelection(selection)) {
1495
+ const { commonAncestor } = $getCommonAncestor(selection.focus.getNode(), selection.anchor.getNode());
1496
+ return $findMatchingParent(commonAncestor, parent => parent.is(node))
1497
+ } else {
1498
+ return false
1499
+ }
1500
+ }
1501
+
1502
+ function $createNodeSelectionWith(...nodes) {
1503
+ const selection = $createNodeSelection();
1504
+ nodes.forEach(node => selection.add(node.getKey()));
1505
+ return selection
1506
+ }
1507
+
1508
+ function $isShadowRoot(node) {
1509
+ return $isElementNode(node) && $isRootOrShadowRoot(node) && !$isRootNode(node)
1510
+ }
1511
+
1512
+ function $makeSafeForRoot(node) {
1513
+ if ($isTextNode(node)) {
1514
+ return $wrapNodeInElement(node, $createParagraphNode)
1515
+ } else if (node.isParentRequired()) {
1516
+ const parent = node.createRequiredParent();
1517
+ return $wrapNodeInElement(node, parent)
1518
+ } else {
1519
+ return node
1520
+ }
1521
+ }
1522
+
1523
+ function getListType(node) {
1524
+ const list = $getNearestNodeOfType(node, ListNode);
1525
+ return list?.getListType() ?? null
1526
+ }
1527
+
1528
+ function isEditorFocused(editor) {
1529
+ const rootElement = editor.getRootElement();
1530
+ return rootElement !== null && rootElement.contains(document.activeElement)
1531
+ }
1532
+
1533
+ function $isAtNodeEdge(point, atStart = null) {
1534
+ if (atStart === null) {
1535
+ return $isAtNodeEdge(point, true) || $isAtNodeEdge(point, false)
1536
+ } else {
1537
+ return atStart ? $isAtNodeStart(point) : $isAtNodeEnd(point)
1538
+ }
1539
+ }
1540
+
1541
+ function $isAtNodeStart(point) {
1542
+ return point.offset === 0
1543
+ }
1544
+
1545
+ function extendTextNodeConversion(conversionName, ...callbacks) {
1546
+ return extendConversion(TextNode, conversionName, (conversionOutput, element) => ({
1547
+ ...conversionOutput,
1548
+ forChild: (lexicalNode, parentNode) => {
1549
+ const originalForChild = conversionOutput?.forChild ?? (x => x);
1550
+ let childNode = originalForChild(lexicalNode, parentNode);
1551
+
1552
+
1553
+ if ($isTextNode(childNode)) {
1554
+ childNode = callbacks.reduce(
1555
+ (childNode, callback) => callback(childNode, element) ?? childNode,
1556
+ childNode
1557
+ );
1558
+ return childNode
1559
+ }
1560
+ }
1561
+ }))
1562
+ }
1563
+
1564
+ function extendConversion(nodeKlass, conversionName, callback = (output => output)) {
1565
+ return (element) => {
1566
+ const converter = nodeKlass.importDOM()?.[conversionName]?.(element);
1567
+ if (!converter) return null
1568
+
1569
+ const conversionOutput = converter.conversion(element);
1570
+ if (!conversionOutput) return conversionOutput
1571
+
1572
+ return callback(conversionOutput, element) ?? conversionOutput
1573
+ }
1574
+ }
1575
+
1576
+ function $isCursorOnLastLine(selection) {
1577
+ const anchorNode = selection.anchor.getNode();
1578
+ const elementNode = $isElementNode(anchorNode) ? anchorNode : anchorNode.getParentOrThrow();
1579
+ const children = elementNode.getChildren();
1580
+ if (children.length === 0) return true
1162
1581
 
1163
1582
  const lastChild = children[children.length - 1];
1164
1583
 
@@ -1796,11 +2215,6 @@ function $isActionTextAttachmentNode(node) {
1796
2215
  return node instanceof ActionTextAttachmentNode
1797
2216
  }
1798
2217
 
1799
- function $generateFilteredNodesFromDOM(editorElement, doc) {
1800
- const nodes = $generateNodesFromDOM(editorElement.editor, doc);
1801
- return filterDisallowedAttachmentNodes(nodes, editorElement)
1802
- }
1803
-
1804
2218
  function filterDisallowedAttachmentNodes(nodes, editorElement) {
1805
2219
  return nodes
1806
2220
  .filter(node => !isDisallowedAttachment(node, editorElement))
@@ -1819,6 +2233,67 @@ function isDisallowedAttachment(node, editorElement) {
1819
2233
  !editorElement.permitsAttachmentContentType(node.contentType)
1820
2234
  }
1821
2235
 
2236
+ // Replaces inline `data:image/...` attachments with upload nodes that flow through the normal
2237
+ // file upload pipeline.
2238
+ //
2239
+ // Without this step, pasted-from-Google-Docs-style content lands in the editor with the entire
2240
+ // base64 image embedded in the attachment's `src`, which then persists into the saved HTML and
2241
+ // bloats the stored document.
2242
+ //
2243
+ // Each conversion dispatches the cancelable `lexxy:file-accept` event so the host's allowlist (and
2244
+ // any other listener) can refuse the synthesized File before it's accepted into the upload
2245
+ // pipeline; on refusal, the node is dropped silently — matching how `Contents#uploadFiles` handles
2246
+ // file-picker rejections.
2247
+ function $convertInlineImageDataURIs(nodes, editorElement) {
2248
+ const topLevel = nodes
2249
+ .map(node => isInlineImageDataURIAttachment(node) ? $tryCreateUploadFromDataURI(node, editorElement) : node)
2250
+ .filter(node => node !== null);
2251
+
2252
+ for (const node of topLevel) {
2253
+ for (const desc of $descendantsMatching([ node ], isInlineImageDataURIAttachment)) {
2254
+ const upload = $tryCreateUploadFromDataURI(desc, editorElement);
2255
+ if (upload) {
2256
+ desc.replace(upload);
2257
+ } else {
2258
+ desc.remove();
2259
+ }
2260
+ }
2261
+ }
2262
+
2263
+ return topLevel
2264
+ }
2265
+
2266
+ function isInlineImageDataURIAttachment(node) {
2267
+ return node instanceof ActionTextAttachmentNode &&
2268
+ /^data:image\/[^,]*;base64,/i.test(node.src ?? "")
2269
+ }
2270
+
2271
+ function $tryCreateUploadFromDataURI(node, editorElement) {
2272
+ const file = dataURIToFile(node.src);
2273
+ if (file && editorElement.acceptsFile(file)) {
2274
+ return editorElement.contents.$createUploadNode(file)
2275
+ }
2276
+ return null
2277
+ }
2278
+
2279
+ function dataURIToFile(dataURI) {
2280
+ try {
2281
+ const [ header, data ] = dataURI.split(",");
2282
+
2283
+ // https://datatracker.ietf.org/doc/html/rfc6838#section-4.2
2284
+ const mimeType = header.match(/^data:(image\/[A-Za-z0-9][A-Za-z0-9!#$&\-^_.+]*)/)?.[1];
2285
+ if (mimeType) {
2286
+ const bytes = Uint8Array.from(atob(data), (c) => c.charCodeAt(0));
2287
+ const extension = mimeTypeToExtension(mimeType) ?? "png";
2288
+ return new File([ bytes ], `pasted-image-${Date.now()}.${extension}`, { type: mimeType })
2289
+ } else {
2290
+ return null
2291
+ }
2292
+ } catch {
2293
+ return null
2294
+ }
2295
+ }
2296
+
1822
2297
  class HorizontalDividerNode extends DecoratorNode {
1823
2298
  static getType() {
1824
2299
  return "horizontal_divider"
@@ -2813,17 +3288,12 @@ class CommandDispatcher {
2813
3288
  this.editor = editorElement.editor;
2814
3289
  this.selection = editorElement.selection;
2815
3290
  this.contents = editorElement.contents;
2816
- this.clipboard = editorElement.clipboard;
2817
3291
 
2818
3292
  this.#registerCommands();
2819
3293
  this.#registerKeyboardCommands();
2820
3294
  this.#registerDragAndDropHandlers();
2821
3295
  }
2822
3296
 
2823
- dispatchPaste(event) {
2824
- return this.clipboard.paste(event)
2825
- }
2826
-
2827
3297
  dispatchBold() {
2828
3298
  this.editor.dispatchCommand(FORMAT_TEXT_COMMAND, "bold");
2829
3299
  }
@@ -3050,8 +3520,6 @@ class CommandDispatcher {
3050
3520
  const methodName = `dispatch${capitalize(command)}`;
3051
3521
  this.#registerCommandHandler(command, 0, this[methodName].bind(this));
3052
3522
  }
3053
-
3054
- this.#registerCommandHandler(PASTE_COMMAND, COMMAND_PRIORITY_LOW, this.dispatchPaste.bind(this));
3055
3523
  }
3056
3524
 
3057
3525
  #registerCommandHandler(command, priority, handler) {
@@ -3184,42 +3652,6 @@ function capitalize(str) {
3184
3652
  return str.charAt(0).toUpperCase() + str.slice(1)
3185
3653
  }
3186
3654
 
3187
- function debounce(fn, wait) {
3188
- let timeout;
3189
-
3190
- return (...args) => {
3191
- clearTimeout(timeout);
3192
- timeout = setTimeout(() => fn(...args), wait);
3193
- }
3194
- }
3195
-
3196
- function debounceAsync(fn, wait) {
3197
- let timeout;
3198
-
3199
- return (...args) => {
3200
- clearTimeout(timeout);
3201
-
3202
- return new Promise((resolve, reject) => {
3203
- timeout = setTimeout(async () => {
3204
- try {
3205
- const result = await fn(...args);
3206
- resolve(result);
3207
- } catch (err) {
3208
- reject(err);
3209
- }
3210
- }, wait);
3211
- })
3212
- }
3213
- }
3214
-
3215
- function delay(ms) {
3216
- return new Promise((resolve) => setTimeout(resolve, ms))
3217
- }
3218
-
3219
- function nextFrame() {
3220
- return new Promise(requestAnimationFrame)
3221
- }
3222
-
3223
3655
  class Selection {
3224
3656
  #listeners = new ListenerBin()
3225
3657
 
@@ -3412,6 +3844,11 @@ class Selection {
3412
3844
  return $isActionTextAttachmentNode(firstNode) && firstNode.isPreviewableImage
3413
3845
  }
3414
3846
 
3847
+ get isAtNodeStart() {
3848
+ const { anchorNode, offset } = this.#getCollapsedSelectionData();
3849
+ return anchorNode && offset === 0
3850
+ }
3851
+
3415
3852
  get nodeAfterCursor() {
3416
3853
  const { anchorNode, offset } = this.#getCollapsedSelectionData();
3417
3854
  if (!anchorNode) return null
@@ -4127,577 +4564,574 @@ class EditorConfiguration {
4127
4564
  }
4128
4565
  }
4129
4566
 
4130
- async function loadFileIntoImage(file, image) {
4131
- return new Promise((resolve) => {
4132
- const reader = new FileReader();
4133
-
4134
- image.addEventListener("load", () => {
4135
- resolve(image);
4136
- });
4567
+ class ImageGalleryNode extends ElementNode {
4568
+ $config() {
4569
+ return this.config("image_gallery", {
4570
+ extends: ElementNode,
4571
+ })
4572
+ }
4137
4573
 
4138
- reader.onload = (event) => {
4139
- image.src = event.target.result || null;
4140
- };
4574
+ static transform() {
4575
+ return (gallery) => {
4576
+ gallery.unwrapEmptyNode()
4577
+ || gallery.replaceWithSingularChild()
4578
+ || gallery.splitAroundInvalidChild();
4579
+ }
4580
+ }
4141
4581
 
4142
- reader.readAsDataURL(file);
4143
- })
4144
- }
4582
+ static importDOM() {
4583
+ return {
4584
+ div: (element) => {
4585
+ if (!this.#isGalleryElement(element)) return null
4145
4586
 
4146
- class ActionTextAttachmentUploadNode extends ActionTextAttachmentNode {
4147
- static getType() {
4148
- return "action_text_attachment_upload"
4587
+ return {
4588
+ conversion: () => {
4589
+ return {
4590
+ node: $createImageGalleryNode()
4591
+ }
4592
+ },
4593
+ priority: 2
4594
+ }
4595
+ }
4596
+ }
4149
4597
  }
4150
4598
 
4151
- static clone(node) {
4152
- return new ActionTextAttachmentUploadNode({ ...node }, node.__key)
4599
+ static canCollapseWith(node) {
4600
+ return $isImageGalleryNode(node) || this.isValidChild(node)
4153
4601
  }
4154
4602
 
4155
- static importJSON(serializedNode) {
4156
- return new ActionTextAttachmentUploadNode({ ...serializedNode })
4603
+ static isValidChild(node) {
4604
+ return $isActionTextAttachmentNode(node) && node.isPreviewableImage
4157
4605
  }
4158
4606
 
4159
- // Should never run since this is a transient node. Defined to remove console warning.
4160
- static importDOM() {
4161
- return null
4607
+ static #isGalleryElement(element) {
4608
+ const attachmentChildren = element.querySelectorAll(`:scope > :is(${this.#attachmentTags.join()})`);
4609
+ return element.textContent.trim() === ""
4610
+ && attachmentChildren.length > 0
4611
+ && element.children.length === attachmentChildren.length
4162
4612
  }
4163
4613
 
4164
- constructor(node, key) {
4165
- const { file, uploadUrl, blobUrlTemplate, progress, width, height, uploadError, fileName, contentType } = node;
4166
- super({ ...node, contentType: file?.type ?? contentType }, key);
4167
- this.file = file ?? null;
4168
- this.fileName = file?.name ?? fileName;
4169
- this.uploadUrl = uploadUrl;
4170
- this.blobUrlTemplate = blobUrlTemplate;
4171
- this.progress = progress ?? null;
4172
- this.width = width;
4173
- this.height = height;
4174
- this.uploadError = uploadError;
4614
+ static get #attachmentTags() {
4615
+ return Object.keys(ActionTextAttachmentNode.importDOM())
4175
4616
  }
4176
4617
 
4177
4618
  createDOM() {
4178
- if (this.uploadError) return this.createDOMForError()
4179
-
4180
- // This side-effect is trigged on DOM load to fire only once and avoid multiple
4181
- // uploads through cloning. The upload is guarded from restarting in case the
4182
- // node is reloaded from saved state such as from history.
4183
- this.#startUploadIfNeeded();
4184
-
4185
- // Bridge-managed uploads (uploadUrl is null) don't have file data to show
4186
- // an image preview, so always show the file icon during upload.
4187
- const canPreviewFile = this.isPreviewableAttachment && this.uploadUrl != null;
4188
- const figure = this.createAttachmentFigure(canPreviewFile);
4189
-
4190
- if (canPreviewFile) {
4191
- const img = figure.appendChild(this.#createDOMForImage());
4619
+ const div = document.createElement("div");
4620
+ div.className = this.#galleryClassNames;
4621
+ return div
4622
+ }
4192
4623
 
4193
- // load file locally to set dimensions and prevent vertical shifting
4194
- loadFileIntoImage(this.file, img).then(img => this.#setDimensionsFromImage(img));
4195
- } else {
4196
- figure.appendChild(this.#createDOMForFile());
4197
- }
4624
+ updateDOM(_prevNode, dom) {
4625
+ dom.className = this.#galleryClassNames;
4626
+ return false
4627
+ }
4198
4628
 
4199
- figure.appendChild(this.#createCaption());
4200
- figure.appendChild(this.#createProgressBar());
4629
+ canBeEmpty() {
4630
+ // Return `true` to conform to `$isBlock(node)`
4631
+ // We clean-up empty galleries with a transform
4632
+ return true
4633
+ }
4201
4634
 
4202
- return figure
4635
+ collapseAtStart(_selection) {
4636
+ return true
4203
4637
  }
4204
4638
 
4205
- updateDOM(prevNode, dom) {
4206
- if (this.uploadError !== prevNode.uploadError) return true
4639
+ insertNewAfter(selection, restoreSelection) {
4640
+ const selectionBeforeLastChild = selection.anchor.getNode().is(this) && selection.anchor.offset == this.getChildrenSize() - 1;
4641
+ if (selectionBeforeLastChild) {
4642
+ const paragraph = $createParagraphNode();
4643
+ this.insertAfter(paragraph, false);
4644
+ paragraph.insertAfter(this.getLastChild(), false);
4645
+ paragraph.selectEnd();
4207
4646
 
4208
- if (prevNode.progress !== this.progress) {
4209
- const progress = dom.querySelector("progress");
4210
- progress.value = this.progress ?? 0;
4647
+ // return null as selection has been managed
4648
+ return null
4211
4649
  }
4212
4650
 
4213
- return false
4651
+ const newNode = $createImageGalleryNode();
4652
+ this.insertAfter(newNode, restoreSelection);
4653
+ return newNode
4654
+ }
4655
+
4656
+ getImageAttachments() {
4657
+ const children = this.getChildren();
4658
+ return children.filter($isActionTextAttachmentNode)
4214
4659
  }
4215
4660
 
4216
4661
  exportDOM() {
4217
- return { element: null }
4662
+ const div = document.createElement("div");
4663
+ div.className = this.#galleryClassNames;
4664
+ return { element: div }
4218
4665
  }
4219
4666
 
4220
- exportJSON() {
4221
- return {
4222
- ...super.exportJSON(),
4223
- type: "action_text_attachment_upload",
4224
- version: 1,
4225
- fileName: this.fileName,
4226
- contentType: this.contentType,
4227
- uploadUrl: this.uploadUrl,
4228
- blobUrlTemplate: this.blobUrlTemplate,
4229
- progress: this.progress,
4230
- width: this.width,
4231
- height: this.height,
4232
- uploadError: this.uploadError
4667
+ collapseWith(node, backwards) {
4668
+ if (!ImageGalleryNode.canCollapseWith(node)) return false
4669
+
4670
+ if (backwards) {
4671
+ $insertFirst(this, node);
4672
+ } else {
4673
+ this.append(node);
4233
4674
  }
4234
- }
4235
4675
 
4236
- get #uploadStarted() {
4237
- return this.progress !== null
4238
- }
4676
+ $unwrapAndFilterDescendants(this, ImageGalleryNode.isValidChild);
4239
4677
 
4240
- #createDOMForImage() {
4241
- return createElement("img")
4678
+ return true
4242
4679
  }
4243
4680
 
4244
- #createDOMForFile() {
4245
- const extension = this.#getFileExtension();
4246
- const span = createElement("span", { className: "attachment__icon", textContent: extension });
4247
- return span
4681
+ unwrapEmptyNode() {
4682
+ if (this.isEmpty()) {
4683
+ const paragraph = $createParagraphNode();
4684
+ return this.replace(paragraph)
4685
+ }
4248
4686
  }
4249
4687
 
4250
- #getFileExtension() {
4251
- return (this.fileName || "").split(".").pop().toLowerCase()
4688
+ replaceWithSingularChild() {
4689
+ if (this.#hasSingularChild) {
4690
+ const child = this.getFirstChild();
4691
+ return this.replace(child)
4692
+ }
4252
4693
  }
4253
4694
 
4254
- #createCaption() {
4255
- const figcaption = createElement("figcaption", { className: "attachment__caption" });
4695
+ splitAroundInvalidChild() {
4696
+ for (const child of $firstToLastIterator(this)) {
4697
+ if (ImageGalleryNode.isValidChild(child)) continue
4256
4698
 
4257
- const nameSpan = createElement("span", { className: "attachment__name", textContent: this.caption || this.fileName || "" });
4258
- const sizeSpan = createElement("span", { className: "attachment__size", textContent: bytesToHumanSize(this.file?.size) });
4259
- figcaption.appendChild(nameSpan);
4260
- figcaption.appendChild(sizeSpan);
4699
+ const poppedNode = $makeSafeForRoot(child);
4700
+ const [ topGallery, secondGallery ] = this.splitAtIndex(poppedNode.getIndexWithinParent());
4701
+ topGallery.insertAfter(poppedNode);
4702
+ poppedNode.selectEnd();
4261
4703
 
4262
- return figcaption
4263
- }
4704
+ // remove an empty gallery rather than let it unwrap to a paragraph
4705
+ if (secondGallery.isEmpty()) secondGallery.remove();
4264
4706
 
4265
- #createProgressBar() {
4266
- return createElement("progress", { value: this.progress ?? 0, max: 100 })
4707
+ break
4708
+ }
4267
4709
  }
4268
4710
 
4269
- #setDimensionsFromImage({ width, height }) {
4270
- if (this.#hasDimensions) return
4711
+ splitAtIndex(index) {
4712
+ return $splitNode(this, index)
4713
+ }
4271
4714
 
4272
- this.patchAndRewriteHistory({ width, height });
4715
+ get #hasSingularChild() {
4716
+ return this.getChildrenSize() === 1
4273
4717
  }
4274
4718
 
4275
- get #hasDimensions() {
4276
- return Boolean(this.width && this.height)
4719
+ get #galleryClassNames() {
4720
+ return `attachment-gallery attachment-gallery--${this.getChildrenSize()}`
4277
4721
  }
4722
+ }
4278
4723
 
4279
- async #startUploadIfNeeded() {
4280
- if (this.#uploadStarted) return
4281
- if (!this.uploadUrl) return // Bridge-managed upload — skip DirectUpload
4724
+ function $createImageGalleryNode() {
4725
+ return new ImageGalleryNode()
4726
+ }
4282
4727
 
4283
- this.#setUploadStarted();
4728
+ function $isImageGalleryNode(node) {
4729
+ return node instanceof ImageGalleryNode
4730
+ }
4284
4731
 
4285
- const { DirectUpload } = await import('@rails/activestorage');
4732
+ function $findOrCreateGalleryForImage(node) {
4733
+ if (!ImageGalleryNode.canCollapseWith(node)) return null
4286
4734
 
4287
- const upload = new DirectUpload(this.file, this.uploadUrl, this);
4288
- upload.delegate = this.#createUploadDelegate();
4735
+ const existingGallery = $getNearestNodeOfType(node, ImageGalleryNode);
4736
+ return existingGallery ?? $wrapNodeInElement(node, $createImageGalleryNode)
4737
+ }
4289
4738
 
4290
- this.#dispatchEvent("lexxy:upload-start", { file: this.file });
4739
+ class Uploader {
4740
+ #files
4291
4741
 
4292
- upload.create((error, blob) => {
4293
- if (error) {
4294
- this.#dispatchEvent("lexxy:upload-end", { file: this.file, error });
4295
- this.#handleUploadError(error);
4296
- } else {
4297
- this.#dispatchEvent("lexxy:upload-end", { file: this.file, error: null });
4298
- this.editor.update(() => {
4299
- this.$showUploadedAttachment(blob);
4300
- });
4301
- }
4302
- });
4742
+ static for(editorElement, files) {
4743
+ const UploaderKlass = GalleryUploader.handle(editorElement, files) ? GalleryUploader : Uploader;
4744
+ return new UploaderKlass(editorElement, files)
4303
4745
  }
4304
4746
 
4305
- #createUploadDelegate() {
4306
- const shouldAuthenticateUploads = Lexxy.global.get("authenticatedUploads");
4747
+ constructor(editorElement, files, options = {}) {
4748
+ this.#files = files;
4749
+ this.options = options;
4307
4750
 
4308
- return {
4309
- directUploadWillCreateBlobWithXHR: (request) => {
4310
- if (shouldAuthenticateUploads) request.withCredentials = true;
4311
- },
4312
- directUploadWillStoreFileWithXHR: (request) => {
4313
- if (shouldAuthenticateUploads) request.withCredentials = true;
4751
+ this.editorElement = editorElement;
4752
+ this.contents = editorElement.contents;
4753
+ this.selection = editorElement.selection;
4754
+ }
4314
4755
 
4315
- const uploadProgressHandler = (event) => this.#handleUploadProgress(event, request);
4316
- request.upload.addEventListener("progress", uploadProgressHandler);
4317
- }
4318
- }
4756
+ get files() {
4757
+ return Array.from(this.#files)
4319
4758
  }
4320
4759
 
4321
- #setUploadStarted() {
4322
- this.#setProgress(1);
4760
+ $uploadFiles() {
4761
+ this.$createUploadNodes();
4762
+ this.$insertUploadNodes();
4323
4763
  }
4324
4764
 
4325
- #handleUploadProgress(event, request) {
4326
- const progress = Math.round(event.loaded / event.total * 100);
4327
- try {
4328
- this.#setProgress(progress);
4329
- this.#dispatchEvent("lexxy:upload-progress", { file: this.file, progress });
4330
- } catch {
4331
- request.abort();
4765
+ $createUploadNodes() {
4766
+ this.nodes = this.files.map(file => this.contents.$createUploadNode(file));
4767
+ }
4768
+
4769
+ $insertUploadNodes() {
4770
+ this.contents.insertAtCursor(...this.nodes);
4771
+ }
4772
+ }
4773
+
4774
+ class GalleryUploader extends Uploader {
4775
+ #gallery
4776
+
4777
+ static handle(editorElement, files) {
4778
+ return this.isMultipleImageUpload(files) || this.gallerySelection(editorElement.selection)
4779
+ }
4780
+
4781
+ static isMultipleImageUpload(files) {
4782
+ let imageFileCount = 0;
4783
+ for (const file of files) {
4784
+ if (isPreviewableImage(file.type)) imageFileCount++;
4785
+ if (imageFileCount > 1) return true
4332
4786
  }
4787
+ return false
4333
4788
  }
4334
4789
 
4335
- #setProgress(progress) {
4336
- this.patchAndRewriteHistory({ progress });
4790
+ static gallerySelection(selection) {
4791
+ return selection.isOnPreviewableImage || this.selectionIsAfterGalleryEdge(selection)
4337
4792
  }
4338
4793
 
4339
- #handleUploadError(error) {
4340
- console.warn(`Upload error for ${this.file?.name ?? "file"}: ${error}`);
4341
-
4342
- this.patchAndRewriteHistory({ uploadError: true });
4794
+ static selectionIsAfterGalleryEdge(selection) {
4795
+ return selection.isAtNodeStart && ImageGalleryNode.canCollapseWith(selection.nodeBeforeCursor)
4343
4796
  }
4344
4797
 
4345
- $showUploadedAttachment(blob) {
4346
- const previewSrc = this.isPreviewableImage && this.file ? URL.createObjectURL(this.file) : null;
4347
-
4348
- const replacementNode = this.#toActionTextAttachmentNodeWith(blob, previewSrc);
4349
- this.replaceAndRewriteHistory(replacementNode);
4798
+ $insertUploadNodes() {
4799
+ this.#findOrCreateGallery();
4800
+ this.#insertImagesInGallery();
4801
+ this.#insertNonImagesAfterGallery();
4802
+ }
4350
4803
 
4351
- return replacementNode.getKey()
4804
+ #findOrCreateGallery() {
4805
+ if (this.selection.isOnPreviewableImage) {
4806
+ this.#gallery = $findOrCreateGalleryForImage(this.#selectedNode);
4807
+ } else if (this.#selectionIsAfterGalleryEdge) {
4808
+ this.#gallery = $findOrCreateGalleryForImage(this.selection.nodeBeforeCursor);
4809
+ } else {
4810
+ this.#gallery = $createImageGalleryNode();
4811
+ this.contents.insertAtCursor(this.#gallery);
4812
+ }
4352
4813
  }
4353
4814
 
4354
- #toActionTextAttachmentNodeWith(blob, previewSrc) {
4355
- const conversion = new AttachmentNodeConversion(this, blob, previewSrc);
4356
- return conversion.toAttachmentNode()
4815
+ get #selectionIsAfterGalleryEdge() {
4816
+ return this.constructor.selectionIsAfterGalleryEdge(this.selection)
4357
4817
  }
4358
4818
 
4359
- #dispatchEvent(name, detail) {
4360
- const figure = this.editor.getElementByKey(this.getKey());
4361
- if (figure) dispatch(figure, name, detail);
4819
+ get #selectedNode() {
4820
+ const { node } = this.selection.selectedNodeWithOffset();
4821
+ return node
4362
4822
  }
4363
- }
4364
4823
 
4365
- class AttachmentNodeConversion {
4366
- constructor(uploadNode, blob, previewSrc) {
4367
- this.uploadNode = uploadNode;
4368
- this.blob = blob;
4369
- this.previewSrc = previewSrc;
4824
+ get #galleryInsertPosition() {
4825
+ if (this.#selectionIsAfterGalleryEdge) return this.#gallery.getChildrenSize()
4826
+
4827
+ const anchor = $getSelection()?.anchor;
4828
+ const galleryHasElementSelection = anchor?.getNode().is(this.#gallery);
4829
+ if (galleryHasElementSelection) return anchor.offset
4830
+
4831
+ const selectedNode = this.#selectedNode;
4832
+ const childIndex = this.#gallery.isParentOf(selectedNode) && selectedNode.getIndexWithinParent();
4833
+ return childIndex !== false ? (childIndex + 1) : 0
4370
4834
  }
4371
4835
 
4372
- toAttachmentNode() {
4373
- return new ActionTextAttachmentNode({
4374
- ...this.uploadNode,
4375
- ...this.#propertiesFromBlob,
4376
- src: this.#src,
4377
- previewSrc: this.previewSrc,
4378
- pendingPreview: this.blob.previewable && !this.uploadNode.isPreviewableImage
4379
- })
4836
+ get #imageNodes() {
4837
+ return this.nodes.filter(node => ImageGalleryNode.isValidChild(node))
4380
4838
  }
4381
4839
 
4382
- get #propertiesFromBlob() {
4383
- const { blob } = this;
4384
- return {
4385
- sgid: blob.attachable_sgid,
4386
- altText: blob.filename,
4387
- contentType: blob.content_type,
4388
- fileName: blob.filename,
4389
- fileSize: blob.byte_size,
4390
- previewable: blob.previewable,
4391
- }
4840
+ get #nonImageNodes() {
4841
+ return this.nodes.filter(node => !ImageGalleryNode.isValidChild(node))
4392
4842
  }
4393
4843
 
4394
- get #src() {
4395
- return this.blob.previewable ? this.blob.url : this.#blobSrc
4844
+ #insertImagesInGallery() {
4845
+ this.#gallery.splice(this.#galleryInsertPosition, 0, this.#imageNodes);
4396
4846
  }
4397
4847
 
4398
- get #blobSrc() {
4399
- return this.uploadNode.blobUrlTemplate
4400
- .replace(":signed_id", this.blob.signed_id)
4401
- .replace(":filename", encodeURIComponent(this.blob.filename))
4848
+ #insertNonImagesAfterGallery() {
4849
+ let beforeNode = this.#gallery;
4850
+
4851
+ for (const node of this.#nonImageNodes) {
4852
+ beforeNode.insertAfter(node);
4853
+ beforeNode = node;
4854
+ }
4402
4855
  }
4403
4856
  }
4404
4857
 
4405
- function $createActionTextAttachmentUploadNode(...args) {
4406
- return new ActionTextAttachmentUploadNode(...args)
4407
- }
4858
+ async function loadFileIntoImage(file, image) {
4859
+ return new Promise((resolve) => {
4860
+ const reader = new FileReader();
4408
4861
 
4409
- class ImageGalleryNode extends ElementNode {
4410
- $config() {
4411
- return this.config("image_gallery", {
4412
- extends: ElementNode,
4413
- })
4414
- }
4862
+ image.addEventListener("load", () => {
4863
+ resolve(image);
4864
+ });
4415
4865
 
4416
- static transform() {
4417
- return (gallery) => {
4418
- gallery.unwrapEmptyNode()
4419
- || gallery.replaceWithSingularChild()
4420
- || gallery.splitAroundInvalidChild();
4421
- }
4422
- }
4866
+ reader.onload = (event) => {
4867
+ image.src = event.target.result || null;
4868
+ };
4423
4869
 
4424
- static importDOM() {
4425
- return {
4426
- div: (element) => {
4427
- if (!this.#isGalleryElement(element)) return null
4870
+ reader.readAsDataURL(file);
4871
+ })
4872
+ }
4428
4873
 
4429
- return {
4430
- conversion: () => {
4431
- return {
4432
- node: $createImageGalleryNode()
4433
- }
4434
- },
4435
- priority: 2
4436
- }
4437
- }
4438
- }
4874
+ class ActionTextAttachmentUploadNode extends ActionTextAttachmentNode {
4875
+ static getType() {
4876
+ return "action_text_attachment_upload"
4439
4877
  }
4440
4878
 
4441
- static canCollapseWith(node) {
4442
- return $isImageGalleryNode(node) || this.isValidChild(node)
4879
+ static clone(node) {
4880
+ return new ActionTextAttachmentUploadNode({ ...node }, node.__key)
4443
4881
  }
4444
4882
 
4445
- static isValidChild(node) {
4446
- return $isActionTextAttachmentNode(node) && node.isPreviewableImage
4883
+ static importJSON(serializedNode) {
4884
+ return new ActionTextAttachmentUploadNode({ ...serializedNode })
4447
4885
  }
4448
4886
 
4449
- static #isGalleryElement(element) {
4450
- const attachmentChildren = element.querySelectorAll(`:scope > :is(${this.#attachmentTags.join()})`);
4451
- return element.textContent.trim() === ""
4452
- && attachmentChildren.length > 0
4453
- && element.children.length === attachmentChildren.length
4887
+ // Should never run since this is a transient node. Defined to remove console warning.
4888
+ static importDOM() {
4889
+ return null
4454
4890
  }
4455
4891
 
4456
- static get #attachmentTags() {
4457
- return Object.keys(ActionTextAttachmentNode.importDOM())
4892
+ constructor(node, key) {
4893
+ const { file, uploadUrl, blobUrlTemplate, progress, width, height, uploadError, fileName, contentType } = node;
4894
+ super({ ...node, contentType: file?.type ?? contentType }, key);
4895
+ this.file = file ?? null;
4896
+ this.fileName = file?.name ?? fileName;
4897
+ this.uploadUrl = uploadUrl;
4898
+ this.blobUrlTemplate = blobUrlTemplate;
4899
+ this.progress = progress ?? null;
4900
+ this.width = width;
4901
+ this.height = height;
4902
+ this.uploadError = uploadError;
4458
4903
  }
4459
4904
 
4460
4905
  createDOM() {
4461
- const div = document.createElement("div");
4462
- div.className = this.#galleryClassNames;
4463
- return div
4464
- }
4465
-
4466
- updateDOM(_prevNode, dom) {
4467
- dom.className = this.#galleryClassNames;
4468
- return false
4469
- }
4906
+ if (this.uploadError) return this.createDOMForError()
4470
4907
 
4471
- canBeEmpty() {
4472
- // Return `true` to conform to `$isBlock(node)`
4473
- // We clean-up empty galleries with a transform
4474
- return true
4475
- }
4908
+ // This side-effect is trigged on DOM load to fire only once and avoid multiple
4909
+ // uploads through cloning. The upload is guarded from restarting in case the
4910
+ // node is reloaded from saved state such as from history.
4911
+ this.#startUploadIfNeeded();
4476
4912
 
4477
- collapseAtStart(_selection) {
4478
- return true
4479
- }
4913
+ // Bridge-managed uploads (uploadUrl is null) don't have file data to show
4914
+ // an image preview, so always show the file icon during upload.
4915
+ const canPreviewFile = this.isPreviewableAttachment && this.uploadUrl != null;
4916
+ const figure = this.createAttachmentFigure(canPreviewFile);
4480
4917
 
4481
- insertNewAfter(selection, restoreSelection) {
4482
- const selectionBeforeLastChild = selection.anchor.getNode().is(this) && selection.anchor.offset == this.getChildrenSize() - 1;
4483
- if (selectionBeforeLastChild) {
4484
- const paragraph = $createParagraphNode();
4485
- this.insertAfter(paragraph, false);
4486
- paragraph.insertAfter(this.getLastChild(), false);
4487
- paragraph.selectEnd();
4918
+ if (canPreviewFile) {
4919
+ const img = figure.appendChild(this.#createDOMForImage());
4488
4920
 
4489
- // return null as selection has been managed
4490
- return null
4921
+ // load file locally to set dimensions and prevent vertical shifting
4922
+ loadFileIntoImage(this.file, img).then(img => this.#setDimensionsFromImage(img));
4923
+ } else {
4924
+ figure.appendChild(this.#createDOMForFile());
4491
4925
  }
4492
4926
 
4493
- const newNode = $createImageGalleryNode();
4494
- this.insertAfter(newNode, restoreSelection);
4495
- return newNode
4496
- }
4497
-
4498
- getImageAttachments() {
4499
- const children = this.getChildren();
4500
- return children.filter($isActionTextAttachmentNode)
4501
- }
4927
+ figure.appendChild(this.#createCaption());
4928
+ figure.appendChild(this.#createProgressBar());
4502
4929
 
4503
- exportDOM() {
4504
- const div = document.createElement("div");
4505
- div.className = this.#galleryClassNames;
4506
- return { element: div }
4930
+ return figure
4507
4931
  }
4508
4932
 
4509
- collapseWith(node, backwards) {
4510
- if (!ImageGalleryNode.canCollapseWith(node)) return false
4933
+ updateDOM(prevNode, dom) {
4934
+ if (this.uploadError !== prevNode.uploadError) return true
4511
4935
 
4512
- if (backwards) {
4513
- $insertFirst(this, node);
4514
- } else {
4515
- this.append(node);
4936
+ if (prevNode.progress !== this.progress) {
4937
+ const progress = dom.querySelector("progress");
4938
+ progress.value = this.progress ?? 0;
4516
4939
  }
4517
4940
 
4518
- $unwrapAndFilterDescendants(this, ImageGalleryNode.isValidChild);
4941
+ return false
4942
+ }
4519
4943
 
4520
- return true
4944
+ exportDOM() {
4945
+ return { element: null }
4521
4946
  }
4522
4947
 
4523
- unwrapEmptyNode() {
4524
- if (this.isEmpty()) {
4525
- const paragraph = $createParagraphNode();
4526
- return this.replace(paragraph)
4948
+ exportJSON() {
4949
+ return {
4950
+ ...super.exportJSON(),
4951
+ type: "action_text_attachment_upload",
4952
+ version: 1,
4953
+ fileName: this.fileName,
4954
+ contentType: this.contentType,
4955
+ uploadUrl: this.uploadUrl,
4956
+ blobUrlTemplate: this.blobUrlTemplate,
4957
+ progress: this.progress,
4958
+ width: this.width,
4959
+ height: this.height,
4960
+ uploadError: this.uploadError
4527
4961
  }
4528
4962
  }
4529
4963
 
4530
- replaceWithSingularChild() {
4531
- if (this.#hasSingularChild) {
4532
- const child = this.getFirstChild();
4533
- return this.replace(child)
4534
- }
4964
+ get #uploadStarted() {
4965
+ return this.progress !== null
4966
+ }
4967
+
4968
+ #createDOMForImage() {
4969
+ return createElement("img")
4535
4970
  }
4536
4971
 
4537
- splitAroundInvalidChild() {
4538
- for (const child of $firstToLastIterator(this)) {
4539
- if (ImageGalleryNode.isValidChild(child)) continue
4972
+ #createDOMForFile() {
4973
+ const extension = this.#getFileExtension();
4974
+ const span = createElement("span", { className: "attachment__icon", textContent: extension });
4975
+ return span
4976
+ }
4540
4977
 
4541
- const poppedNode = $makeSafeForRoot(child);
4542
- const [ topGallery, secondGallery ] = this.splitAtIndex(poppedNode.getIndexWithinParent());
4543
- topGallery.insertAfter(poppedNode);
4544
- poppedNode.selectEnd();
4978
+ #getFileExtension() {
4979
+ return (this.fileName || "").split(".").pop().toLowerCase()
4980
+ }
4545
4981
 
4546
- // remove an empty gallery rather than let it unwrap to a paragraph
4547
- if (secondGallery.isEmpty()) secondGallery.remove();
4982
+ #createCaption() {
4983
+ const figcaption = createElement("figcaption", { className: "attachment__caption" });
4548
4984
 
4549
- break
4550
- }
4985
+ const nameSpan = createElement("span", { className: "attachment__name", textContent: this.caption || this.fileName || "" });
4986
+ const sizeSpan = createElement("span", { className: "attachment__size", textContent: bytesToHumanSize(this.file?.size) });
4987
+ figcaption.appendChild(nameSpan);
4988
+ figcaption.appendChild(sizeSpan);
4989
+
4990
+ return figcaption
4551
4991
  }
4552
4992
 
4553
- splitAtIndex(index) {
4554
- return $splitNode(this, index)
4993
+ #createProgressBar() {
4994
+ return createElement("progress", { value: this.progress ?? 0, max: 100 })
4555
4995
  }
4556
4996
 
4557
- get #hasSingularChild() {
4558
- return this.getChildrenSize() === 1
4997
+ #setDimensionsFromImage({ width, height }) {
4998
+ if (this.#hasDimensions) return
4999
+
5000
+ this.patchAndRewriteHistory({ width, height });
4559
5001
  }
4560
5002
 
4561
- get #galleryClassNames() {
4562
- return `attachment-gallery attachment-gallery--${this.getChildrenSize()}`
5003
+ get #hasDimensions() {
5004
+ return Boolean(this.width && this.height)
4563
5005
  }
4564
- }
4565
5006
 
4566
- function $createImageGalleryNode() {
4567
- return new ImageGalleryNode()
4568
- }
5007
+ async #startUploadIfNeeded() {
5008
+ if (this.#uploadStarted) return
5009
+ if (!this.uploadUrl) return // Bridge-managed upload — skip DirectUpload
4569
5010
 
4570
- function $isImageGalleryNode(node) {
4571
- return node instanceof ImageGalleryNode
4572
- }
5011
+ this.#setUploadStarted();
4573
5012
 
4574
- function $findOrCreateGalleryForImage(node) {
4575
- if (!ImageGalleryNode.canCollapseWith(node)) return null
5013
+ const { DirectUpload } = await import('@rails/activestorage');
4576
5014
 
4577
- const existingGallery = $getNearestNodeOfType(node, ImageGalleryNode);
4578
- return existingGallery ?? $wrapNodeInElement(node, $createImageGalleryNode)
4579
- }
5015
+ const upload = new DirectUpload(this.file, this.uploadUrl, this);
5016
+ upload.delegate = this.#createUploadDelegate();
4580
5017
 
4581
- class Uploader {
4582
- #files
5018
+ this.#dispatchEvent("lexxy:upload-start", { file: this.file });
4583
5019
 
4584
- static for(editorElement, files) {
4585
- const UploaderKlass = GalleryUploader.handle(editorElement, files) ? GalleryUploader : Uploader;
4586
- return new UploaderKlass(editorElement, files)
5020
+ upload.create((error, blob) => {
5021
+ if (error) {
5022
+ this.#dispatchEvent("lexxy:upload-end", { file: this.file, error });
5023
+ this.#handleUploadError(error);
5024
+ } else {
5025
+ this.#dispatchEvent("lexxy:upload-end", { file: this.file, error: null });
5026
+ this.editor.update(() => {
5027
+ this.$showUploadedAttachment(blob);
5028
+ });
5029
+ }
5030
+ });
4587
5031
  }
4588
5032
 
4589
- constructor(editorElement, files) {
4590
- this.#files = files;
4591
-
4592
- this.editorElement = editorElement;
4593
- this.contents = editorElement.contents;
4594
- this.selection = editorElement.selection;
4595
- }
5033
+ #createUploadDelegate() {
5034
+ const shouldAuthenticateUploads = Lexxy.global.get("authenticatedUploads");
4596
5035
 
4597
- get files() {
4598
- return Array.from(this.#files)
4599
- }
5036
+ return {
5037
+ directUploadWillCreateBlobWithXHR: (request) => {
5038
+ if (shouldAuthenticateUploads) request.withCredentials = true;
5039
+ },
5040
+ directUploadWillStoreFileWithXHR: (request) => {
5041
+ if (shouldAuthenticateUploads) request.withCredentials = true;
4600
5042
 
4601
- $uploadFiles() {
4602
- this.$createUploadNodes();
4603
- this.$insertUploadNodes();
5043
+ const uploadProgressHandler = (event) => this.#handleUploadProgress(event, request);
5044
+ request.upload.addEventListener("progress", uploadProgressHandler);
5045
+ }
5046
+ }
4604
5047
  }
4605
5048
 
4606
- $createUploadNodes() {
4607
- this.nodes = this.files.map(file =>
4608
- $createActionTextAttachmentUploadNode({
4609
- ...this.#nodeUrlProperties,
4610
- file: file,
4611
- contentType: file.type
4612
- })
4613
- );
5049
+ #setUploadStarted() {
5050
+ this.#setProgress(1);
4614
5051
  }
4615
5052
 
4616
- $insertUploadNodes() {
4617
- this.contents.insertAtCursor(...this.nodes);
5053
+ #handleUploadProgress(event, request) {
5054
+ const progress = Math.round(event.loaded / event.total * 100);
5055
+ try {
5056
+ this.#setProgress(progress);
5057
+ this.#dispatchEvent("lexxy:upload-progress", { file: this.file, progress });
5058
+ } catch {
5059
+ request.abort();
5060
+ }
4618
5061
  }
4619
5062
 
4620
- get #nodeUrlProperties() {
4621
- return {
4622
- uploadUrl: this.editorElement.directUploadUrl,
4623
- blobUrlTemplate: this.editorElement.blobUrlTemplate
4624
- }
5063
+ #setProgress(progress) {
5064
+ this.patchAndRewriteHistory({ progress });
4625
5065
  }
4626
- }
4627
5066
 
4628
- class GalleryUploader extends Uploader {
4629
- #gallery
5067
+ #handleUploadError(error) {
5068
+ console.warn(`Upload error for ${this.file?.name ?? "file"}: ${error}`);
4630
5069
 
4631
- static handle(editorElement, files) {
4632
- return this.#isMultipleImageUpload(files) || this.#gallerySelection(editorElement.selection)
5070
+ this.patchAndRewriteHistory({ uploadError: true });
4633
5071
  }
4634
5072
 
4635
- static #isMultipleImageUpload(files) {
4636
- let imageFileCount = 0;
4637
- for (const file of files) {
4638
- if (isPreviewableImage(file.type)) imageFileCount++;
4639
- if (imageFileCount > 1) return true
4640
- }
4641
- return false
4642
- }
5073
+ $showUploadedAttachment(blob) {
5074
+ const previewSrc = this.isPreviewableImage && this.file ? URL.createObjectURL(this.file) : null;
4643
5075
 
4644
- static #gallerySelection(selection) {
4645
- if (selection.isOnPreviewableImage) return true
5076
+ const replacementNode = this.#toActionTextAttachmentNodeWith(blob, previewSrc);
5077
+ this.replaceAndRewriteHistory(replacementNode);
4646
5078
 
4647
- const { node: selectedNode } = selection.selectedNodeWithOffset();
4648
- return $getNearestNodeOfType(selectedNode, ImageGalleryNode) !== null
5079
+ return replacementNode.getKey()
4649
5080
  }
4650
5081
 
4651
- $insertUploadNodes() {
4652
- this.#findOrCreateGallery();
4653
- this.#insertImagesInGallery();
4654
- this.#insertNonImagesAfterGallery();
5082
+ #toActionTextAttachmentNodeWith(blob, previewSrc) {
5083
+ const conversion = new AttachmentNodeConversion(this, blob, previewSrc);
5084
+ return conversion.toAttachmentNode()
4655
5085
  }
4656
5086
 
4657
- #findOrCreateGallery() {
4658
- if (this.selection.isOnPreviewableImage) {
4659
- this.#gallery = $findOrCreateGalleryForImage(this.#selectedNode);
4660
- } else {
4661
- this.#gallery = $createImageGalleryNode();
4662
- this.contents.insertAtCursor(this.#gallery);
4663
- }
5087
+ #dispatchEvent(name, detail) {
5088
+ const figure = this.editor.getElementByKey(this.getKey());
5089
+ if (figure) dispatch(figure, name, detail);
4664
5090
  }
5091
+ }
4665
5092
 
4666
- get #selectedNode() {
4667
- const { node } = this.selection.selectedNodeWithOffset();
4668
- return node
5093
+ class AttachmentNodeConversion {
5094
+ constructor(uploadNode, blob, previewSrc) {
5095
+ this.uploadNode = uploadNode;
5096
+ this.blob = blob;
5097
+ this.previewSrc = previewSrc;
4669
5098
  }
4670
5099
 
4671
- get #galleryInsertPosition() {
4672
- const anchor = $getSelection()?.anchor;
4673
- const galleryHasElementSelection = anchor?.getNode().is(this.#gallery);
4674
- if (galleryHasElementSelection) return anchor.offset
4675
-
4676
- const selectedNode = this.#selectedNode;
4677
- const childIndex = this.#gallery.isParentOf(selectedNode) && selectedNode.getIndexWithinParent();
4678
- return childIndex !== false ? (childIndex + 1) : 0
5100
+ toAttachmentNode() {
5101
+ return new ActionTextAttachmentNode({
5102
+ ...this.uploadNode,
5103
+ ...this.#propertiesFromBlob,
5104
+ src: this.#src,
5105
+ previewSrc: this.previewSrc,
5106
+ pendingPreview: this.blob.previewable && !this.uploadNode.isPreviewableImage
5107
+ })
4679
5108
  }
4680
5109
 
4681
- get #imageNodes() {
4682
- return this.nodes.filter(node => ImageGalleryNode.isValidChild(node))
5110
+ get #propertiesFromBlob() {
5111
+ const { blob } = this;
5112
+ return {
5113
+ sgid: blob.attachable_sgid,
5114
+ altText: blob.filename,
5115
+ contentType: blob.content_type,
5116
+ fileName: blob.filename,
5117
+ fileSize: blob.byte_size,
5118
+ previewable: blob.previewable,
5119
+ }
4683
5120
  }
4684
5121
 
4685
- get #nonImageNodes() {
4686
- return this.nodes.filter(node => !ImageGalleryNode.isValidChild(node))
5122
+ get #src() {
5123
+ return this.blob.previewable ? this.blob.url : this.#blobSrc
4687
5124
  }
4688
5125
 
4689
- #insertImagesInGallery() {
4690
- this.#gallery.splice(this.#galleryInsertPosition, 0, this.#imageNodes);
5126
+ get #blobSrc() {
5127
+ return this.uploadNode.blobUrlTemplate
5128
+ .replace(":signed_id", this.blob.signed_id)
5129
+ .replace(":filename", encodeURIComponent(this.blob.filename))
4691
5130
  }
5131
+ }
4692
5132
 
4693
- #insertNonImagesAfterGallery() {
4694
- let beforeNode = this.#gallery;
4695
-
4696
- for (const node of this.#nonImageNodes) {
4697
- beforeNode.insertAfter(node);
4698
- beforeNode = node;
4699
- }
4700
- }
5133
+ function $createActionTextAttachmentUploadNode(...args) {
5134
+ return new ActionTextAttachmentUploadNode(...args)
4701
5135
  }
4702
5136
 
4703
5137
  class NodeInserter {
@@ -4806,7 +5240,7 @@ class Contents {
4806
5240
  this.editor.update(() => {
4807
5241
  if ($hasUpdateTag(PASTE_TAG)) this.#stripTableCellColorStyles(doc);
4808
5242
 
4809
- const nodes = $generateFilteredNodesFromDOM(this.editorElement, doc);
5243
+ const nodes = this.editorElement.$generateNodesFromDOM(doc);
4810
5244
  if (!this.#insertUploadNodes(nodes)) {
4811
5245
  this.insertAtCursor(...nodes);
4812
5246
  }
@@ -5015,7 +5449,7 @@ class Contents {
5015
5449
  console.warn("This editor does not supports attachments (it's configured with [attachments=false])");
5016
5450
  return
5017
5451
  }
5018
- const validFiles = Array.from(files).filter(this.#shouldUploadFile.bind(this));
5452
+ const validFiles = Array.from(files).filter(file => this.editorElement.acceptsFile(file));
5019
5453
 
5020
5454
  this.editor.update(() => {
5021
5455
  const uploader = Uploader.for(this.editorElement, validFiles);
@@ -5029,6 +5463,15 @@ class Contents {
5029
5463
  });
5030
5464
  }
5031
5465
 
5466
+ $createUploadNode(file) {
5467
+ return $createActionTextAttachmentUploadNode({
5468
+ file,
5469
+ uploadUrl: this.editorElement.directUploadUrl,
5470
+ blobUrlTemplate: this.editorElement.blobUrlTemplate,
5471
+ contentType: file.type,
5472
+ })
5473
+ }
5474
+
5032
5475
  insertPendingAttachment(file) {
5033
5476
  if (!this.editorElement.supportsAttachments) return null
5034
5477
 
@@ -5367,14 +5810,10 @@ class Contents {
5367
5810
  }
5368
5811
 
5369
5812
  #createHtmlNodeWith(html) {
5370
- const htmlNodes = $generateFilteredNodesFromDOM(this.editorElement, parseHtml(html));
5813
+ const htmlNodes = this.editorElement.$generateNodesFromDOM(parseHtml(html));
5371
5814
  return htmlNodes[0] || $createParagraphNode()
5372
5815
  }
5373
5816
 
5374
- #shouldUploadFile(file) {
5375
- return dispatch(this.editorElement, "lexxy:file-accept", { file }, true)
5376
- }
5377
-
5378
5817
  // When the selection anchor is on a shadow root (e.g. a table cell), Lexical's
5379
5818
  // insertNodes can't find a block parent and fails silently. Normalize the
5380
5819
  // selection to point inside the shadow root's content instead.
@@ -5396,10 +5835,22 @@ class Contents {
5396
5835
  }
5397
5836
 
5398
5837
  class Clipboard {
5838
+ #listeners = new ListenerBin()
5839
+
5399
5840
  constructor(editorElement) {
5400
5841
  this.editorElement = editorElement;
5401
5842
  this.editor = editorElement.editor;
5402
5843
  this.contents = editorElement.contents;
5844
+
5845
+ this.#registerPasteCommands();
5846
+ }
5847
+
5848
+ dispose() {
5849
+ this.editorElement = null;
5850
+ this.editor = null;
5851
+ this.contents = null;
5852
+
5853
+ this.#listeners.dispose();
5403
5854
  }
5404
5855
 
5405
5856
  paste(event) {
@@ -5424,6 +5875,25 @@ class Clipboard {
5424
5875
  return handled
5425
5876
  }
5426
5877
 
5878
+ #registerPasteCommands() {
5879
+ this.#listeners.track(
5880
+ this.editor.registerCommand(PASTE_COMMAND, this.paste.bind(this), COMMAND_PRIORITY_NORMAL),
5881
+ this.editor.registerCommand(
5882
+ SELECTION_INSERT_CLIPBOARD_NODES_COMMAND,
5883
+ (payload) => this.#handleParsedClipboardNodes(payload),
5884
+ COMMAND_PRIORITY_NORMAL
5885
+ )
5886
+ );
5887
+ }
5888
+
5889
+ #handleParsedClipboardNodes({ nodes, selection }) {
5890
+ const url = $bareUrlFromSingleLink(nodes);
5891
+ if (!url) return false
5892
+
5893
+ this.#insertSingleLinkAt(selection, url);
5894
+ return true
5895
+ }
5896
+
5427
5897
  #isPlainTextOrURLPasted(clipboardData) {
5428
5898
  return this.#isOnlyPlainTextPasted(clipboardData) || this.#isOnlyURLPasted(clipboardData)
5429
5899
  }
@@ -5473,9 +5943,9 @@ class Clipboard {
5473
5943
  #pastePlainText(clipboardData) {
5474
5944
  const item = clipboardData.items[0];
5475
5945
  item.getAsString((text) => {
5476
- if (isUrl(text) && this.contents.hasSelectedText()) {
5946
+ if (isAutolinkableURL(text) && this.contents.hasSelectedText()) {
5477
5947
  this.contents.createLinkWithSelectedText(text);
5478
- } else if (isUrl(text)) {
5948
+ } else if (isAutolinkableURL(text)) {
5479
5949
  const nodeKey = this.contents.createLink(text);
5480
5950
  this.#dispatchLinkInsertEvent(nodeKey, { url: text });
5481
5951
  } else if (this.editorElement.supportsMarkdown) {
@@ -5486,6 +5956,24 @@ class Clipboard {
5486
5956
  });
5487
5957
  }
5488
5958
 
5959
+ #insertSingleLinkAt(selection, url) {
5960
+ if (!$isRangeSelection(selection)) return
5961
+
5962
+ if (!selection.isCollapsed()) {
5963
+ $toggleLink(null);
5964
+ $toggleLink(url);
5965
+ return
5966
+ }
5967
+
5968
+ const linkNode = $createLinkNode(url).append($createTextNode(url));
5969
+ selection.insertNodes([ linkNode ]);
5970
+
5971
+ // Defer the lexxy:insert-link event until after the active update commits;
5972
+ // listeners may run editor mutations of their own.
5973
+ const nodeKey = linkNode.getKey();
5974
+ Promise.resolve().then(() => this.#dispatchLinkInsertEvent(nodeKey, { url }));
5975
+ }
5976
+
5489
5977
  #dispatchLinkInsertEvent(nodeKey, payload) {
5490
5978
  const linkManipulationMethods = {
5491
5979
  replaceLinkWith: (html, options) => this.contents.replaceNodeWithHTML(nodeKey, html, options),
@@ -5577,6 +6065,28 @@ class Clipboard {
5577
6065
  }
5578
6066
  }
5579
6067
 
6068
+ function $bareUrlFromSingleLink(nodes) {
6069
+ if (nodes.length !== 1) return null
6070
+
6071
+ const node = nodes[0];
6072
+ if ($isLinkNode(node)) return $bareUrlFromLink(node)
6073
+
6074
+ if ($isParagraphNode(node)) {
6075
+ const children = node.getChildren();
6076
+ if (children.length === 1 && $isLinkNode(children[0])) {
6077
+ return $bareUrlFromLink(children[0])
6078
+ }
6079
+ }
6080
+
6081
+ return null
6082
+ }
6083
+
6084
+ function $bareUrlFromLink(linkNode) {
6085
+ const url = linkNode.getURL();
6086
+ if (!url) return null
6087
+ return linkNode.getTextContent() === url ? url : null
6088
+ }
6089
+
5580
6090
  class Extensions {
5581
6091
 
5582
6092
  constructor(lexxyElement) {
@@ -5595,6 +6105,13 @@ class Extensions {
5595
6105
 
5596
6106
  this.#clearPreviousExtensionToolbarButtons(toolbar);
5597
6107
  this.#addExtensionToolbarButtons(toolbar);
6108
+ toolbar.requestOverflowRefresh();
6109
+ }
6110
+
6111
+ dispose() {
6112
+ while (this.enabledExtensions.length) {
6113
+ this.enabledExtensions.pop().dispose();
6114
+ }
5598
6115
  }
5599
6116
 
5600
6117
  #clearPreviousExtensionToolbarButtons(toolbar) {
@@ -6002,9 +6519,15 @@ class TablesExtension extends LexxyExtension {
6002
6519
  setScrollableTablesActive(editor, true);
6003
6520
 
6004
6521
  return mergeRegister(
6005
- // Register Lexical table plugins
6006
6522
  registerTablePlugin(editor),
6007
- registerTableSelectionObserver(editor, true),
6523
+
6524
+ // Lexxy registers extensions before setRootElement(), but table
6525
+ // drag-selection needs a root before wiring its pointer handlers.
6526
+ editor.registerRootListener((rootElement) => {
6527
+ if (rootElement) {
6528
+ return registerTableSelectionObserver(editor, true)
6529
+ }
6530
+ }),
6008
6531
 
6009
6532
  // Bug fix: Prevent hardcoded background color (Lexical #8089)
6010
6533
  editor.registerNodeTransform(TableCellNode, (node) => {
@@ -6712,7 +7235,8 @@ class FormatEscapeExtension extends LexxyExtension {
6712
7235
  KEY_ARROW_DOWN_COMMAND,
6713
7236
  (event) => $handleArrowDownInCodeBlock(event),
6714
7237
  COMMAND_PRIORITY_NORMAL
6715
- )
7238
+ ),
7239
+ editor.registerNodeTransform(QuoteNode, $ensureQuoteHasParagraphChild)
6716
7240
  )
6717
7241
  }
6718
7242
  })
@@ -6765,6 +7289,13 @@ function $handleArrowDownInCodeBlock(event) {
6765
7289
  return false
6766
7290
  }
6767
7291
 
7292
+ function $ensureQuoteHasParagraphChild(quoteNode) {
7293
+ if (!quoteNode.isEmpty()) return
7294
+
7295
+ quoteNode.append($createParagraphNode());
7296
+ if ($containsRangeSelection(quoteNode)) quoteNode.getFirstChild().select();
7297
+ }
7298
+
6768
7299
  class LinkOpenerExtension extends LexxyExtension {
6769
7300
  get enabled() {
6770
7301
  return this.editorElement.supportsRichText
@@ -6828,6 +7359,38 @@ function $openLink(target) {
6828
7359
  }
6829
7360
  }
6830
7361
 
7362
+ class PreventLexicalTripleClickExtension extends LexxyExtension {
7363
+ get lexicalExtension() {
7364
+ return defineExtension({
7365
+ name: "lexxy/prevent-lexical-triple-click",
7366
+ register: (editor) => editor.registerRootListener((rootElement) => {
7367
+ if (rootElement) {
7368
+ return registerEventListener(
7369
+ rootElement,
7370
+ "click",
7371
+ this.#handleTripleClick.bind(this),
7372
+ { capture: true }
7373
+ )
7374
+ }
7375
+ })
7376
+ })
7377
+ }
7378
+
7379
+ // Stop propagation of the triple-click to prevent Lexical's handler from running.
7380
+ //
7381
+ // Lexical's onClick handler implements a triple-click handler that is trivial/anemic/naïve. The
7382
+ // intention of the change, made in facebook/lexical#4512, seems to be to deal with browsers'
7383
+ // "overselection" behavior, where a triple-click selection might end at offset 0 of the following
7384
+ // block, which can cause issues when transforming the selection. But the implementation breaks
7385
+ // many common real-world use cases and Lexxy does not demonstrate the behavior it's intended to
7386
+ // work around (in headers or tables).
7387
+ #handleTripleClick(event) {
7388
+ if (event.detail === 3) {
7389
+ event.stopPropagation();
7390
+ }
7391
+ }
7392
+ }
7393
+
6831
7394
  class LexicalEditorElement extends HTMLElement {
6832
7395
  static formAssociated = true
6833
7396
  static debug = false
@@ -6852,6 +7415,7 @@ class LexicalEditorElement extends HTMLElement {
6852
7415
  this.id ||= generateDomId("lexxy-editor");
6853
7416
  this.config = new EditorConfiguration(this);
6854
7417
  this.extensions = new Extensions(this);
7418
+ this.#disposables.push(this.extensions);
6855
7419
 
6856
7420
  this.editor = this.#createEditor();
6857
7421
  this.#disposables.push(this.editor);
@@ -6864,6 +7428,8 @@ class LexicalEditorElement extends HTMLElement {
6864
7428
  this.#disposables.push(this.selection);
6865
7429
 
6866
7430
  this.clipboard = new Clipboard(this);
7431
+ this.#disposables.push(this.clipboard);
7432
+
6867
7433
  this.adapter = new BrowserAdapter();
6868
7434
 
6869
7435
  const commandDispatcher = CommandDispatcher.configureFor(this);
@@ -6935,7 +7501,8 @@ class LexicalEditorElement extends HTMLElement {
6935
7501
  RewritableHistoryExtension,
6936
7502
  AttachmentsExtension,
6937
7503
  FormatEscapeExtension,
6938
- LinkOpenerExtension
7504
+ LinkOpenerExtension,
7505
+ PreventLexicalTripleClickExtension
6939
7506
  ]
6940
7507
  }
6941
7508
 
@@ -6966,6 +7533,16 @@ class LexicalEditorElement extends HTMLElement {
6966
7533
  }
6967
7534
  }
6968
7535
 
7536
+ acceptsFile(file) {
7537
+ return dispatch(this, "lexxy:file-accept", { file }, true)
7538
+ }
7539
+
7540
+ $generateNodesFromDOM(doc) {
7541
+ let nodes = $generateNodesFromDOM(this.editor, doc);
7542
+ if ($hasUpdateTag(PASTE_TAG)) nodes = $convertInlineImageDataURIs(nodes, this);
7543
+ return filterDisallowedAttachmentNodes(nodes, this)
7544
+ }
7545
+
6969
7546
  get isEmpty() {
6970
7547
  return [ "<p><br></p>", "<p></p>", "" ].includes(this.value.trim())
6971
7548
  }
@@ -7088,7 +7665,7 @@ class LexicalEditorElement extends HTMLElement {
7088
7665
 
7089
7666
  #parseHtmlIntoLexicalNodes(html) {
7090
7667
  if (!html) html = "<p></p>";
7091
- const nodes = $generateFilteredNodesFromDOM(this, parseHtml(`${html}`));
7668
+ const nodes = this.$generateNodesFromDOM(parseHtml(`${html}`));
7092
7669
 
7093
7670
  return nodes
7094
7671
  .filter(this.#isNotWhitespaceOnlyNode)
@@ -7597,253 +8174,6 @@ function $getReadableTextContent(node) {
7597
8174
  return node.getTextContent()
7598
8175
  }
7599
8176
 
7600
- class ToolbarDropdown extends HTMLElement {
7601
- #listeners = new ListenerBin()
7602
-
7603
- connectedCallback() {
7604
- this.container = this.closest("details");
7605
-
7606
- this.#listeners.track(
7607
- registerEventListener(this.container, "toggle", this.#handleToggle),
7608
- registerEventListener(this.container, "keydown", this.#handleKeyDown)
7609
- );
7610
-
7611
- this.#onToolbarEditor(this.initialize.bind(this));
7612
- }
7613
-
7614
- disconnectedCallback() {
7615
- this.#listeners.dispose();
7616
- }
7617
-
7618
- get toolbar() {
7619
- return this.closest("lexxy-toolbar")
7620
- }
7621
-
7622
- get editorElement() {
7623
- return this.toolbar.editorElement
7624
- }
7625
-
7626
- get editor() {
7627
- return this.toolbar.editor
7628
- }
7629
-
7630
- track(...listeners) {
7631
- this.#listeners.track(...listeners);
7632
- }
7633
-
7634
- initialize() {
7635
- // Any post-editor initialization
7636
- }
7637
-
7638
- close() {
7639
- this.editor.focus();
7640
- this.container.open = false;
7641
- }
7642
-
7643
- async #onToolbarEditor(callback) {
7644
- await this.toolbar.editorElement;
7645
- callback();
7646
- }
7647
-
7648
- #handleToggle = () => {
7649
- if (this.container.open) {
7650
- this.#handleOpen();
7651
- }
7652
- }
7653
-
7654
- async #handleOpen() {
7655
- this.#interactiveElements[0].focus();
7656
- this.#resetTabIndexValues();
7657
- }
7658
-
7659
- #handleKeyDown = (event) => {
7660
- if (event.key === "Escape") {
7661
- event.stopPropagation();
7662
- this.close();
7663
- }
7664
- }
7665
-
7666
- async #resetTabIndexValues() {
7667
- await nextFrame();
7668
- this.#buttons.forEach((element, index) => {
7669
- element.setAttribute("tabindex", index === 0 ? 0 : "-1");
7670
- });
7671
- }
7672
-
7673
- get #interactiveElements() {
7674
- return Array.from(this.querySelectorAll("button, input"))
7675
- }
7676
-
7677
- get #buttons() {
7678
- return Array.from(this.querySelectorAll("button"))
7679
- }
7680
- }
7681
-
7682
- class LinkDropdown extends ToolbarDropdown {
7683
- connectedCallback() {
7684
- super.connectedCallback();
7685
-
7686
- this.input = this.querySelector("input");
7687
-
7688
- this.track(
7689
- registerEventListener(this.container, "toggle", this.#handleToggle),
7690
- registerEventListener(this.input, "keydown", this.#handleEnter),
7691
- registerEventListener(this.linkButton, "click", this.#handleLink),
7692
- registerEventListener(this.unlinkButton, "click", this.#handleUnlink)
7693
- );
7694
- }
7695
-
7696
- get linkButton() {
7697
- return this.querySelector("[value='link']")
7698
- }
7699
-
7700
- get unlinkButton() {
7701
- return this.querySelector("[value='unlink']")
7702
- }
7703
-
7704
- #handleToggle = ({ newState }) => {
7705
- this.input.value = this.#selectedLinkUrl;
7706
- this.input.required = newState === "open";
7707
- }
7708
-
7709
- #handleEnter = (event) => {
7710
- if (event.key === "Enter") {
7711
- event.preventDefault();
7712
- event.stopPropagation();
7713
- this.#handleLink(event);
7714
- }
7715
- }
7716
-
7717
- #handleLink = () => {
7718
- if (!this.input.checkValidity()) {
7719
- this.input.reportValidity();
7720
- return
7721
- }
7722
-
7723
- this.editor.dispatchCommand("link", this.input.value);
7724
- this.close();
7725
- }
7726
-
7727
- #handleUnlink = () => {
7728
- this.editor.dispatchCommand("unlink");
7729
- this.close();
7730
- }
7731
-
7732
- get #selectedLinkUrl() {
7733
- return this.editor.getEditorState().read(() => {
7734
- const linkNode = this.editorElement.selection.nearestNodeOfType(LinkNode);
7735
- return linkNode?.getURL() ?? ""
7736
- })
7737
- }
7738
- }
7739
-
7740
- const APPLY_HIGHLIGHT_SELECTOR = "button.lexxy-highlight-button";
7741
- const REMOVE_HIGHLIGHT_SELECTOR = "[data-command='removeHighlight']";
7742
-
7743
- // Use Symbol instead of null since $getSelectionStyleValueForProperty
7744
- // responds differently for backward selections if null is the default
7745
- // see https://github.com/facebook/lexical/issues/8013
7746
- const NO_STYLE = Symbol("no_style");
7747
-
7748
- class HighlightDropdown extends ToolbarDropdown {
7749
- initialize() {
7750
- this.#setUpButtons();
7751
- this.#registerButtonHandlers();
7752
- }
7753
-
7754
- connectedCallback() {
7755
- super.connectedCallback();
7756
- this.track(registerEventListener(this.container, "toggle", this.#handleToggle));
7757
- }
7758
-
7759
- #registerButtonHandlers() {
7760
- this.#colorButtons.forEach(button => {
7761
- this.track(registerEventListener(button, "click", this.#handleColorButtonClick));
7762
- });
7763
- this.track(registerEventListener(this.querySelector(REMOVE_HIGHLIGHT_SELECTOR), "click", this.#handleRemoveHighlightClick));
7764
- }
7765
-
7766
- #setUpButtons() {
7767
- this.#buttonContainer.innerHTML = "";
7768
-
7769
- const colorGroups = this.editorElement.config.get("highlight.buttons");
7770
-
7771
- this.#populateButtonGroup("color", colorGroups.color);
7772
- this.#populateButtonGroup("background-color", colorGroups["background-color"]);
7773
-
7774
- const maxNumberOfColors = Math.max(colorGroups.color.length, colorGroups["background-color"].length);
7775
- this.style.setProperty("--max-colors", maxNumberOfColors);
7776
- }
7777
-
7778
- #populateButtonGroup(attribute, values) {
7779
- values.forEach((value, index) => {
7780
- this.#buttonContainer.appendChild(this.#createButton(attribute, value, index));
7781
- });
7782
- }
7783
-
7784
- #createButton(attribute, value, index) {
7785
- const button = document.createElement("button");
7786
- button.dataset.style = attribute;
7787
- button.style.setProperty(attribute, value);
7788
- button.dataset.value = value;
7789
- button.classList.add("lexxy-editor__toolbar-button", "lexxy-highlight-button");
7790
- button.name = attribute + "-" + index;
7791
- return button
7792
- }
7793
-
7794
- #handleToggle = ({ newState }) => {
7795
- if (newState === "open") {
7796
- this.editor.getEditorState().read(() => {
7797
- this.#updateColorButtonStates($getSelection());
7798
- });
7799
- }
7800
- }
7801
-
7802
- #handleColorButtonClick = (event) => {
7803
- event.preventDefault();
7804
-
7805
- const button = event.target.closest(APPLY_HIGHLIGHT_SELECTOR);
7806
- if (!button) return
7807
-
7808
- const attribute = button.dataset.style;
7809
- const value = button.dataset.value;
7810
-
7811
- this.editor.dispatchCommand("toggleHighlight", { [attribute]: value });
7812
- this.close();
7813
- }
7814
-
7815
- #handleRemoveHighlightClick = (event) => {
7816
- event.preventDefault();
7817
-
7818
- this.editor.dispatchCommand("removeHighlight");
7819
- this.close();
7820
- }
7821
-
7822
- #updateColorButtonStates(selection) {
7823
- if (!$isRangeSelection(selection)) { return }
7824
-
7825
- // Use non-"" default, so "" indicates mixed highlighting
7826
- const textColor = $getSelectionStyleValueForProperty(selection, "color", NO_STYLE);
7827
- const backgroundColor = $getSelectionStyleValueForProperty(selection, "background-color", NO_STYLE);
7828
-
7829
- this.#colorButtons.forEach(button => {
7830
- const matchesSelection = button.dataset.value === textColor || button.dataset.value === backgroundColor;
7831
- button.setAttribute("aria-pressed", matchesSelection);
7832
- });
7833
-
7834
- const hasHighlight = textColor !== NO_STYLE || backgroundColor !== NO_STYLE;
7835
- this.querySelector(REMOVE_HIGHLIGHT_SELECTOR).disabled = !hasHighlight;
7836
- }
7837
-
7838
- get #buttonContainer() {
7839
- return this.querySelector(".lexxy-highlight-colors")
7840
- }
7841
-
7842
- get #colorButtons() {
7843
- return Array.from(this.querySelectorAll(APPLY_HIGHLIGHT_SELECTOR))
7844
- }
7845
- }
7846
-
7847
8177
  class BaseSource {
7848
8178
  // Template method to override
7849
8179
  async buildListItems(filter = "") {
@@ -8236,7 +8566,7 @@ class LexicalPromptElement extends HTMLElement {
8236
8566
  }
8237
8567
 
8238
8568
  #selectOption(listItem) {
8239
- this.#clearSelection();
8569
+ this.#clearListItemSelection();
8240
8570
  listItem.toggleAttribute("aria-selected", true);
8241
8571
  listItem.scrollIntoView({ block: "nearest", behavior: "smooth" });
8242
8572
  listItem.focus();
@@ -8246,18 +8576,28 @@ class LexicalPromptElement extends HTMLElement {
8246
8576
  this.#editorElement.focus();
8247
8577
  });
8248
8578
 
8249
- this.#editorContentElement.setAttribute("aria-controls", this.popoverElement.id);
8250
- this.#editorContentElement.setAttribute("aria-activedescendant", listItem.id);
8251
- this.#editorContentElement.setAttribute("aria-haspopup", "listbox");
8579
+ this.#setEditorAssociationAttribute("aria-controls", this.popoverElement.id);
8580
+ this.#setEditorAssociationAttribute("aria-activedescendant", listItem.id);
8581
+ this.#setEditorAssociationAttribute("aria-haspopup", "listbox");
8252
8582
  }
8253
8583
 
8254
- #clearSelection() {
8584
+ #clearListItemSelection() {
8255
8585
  this.#listItemElements.forEach((item) => { item.toggleAttribute("aria-selected", false); });
8586
+ }
8587
+
8588
+ #clearSelection() {
8589
+ this.#clearListItemSelection();
8256
8590
  this.#editorContentElement.removeAttribute("aria-controls");
8257
8591
  this.#editorContentElement.removeAttribute("aria-activedescendant");
8258
8592
  this.#editorContentElement.removeAttribute("aria-haspopup");
8259
8593
  }
8260
8594
 
8595
+ #setEditorAssociationAttribute(name, value) {
8596
+ if (this.#editorContentElement.getAttribute(name) !== value) {
8597
+ this.#editorContentElement.setAttribute(name, value);
8598
+ }
8599
+ }
8600
+
8261
8601
  #positionPopover() {
8262
8602
  const { x, y, fontSize } = this.#selection.cursorPosition;
8263
8603
  const editorRect = this.#editorElement.getBoundingClientRect();
@@ -8353,7 +8693,7 @@ class LexicalPromptElement extends HTMLElement {
8353
8693
 
8354
8694
  #showEmptyResults() {
8355
8695
  this.popoverElement.classList.add("lexxy-prompt-menu--empty");
8356
- const el = createElement("li", { innerHTML: this.#emptyResultsMessage });
8696
+ const el = createElement("li", { textContent: this.#emptyResultsMessage });
8357
8697
  el.classList.add("lexxy-prompt-menu__item--empty");
8358
8698
  this.popoverElement.append(el);
8359
8699
  }
@@ -8435,7 +8775,7 @@ class LexicalPromptElement extends HTMLElement {
8435
8775
  }
8436
8776
 
8437
8777
  #buildEditableTextNodes(template) {
8438
- return $generateFilteredNodesFromDOM(this.#editorElement, parseHtml(`${template.innerHTML}`))
8778
+ return this.#editorElement.$generateNodesFromDOM(parseHtml(`${template.innerHTML}`))
8439
8779
  }
8440
8780
 
8441
8781
  #insertTemplatesAsAttachments(templates, stringToReplace, fallbackSgid = null) {
@@ -9414,14 +9754,19 @@ class TableTools extends HTMLElement {
9414
9754
 
9415
9755
  function defineElements() {
9416
9756
  const elements = {
9757
+ // Toolbar must be registered BEFORE Editor
9417
9758
  "lexxy-toolbar": LexicalToolbarElement,
9418
- "lexxy-editor": LexicalEditorElement,
9419
- "lexxy-link-dropdown": LinkDropdown,
9759
+ "lexxy-toolbar-dropdown": ToolbarDropdown,
9420
9760
  "lexxy-highlight-dropdown": HighlightDropdown,
9761
+ "lexxy-link-dropdown": LinkDropdown,
9762
+
9763
+ "lexxy-editor": LexicalEditorElement,
9764
+
9765
+ // Prompt must be registered AFTER Editor
9421
9766
  "lexxy-prompt": LexicalPromptElement,
9422
9767
  "lexxy-code-language-picker": CodeLanguagePicker,
9423
9768
  "lexxy-node-delete-button": NodeDeleteButton,
9424
- "lexxy-table-tools": TableTools,
9769
+ "lexxy-table-tools": TableTools
9425
9770
  };
9426
9771
 
9427
9772
  Object.entries(elements).forEach(([ name, element ]) => {