@37signals/lexxy 0.9.32 → 1.0.0

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,13 +1,13 @@
1
1
  export { highlightCode, highlightElement } from './lexxy_helpers.esm.js';
2
2
  import createDOMPurify from 'dompurify';
3
3
  import { getStyleObjectFromCSS, getCSSFromStyleObject, $getSelectionStyleValueForProperty, $ensureForwardRangeSelection, $isAtNodeEnd, $patchStyleText, $setBlocksType, $forEachSelectedTextNode } from '@lexical/selection';
4
- import { SKIP_DOM_SELECTION_TAG, CAN_UNDO_COMMAND, COMMAND_PRIORITY_LOW, CAN_REDO_COMMAND, $getSelection, $isRangeSelection, DecoratorNode, $createTextNode, defineExtension, $getRoot, $caretFromPoint, $setSelectionFromCaretRange, $getCaretRange, $normalizeCaret, $getChildCaret, $getCaretInDirection, $isParagraphNode, $isLineBreakNode, $createParagraphNode, $isElementNode, $isRootOrShadowRoot, $isRootNode, $createNodeSelection, $isDecoratorNode, $isTextNode, $getSiblingCaret, $rewindSiblingCaret, $splitAtPointCaretNext, $normalizeSelection__EXPERIMENTAL, $isChildCaret, $isTextPointCaret, $isExtendableTextPointCaret, $isSiblingCaret, $getCommonAncestor, $findMatchingParent, TextNode, createCommand, COMMAND_PRIORITY_EDITOR, $getEditor, $getNodeByKey, HISTORY_MERGE_TAG, SKIP_SCROLL_INTO_VIEW_TAG, $cloneWithProperties, $getNearestRootOrShadowRoot, $createRangeSelection, $setSelection, createState, COMMAND_PRIORITY_NORMAL, $getState, $setState, $hasUpdateTag, PASTE_TAG, FORMAT_TEXT_COMMAND, UNDO_COMMAND, REDO_COMMAND, KEY_ARROW_RIGHT_COMMAND, KEY_TAB_COMMAND, INSERT_LINE_BREAK_COMMAND, COMMAND_PRIORITY_HIGH, INSERT_PARAGRAPH_COMMAND, OUTDENT_CONTENT_COMMAND, INDENT_CONTENT_COMMAND, $isNodeSelection, KEY_ARROW_LEFT_COMMAND, KEY_ARROW_UP_COMMAND, KEY_ARROW_DOWN_COMMAND, DELETE_CHARACTER_COMMAND, SELECTION_CHANGE_COMMAND, COMMAND_PRIORITY_CRITICAL, $addUpdateTag, CLICK_COMMAND, isDOMNode, $getNearestNodeFromDOMNode, ElementNode, $splitNode, $getChildCaretAtIndex, $createLineBreakNode, SELECTION_INSERT_CLIPBOARD_NODES_COMMAND, PASTE_COMMAND, $onUpdate, ParagraphNode, RootNode, DRAGSTART_COMMAND, DROP_COMMAND, mergeRegister as mergeRegister$1, $createRangeSelectionFromDom, CLEAR_HISTORY_COMMAND, KEY_ENTER_COMMAND, KEY_SPACE_COMMAND, INPUT_COMMAND, KEY_BACKSPACE_COMMAND, KEY_DOWN_COMMAND } from 'lexical';
4
+ import { SKIP_DOM_SELECTION_TAG, CAN_UNDO_COMMAND, COMMAND_PRIORITY_LOW, CAN_REDO_COMMAND, $getSelection, $isRangeSelection, $isTextNode, DecoratorNode, $createTextNode, defineExtension, $createNodeSelection, $getRoot, $caretFromPoint, $setSelectionFromCaretRange, $getCaretRange, $normalizeCaret, $getChildCaret, $getCaretInDirection, $isParagraphNode, $isLineBreakNode, $createParagraphNode, $isElementNode, $isRootOrShadowRoot, $isRootNode, $isDecoratorNode, $getSiblingCaret, $rewindSiblingCaret, $splitAtPointCaretNext, $normalizeSelection__EXPERIMENTAL, $isNodeSelection, $isChildCaret, $isTextPointCaret, $isExtendableTextPointCaret, $isSiblingCaret, $getCommonAncestor, $findMatchingParent, TextNode, createCommand, COMMAND_PRIORITY_EDITOR, $getEditor, $getNodeByKey, HISTORY_MERGE_TAG, SKIP_SCROLL_INTO_VIEW_TAG, $cloneWithProperties, $getNearestRootOrShadowRoot, $createRangeSelection, $setSelection, ElementNode, $splitNode, createState, COMMAND_PRIORITY_NORMAL, $getState, $setState, $hasUpdateTag, PASTE_TAG, FORMAT_TEXT_COMMAND, UNDO_COMMAND, REDO_COMMAND, KEY_ARROW_RIGHT_COMMAND, KEY_TAB_COMMAND, INSERT_LINE_BREAK_COMMAND, COMMAND_PRIORITY_HIGH, INSERT_PARAGRAPH_COMMAND, OUTDENT_CONTENT_COMMAND, INDENT_CONTENT_COMMAND, KEY_ARROW_LEFT_COMMAND, KEY_ARROW_UP_COMMAND, KEY_ARROW_DOWN_COMMAND, DELETE_CHARACTER_COMMAND, SELECTION_CHANGE_COMMAND, COMMAND_PRIORITY_CRITICAL, $addUpdateTag, CLICK_COMMAND, isDOMNode, $getNearestNodeFromDOMNode, $getChildCaretAtIndex, $createLineBreakNode, SELECTION_INSERT_CLIPBOARD_NODES_COMMAND, PASTE_COMMAND, $onUpdate, ParagraphNode, RootNode, DRAGSTART_COMMAND, DROP_COMMAND, KEY_DOWN_COMMAND, flipDirection, mergeRegister as mergeRegister$1, $createRangeSelectionFromDom, CLEAR_HISTORY_COMMAND, KEY_ENTER_COMMAND, KEY_SPACE_COMMAND, INPUT_COMMAND, HISTORY_PUSH_TAG, KEY_BACKSPACE_COMMAND } from 'lexical';
5
5
  import * as lexical from 'lexical';
6
6
  export { lexical as Lexical };
7
7
  import { LinkNode, $createAutoLinkNode, $toggleLink, $createLinkNode, $isLinkNode, AutoLinkNode } from '@lexical/link';
8
8
  import { buildEditorFromExtensions } from '@lexical/extension';
9
9
  import { ListNode, ListItemNode, $getListDepth, $isListNode, $isListItemNode, INSERT_UNORDERED_LIST_COMMAND, INSERT_ORDERED_LIST_COMMAND, $createListNode, $createListItemNode, registerList } from '@lexical/list';
10
- import { $getNearestNodeOfType, $wrapNodeInElement, $lastToFirstIterator, $descendantsMatching, mergeRegister, $insertNodeToNearestRoot, $insertFirst, $unwrapAndFilterDescendants, $firstToLastIterator, $getNearestBlockElementAncestorOrThrow, IS_APPLE } from '@lexical/utils';
10
+ import { $getNearestNodeOfType, $wrapNodeInElement, $lastToFirstIterator, $descendantsMatching, $insertFirst, $unwrapAndFilterDescendants, $firstToLastIterator, mergeRegister, $insertNodeToNearestRoot, $getNearestBlockElementAncestorOrThrow, IS_APPLE } from '@lexical/utils';
11
11
  import { registerPlainText } from '@lexical/plain-text';
12
12
  import { RichTextExtension, $isQuoteNode, QuoteNode, $isHeadingNode, $createHeadingNode, $createQuoteNode, HeadingNode, registerRichText } from '@lexical/rich-text';
13
13
  import { $generateNodesFromDOM, $generateHtmlFromNodes } from '@lexical/html';
@@ -74,10 +74,6 @@ class Configuration {
74
74
  }
75
75
  }
76
76
 
77
- function range(from, to) {
78
- return [ ...Array(1 + to - from).keys() ].map(i => i + from)
79
- }
80
-
81
77
  const global = new Configuration({
82
78
  attachmentTagName: "action-text-attachment",
83
79
  attachmentContentTypeNamespace: "actiontext",
@@ -98,8 +94,28 @@ const presets = new Configuration({
98
94
  headings: [ "h2", "h3", "h4" ],
99
95
  highlight: {
100
96
  buttons: {
101
- color: range(1, 9).map(n => `var(--highlight-${n})`),
102
- "background-color": range(1, 9).map(n => `var(--highlight-bg-${n})`),
97
+ color: [
98
+ { value: "var(--highlight-1)", label: "Yellow" },
99
+ { value: "var(--highlight-2)", label: "Orange" },
100
+ { value: "var(--highlight-3)", label: "Red" },
101
+ { value: "var(--highlight-4)", label: "Magenta" },
102
+ { value: "var(--highlight-5)", label: "Purple" },
103
+ { value: "var(--highlight-6)", label: "Blue" },
104
+ { value: "var(--highlight-7)", label: "Green" },
105
+ { value: "var(--highlight-8)", label: "Sand" },
106
+ { value: "var(--highlight-9)", label: "Gray" },
107
+ ],
108
+ "background-color": [
109
+ { value: "var(--highlight-bg-1)", label: "Yellow" },
110
+ { value: "var(--highlight-bg-2)", label: "Orange" },
111
+ { value: "var(--highlight-bg-3)", label: "Red" },
112
+ { value: "var(--highlight-bg-4)", label: "Magenta" },
113
+ { value: "var(--highlight-bg-5)", label: "Purple" },
114
+ { value: "var(--highlight-bg-6)", label: "Blue" },
115
+ { value: "var(--highlight-bg-7)", label: "Green" },
116
+ { value: "var(--highlight-bg-8)", label: "Sand" },
117
+ { value: "var(--highlight-bg-9)", label: "Gray" },
118
+ ],
103
119
  },
104
120
  permit: {
105
121
  color: [],
@@ -568,11 +584,45 @@ function checkVisibility(element, options) {
568
584
  }
569
585
  }
570
586
 
571
- function handleRollingTabIndex(elements, event) {
587
+ // Keyboard and programmatic clicks carry a synthetic pointer.
588
+ function isKeyboardActivation(event) {
589
+ return event instanceof PointerEvent && event.pointerId === -1
590
+ }
591
+
592
+ function trapFocusAtTabBoundary(container, event) {
593
+ if (event.key !== "Tab") return
594
+
595
+ const tabStops = Array.from(container.querySelectorAll("a[href], area[href], button, input:not([type='hidden']), select, textarea, iframe, object, embed, [contenteditable]:not([contenteditable='false']), [tabindex], summary, audio[controls], video[controls]"))
596
+ .filter(element => element.tabIndex >= 0 && !element.matches(":disabled") && !element.closest("[inert]") && isActiveAndVisible(element))
597
+ .sort((first, second) => {
598
+ if (first.tabIndex > 0 && second.tabIndex > 0) {
599
+ return first.tabIndex - second.tabIndex
600
+ } else if (first.tabIndex > 0) {
601
+ return -1
602
+ } else if (second.tabIndex > 0) {
603
+ return 1
604
+ } else {
605
+ return 0
606
+ }
607
+ });
608
+
609
+ const firstTabStop = tabStops[0];
610
+ const lastTabStop = tabStops.at(-1);
611
+
612
+ if (event.shiftKey && document.activeElement === firstTabStop) {
613
+ event.preventDefault();
614
+ lastTabStop.focus();
615
+ } else if (!event.shiftKey && document.activeElement === lastTabStop) {
616
+ event.preventDefault();
617
+ firstTabStop.focus();
618
+ }
619
+ }
620
+
621
+ function handleRollingTabIndex(elements, event, { orientation = "horizontal", wrap = false } = {}) {
572
622
  const previousActiveElement = document.activeElement;
573
623
 
574
624
  if (elements.includes(previousActiveElement)) {
575
- const finder = new NextElementFinder(elements, event.key);
625
+ const finder = new NextElementFinder(elements, event.key, { orientation, wrap });
576
626
 
577
627
  if (finder.selectNext(previousActiveElement)) {
578
628
  event.preventDefault();
@@ -581,9 +631,11 @@ function handleRollingTabIndex(elements, event) {
581
631
  }
582
632
 
583
633
  class NextElementFinder {
584
- constructor(elements, key) {
634
+ constructor(elements, key, { orientation, wrap }) {
585
635
  this.elements = elements;
586
636
  this.key = key;
637
+ this.orientation = orientation;
638
+ this.wrap = wrap;
587
639
  }
588
640
 
589
641
  selectNext(fromElement) {
@@ -600,23 +652,36 @@ class NextElementFinder {
600
652
  }
601
653
 
602
654
  #findNextElement(fromElement) {
603
- switch (this.key) {
604
- case "ArrowRight":
605
- case "ArrowDown":
655
+ switch (this.#directionFor(this.key)) {
656
+ case "next":
606
657
  return this.#findNextSibling(fromElement)
607
658
 
608
- case "ArrowLeft":
609
- case "ArrowUp":
659
+ case "previous":
610
660
  return this.#findPreviousSibling(fromElement)
611
661
 
612
- case "Home":
662
+ case "first":
613
663
  return this.#findFirst()
614
664
 
615
- case "End":
665
+ case "last":
616
666
  return this.#findLast()
617
667
  }
618
668
  }
619
669
 
670
+ #directionFor(key) {
671
+ if (key === "Home") return "first"
672
+ if (key === "End") return "last"
673
+ if (this.#nextKeys.includes(key)) return "next"
674
+ if (this.#previousKeys.includes(key)) return "previous"
675
+ }
676
+
677
+ get #nextKeys() {
678
+ return this.orientation === "both" ? [ "ArrowRight", "ArrowDown" ] : [ "ArrowRight" ]
679
+ }
680
+
681
+ get #previousKeys() {
682
+ return this.orientation === "both" ? [ "ArrowLeft", "ArrowUp" ] : [ "ArrowLeft" ]
683
+ }
684
+
620
685
  #findFirst(elements = this.elements) {
621
686
  return elements.find(isActiveAndVisible)
622
687
  }
@@ -627,12 +692,20 @@ class NextElementFinder {
627
692
 
628
693
  #findNextSibling(element) {
629
694
  const afterElements = this.elements.slice(this.#indexOf(element) + 1);
630
- return this.#findFirst(afterElements)
695
+ return this.#findFirst(afterElements) ?? this.#wrapToFirst()
631
696
  }
632
697
 
633
698
  #findPreviousSibling(element) {
634
699
  const beforeElements = this.elements.slice(0, this.#indexOf(element));
635
- return this.#findLast(beforeElements)
700
+ return this.#findLast(beforeElements) ?? this.#wrapToLast()
701
+ }
702
+
703
+ #wrapToFirst() {
704
+ return this.wrap ? this.#findFirst() : undefined
705
+ }
706
+
707
+ #wrapToLast() {
708
+ return this.wrap ? this.#findLast() : undefined
636
709
  }
637
710
 
638
711
  #indexOf(element) {
@@ -653,29 +726,29 @@ class NextElementFinder {
653
726
 
654
727
  var ToolbarIcons = {
655
728
  "bold":
656
- `<svg viewBox="0 0 18 18" xmlns="http://www.w3.org/2000/svg">
729
+ `<svg viewBox="0 0 18 18" xmlns="http://www.w3.org/2000/svg" aria-hidden="true">
657
730
  <path fill-rule="evenodd" clip-rule="evenodd" d="M9.05273 1.88232C10.6866 1.88237 12.0033 2.20353 12.9529 2.89673L13.1272 3.0293C13.974 3.70864 14.4008 4.63245 14.4009 5.76562C14.4008 6.49354 14.2316 7.15281 13.8845 7.73145C13.6683 8.09188 13.3997 8.40162 13.0818 8.66016C13.5902 8.92606 14.0196 9.28599 14.3635 9.74121C14.8586 10.3834 15.0945 11.1743 15.0945 12.0879C15.0944 13.3698 14.5922 14.3931 13.5879 15.1106L13.5857 15.1128C12.5967 15.805 11.196 16.125 9.43799 16.125H3.10547V1.88232L9.05273 1.88232ZM6.36108 13.4084H9.28418C10.224 13.4084 10.8634 13.2491 11.2581 12.9851C11.6259 12.7389 11.8198 12.3768 11.8198 11.8367C11.8197 11.2968 11.6259 10.9351 11.2581 10.689C10.8634 10.425 10.2241 10.2649 9.28418 10.2649H6.36108V13.4084ZM6.36108 7.56812H8.78247C9.5163 7.56809 10.0547 7.45371 10.429 7.25757L10.5791 7.16895C10.9438 6.92178 11.1255 6.57934 11.1255 6.09302C11.1254 5.59017 10.9414 5.25227 10.5835 5.02002L10.5784 5.01636L10.5732 5.01343C10.1994 4.75387 9.61878 4.59818 8.78247 4.59814H6.36108V7.56812Z"/>
658
731
  </svg>`,
659
732
 
660
733
  "italic":
661
- `<svg viewBox="0 0 18 18" xmlns="http://www.w3.org/2000/svg">
734
+ `<svg viewBox="0 0 18 18" xmlns="http://www.w3.org/2000/svg" aria-hidden="true">
662
735
  <path d="M14.1379 3.91187L14.1086 4.06421H11.4668L9.49805 13.9431H12.0981L11.7473 15.7852L11.7188 15.9375H4.16675L4.51758 14.0955L4.54614 13.9431H7.18799L9.17505 4.06421H6.55664L6.90747 2.22217L6.93677 2.06982H14.4888L14.1379 3.91187Z"/>
663
736
  </svg>`,
664
737
 
665
738
  "strikethrough":
666
- `<svg viewBox="0 0 18 18" xmlns="http://www.w3.org/2000/svg">
739
+ `<svg viewBox="0 0 18 18" xmlns="http://www.w3.org/2000/svg" aria-hidden="true">
667
740
  <path d="M14.3723 11.8015C14.3771 11.8858 14.3811 11.9756 14.3811 12.0681C14.3811 12.811 14.1777 13.4959 13.7725 14.1174L13.7717 14.1189C13.3624 14.7329 12.7463 15.2162 11.9377 15.5742L11.9348 15.5757C11.1214 15.9223 10.1306 16.092 8.96997 16.092C7.9356 16.092 6.93308 15.9348 5.96338 15.6204L5.96045 15.6189C5.00593 15.292 4.24112 14.8699 3.67676 14.3459L3.57568 14.2522L3.63501 14.1277L4.45605 12.397L4.64282 12.5654C5.13492 13.0083 5.76733 13.3759 6.54492 13.6648C7.33475 13.9406 8.14322 14.0786 8.96997 14.0786C10.0731 14.0786 10.8638 13.8932 11.3708 13.5513C11.8757 13.1982 12.1172 12.7464 12.1172 12.1838C12.1172 12.0662 12.1049 11.9556 12.0828 11.8513L12.0344 11.625H14.3621L14.3723 11.8015Z"/>
668
741
  <path d="M9.2981 1.91602C10.111 1.91604 10.9109 2.02122 11.6975 2.23096C12.4855 2.44111 13.1683 2.74431 13.7417 3.14429L13.8655 3.23071L13.8083 3.36987L13.1726 4.91235L13.0869 5.1189L12.8987 4.99878C12.3487 4.64881 11.761 4.38633 11.1365 4.21143L11.1328 4.20996C10.585 4.04564 10.0484 3.95419 9.52295 3.93384L9.2981 3.92944C8.22329 3.92944 7.44693 4.12611 6.94043 4.49121C6.44619 4.85665 6.20874 5.31616 6.20874 5.88135L6.21533 6.03296C6.24495 6.37662 6.37751 6.65526 6.61011 6.87964L6.72144 6.97632C6.98746 7.19529 7.30625 7.37584 7.68018 7.51538L8.05151 7.63184C8.45325 7.75061 8.94669 7.87679 9.53247 8.01123L9.53467 8.01196C10.1213 8.15305 10.6426 8.29569 11.0991 8.4375H15C15.5178 8.4375 15.9375 8.85723 15.9375 9.375C15.9375 9.89277 15.5178 10.3125 15 10.3125H3C2.48223 10.3125 2.0625 9.89277 2.0625 9.375C2.0625 8.85723 2.48223 8.4375 3 8.4375H4.93726C4.83783 8.34526 4.74036 8.24896 4.64795 8.146L4.64502 8.14233C4.1721 7.58596 3.94482 6.85113 3.94482 5.95825C3.94483 5.20441 4.14059 4.51965 4.53369 3.90967L4.53516 3.90747C4.94397 3.29427 5.55262 2.81114 6.34863 2.45288C7.15081 2.0919 8.13683 1.91602 9.2981 1.91602Z"/>
669
742
  </svg>`,
670
743
 
671
744
  "underline":
672
- `<svg viewBox="0 0 18 18" xmlns="http://www.w3.org/2000/svg">
745
+ `<svg viewBox="0 0 18 18" xmlns="http://www.w3.org/2000/svg" aria-hidden="true">
673
746
  <path d="M14 14C14.5523 14 15 14.4477 15 15C15 15.5523 14.5523 16 14 16H4C3.44772 16 3 15.5523 3 15C3 14.4477 3.44772 14 4 14H14Z"/>
674
747
  <path d="M12.625 1.59375C13.25 1.59375 13.625 1.97656 13.625 2.64062V9.02344C13.625 11.4844 11.8516 13.1875 8.99219 13.1875C6.14062 13.1875 4.35938 11.4844 4.35938 9.02344V2.64062C4.35938 1.97656 4.74219 1.59375 5.36719 1.59375C6 1.59375 6.375 1.97656 6.375 2.64062V8.84375C6.375 10.3828 7.32031 11.4297 8.99219 11.4297C10.6641 11.4297 11.6172 10.3828 11.6172 8.84375V2.64062C11.6172 1.97656 11.9922 1.59375 12.625 1.59375Z"/>
675
748
  </svg>`,
676
749
 
677
750
  "heading":
678
- `<svg viewBox="0 0 18 18" xmlns="http://www.w3.org/2000/svg">
751
+ `<svg viewBox="0 0 18 18" xmlns="http://www.w3.org/2000/svg" aria-hidden="true">
679
752
  <path d="M11.5 2C12.0523 2 12.5 2.44772 12.5 3V3.5C12.5 4.05228 12.0523 4.5 11.5 4.5H8V15C8 15.5523 7.55228 16 7 16H6.5C5.94772 16 5.5 15.5523 5.5 15V4.5H2C1.44772 4.5 1 4.05228 1 3.5V3C1 2.44772 1.44772 2 2 2H11.5ZM16 7C16.5523 7 17 7.44772 17 8V8.5C17 9.05228 16.5523 9.5 16 9.5H15V15C15 15.5523 14.5523 16 14 16H13.5C12.9477 16 12.5 15.5523 12.5 15V9.5H11.5C10.9477 9.5 10.5 9.05228 10.5 8.5V8C10.5 7.44772 10.9477 7 11.5 7H16Z"/>
680
753
  </svg>`,
681
754
 
@@ -685,17 +758,17 @@ var ToolbarIcons = {
685
758
  </svg>`,
686
759
 
687
760
  "h2":
688
- `<svg viewBox="0 0 18 18" xmlns="http://www.w3.org/2000/svg">
761
+ `<svg viewBox="0 0 18 18" xmlns="http://www.w3.org/2000/svg" aria-hidden="true">
689
762
  <path d="M8.12207 4.30078C8.84668 4.30078 9.25684 4.74512 9.25684 5.51758V12.5518C9.25677 13.3241 8.84662 13.7686 8.12207 13.7686C7.39752 13.7686 6.9942 13.3309 6.99414 12.5518V9.87207H3.56934V12.5518C3.56927 13.3241 3.15912 13.7686 2.43457 13.7686C1.71002 13.7686 1.3067 13.3309 1.30664 12.5518V5.51758C1.30664 4.73828 1.70996 4.30078 2.43457 4.30078C3.15918 4.30078 3.56934 4.74512 3.56934 5.51758V8.07422H6.99414V5.51758C6.99414 4.73828 7.39746 4.30078 8.12207 4.30078ZM13.6445 4.19824C15.5244 4.19824 16.8984 5.34668 16.8984 6.91211C16.8984 7.8759 16.4335 8.7237 15.292 9.84473L13.3438 11.8135V11.9092H16.1875C16.8232 11.9092 17.2197 12.251 17.2197 12.8115C17.2196 13.3651 16.83 13.7002 16.1875 13.7002H11.5117C10.8487 13.7002 10.4112 13.3241 10.4111 12.75C10.4111 12.3399 10.6368 11.9843 11.3203 11.3145L13.6855 8.88086C14.4169 8.13583 14.7245 7.64349 14.7246 7.12402C14.7246 6.4541 14.2393 6.00293 13.5215 6.00293C12.9404 6.00293 12.5166 6.29688 12.2158 6.90527C11.9151 7.37002 11.6552 7.54785 11.2588 7.54785C10.7188 7.54785 10.3429 7.17861 10.3428 6.65918C10.3428 5.3877 11.7783 4.19824 13.6445 4.19824Z"/>
690
763
  </svg>`,
691
764
 
692
765
  "h3":
693
- `<svg viewBox="0 0 18 18" xmlns="http://www.w3.org/2000/svg">
766
+ `<svg viewBox="0 0 18 18" xmlns="http://www.w3.org/2000/svg" aria-hidden="true">
694
767
  <path d="M13.5967 4.19824C15.5928 4.19824 16.9873 5.23047 16.9873 6.7002C16.9872 7.705 16.2421 8.60059 15.2988 8.7168V8.86719C16.4199 8.94923 17.2607 9.89942 17.2607 11.0889C17.2606 12.7362 15.7362 13.9053 13.583 13.9053C11.6827 13.9053 10.1925 12.873 10.1924 11.7041C10.1924 11.1846 10.5547 10.8154 11.0537 10.8154C11.3818 10.8154 11.6553 10.9727 11.9629 11.3555C12.3799 11.9159 12.92 12.2031 13.583 12.2031C14.4853 12.2031 15.0731 11.7313 15.0732 11C15.0732 10.2754 14.4785 9.7832 13.5898 9.7832H13.0361C12.5645 9.7832 12.2159 9.4208 12.2158 8.92188C12.2158 8.44336 12.5576 8.07422 13.0361 8.07422H13.5693C14.3075 8.07422 14.8544 7.60928 14.8545 6.97363C14.8545 6.33789 14.3213 5.90039 13.5557 5.90039C12.9678 5.90039 12.5029 6.16016 12.0859 6.71387C11.8399 7.03508 11.5527 7.17871 11.1973 7.17871C10.671 7.17871 10.295 6.82314 10.2949 6.31738C10.2949 5.18945 11.751 4.19824 13.5967 4.19824ZM8.0332 4.30078C8.75781 4.30078 9.16797 4.74512 9.16797 5.51758V12.5518C9.1679 13.3241 8.75776 13.7686 8.0332 13.7686C7.30865 13.7686 6.90534 13.3309 6.90527 12.5518V9.87207H3.48047V12.5518C3.4804 13.3241 3.07026 13.7686 2.3457 13.7686C1.62115 13.7686 1.21784 13.3309 1.21777 12.5518V5.51758C1.21777 4.73828 1.62109 4.30078 2.3457 4.30078C3.07031 4.30078 3.48047 4.74512 3.48047 5.51758V8.07422H6.90527V5.51758C6.90527 4.73828 7.30859 4.30078 8.0332 4.30078Z"/>
695
768
  </svg>`,
696
769
 
697
770
  "h4":
698
- `<svg viewBox="0 0 18 18" xmlns="http://www.w3.org/2000/svg">
771
+ `<svg viewBox="0 0 18 18" xmlns="http://www.w3.org/2000/svg" aria-hidden="true">
699
772
  <path d="M14.6357 4.22559C15.7432 4.22559 16.4336 4.80664 16.4336 5.73633V10.3164H16.7275C17.2881 10.3164 17.6436 10.6787 17.6436 11.2256C17.6435 11.7655 17.3017 12.1006 16.7275 12.1006H16.4336V12.6611C16.4335 13.3515 16.0234 13.7891 15.374 13.7891C14.7247 13.7891 14.3282 13.3583 14.3281 12.6611V12.1006H11.04C10.2335 12.1006 9.76863 11.6766 9.76855 10.918C9.76855 10.5762 9.85064 10.3026 10.1104 9.74219C10.7666 8.42289 11.5733 7.0146 12.5713 5.54492C13.2549 4.56738 13.7812 4.22559 14.6357 4.22559ZM7.88965 4.30078C8.61426 4.30078 9.02441 4.74512 9.02441 5.51758V12.5518C9.02435 13.3241 8.6142 13.7686 7.88965 13.7686C7.1651 13.7686 6.76178 13.3309 6.76172 12.5518V9.87207H3.33691V12.5518C3.33685 13.3241 2.9267 13.7686 2.20215 13.7686C1.4776 13.7686 1.07428 13.3309 1.07422 12.5518V5.51758C1.07422 4.73828 1.47754 4.30078 2.20215 4.30078C2.92676 4.30078 3.33691 4.74512 3.33691 5.51758V8.07422H6.76172V5.51758C6.76172 4.73828 7.16504 4.30078 7.88965 4.30078ZM14.2188 6.07812C13.6035 7.02841 12.2158 9.48929 11.7988 10.2686V10.3164H14.3281V6.07812H14.2188Z"/>
700
773
  </svg>`,
701
774
 
@@ -710,44 +783,44 @@ var ToolbarIcons = {
710
783
  </svg>`,
711
784
 
712
785
  "paragraph":
713
- `<svg viewBox="0 0 18 18" xmlns="http://www.w3.org/2000/svg">
786
+ `<svg viewBox="0 0 18 18" xmlns="http://www.w3.org/2000/svg" aria-hidden="true">
714
787
  <path d="M9 12C9.55228 12 10 12.4477 10 13C10 13.5523 9.55228 14 9 14H3C2.44772 14 2 13.5523 2 13C2 12.4477 2.44772 12 3 12H9ZM15 8C15.5523 8 16 8.44772 16 9C16 9.55228 15.5523 10 15 10H3C2.44772 10 2 9.55228 2 9C2 8.44772 2.44772 8 3 8H15ZM15 4C15.5523 4 16 4.44772 16 5C16 5.55228 15.5523 6 15 6H3C2.44772 6 2 5.55228 2 5C2 4.44772 2.44772 4 3 4H15Z"/>
715
788
  </svg>`,
716
789
 
717
790
  "clearFormatting":
718
- `<svg viewBox="0 0 18 18" xmlns="http://www.w3.org/2000/svg">
791
+ `<svg viewBox="0 0 18 18" xmlns="http://www.w3.org/2000/svg" aria-hidden="true">
719
792
  <path d="M10.0607 2.07533C10.8417 1.29432 12.1078 1.2943 12.8888 2.07533L16.424 5.61049C17.205 6.39154 17.205 7.65854 16.424 8.43959L9.44937 15.4142C9.07435 15.7891 8.5656 16.0001 8.03531 16.0001H5.0148C4.55074 16.0001 4.10309 15.8385 3.74722 15.547L3.60074 15.4142L1.57534 13.3888C0.79431 12.6078 0.794336 11.3417 1.57534 10.5607L10.0607 2.07533ZM2.98941 11.9747L5.0148 14.0001H8.03531L9.71792 12.3165L6.18179 8.78139L2.98941 11.9747Z"/>
720
793
  </svg>`,
721
794
 
722
795
  "highlight":
723
- `<svg viewBox="0 0 18 18" xmlns="http://www.w3.org/2000/svg">
796
+ `<svg viewBox="0 0 18 18" xmlns="http://www.w3.org/2000/svg" aria-hidden="true">
724
797
  <path d="M16.4564 14.4272C17.1356 15.5592 16.3204 17.0002 15.0003 17.0004C13.68 17.0004 12.864 15.5593 13.5433 14.4272L15.0003 12.0004L16.4564 14.4272ZM5.1214 1.70746C5.51192 1.31693 6.14494 1.31693 6.53546 1.70746L9.7171 4.8891L13.2532 8.42426C14.2295 9.40056 14.2295 10.9841 13.2532 11.9604L9.7171 15.4955C8.74078 16.4718 7.15822 16.4718 6.18195 15.4955L2.64679 11.9604C1.67048 10.9841 1.67048 9.40057 2.64679 8.42426L6.18195 4.8891C6.30299 4.76805 6.43323 4.66177 6.57062 4.57074L5.1214 3.12152C4.73091 2.73104 4.73099 2.09799 5.1214 1.70746ZM8.30304 6.30316C8.10776 6.10815 7.79119 6.10799 7.59601 6.30316L4.06085 9.83929L3.9964 9.91742C3.88661 10.0838 3.88645 10.3019 3.9964 10.4682L4.02277 10.5004H11.8763C12.0312 10.3043 12.02 10.0205 11.8392 9.83929L8.30304 6.30316Z"/>
725
798
  </svg>`,
726
799
 
727
800
  "link":
728
- `<svg viewBox="0 0 18 18" xmlns="http://www.w3.org/2000/svg">
801
+ `<svg viewBox="0 0 18 18" xmlns="http://www.w3.org/2000/svg" aria-hidden="true">
729
802
  <path d="M12.8885 7.23091L13.9479 6.17155C14.5337 5.58576 14.5337 4.63602 13.9479 4.05023C13.3621 3.46444 12.4124 3.46444 11.8266 4.05023L8.29235 7.58446C7.9263 7.95051 7.90312 8.52994 8.2233 8.92271L8.36141 9.07463C8.68158 9.4674 8.65841 10.0468 8.29235 10.4129C7.90183 10.8034 7.26866 10.8034 6.87814 10.4129C5.70657 9.24131 5.70657 7.34182 6.87814 6.17025L10.4124 2.63602C11.7792 1.26918 13.9953 1.26918 15.3621 2.63602C16.729 4.00285 16.729 6.21893 15.3621 7.58576L14.3028 8.64512C13.9122 9.03564 13.2791 9.03564 12.8885 8.64512C12.498 8.2546 12.498 7.62143 12.8885 7.23091Z"/>
730
803
  <path d="M5.11038 10.7664L4.04843 11.8284C3.46264 12.4142 3.46264 13.3639 4.04842 13.9497C4.63421 14.5355 5.58396 14.5355 6.16975 13.9497L9.70657 10.4129C10.0726 10.0468 10.0958 9.46741 9.77563 9.07464L9.63752 8.92272C9.31734 8.52995 9.34052 7.95052 9.70657 7.58446C10.0971 7.19394 10.7303 7.19394 11.1208 7.58446C12.2924 8.75604 12.2924 10.6555 11.1208 11.8271L7.58396 15.3639C6.21712 16.7308 4.00105 16.7308 2.63421 15.3639C1.26738 13.9971 1.26738 11.781 2.63421 10.4142L3.69617 9.35223C4.08669 8.96171 4.71986 8.96171 5.11038 9.35223C5.5009 9.74275 5.5009 10.3759 5.11038 10.7664Z"/>
731
804
  </svg>`,
732
805
 
733
806
  "quote":
734
- `<svg viewBox="0 0 18 18" xmlns="http://www.w3.org/2000/svg">
807
+ `<svg viewBox="0 0 18 18" xmlns="http://www.w3.org/2000/svg" aria-hidden="true">
735
808
  <path d="M4.96387 4.23438C6.8769 4.23438 8.42767 5.78522 8.42773 7.69824C8.42773 8.32925 8.25769 8.92015 7.96289 9.42969L7.96387 9.43066L5.11816 14.3584C4.77659 14.95 4.02038 15.153 3.42871 14.8115C2.83701 14.4699 2.63397 13.7128 2.97559 13.1211L4.16113 11.0674C2.63532 10.7052 1.5 9.33485 1.5 7.69824C1.50006 5.78524 3.05086 4.2344 4.96387 4.23438ZM13.0361 4.23438C14.9491 4.23449 16.4999 5.7853 16.5 7.69824C16.5 8.32921 16.3299 8.92017 16.0352 9.42969L16.0361 9.43066L13.1904 14.3584C12.8488 14.9501 12.0917 15.1531 11.5 14.8115C10.9085 14.4698 10.7063 13.7127 11.0479 13.1211L12.2324 11.0674C10.7069 10.7049 9.57227 9.33461 9.57227 7.69824C9.57233 5.78522 11.1231 4.23438 13.0361 4.23438Z"/>
736
809
  </svg>`,
737
810
 
738
811
  "code":
739
- `<svg viewBox="0 0 18 18" xmlns="http://www.w3.org/2000/svg">
812
+ `<svg viewBox="0 0 18 18" xmlns="http://www.w3.org/2000/svg" aria-hidden="true">
740
813
  <path d="M6.29289 3.79295C6.68342 3.40243 7.31643 3.40243 7.70696 3.79295C8.09748 4.18348 8.09748 4.81649 7.70696 5.20702L3.91399 8.99999L7.70696 12.793C8.09748 13.1835 8.09748 13.8165 7.70696 14.207C7.31643 14.5975 6.68342 14.5975 6.29289 14.207L1.79289 9.70702C1.40237 9.31649 1.40237 8.68348 1.79289 8.29295L6.29289 3.79295Z"/>
741
814
  <path d="M11.707 3.79295C11.3164 3.40243 10.6834 3.40243 10.2929 3.79295C9.90237 4.18348 9.90237 4.81649 10.2929 5.20702L14.0859 8.99999L10.2929 12.793C9.90237 13.1835 9.90237 13.8165 10.2929 14.207C10.6834 14.5975 11.3164 14.5975 11.707 14.207L16.207 9.70702C16.5975 9.31649 16.5975 8.68348 16.207 8.29295L11.707 3.79295Z"/>
742
815
  </svg>`,
743
816
 
744
817
  "ul":
745
- `<svg viewBox="0 0 18 18" xmlns="http://www.w3.org/2000/svg">
818
+ `<svg viewBox="0 0 18 18" xmlns="http://www.w3.org/2000/svg" aria-hidden="true">
746
819
  <path d="M3 12.5C3.82843 12.5 4.5 13.1716 4.5 14C4.5 14.8284 3.82843 15.5 3 15.5C2.17157 15.5 1.5 14.8284 1.5 14C1.5 13.1716 2.17157 12.5 3 12.5ZM15.5 13C16.0523 13 16.5 13.4477 16.5 14C16.5 14.5523 16.0523 15 15.5 15H7C6.44772 15 6 14.5523 6 14C6 13.4477 6.44772 13 7 13H15.5ZM3 7.5C3.82843 7.5 4.5 8.17157 4.5 9C4.5 9.82843 3.82843 10.5 3 10.5C2.17157 10.5 1.5 9.82843 1.5 9C1.5 8.17157 2.17157 7.5 3 7.5ZM15.5 8C16.0523 8 16.5 8.44772 16.5 9C16.5 9.55228 16.0523 10 15.5 10H7C6.44772 10 6 9.55228 6 9C6 8.44772 6.44772 8 7 8H15.5ZM3 2.5C3.82843 2.5 4.5 3.17157 4.5 4C4.5 4.82843 3.82843 5.5 3 5.5C2.17157 5.5 1.5 4.82843 1.5 4C1.5 3.17157 2.17157 2.5 3 2.5ZM15.5 3C16.0523 3 16.5 3.44772 16.5 4C16.5 4.55228 16.0523 5 15.5 5H7C6.44772 5 6 4.55228 6 4C6 3.44772 6.44772 3 7 3H15.5Z"/>
747
820
  </svg>`,
748
821
 
749
822
  "ol":
750
- `<svg viewBox="0 0 18 18" xmlns="http://www.w3.org/2000/svg">
823
+ `<svg viewBox="0 0 18 18" xmlns="http://www.w3.org/2000/svg" aria-hidden="true">
751
824
  <path d="M15.5 13C16.0523 13 16.5 13.4477 16.5 14C16.5 14.5523 16.0523 15 15.5 15H7C6.44772 15 6 14.5523 6 14C6 13.4477 6.44772 13 7 13H15.5ZM15.5 8C16.0523 8 16.5 8.44772 16.5 9C16.5 9.55228 16.0523 10 15.5 10H7C6.44772 10 6 9.55228 6 9C6 8.44772 6.44772 8 7 8H15.5ZM15.5 3C16.0523 3 16.5 3.44772 16.5 4C16.5 4.55228 16.0523 5 15.5 5H7C6.44772 5 6 4.55228 6 4C6 3.44772 6.44772 3 7 3H15.5Z"/>
752
825
  <path d="M2.98657 16.0967C2.68042 16.0967 2.41187 16.0465 2.18091 15.9463C1.95174 15.846 1.77002 15.7046 1.63574 15.522C1.50146 15.3376 1.42448 15.1227 1.40479 14.8774L1.4021 14.8452H2.34204L2.34741 14.8748C2.35815 14.9589 2.39038 15.035 2.44409 15.103C2.49959 15.1711 2.5721 15.2248 2.66162 15.2642C2.75293 15.3035 2.86035 15.3232 2.98389 15.3232C3.10563 15.3232 3.21037 15.3027 3.2981 15.2615C3.38761 15.2185 3.45654 15.1603 3.50488 15.0869C3.55322 15.0135 3.57739 14.9294 3.57739 14.8345V14.8291C3.57739 14.6715 3.51921 14.5516 3.40283 14.4692C3.28646 14.3869 3.12085 14.3457 2.90601 14.3457H2.48706V13.677H2.90063C3.02775 13.677 3.13607 13.6582 3.22559 13.6206C3.31689 13.583 3.38672 13.5302 3.43506 13.4622C3.48519 13.3941 3.51025 13.3153 3.51025 13.2258V13.2205C3.51025 13.1256 3.48877 13.0441 3.4458 12.9761C3.40462 12.9062 3.34375 12.8534 3.26318 12.8176C3.18441 12.78 3.08952 12.7612 2.97852 12.7612C2.86572 12.7612 2.76636 12.7809 2.68042 12.8203C2.59627 12.8579 2.52913 12.9125 2.479 12.9841C2.43066 13.054 2.40112 13.1363 2.39038 13.2312L2.3877 13.2581H1.49341L1.49609 13.2205C1.514 12.977 1.58561 12.7666 1.71094 12.5894C1.83805 12.4103 2.00903 12.2725 2.22388 12.1758C2.44051 12.0773 2.69206 12.0281 2.97852 12.0281C3.27393 12.0281 3.52995 12.0728 3.74658 12.1624C3.96322 12.2501 4.13062 12.3727 4.24878 12.5303C4.36694 12.6878 4.42603 12.8722 4.42603 13.0835V13.0889C4.42603 13.2518 4.38932 13.3941 4.31592 13.5159C4.2443 13.6358 4.14762 13.7343 4.02588 13.8113C3.90592 13.8883 3.77254 13.942 3.62573 13.9724V13.9912C3.91756 14.0199 4.14941 14.1121 4.32129 14.2678C4.49316 14.4236 4.5791 14.6295 4.5791 14.8855V14.8909C4.5791 15.1344 4.51375 15.3474 4.38306 15.53C4.25236 15.7109 4.06795 15.8505 3.82983 15.949C3.59172 16.0474 3.31063 16.0967 2.98657 16.0967Z"/>
753
826
  <path d="M1.54443 11V10.342L2.76099 9.20874C2.95076 9.03507 3.09757 8.89274 3.20142 8.78174C3.30705 8.66895 3.37956 8.57316 3.41895 8.49438C3.46012 8.41382 3.48071 8.33415 3.48071 8.25537V8.24463C3.48071 8.14795 3.46012 8.0638 3.41895 7.99219C3.37777 7.92057 3.31779 7.86507 3.23901 7.82568C3.16024 7.7863 3.06714 7.7666 2.95972 7.7666C2.84692 7.7666 2.74756 7.78988 2.66162 7.83643C2.57747 7.88298 2.51123 7.94743 2.46289 8.02979C2.41455 8.11035 2.39038 8.20345 2.39038 8.30908V8.33057L1.48804 8.32788V8.31177C1.48804 8.05396 1.5507 7.82837 1.67603 7.63501C1.80314 7.44165 1.97949 7.29126 2.20508 7.18384C2.43245 7.07463 2.69653 7.02002 2.99731 7.02002C3.28556 7.02002 3.53711 7.06836 3.75195 7.16504C3.96859 7.25993 4.13688 7.39331 4.25684 7.56519C4.37858 7.73706 4.43945 7.93758 4.43945 8.16675V8.18018C4.43945 8.3252 4.40902 8.46932 4.34814 8.61255C4.28727 8.75578 4.18701 8.90885 4.04736 9.07178C3.90771 9.23291 3.71883 9.41642 3.48071 9.62231L2.58374 10.4092L2.85498 9.98486V10.4092L2.58374 10.2319H4.49048V11H1.54443Z"/>
@@ -755,37 +828,37 @@ var ToolbarIcons = {
755
828
  </svg>`,
756
829
 
757
830
  "image":
758
- `<svg viewBox="0 0 18 18" xmlns="http://www.w3.org/2000/svg">
831
+ `<svg viewBox="0 0 18 18" xmlns="http://www.w3.org/2000/svg" aria-hidden="true">
759
832
  <path d="M14 2C15.6569 2 17 3.34315 17 5V13C17 14.6569 15.6569 16 14 16H4C2.34315 16 1 14.6569 1 13V5C1 3.34315 2.34315 2 4 2H14ZM3.06348 13.3496C3.2053 13.7294 3.57078 14 4 14H13.5859L11 11.4141L9.70703 12.707C9.31651 13.0976 8.68349 13.0976 8.29297 12.707C7.90244 12.3165 7.90244 11.6835 8.29297 11.293L8.58594 11L7 9.41406L3.06348 13.3496ZM4 4C3.44772 4 3 4.44772 3 5V10.5859L6.29297 7.29297L6.36914 7.22461C6.76191 6.90427 7.34092 6.92686 7.70703 7.29297L10 9.58594L10.293 9.29297L10.3691 9.22461C10.7619 8.90427 11.3409 8.92686 11.707 9.29297L15 12.5859V5C15 4.44772 14.5523 4 14 4H4ZM12.5 5C13.3284 5 14 5.67157 14 6.5C14 7.32843 13.3284 8 12.5 8C11.6716 8 11 7.32843 11 6.5C11 5.67157 11.6716 5 12.5 5Z"/>
760
833
  </svg>`,
761
834
 
762
835
  "attachment":
763
- `<svg viewBox="0 0 18 18" xmlns="http://www.w3.org/2000/svg">
836
+ `<svg viewBox="0 0 18 18" xmlns="http://www.w3.org/2000/svg" aria-hidden="true">
764
837
  <path d="M13 13.5V6C13 4.067 11.433 2.5 9.5 2.5C7.567 2.5 6 4.067 6 6V13.5C6 14.6046 6.89543 15.5 8 15.5H8.23047C9.20759 15.5 10 14.7076 10 13.7305V7C10 6.72386 9.77614 6.5 9.5 6.5C9.22386 6.5 9 6.72386 9 7V12.5C9 13.0523 8.55228 13.5 8 13.5C7.44772 13.5 7 13.0523 7 12.5V7C7 5.61929 8.11929 4.5 9.5 4.5C10.8807 4.5 12 5.61929 12 7V13.7305C12 15.8122 10.3122 17.5 8.23047 17.5H8C5.79086 17.5 4 15.7091 4 13.5V6C4 2.96243 6.46243 0.5 9.5 0.5C12.5376 0.5 15 2.96243 15 6V13.5C15 14.0523 14.5523 14.5 14 14.5C13.4477 14.5 13 14.0523 13 13.5Z"/>
765
838
  </svg>`,
766
839
 
767
840
  "table":
768
- `<svg viewBox="0 0 18 18" xmlns="http://www.w3.org/2000/svg">
841
+ `<svg viewBox="0 0 18 18" xmlns="http://www.w3.org/2000/svg" aria-hidden="true">
769
842
  <path d="M15 1C16.1046 1 17 1.89543 17 3V15C17 16.1046 16.1046 17 15 17H3C1.89543 17 1 16.1046 1 15V3C1 1.89543 1.89543 1 3 1H15ZM3 15H8V10H3V15ZM10 10V15H15V10H10ZM10 8H15V3H10V8ZM3 8H8V3H3V8Z"/>
770
843
  </svg>`,
771
844
 
772
845
  "hr":
773
- `<svg viewBox="0 0 18 18" xmlns="http://www.w3.org/2000/svg">
846
+ `<svg viewBox="0 0 18 18" xmlns="http://www.w3.org/2000/svg" aria-hidden="true">
774
847
  <path d="M12.75 12C13.1642 12 13.5 12.3358 13.5 12.75V14.25C13.5 14.6642 13.1642 15 12.75 15H5.25C4.83579 15 4.5 14.6642 4.5 14.25V12.75C4.5 12.3358 4.83579 12 5.25 12H12.75ZM15.4863 8C16.0461 8 16.5 8.44771 16.5 9C16.5 9.55229 16.0461 10 15.4863 10H2.51367C1.95392 10 1.5 9.55229 1.5 9C1.5 8.44771 1.95392 8 2.51367 8H15.4863ZM12.75 3C13.1642 3 13.5 3.33579 13.5 3.75V5.25C13.5 5.66421 13.1642 6 12.75 6H5.25C4.83579 6 4.5 5.66421 4.5 5.25V3.75C4.5 3.33579 4.83579 3 5.25 3H12.75Z"/>
775
848
  </svg>`,
776
849
 
777
850
  "undo":
778
- `<svg viewBox="0 0 24 24" xmlns="http://www.w3.org/2000/svg">
851
+ `<svg viewBox="0 0 24 24" xmlns="http://www.w3.org/2000/svg" aria-hidden="true">
779
852
  <path d="M8.36612 5.36612C8.85427 4.87796 9.64554 4.87796 10.1337 5.36612C10.6218 5.85428 10.6218 6.64557 10.1337 7.13369L7.26748 9.9999H15.2499C18.1494 9.99996 20.4999 12.3504 20.4999 15.2499V19.2499C20.4999 19.9402 19.9402 20.4999 19.2499 20.4999C18.5596 20.4999 18 19.9402 17.9999 19.2499V15.2499C17.9999 13.7312 16.7686 12.5 15.2499 12.4999H7.26748L10.1337 15.3661C10.6218 15.8543 10.6218 16.6456 10.1337 17.1337C9.64557 17.6218 8.85428 17.6218 8.36612 17.1337L3.36612 12.1337C2.87796 11.6455 2.87796 10.8543 3.36612 10.3661L8.36612 5.36612Z"/>
780
853
  </svg>`,
781
854
 
782
855
  "redo":
783
- `<svg viewBox="0 0 24 24" xmlns="http://www.w3.org/2000/svg">
856
+ `<svg viewBox="0 0 24 24" xmlns="http://www.w3.org/2000/svg" aria-hidden="true">
784
857
  <path d="M15.6338 5.1163C15.1456 4.62814 14.3543 4.62814 13.8662 5.1163C13.3781 5.60446 13.3781 6.39575 13.8662 6.88388L16.7324 9.75009H8.74997C5.85052 9.75014 3.49997 12.1006 3.49997 15.0001V19.0001C3.50002 19.6904 4.05969 20.25 4.74997 20.2501C5.4403 20.2501 5.99992 19.6904 5.99997 19.0001V15.0001C5.99997 13.4813 7.23123 12.2501 8.74997 12.2501H16.7324L13.8662 15.1163C13.3781 15.6045 13.3781 16.3958 13.8662 16.8839C14.3543 17.372 15.1456 17.3719 15.6338 16.8839L20.6338 11.8839C21.1219 11.3957 21.1219 10.6045 20.6338 10.1163L15.6338 5.1163Z" />
785
858
  </svg>`,
786
859
 
787
860
  "overflow":
788
- `<svg viewBox="0 0 18 18" xmlns="http://www.w3.org/2000/svg">
861
+ `<svg viewBox="0 0 18 18" xmlns="http://www.w3.org/2000/svg" aria-hidden="true">
789
862
  <path d="M3 6.75C4.24264 6.75 5.25 7.75736 5.25 9C5.25 10.2426 4.24264 11.25 3 11.25C1.75736 11.25 0.75 10.2426 0.75 9C0.75 7.75736 1.75736 6.75 3 6.75ZM9 6.75C10.2426 6.75 11.25 7.75736 11.25 9C11.25 10.2426 10.2426 11.25 9 11.25C7.75736 11.25 6.75 10.2426 6.75 9C6.75 7.75736 7.75736 6.75 9 6.75ZM15 6.75C16.2426 6.75 17.25 7.75736 17.25 9C17.25 10.2426 16.2426 11.25 15 11.25C13.7574 11.25 12.75 10.2426 12.75 9C12.75 7.75736 13.7574 6.75 15 6.75Z"/>
790
863
  </svg>`
791
864
  };
@@ -912,14 +985,21 @@ class LexicalToolbarElement extends HTMLElement {
912
985
  }
913
986
  }
914
987
 
915
- #dispatchButtonCommand(event, { dataset: { command, payload } }) {
916
- const isKeyboard = event instanceof PointerEvent && event.pointerId === -1;
988
+ #dispatchButtonCommand(event, button) {
989
+ if (button.ariaDisabled === "true") return
990
+
991
+ const { command, payload } = button.dataset;
992
+ const shouldKeepToolbarFocus = isKeyboardActivation(event) && !this.#belongsToDropdown(button);
917
993
 
918
994
  this.editor.update(() => {
919
995
  this.editor.dispatchCommand(command, payload);
920
- }, { tag: isKeyboard ? SKIP_DOM_SELECTION_TAG : undefined });
996
+ }, { tag: shouldKeepToolbarFocus ? SKIP_DOM_SELECTION_TAG : undefined });
997
+
998
+ if (!shouldKeepToolbarFocus) this.editor.focus();
999
+ }
921
1000
 
922
- if (!isKeyboard) this.editor.focus();
1001
+ #belongsToDropdown(button) {
1002
+ return button.closest("[data-dropdown-panel]") != null
923
1003
  }
924
1004
 
925
1005
  #bindHotkeys() {
@@ -959,8 +1039,8 @@ class LexicalToolbarElement extends HTMLElement {
959
1039
  }
960
1040
 
961
1041
  #handleEditorFocus = () => {
962
- const firstVisible = this.#buttons.find(isActiveAndVisible);
963
- if (firstVisible) firstVisible.tabIndex = 0;
1042
+ const firstVisibleButton = this.#toolbarButtons.find(isActiveAndVisible);
1043
+ if (firstVisibleButton) firstVisibleButton.tabIndex = 0;
964
1044
  }
965
1045
 
966
1046
  #handleEditorBlur = () => {
@@ -968,11 +1048,11 @@ class LexicalToolbarElement extends HTMLElement {
968
1048
  }
969
1049
 
970
1050
  #handleKeydown = (event) => {
971
- handleRollingTabIndex(this.#buttons, event);
1051
+ handleRollingTabIndex(this.#toolbarButtons, event);
972
1052
  }
973
1053
 
974
1054
  #resetTabIndexValues() {
975
- this.#buttons.forEach((button) => {
1055
+ this.#toolbarButtons.forEach((button) => {
976
1056
  button.tabIndex = -1;
977
1057
  });
978
1058
  }
@@ -1009,7 +1089,7 @@ class LexicalToolbarElement extends HTMLElement {
1009
1089
  this.#setButtonPressed("underline", isUnderline);
1010
1090
 
1011
1091
  this.#setButtonPressed("format", isInHeading);
1012
- this.#setButtonPressed("paragraph", !isInHeading);
1092
+ this.#setButtonChecked("paragraph", !isInHeading);
1013
1093
 
1014
1094
  this.querySelector("lexxy-heading-dropdown")
1015
1095
  ?.updateActiveHeading(headingTag);
@@ -1028,25 +1108,17 @@ class LexicalToolbarElement extends HTMLElement {
1028
1108
 
1029
1109
  #setButtonPressed(name, isPressed) {
1030
1110
  const button = this.querySelector(`[name="${name}"]`);
1031
- if (button) {
1032
- const next = isPressed.toString();
1033
- if (button.getAttribute("aria-pressed") !== next) {
1034
- button.setAttribute("aria-pressed", next);
1035
- }
1036
- }
1111
+ if (button) button.ariaPressed = isPressed;
1112
+ }
1113
+
1114
+ #setButtonChecked(name, isChecked) {
1115
+ const button = this.querySelector(`[name="${name}"]`);
1116
+ if (button) button.ariaChecked = isChecked;
1037
1117
  }
1038
1118
 
1039
1119
  #setButtonDisabled(name, isDisabled) {
1040
1120
  const button = this.querySelector(`[name="${name}"]`);
1041
- if (button) {
1042
- if (button.disabled !== isDisabled) {
1043
- button.disabled = isDisabled;
1044
- }
1045
- const next = isDisabled.toString();
1046
- if (button.getAttribute("aria-disabled") !== next) {
1047
- button.setAttribute("aria-disabled", next);
1048
- }
1049
- }
1121
+ if (button) button.ariaDisabled = isDisabled;
1050
1122
  }
1051
1123
 
1052
1124
  #refreshOverflow() {
@@ -1147,8 +1219,10 @@ class LexicalToolbarElement extends HTMLElement {
1147
1219
  return Array.from(this.querySelectorAll(":scope > button:not([data-prevent-overflow])"))
1148
1220
  }
1149
1221
 
1150
- get #buttons() {
1151
- return Array.from(this.querySelectorAll(":scope button"))
1222
+ get #toolbarButtons() {
1223
+ return Array.from(this.querySelectorAll(":scope button")).filter((button) => {
1224
+ return !button.closest("[data-dropdown-panel]")
1225
+ })
1152
1226
  }
1153
1227
 
1154
1228
  get #toolbarItems() {
@@ -1187,8 +1261,8 @@ class LexicalToolbarElement extends HTMLElement {
1187
1261
  <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">
1188
1262
  ${ToolbarIcons.heading}
1189
1263
  </button>
1190
- <div data-dropdown-panel role="menu" class="lexxy-editor__toolbar-dropdown-list" hidden>
1191
- <button type="button" name="paragraph" data-command="setFormatParagraph" title="Paragraph" role="menuitem">
1264
+ <div data-dropdown-panel role="menu" aria-label="Text formatting" class="lexxy-editor__toolbar-dropdown-list" hidden>
1265
+ <button type="button" name="paragraph" data-command="setFormatParagraph" title="Paragraph" role="menuitemradio">
1192
1266
  ${ToolbarIcons.paragraph} <span>Normal</span>
1193
1267
  </button>
1194
1268
  <lexxy-heading-dropdown>
@@ -1205,7 +1279,7 @@ class LexicalToolbarElement extends HTMLElement {
1205
1279
  <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">
1206
1280
  ${ToolbarIcons.highlight}
1207
1281
  </button>
1208
- <div data-dropdown-panel role="menu" hidden>
1282
+ <div data-dropdown-panel role="menu" aria-label="Color highlight" hidden>
1209
1283
  <div class="lexxy-highlight-colors"></div>
1210
1284
  <button data-command="removeHighlight" type="button" class="lexxy-editor__toolbar-button lexxy-editor__toolbar-dropdown-reset" role="menuitem">Remove all coloring</button>
1211
1285
  </div>
@@ -1247,19 +1321,19 @@ class LexicalToolbarElement extends HTMLElement {
1247
1321
  ${ToolbarIcons.hr}
1248
1322
  </button>
1249
1323
 
1250
- <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">
1324
+ <button class="lexxy-editor__toolbar-button lexxy-editor__toolbar-button--push-right" type="button" name="undo" data-command="undo" title="Undo" aria-disabled="true">
1251
1325
  ${ToolbarIcons.undo}
1252
1326
  </button>
1253
1327
 
1254
- <button class="lexxy-editor__toolbar-button" type="button" name="redo" data-command="redo" title="Redo" disabled aria-disabled="true">
1328
+ <button class="lexxy-editor__toolbar-button" type="button" name="redo" data-command="redo" title="Redo" aria-disabled="true">
1255
1329
  ${ToolbarIcons.redo}
1256
1330
  </button>
1257
1331
 
1258
1332
  <lexxy-toolbar-dropdown class="lexxy-editor__toolbar-dropdown lexxy-editor__toolbar-button--push-right lexxy-editor__toolbar-overflow">
1259
- <button data-dropdown-trigger class="lexxy-editor__toolbar-button" type="button" aria-haspopup="menu" aria-expanded="false" aria-label="Show more toolbar buttons">
1333
+ <button data-dropdown-trigger class="lexxy-editor__toolbar-button" type="button" title="More options" aria-haspopup="menu" aria-expanded="false">
1260
1334
  ${ToolbarIcons.overflow}
1261
1335
  </button>
1262
- <div data-dropdown-panel role="menu" class="lexxy-editor__toolbar-overflow-menu" aria-label="More toolbar buttons" hidden></div>
1336
+ <div data-dropdown-panel role="menu" class="lexxy-editor__toolbar-overflow-menu" aria-label="More options" hidden></div>
1263
1337
  </lexxy-toolbar-dropdown>
1264
1338
  `
1265
1339
  }
@@ -1303,9 +1377,10 @@ function nextFrame() {
1303
1377
 
1304
1378
  class ToolbarDropdown extends HTMLElement {
1305
1379
  #listeners = new ListenerBin()
1380
+ #shouldReturnFocusToTrigger = false
1306
1381
 
1307
1382
  connectedCallback() {
1308
- this.#onToolbarEditor(() => {
1383
+ this.#onHostEditor(() => {
1309
1384
  this.#registerListeners();
1310
1385
  this.editorReady();
1311
1386
  });
@@ -1327,16 +1402,16 @@ class ToolbarDropdown extends HTMLElement {
1327
1402
  return this.querySelector(":scope > [data-dropdown-panel]")
1328
1403
  }
1329
1404
 
1330
- get toolbar() {
1331
- return this.closest("lexxy-toolbar")
1405
+ get host() {
1406
+ return this.closest("lexxy-toolbar, lexxy-table-tools")
1332
1407
  }
1333
1408
 
1334
1409
  get editorElement() {
1335
- return this.toolbar?.editorElement
1410
+ return this.host?.editorElement
1336
1411
  }
1337
1412
 
1338
1413
  get editor() {
1339
- return this.toolbar?.editor
1414
+ return this.host?.editor
1340
1415
  }
1341
1416
 
1342
1417
  get isOpen() {
@@ -1353,7 +1428,7 @@ class ToolbarDropdown extends HTMLElement {
1353
1428
 
1354
1429
  open() {
1355
1430
  if (this.isOpen) return
1356
- this.trigger.setAttribute("aria-expanded", "true");
1431
+ this.trigger.ariaExpanded = true;
1357
1432
  this.panel.hidden = false;
1358
1433
  this.onOpen();
1359
1434
  this.#focusFirstInteractive();
@@ -1363,7 +1438,7 @@ class ToolbarDropdown extends HTMLElement {
1363
1438
  if (focusEditor) this.editor?.focus();
1364
1439
 
1365
1440
  if (this.isClosed) return
1366
- this.trigger.setAttribute("aria-expanded", "false");
1441
+ this.trigger.ariaExpanded = false;
1367
1442
  this.panel.hidden = true;
1368
1443
  this.onClose();
1369
1444
  }
@@ -1375,32 +1450,49 @@ class ToolbarDropdown extends HTMLElement {
1375
1450
  );
1376
1451
  }
1377
1452
 
1378
- #handleTriggerClick = () => {
1453
+ #handleTriggerClick = (event) => {
1379
1454
  if (this.isOpen) {
1380
1455
  this.close({ focusEditor: false });
1381
1456
  } else {
1382
- this.toolbar?.closeDropdowns({ except: this });
1457
+ this.#shouldReturnFocusToTrigger = this.#isOpenedFromHost(event);
1458
+ this.host?.closeDropdowns({ except: this });
1383
1459
  this.open();
1384
1460
  }
1385
1461
  }
1386
1462
 
1387
- async #onToolbarEditor(callback) {
1388
- if (!this.toolbar) return
1463
+ #isOpenedFromHost(event) {
1464
+ return isKeyboardActivation(event) && this.host?.contains(document.activeElement)
1465
+ }
1466
+
1467
+ async #onHostEditor(callback) {
1468
+ if (!this.host) return
1389
1469
 
1390
- await this.toolbar.getEditorElement();
1391
- if (this.isConnected && this.toolbar) callback();
1470
+ await this.host.getEditorElement();
1471
+ if (this.isConnected && this.host) callback();
1392
1472
  }
1393
1473
 
1394
1474
  #handleKeyDown = (event) => {
1395
- if (event.key === "Escape") {
1475
+ if (event.key === "Escape" && this.isOpen) {
1476
+ event.stopPropagation();
1477
+ this.close({ focusEditor: !this.#shouldReturnFocusToTrigger });
1478
+ if (this.#shouldReturnFocusToTrigger) this.trigger?.focus();
1479
+ } else if (this.isOpen && this.panel.role === "dialog") {
1480
+ trapFocusAtTabBoundary(this.panel, event);
1481
+ } else if (this.#isNavigatingMenu(event)) {
1396
1482
  event.stopPropagation();
1397
- this.close();
1483
+ handleRollingTabIndex(this.#buttons, event, { orientation: "both", wrap: true });
1398
1484
  }
1399
1485
  }
1400
1486
 
1487
+ #isNavigatingMenu(event) {
1488
+ return this.panel.role === "menu" && this.panel.contains(event.target)
1489
+ }
1490
+
1401
1491
  async #focusFirstInteractive() {
1402
- this.#interactiveElements[0]?.focus();
1403
- await this.#resetTabIndexValues();
1492
+ // Ask for the ring explicitly: opening the menu with the mouse otherwise leaves the
1493
+ // first item focused without a focus ring, since focus() inherits the mouse modality.
1494
+ this.#interactiveElements[0]?.focus({ focusVisible: true });
1495
+ if (this.panel.role === "menu") await this.#resetTabIndexValues();
1404
1496
  }
1405
1497
 
1406
1498
  async #resetTabIndexValues() {
@@ -1482,8 +1574,8 @@ class HeadingDropdown extends HTMLElement {
1482
1574
  updateActiveHeading(tag) {
1483
1575
  this.#headingButtons.forEach(button => {
1484
1576
  const next = (button.dataset.heading === tag).toString();
1485
- if (button.getAttribute("aria-pressed") !== next) {
1486
- button.setAttribute("aria-pressed", next);
1577
+ if (button.getAttribute("aria-checked") !== next) {
1578
+ button.setAttribute("aria-checked", next);
1487
1579
  }
1488
1580
  });
1489
1581
  }
@@ -1528,7 +1620,7 @@ class HeadingDropdown extends HTMLElement {
1528
1620
  button.classList.add("lexxy-heading-button");
1529
1621
  button.name = name;
1530
1622
  button.title = label;
1531
- button.setAttribute("role", "menuitem");
1623
+ button.setAttribute("role", "menuitemradio");
1532
1624
  button.innerHTML = `${icon} <span>${label}</span>`;
1533
1625
  return button
1534
1626
  }
@@ -1563,6 +1655,143 @@ class HeadingDropdown extends HTMLElement {
1563
1655
  }
1564
1656
  }
1565
1657
 
1658
+ // Shared, strictly-contained element used to attach ephemeral nodes when we
1659
+ // need to read computed styles (e.g. canonicalizing style values, resolving
1660
+ // CSS custom properties). The container is created once and attached to
1661
+ // `document.body` once; subsequent child mutations happen *inside* the
1662
+ // contained subtree so they do not invalidate style on the rest of the page.
1663
+ //
1664
+ // Without this, `document.body.appendChild(...)` / `element.remove()` calls
1665
+ // forced the browser to re-evaluate every ancestor-dependent selector (`:has()`,
1666
+ // descendant combinators, universal sibling rules) across the document on each
1667
+ // invocation — a 13,000+ element style recalc per call on a typical Basecamp
1668
+ // page.
1669
+
1670
+ let resolverRoot = null;
1671
+
1672
+ function styleResolverRoot() {
1673
+ if (resolverRoot && resolverRoot.isConnected) return resolverRoot
1674
+
1675
+ resolverRoot = document.createElement("div");
1676
+ resolverRoot.setAttribute("aria-hidden", "true");
1677
+ resolverRoot.setAttribute("data-lexxy-style-resolver", "");
1678
+ // `contain: strict` (size, layout, paint, style) isolates everything.
1679
+ // The root itself paints nothing (visibility hidden), has zero
1680
+ // geometric impact (position fixed, intrinsic size via contain), and
1681
+ // never leaks style invalidation to its ancestors.
1682
+ resolverRoot.style.cssText = "contain: strict; position: fixed; top: 0; left: 0; visibility: hidden; pointer-events: none; width: 0; height: 0;";
1683
+ document.body.appendChild(resolverRoot);
1684
+ return resolverRoot
1685
+ }
1686
+
1687
+ function isSelectionHighlighted(selection) {
1688
+ if (!$isRangeSelection(selection)) return false
1689
+
1690
+ if (selection.isCollapsed()) {
1691
+ return hasHighlightStyles(selection.style)
1692
+ } else {
1693
+ return selection.hasFormat("highlight")
1694
+ }
1695
+ }
1696
+
1697
+ function getHighlightStyles(selection) {
1698
+ if (!$isRangeSelection(selection)) return null
1699
+
1700
+ let styles = getStyleObjectFromCSS(selection.style);
1701
+ if (!styles.color && !styles["background-color"]) {
1702
+ const anchorNode = selection.anchor.getNode();
1703
+ if ($isTextNode(anchorNode)) {
1704
+ styles = getStyleObjectFromCSS(anchorNode.getStyle());
1705
+ }
1706
+ }
1707
+
1708
+ const color = styles.color || null;
1709
+ const backgroundColor = styles["background-color"] || null;
1710
+ if (!color && !backgroundColor) return null
1711
+
1712
+ return { color, backgroundColor }
1713
+ }
1714
+
1715
+ function hasHighlightStyles(cssOrStyles) {
1716
+ const styles = typeof cssOrStyles === "string" ? getStyleObjectFromCSS(cssOrStyles) : cssOrStyles;
1717
+ return !!(styles.color || styles["background-color"])
1718
+ }
1719
+
1720
+ function normalizeColorButtons(buttons) {
1721
+ return buttons.map((button) => (typeof button === "string" ? { value: button, label: button } : button))
1722
+ }
1723
+
1724
+ function applyCanonicalizers(styles, canonicalizers = []) {
1725
+ return canonicalizers.reduce((css, canonicalizer) => {
1726
+ return canonicalizer.applyCanonicalization(css)
1727
+ }, styles)
1728
+ }
1729
+
1730
+ class StyleCanonicalizer {
1731
+ constructor(property, allowedValues= []) {
1732
+ this._property = property;
1733
+ this._allowedValues = allowedValues;
1734
+ this._canonicalValues = this.#allowedValuesIdentityObject;
1735
+ }
1736
+
1737
+ applyCanonicalization(css) {
1738
+ const styles = { ...getStyleObjectFromCSS(css) };
1739
+
1740
+ styles[this._property] = this.getCanonicalAllowedValue(styles[this._property]);
1741
+ if (!styles[this._property]) {
1742
+ delete styles[this._property];
1743
+ }
1744
+
1745
+ return getCSSFromStyleObject(styles)
1746
+ }
1747
+
1748
+ getCanonicalAllowedValue(value) {
1749
+ return this._canonicalValues[value] ||= this.#resolveCannonicalValue(value)
1750
+ }
1751
+
1752
+ // Private
1753
+
1754
+ get #allowedValuesIdentityObject() {
1755
+ return this._allowedValues.reduce((object, value) => ({ ...object, [value]: value }), {})
1756
+ }
1757
+
1758
+ #resolveCannonicalValue(value) {
1759
+ let index = this.#computedAllowedValues.indexOf(value);
1760
+ if (index === -1) {
1761
+ index = this.#computedAllowedValues.indexOf(computeStyleValues(this._property, [ value ])[0]);
1762
+ }
1763
+ return index === -1 ? null : this._allowedValues[index]
1764
+ }
1765
+
1766
+ get #computedAllowedValues() {
1767
+ return this._computedAllowedValues ||= computeStyleValues(this._property, this._allowedValues)
1768
+ }
1769
+ }
1770
+
1771
+ // Separates DOM writes from layout reads to avoid forced reflows, and attaches
1772
+ // resolver elements to a strictly-contained root (outside the normal document
1773
+ // flow) so neither the attach nor the detach invalidate styles on the rest of
1774
+ // the page. Without containment, appending to `document.body` triggered a
1775
+ // page-wide style recalc on every canonicalization pass.
1776
+ function computeStyleValues(property, values) {
1777
+ const fragment = document.createDocumentFragment();
1778
+
1779
+ const elements = values.map(value => {
1780
+ const element = createElement("span", { style: `display: none; ${property}: ${value};` });
1781
+ fragment.appendChild(element);
1782
+ return element
1783
+ });
1784
+
1785
+ styleResolverRoot().appendChild(fragment);
1786
+
1787
+ const computed = elements.map(element =>
1788
+ window.getComputedStyle(element).getPropertyValue(property)
1789
+ );
1790
+
1791
+ elements.forEach(element => element.remove());
1792
+ return computed
1793
+ }
1794
+
1566
1795
  const APPLY_HIGHLIGHT_SELECTOR = "button.lexxy-highlight-button";
1567
1796
  const REMOVE_HIGHLIGHT_SELECTOR = "[data-command='removeHighlight']";
1568
1797
 
@@ -1594,27 +1823,36 @@ class HighlightDropdown extends ToolbarDropdown {
1594
1823
 
1595
1824
  const colorGroups = this.editorElement.config.get("highlight.buttons");
1596
1825
 
1597
- this.#populateButtonGroup("color", colorGroups.color);
1598
- this.#populateButtonGroup("background-color", colorGroups["background-color"]);
1826
+ this.#appendColorGroup("Text color", "color", colorGroups.color);
1827
+ this.#appendColorGroup("Background color", "background-color", colorGroups["background-color"]);
1599
1828
 
1600
1829
  const maxNumberOfColors = Math.max(colorGroups.color.length, colorGroups["background-color"].length);
1601
1830
  this.panel.style.setProperty("--max-colors", maxNumberOfColors);
1602
1831
  }
1603
1832
 
1604
- #populateButtonGroup(attribute, values) {
1605
- values.forEach((value, index) => {
1606
- this.#buttonContainer.appendChild(this.#createButton(attribute, value, index));
1833
+ #appendColorGroup(label, attribute, buttons) {
1834
+ const group = createElement("div", {
1835
+ role: "group",
1836
+ class: "lexxy-highlight-colors__group",
1837
+ "aria-label": label
1838
+ });
1839
+
1840
+ normalizeColorButtons(buttons).forEach((button, index) => {
1841
+ group.appendChild(this.#createButton(attribute, button, index));
1607
1842
  });
1843
+
1844
+ this.#buttonContainer.append(group);
1608
1845
  }
1609
1846
 
1610
- #createButton(attribute, value, index) {
1847
+ #createButton(attribute, button, index) {
1611
1848
  return createElement("button", {
1612
1849
  type: "button",
1613
- dataset: { value, style: attribute },
1614
- style: `${attribute}: ${value}`,
1850
+ dataset: { value: button.value, style: attribute },
1851
+ style: `${attribute}: ${button.value}`,
1615
1852
  class: "lexxy-editor__toolbar-button lexxy-highlight-button",
1616
1853
  name: `${attribute}-${index}`,
1617
- role: "menuitem"
1854
+ role: "menuitemcheckbox",
1855
+ "aria-label": button.label
1618
1856
  })
1619
1857
  }
1620
1858
 
@@ -1638,11 +1876,7 @@ class HighlightDropdown extends ToolbarDropdown {
1638
1876
  const backgroundColor = $getSelectionStyleValueForProperty(selection, "background-color", NO_STYLE);
1639
1877
 
1640
1878
  this.#colorButtons.forEach(button => {
1641
- const matchesSelection = button.dataset.value === textColor || button.dataset.value === backgroundColor;
1642
- const next = matchesSelection.toString();
1643
- if (button.getAttribute("aria-pressed") !== next) {
1644
- button.setAttribute("aria-pressed", next);
1645
- }
1879
+ button.ariaChecked = button.dataset.value === textColor || button.dataset.value === backgroundColor;
1646
1880
  });
1647
1881
 
1648
1882
  const hasHighlight = textColor !== NO_STYLE || backgroundColor !== NO_STYLE;
@@ -1939,13 +2173,35 @@ class CustomActionTextAttachmentNode extends DecoratorNode {
1939
2173
  // is cosmetic: it is a nested attachment's rendering inside this one, not the
1940
2174
  // attribute anything re-imports from.
1941
2175
  figure.insertAdjacentHTML("beforeend", EditorSanitizer.for(editor).sanitize(this.innerHtml, { safeForXml: true }));
1942
-
1943
- const deleteButton = createElement("lexxy-node-delete-button");
1944
- figure.appendChild(deleteButton);
2176
+ this.#markImagesAsDecorative(figure);
2177
+ this.#tagLabelImage(figure);
2178
+ this.#tagLabelMirrors(figure);
1945
2179
 
1946
2180
  return figure
1947
2181
  }
1948
2182
 
2183
+ exposeLabel(figure) {
2184
+ const labelImage = figure.querySelector("[data-lexxy-label-image]");
2185
+ if (labelImage) labelImage.alt = this.label;
2186
+
2187
+ for (const span of figure.querySelectorAll("[data-lexxy-label-mirror]")) {
2188
+ span.setAttribute("aria-hidden", "true");
2189
+ }
2190
+ }
2191
+
2192
+ get label() {
2193
+ return this.getReadableTextContent()
2194
+ }
2195
+
2196
+ restoreLabel(figure) {
2197
+ const labelImage = figure.querySelector("[data-lexxy-label-image]");
2198
+ if (labelImage) labelImage.alt = labelImage.dataset.lexxyOriginalAlt;
2199
+
2200
+ for (const span of figure.querySelectorAll("[data-lexxy-label-mirror]")) {
2201
+ span.removeAttribute("aria-hidden");
2202
+ }
2203
+ }
2204
+
1949
2205
  updateDOM() {
1950
2206
  return false
1951
2207
  }
@@ -1986,6 +2242,55 @@ class CustomActionTextAttachmentNode extends DecoratorNode {
1986
2242
  decorate() {
1987
2243
  return null
1988
2244
  }
2245
+
2246
+ #markImagesAsDecorative(figure) {
2247
+ for (const img of figure.querySelectorAll("img:not([alt])")) {
2248
+ img.alt = "";
2249
+ }
2250
+ }
2251
+
2252
+ // Images injected later by other modules must not become the announcement image.
2253
+ #tagLabelImage(figure) {
2254
+ const image = figure.querySelector("img");
2255
+ if (image) {
2256
+ image.setAttribute("data-lexxy-label-image", "");
2257
+ image.dataset.lexxyOriginalAlt = image.alt;
2258
+ }
2259
+ }
2260
+
2261
+ // Duplicate labels would be spoken twice. Only authored spans should be silenced,
2262
+ // since the fake selection injects its own label later.
2263
+ #tagLabelMirrors(figure) {
2264
+ const trimmedLabel = this.label.trim();
2265
+ const matches = [ ...figure.querySelectorAll("span") ].filter((span) => span.textContent.trim() === trimmedLabel);
2266
+ const deepest = matches.filter((span) => !matches.some((other) => other !== span && span.contains(other)));
2267
+ const labelImage = figure.querySelector("[data-lexxy-label-image]");
2268
+
2269
+ for (const span of deepest) {
2270
+ if (span.getAttribute("aria-hidden") !== "true") {
2271
+ if (span.contains(labelImage)) {
2272
+ this.#wrapLabelTextNodesIn(span);
2273
+ } else {
2274
+ span.setAttribute("data-lexxy-label-mirror", "");
2275
+ }
2276
+ }
2277
+ }
2278
+ }
2279
+
2280
+ #wrapLabelTextNodesIn(element) {
2281
+ const walker = document.createTreeWalker(element, NodeFilter.SHOW_TEXT);
2282
+ const textNodes = [];
2283
+
2284
+ while (walker.nextNode()) {
2285
+ if (walker.currentNode.textContent.trim()) textNodes.push(walker.currentNode);
2286
+ }
2287
+
2288
+ for (const textNode of textNodes) {
2289
+ const mirror = createElement("span", { "data-lexxy-label-mirror": "" });
2290
+ textNode.replaceWith(mirror);
2291
+ mirror.append(textNode);
2292
+ }
2293
+ }
1989
2294
  }
1990
2295
 
1991
2296
  function $isCustomActionTextAttachmentNode(node) {
@@ -2089,16 +2394,45 @@ function $createNodeSelectionWith(...nodes) {
2089
2394
  return selection
2090
2395
  }
2091
2396
 
2092
- function $isShadowRoot(node) {
2093
- return $isElementNode(node) && $isRootOrShadowRoot(node) && !$isRootNode(node)
2397
+ function $singleSelectedNode() {
2398
+ const selection = $getSelection();
2399
+ if ($isNodeSelection(selection)) {
2400
+ const nodes = selection.getNodes();
2401
+ if (nodes.length === 1) {
2402
+ return nodes[0]
2403
+ } else {
2404
+ return null
2405
+ }
2406
+ } else {
2407
+ return null
2408
+ }
2094
2409
  }
2095
2410
 
2096
- function $isSafeForRoot(node) {
2097
- return ($isElementNode(node) || $isDecoratorNode(node)) && !node.isParentRequired()
2411
+ function $selectedLabelledDecoratorNode() {
2412
+ const node = $singleSelectedNode();
2413
+ if (typeof node?.label === "string") {
2414
+ return { key: node.getKey(), label: node.label }
2415
+ } else {
2416
+ return null
2417
+ }
2098
2418
  }
2099
2419
 
2100
- function $makeSafeForRoot(node) {
2101
- if ($isSafeForRoot(node)) {
2420
+ function registerLabelledDecoratorSelection(editor, onChange) {
2421
+ return editor.registerUpdateListener(({ editorState }) => {
2422
+ onChange(editorState.read($selectedLabelledDecoratorNode));
2423
+ })
2424
+ }
2425
+
2426
+ function $isShadowRoot(node) {
2427
+ return $isElementNode(node) && $isRootOrShadowRoot(node) && !$isRootNode(node)
2428
+ }
2429
+
2430
+ function $isSafeForRoot(node) {
2431
+ return ($isElementNode(node) || $isDecoratorNode(node)) && !node.isParentRequired()
2432
+ }
2433
+
2434
+ function $makeSafeForRoot(node) {
2435
+ if ($isSafeForRoot(node)) {
2102
2436
  return node
2103
2437
  } else if (node.getParent()) {
2104
2438
  return $wrapNodeInElement(node, () => node.createParentElementNode())
@@ -2114,6 +2448,10 @@ function getListType(node) {
2114
2448
  return list?.getListType() ?? null
2115
2449
  }
2116
2450
 
2451
+ function announceFromEditor(editor, message) {
2452
+ editor.getRootElement()?.closest("lexxy-editor")?.announce(message);
2453
+ }
2454
+
2117
2455
  function isEditorFocused(editor) {
2118
2456
  const rootElement = editor.getRootElement();
2119
2457
  return rootElement !== null && rootElement.contains(document.activeElement)
@@ -2689,7 +3027,7 @@ class ActionTextAttachmentNode extends DecoratorNode {
2689
3027
  node: new ActionTextAttachmentNode({
2690
3028
  src: img.getAttribute("src"),
2691
3029
  fileName: fileName,
2692
- caption: img.getAttribute("alt") || "",
3030
+ altText: img.getAttribute("alt"),
2693
3031
  contentType: "image/*",
2694
3032
  width: img.getAttribute("width"),
2695
3033
  height: img.getAttribute("height")
@@ -2766,14 +3104,27 @@ class ActionTextAttachmentNode extends DecoratorNode {
2766
3104
  updateDOM(prevNode, dom) {
2767
3105
  if (this.uploadError !== prevNode.uploadError) return true
2768
3106
 
2769
- const caption = dom.querySelector("figcaption textarea");
2770
- if (caption && this.caption) {
2771
- caption.value = this.caption;
3107
+ const image = dom.querySelector("img");
3108
+ if (image && prevNode.altText !== this.altText) {
3109
+ image.alt = this.altText;
3110
+ }
3111
+
3112
+ if (prevNode.caption !== this.caption) {
3113
+ dom.querySelector("figcaption")?.toggleAttribute("data-placeholder", !this.caption);
3114
+ }
3115
+
3116
+ const captionText = dom.querySelector("figcaption .attachment__caption-text");
3117
+ if (captionText && prevNode.captionLabel !== this.captionLabel) {
3118
+ captionText.textContent = this.captionLabel;
2772
3119
  }
2773
3120
 
2774
3121
  return false
2775
3122
  }
2776
3123
 
3124
+ get captionLabel() {
3125
+ return this.caption || this.fileName || ""
3126
+ }
3127
+
2777
3128
  getTextContent() {
2778
3129
  return `[${this.caption || this.fileName}]\n\n`
2779
3130
  }
@@ -2835,9 +3186,6 @@ class ActionTextAttachmentNode extends DecoratorNode {
2835
3186
  figure.draggable = true;
2836
3187
  figure.dataset.lexicalNodeKey = this.__key;
2837
3188
 
2838
- const deleteButton = createElement("lexxy-node-delete-button");
2839
- figure.appendChild(deleteButton);
2840
-
2841
3189
  return figure
2842
3190
  }
2843
3191
 
@@ -2853,6 +3201,18 @@ class ActionTextAttachmentNode extends DecoratorNode {
2853
3201
  return this.contentType.startsWith("video/")
2854
3202
  }
2855
3203
 
3204
+ get label() {
3205
+ if (this.caption && this.altText && this.altText !== this.caption && this.altText !== this.fileName) {
3206
+ return `${this.caption}. ${this.altText}`
3207
+ } else {
3208
+ return this.caption || this.altText || this.fileName || ""
3209
+ }
3210
+ }
3211
+
3212
+ focusCaption() {
3213
+ return this.editor.getRootElement()?.closest("lexxy-editor")?.captionEditor.open(this.getKey()) ?? false
3214
+ }
3215
+
2856
3216
  #createDOMForPendingPreview() {
2857
3217
  const figure = this.createAttachmentFigure(false);
2858
3218
  figure.appendChild(this.#createDOMForFile());
@@ -2992,8 +3352,9 @@ class ActionTextAttachmentNode extends DecoratorNode {
2992
3352
  }
2993
3353
 
2994
3354
  #swapToPreviewDOM(figure, previewSrc) {
3355
+ const altText = this.editor.read(() => this.getLatest().altText);
2995
3356
  this.#swapFigureContent(figure, "attachment--file", "attachment--preview", () => {
2996
- const img = createElement("img", { src: previewSrc, draggable: false, alt: this.altText });
3357
+ const img = createElement("img", { src: previewSrc, draggable: false, alt: altText });
2997
3358
  img.onerror = () => this.#swapPreviewToFileDOM(img);
2998
3359
  const container = createElement("div", { className: "attachment__container" });
2999
3360
  container.appendChild(img);
@@ -3043,54 +3404,17 @@ class ActionTextAttachmentNode extends DecoratorNode {
3043
3404
  }
3044
3405
 
3045
3406
  #createEditableCaption() {
3046
- const caption = createElement("figcaption", { className: "attachment__caption" });
3047
- const input = createElement("textarea", {
3048
- value: this.caption,
3049
- placeholder: this.fileName,
3050
- rows: "1"
3407
+ const caption = createElement("figcaption", { className: "attachment__caption attachment__caption--editable" });
3408
+ const text = createElement("span", { className: "attachment__caption-text", textContent: this.captionLabel });
3409
+ caption.appendChild(text);
3410
+ caption.toggleAttribute("data-placeholder", !this.caption);
3411
+ caption.addEventListener("mousedown", event => event.preventDefault());
3412
+ caption.addEventListener("click", event => {
3413
+ event.stopPropagation();
3414
+ this.focusCaption();
3051
3415
  });
3052
-
3053
- input.addEventListener("focusin", () => input.placeholder = "Add caption...");
3054
- input.addEventListener("blur", (event) => this.#handleCaptionInputBlurred(event));
3055
- input.addEventListener("keydown", (event) => this.#handleCaptionInputKeydown(event));
3056
- input.addEventListener("copy", (event) => event.stopPropagation());
3057
- input.addEventListener("cut", (event) => event.stopPropagation());
3058
- input.addEventListener("paste", (event) => event.stopPropagation());
3059
-
3060
- caption.appendChild(input);
3061
-
3062
3416
  return caption
3063
3417
  }
3064
-
3065
- #handleCaptionInputBlurred(event) {
3066
- this.#updateCaptionValueFromInput(event.target);
3067
- }
3068
-
3069
- #updateCaptionValueFromInput(input) {
3070
- input.placeholder = this.fileName;
3071
- this.editor.update(() => {
3072
- this.getWritable().caption = input.value;
3073
- });
3074
- }
3075
-
3076
- #handleCaptionInputKeydown(event) {
3077
- if (event.key === "Enter") {
3078
- event.preventDefault();
3079
- event.target.blur();
3080
-
3081
- this.editor.update(() => {
3082
- // Place the cursor after the current image
3083
- this.selectNext(0, 0);
3084
- }, {
3085
- tag: HISTORY_MERGE_TAG
3086
- });
3087
- }
3088
-
3089
- // Stop all keydown events from bubbling to the Lexical root element.
3090
- // The caption textarea is outside Lexical's content model and should
3091
- // handle its own keyboard events natively (Ctrl+A, Ctrl+C, Ctrl+X, etc.).
3092
- event.stopPropagation();
3093
- }
3094
3418
  }
3095
3419
 
3096
3420
  function $createActionTextAttachmentNode(...args) {
@@ -3210,14 +3534,11 @@ class HorizontalDividerNode extends DecoratorNode {
3210
3534
  super(key);
3211
3535
  }
3212
3536
 
3537
+ // The hr already provides separator semantics; a figure boundary would be redundant.
3213
3538
  createDOM() {
3214
- const figure = createElement("figure", { className: "horizontal-divider" });
3215
- const hr = createElement("hr");
3216
-
3217
- figure.appendChild(hr);
3218
-
3219
- const deleteButton = createElement("lexxy-node-delete-button");
3220
- figure.appendChild(deleteButton);
3539
+ const figure = createElement("figure", { className: "horizontal-divider", role: "presentation" });
3540
+ figure.appendChild(createElement("hr"));
3541
+ figure.dataset.lexicalNodeKey = this.__key;
3221
3542
 
3222
3543
  return figure
3223
3544
  }
@@ -3234,6 +3555,10 @@ class HorizontalDividerNode extends DecoratorNode {
3234
3555
  return false
3235
3556
  }
3236
3557
 
3558
+ get label() {
3559
+ return "Horizontal divider"
3560
+ }
3561
+
3237
3562
  exportDOM() {
3238
3563
  const hr = createElement("hr");
3239
3564
  return { element: hr }
@@ -3499,189 +3824,366 @@ var theme = {
3499
3824
  }
3500
3825
  };
3501
3826
 
3502
- class UploadRequests {
3503
- #requestsByKey = new Map()
3504
-
3505
- track(key, request) {
3506
- this.#requestsByKey.set(key, request);
3827
+ class ImageGalleryNode extends ElementNode {
3828
+ $config() {
3829
+ return this.config("image_gallery", {
3830
+ extends: ElementNode,
3831
+ })
3507
3832
  }
3508
3833
 
3509
- forget(key) {
3510
- this.#requestsByKey.delete(key);
3834
+ static transform() {
3835
+ return (gallery) => {
3836
+ gallery.unwrapEmptyNode()
3837
+ || gallery.replaceWithSingularChild()
3838
+ || gallery.splitAroundInvalidChild();
3839
+ }
3511
3840
  }
3512
3841
 
3513
- abort(key) {
3514
- const request = this.#requestsByKey.get(key);
3515
- if (request) {
3516
- this.#requestsByKey.delete(key);
3517
- request.abort();
3842
+ static importDOM() {
3843
+ return {
3844
+ div: (element) => {
3845
+ if (!this.#isGalleryElement(element)) return null
3846
+
3847
+ return {
3848
+ conversion: () => {
3849
+ return {
3850
+ node: $createImageGalleryNode()
3851
+ }
3852
+ },
3853
+ priority: 2
3854
+ }
3855
+ }
3518
3856
  }
3519
3857
  }
3520
3858
 
3521
- clear() {
3522
- this.#requestsByKey.clear();
3859
+ static canCollapseWith(node) {
3860
+ return $isImageGalleryNode(node) || this.isValidChild(node)
3523
3861
  }
3524
- }
3525
3862
 
3526
- // Partition a stretch of text into consecutive { start, end, range } segments,
3527
- // where offsets are relative to the text and range is the covering source
3528
- // range — expressed in outer coordinates as { start, end, ... } — or null for
3529
- // the stretches no range covers. Ranges must be sorted and non-overlapping.
3530
- function segmentTextByRanges(text, textStart, ranges) {
3531
- const segments = [];
3532
- let cursor = 0;
3863
+ static isValidChild(node) {
3864
+ return $isActionTextAttachmentNode(node) && node.isPreviewableImage
3865
+ }
3533
3866
 
3534
- for (const range of ranges) {
3535
- const from = Math.max(range.start - textStart, cursor);
3536
- const to = Math.min(range.end - textStart, text.length);
3867
+ static #isGalleryElement(element) {
3868
+ const attachmentChildren = element.querySelectorAll(`:scope > :is(${this.#attachmentTags.join()})`);
3869
+ return element.textContent.trim() === ""
3870
+ && attachmentChildren.length > 0
3871
+ && element.children.length === attachmentChildren.length
3872
+ }
3537
3873
 
3538
- if (from < to) {
3539
- if (from > cursor) {
3540
- segments.push({ start: cursor, end: from, range: null });
3541
- }
3542
- segments.push({ start: from, end: to, range });
3543
- cursor = to;
3874
+ static get #attachmentTags() {
3875
+ return Object.keys(ActionTextAttachmentNode.importDOM())
3876
+ }
3877
+
3878
+ createDOM() {
3879
+ const div = document.createElement("div");
3880
+ div.className = this.#galleryClassNames;
3881
+ return div
3882
+ }
3883
+
3884
+ updateDOM(_prevNode, dom) {
3885
+ dom.className = this.#galleryClassNames;
3886
+ return false
3887
+ }
3888
+
3889
+ canBeEmpty() {
3890
+ // Return `true` to conform to `$isBlock(node)`
3891
+ // We clean-up empty galleries with a transform
3892
+ return true
3893
+ }
3894
+
3895
+ collapseAtStart(_selection) {
3896
+ return true
3897
+ }
3898
+
3899
+ insertNewAfter(selection, restoreSelection) {
3900
+ const selectionBeforeLastChild = selection.anchor.getNode().is(this) && selection.anchor.offset == this.getChildrenSize() - 1;
3901
+ if (selectionBeforeLastChild) {
3902
+ const paragraph = $createParagraphNode();
3903
+ this.insertAfter(paragraph, false);
3904
+ paragraph.insertAfter(this.getLastChild(), false);
3905
+ paragraph.selectEnd();
3906
+
3907
+ // return null as selection has been managed
3908
+ return null
3544
3909
  }
3910
+
3911
+ const newNode = $createImageGalleryNode();
3912
+ this.insertAfter(newNode, restoreSelection);
3913
+ return newNode
3545
3914
  }
3546
3915
 
3547
- if (cursor < text.length) {
3548
- segments.push({ start: cursor, end: text.length, range: null });
3916
+ getImageAttachments() {
3917
+ const children = this.getChildren();
3918
+ return children.filter($isActionTextAttachmentNode)
3549
3919
  }
3550
3920
 
3551
- return segments
3552
- }
3921
+ exportDOM() {
3922
+ const div = document.createElement("div");
3923
+ div.className = this.#galleryClassNames;
3924
+ return { element: div }
3925
+ }
3553
3926
 
3554
- // Shared, strictly-contained element used to attach ephemeral nodes when we
3555
- // need to read computed styles (e.g. canonicalizing style values, resolving
3556
- // CSS custom properties). The container is created once and attached to
3557
- // `document.body` once; subsequent child mutations happen *inside* the
3558
- // contained subtree so they do not invalidate style on the rest of the page.
3559
- //
3560
- // Without this, `document.body.appendChild(...)` / `element.remove()` calls
3561
- // forced the browser to re-evaluate every ancestor-dependent selector (`:has()`,
3562
- // descendant combinators, universal sibling rules) across the document on each
3563
- // invocation — a 13,000+ element style recalc per call on a typical Basecamp
3564
- // page.
3927
+ collapseWith(node, backwards) {
3928
+ if (!ImageGalleryNode.canCollapseWith(node)) return false
3565
3929
 
3566
- let resolverRoot = null;
3930
+ if (backwards) {
3931
+ $insertFirst(this, node);
3932
+ } else {
3933
+ this.append(node);
3934
+ }
3567
3935
 
3568
- function styleResolverRoot() {
3569
- if (resolverRoot && resolverRoot.isConnected) return resolverRoot
3936
+ $unwrapAndFilterDescendants(this, ImageGalleryNode.isValidChild);
3570
3937
 
3571
- resolverRoot = document.createElement("div");
3572
- resolverRoot.setAttribute("aria-hidden", "true");
3573
- resolverRoot.setAttribute("data-lexxy-style-resolver", "");
3574
- // `contain: strict` (size, layout, paint, style) isolates everything.
3575
- // The root itself paints nothing (visibility hidden), has zero
3576
- // geometric impact (position fixed, intrinsic size via contain), and
3577
- // never leaks style invalidation to its ancestors.
3578
- resolverRoot.style.cssText = "contain: strict; position: fixed; top: 0; left: 0; visibility: hidden; pointer-events: none; width: 0; height: 0;";
3579
- document.body.appendChild(resolverRoot);
3580
- return resolverRoot
3581
- }
3938
+ return true
3939
+ }
3582
3940
 
3583
- function isSelectionHighlighted(selection) {
3584
- if (!$isRangeSelection(selection)) return false
3941
+ unwrapEmptyNode() {
3942
+ if (this.isEmpty()) {
3943
+ const paragraph = $createParagraphNode();
3944
+ return this.replace(paragraph)
3945
+ }
3946
+ }
3585
3947
 
3586
- if (selection.isCollapsed()) {
3587
- return hasHighlightStyles(selection.style)
3588
- } else {
3589
- return selection.hasFormat("highlight")
3948
+ replaceWithSingularChild() {
3949
+ if (this.#hasSingularChild) {
3950
+ const child = this.getFirstChild();
3951
+ return this.replace($makeSafeForRoot(child))
3952
+ }
3590
3953
  }
3591
- }
3592
3954
 
3593
- function getHighlightStyles(selection) {
3594
- if (!$isRangeSelection(selection)) return null
3955
+ splitAroundInvalidChild() {
3956
+ for (const child of $firstToLastIterator(this)) {
3957
+ if (ImageGalleryNode.isValidChild(child)) continue
3595
3958
 
3596
- let styles = getStyleObjectFromCSS(selection.style);
3597
- if (!styles.color && !styles["background-color"]) {
3598
- const anchorNode = selection.anchor.getNode();
3599
- if ($isTextNode(anchorNode)) {
3600
- styles = getStyleObjectFromCSS(anchorNode.getStyle());
3959
+ const poppedNode = $makeSafeForRoot(child);
3960
+ const [ topGallery, secondGallery ] = this.splitAtIndex(poppedNode.getIndexWithinParent());
3961
+ topGallery.insertAfter(poppedNode);
3962
+ poppedNode.selectEnd();
3963
+
3964
+ // remove an empty gallery rather than let it unwrap to a paragraph
3965
+ if (secondGallery.isEmpty()) secondGallery.remove();
3966
+
3967
+ break
3601
3968
  }
3602
3969
  }
3603
3970
 
3604
- const color = styles.color || null;
3605
- const backgroundColor = styles["background-color"] || null;
3606
- if (!color && !backgroundColor) return null
3971
+ splitAtIndex(index) {
3972
+ return $splitNode(this, index)
3973
+ }
3607
3974
 
3608
- return { color, backgroundColor }
3975
+ get #hasSingularChild() {
3976
+ return this.getChildrenSize() === 1
3977
+ }
3978
+
3979
+ get #galleryClassNames() {
3980
+ return `attachment-gallery attachment-gallery--${this.getChildrenSize()}`
3981
+ }
3609
3982
  }
3610
3983
 
3611
- function hasHighlightStyles(cssOrStyles) {
3612
- const styles = typeof cssOrStyles === "string" ? getStyleObjectFromCSS(cssOrStyles) : cssOrStyles;
3613
- return !!(styles.color || styles["background-color"])
3984
+ function $createImageGalleryNode() {
3985
+ return new ImageGalleryNode()
3614
3986
  }
3615
3987
 
3616
- function applyCanonicalizers(styles, canonicalizers = []) {
3617
- return canonicalizers.reduce((css, canonicalizer) => {
3618
- return canonicalizer.applyCanonicalization(css)
3619
- }, styles)
3988
+ function $isImageGalleryNode(node) {
3989
+ return node instanceof ImageGalleryNode
3620
3990
  }
3621
3991
 
3622
- class StyleCanonicalizer {
3623
- constructor(property, allowedValues= []) {
3624
- this._property = property;
3625
- this._allowedValues = allowedValues;
3626
- this._canonicalValues = this.#allowedValuesIdentityObject;
3992
+ function $findOrCreateGalleryForImage(node) {
3993
+ if (!ImageGalleryNode.canCollapseWith(node)) return null
3994
+
3995
+ const existingGallery = $getNearestNodeOfType(node, ImageGalleryNode);
3996
+ return existingGallery ?? $wrapNodeInElement(node, $createImageGalleryNode)
3997
+ }
3998
+
3999
+ class CaptionEditor {
4000
+ #editorElement
4001
+ #editor
4002
+ #input = createElement("textarea", { className: "lexxy-caption-editor", hidden: true, tabIndex: -1, rows: 1, placeholder: "Add caption..." })
4003
+ #nodeKey
4004
+ #caption
4005
+ #listeners = new ListenerBin()
4006
+ #resizeObserver = new ResizeObserver(() => this.#updatePosition())
4007
+
4008
+ constructor(editorElement) {
4009
+ this.#editorElement = editorElement;
4010
+ this.#editor = editorElement.editor;
4011
+ editorElement.append(this.#input);
4012
+ this.#listeners.track(
4013
+ registerEventListener(this.#input, "blur", () => this.#save()),
4014
+ registerEventListener(this.#input, "keydown", event => this.#handleKeydown(event)),
4015
+ registerEventListener(this.#input, "input", () => this.#updatePosition()),
4016
+ registerEventListener(editorElement, "scroll", () => this.#updatePosition(), { capture: true, passive: true }),
4017
+ this.#editor.registerUpdateListener(() => this.#checkAttachment())
4018
+ );
3627
4019
  }
3628
4020
 
3629
- applyCanonicalization(css) {
3630
- const styles = { ...getStyleObjectFromCSS(css) };
4021
+ dispose() {
4022
+ this.#listeners.dispose();
4023
+ this.#close();
4024
+ this.#input.remove();
4025
+ }
3631
4026
 
3632
- styles[this._property] = this.getCanonicalAllowedValue(styles[this._property]);
3633
- if (!styles[this._property]) {
3634
- delete styles[this._property];
4027
+ open(nodeKey) {
4028
+ const caption = this.#editor.getElementByKey(nodeKey)?.querySelector("figcaption.attachment__caption--editable");
4029
+ if (caption) {
4030
+ if (this.#nodeKey !== nodeKey) {
4031
+ this.#edit(nodeKey, caption);
4032
+ }
4033
+ return true
4034
+ } else {
4035
+ return false
3635
4036
  }
4037
+ }
3636
4038
 
3637
- return getCSSFromStyleObject(styles)
4039
+ #save() {
4040
+ if (this.#nodeKey) {
4041
+ const key = this.#nodeKey;
4042
+ const value = this.#input.value;
4043
+ this.#close();
4044
+ this.#editor.update(() => {
4045
+ const node = $getNodeByKey(key);
4046
+ if (node && node.caption !== value) node.getWritable().caption = value;
4047
+ }, { tag: SKIP_DOM_SELECTION_TAG, discrete: true });
4048
+ }
3638
4049
  }
3639
4050
 
3640
- getCanonicalAllowedValue(value) {
3641
- return this._canonicalValues[value] ||= this.#resolveCannonicalValue(value)
4051
+ #close() {
4052
+ this.#nodeKey = null;
4053
+ this.#resizeObserver.disconnect();
4054
+ if (this.#caption) {
4055
+ this.#caption.classList.remove("attachment__caption--editing");
4056
+ this.#caption.style.removeProperty("min-height");
4057
+ this.#caption = null;
4058
+ }
4059
+ this.#input.hidden = true;
3642
4060
  }
3643
4061
 
3644
- // Private
4062
+ #handleKeydown(event) {
4063
+ if (!event.isComposing && [ "Enter", "Escape" ].includes(event.key)) {
4064
+ event.preventDefault();
4065
+ const key = this.#nodeKey;
4066
+ this.#input.blur();
4067
+ this.#editor.getRootElement()?.focus({ preventScroll: true });
4068
+ this.#editor.update(() => {
4069
+ const node = $getNodeByKey(key);
4070
+ if (node) {
4071
+ if (event.key === "Enter") {
4072
+ if ($isImageGalleryNode(node.getParent()) && !node.getNextSibling()) {
4073
+ node.getParent().selectNext(0, 0);
4074
+ } else {
4075
+ node.selectNext(0, 0);
4076
+ }
4077
+ } else {
4078
+ $setSelection($createNodeSelectionWith(node));
4079
+ }
4080
+ }
4081
+ }, { tag: HISTORY_MERGE_TAG });
4082
+ }
4083
+ event.stopPropagation();
4084
+ }
3645
4085
 
3646
- get #allowedValuesIdentityObject() {
3647
- return this._allowedValues.reduce((object, value) => ({ ...object, [value]: value }), {})
4086
+ #updatePosition() {
4087
+ if (this.#caption?.isConnected) {
4088
+ const rect = this.#caption.getBoundingClientRect();
4089
+ const editorRect = this.#editorElement.getBoundingClientRect();
4090
+ this.#input.style.left = `${rect.left - editorRect.left - this.#editorElement.clientLeft + this.#editorElement.scrollLeft}px`;
4091
+ this.#input.style.top = `${rect.top - editorRect.top - this.#editorElement.clientTop + this.#editorElement.scrollTop}px`;
4092
+ this.#input.style.width = `${rect.width}px`;
4093
+ this.#input.style.height = "0px";
4094
+ const height = this.#input.scrollHeight;
4095
+ this.#input.style.height = `${height}px`;
4096
+ this.#caption.style.minHeight = `${height}px`;
4097
+ }
4098
+ }
4099
+
4100
+ #checkAttachment() {
4101
+ if (this.#nodeKey && this.#editor.getElementByKey(this.#nodeKey)?.querySelector("figcaption") !== this.#caption) {
4102
+ this.#close();
4103
+ } else {
4104
+ this.#updatePosition();
4105
+ }
4106
+ }
4107
+
4108
+ #edit(nodeKey, caption) {
4109
+ this.#save();
4110
+ this.#nodeKey = nodeKey;
4111
+ this.#caption = caption;
4112
+ this.#editor.getEditorState().read(() => {
4113
+ const node = $getNodeByKey(nodeKey);
4114
+ this.#input.value = node.caption;
4115
+ if (node.isVideo) {
4116
+ this.#input.ariaLabel = "Video caption";
4117
+ } else {
4118
+ this.#input.ariaLabel = "Image caption";
4119
+ }
4120
+ });
4121
+
4122
+ caption.scrollIntoView({ block: "nearest", inline: "nearest" });
4123
+ const style = getComputedStyle(caption);
4124
+ this.#input.style.font = style.font;
4125
+ this.#input.style.padding = style.padding;
4126
+ this.#input.style.color = style.color;
4127
+ this.#input.style.textAlign = getComputedStyle(caption.firstElementChild).textAlign;
4128
+ this.#input.hidden = false;
4129
+ caption.classList.add("attachment__caption--editing");
4130
+ this.#resizeObserver.observe(caption);
4131
+ this.#resizeObserver.observe(this.#editorElement);
4132
+ this.#updatePosition();
4133
+ this.#input.focus({ preventScroll: true });
4134
+ }
4135
+ }
4136
+
4137
+ class UploadRequests {
4138
+ #requestsByKey = new Map()
4139
+
4140
+ track(key, request) {
4141
+ this.#requestsByKey.set(key, request);
4142
+ }
4143
+
4144
+ forget(key) {
4145
+ this.#requestsByKey.delete(key);
3648
4146
  }
3649
4147
 
3650
- #resolveCannonicalValue(value) {
3651
- let index = this.#computedAllowedValues.indexOf(value);
3652
- if (index === -1) {
3653
- index = this.#computedAllowedValues.indexOf(computeStyleValues(this._property, [ value ])[0]);
4148
+ abort(key) {
4149
+ const request = this.#requestsByKey.get(key);
4150
+ if (request) {
4151
+ this.#requestsByKey.delete(key);
4152
+ request.abort();
3654
4153
  }
3655
- return index === -1 ? null : this._allowedValues[index]
3656
4154
  }
3657
4155
 
3658
- get #computedAllowedValues() {
3659
- return this._computedAllowedValues ||= computeStyleValues(this._property, this._allowedValues)
4156
+ clear() {
4157
+ this.#requestsByKey.clear();
3660
4158
  }
3661
4159
  }
3662
4160
 
3663
- // Separates DOM writes from layout reads to avoid forced reflows, and attaches
3664
- // resolver elements to a strictly-contained root (outside the normal document
3665
- // flow) so neither the attach nor the detach invalidate styles on the rest of
3666
- // the page. Without containment, appending to `document.body` triggered a
3667
- // page-wide style recalc on every canonicalization pass.
3668
- function computeStyleValues(property, values) {
3669
- const fragment = document.createDocumentFragment();
4161
+ // Partition a stretch of text into consecutive { start, end, range } segments,
4162
+ // where offsets are relative to the text and range is the covering source
4163
+ // range — expressed in outer coordinates as { start, end, ... } — or null for
4164
+ // the stretches no range covers. Ranges must be sorted and non-overlapping.
4165
+ function segmentTextByRanges(text, textStart, ranges) {
4166
+ const segments = [];
4167
+ let cursor = 0;
3670
4168
 
3671
- const elements = values.map(value => {
3672
- const element = createElement("span", { style: `display: none; ${property}: ${value};` });
3673
- fragment.appendChild(element);
3674
- return element
3675
- });
4169
+ for (const range of ranges) {
4170
+ const from = Math.max(range.start - textStart, cursor);
4171
+ const to = Math.min(range.end - textStart, text.length);
3676
4172
 
3677
- styleResolverRoot().appendChild(fragment);
4173
+ if (from < to) {
4174
+ if (from > cursor) {
4175
+ segments.push({ start: cursor, end: from, range: null });
4176
+ }
4177
+ segments.push({ start: from, end: to, range });
4178
+ cursor = to;
4179
+ }
4180
+ }
3678
4181
 
3679
- const computed = elements.map(element =>
3680
- window.getComputedStyle(element).getPropertyValue(property)
3681
- );
4182
+ if (cursor < text.length) {
4183
+ segments.push({ start: cursor, end: text.length, range: null });
4184
+ }
3682
4185
 
3683
- elements.forEach(element => element.remove());
3684
- return computed
4186
+ return segments
3685
4187
  }
3686
4188
 
3687
4189
  const TOGGLE_HIGHLIGHT_COMMAND = createCommand();
@@ -3966,11 +4468,15 @@ function $extractHighlightRangesFromCodeNode(codeNode) {
3966
4468
 
3967
4469
  function buildCanonicalizers(config) {
3968
4470
  return [
3969
- new StyleCanonicalizer("color", [ ...config.buttons.color, ...config.permit.color ]),
3970
- new StyleCanonicalizer("background-color", [ ...config.buttons["background-color"], ...config.permit["background-color"] ])
4471
+ new StyleCanonicalizer("color", [ ...colorValues(config.buttons.color), ...config.permit.color ]),
4472
+ new StyleCanonicalizer("background-color", [ ...colorValues(config.buttons["background-color"]), ...config.permit["background-color"] ])
3971
4473
  ]
3972
4474
  }
3973
4475
 
4476
+ function colorValues(buttons) {
4477
+ return normalizeColorButtons(buttons).map((button) => button.value)
4478
+ }
4479
+
3974
4480
  function $toggleSelectionStyles(editor, styles) {
3975
4481
  const selection = $getSelection();
3976
4482
  if (!$isRangeSelection(selection)) return
@@ -4909,29 +5415,25 @@ class Selection {
4909
5415
  }, COMMAND_PRIORITY_LOW),
4910
5416
 
4911
5417
  this.editor.registerCommand(SELECTION_CHANGE_COMMAND, () => {
4912
- this.#preserveFocusOfOtherFields();
5418
+ this.#preserveFocusOutsideEditor();
4913
5419
  return false
4914
5420
  }, COMMAND_PRIORITY_CRITICAL)
4915
5421
  );
4916
5422
  }
4917
5423
 
4918
- // Firefox (152+) keeps window.getSelection() anchored inside the editor after focus
4919
- // moves to another field, and fires document selectionchange for every keystroke
4920
- // typed there. Lexical reads that stale selection, reconciles, and pulls focus back
4921
- // into the editor — stealing it mid-typing. When another text-entry field owns focus,
4922
- // tag the update so Lexical leaves the DOM selection (and with it, focus) alone.
4923
- #preserveFocusOfOtherFields() {
4924
- if (this.#anotherFieldIsFocused) {
5424
+ // Selection changes can arrive after Tab moves focus to a toolbar or another field.
5425
+ // Reconciling that stale selection would pull focus back into the editor.
5426
+ #preserveFocusOutsideEditor() {
5427
+ if (this.#isAnotherElementFocused) {
4925
5428
  $addUpdateTag(SKIP_DOM_SELECTION_TAG);
4926
5429
  }
4927
5430
  }
4928
5431
 
4929
- get #anotherFieldIsFocused() {
5432
+ get #isAnotherElementFocused() {
4930
5433
  const rootElement = this.editor.getRootElement();
4931
5434
  const activeElement = rootElement?.ownerDocument.activeElement;
4932
5435
 
4933
- return activeElement && !rootElement.contains(activeElement) &&
4934
- (activeElement.tagName === "INPUT" || activeElement.tagName === "TEXTAREA" || activeElement.isContentEditable)
5436
+ return activeElement && activeElement !== rootElement.ownerDocument.body && !rootElement.contains(activeElement)
4935
5437
  }
4936
5438
 
4937
5439
  #listenForNodeSelections() {
@@ -5461,243 +5963,71 @@ class Selection {
5461
5963
 
5462
5964
  // Get a reliable bounding rect for the first or last character in a DOM
5463
5965
  // element by creating a non-collapsed range around it.
5464
- #getEdgeCharRect(element, edge) {
5465
- const walker = document.createTreeWalker(element, 4 /* NodeFilter.SHOW_TEXT */);
5466
- let textNode;
5467
-
5468
- if (edge === "first") {
5469
- textNode = walker.nextNode();
5470
- } else {
5471
- while (walker.nextNode()) textNode = walker.currentNode;
5472
- }
5473
-
5474
- if (!textNode || textNode.length === 0) return null
5475
-
5476
- const range = document.createRange();
5477
- if (edge === "first") {
5478
- range.setStart(textNode, 0);
5479
- range.setEnd(textNode, 1);
5480
- } else {
5481
- range.setStart(textNode, textNode.length - 1);
5482
- range.setEnd(textNode, textNode.length);
5483
- }
5484
-
5485
- return range.getBoundingClientRect()
5486
- }
5487
- }
5488
-
5489
- class EditorConfiguration {
5490
- #editorElement
5491
- #config
5492
-
5493
- constructor(editorElement) {
5494
- this.#editorElement = editorElement;
5495
- this.#config = new Configuration(
5496
- Lexxy.presets.get("default"),
5497
- Lexxy.presets.get(editorElement.preset),
5498
- this.#overrides
5499
- );
5500
- }
5501
-
5502
- get(path) {
5503
- return this.#config.get(path)
5504
- }
5505
-
5506
- get #overrides() {
5507
- const overrides = {};
5508
- for (const option of this.#defaultOptions) {
5509
- const attribute = dasherize(option);
5510
- if (this.#editorElement.hasAttribute(attribute)) {
5511
- overrides[option] = this.#parseAttribute(attribute);
5512
- }
5513
- }
5514
- return overrides
5515
- }
5516
-
5517
- get #defaultOptions() {
5518
- return Object.keys(Lexxy.presets.get("default"))
5519
- }
5520
-
5521
- #parseAttribute(attribute) {
5522
- const value = this.#editorElement.getAttribute(attribute);
5523
- try {
5524
- return JSON.parse(value)
5525
- } catch {
5526
- return value
5527
- }
5528
- }
5529
- }
5530
-
5531
- class ImageGalleryNode extends ElementNode {
5532
- $config() {
5533
- return this.config("image_gallery", {
5534
- extends: ElementNode,
5535
- })
5536
- }
5537
-
5538
- static transform() {
5539
- return (gallery) => {
5540
- gallery.unwrapEmptyNode()
5541
- || gallery.replaceWithSingularChild()
5542
- || gallery.splitAroundInvalidChild();
5543
- }
5544
- }
5545
-
5546
- static importDOM() {
5547
- return {
5548
- div: (element) => {
5549
- if (!this.#isGalleryElement(element)) return null
5550
-
5551
- return {
5552
- conversion: () => {
5553
- return {
5554
- node: $createImageGalleryNode()
5555
- }
5556
- },
5557
- priority: 2
5558
- }
5559
- }
5560
- }
5561
- }
5562
-
5563
- static canCollapseWith(node) {
5564
- return $isImageGalleryNode(node) || this.isValidChild(node)
5565
- }
5566
-
5567
- static isValidChild(node) {
5568
- return $isActionTextAttachmentNode(node) && node.isPreviewableImage
5569
- }
5570
-
5571
- static #isGalleryElement(element) {
5572
- const attachmentChildren = element.querySelectorAll(`:scope > :is(${this.#attachmentTags.join()})`);
5573
- return element.textContent.trim() === ""
5574
- && attachmentChildren.length > 0
5575
- && element.children.length === attachmentChildren.length
5576
- }
5577
-
5578
- static get #attachmentTags() {
5579
- return Object.keys(ActionTextAttachmentNode.importDOM())
5580
- }
5581
-
5582
- createDOM() {
5583
- const div = document.createElement("div");
5584
- div.className = this.#galleryClassNames;
5585
- return div
5586
- }
5587
-
5588
- updateDOM(_prevNode, dom) {
5589
- dom.className = this.#galleryClassNames;
5590
- return false
5591
- }
5592
-
5593
- canBeEmpty() {
5594
- // Return `true` to conform to `$isBlock(node)`
5595
- // We clean-up empty galleries with a transform
5596
- return true
5597
- }
5598
-
5599
- collapseAtStart(_selection) {
5600
- return true
5601
- }
5602
-
5603
- insertNewAfter(selection, restoreSelection) {
5604
- const selectionBeforeLastChild = selection.anchor.getNode().is(this) && selection.anchor.offset == this.getChildrenSize() - 1;
5605
- if (selectionBeforeLastChild) {
5606
- const paragraph = $createParagraphNode();
5607
- this.insertAfter(paragraph, false);
5608
- paragraph.insertAfter(this.getLastChild(), false);
5609
- paragraph.selectEnd();
5610
-
5611
- // return null as selection has been managed
5612
- return null
5613
- }
5614
-
5615
- const newNode = $createImageGalleryNode();
5616
- this.insertAfter(newNode, restoreSelection);
5617
- return newNode
5618
- }
5619
-
5620
- getImageAttachments() {
5621
- const children = this.getChildren();
5622
- return children.filter($isActionTextAttachmentNode)
5623
- }
5624
-
5625
- exportDOM() {
5626
- const div = document.createElement("div");
5627
- div.className = this.#galleryClassNames;
5628
- return { element: div }
5629
- }
5630
-
5631
- collapseWith(node, backwards) {
5632
- if (!ImageGalleryNode.canCollapseWith(node)) return false
5633
-
5634
- if (backwards) {
5635
- $insertFirst(this, node);
5636
- } else {
5637
- this.append(node);
5638
- }
5639
-
5640
- $unwrapAndFilterDescendants(this, ImageGalleryNode.isValidChild);
5641
-
5642
- return true
5643
- }
5644
-
5645
- unwrapEmptyNode() {
5646
- if (this.isEmpty()) {
5647
- const paragraph = $createParagraphNode();
5648
- return this.replace(paragraph)
5649
- }
5650
- }
5651
-
5652
- replaceWithSingularChild() {
5653
- if (this.#hasSingularChild) {
5654
- const child = this.getFirstChild();
5655
- return this.replace($makeSafeForRoot(child))
5656
- }
5657
- }
5658
-
5659
- splitAroundInvalidChild() {
5660
- for (const child of $firstToLastIterator(this)) {
5661
- if (ImageGalleryNode.isValidChild(child)) continue
5966
+ #getEdgeCharRect(element, edge) {
5967
+ const walker = document.createTreeWalker(element, 4 /* NodeFilter.SHOW_TEXT */);
5968
+ let textNode;
5662
5969
 
5663
- const poppedNode = $makeSafeForRoot(child);
5664
- const [ topGallery, secondGallery ] = this.splitAtIndex(poppedNode.getIndexWithinParent());
5665
- topGallery.insertAfter(poppedNode);
5666
- poppedNode.selectEnd();
5970
+ if (edge === "first") {
5971
+ textNode = walker.nextNode();
5972
+ } else {
5973
+ while (walker.nextNode()) textNode = walker.currentNode;
5974
+ }
5667
5975
 
5668
- // remove an empty gallery rather than let it unwrap to a paragraph
5669
- if (secondGallery.isEmpty()) secondGallery.remove();
5976
+ if (!textNode || textNode.length === 0) return null
5670
5977
 
5671
- break
5978
+ const range = document.createRange();
5979
+ if (edge === "first") {
5980
+ range.setStart(textNode, 0);
5981
+ range.setEnd(textNode, 1);
5982
+ } else {
5983
+ range.setStart(textNode, textNode.length - 1);
5984
+ range.setEnd(textNode, textNode.length);
5672
5985
  }
5673
- }
5674
5986
 
5675
- splitAtIndex(index) {
5676
- return $splitNode(this, index)
5987
+ return range.getBoundingClientRect()
5677
5988
  }
5989
+ }
5678
5990
 
5679
- get #hasSingularChild() {
5680
- return this.getChildrenSize() === 1
5681
- }
5991
+ class EditorConfiguration {
5992
+ #editorElement
5993
+ #config
5682
5994
 
5683
- get #galleryClassNames() {
5684
- return `attachment-gallery attachment-gallery--${this.getChildrenSize()}`
5995
+ constructor(editorElement) {
5996
+ this.#editorElement = editorElement;
5997
+ this.#config = new Configuration(
5998
+ Lexxy.presets.get("default"),
5999
+ Lexxy.presets.get(editorElement.preset),
6000
+ this.#overrides
6001
+ );
5685
6002
  }
5686
- }
5687
6003
 
5688
- function $createImageGalleryNode() {
5689
- return new ImageGalleryNode()
5690
- }
6004
+ get(path) {
6005
+ return this.#config.get(path)
6006
+ }
5691
6007
 
5692
- function $isImageGalleryNode(node) {
5693
- return node instanceof ImageGalleryNode
5694
- }
6008
+ get #overrides() {
6009
+ const overrides = {};
6010
+ for (const option of this.#defaultOptions) {
6011
+ const attribute = dasherize(option);
6012
+ if (this.#editorElement.hasAttribute(attribute)) {
6013
+ overrides[option] = this.#parseAttribute(attribute);
6014
+ }
6015
+ }
6016
+ return overrides
6017
+ }
5695
6018
 
5696
- function $findOrCreateGalleryForImage(node) {
5697
- if (!ImageGalleryNode.canCollapseWith(node)) return null
6019
+ get #defaultOptions() {
6020
+ return Object.keys(Lexxy.presets.get("default"))
6021
+ }
5698
6022
 
5699
- const existingGallery = $getNearestNodeOfType(node, ImageGalleryNode);
5700
- return existingGallery ?? $wrapNodeInElement(node, $createImageGalleryNode)
6023
+ #parseAttribute(attribute) {
6024
+ const value = this.#editorElement.getAttribute(attribute);
6025
+ try {
6026
+ return JSON.parse(value)
6027
+ } catch {
6028
+ return value
6029
+ }
6030
+ }
5701
6031
  }
5702
6032
 
5703
6033
  class Uploader {
@@ -5907,6 +6237,11 @@ class ActionTextAttachmentUploadNode extends ActionTextAttachmentNode {
5907
6237
  updateDOM(prevNode, dom) {
5908
6238
  if (this.uploadError !== prevNode.uploadError) return true
5909
6239
 
6240
+ const image = dom.querySelector("img");
6241
+ if (image && prevNode.altText !== this.altText) {
6242
+ image.alt = this.altText;
6243
+ }
6244
+
5910
6245
  if (prevNode.progress !== this.progress) {
5911
6246
  const progress = dom.querySelector("progress");
5912
6247
  progress.value = this.progress ?? 0;
@@ -5940,7 +6275,7 @@ class ActionTextAttachmentUploadNode extends ActionTextAttachmentNode {
5940
6275
  }
5941
6276
 
5942
6277
  #createDOMForImage() {
5943
- return createElement("img")
6278
+ return createElement("img", { alt: this.altText })
5944
6279
  }
5945
6280
 
5946
6281
  #createDOMForFile() {
@@ -6105,7 +6440,6 @@ class AttachmentNodeConversion {
6105
6440
  const { blob } = this;
6106
6441
  return {
6107
6442
  sgid: blob.attachable_sgid,
6108
- altText: blob.filename,
6109
6443
  contentType: blob.content_type,
6110
6444
  fileName: blob.filename,
6111
6445
  fileSize: blob.byte_size,
@@ -6983,7 +7317,7 @@ class Contents {
6983
7317
  this.#performTextReplacement(anchorNode, lastIndex, stringToReplace, replacementNodes);
6984
7318
  }
6985
7319
 
6986
- uploadFiles(files, { selectLast } = {}) {
7320
+ uploadFiles(files, { selectLast, altText } = {}) {
6987
7321
  if (!this.editorElement) return // Disposed (e.g. on turbo:before-cache); a late drop can still land here
6988
7322
 
6989
7323
  if (!this.editorElement.supportsAttachments) {
@@ -6996,6 +7330,10 @@ class Contents {
6996
7330
  const uploader = Uploader.for(this.editorElement, validFiles);
6997
7331
  uploader.$uploadFiles();
6998
7332
 
7333
+ if (altText && files.length === 1 && uploader.nodes.length === 1) {
7334
+ uploader.nodes[0].getWritable().altText = altText;
7335
+ }
7336
+
6999
7337
  if (selectLast && uploader.nodes?.length) {
7000
7338
  const lastNode = uploader.nodes.at(-1);
7001
7339
  lastNode.selectEnd();
@@ -7708,46 +8046,51 @@ class Clipboard {
7708
8046
  }
7709
8047
 
7710
8048
  #handlePastedFiles(clipboardData) {
7711
- if (!this.editorElement.supportsAttachments) return false
7712
-
7713
- const html = clipboardData.getData("text/html");
7714
- const files = clipboardData.files;
7715
-
7716
- if (files.length && this.#isCopiedImageHTML(html)) {
7717
- this.#uploadFilesPreservingScroll(files);
7718
- return true
7719
- }
7720
-
7721
- if (html && !this.#isLexicalClipboardData(clipboardData)) {
7722
- this.contents.insertHtml(html, { tag: PASTE_TAG });
7723
- return true
7724
- }
8049
+ if (this.editorElement.supportsAttachments) {
8050
+ const html = clipboardData.getData("text/html");
8051
+ const files = clipboardData.files;
8052
+ const copiedImage = files.length && this.#copiedImage(html);
7725
8053
 
7726
- if (files.length) {
7727
- this.#uploadFilesPreservingScroll(files);
7728
- return true
8054
+ if (copiedImage) {
8055
+ this.#uploadFilesPreservingScroll(files, copiedImage.getAttribute("alt"));
8056
+ return true
8057
+ } else if (html && !this.#isLexicalClipboardData(clipboardData)) {
8058
+ this.contents.insertHtml(html, { tag: PASTE_TAG });
8059
+ return true
8060
+ } else if (files.length) {
8061
+ this.#uploadFilesPreservingScroll(files);
8062
+ return true
8063
+ } else {
8064
+ return false
8065
+ }
8066
+ } else {
8067
+ return false
7729
8068
  }
7730
-
7731
- return false
7732
8069
  }
7733
8070
 
7734
8071
  #isLexicalClipboardData(clipboardData) {
7735
8072
  return Array.from(clipboardData.types).includes("application/x-lexical-editor")
7736
8073
  }
7737
8074
 
7738
- #isCopiedImageHTML(html) {
7739
- if (!html) return false
7740
-
7741
- const doc = parseHtml(html);
7742
- const elementChildren = Array.from(doc.body.children);
8075
+ #copiedImage(html) {
8076
+ if (html) {
8077
+ const doc = parseHtml(html);
8078
+ const elementChildren = Array.from(doc.body.children);
7743
8079
 
7744
- return elementChildren.length === 1 && elementChildren[0].tagName === "IMG"
8080
+ if (elementChildren.length === 1 && elementChildren[0].tagName === "IMG") {
8081
+ return elementChildren[0]
8082
+ } else {
8083
+ return null
8084
+ }
8085
+ } else {
8086
+ return null
8087
+ }
7745
8088
  }
7746
8089
 
7747
- #uploadFilesPreservingScroll(files) {
8090
+ #uploadFilesPreservingScroll(files, altText) {
7748
8091
  this.#preservingScrollPosition(() => {
7749
8092
  if (files.length) {
7750
- this.contents.uploadFiles(files, { selectLast: true });
8093
+ this.contents.uploadFiles(files, { selectLast: true, altText });
7751
8094
  }
7752
8095
  });
7753
8096
  }
@@ -8860,6 +9203,318 @@ class AttachmentDragAndDrop {
8860
9203
  }
8861
9204
  }
8862
9205
 
9206
+ // Lexical clears the DOM range on NodeSelection, leaving screen readers in focus
9207
+ // mode to read stray adjacent characters unless we provide a labelled range.
9208
+ class AttachmentFakeSelection {
9209
+ #editor
9210
+ #listeners = new ListenerBin()
9211
+ #container = createElement("span", { className: "lexxy-fake-selection" })
9212
+
9213
+ constructor(editor) {
9214
+ this.#editor = editor;
9215
+ this.#listeners.track(
9216
+ registerLabelledDecoratorSelection(editor, (target) => this.#update(target))
9217
+ );
9218
+ }
9219
+
9220
+ destroy() {
9221
+ this.#listeners.dispose();
9222
+ this.#container.remove();
9223
+ this.#container = null;
9224
+ }
9225
+
9226
+ #update(target) {
9227
+ if (this.#container) {
9228
+ const figure = target && this.#editor.getElementByKey(target.key);
9229
+ if (figure) {
9230
+ const label = target.label || "Attachment";
9231
+ if (this.#container.textContent !== label) this.#container.textContent = label;
9232
+ if (this.#container.parentNode !== figure) figure.append(this.#container);
9233
+ this.#parkSelection();
9234
+ } else {
9235
+ this.#container.remove();
9236
+ }
9237
+ }
9238
+ }
9239
+
9240
+ #parkSelection() {
9241
+ const textNode = this.#container.firstChild;
9242
+ if (textNode && this.#isEditorFocused && !this.#isAlreadyParkedHere) {
9243
+ const range = document.createRange();
9244
+ range.setStart(textNode, 0);
9245
+ range.setEnd(textNode, textNode.length);
9246
+
9247
+ const selection = window.getSelection();
9248
+ selection.removeAllRanges();
9249
+ selection.addRange(range);
9250
+ }
9251
+ }
9252
+
9253
+ // Parking the range while a nested control is focused steals its screen reader cursor.
9254
+ get #isEditorFocused() {
9255
+ const root = this.#editor.getRootElement();
9256
+ return root != null && document.activeElement === root
9257
+ }
9258
+
9259
+ get #isAlreadyParkedHere() {
9260
+ const selection = window.getSelection();
9261
+ if (selection?.rangeCount === 1) {
9262
+ const textNode = this.#container.firstChild;
9263
+ return selection.anchorNode === textNode
9264
+ && selection.focusNode === textNode
9265
+ && selection.anchorOffset === 0
9266
+ && selection.focusOffset === textNode.length
9267
+ } else {
9268
+ return false
9269
+ }
9270
+ }
9271
+ }
9272
+
9273
+ class AttachmentKeyboardMove {
9274
+ #editor
9275
+ #listeners = new ListenerBin()
9276
+ #scrollFrame
9277
+
9278
+ constructor(editor) {
9279
+ this.#editor = editor;
9280
+
9281
+ this.#listeners.track(
9282
+ editor.registerCommand(KEY_DOWN_COMMAND, this.#tryReorder, COMMAND_PRIORITY_HIGH)
9283
+ );
9284
+ }
9285
+
9286
+ destroy() {
9287
+ this.#listeners.dispose();
9288
+ cancelAnimationFrame(this.#scrollFrame);
9289
+ }
9290
+
9291
+ #tryReorder = (event) => {
9292
+ const node = $singleSelectedNode();
9293
+ if (event.altKey && event.shiftKey && event.key.startsWith("Arrow") && $isActionTextAttachmentNode(node)) {
9294
+ event.preventDefault();
9295
+ const message = this.#executeMove(event.key, node);
9296
+ $setSelection($createNodeSelectionWith(node));
9297
+ announceFromEditor(this.#editor, message);
9298
+ this.#keepInView(node);
9299
+ return true
9300
+ } else {
9301
+ return false
9302
+ }
9303
+ }
9304
+
9305
+ #executeMove(key, node) {
9306
+ switch (key) {
9307
+ case "ArrowUp": return new AttachmentReorder(node, "previous").moveVertically()
9308
+ case "ArrowDown": return new AttachmentReorder(node, "next").moveVertically()
9309
+ case "ArrowLeft": return new AttachmentReorder(node, "previous").reorderInGallery()
9310
+ case "ArrowRight": return new AttachmentReorder(node, "next").reorderInGallery()
9311
+ default: return null
9312
+ }
9313
+ }
9314
+
9315
+ // Node selections don't scroll into view automatically like a caret does.
9316
+ #keepInView(node) {
9317
+ const key = node.getKey();
9318
+ cancelAnimationFrame(this.#scrollFrame);
9319
+ this.#scrollFrame = requestAnimationFrame(() => {
9320
+ this.#editor.getElementByKey(key)?.scrollIntoView({ block: "nearest" });
9321
+ });
9322
+ }
9323
+ }
9324
+
9325
+ class AttachmentReorder {
9326
+ constructor(node, direction) {
9327
+ this.node = node;
9328
+ this.direction = direction;
9329
+ }
9330
+
9331
+ moveVertically() {
9332
+ if ($isImageGalleryNode(this.node.getParent())) {
9333
+ return this.#extractFromGallery()
9334
+ } else {
9335
+ return this.#moveBlock() || this.#blockedMessage
9336
+ }
9337
+ }
9338
+
9339
+ reorderInGallery() {
9340
+ const sibling = $getSiblingCaret(this.node, this.direction).getNodeAtCaret();
9341
+ if ($isImageGalleryNode(this.node.getParent()) && sibling) {
9342
+ $getSiblingCaret(sibling, this.direction).insert(this.node);
9343
+ return "Image reordered in gallery"
9344
+ } else {
9345
+ return this.#blockedMessage
9346
+ }
9347
+ }
9348
+
9349
+ #extractFromGallery() {
9350
+ const gallery = this.node.getParent();
9351
+ $getSiblingCaret(gallery, this.direction).insert(this.node);
9352
+ return "Image removed from gallery"
9353
+ }
9354
+
9355
+ #moveBlock() {
9356
+ const target = this.#skipProvisional($getSiblingCaret(this.node, this.direction).getNodeAtCaret());
9357
+ if (this.#canJoinGallery(target)) {
9358
+ return this.#joinGallery(target)
9359
+ } else if (this.#canFormGalleryWith(target)) {
9360
+ return this.#formGalleryWith(target)
9361
+ } else if (target) {
9362
+ return this.#swapWith(target)
9363
+ } else {
9364
+ return null
9365
+ }
9366
+ }
9367
+
9368
+ #skipProvisional(node) {
9369
+ let current = node;
9370
+ while (current && $isProvisionalParagraphNode(current)) {
9371
+ current = $getSiblingCaret(current, this.direction).getNodeAtCaret();
9372
+ }
9373
+ return current
9374
+ }
9375
+
9376
+ #canJoinGallery(target) {
9377
+ return $isImageGalleryNode(target) && ImageGalleryNode.isValidChild(this.node)
9378
+ }
9379
+
9380
+ #joinGallery(gallery) {
9381
+ $getChildCaret(gallery, this.direction).insert(this.node);
9382
+ return "Image added to gallery"
9383
+ }
9384
+
9385
+ #canFormGalleryWith(target) {
9386
+ return $isActionTextAttachmentNode(target)
9387
+ && target.isPreviewableImage
9388
+ && this.node.isPreviewableImage
9389
+ }
9390
+
9391
+ #formGalleryWith(target) {
9392
+ if ($findOrCreateGalleryForImage(target)) {
9393
+ $getSiblingCaret(target, flipDirection(this.direction)).insert(this.node);
9394
+ return "Gallery created"
9395
+ } else {
9396
+ return null
9397
+ }
9398
+ }
9399
+
9400
+ #swapWith(target) {
9401
+ $getSiblingCaret(target, this.direction).insert(this.node);
9402
+ if (this.direction === "next") {
9403
+ return "Attachment moved down"
9404
+ } else {
9405
+ return "Attachment moved up"
9406
+ }
9407
+ }
9408
+
9409
+ get #blockedMessage() {
9410
+ if (this.direction === "next") {
9411
+ return "Already at the end"
9412
+ } else {
9413
+ return "Already at the start"
9414
+ }
9415
+ }
9416
+ }
9417
+
9418
+ // Using a mention's label as avatar alt text can interrupt line-by-line reading,
9419
+ // but character navigation needs it near the caret to announce the mention.
9420
+ class CustomAttachmentLabels {
9421
+ #editor
9422
+ #listeners = new ListenerBin()
9423
+ #active = null
9424
+
9425
+ constructor(editor) {
9426
+ this.#editor = editor;
9427
+ this.#listeners.track(
9428
+ editor.registerUpdateListener(this.#updateLabel),
9429
+ // A blur command opens a Lexical update that can pull focus back into the editor.
9430
+ editor.registerRootListener(rootElement => {
9431
+ if (rootElement) {
9432
+ return registerEventListener(rootElement, "blur", () => this.#restoreLabel())
9433
+ }
9434
+ })
9435
+ );
9436
+ }
9437
+
9438
+ destroy() {
9439
+ this.#listeners.dispose();
9440
+ this.#restoreLabel();
9441
+ }
9442
+
9443
+ #updateLabel = ({ editorState }) => {
9444
+ if (document.activeElement === this.#editor.getRootElement()) {
9445
+ editorState.read(() => {
9446
+ const selection = $getSelection();
9447
+ const attachment = customAttachmentSelectedBy(selection) || customAttachmentBesideAnchor(selection);
9448
+
9449
+ if (attachment) {
9450
+ this.#exposeLabelOn(attachment);
9451
+ } else {
9452
+ this.#restoreLabel();
9453
+ }
9454
+ });
9455
+ } else {
9456
+ this.#restoreLabel();
9457
+ }
9458
+ }
9459
+
9460
+ #exposeLabelOn(attachment) {
9461
+ const figure = this.#editor.getElementByKey(attachment.getKey());
9462
+ if (figure && figure !== this.#active?.figure) {
9463
+ this.#restoreLabel();
9464
+ attachment.exposeLabel(figure);
9465
+ this.#active = { figure, attachment };
9466
+ }
9467
+ }
9468
+
9469
+ #restoreLabel() {
9470
+ if (this.#active) {
9471
+ const { attachment, figure } = this.#active;
9472
+ attachment.restoreLabel(figure);
9473
+ this.#active = null;
9474
+ }
9475
+ }
9476
+ }
9477
+
9478
+ function customAttachmentSelectedBy(selection) {
9479
+ if ($isNodeSelection(selection)) {
9480
+ const nodes = selection.getNodes();
9481
+ if (nodes.length === 1 && $isCustomActionTextAttachmentNode(nodes[0])) {
9482
+ return nodes[0]
9483
+ } else {
9484
+ return null
9485
+ }
9486
+ } else {
9487
+ return null
9488
+ }
9489
+ }
9490
+
9491
+ // Screen readers need the label one character early to announce it as the caret
9492
+ // crosses the figure.
9493
+ function customAttachmentBesideAnchor(selection) {
9494
+ if ($isRangeSelection(selection) && selection.isCollapsed()) {
9495
+ const anchor = selection.anchor;
9496
+ const parent = anchor.getNode();
9497
+ const candidates = [];
9498
+
9499
+ if (anchor.type === "text") {
9500
+ const toEnd = parent.getTextContentSize() - anchor.offset;
9501
+ if (toEnd <= 1) candidates.push({ node: parent.getNextSibling(), distance: toEnd });
9502
+ if (anchor.offset <= 1) candidates.push({ node: parent.getPreviousSibling(), distance: anchor.offset });
9503
+ } else {
9504
+ candidates.push({ node: parent.getChildAtIndex(anchor.offset), distance: 0 });
9505
+ candidates.push({ node: parent.getChildAtIndex(anchor.offset - 1), distance: 0 });
9506
+ }
9507
+
9508
+ const nearest = candidates
9509
+ .filter(({ node }) => $isCustomActionTextAttachmentNode(node))
9510
+ .sort((a, b) => a.distance - b.distance)[0];
9511
+
9512
+ return nearest?.node ?? null
9513
+ } else {
9514
+ return null
9515
+ }
9516
+ }
9517
+
8863
9518
  const ATTACHMENT_ATTRIBUTES = [ "alt", "caption", "content", "content-type", "data-direct-upload-id",
8864
9519
  "data-sgid", "filename", "filesize", "height", "presentation", "previewable", "sgid", "url", "width" ];
8865
9520
 
@@ -8886,12 +9541,19 @@ class AttachmentsExtension extends LexxyExtension {
8886
9541
  ],
8887
9542
  register: (editor) => {
8888
9543
  const dragAndDrop = new AttachmentDragAndDrop(editor);
9544
+ const keyboardMove = new AttachmentKeyboardMove(editor);
9545
+ const fakeSelection = new AttachmentFakeSelection(editor);
9546
+ const customAttachmentLabels = new CustomAttachmentLabels(editor);
8889
9547
 
8890
9548
  return mergeRegister(
8891
9549
  editor.registerNodeTransform(ActionTextAttachmentNode, $extractAttachmentFromParagraph),
8892
9550
  editor.registerCommand(DELETE_CHARACTER_COMMAND, $collapseIntoGallery, COMMAND_PRIORITY_NORMAL),
9551
+ editor.registerCommand(KEY_TAB_COMMAND, $focusCaptionFromSelectedAttachment, COMMAND_PRIORITY_HIGH),
8893
9552
  editor.registerMutationListener(ActionTextAttachmentUploadNode, this.#handleUploadMutations.bind(this)),
8894
- () => dragAndDrop.destroy()
9553
+ () => dragAndDrop.destroy(),
9554
+ () => keyboardMove.destroy(),
9555
+ () => fakeSelection.destroy(),
9556
+ () => customAttachmentLabels.destroy()
8895
9557
  )
8896
9558
  }
8897
9559
  })
@@ -8989,6 +9651,16 @@ function $collapseAtGalleryEdge(anchor, backwards) {
8989
9651
  }
8990
9652
  }
8991
9653
 
9654
+ function $focusCaptionFromSelectedAttachment(event) {
9655
+ const node = $singleSelectedNode();
9656
+ if (!event.shiftKey && $isActionTextAttachmentNode(node) && node.focusCaption()) {
9657
+ event.preventDefault();
9658
+ return true
9659
+ } else {
9660
+ return false
9661
+ }
9662
+ }
9663
+
8992
9664
  // Manual selection handling to prevent Lexical merging the gallery with a <p> and unwrapping it
8993
9665
  function $moveSelectionBeforeGallery(anchor) {
8994
9666
  const previousNode = anchor.getNode().getPreviousSibling();
@@ -9718,8 +10390,10 @@ class LexicalEditorElement extends HTMLElement {
9718
10390
  #historyState = { undo: false, redo: false }
9719
10391
 
9720
10392
  #validity = new Map()
10393
+ #validationAttempted = false
9721
10394
  #validationTextArea = document.createElement("textarea")
9722
10395
  #uploadRequests
10396
+ #liveRegion
9723
10397
 
9724
10398
  constructor() {
9725
10399
  super();
@@ -9750,6 +10424,10 @@ class LexicalEditorElement extends HTMLElement {
9750
10424
  this.clipboard = new Clipboard(this);
9751
10425
  this.#disposables.push(this.clipboard);
9752
10426
 
10427
+ this.#liveRegion = this.querySelector("lexxy-live-region") ?? createElement("lexxy-live-region");
10428
+ this.append(this.#liveRegion);
10429
+ this.#disposables.push(this.#liveRegion);
10430
+
9753
10431
  this.adapter = new BrowserAdapter();
9754
10432
  this.#uploadRequests = new UploadRequests();
9755
10433
 
@@ -9797,10 +10475,13 @@ class LexicalEditorElement extends HTMLElement {
9797
10475
  }
9798
10476
 
9799
10477
  requiredChangedCallback() {
10478
+ if (this.editorContentElement) this.#synchronizeAriaAttribute("aria-required");
9800
10479
  if (this.isConnected) this.#requestValidityRefresh();
9801
10480
  }
9802
10481
 
9803
10482
  formResetCallback() {
10483
+ this.#validationAttempted = false;
10484
+ this.#synchronizeAriaAttribute("aria-invalid");
9804
10485
  this.value = this.#initialValue;
9805
10486
  this.editor.dispatchCommand(CLEAR_HISTORY_COMMAND, undefined);
9806
10487
  }
@@ -9827,6 +10508,15 @@ class LexicalEditorElement extends HTMLElement {
9827
10508
  return this.internals.validity
9828
10509
  }
9829
10510
 
10511
+ get validationMessage() {
10512
+ return this.internals.validationMessage
10513
+ }
10514
+
10515
+ setCustomValidity(message) {
10516
+ this.#validationTextArea.setCustomValidity(message);
10517
+ this.#refreshValidity();
10518
+ }
10519
+
9830
10520
  checkValidity() {
9831
10521
  return this.internals.checkValidity()
9832
10522
  }
@@ -9895,6 +10585,16 @@ class LexicalEditorElement extends HTMLElement {
9895
10585
  return dispatch(this, "lexxy:file-accept", { file }, true)
9896
10586
  }
9897
10587
 
10588
+ announce(message) {
10589
+ if (message) {
10590
+ if (typeof document.ariaNotify === "function") {
10591
+ document.ariaNotify(message, { priority: "high" });
10592
+ } else {
10593
+ this.#liveRegion?.announce(message);
10594
+ }
10595
+ }
10596
+ }
10597
+
9898
10598
  $generateNodesFromDOM(doc, { editor = this.editor } = {}) {
9899
10599
  let nodes = $generateNodesFromDOM(editor, doc);
9900
10600
  if ($hasUpdateTag(PASTE_TAG)) nodes = $convertInlineImageDataURIs(nodes, this);
@@ -10054,6 +10754,7 @@ class LexicalEditorElement extends HTMLElement {
10054
10754
  this.#attachDebugHooks();
10055
10755
  this.#attachToolbar();
10056
10756
  this.#resetBeforeTurboCaches();
10757
+ this.#observeAriaAttributes();
10057
10758
 
10058
10759
  this.#setInternalFormValue(this.value, { suppressEvent: true });
10059
10760
  this.#synchronizeWithChanges();
@@ -10126,7 +10827,9 @@ class LexicalEditorElement extends HTMLElement {
10126
10827
  classList: "lexxy-editor__content",
10127
10828
  contenteditable: true,
10128
10829
  role: "textbox",
10129
- "aria-multiline": true,
10830
+ "aria-multiline": this.supportsMultiLine,
10831
+ "aria-required": this.required,
10832
+ "aria-invalid": false,
10130
10833
  "aria-label": this.#labelText,
10131
10834
  placeholder: this.getAttribute("placeholder")
10132
10835
  });
@@ -10159,6 +10862,46 @@ class LexicalEditorElement extends HTMLElement {
10159
10862
  return Array.from(this.attributes).filter(attribute => attribute.name.startsWith("aria-"))
10160
10863
  }
10161
10864
 
10865
+ #observeAriaAttributes() {
10866
+ const observer = new MutationObserver(mutations => {
10867
+ for (const { attributeName } of mutations) {
10868
+ if (attributeName.startsWith("aria-")) {
10869
+ this.#synchronizeAriaAttribute(attributeName);
10870
+ } else if (attributeName === "single-line") {
10871
+ this.#synchronizeAriaAttribute("aria-multiline");
10872
+ }
10873
+ }
10874
+ });
10875
+ observer.observe(this, { attributes: true });
10876
+
10877
+ this.#listeners.track(
10878
+ () => observer.disconnect(),
10879
+ registerEventListener(this, "invalid", () => {
10880
+ this.#validationAttempted = true;
10881
+ this.#synchronizeAriaAttribute("aria-invalid");
10882
+ })
10883
+ );
10884
+ }
10885
+
10886
+ #synchronizeAriaAttribute(name) {
10887
+ const value = this.getAttribute(name) ?? this.#defaultAriaValue(name);
10888
+ if (value === null) {
10889
+ this.editorContentElement.removeAttribute(name);
10890
+ } else if (this.editorContentElement.getAttribute(name) !== String(value)) {
10891
+ this.editorContentElement.setAttribute(name, value);
10892
+ }
10893
+ }
10894
+
10895
+ #defaultAriaValue(name) {
10896
+ switch (name) {
10897
+ case "aria-label": return this.#labelText
10898
+ case "aria-required": return this.required
10899
+ case "aria-multiline": return this.supportsMultiLine
10900
+ case "aria-invalid": return this.#validationAttempted && !this.validity.valid
10901
+ default: return null
10902
+ }
10903
+ }
10904
+
10162
10905
  #setInternalFormValue(html, { suppressEvent = false } = {}) {
10163
10906
  const changed = html !== this.#previousInternalFormValue;
10164
10907
 
@@ -10215,11 +10958,16 @@ class LexicalEditorElement extends HTMLElement {
10215
10958
  #refreshValidity() {
10216
10959
  this.#refreshInternalValidity();
10217
10960
  const { validity, message } = this.#calculateValidity();
10218
- this.internals.setValidity(validity, message, this.editorContentElement);
10961
+ this.internals.setValidity(validity, message, this.editorContentElement ?? undefined);
10962
+ if (this.editorContentElement) {
10963
+ this.#synchronizeAriaAttribute("aria-invalid");
10964
+ }
10219
10965
  }
10220
10966
 
10221
10967
  #refreshInternalValidity() {
10222
- this.#validationTextArea.required = this.required && this.isBlank;
10968
+ if (this.editorContentElement) {
10969
+ this.#validationTextArea.required = this.required && this.isBlank;
10970
+ }
10223
10971
  const flags = this.#validationTextArea.validity;
10224
10972
  const message = this.#validationTextArea.validationMessage;
10225
10973
 
@@ -10234,12 +10982,14 @@ class LexicalEditorElement extends HTMLElement {
10234
10982
  // internal TextArea's ValidityState can contain `valid: true`
10235
10983
  if (flags.valid === true) continue
10236
10984
 
10985
+ let hasError = false;
10237
10986
  for (const flag in flags) {
10238
10987
  if (flags[flag]) {
10239
10988
  validity[flag] = true;
10240
- messages.push(message);
10989
+ hasError = true;
10241
10990
  }
10242
10991
  }
10992
+ if (hasError) messages.push(message);
10243
10993
  }
10244
10994
 
10245
10995
  return { validity, message: messages.join("\n") }
@@ -10260,6 +11010,9 @@ class LexicalEditorElement extends HTMLElement {
10260
11010
  );
10261
11011
  this.#registerTableComponents();
10262
11012
  this.#registerCodeLanguagePicker();
11013
+ if (this.supportsAttachments) {
11014
+ this.#registerAttachmentToolbar();
11015
+ }
10263
11016
  if (this.supportsMarkdown) {
10264
11017
  const transformers = [ ...TRANSFORMERS, HORIZONTAL_DIVIDER ];
10265
11018
  registered.push(
@@ -10288,6 +11041,15 @@ class LexicalEditorElement extends HTMLElement {
10288
11041
  this.#disposables.push(codeLanguagePicker);
10289
11042
  }
10290
11043
 
11044
+ #registerAttachmentToolbar() {
11045
+ let attachmentToolbar = this.querySelector("lexxy-attachment-toolbar");
11046
+ attachmentToolbar ??= createElement("lexxy-attachment-toolbar");
11047
+ this.append(attachmentToolbar);
11048
+ this.#disposables.push(attachmentToolbar);
11049
+ this.captionEditor = new CaptionEditor(this);
11050
+ this.#disposables.push(this.captionEditor);
11051
+ }
11052
+
10291
11053
  #handleEnter() {
10292
11054
  // We can't prevent these externally using regular keydown because Lexical handles it first.
10293
11055
  this.#listeners.track(this.editor.registerCommand(
@@ -10496,21 +11258,22 @@ class LexicalEditorElement extends HTMLElement {
10496
11258
  // — triggering at most one forced reflow. The previous implementation interleaved
10497
11259
  // setProperty/getComputedStyle/removeProperty on the same element, forcing a style
10498
11260
  // recalc on every iteration during editor initialization.
10499
- #resolveColors(property, cssValues) {
11261
+ #resolveColors(property, buttons) {
10500
11262
  const container = document.createElement("span");
10501
11263
  container.style.display = "none";
10502
11264
 
10503
- const resolvers = cssValues.map(cssValue => {
11265
+ const resolvers = normalizeColorButtons(buttons).map(({ value, label }) => {
10504
11266
  const element = document.createElement("span");
10505
- element.style.setProperty(property, cssValue);
11267
+ element.style.setProperty(property, value);
10506
11268
  container.appendChild(element);
10507
- return { element, name: cssValue }
11269
+ return { element, name: value, label }
10508
11270
  });
10509
11271
 
10510
11272
  styleResolverRoot().appendChild(container);
10511
11273
 
10512
- const resolved = resolvers.map(({ element, name }) => ({
11274
+ const resolved = resolvers.map(({ element, name, label }) => ({
10513
11275
  name,
11276
+ label,
10514
11277
  value: window.getComputedStyle(element).getPropertyValue(property)
10515
11278
  }));
10516
11279
 
@@ -10544,6 +11307,7 @@ class LexicalEditorElement extends HTMLElement {
10544
11307
 
10545
11308
  #resetValidity() {
10546
11309
  this.#validity = new Map();
11310
+ this.#validationAttempted = false;
10547
11311
  }
10548
11312
  }
10549
11313
 
@@ -10574,6 +11338,39 @@ function $getReadableTextContent(node) {
10574
11338
  return node.getTextContent()
10575
11339
  }
10576
11340
 
11341
+ const REMOVAL_DELAY = 1000;
11342
+
11343
+ class LiveRegion extends HTMLElement {
11344
+ #additions = createElement("span", { ariaLive: "assertive", ariaRelevant: "additions" })
11345
+ #removalTimeouts = new Set()
11346
+
11347
+ connectedCallback() {
11348
+ this.replaceChildren(this.#additions);
11349
+ }
11350
+
11351
+ disconnectedCallback() {
11352
+ this.dispose();
11353
+ }
11354
+
11355
+ dispose() {
11356
+ for (const timeout of this.#removalTimeouts) clearTimeout(timeout);
11357
+ this.#removalTimeouts.clear();
11358
+ this.#additions.replaceChildren();
11359
+ this.replaceChildren();
11360
+ }
11361
+
11362
+ announce(message) {
11363
+ const announcement = createElement("div", { textContent: message });
11364
+ this.#additions.appendChild(announcement);
11365
+
11366
+ const timeout = setTimeout(() => {
11367
+ announcement.remove();
11368
+ this.#removalTimeouts.delete(timeout);
11369
+ }, REMOVAL_DELAY);
11370
+ this.#removalTimeouts.add(timeout);
11371
+ }
11372
+ }
11373
+
10577
11374
  class BaseSource {
10578
11375
  // Template method to override
10579
11376
  async buildListItems(filter = "") {
@@ -11456,50 +12253,373 @@ class CodeLanguagePicker extends HTMLElement {
11456
12253
  }
11457
12254
  }
11458
12255
 
12256
+ class AlternativeTextDialog {
12257
+ #editor
12258
+ #editorElement
12259
+ #dialog
12260
+ #input
12261
+ #nodeKey
12262
+ #elementToRefocus
12263
+ #listeners = new ListenerBin()
12264
+
12265
+ constructor(editorElement) {
12266
+ this.#editor = editorElement.editor;
12267
+ this.#editorElement = editorElement;
12268
+ this.#dialog = createElement("dialog", { className: "lexxy-alternative-text-dialog", ariaLabel: "Alternative text" });
12269
+ this.#input = createElement("textarea", { rows: 1, autofocus: true, ariaLabel: "Description", placeholder: "Keep it short, but enough to convey the context…" });
12270
+
12271
+ const title = createElement("h2", { textContent: "Describe the image for people who can't see it:" });
12272
+
12273
+ const actions = createElement("div", { className: "lexxy-alternative-text-dialog__actions" });
12274
+ const cancel = createElement("button", { type: "button", textContent: "Cancel" });
12275
+ const save = createElement("button", { type: "button", className: "lexxy-alternative-text-dialog__save", textContent: "Save" });
12276
+ actions.append(cancel, save);
12277
+
12278
+ const controls = createElement("div", { className: "lexxy-alternative-text-dialog__controls" });
12279
+ controls.append(this.#input, actions);
12280
+ this.#dialog.append(title, controls);
12281
+ editorElement.appendChild(this.#dialog);
12282
+
12283
+ this.#listeners.track(
12284
+ registerEventListener(cancel, "click", () => this.#close()),
12285
+ registerEventListener(save, "click", () => this.#save()),
12286
+ registerEventListener(this.#dialog, "keydown", this.#handleKeyDown),
12287
+ registerEventListener(document, "pointerdown", this.#closeOnClickOutside)
12288
+ );
12289
+ }
12290
+
12291
+ dispose() {
12292
+ this.#dialog.remove();
12293
+ this.#listeners.dispose();
12294
+ }
12295
+
12296
+ open(nodeKey) {
12297
+ const description = this.#editor.read(() => {
12298
+ const node = $getNodeByKey(nodeKey);
12299
+ if ($isActionTextAttachmentNode(node)) {
12300
+ return node.altText
12301
+ }
12302
+ });
12303
+
12304
+ if (description !== undefined) {
12305
+ this.#nodeKey = nodeKey;
12306
+ this.#input.value = description;
12307
+ this.#anchorBelowAttachment(nodeKey);
12308
+ this.#elementToRefocus = document.activeElement;
12309
+ this.#editorElement.querySelector("lexxy-toolbar")?.closeDropdowns();
12310
+ this.#dialog.show();
12311
+ this.#input.focus();
12312
+ }
12313
+ }
12314
+
12315
+ #anchorBelowAttachment(nodeKey) {
12316
+ const figureElement = this.#editor.getElementByKey(nodeKey);
12317
+ if (figureElement) {
12318
+ const rect = figureElement.getBoundingClientRect();
12319
+ const editorRect = this.#editorElement.getBoundingClientRect();
12320
+ const bottom = rect.bottom - editorRect.top - this.#editorElement.clientTop + this.#editorElement.scrollTop;
12321
+ this.#dialog.style.setProperty("--lexxy-anchor-bottom", `${bottom}px`);
12322
+ }
12323
+ }
12324
+
12325
+ #save() {
12326
+ const description = this.#input.value.trim();
12327
+ this.#editor.update(() => {
12328
+ const node = $getNodeByKey(this.#nodeKey);
12329
+ if ($isActionTextAttachmentNode(node) && node.altText !== description) {
12330
+ node.getWritable().altText = description;
12331
+ }
12332
+ }, { tag: [ HISTORY_PUSH_TAG, SKIP_DOM_SELECTION_TAG ] });
12333
+ this.#close();
12334
+ }
12335
+
12336
+ #handleKeyDown = (event) => {
12337
+ event.stopPropagation();
12338
+ if (event.key === "Escape") {
12339
+ this.#close();
12340
+ } else {
12341
+ trapFocusAtTabBoundary(this.#dialog, event);
12342
+ }
12343
+ }
12344
+
12345
+ // Dismissing by clicking elsewhere must leave focus where the click landed.
12346
+ #closeOnClickOutside = (event) => {
12347
+ if (this.#dialog.open && !this.#dialog.contains(event.target)) {
12348
+ this.#close({ refocus: false });
12349
+ }
12350
+ }
12351
+
12352
+ #close({ refocus = true } = {}) {
12353
+ this.#dialog.close();
12354
+ if (refocus && this.#elementToRefocus?.isConnected) {
12355
+ this.#elementToRefocus.focus();
12356
+ }
12357
+ this.#elementToRefocus = null;
12358
+ }
12359
+ }
12360
+
11459
12361
  const DELETE_ICON = `<svg viewBox="0 0 18 18" xmlns="http://www.w3.org/2000/svg">
11460
12362
  <path d="M11.2041 1.01074C12.2128 1.113 13 1.96435 13 3V4H15L15.1025 4.00488C15.6067 4.05621 16 4.48232 16 5C16 5.55228 15.5523 6 15 6H14.8457L14.1416 15.1533C14.0614 16.1953 13.1925 17 12.1475 17H5.85254L5.6582 16.9902C4.76514 16.9041 4.03607 16.2296 3.88184 15.3457L3.8584 15.1533L3.1543 6H3C2.44772 6 2 5.55228 2 5C2 4.44772 2.44772 4 3 4H5V3C5 1.89543 5.89543 1 7 1H11L11.2041 1.01074ZM5.85254 15H12.1475L12.8398 6H5.16016L5.85254 15ZM7 4H11V3H7V4Z"/>
11461
12363
  </svg>`;
11462
12364
 
11463
- class NodeDeleteButton extends HTMLElement {
12365
+ class AttachmentToolbar extends HTMLElement {
12366
+ #listeners = new ListenerBin()
12367
+ #buttons = []
12368
+ #alternativeTextButton
12369
+ #alternativeTextDialog
12370
+ #currentNodeKey = null
12371
+ #reflowObserver = new ResizeObserver(() => this.#updatePosition())
12372
+ #presentationObserver = new MutationObserver(() => this.#refresh())
12373
+ #observedElement = null
12374
+
11464
12375
  connectedCallback() {
11465
- this.editorElement = this.closest("lexxy-editor");
11466
- this.editor = this.editorElement.editor;
11467
12376
  this.classList.add("lexxy-floating-controls");
12377
+ this.role = "toolbar";
12378
+ this.hidden = true;
11468
12379
 
11469
- if (!this.querySelector(".lexxy-node-delete")) {
11470
- this.#attachDeleteButton();
12380
+ if (this.#editor) {
12381
+ this.#setUpButtons();
12382
+ this.#monitorSelection();
12383
+ this.#followScrolling();
12384
+ this.#registerKeyboardShortcut();
11471
12385
  }
11472
12386
  }
11473
12387
 
11474
12388
  disconnectedCallback() {
11475
- this.editor = null;
11476
- this.editorElement = null;
12389
+ this.dispose();
12390
+ }
12391
+
12392
+ dispose() {
12393
+ this.#listeners.dispose();
12394
+ this.#reflowObserver.disconnect();
12395
+ this.#presentationObserver.disconnect();
12396
+ this.#alternativeTextDialog?.dispose();
12397
+ }
12398
+
12399
+ get #editor() {
12400
+ return this.#editorElement?.editor
12401
+ }
12402
+
12403
+ get #editorElement() {
12404
+ return this.closest("lexxy-editor")
11477
12405
  }
11478
12406
 
11479
- #attachDeleteButton() {
12407
+ #setUpButtons() {
12408
+ this.innerHTML = "";
12409
+
11480
12410
  const container = createElement("div", { className: "lexxy-floating-controls__group" });
12411
+ container.appendChild(this.#createAlternativeTextButton());
12412
+ container.appendChild(this.#createRemoveButton());
12413
+ this.#alternativeTextDialog = new AlternativeTextDialog(this.#editorElement);
12414
+ this.appendChild(container);
12415
+
12416
+ this.#buttons = Array.from(this.querySelectorAll("button"));
12417
+ this.#listeners.track(
12418
+ registerEventListener(this, "keydown", this.#navigateOrExit),
12419
+ registerEventListener(this, "focusout", this.#resetTabIndexOnExit)
12420
+ );
12421
+ }
11481
12422
 
11482
- this.deleteButton = createElement("button", {
11483
- className: "lexxy-node-delete",
12423
+ #createRemoveButton() {
12424
+ const button = createElement("button", {
11484
12425
  type: "button",
11485
- "aria-label": "Remove"
12426
+ className: "lexxy-attachment-toolbar__remove",
12427
+ ariaLabel: "Remove",
12428
+ tabIndex: -1
11486
12429
  });
11487
- this.deleteButton.tabIndex = -1;
11488
- this.deleteButton.innerHTML = DELETE_ICON;
12430
+ button.innerHTML = DELETE_ICON;
12431
+ this.#listeners.track(registerEventListener(button, "click", () => this.#removeSelectedNode()));
12432
+ return button
12433
+ }
11489
12434
 
11490
- this.handleDeleteClick = () => this.#deleteNode();
11491
- this.deleteButton.addEventListener("click", this.handleDeleteClick);
11492
- container.appendChild(this.deleteButton);
12435
+ #removeSelectedNode() {
12436
+ if (this.#hasSelectedNode) {
12437
+ this.#editor.update(() => $getNodeByKey(this.#currentNodeKey)?.remove());
12438
+ this.#editor.focus();
12439
+ }
12440
+ }
11493
12441
 
11494
- this.appendChild(container);
12442
+ get #hasSelectedNode() {
12443
+ return this.#currentNodeKey !== null
11495
12444
  }
11496
12445
 
11497
- #deleteNode() {
11498
- this.editor.update(() => {
11499
- const node = $getNearestNodeFromDOMNode(this);
11500
- node?.remove();
12446
+ #createAlternativeTextButton() {
12447
+ this.#alternativeTextButton = createElement("button", {
12448
+ type: "button",
12449
+ className: "lexxy-attachment-toolbar__alternative-text",
12450
+ ariaLabel: "Alternative text",
12451
+ title: "Alternative text",
12452
+ ariaHasPopup: "dialog",
12453
+ tabIndex: -1
12454
+ });
12455
+ this.#alternativeTextButton.appendChild(createElement("span", null, "ALT"));
12456
+ this.#listeners.track(registerEventListener(this.#alternativeTextButton, "click", () => {
12457
+ this.#alternativeTextDialog.open(this.#currentNodeKey);
12458
+ }));
12459
+ return this.#alternativeTextButton
12460
+ }
12461
+
12462
+ #navigateOrExit = (event) => {
12463
+ if (event.key === "Escape") {
12464
+ event.preventDefault();
12465
+ event.stopPropagation();
12466
+ this.#returnFocusToEditor();
12467
+ } else {
12468
+ handleRollingTabIndex(this.#buttons, event);
12469
+ }
12470
+ }
12471
+
12472
+ #returnFocusToEditor() {
12473
+ this.#editor.getRootElement()?.focus({ preventScroll: true });
12474
+ if (this.#currentNodeKey) {
12475
+ const nodeKey = this.#currentNodeKey;
12476
+ this.#editor.update(() => {
12477
+ const node = $getNodeByKey(nodeKey);
12478
+ if (node) $setSelection($createNodeSelectionWith(node));
12479
+ });
12480
+ }
12481
+ }
12482
+
12483
+ #resetTabIndexOnExit = (event) => {
12484
+ if (!this.contains(event.relatedTarget)) {
12485
+ this.#buttons.forEach(button => button.tabIndex = -1);
12486
+ }
12487
+ }
12488
+
12489
+ #monitorSelection() {
12490
+ this.#listeners.track(
12491
+ registerLabelledDecoratorSelection(this.#editor, (target) => this.#updateForSelection(target))
12492
+ );
12493
+ }
12494
+
12495
+ #updateForSelection(target) {
12496
+ if (target) {
12497
+ this.#show(target.key);
12498
+ } else {
12499
+ this.#hide();
12500
+ }
12501
+ }
12502
+
12503
+ #show(nodeKey) {
12504
+ this.#currentNodeKey = nodeKey;
12505
+ this.#refresh();
12506
+ this.hidden = false;
12507
+ this.#reflowObserver.observe(this.#editorElement);
12508
+ }
12509
+
12510
+ #refresh() {
12511
+ if (this.#hasSelectedNode) {
12512
+ this.#watchPresentation(this.#currentNodeKey);
12513
+ this.#describeNode(this.#currentNodeKey);
12514
+ this.#updatePosition();
12515
+ }
12516
+ }
12517
+
12518
+ // Failed previews change presentation without a Lexical update, and a node
12519
+ // can replace its figure without changing its key.
12520
+ #watchPresentation(nodeKey) {
12521
+ const element = this.#editor.getElementByKey(nodeKey);
12522
+ if (element !== this.#observedElement) {
12523
+ this.#presentationObserver.disconnect();
12524
+ this.#observedElement = element;
12525
+ if (element) this.#presentationObserver.observe(element, { attributes: true, attributeFilter: [ "class" ] });
12526
+ }
12527
+ }
12528
+
12529
+ #describeNode(nodeKey) {
12530
+ const element = this.#editor.getElementByKey(nodeKey);
12531
+
12532
+ this.#editor.getEditorState().read(() => {
12533
+ const node = $getNodeByKey(nodeKey);
12534
+ this.#alternativeTextButton.hidden = !this.#canEditAlternativeText(node);
12535
+ this.dataset.nodeType = node.getType();
12536
+ this.dataset.presentation = this.#presentationOf(node, element);
12537
+
12538
+ if (node.contentType) {
12539
+ this.dataset.contentType = node.contentType;
12540
+ } else {
12541
+ delete this.dataset.contentType;
12542
+ }
11501
12543
  });
11502
12544
  }
12545
+
12546
+ #canEditAlternativeText(node) {
12547
+ return this.#editorElement.dataset.actionTextSupportsAlt !== "false"
12548
+ && node.getType() === "action_text_attachment"
12549
+ && node.isPreviewableAttachment
12550
+ }
12551
+
12552
+ #presentationOf(node, element) {
12553
+ if ($isImageGalleryNode(node.getParent())) {
12554
+ return "gallery"
12555
+ } else if (node.isInline() && this.#rendersInline(element)) {
12556
+ return "inline"
12557
+ } else if (element?.classList.contains("attachment--preview")) {
12558
+ return "preview"
12559
+ } else {
12560
+ return "block"
12561
+ }
12562
+ }
12563
+
12564
+ #rendersInline(element) {
12565
+ return element != null && getComputedStyle(element).display.startsWith("inline")
12566
+ }
12567
+
12568
+ #updatePosition() {
12569
+ const figureElement = this.#editor.getElementByKey(this.#currentNodeKey);
12570
+ if (figureElement) {
12571
+ const rect = figureElement.getBoundingClientRect();
12572
+ const editorRect = this.#editorElement.getBoundingClientRect();
12573
+ // We are positioned against the editor's padding box, but the rect measures its border box.
12574
+ const borderTop = this.#editorElement.clientTop;
12575
+ const borderLeft = this.#editorElement.clientLeft;
12576
+ this.style.setProperty("--lexxy-anchor-top", `${rect.top - editorRect.top - borderTop}px`);
12577
+ this.style.setProperty("--lexxy-anchor-left", `${rect.left - editorRect.left - borderLeft}px`);
12578
+ this.style.setProperty("--lexxy-anchor-width", `${rect.width}px`);
12579
+ this.style.setProperty("--lexxy-anchor-height", `${rect.height}px`);
12580
+ this.#flipWhenOverflowing(editorRect);
12581
+ }
12582
+ }
12583
+
12584
+ #flipWhenOverflowing(editorRect) {
12585
+ this.removeAttribute("data-overflow");
12586
+ if (this.getBoundingClientRect().right > editorRect.right) {
12587
+ this.setAttribute("data-overflow", "");
12588
+ }
12589
+ }
12590
+
12591
+ #hide() {
12592
+ if (this.#hasSelectedNode) {
12593
+ this.#currentNodeKey = null;
12594
+ this.hidden = true;
12595
+ this.#reflowObserver.disconnect();
12596
+ this.#presentationObserver.disconnect();
12597
+ this.#observedElement = null;
12598
+ }
12599
+ }
12600
+
12601
+ // Scrolling inside the editor moves the figure without resizing anything.
12602
+ #followScrolling() {
12603
+ this.#listeners.track(registerEventListener(this.#editorElement, "scroll", () => this.#updatePosition(), { capture: true, passive: true }));
12604
+ }
12605
+
12606
+ #registerKeyboardShortcut() {
12607
+ this.#listeners.track(this.#editor.registerCommand(
12608
+ KEY_DOWN_COMMAND,
12609
+ this.#focusToolbarOnAltF10,
12610
+ COMMAND_PRIORITY_HIGH
12611
+ ));
12612
+ }
12613
+
12614
+ #focusToolbarOnAltF10 = (event) => {
12615
+ if (this.#hasSelectedNode && event.altKey && event.key === "F10") {
12616
+ event.preventDefault();
12617
+ this.#buttons.find(isActiveAndVisible)?.focus();
12618
+ return true
12619
+ } else {
12620
+ return false
12621
+ }
12622
+ }
11503
12623
  }
11504
12624
 
11505
12625
  class TableController {
@@ -11904,7 +13024,7 @@ class TableTools extends HTMLElement {
11904
13024
  #listeners = new ListenerBin()
11905
13025
 
11906
13026
  connectedCallback() {
11907
- this.tableController = new TableController(this.#editorElement);
13027
+ this.tableController = new TableController(this.editorElement);
11908
13028
  this.classList.add("lexxy-floating-controls");
11909
13029
  this.role = "toolbar";
11910
13030
 
@@ -11925,16 +13045,34 @@ class TableTools extends HTMLElement {
11925
13045
  this.tableController = null;
11926
13046
  }
11927
13047
 
11928
- get #editor() {
11929
- return this.#editorElement.editor
13048
+ get editorElement() {
13049
+ return this.closest("lexxy-editor")
11930
13050
  }
11931
13051
 
11932
- get #editorElement() {
11933
- return this.closest("lexxy-editor")
13052
+ get editor() {
13053
+ return this.editorElement.editor
13054
+ }
13055
+
13056
+ getEditorElement() {
13057
+ return this.editorElement
13058
+ }
13059
+
13060
+ closeDropdowns({ except } = {}) {
13061
+ this.#moreMenus.forEach(menu => {
13062
+ if (menu !== except) menu.close({ focusEditor: false });
13063
+ });
13064
+ }
13065
+
13066
+ get #moreMenus() {
13067
+ return this.querySelectorAll("lexxy-toolbar-dropdown")
13068
+ }
13069
+
13070
+ get #hasOpenMoreMenu() {
13071
+ return Array.from(this.#moreMenus).some(menu => menu.isOpen)
11934
13072
  }
11935
13073
 
11936
13074
  get #tableToolsButtons() {
11937
- return Array.from(this.querySelectorAll("button, details > summary"))
13075
+ return Array.from(this.querySelectorAll("button")).filter(button => !button.closest("[data-dropdown-panel]"))
11938
13076
  }
11939
13077
 
11940
13078
  #setUpButtons() {
@@ -11953,23 +13091,37 @@ class TableTools extends HTMLElement {
11953
13091
  const plusButton = this.#createButton(`Add ${childType}`, { action: "insert", childType, direction: "after" }, "+");
11954
13092
  const minusButton = this.#createButton(`Remove ${childType}`, { action: "delete", childType }, "−");
11955
13093
 
11956
- const dropdown = createElement("details", { className: "lexxy-table-control__more-menu" });
11957
- dropdown.setAttribute("name", "lexxy-dropdown");
11958
- dropdown.tabIndex = -1;
11959
-
11960
- const count = createElement("summary", {}, `_ ${childType}s`);
11961
- setCountProperty(count);
11962
- dropdown.appendChild(count);
11963
-
11964
- dropdown.appendChild(moreMenu);
11965
-
11966
13094
  container.appendChild(minusButton);
11967
- container.appendChild(dropdown);
13095
+ container.appendChild(this.#createMoreMenu(childType, setCountProperty, moreMenu));
11968
13096
  container.appendChild(plusButton);
11969
13097
 
11970
13098
  return container
11971
13099
  }
11972
13100
 
13101
+ #createMoreMenu(childType, setCountProperty, menu) {
13102
+ const dropdown = createElement("lexxy-toolbar-dropdown", { className: "lexxy-table-control__more-menu" });
13103
+
13104
+ const trigger = createElement("button", {
13105
+ type: "button",
13106
+ className: "lexxy-table-control__more-menu-trigger",
13107
+ dataset: { dropdownTrigger: "" },
13108
+ "aria-haspopup": "menu",
13109
+ "aria-expanded": "false"
13110
+ }, `_ ${childType}s`);
13111
+ trigger.tabIndex = -1;
13112
+ setCountProperty(trigger);
13113
+
13114
+ menu.dataset.dropdownPanel = "";
13115
+ menu.role = "menu";
13116
+ menu.setAttribute("aria-label", `${childType} options`);
13117
+ menu.hidden = true;
13118
+
13119
+ dropdown.appendChild(trigger);
13120
+ dropdown.appendChild(menu);
13121
+
13122
+ return dropdown
13123
+ }
13124
+
11973
13125
  #createRowButtonsContainer() {
11974
13126
  return this.#createButtonsContainer(
11975
13127
  "row",
@@ -11988,15 +13140,18 @@ class TableTools extends HTMLElement {
11988
13140
 
11989
13141
  #createMoreMenuSection(childType) {
11990
13142
  const section = createElement("div", { className: "lexxy-floating-controls__group lexxy-table-control__more-menu-details" });
11991
- const addBeforeButton = this.#createButton(`Add ${childType} before`, { action: "insert", childType, direction: "before" });
11992
- const addAfterButton = this.#createButton(`Add ${childType} after`, { action: "insert", childType, direction: "after" });
11993
- const toggleStyleButton = this.#createButton(`Toggle ${childType} style`, { action: "toggle", childType });
11994
- const deleteButton = this.#createButton(`Remove ${childType}`, { action: "delete", childType });
11995
13143
 
11996
- section.appendChild(addBeforeButton);
11997
- section.appendChild(addAfterButton);
11998
- section.appendChild(toggleStyleButton);
11999
- section.appendChild(deleteButton);
13144
+ const items = [
13145
+ this.#createButton(`Add ${childType} before`, { action: "insert", childType, direction: "before" }),
13146
+ this.#createButton(`Add ${childType} after`, { action: "insert", childType, direction: "after" }),
13147
+ this.#createButton(`Toggle ${childType} style`, { action: "toggle", childType }),
13148
+ this.#createButton(`Remove ${childType}`, { action: "delete", childType })
13149
+ ];
13150
+
13151
+ items.forEach(item => {
13152
+ item.role = "menuitem";
13153
+ section.appendChild(item);
13154
+ });
12000
13155
 
12001
13156
  return section
12002
13157
  }
@@ -12037,7 +13192,7 @@ class TableTools extends HTMLElement {
12037
13192
  }
12038
13193
 
12039
13194
  #registerKeyboardShortcuts() {
12040
- this.#listeners.track(this.#editor.registerCommand(KEY_DOWN_COMMAND, this.#focusToolbarOnAltF10, COMMAND_PRIORITY_HIGH));
13195
+ this.#listeners.track(this.editor.registerCommand(KEY_DOWN_COMMAND, this.#focusToolbarOnAltF10, COMMAND_PRIORITY_HIGH));
12041
13196
  }
12042
13197
 
12043
13198
  #focusToolbarOnAltF10 = (event) => {
@@ -12068,9 +13223,9 @@ class TableTools extends HTMLElement {
12068
13223
  const cell = this.tableController.currentCell;
12069
13224
  if (!cell) return
12070
13225
 
12071
- this.#editor.update(() => {
13226
+ this.editor.update(() => {
12072
13227
  cell.select();
12073
- this.#editor.focus();
13228
+ this.editor.focus();
12074
13229
  });
12075
13230
 
12076
13231
  this.#update();
@@ -12107,7 +13262,7 @@ class TableTools extends HTMLElement {
12107
13262
  if (!cellsToHighlight) return
12108
13263
 
12109
13264
  cellsToHighlight.forEach(cell => {
12110
- const cellElement = this.#editor.getElementByKey(cell.getKey());
13265
+ const cellElement = this.editor.getElementByKey(cell.getKey());
12111
13266
  if (!cellElement) return
12112
13267
 
12113
13268
  cellElement.classList.toggle(theme.tableCellHighlight, true);
@@ -12116,8 +13271,8 @@ class TableTools extends HTMLElement {
12116
13271
  }
12117
13272
 
12118
13273
  #monitorForTableSelection() {
12119
- this.#listeners.track(this.#editor.registerUpdateListener(() => {
12120
- const tableNode = this.#editor.getRootElement() && this.tableController.updateSelectedTable();
13274
+ this.#listeners.track(this.editor.registerUpdateListener(() => {
13275
+ const tableNode = this.editor.getRootElement() && this.tableController.updateSelectedTable();
12121
13276
 
12122
13277
  if (tableNode) {
12123
13278
  this.#show();
@@ -12128,8 +13283,12 @@ class TableTools extends HTMLElement {
12128
13283
  }
12129
13284
 
12130
13285
  #executeTableCommand(command) {
13286
+ const fromMoreMenu = this.#hasOpenMoreMenu;
13287
+
12131
13288
  this.tableController.executeTableCommand(command);
12132
13289
  this.#update();
13290
+
13291
+ if (fromMoreMenu) this.editor.focus();
12133
13292
  }
12134
13293
 
12135
13294
  #show() {
@@ -12153,18 +13312,18 @@ class TableTools extends HTMLElement {
12153
13312
  }
12154
13313
 
12155
13314
  #closeMoreMenu() {
12156
- this.querySelector("details[open]")?.removeAttribute("open");
13315
+ this.closeDropdowns();
12157
13316
  }
12158
13317
 
12159
13318
  #updateButtonsPosition() {
12160
13319
  const tableNode = this.tableController.currentTableNode;
12161
13320
  if (!tableNode) return
12162
13321
 
12163
- const tableElement = this.#editor.getElementByKey(tableNode.getKey());
13322
+ const tableElement = this.editor.getElementByKey(tableNode.getKey());
12164
13323
  if (!tableElement) return
12165
13324
 
12166
13325
  const tableRect = tableElement.getBoundingClientRect();
12167
- const editorRect = this.#editorElement.getBoundingClientRect();
13326
+ const editorRect = this.editorElement.getBoundingClientRect();
12168
13327
 
12169
13328
  const relativeTop = tableRect.top - editorRect.top;
12170
13329
  const relativeCenter = (tableRect.left + tableRect.right) / 2 - editorRect.left;
@@ -12176,7 +13335,7 @@ class TableTools extends HTMLElement {
12176
13335
  const tableNode = this.tableController.currentTableNode;
12177
13336
  if (!tableNode) return
12178
13337
 
12179
- const tableElement = $getElementForTableNode(this.#editor, tableNode);
13338
+ const tableElement = $getElementForTableNode(this.editor, tableNode);
12180
13339
  if (!tableElement) return
12181
13340
 
12182
13341
  const rowCount = tableElement.rows;
@@ -12190,18 +13349,18 @@ class TableTools extends HTMLElement {
12190
13349
  const cell = this.tableController.currentCell;
12191
13350
  if (!cell) return
12192
13351
 
12193
- const cellElement = this.#editor.getElementByKey(cell.getKey());
12194
- if (!cellElement) return
13352
+ const cellElement = this.editor.getElementByKey(cell.getKey());
13353
+ if (!cellElement?.contains(document.getSelection()?.anchorNode)) return
12195
13354
 
12196
13355
  cellElement.classList.add(theme.tableCellFocus);
12197
13356
  }
12198
13357
 
12199
13358
  #clearCellStyles() {
12200
- this.#editorElement.querySelectorAll(`.${theme.tableCellFocus}`)?.forEach(cell => {
13359
+ this.editorElement.querySelectorAll(`.${theme.tableCellFocus}`)?.forEach(cell => {
12201
13360
  cell.classList.remove(theme.tableCellFocus);
12202
13361
  });
12203
13362
 
12204
- this.#editorElement.querySelectorAll(`.${theme.tableCellHighlight}`)?.forEach(cell => {
13363
+ this.editorElement.querySelectorAll(`.${theme.tableCellHighlight}`)?.forEach(cell => {
12205
13364
  cell.classList.remove(theme.tableCellHighlight);
12206
13365
  cell.removeAttribute("data-action");
12207
13366
  cell.removeAttribute("data-child-type");
@@ -12233,7 +13392,8 @@ function defineElements() {
12233
13392
  // Prompt must be registered AFTER Editor
12234
13393
  "lexxy-prompt": LexicalPromptElement,
12235
13394
  "lexxy-code-language-picker": CodeLanguagePicker,
12236
- "lexxy-node-delete-button": NodeDeleteButton,
13395
+ "lexxy-live-region": LiveRegion,
13396
+ "lexxy-attachment-toolbar": AttachmentToolbar,
12237
13397
  "lexxy-table-tools": TableTools
12238
13398
  };
12239
13399