@37signals/lexxy 0.9.32 → 0.9.33

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, 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,16 @@ 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 handleRollingTabIndex(elements, event, { orientation = "horizontal", wrap = false } = {}) {
572
593
  const previousActiveElement = document.activeElement;
573
594
 
574
595
  if (elements.includes(previousActiveElement)) {
575
- const finder = new NextElementFinder(elements, event.key);
596
+ const finder = new NextElementFinder(elements, event.key, { orientation, wrap });
576
597
 
577
598
  if (finder.selectNext(previousActiveElement)) {
578
599
  event.preventDefault();
@@ -581,9 +602,11 @@ function handleRollingTabIndex(elements, event) {
581
602
  }
582
603
 
583
604
  class NextElementFinder {
584
- constructor(elements, key) {
605
+ constructor(elements, key, { orientation, wrap }) {
585
606
  this.elements = elements;
586
607
  this.key = key;
608
+ this.orientation = orientation;
609
+ this.wrap = wrap;
587
610
  }
588
611
 
589
612
  selectNext(fromElement) {
@@ -600,23 +623,36 @@ class NextElementFinder {
600
623
  }
601
624
 
602
625
  #findNextElement(fromElement) {
603
- switch (this.key) {
604
- case "ArrowRight":
605
- case "ArrowDown":
626
+ switch (this.#directionFor(this.key)) {
627
+ case "next":
606
628
  return this.#findNextSibling(fromElement)
607
629
 
608
- case "ArrowLeft":
609
- case "ArrowUp":
630
+ case "previous":
610
631
  return this.#findPreviousSibling(fromElement)
611
632
 
612
- case "Home":
633
+ case "first":
613
634
  return this.#findFirst()
614
635
 
615
- case "End":
636
+ case "last":
616
637
  return this.#findLast()
617
638
  }
618
639
  }
619
640
 
641
+ #directionFor(key) {
642
+ if (key === "Home") return "first"
643
+ if (key === "End") return "last"
644
+ if (this.#nextKeys.includes(key)) return "next"
645
+ if (this.#previousKeys.includes(key)) return "previous"
646
+ }
647
+
648
+ get #nextKeys() {
649
+ return this.orientation === "both" ? [ "ArrowRight", "ArrowDown" ] : [ "ArrowRight" ]
650
+ }
651
+
652
+ get #previousKeys() {
653
+ return this.orientation === "both" ? [ "ArrowLeft", "ArrowUp" ] : [ "ArrowLeft" ]
654
+ }
655
+
620
656
  #findFirst(elements = this.elements) {
621
657
  return elements.find(isActiveAndVisible)
622
658
  }
@@ -627,12 +663,20 @@ class NextElementFinder {
627
663
 
628
664
  #findNextSibling(element) {
629
665
  const afterElements = this.elements.slice(this.#indexOf(element) + 1);
630
- return this.#findFirst(afterElements)
666
+ return this.#findFirst(afterElements) ?? this.#wrapToFirst()
631
667
  }
632
668
 
633
669
  #findPreviousSibling(element) {
634
670
  const beforeElements = this.elements.slice(0, this.#indexOf(element));
635
- return this.#findLast(beforeElements)
671
+ return this.#findLast(beforeElements) ?? this.#wrapToLast()
672
+ }
673
+
674
+ #wrapToFirst() {
675
+ return this.wrap ? this.#findFirst() : undefined
676
+ }
677
+
678
+ #wrapToLast() {
679
+ return this.wrap ? this.#findLast() : undefined
636
680
  }
637
681
 
638
682
  #indexOf(element) {
@@ -653,29 +697,29 @@ class NextElementFinder {
653
697
 
654
698
  var ToolbarIcons = {
655
699
  "bold":
656
- `<svg viewBox="0 0 18 18" xmlns="http://www.w3.org/2000/svg">
700
+ `<svg viewBox="0 0 18 18" xmlns="http://www.w3.org/2000/svg" aria-hidden="true">
657
701
  <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
702
  </svg>`,
659
703
 
660
704
  "italic":
661
- `<svg viewBox="0 0 18 18" xmlns="http://www.w3.org/2000/svg">
705
+ `<svg viewBox="0 0 18 18" xmlns="http://www.w3.org/2000/svg" aria-hidden="true">
662
706
  <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
707
  </svg>`,
664
708
 
665
709
  "strikethrough":
666
- `<svg viewBox="0 0 18 18" xmlns="http://www.w3.org/2000/svg">
710
+ `<svg viewBox="0 0 18 18" xmlns="http://www.w3.org/2000/svg" aria-hidden="true">
667
711
  <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
712
  <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
713
  </svg>`,
670
714
 
671
715
  "underline":
672
- `<svg viewBox="0 0 18 18" xmlns="http://www.w3.org/2000/svg">
716
+ `<svg viewBox="0 0 18 18" xmlns="http://www.w3.org/2000/svg" aria-hidden="true">
673
717
  <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
718
  <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
719
  </svg>`,
676
720
 
677
721
  "heading":
678
- `<svg viewBox="0 0 18 18" xmlns="http://www.w3.org/2000/svg">
722
+ `<svg viewBox="0 0 18 18" xmlns="http://www.w3.org/2000/svg" aria-hidden="true">
679
723
  <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
724
  </svg>`,
681
725
 
@@ -685,17 +729,17 @@ var ToolbarIcons = {
685
729
  </svg>`,
686
730
 
687
731
  "h2":
688
- `<svg viewBox="0 0 18 18" xmlns="http://www.w3.org/2000/svg">
732
+ `<svg viewBox="0 0 18 18" xmlns="http://www.w3.org/2000/svg" aria-hidden="true">
689
733
  <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
734
  </svg>`,
691
735
 
692
736
  "h3":
693
- `<svg viewBox="0 0 18 18" xmlns="http://www.w3.org/2000/svg">
737
+ `<svg viewBox="0 0 18 18" xmlns="http://www.w3.org/2000/svg" aria-hidden="true">
694
738
  <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
739
  </svg>`,
696
740
 
697
741
  "h4":
698
- `<svg viewBox="0 0 18 18" xmlns="http://www.w3.org/2000/svg">
742
+ `<svg viewBox="0 0 18 18" xmlns="http://www.w3.org/2000/svg" aria-hidden="true">
699
743
  <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
744
  </svg>`,
701
745
 
@@ -710,44 +754,44 @@ var ToolbarIcons = {
710
754
  </svg>`,
711
755
 
712
756
  "paragraph":
713
- `<svg viewBox="0 0 18 18" xmlns="http://www.w3.org/2000/svg">
757
+ `<svg viewBox="0 0 18 18" xmlns="http://www.w3.org/2000/svg" aria-hidden="true">
714
758
  <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
759
  </svg>`,
716
760
 
717
761
  "clearFormatting":
718
- `<svg viewBox="0 0 18 18" xmlns="http://www.w3.org/2000/svg">
762
+ `<svg viewBox="0 0 18 18" xmlns="http://www.w3.org/2000/svg" aria-hidden="true">
719
763
  <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
764
  </svg>`,
721
765
 
722
766
  "highlight":
723
- `<svg viewBox="0 0 18 18" xmlns="http://www.w3.org/2000/svg">
767
+ `<svg viewBox="0 0 18 18" xmlns="http://www.w3.org/2000/svg" aria-hidden="true">
724
768
  <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
769
  </svg>`,
726
770
 
727
771
  "link":
728
- `<svg viewBox="0 0 18 18" xmlns="http://www.w3.org/2000/svg">
772
+ `<svg viewBox="0 0 18 18" xmlns="http://www.w3.org/2000/svg" aria-hidden="true">
729
773
  <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
774
  <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
775
  </svg>`,
732
776
 
733
777
  "quote":
734
- `<svg viewBox="0 0 18 18" xmlns="http://www.w3.org/2000/svg">
778
+ `<svg viewBox="0 0 18 18" xmlns="http://www.w3.org/2000/svg" aria-hidden="true">
735
779
  <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
780
  </svg>`,
737
781
 
738
782
  "code":
739
- `<svg viewBox="0 0 18 18" xmlns="http://www.w3.org/2000/svg">
783
+ `<svg viewBox="0 0 18 18" xmlns="http://www.w3.org/2000/svg" aria-hidden="true">
740
784
  <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
785
  <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
786
  </svg>`,
743
787
 
744
788
  "ul":
745
- `<svg viewBox="0 0 18 18" xmlns="http://www.w3.org/2000/svg">
789
+ `<svg viewBox="0 0 18 18" xmlns="http://www.w3.org/2000/svg" aria-hidden="true">
746
790
  <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
791
  </svg>`,
748
792
 
749
793
  "ol":
750
- `<svg viewBox="0 0 18 18" xmlns="http://www.w3.org/2000/svg">
794
+ `<svg viewBox="0 0 18 18" xmlns="http://www.w3.org/2000/svg" aria-hidden="true">
751
795
  <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
796
  <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
797
  <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 +799,37 @@ var ToolbarIcons = {
755
799
  </svg>`,
756
800
 
757
801
  "image":
758
- `<svg viewBox="0 0 18 18" xmlns="http://www.w3.org/2000/svg">
802
+ `<svg viewBox="0 0 18 18" xmlns="http://www.w3.org/2000/svg" aria-hidden="true">
759
803
  <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
804
  </svg>`,
761
805
 
762
806
  "attachment":
763
- `<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">
764
808
  <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
809
  </svg>`,
766
810
 
767
811
  "table":
768
- `<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">
769
813
  <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
814
  </svg>`,
771
815
 
772
816
  "hr":
773
- `<svg viewBox="0 0 18 18" xmlns="http://www.w3.org/2000/svg">
817
+ `<svg viewBox="0 0 18 18" xmlns="http://www.w3.org/2000/svg" aria-hidden="true">
774
818
  <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
819
  </svg>`,
776
820
 
777
821
  "undo":
778
- `<svg viewBox="0 0 24 24" xmlns="http://www.w3.org/2000/svg">
822
+ `<svg viewBox="0 0 24 24" xmlns="http://www.w3.org/2000/svg" aria-hidden="true">
779
823
  <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
824
  </svg>`,
781
825
 
782
826
  "redo":
783
- `<svg viewBox="0 0 24 24" xmlns="http://www.w3.org/2000/svg">
827
+ `<svg viewBox="0 0 24 24" xmlns="http://www.w3.org/2000/svg" aria-hidden="true">
784
828
  <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
829
  </svg>`,
786
830
 
787
831
  "overflow":
788
- `<svg viewBox="0 0 18 18" xmlns="http://www.w3.org/2000/svg">
832
+ `<svg viewBox="0 0 18 18" xmlns="http://www.w3.org/2000/svg" aria-hidden="true">
789
833
  <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
834
  </svg>`
791
835
  };
@@ -912,14 +956,21 @@ class LexicalToolbarElement extends HTMLElement {
912
956
  }
913
957
  }
914
958
 
915
- #dispatchButtonCommand(event, { dataset: { command, payload } }) {
916
- const isKeyboard = event instanceof PointerEvent && event.pointerId === -1;
959
+ #dispatchButtonCommand(event, button) {
960
+ if (button.ariaDisabled === "true") return
961
+
962
+ const { command, payload } = button.dataset;
963
+ const shouldKeepToolbarFocus = isKeyboardActivation(event) && !this.#belongsToDropdown(button);
917
964
 
918
965
  this.editor.update(() => {
919
966
  this.editor.dispatchCommand(command, payload);
920
- }, { tag: isKeyboard ? SKIP_DOM_SELECTION_TAG : undefined });
967
+ }, { tag: shouldKeepToolbarFocus ? SKIP_DOM_SELECTION_TAG : undefined });
921
968
 
922
- if (!isKeyboard) this.editor.focus();
969
+ if (!shouldKeepToolbarFocus) this.editor.focus();
970
+ }
971
+
972
+ #belongsToDropdown(button) {
973
+ return button.closest("[data-dropdown-panel]") != null
923
974
  }
924
975
 
925
976
  #bindHotkeys() {
@@ -959,8 +1010,8 @@ class LexicalToolbarElement extends HTMLElement {
959
1010
  }
960
1011
 
961
1012
  #handleEditorFocus = () => {
962
- const firstVisible = this.#buttons.find(isActiveAndVisible);
963
- if (firstVisible) firstVisible.tabIndex = 0;
1013
+ const firstVisibleButton = this.#toolbarButtons.find(isActiveAndVisible);
1014
+ if (firstVisibleButton) firstVisibleButton.tabIndex = 0;
964
1015
  }
965
1016
 
966
1017
  #handleEditorBlur = () => {
@@ -968,11 +1019,11 @@ class LexicalToolbarElement extends HTMLElement {
968
1019
  }
969
1020
 
970
1021
  #handleKeydown = (event) => {
971
- handleRollingTabIndex(this.#buttons, event);
1022
+ handleRollingTabIndex(this.#toolbarButtons, event);
972
1023
  }
973
1024
 
974
1025
  #resetTabIndexValues() {
975
- this.#buttons.forEach((button) => {
1026
+ this.#toolbarButtons.forEach((button) => {
976
1027
  button.tabIndex = -1;
977
1028
  });
978
1029
  }
@@ -1009,7 +1060,7 @@ class LexicalToolbarElement extends HTMLElement {
1009
1060
  this.#setButtonPressed("underline", isUnderline);
1010
1061
 
1011
1062
  this.#setButtonPressed("format", isInHeading);
1012
- this.#setButtonPressed("paragraph", !isInHeading);
1063
+ this.#setButtonChecked("paragraph", !isInHeading);
1013
1064
 
1014
1065
  this.querySelector("lexxy-heading-dropdown")
1015
1066
  ?.updateActiveHeading(headingTag);
@@ -1028,25 +1079,17 @@ class LexicalToolbarElement extends HTMLElement {
1028
1079
 
1029
1080
  #setButtonPressed(name, isPressed) {
1030
1081
  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
- }
1082
+ if (button) button.ariaPressed = isPressed;
1083
+ }
1084
+
1085
+ #setButtonChecked(name, isChecked) {
1086
+ const button = this.querySelector(`[name="${name}"]`);
1087
+ if (button) button.ariaChecked = isChecked;
1037
1088
  }
1038
1089
 
1039
1090
  #setButtonDisabled(name, isDisabled) {
1040
1091
  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
- }
1092
+ if (button) button.ariaDisabled = isDisabled;
1050
1093
  }
1051
1094
 
1052
1095
  #refreshOverflow() {
@@ -1147,8 +1190,10 @@ class LexicalToolbarElement extends HTMLElement {
1147
1190
  return Array.from(this.querySelectorAll(":scope > button:not([data-prevent-overflow])"))
1148
1191
  }
1149
1192
 
1150
- get #buttons() {
1151
- return Array.from(this.querySelectorAll(":scope button"))
1193
+ get #toolbarButtons() {
1194
+ return Array.from(this.querySelectorAll(":scope button")).filter((button) => {
1195
+ return !button.closest("[data-dropdown-panel]")
1196
+ })
1152
1197
  }
1153
1198
 
1154
1199
  get #toolbarItems() {
@@ -1187,8 +1232,8 @@ class LexicalToolbarElement extends HTMLElement {
1187
1232
  <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
1233
  ${ToolbarIcons.heading}
1189
1234
  </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">
1235
+ <div data-dropdown-panel role="menu" aria-label="Text formatting" class="lexxy-editor__toolbar-dropdown-list" hidden>
1236
+ <button type="button" name="paragraph" data-command="setFormatParagraph" title="Paragraph" role="menuitemradio">
1192
1237
  ${ToolbarIcons.paragraph} <span>Normal</span>
1193
1238
  </button>
1194
1239
  <lexxy-heading-dropdown>
@@ -1205,7 +1250,7 @@ class LexicalToolbarElement extends HTMLElement {
1205
1250
  <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
1251
  ${ToolbarIcons.highlight}
1207
1252
  </button>
1208
- <div data-dropdown-panel role="menu" hidden>
1253
+ <div data-dropdown-panel role="menu" aria-label="Color highlight" hidden>
1209
1254
  <div class="lexxy-highlight-colors"></div>
1210
1255
  <button data-command="removeHighlight" type="button" class="lexxy-editor__toolbar-button lexxy-editor__toolbar-dropdown-reset" role="menuitem">Remove all coloring</button>
1211
1256
  </div>
@@ -1247,19 +1292,19 @@ class LexicalToolbarElement extends HTMLElement {
1247
1292
  ${ToolbarIcons.hr}
1248
1293
  </button>
1249
1294
 
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">
1295
+ <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
1296
  ${ToolbarIcons.undo}
1252
1297
  </button>
1253
1298
 
1254
- <button class="lexxy-editor__toolbar-button" type="button" name="redo" data-command="redo" title="Redo" disabled aria-disabled="true">
1299
+ <button class="lexxy-editor__toolbar-button" type="button" name="redo" data-command="redo" title="Redo" aria-disabled="true">
1255
1300
  ${ToolbarIcons.redo}
1256
1301
  </button>
1257
1302
 
1258
1303
  <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">
1304
+ <button data-dropdown-trigger class="lexxy-editor__toolbar-button" type="button" title="More options" aria-haspopup="menu" aria-expanded="false">
1260
1305
  ${ToolbarIcons.overflow}
1261
1306
  </button>
1262
- <div data-dropdown-panel role="menu" class="lexxy-editor__toolbar-overflow-menu" aria-label="More toolbar buttons" hidden></div>
1307
+ <div data-dropdown-panel role="menu" class="lexxy-editor__toolbar-overflow-menu" aria-label="More options" hidden></div>
1263
1308
  </lexxy-toolbar-dropdown>
1264
1309
  `
1265
1310
  }
@@ -1303,9 +1348,10 @@ function nextFrame() {
1303
1348
 
1304
1349
  class ToolbarDropdown extends HTMLElement {
1305
1350
  #listeners = new ListenerBin()
1351
+ #shouldReturnFocusToTrigger = false
1306
1352
 
1307
1353
  connectedCallback() {
1308
- this.#onToolbarEditor(() => {
1354
+ this.#onHostEditor(() => {
1309
1355
  this.#registerListeners();
1310
1356
  this.editorReady();
1311
1357
  });
@@ -1327,16 +1373,16 @@ class ToolbarDropdown extends HTMLElement {
1327
1373
  return this.querySelector(":scope > [data-dropdown-panel]")
1328
1374
  }
1329
1375
 
1330
- get toolbar() {
1331
- return this.closest("lexxy-toolbar")
1376
+ get host() {
1377
+ return this.closest("lexxy-toolbar, lexxy-table-tools")
1332
1378
  }
1333
1379
 
1334
1380
  get editorElement() {
1335
- return this.toolbar?.editorElement
1381
+ return this.host?.editorElement
1336
1382
  }
1337
1383
 
1338
1384
  get editor() {
1339
- return this.toolbar?.editor
1385
+ return this.host?.editor
1340
1386
  }
1341
1387
 
1342
1388
  get isOpen() {
@@ -1353,7 +1399,7 @@ class ToolbarDropdown extends HTMLElement {
1353
1399
 
1354
1400
  open() {
1355
1401
  if (this.isOpen) return
1356
- this.trigger.setAttribute("aria-expanded", "true");
1402
+ this.trigger.ariaExpanded = true;
1357
1403
  this.panel.hidden = false;
1358
1404
  this.onOpen();
1359
1405
  this.#focusFirstInteractive();
@@ -1363,7 +1409,7 @@ class ToolbarDropdown extends HTMLElement {
1363
1409
  if (focusEditor) this.editor?.focus();
1364
1410
 
1365
1411
  if (this.isClosed) return
1366
- this.trigger.setAttribute("aria-expanded", "false");
1412
+ this.trigger.ariaExpanded = false;
1367
1413
  this.panel.hidden = true;
1368
1414
  this.onClose();
1369
1415
  }
@@ -1375,31 +1421,46 @@ class ToolbarDropdown extends HTMLElement {
1375
1421
  );
1376
1422
  }
1377
1423
 
1378
- #handleTriggerClick = () => {
1424
+ #handleTriggerClick = (event) => {
1379
1425
  if (this.isOpen) {
1380
1426
  this.close({ focusEditor: false });
1381
1427
  } else {
1382
- this.toolbar?.closeDropdowns({ except: this });
1428
+ this.#shouldReturnFocusToTrigger = this.#isOpenedFromHost(event);
1429
+ this.host?.closeDropdowns({ except: this });
1383
1430
  this.open();
1384
1431
  }
1385
1432
  }
1386
1433
 
1387
- async #onToolbarEditor(callback) {
1388
- if (!this.toolbar) return
1434
+ #isOpenedFromHost(event) {
1435
+ return isKeyboardActivation(event) && this.host?.contains(document.activeElement)
1436
+ }
1389
1437
 
1390
- await this.toolbar.getEditorElement();
1391
- if (this.isConnected && this.toolbar) callback();
1438
+ async #onHostEditor(callback) {
1439
+ if (!this.host) return
1440
+
1441
+ await this.host.getEditorElement();
1442
+ if (this.isConnected && this.host) callback();
1392
1443
  }
1393
1444
 
1394
1445
  #handleKeyDown = (event) => {
1395
1446
  if (event.key === "Escape") {
1396
1447
  event.stopPropagation();
1397
- this.close();
1448
+ this.close({ focusEditor: !this.#shouldReturnFocusToTrigger });
1449
+ if (this.#shouldReturnFocusToTrigger) this.trigger?.focus();
1450
+ } else if (this.#isNavigatingMenu(event)) {
1451
+ event.stopPropagation();
1452
+ handleRollingTabIndex(this.#buttons, event, { orientation: "both", wrap: true });
1398
1453
  }
1399
1454
  }
1400
1455
 
1456
+ #isNavigatingMenu(event) {
1457
+ return this.panel.role === "menu" && this.panel.contains(event.target)
1458
+ }
1459
+
1401
1460
  async #focusFirstInteractive() {
1402
- this.#interactiveElements[0]?.focus();
1461
+ // Ask for the ring explicitly: opening the menu with the mouse otherwise leaves the
1462
+ // first item focused without a focus ring, since focus() inherits the mouse modality.
1463
+ this.#interactiveElements[0]?.focus({ focusVisible: true });
1403
1464
  await this.#resetTabIndexValues();
1404
1465
  }
1405
1466
 
@@ -1482,8 +1543,8 @@ class HeadingDropdown extends HTMLElement {
1482
1543
  updateActiveHeading(tag) {
1483
1544
  this.#headingButtons.forEach(button => {
1484
1545
  const next = (button.dataset.heading === tag).toString();
1485
- if (button.getAttribute("aria-pressed") !== next) {
1486
- button.setAttribute("aria-pressed", next);
1546
+ if (button.getAttribute("aria-checked") !== next) {
1547
+ button.setAttribute("aria-checked", next);
1487
1548
  }
1488
1549
  });
1489
1550
  }
@@ -1528,7 +1589,7 @@ class HeadingDropdown extends HTMLElement {
1528
1589
  button.classList.add("lexxy-heading-button");
1529
1590
  button.name = name;
1530
1591
  button.title = label;
1531
- button.setAttribute("role", "menuitem");
1592
+ button.setAttribute("role", "menuitemradio");
1532
1593
  button.innerHTML = `${icon} <span>${label}</span>`;
1533
1594
  return button
1534
1595
  }
@@ -1563,6 +1624,143 @@ class HeadingDropdown extends HTMLElement {
1563
1624
  }
1564
1625
  }
1565
1626
 
1627
+ // Shared, strictly-contained element used to attach ephemeral nodes when we
1628
+ // need to read computed styles (e.g. canonicalizing style values, resolving
1629
+ // CSS custom properties). The container is created once and attached to
1630
+ // `document.body` once; subsequent child mutations happen *inside* the
1631
+ // contained subtree so they do not invalidate style on the rest of the page.
1632
+ //
1633
+ // Without this, `document.body.appendChild(...)` / `element.remove()` calls
1634
+ // forced the browser to re-evaluate every ancestor-dependent selector (`:has()`,
1635
+ // descendant combinators, universal sibling rules) across the document on each
1636
+ // invocation — a 13,000+ element style recalc per call on a typical Basecamp
1637
+ // page.
1638
+
1639
+ let resolverRoot = null;
1640
+
1641
+ function styleResolverRoot() {
1642
+ if (resolverRoot && resolverRoot.isConnected) return resolverRoot
1643
+
1644
+ resolverRoot = document.createElement("div");
1645
+ resolverRoot.setAttribute("aria-hidden", "true");
1646
+ resolverRoot.setAttribute("data-lexxy-style-resolver", "");
1647
+ // `contain: strict` (size, layout, paint, style) isolates everything.
1648
+ // The root itself paints nothing (visibility hidden), has zero
1649
+ // geometric impact (position fixed, intrinsic size via contain), and
1650
+ // never leaks style invalidation to its ancestors.
1651
+ resolverRoot.style.cssText = "contain: strict; position: fixed; top: 0; left: 0; visibility: hidden; pointer-events: none; width: 0; height: 0;";
1652
+ document.body.appendChild(resolverRoot);
1653
+ return resolverRoot
1654
+ }
1655
+
1656
+ function isSelectionHighlighted(selection) {
1657
+ if (!$isRangeSelection(selection)) return false
1658
+
1659
+ if (selection.isCollapsed()) {
1660
+ return hasHighlightStyles(selection.style)
1661
+ } else {
1662
+ return selection.hasFormat("highlight")
1663
+ }
1664
+ }
1665
+
1666
+ function getHighlightStyles(selection) {
1667
+ if (!$isRangeSelection(selection)) return null
1668
+
1669
+ let styles = getStyleObjectFromCSS(selection.style);
1670
+ if (!styles.color && !styles["background-color"]) {
1671
+ const anchorNode = selection.anchor.getNode();
1672
+ if ($isTextNode(anchorNode)) {
1673
+ styles = getStyleObjectFromCSS(anchorNode.getStyle());
1674
+ }
1675
+ }
1676
+
1677
+ const color = styles.color || null;
1678
+ const backgroundColor = styles["background-color"] || null;
1679
+ if (!color && !backgroundColor) return null
1680
+
1681
+ return { color, backgroundColor }
1682
+ }
1683
+
1684
+ function hasHighlightStyles(cssOrStyles) {
1685
+ const styles = typeof cssOrStyles === "string" ? getStyleObjectFromCSS(cssOrStyles) : cssOrStyles;
1686
+ return !!(styles.color || styles["background-color"])
1687
+ }
1688
+
1689
+ function normalizeColorButtons(buttons) {
1690
+ return buttons.map((button) => (typeof button === "string" ? { value: button, label: button } : button))
1691
+ }
1692
+
1693
+ function applyCanonicalizers(styles, canonicalizers = []) {
1694
+ return canonicalizers.reduce((css, canonicalizer) => {
1695
+ return canonicalizer.applyCanonicalization(css)
1696
+ }, styles)
1697
+ }
1698
+
1699
+ class StyleCanonicalizer {
1700
+ constructor(property, allowedValues= []) {
1701
+ this._property = property;
1702
+ this._allowedValues = allowedValues;
1703
+ this._canonicalValues = this.#allowedValuesIdentityObject;
1704
+ }
1705
+
1706
+ applyCanonicalization(css) {
1707
+ const styles = { ...getStyleObjectFromCSS(css) };
1708
+
1709
+ styles[this._property] = this.getCanonicalAllowedValue(styles[this._property]);
1710
+ if (!styles[this._property]) {
1711
+ delete styles[this._property];
1712
+ }
1713
+
1714
+ return getCSSFromStyleObject(styles)
1715
+ }
1716
+
1717
+ getCanonicalAllowedValue(value) {
1718
+ return this._canonicalValues[value] ||= this.#resolveCannonicalValue(value)
1719
+ }
1720
+
1721
+ // Private
1722
+
1723
+ get #allowedValuesIdentityObject() {
1724
+ return this._allowedValues.reduce((object, value) => ({ ...object, [value]: value }), {})
1725
+ }
1726
+
1727
+ #resolveCannonicalValue(value) {
1728
+ let index = this.#computedAllowedValues.indexOf(value);
1729
+ if (index === -1) {
1730
+ index = this.#computedAllowedValues.indexOf(computeStyleValues(this._property, [ value ])[0]);
1731
+ }
1732
+ return index === -1 ? null : this._allowedValues[index]
1733
+ }
1734
+
1735
+ get #computedAllowedValues() {
1736
+ return this._computedAllowedValues ||= computeStyleValues(this._property, this._allowedValues)
1737
+ }
1738
+ }
1739
+
1740
+ // Separates DOM writes from layout reads to avoid forced reflows, and attaches
1741
+ // resolver elements to a strictly-contained root (outside the normal document
1742
+ // flow) so neither the attach nor the detach invalidate styles on the rest of
1743
+ // the page. Without containment, appending to `document.body` triggered a
1744
+ // page-wide style recalc on every canonicalization pass.
1745
+ function computeStyleValues(property, values) {
1746
+ const fragment = document.createDocumentFragment();
1747
+
1748
+ const elements = values.map(value => {
1749
+ const element = createElement("span", { style: `display: none; ${property}: ${value};` });
1750
+ fragment.appendChild(element);
1751
+ return element
1752
+ });
1753
+
1754
+ styleResolverRoot().appendChild(fragment);
1755
+
1756
+ const computed = elements.map(element =>
1757
+ window.getComputedStyle(element).getPropertyValue(property)
1758
+ );
1759
+
1760
+ elements.forEach(element => element.remove());
1761
+ return computed
1762
+ }
1763
+
1566
1764
  const APPLY_HIGHLIGHT_SELECTOR = "button.lexxy-highlight-button";
1567
1765
  const REMOVE_HIGHLIGHT_SELECTOR = "[data-command='removeHighlight']";
1568
1766
 
@@ -1594,27 +1792,36 @@ class HighlightDropdown extends ToolbarDropdown {
1594
1792
 
1595
1793
  const colorGroups = this.editorElement.config.get("highlight.buttons");
1596
1794
 
1597
- this.#populateButtonGroup("color", colorGroups.color);
1598
- this.#populateButtonGroup("background-color", colorGroups["background-color"]);
1795
+ this.#appendColorGroup("Text color", "color", colorGroups.color);
1796
+ this.#appendColorGroup("Background color", "background-color", colorGroups["background-color"]);
1599
1797
 
1600
1798
  const maxNumberOfColors = Math.max(colorGroups.color.length, colorGroups["background-color"].length);
1601
1799
  this.panel.style.setProperty("--max-colors", maxNumberOfColors);
1602
1800
  }
1603
1801
 
1604
- #populateButtonGroup(attribute, values) {
1605
- values.forEach((value, index) => {
1606
- this.#buttonContainer.appendChild(this.#createButton(attribute, value, index));
1802
+ #appendColorGroup(label, attribute, buttons) {
1803
+ const group = createElement("div", {
1804
+ role: "group",
1805
+ class: "lexxy-highlight-colors__group",
1806
+ "aria-label": label
1807
+ });
1808
+
1809
+ normalizeColorButtons(buttons).forEach((button, index) => {
1810
+ group.appendChild(this.#createButton(attribute, button, index));
1607
1811
  });
1812
+
1813
+ this.#buttonContainer.append(group);
1608
1814
  }
1609
1815
 
1610
- #createButton(attribute, value, index) {
1816
+ #createButton(attribute, button, index) {
1611
1817
  return createElement("button", {
1612
1818
  type: "button",
1613
- dataset: { value, style: attribute },
1614
- style: `${attribute}: ${value}`,
1819
+ dataset: { value: button.value, style: attribute },
1820
+ style: `${attribute}: ${button.value}`,
1615
1821
  class: "lexxy-editor__toolbar-button lexxy-highlight-button",
1616
1822
  name: `${attribute}-${index}`,
1617
- role: "menuitem"
1823
+ role: "menuitemcheckbox",
1824
+ "aria-label": button.label
1618
1825
  })
1619
1826
  }
1620
1827
 
@@ -1638,11 +1845,7 @@ class HighlightDropdown extends ToolbarDropdown {
1638
1845
  const backgroundColor = $getSelectionStyleValueForProperty(selection, "background-color", NO_STYLE);
1639
1846
 
1640
1847
  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
- }
1848
+ button.ariaChecked = button.dataset.value === textColor || button.dataset.value === backgroundColor;
1646
1849
  });
1647
1850
 
1648
1851
  const hasHighlight = textColor !== NO_STYLE || backgroundColor !== NO_STYLE;
@@ -1939,13 +2142,35 @@ class CustomActionTextAttachmentNode extends DecoratorNode {
1939
2142
  // is cosmetic: it is a nested attachment's rendering inside this one, not the
1940
2143
  // attribute anything re-imports from.
1941
2144
  figure.insertAdjacentHTML("beforeend", EditorSanitizer.for(editor).sanitize(this.innerHtml, { safeForXml: true }));
1942
-
1943
- const deleteButton = createElement("lexxy-node-delete-button");
1944
- figure.appendChild(deleteButton);
2145
+ this.#markImagesAsDecorative(figure);
2146
+ this.#tagLabelImage(figure);
2147
+ this.#tagLabelMirrors(figure);
1945
2148
 
1946
2149
  return figure
1947
2150
  }
1948
2151
 
2152
+ exposeLabel(figure) {
2153
+ const labelImage = figure.querySelector("[data-lexxy-label-image]");
2154
+ if (labelImage) labelImage.alt = this.label;
2155
+
2156
+ for (const span of figure.querySelectorAll("[data-lexxy-label-mirror]")) {
2157
+ span.setAttribute("aria-hidden", "true");
2158
+ }
2159
+ }
2160
+
2161
+ get label() {
2162
+ return this.getReadableTextContent()
2163
+ }
2164
+
2165
+ restoreLabel(figure) {
2166
+ const labelImage = figure.querySelector("[data-lexxy-label-image]");
2167
+ if (labelImage) labelImage.alt = labelImage.dataset.lexxyOriginalAlt;
2168
+
2169
+ for (const span of figure.querySelectorAll("[data-lexxy-label-mirror]")) {
2170
+ span.removeAttribute("aria-hidden");
2171
+ }
2172
+ }
2173
+
1949
2174
  updateDOM() {
1950
2175
  return false
1951
2176
  }
@@ -1986,6 +2211,34 @@ class CustomActionTextAttachmentNode extends DecoratorNode {
1986
2211
  decorate() {
1987
2212
  return null
1988
2213
  }
2214
+
2215
+ #markImagesAsDecorative(figure) {
2216
+ for (const img of figure.querySelectorAll("img:not([alt])")) {
2217
+ img.alt = "";
2218
+ }
2219
+ }
2220
+
2221
+ // Images injected later by other modules must not become the announcement image.
2222
+ #tagLabelImage(figure) {
2223
+ const image = figure.querySelector("img");
2224
+ if (image) {
2225
+ image.setAttribute("data-lexxy-label-image", "");
2226
+ image.dataset.lexxyOriginalAlt = image.alt;
2227
+ }
2228
+ }
2229
+
2230
+ // Duplicate labels would be spoken twice. Only authored spans should be silenced,
2231
+ // since the fake selection injects its own label later.
2232
+ #tagLabelMirrors(figure) {
2233
+ const trimmedLabel = this.label.trim();
2234
+ const matches = [ ...figure.querySelectorAll("span") ].filter((span) => span.textContent.trim() === trimmedLabel);
2235
+ const deepest = matches.filter((span) => !matches.some((other) => other !== span && span.contains(other)));
2236
+ for (const span of deepest) {
2237
+ if (span.getAttribute("aria-hidden") !== "true") {
2238
+ span.setAttribute("data-lexxy-label-mirror", "");
2239
+ }
2240
+ }
2241
+ }
1989
2242
  }
1990
2243
 
1991
2244
  function $isCustomActionTextAttachmentNode(node) {
@@ -2089,6 +2342,35 @@ function $createNodeSelectionWith(...nodes) {
2089
2342
  return selection
2090
2343
  }
2091
2344
 
2345
+ function $singleSelectedNode() {
2346
+ const selection = $getSelection();
2347
+ if ($isNodeSelection(selection)) {
2348
+ const nodes = selection.getNodes();
2349
+ if (nodes.length === 1) {
2350
+ return nodes[0]
2351
+ } else {
2352
+ return null
2353
+ }
2354
+ } else {
2355
+ return null
2356
+ }
2357
+ }
2358
+
2359
+ function $selectedLabelledDecoratorNode() {
2360
+ const node = $singleSelectedNode();
2361
+ if (typeof node?.label === "string") {
2362
+ return { key: node.getKey(), label: node.label }
2363
+ } else {
2364
+ return null
2365
+ }
2366
+ }
2367
+
2368
+ function registerLabelledDecoratorSelection(editor, onChange) {
2369
+ return editor.registerUpdateListener(({ editorState }) => {
2370
+ onChange(editorState.read($selectedLabelledDecoratorNode));
2371
+ })
2372
+ }
2373
+
2092
2374
  function $isShadowRoot(node) {
2093
2375
  return $isElementNode(node) && $isRootOrShadowRoot(node) && !$isRootNode(node)
2094
2376
  }
@@ -2114,6 +2396,10 @@ function getListType(node) {
2114
2396
  return list?.getListType() ?? null
2115
2397
  }
2116
2398
 
2399
+ function announceFromEditor(editor, message) {
2400
+ editor.getRootElement()?.closest("lexxy-editor")?.announce(message);
2401
+ }
2402
+
2117
2403
  function isEditorFocused(editor) {
2118
2404
  const rootElement = editor.getRootElement();
2119
2405
  return rootElement !== null && rootElement.contains(document.activeElement)
@@ -2766,14 +3052,27 @@ class ActionTextAttachmentNode extends DecoratorNode {
2766
3052
  updateDOM(prevNode, dom) {
2767
3053
  if (this.uploadError !== prevNode.uploadError) return true
2768
3054
 
2769
- const caption = dom.querySelector("figcaption textarea");
2770
- if (caption && this.caption) {
2771
- caption.value = this.caption;
3055
+ const image = dom.querySelector("img");
3056
+ if (image && prevNode.altText !== this.altText) {
3057
+ image.alt = this.altText;
3058
+ }
3059
+
3060
+ if (prevNode.caption !== this.caption) {
3061
+ dom.querySelector("figcaption")?.toggleAttribute("data-placeholder", !this.caption);
3062
+ }
3063
+
3064
+ const captionText = dom.querySelector("figcaption .attachment__caption-text");
3065
+ if (captionText && prevNode.captionLabel !== this.captionLabel) {
3066
+ captionText.textContent = this.captionLabel;
2772
3067
  }
2773
3068
 
2774
3069
  return false
2775
3070
  }
2776
3071
 
3072
+ get captionLabel() {
3073
+ return this.caption || this.fileName || ""
3074
+ }
3075
+
2777
3076
  getTextContent() {
2778
3077
  return `[${this.caption || this.fileName}]\n\n`
2779
3078
  }
@@ -2835,9 +3134,6 @@ class ActionTextAttachmentNode extends DecoratorNode {
2835
3134
  figure.draggable = true;
2836
3135
  figure.dataset.lexicalNodeKey = this.__key;
2837
3136
 
2838
- const deleteButton = createElement("lexxy-node-delete-button");
2839
- figure.appendChild(deleteButton);
2840
-
2841
3137
  return figure
2842
3138
  }
2843
3139
 
@@ -2853,6 +3149,18 @@ class ActionTextAttachmentNode extends DecoratorNode {
2853
3149
  return this.contentType.startsWith("video/")
2854
3150
  }
2855
3151
 
3152
+ get label() {
3153
+ if (this.caption && this.altText && this.altText !== this.caption && this.altText !== this.fileName) {
3154
+ return `${this.caption}. ${this.altText}`
3155
+ } else {
3156
+ return this.caption || this.altText || this.fileName || ""
3157
+ }
3158
+ }
3159
+
3160
+ focusCaption() {
3161
+ return this.editor.getRootElement()?.closest("lexxy-editor")?.captionEditor.open(this.getKey()) ?? false
3162
+ }
3163
+
2856
3164
  #createDOMForPendingPreview() {
2857
3165
  const figure = this.createAttachmentFigure(false);
2858
3166
  figure.appendChild(this.#createDOMForFile());
@@ -3043,55 +3351,18 @@ class ActionTextAttachmentNode extends DecoratorNode {
3043
3351
  }
3044
3352
 
3045
3353
  #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"
3354
+ const caption = createElement("figcaption", { className: "attachment__caption attachment__caption--editable" });
3355
+ const text = createElement("span", { className: "attachment__caption-text", textContent: this.captionLabel });
3356
+ caption.appendChild(text);
3357
+ caption.toggleAttribute("data-placeholder", !this.caption);
3358
+ caption.addEventListener("mousedown", event => event.preventDefault());
3359
+ caption.addEventListener("click", event => {
3360
+ event.stopPropagation();
3361
+ this.focusCaption();
3051
3362
  });
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
3363
  return caption
3063
3364
  }
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
- }
3365
+ }
3095
3366
 
3096
3367
  function $createActionTextAttachmentNode(...args) {
3097
3368
  return new ActionTextAttachmentNode(...args)
@@ -3210,14 +3481,11 @@ class HorizontalDividerNode extends DecoratorNode {
3210
3481
  super(key);
3211
3482
  }
3212
3483
 
3484
+ // The hr already provides separator semantics; a figure boundary would be redundant.
3213
3485
  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);
3486
+ const figure = createElement("figure", { className: "horizontal-divider", role: "presentation" });
3487
+ figure.appendChild(createElement("hr"));
3488
+ figure.dataset.lexicalNodeKey = this.__key;
3221
3489
 
3222
3490
  return figure
3223
3491
  }
@@ -3234,6 +3502,10 @@ class HorizontalDividerNode extends DecoratorNode {
3234
3502
  return false
3235
3503
  }
3236
3504
 
3505
+ get label() {
3506
+ return "Horizontal divider"
3507
+ }
3508
+
3237
3509
  exportDOM() {
3238
3510
  const hr = createElement("hr");
3239
3511
  return { element: hr }
@@ -3499,189 +3771,366 @@ var theme = {
3499
3771
  }
3500
3772
  };
3501
3773
 
3502
- class UploadRequests {
3503
- #requestsByKey = new Map()
3504
-
3505
- track(key, request) {
3506
- this.#requestsByKey.set(key, request);
3774
+ class ImageGalleryNode extends ElementNode {
3775
+ $config() {
3776
+ return this.config("image_gallery", {
3777
+ extends: ElementNode,
3778
+ })
3507
3779
  }
3508
3780
 
3509
- forget(key) {
3510
- this.#requestsByKey.delete(key);
3781
+ static transform() {
3782
+ return (gallery) => {
3783
+ gallery.unwrapEmptyNode()
3784
+ || gallery.replaceWithSingularChild()
3785
+ || gallery.splitAroundInvalidChild();
3786
+ }
3511
3787
  }
3512
3788
 
3513
- abort(key) {
3514
- const request = this.#requestsByKey.get(key);
3515
- if (request) {
3516
- this.#requestsByKey.delete(key);
3517
- request.abort();
3789
+ static importDOM() {
3790
+ return {
3791
+ div: (element) => {
3792
+ if (!this.#isGalleryElement(element)) return null
3793
+
3794
+ return {
3795
+ conversion: () => {
3796
+ return {
3797
+ node: $createImageGalleryNode()
3798
+ }
3799
+ },
3800
+ priority: 2
3801
+ }
3802
+ }
3518
3803
  }
3519
3804
  }
3520
3805
 
3521
- clear() {
3522
- this.#requestsByKey.clear();
3806
+ static canCollapseWith(node) {
3807
+ return $isImageGalleryNode(node) || this.isValidChild(node)
3523
3808
  }
3524
- }
3525
3809
 
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;
3810
+ static isValidChild(node) {
3811
+ return $isActionTextAttachmentNode(node) && node.isPreviewableImage
3812
+ }
3533
3813
 
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);
3814
+ static #isGalleryElement(element) {
3815
+ const attachmentChildren = element.querySelectorAll(`:scope > :is(${this.#attachmentTags.join()})`);
3816
+ return element.textContent.trim() === ""
3817
+ && attachmentChildren.length > 0
3818
+ && element.children.length === attachmentChildren.length
3819
+ }
3537
3820
 
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;
3821
+ static get #attachmentTags() {
3822
+ return Object.keys(ActionTextAttachmentNode.importDOM())
3823
+ }
3824
+
3825
+ createDOM() {
3826
+ const div = document.createElement("div");
3827
+ div.className = this.#galleryClassNames;
3828
+ return div
3829
+ }
3830
+
3831
+ updateDOM(_prevNode, dom) {
3832
+ dom.className = this.#galleryClassNames;
3833
+ return false
3834
+ }
3835
+
3836
+ canBeEmpty() {
3837
+ // Return `true` to conform to `$isBlock(node)`
3838
+ // We clean-up empty galleries with a transform
3839
+ return true
3840
+ }
3841
+
3842
+ collapseAtStart(_selection) {
3843
+ return true
3844
+ }
3845
+
3846
+ insertNewAfter(selection, restoreSelection) {
3847
+ const selectionBeforeLastChild = selection.anchor.getNode().is(this) && selection.anchor.offset == this.getChildrenSize() - 1;
3848
+ if (selectionBeforeLastChild) {
3849
+ const paragraph = $createParagraphNode();
3850
+ this.insertAfter(paragraph, false);
3851
+ paragraph.insertAfter(this.getLastChild(), false);
3852
+ paragraph.selectEnd();
3853
+
3854
+ // return null as selection has been managed
3855
+ return null
3544
3856
  }
3857
+
3858
+ const newNode = $createImageGalleryNode();
3859
+ this.insertAfter(newNode, restoreSelection);
3860
+ return newNode
3545
3861
  }
3546
3862
 
3547
- if (cursor < text.length) {
3548
- segments.push({ start: cursor, end: text.length, range: null });
3863
+ getImageAttachments() {
3864
+ const children = this.getChildren();
3865
+ return children.filter($isActionTextAttachmentNode)
3549
3866
  }
3550
3867
 
3551
- return segments
3552
- }
3868
+ exportDOM() {
3869
+ const div = document.createElement("div");
3870
+ div.className = this.#galleryClassNames;
3871
+ return { element: div }
3872
+ }
3553
3873
 
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.
3874
+ collapseWith(node, backwards) {
3875
+ if (!ImageGalleryNode.canCollapseWith(node)) return false
3565
3876
 
3566
- let resolverRoot = null;
3877
+ if (backwards) {
3878
+ $insertFirst(this, node);
3879
+ } else {
3880
+ this.append(node);
3881
+ }
3567
3882
 
3568
- function styleResolverRoot() {
3569
- if (resolverRoot && resolverRoot.isConnected) return resolverRoot
3883
+ $unwrapAndFilterDescendants(this, ImageGalleryNode.isValidChild);
3570
3884
 
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
- }
3885
+ return true
3886
+ }
3582
3887
 
3583
- function isSelectionHighlighted(selection) {
3584
- if (!$isRangeSelection(selection)) return false
3888
+ unwrapEmptyNode() {
3889
+ if (this.isEmpty()) {
3890
+ const paragraph = $createParagraphNode();
3891
+ return this.replace(paragraph)
3892
+ }
3893
+ }
3585
3894
 
3586
- if (selection.isCollapsed()) {
3587
- return hasHighlightStyles(selection.style)
3588
- } else {
3589
- return selection.hasFormat("highlight")
3895
+ replaceWithSingularChild() {
3896
+ if (this.#hasSingularChild) {
3897
+ const child = this.getFirstChild();
3898
+ return this.replace($makeSafeForRoot(child))
3899
+ }
3590
3900
  }
3591
- }
3592
3901
 
3593
- function getHighlightStyles(selection) {
3594
- if (!$isRangeSelection(selection)) return null
3902
+ splitAroundInvalidChild() {
3903
+ for (const child of $firstToLastIterator(this)) {
3904
+ if (ImageGalleryNode.isValidChild(child)) continue
3595
3905
 
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());
3906
+ const poppedNode = $makeSafeForRoot(child);
3907
+ const [ topGallery, secondGallery ] = this.splitAtIndex(poppedNode.getIndexWithinParent());
3908
+ topGallery.insertAfter(poppedNode);
3909
+ poppedNode.selectEnd();
3910
+
3911
+ // remove an empty gallery rather than let it unwrap to a paragraph
3912
+ if (secondGallery.isEmpty()) secondGallery.remove();
3913
+
3914
+ break
3601
3915
  }
3602
3916
  }
3603
3917
 
3604
- const color = styles.color || null;
3605
- const backgroundColor = styles["background-color"] || null;
3606
- if (!color && !backgroundColor) return null
3918
+ splitAtIndex(index) {
3919
+ return $splitNode(this, index)
3920
+ }
3607
3921
 
3608
- return { color, backgroundColor }
3922
+ get #hasSingularChild() {
3923
+ return this.getChildrenSize() === 1
3924
+ }
3925
+
3926
+ get #galleryClassNames() {
3927
+ return `attachment-gallery attachment-gallery--${this.getChildrenSize()}`
3928
+ }
3609
3929
  }
3610
3930
 
3611
- function hasHighlightStyles(cssOrStyles) {
3612
- const styles = typeof cssOrStyles === "string" ? getStyleObjectFromCSS(cssOrStyles) : cssOrStyles;
3613
- return !!(styles.color || styles["background-color"])
3931
+ function $createImageGalleryNode() {
3932
+ return new ImageGalleryNode()
3614
3933
  }
3615
3934
 
3616
- function applyCanonicalizers(styles, canonicalizers = []) {
3617
- return canonicalizers.reduce((css, canonicalizer) => {
3618
- return canonicalizer.applyCanonicalization(css)
3619
- }, styles)
3935
+ function $isImageGalleryNode(node) {
3936
+ return node instanceof ImageGalleryNode
3620
3937
  }
3621
3938
 
3622
- class StyleCanonicalizer {
3623
- constructor(property, allowedValues= []) {
3624
- this._property = property;
3625
- this._allowedValues = allowedValues;
3626
- this._canonicalValues = this.#allowedValuesIdentityObject;
3939
+ function $findOrCreateGalleryForImage(node) {
3940
+ if (!ImageGalleryNode.canCollapseWith(node)) return null
3941
+
3942
+ const existingGallery = $getNearestNodeOfType(node, ImageGalleryNode);
3943
+ return existingGallery ?? $wrapNodeInElement(node, $createImageGalleryNode)
3944
+ }
3945
+
3946
+ class CaptionEditor {
3947
+ #editorElement
3948
+ #editor
3949
+ #input = createElement("textarea", { className: "lexxy-caption-editor", hidden: true, tabIndex: -1, rows: 1, placeholder: "Add caption..." })
3950
+ #nodeKey
3951
+ #caption
3952
+ #listeners = new ListenerBin()
3953
+ #resizeObserver = new ResizeObserver(() => this.#updatePosition())
3954
+
3955
+ constructor(editorElement) {
3956
+ this.#editorElement = editorElement;
3957
+ this.#editor = editorElement.editor;
3958
+ editorElement.append(this.#input);
3959
+ this.#listeners.track(
3960
+ registerEventListener(this.#input, "blur", () => this.#save()),
3961
+ registerEventListener(this.#input, "keydown", event => this.#handleKeydown(event)),
3962
+ registerEventListener(this.#input, "input", () => this.#updatePosition()),
3963
+ registerEventListener(editorElement, "scroll", () => this.#updatePosition(), { capture: true, passive: true }),
3964
+ this.#editor.registerUpdateListener(() => this.#checkAttachment())
3965
+ );
3966
+ }
3967
+
3968
+ dispose() {
3969
+ this.#listeners.dispose();
3970
+ this.#close();
3971
+ this.#input.remove();
3972
+ }
3973
+
3974
+ open(nodeKey) {
3975
+ const caption = this.#editor.getElementByKey(nodeKey)?.querySelector("figcaption.attachment__caption--editable");
3976
+ if (caption) {
3977
+ if (this.#nodeKey !== nodeKey) {
3978
+ this.#edit(nodeKey, caption);
3979
+ }
3980
+ return true
3981
+ } else {
3982
+ return false
3983
+ }
3984
+ }
3985
+
3986
+ #save() {
3987
+ if (this.#nodeKey) {
3988
+ const key = this.#nodeKey;
3989
+ const value = this.#input.value;
3990
+ this.#close();
3991
+ this.#editor.update(() => {
3992
+ const node = $getNodeByKey(key);
3993
+ if (node && node.caption !== value) node.getWritable().caption = value;
3994
+ }, { tag: SKIP_DOM_SELECTION_TAG, discrete: true });
3995
+ }
3996
+ }
3997
+
3998
+ #close() {
3999
+ this.#nodeKey = null;
4000
+ this.#resizeObserver.disconnect();
4001
+ if (this.#caption) {
4002
+ this.#caption.classList.remove("attachment__caption--editing");
4003
+ this.#caption.style.removeProperty("min-height");
4004
+ this.#caption = null;
4005
+ }
4006
+ this.#input.hidden = true;
4007
+ }
4008
+
4009
+ #handleKeydown(event) {
4010
+ if (!event.isComposing && [ "Enter", "Escape" ].includes(event.key)) {
4011
+ event.preventDefault();
4012
+ const key = this.#nodeKey;
4013
+ this.#input.blur();
4014
+ this.#editor.getRootElement()?.focus({ preventScroll: true });
4015
+ this.#editor.update(() => {
4016
+ const node = $getNodeByKey(key);
4017
+ if (node) {
4018
+ if (event.key === "Enter") {
4019
+ if ($isImageGalleryNode(node.getParent()) && !node.getNextSibling()) {
4020
+ node.getParent().selectNext(0, 0);
4021
+ } else {
4022
+ node.selectNext(0, 0);
4023
+ }
4024
+ } else {
4025
+ $setSelection($createNodeSelectionWith(node));
4026
+ }
4027
+ }
4028
+ }, { tag: HISTORY_MERGE_TAG });
4029
+ }
4030
+ event.stopPropagation();
3627
4031
  }
3628
4032
 
3629
- applyCanonicalization(css) {
3630
- const styles = { ...getStyleObjectFromCSS(css) };
3631
-
3632
- styles[this._property] = this.getCanonicalAllowedValue(styles[this._property]);
3633
- if (!styles[this._property]) {
3634
- delete styles[this._property];
4033
+ #updatePosition() {
4034
+ if (this.#caption?.isConnected) {
4035
+ const rect = this.#caption.getBoundingClientRect();
4036
+ const editorRect = this.#editorElement.getBoundingClientRect();
4037
+ this.#input.style.left = `${rect.left - editorRect.left - this.#editorElement.clientLeft + this.#editorElement.scrollLeft}px`;
4038
+ this.#input.style.top = `${rect.top - editorRect.top - this.#editorElement.clientTop + this.#editorElement.scrollTop}px`;
4039
+ this.#input.style.width = `${rect.width}px`;
4040
+ this.#input.style.height = "0px";
4041
+ const height = this.#input.scrollHeight;
4042
+ this.#input.style.height = `${height}px`;
4043
+ this.#caption.style.minHeight = `${height}px`;
3635
4044
  }
4045
+ }
3636
4046
 
3637
- return getCSSFromStyleObject(styles)
4047
+ #checkAttachment() {
4048
+ if (this.#nodeKey && this.#editor.getElementByKey(this.#nodeKey)?.querySelector("figcaption") !== this.#caption) {
4049
+ this.#close();
4050
+ } else {
4051
+ this.#updatePosition();
4052
+ }
3638
4053
  }
3639
4054
 
3640
- getCanonicalAllowedValue(value) {
3641
- return this._canonicalValues[value] ||= this.#resolveCannonicalValue(value)
4055
+ #edit(nodeKey, caption) {
4056
+ this.#save();
4057
+ this.#nodeKey = nodeKey;
4058
+ this.#caption = caption;
4059
+ this.#editor.getEditorState().read(() => {
4060
+ const node = $getNodeByKey(nodeKey);
4061
+ this.#input.value = node.caption;
4062
+ if (node.isVideo) {
4063
+ this.#input.ariaLabel = "Video caption";
4064
+ } else {
4065
+ this.#input.ariaLabel = "Image caption";
4066
+ }
4067
+ });
4068
+
4069
+ caption.scrollIntoView({ block: "nearest", inline: "nearest" });
4070
+ const style = getComputedStyle(caption);
4071
+ this.#input.style.font = style.font;
4072
+ this.#input.style.padding = style.padding;
4073
+ this.#input.style.color = style.color;
4074
+ this.#input.style.textAlign = getComputedStyle(caption.firstElementChild).textAlign;
4075
+ this.#input.hidden = false;
4076
+ caption.classList.add("attachment__caption--editing");
4077
+ this.#resizeObserver.observe(caption);
4078
+ this.#resizeObserver.observe(this.#editorElement);
4079
+ this.#updatePosition();
4080
+ this.#input.focus({ preventScroll: true });
3642
4081
  }
4082
+ }
3643
4083
 
3644
- // Private
4084
+ class UploadRequests {
4085
+ #requestsByKey = new Map()
3645
4086
 
3646
- get #allowedValuesIdentityObject() {
3647
- return this._allowedValues.reduce((object, value) => ({ ...object, [value]: value }), {})
4087
+ track(key, request) {
4088
+ this.#requestsByKey.set(key, request);
3648
4089
  }
3649
4090
 
3650
- #resolveCannonicalValue(value) {
3651
- let index = this.#computedAllowedValues.indexOf(value);
3652
- if (index === -1) {
3653
- index = this.#computedAllowedValues.indexOf(computeStyleValues(this._property, [ value ])[0]);
4091
+ forget(key) {
4092
+ this.#requestsByKey.delete(key);
4093
+ }
4094
+
4095
+ abort(key) {
4096
+ const request = this.#requestsByKey.get(key);
4097
+ if (request) {
4098
+ this.#requestsByKey.delete(key);
4099
+ request.abort();
3654
4100
  }
3655
- return index === -1 ? null : this._allowedValues[index]
3656
4101
  }
3657
4102
 
3658
- get #computedAllowedValues() {
3659
- return this._computedAllowedValues ||= computeStyleValues(this._property, this._allowedValues)
4103
+ clear() {
4104
+ this.#requestsByKey.clear();
3660
4105
  }
3661
4106
  }
3662
4107
 
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();
4108
+ // Partition a stretch of text into consecutive { start, end, range } segments,
4109
+ // where offsets are relative to the text and range is the covering source
4110
+ // range — expressed in outer coordinates as { start, end, ... } — or null for
4111
+ // the stretches no range covers. Ranges must be sorted and non-overlapping.
4112
+ function segmentTextByRanges(text, textStart, ranges) {
4113
+ const segments = [];
4114
+ let cursor = 0;
3670
4115
 
3671
- const elements = values.map(value => {
3672
- const element = createElement("span", { style: `display: none; ${property}: ${value};` });
3673
- fragment.appendChild(element);
3674
- return element
3675
- });
4116
+ for (const range of ranges) {
4117
+ const from = Math.max(range.start - textStart, cursor);
4118
+ const to = Math.min(range.end - textStart, text.length);
3676
4119
 
3677
- styleResolverRoot().appendChild(fragment);
4120
+ if (from < to) {
4121
+ if (from > cursor) {
4122
+ segments.push({ start: cursor, end: from, range: null });
4123
+ }
4124
+ segments.push({ start: from, end: to, range });
4125
+ cursor = to;
4126
+ }
4127
+ }
3678
4128
 
3679
- const computed = elements.map(element =>
3680
- window.getComputedStyle(element).getPropertyValue(property)
3681
- );
4129
+ if (cursor < text.length) {
4130
+ segments.push({ start: cursor, end: text.length, range: null });
4131
+ }
3682
4132
 
3683
- elements.forEach(element => element.remove());
3684
- return computed
4133
+ return segments
3685
4134
  }
3686
4135
 
3687
4136
  const TOGGLE_HIGHLIGHT_COMMAND = createCommand();
@@ -3966,11 +4415,15 @@ function $extractHighlightRangesFromCodeNode(codeNode) {
3966
4415
 
3967
4416
  function buildCanonicalizers(config) {
3968
4417
  return [
3969
- new StyleCanonicalizer("color", [ ...config.buttons.color, ...config.permit.color ]),
3970
- new StyleCanonicalizer("background-color", [ ...config.buttons["background-color"], ...config.permit["background-color"] ])
4418
+ new StyleCanonicalizer("color", [ ...colorValues(config.buttons.color), ...config.permit.color ]),
4419
+ new StyleCanonicalizer("background-color", [ ...colorValues(config.buttons["background-color"]), ...config.permit["background-color"] ])
3971
4420
  ]
3972
4421
  }
3973
4422
 
4423
+ function colorValues(buttons) {
4424
+ return normalizeColorButtons(buttons).map((button) => button.value)
4425
+ }
4426
+
3974
4427
  function $toggleSelectionStyles(editor, styles) {
3975
4428
  const selection = $getSelection();
3976
4429
  if (!$isRangeSelection(selection)) return
@@ -4909,29 +5362,25 @@ class Selection {
4909
5362
  }, COMMAND_PRIORITY_LOW),
4910
5363
 
4911
5364
  this.editor.registerCommand(SELECTION_CHANGE_COMMAND, () => {
4912
- this.#preserveFocusOfOtherFields();
5365
+ this.#preserveFocusOutsideEditor();
4913
5366
  return false
4914
5367
  }, COMMAND_PRIORITY_CRITICAL)
4915
5368
  );
4916
5369
  }
4917
5370
 
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) {
5371
+ // Selection changes can arrive after Tab moves focus to a toolbar or another field.
5372
+ // Reconciling that stale selection would pull focus back into the editor.
5373
+ #preserveFocusOutsideEditor() {
5374
+ if (this.#isAnotherElementFocused) {
4925
5375
  $addUpdateTag(SKIP_DOM_SELECTION_TAG);
4926
5376
  }
4927
5377
  }
4928
5378
 
4929
- get #anotherFieldIsFocused() {
5379
+ get #isAnotherElementFocused() {
4930
5380
  const rootElement = this.editor.getRootElement();
4931
5381
  const activeElement = rootElement?.ownerDocument.activeElement;
4932
5382
 
4933
- return activeElement && !rootElement.contains(activeElement) &&
4934
- (activeElement.tagName === "INPUT" || activeElement.tagName === "TEXTAREA" || activeElement.isContentEditable)
5383
+ return activeElement && activeElement !== rootElement.ownerDocument.body && !rootElement.contains(activeElement)
4935
5384
  }
4936
5385
 
4937
5386
  #listenForNodeSelections() {
@@ -5464,240 +5913,68 @@ class Selection {
5464
5913
  #getEdgeCharRect(element, edge) {
5465
5914
  const walker = document.createTreeWalker(element, 4 /* NodeFilter.SHOW_TEXT */);
5466
5915
  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);
5916
+
5917
+ if (edge === "first") {
5918
+ textNode = walker.nextNode();
5636
5919
  } else {
5637
- this.append(node);
5920
+ while (walker.nextNode()) textNode = walker.currentNode;
5638
5921
  }
5639
5922
 
5640
- $unwrapAndFilterDescendants(this, ImageGalleryNode.isValidChild);
5641
-
5642
- return true
5643
- }
5923
+ if (!textNode || textNode.length === 0) return null
5644
5924
 
5645
- unwrapEmptyNode() {
5646
- if (this.isEmpty()) {
5647
- const paragraph = $createParagraphNode();
5648
- return this.replace(paragraph)
5925
+ const range = document.createRange();
5926
+ if (edge === "first") {
5927
+ range.setStart(textNode, 0);
5928
+ range.setEnd(textNode, 1);
5929
+ } else {
5930
+ range.setStart(textNode, textNode.length - 1);
5931
+ range.setEnd(textNode, textNode.length);
5649
5932
  }
5650
- }
5651
5933
 
5652
- replaceWithSingularChild() {
5653
- if (this.#hasSingularChild) {
5654
- const child = this.getFirstChild();
5655
- return this.replace($makeSafeForRoot(child))
5656
- }
5934
+ return range.getBoundingClientRect()
5657
5935
  }
5936
+ }
5658
5937
 
5659
- splitAroundInvalidChild() {
5660
- for (const child of $firstToLastIterator(this)) {
5661
- if (ImageGalleryNode.isValidChild(child)) continue
5662
-
5663
- const poppedNode = $makeSafeForRoot(child);
5664
- const [ topGallery, secondGallery ] = this.splitAtIndex(poppedNode.getIndexWithinParent());
5665
- topGallery.insertAfter(poppedNode);
5666
- poppedNode.selectEnd();
5667
-
5668
- // remove an empty gallery rather than let it unwrap to a paragraph
5669
- if (secondGallery.isEmpty()) secondGallery.remove();
5938
+ class EditorConfiguration {
5939
+ #editorElement
5940
+ #config
5670
5941
 
5671
- break
5672
- }
5942
+ constructor(editorElement) {
5943
+ this.#editorElement = editorElement;
5944
+ this.#config = new Configuration(
5945
+ Lexxy.presets.get("default"),
5946
+ Lexxy.presets.get(editorElement.preset),
5947
+ this.#overrides
5948
+ );
5673
5949
  }
5674
5950
 
5675
- splitAtIndex(index) {
5676
- return $splitNode(this, index)
5951
+ get(path) {
5952
+ return this.#config.get(path)
5677
5953
  }
5678
5954
 
5679
- get #hasSingularChild() {
5680
- return this.getChildrenSize() === 1
5955
+ get #overrides() {
5956
+ const overrides = {};
5957
+ for (const option of this.#defaultOptions) {
5958
+ const attribute = dasherize(option);
5959
+ if (this.#editorElement.hasAttribute(attribute)) {
5960
+ overrides[option] = this.#parseAttribute(attribute);
5961
+ }
5962
+ }
5963
+ return overrides
5681
5964
  }
5682
5965
 
5683
- get #galleryClassNames() {
5684
- return `attachment-gallery attachment-gallery--${this.getChildrenSize()}`
5966
+ get #defaultOptions() {
5967
+ return Object.keys(Lexxy.presets.get("default"))
5685
5968
  }
5686
- }
5687
-
5688
- function $createImageGalleryNode() {
5689
- return new ImageGalleryNode()
5690
- }
5691
-
5692
- function $isImageGalleryNode(node) {
5693
- return node instanceof ImageGalleryNode
5694
- }
5695
5969
 
5696
- function $findOrCreateGalleryForImage(node) {
5697
- if (!ImageGalleryNode.canCollapseWith(node)) return null
5698
-
5699
- const existingGallery = $getNearestNodeOfType(node, ImageGalleryNode);
5700
- return existingGallery ?? $wrapNodeInElement(node, $createImageGalleryNode)
5970
+ #parseAttribute(attribute) {
5971
+ const value = this.#editorElement.getAttribute(attribute);
5972
+ try {
5973
+ return JSON.parse(value)
5974
+ } catch {
5975
+ return value
5976
+ }
5977
+ }
5701
5978
  }
5702
5979
 
5703
5980
  class Uploader {
@@ -8788,75 +9065,387 @@ class AttachmentDragAndDrop {
8788
9065
  if (position === "before") {
8789
9066
  targetNode.insertBefore(draggedNode);
8790
9067
  } else {
8791
- targetNode.insertAfter(draggedNode);
9068
+ targetNode.insertAfter(draggedNode);
9069
+ }
9070
+ }
9071
+
9072
+ #dropIntoList(draggedNode, target) {
9073
+ const listItemNode = $getNearestNodeFromDOMNode(target.element);
9074
+ if (!listItemNode || !$isListItemNode(listItemNode)) return
9075
+
9076
+ const listNode = listItemNode.getParent();
9077
+ if (!listNode || !$isListNode(listNode)) return
9078
+
9079
+ const children = listNode.getChildren();
9080
+ const index = children.indexOf(listItemNode);
9081
+ if (index === -1) return
9082
+
9083
+ const splitIndex = target.position === "before" ? index : index + 1;
9084
+
9085
+ draggedNode.remove();
9086
+
9087
+ if (splitIndex === 0) {
9088
+ listNode.insertBefore(draggedNode);
9089
+ } else if (splitIndex >= children.length) {
9090
+ listNode.insertAfter(draggedNode);
9091
+ } else {
9092
+ const [ , listAfter ] = $splitNode(listNode, splitIndex);
9093
+ listAfter.insertBefore(draggedNode);
9094
+ }
9095
+ }
9096
+
9097
+ #dropBetweenBlocks(draggedNode, target) {
9098
+ const targetNode = $getNearestNodeFromDOMNode(target.element);
9099
+ if (!targetNode) return
9100
+
9101
+ const topLevelTarget = targetNode.getTopLevelElement?.() || targetNode;
9102
+ if (draggedNode.is(topLevelTarget)) return
9103
+
9104
+ draggedNode.remove();
9105
+
9106
+ if (target.position === "before") {
9107
+ topLevelTarget.insertBefore(draggedNode);
9108
+ } else {
9109
+ topLevelTarget.insertAfter(draggedNode);
9110
+ }
9111
+ }
9112
+
9113
+ // -- Lifecycle helpers -----------------------------------------------------
9114
+
9115
+ #cleanup() {
9116
+ this.#clearDropIndicators();
9117
+
9118
+ if (this.#draggedNodeKey) {
9119
+ const rootElement = this.#editor.getRootElement();
9120
+ if (rootElement) {
9121
+ const figure = rootElement.querySelector(`[data-lexical-node-key="${this.#draggedNodeKey}"]`);
9122
+ figure?.classList.remove("lexxy-dragging");
9123
+ }
9124
+ }
9125
+
9126
+ this.#draggedNodeKey = null;
9127
+
9128
+ if (this.#rafId) {
9129
+ cancelAnimationFrame(this.#rafId);
9130
+ this.#rafId = null;
9131
+ }
9132
+
9133
+ if (this.#draggingRafId) {
9134
+ cancelAnimationFrame(this.#draggingRafId);
9135
+ this.#draggingRafId = null;
9136
+ }
9137
+ }
9138
+ }
9139
+
9140
+ // Lexical clears the DOM range on NodeSelection, leaving screen readers in focus
9141
+ // mode to read stray adjacent characters unless we provide a labelled range.
9142
+ class AttachmentFakeSelection {
9143
+ #editor
9144
+ #listeners = new ListenerBin()
9145
+ #container = createElement("span", { className: "lexxy-fake-selection" })
9146
+
9147
+ constructor(editor) {
9148
+ this.#editor = editor;
9149
+ this.#listeners.track(
9150
+ registerLabelledDecoratorSelection(editor, (target) => this.#update(target))
9151
+ );
9152
+ }
9153
+
9154
+ destroy() {
9155
+ this.#listeners.dispose();
9156
+ this.#container.remove();
9157
+ this.#container = null;
9158
+ }
9159
+
9160
+ #update(target) {
9161
+ if (this.#container) {
9162
+ const figure = target && this.#editor.getElementByKey(target.key);
9163
+ if (figure) {
9164
+ const label = target.label || "Attachment";
9165
+ if (this.#container.textContent !== label) this.#container.textContent = label;
9166
+ if (this.#container.parentNode !== figure) figure.append(this.#container);
9167
+ this.#parkSelection();
9168
+ } else {
9169
+ this.#container.remove();
9170
+ }
9171
+ }
9172
+ }
9173
+
9174
+ #parkSelection() {
9175
+ const textNode = this.#container.firstChild;
9176
+ if (textNode && this.#isEditorFocused && !this.#isAlreadyParkedHere) {
9177
+ const range = document.createRange();
9178
+ range.setStart(textNode, 0);
9179
+ range.setEnd(textNode, textNode.length);
9180
+
9181
+ const selection = window.getSelection();
9182
+ selection.removeAllRanges();
9183
+ selection.addRange(range);
9184
+ }
9185
+ }
9186
+
9187
+ // Parking the range while a nested control is focused steals its screen reader cursor.
9188
+ get #isEditorFocused() {
9189
+ const root = this.#editor.getRootElement();
9190
+ return root != null && document.activeElement === root
9191
+ }
9192
+
9193
+ get #isAlreadyParkedHere() {
9194
+ const selection = window.getSelection();
9195
+ if (selection?.rangeCount === 1) {
9196
+ const textNode = this.#container.firstChild;
9197
+ return selection.anchorNode === textNode
9198
+ && selection.focusNode === textNode
9199
+ && selection.anchorOffset === 0
9200
+ && selection.focusOffset === textNode.length
9201
+ } else {
9202
+ return false
9203
+ }
9204
+ }
9205
+ }
9206
+
9207
+ class AttachmentKeyboardMove {
9208
+ #editor
9209
+ #listeners = new ListenerBin()
9210
+ #scrollFrame
9211
+
9212
+ constructor(editor) {
9213
+ this.#editor = editor;
9214
+
9215
+ this.#listeners.track(
9216
+ editor.registerCommand(KEY_DOWN_COMMAND, this.#tryReorder, COMMAND_PRIORITY_HIGH)
9217
+ );
9218
+ }
9219
+
9220
+ destroy() {
9221
+ this.#listeners.dispose();
9222
+ cancelAnimationFrame(this.#scrollFrame);
9223
+ }
9224
+
9225
+ #tryReorder = (event) => {
9226
+ const node = $singleSelectedNode();
9227
+ if (event.altKey && event.shiftKey && event.key.startsWith("Arrow") && $isActionTextAttachmentNode(node)) {
9228
+ event.preventDefault();
9229
+ const message = this.#executeMove(event.key, node);
9230
+ $setSelection($createNodeSelectionWith(node));
9231
+ announceFromEditor(this.#editor, message);
9232
+ this.#keepInView(node);
9233
+ return true
9234
+ } else {
9235
+ return false
9236
+ }
9237
+ }
9238
+
9239
+ #executeMove(key, node) {
9240
+ switch (key) {
9241
+ case "ArrowUp": return new AttachmentReorder(node, "previous").moveVertically()
9242
+ case "ArrowDown": return new AttachmentReorder(node, "next").moveVertically()
9243
+ case "ArrowLeft": return new AttachmentReorder(node, "previous").reorderInGallery()
9244
+ case "ArrowRight": return new AttachmentReorder(node, "next").reorderInGallery()
9245
+ default: return null
9246
+ }
9247
+ }
9248
+
9249
+ // Node selections don't scroll into view automatically like a caret does.
9250
+ #keepInView(node) {
9251
+ const key = node.getKey();
9252
+ cancelAnimationFrame(this.#scrollFrame);
9253
+ this.#scrollFrame = requestAnimationFrame(() => {
9254
+ this.#editor.getElementByKey(key)?.scrollIntoView({ block: "nearest" });
9255
+ });
9256
+ }
9257
+ }
9258
+
9259
+ class AttachmentReorder {
9260
+ constructor(node, direction) {
9261
+ this.node = node;
9262
+ this.direction = direction;
9263
+ }
9264
+
9265
+ moveVertically() {
9266
+ if ($isImageGalleryNode(this.node.getParent())) {
9267
+ return this.#extractFromGallery()
9268
+ } else {
9269
+ return this.#moveBlock() || this.#blockedMessage
9270
+ }
9271
+ }
9272
+
9273
+ reorderInGallery() {
9274
+ const sibling = $getSiblingCaret(this.node, this.direction).getNodeAtCaret();
9275
+ if ($isImageGalleryNode(this.node.getParent()) && sibling) {
9276
+ $getSiblingCaret(sibling, this.direction).insert(this.node);
9277
+ return "Image reordered in gallery"
9278
+ } else {
9279
+ return this.#blockedMessage
9280
+ }
9281
+ }
9282
+
9283
+ #extractFromGallery() {
9284
+ const gallery = this.node.getParent();
9285
+ $getSiblingCaret(gallery, this.direction).insert(this.node);
9286
+ return "Image removed from gallery"
9287
+ }
9288
+
9289
+ #moveBlock() {
9290
+ const target = this.#skipProvisional($getSiblingCaret(this.node, this.direction).getNodeAtCaret());
9291
+ if (this.#canJoinGallery(target)) {
9292
+ return this.#joinGallery(target)
9293
+ } else if (this.#canFormGalleryWith(target)) {
9294
+ return this.#formGalleryWith(target)
9295
+ } else if (target) {
9296
+ return this.#swapWith(target)
9297
+ } else {
9298
+ return null
9299
+ }
9300
+ }
9301
+
9302
+ #skipProvisional(node) {
9303
+ let current = node;
9304
+ while (current && $isProvisionalParagraphNode(current)) {
9305
+ current = $getSiblingCaret(current, this.direction).getNodeAtCaret();
9306
+ }
9307
+ return current
9308
+ }
9309
+
9310
+ #canJoinGallery(target) {
9311
+ return $isImageGalleryNode(target) && ImageGalleryNode.isValidChild(this.node)
9312
+ }
9313
+
9314
+ #joinGallery(gallery) {
9315
+ $getChildCaret(gallery, this.direction).insert(this.node);
9316
+ return "Image added to gallery"
9317
+ }
9318
+
9319
+ #canFormGalleryWith(target) {
9320
+ return $isActionTextAttachmentNode(target)
9321
+ && target.isPreviewableImage
9322
+ && this.node.isPreviewableImage
9323
+ }
9324
+
9325
+ #formGalleryWith(target) {
9326
+ if ($findOrCreateGalleryForImage(target)) {
9327
+ $getSiblingCaret(target, flipDirection(this.direction)).insert(this.node);
9328
+ return "Gallery created"
9329
+ } else {
9330
+ return null
9331
+ }
9332
+ }
9333
+
9334
+ #swapWith(target) {
9335
+ $getSiblingCaret(target, this.direction).insert(this.node);
9336
+ if (this.direction === "next") {
9337
+ return "Attachment moved down"
9338
+ } else {
9339
+ return "Attachment moved up"
8792
9340
  }
8793
9341
  }
8794
9342
 
8795
- #dropIntoList(draggedNode, target) {
8796
- const listItemNode = $getNearestNodeFromDOMNode(target.element);
8797
- if (!listItemNode || !$isListItemNode(listItemNode)) return
9343
+ get #blockedMessage() {
9344
+ if (this.direction === "next") {
9345
+ return "Already at the end"
9346
+ } else {
9347
+ return "Already at the start"
9348
+ }
9349
+ }
9350
+ }
8798
9351
 
8799
- const listNode = listItemNode.getParent();
8800
- if (!listNode || !$isListNode(listNode)) return
9352
+ // Using a mention's label as avatar alt text can interrupt line-by-line reading,
9353
+ // but character navigation needs it near the caret to announce the mention.
9354
+ class CustomAttachmentLabels {
9355
+ #editor
9356
+ #listeners = new ListenerBin()
9357
+ #active = null
8801
9358
 
8802
- const children = listNode.getChildren();
8803
- const index = children.indexOf(listItemNode);
8804
- if (index === -1) return
9359
+ constructor(editor) {
9360
+ this.#editor = editor;
9361
+ this.#listeners.track(
9362
+ editor.registerUpdateListener(this.#updateLabel),
9363
+ // A blur command opens a Lexical update that can pull focus back into the editor.
9364
+ editor.registerRootListener(rootElement => {
9365
+ if (rootElement) {
9366
+ return registerEventListener(rootElement, "blur", () => this.#restoreLabel())
9367
+ }
9368
+ })
9369
+ );
9370
+ }
8805
9371
 
8806
- const splitIndex = target.position === "before" ? index : index + 1;
9372
+ destroy() {
9373
+ this.#listeners.dispose();
9374
+ this.#restoreLabel();
9375
+ }
8807
9376
 
8808
- draggedNode.remove();
9377
+ #updateLabel = ({ editorState }) => {
9378
+ if (document.activeElement === this.#editor.getRootElement()) {
9379
+ editorState.read(() => {
9380
+ const selection = $getSelection();
9381
+ const attachment = customAttachmentSelectedBy(selection) || customAttachmentBesideAnchor(selection);
8809
9382
 
8810
- if (splitIndex === 0) {
8811
- listNode.insertBefore(draggedNode);
8812
- } else if (splitIndex >= children.length) {
8813
- listNode.insertAfter(draggedNode);
9383
+ if (attachment) {
9384
+ this.#exposeLabelOn(attachment);
9385
+ } else {
9386
+ this.#restoreLabel();
9387
+ }
9388
+ });
8814
9389
  } else {
8815
- const [ , listAfter ] = $splitNode(listNode, splitIndex);
8816
- listAfter.insertBefore(draggedNode);
9390
+ this.#restoreLabel();
8817
9391
  }
8818
9392
  }
8819
9393
 
8820
- #dropBetweenBlocks(draggedNode, target) {
8821
- const targetNode = $getNearestNodeFromDOMNode(target.element);
8822
- if (!targetNode) return
8823
-
8824
- const topLevelTarget = targetNode.getTopLevelElement?.() || targetNode;
8825
- if (draggedNode.is(topLevelTarget)) return
9394
+ #exposeLabelOn(attachment) {
9395
+ const figure = this.#editor.getElementByKey(attachment.getKey());
9396
+ if (figure && figure !== this.#active?.figure) {
9397
+ this.#restoreLabel();
9398
+ attachment.exposeLabel(figure);
9399
+ this.#active = { figure, attachment };
9400
+ }
9401
+ }
8826
9402
 
8827
- draggedNode.remove();
9403
+ #restoreLabel() {
9404
+ if (this.#active) {
9405
+ const { attachment, figure } = this.#active;
9406
+ attachment.restoreLabel(figure);
9407
+ this.#active = null;
9408
+ }
9409
+ }
9410
+ }
8828
9411
 
8829
- if (target.position === "before") {
8830
- topLevelTarget.insertBefore(draggedNode);
9412
+ function customAttachmentSelectedBy(selection) {
9413
+ if ($isNodeSelection(selection)) {
9414
+ const nodes = selection.getNodes();
9415
+ if (nodes.length === 1 && $isCustomActionTextAttachmentNode(nodes[0])) {
9416
+ return nodes[0]
8831
9417
  } else {
8832
- topLevelTarget.insertAfter(draggedNode);
9418
+ return null
8833
9419
  }
9420
+ } else {
9421
+ return null
8834
9422
  }
9423
+ }
8835
9424
 
8836
- // -- Lifecycle helpers -----------------------------------------------------
8837
-
8838
- #cleanup() {
8839
- this.#clearDropIndicators();
9425
+ // Screen readers need the label one character early to announce it as the caret
9426
+ // crosses the figure.
9427
+ function customAttachmentBesideAnchor(selection) {
9428
+ if ($isRangeSelection(selection) && selection.isCollapsed()) {
9429
+ const anchor = selection.anchor;
9430
+ const parent = anchor.getNode();
9431
+ const candidates = [];
8840
9432
 
8841
- if (this.#draggedNodeKey) {
8842
- const rootElement = this.#editor.getRootElement();
8843
- if (rootElement) {
8844
- const figure = rootElement.querySelector(`[data-lexical-node-key="${this.#draggedNodeKey}"]`);
8845
- figure?.classList.remove("lexxy-dragging");
8846
- }
9433
+ if (anchor.type === "text") {
9434
+ const toEnd = parent.getTextContentSize() - anchor.offset;
9435
+ if (toEnd <= 1) candidates.push({ node: parent.getNextSibling(), distance: toEnd });
9436
+ if (anchor.offset <= 1) candidates.push({ node: parent.getPreviousSibling(), distance: anchor.offset });
9437
+ } else {
9438
+ candidates.push({ node: parent.getChildAtIndex(anchor.offset), distance: 0 });
9439
+ candidates.push({ node: parent.getChildAtIndex(anchor.offset - 1), distance: 0 });
8847
9440
  }
8848
9441
 
8849
- this.#draggedNodeKey = null;
8850
-
8851
- if (this.#rafId) {
8852
- cancelAnimationFrame(this.#rafId);
8853
- this.#rafId = null;
8854
- }
9442
+ const nearest = candidates
9443
+ .filter(({ node }) => $isCustomActionTextAttachmentNode(node))
9444
+ .sort((a, b) => a.distance - b.distance)[0];
8855
9445
 
8856
- if (this.#draggingRafId) {
8857
- cancelAnimationFrame(this.#draggingRafId);
8858
- this.#draggingRafId = null;
8859
- }
9446
+ return nearest?.node ?? null
9447
+ } else {
9448
+ return null
8860
9449
  }
8861
9450
  }
8862
9451
 
@@ -8886,12 +9475,19 @@ class AttachmentsExtension extends LexxyExtension {
8886
9475
  ],
8887
9476
  register: (editor) => {
8888
9477
  const dragAndDrop = new AttachmentDragAndDrop(editor);
9478
+ const keyboardMove = new AttachmentKeyboardMove(editor);
9479
+ const fakeSelection = new AttachmentFakeSelection(editor);
9480
+ const customAttachmentLabels = new CustomAttachmentLabels(editor);
8889
9481
 
8890
9482
  return mergeRegister(
8891
9483
  editor.registerNodeTransform(ActionTextAttachmentNode, $extractAttachmentFromParagraph),
8892
9484
  editor.registerCommand(DELETE_CHARACTER_COMMAND, $collapseIntoGallery, COMMAND_PRIORITY_NORMAL),
9485
+ editor.registerCommand(KEY_TAB_COMMAND, $focusCaptionFromSelectedAttachment, COMMAND_PRIORITY_HIGH),
8893
9486
  editor.registerMutationListener(ActionTextAttachmentUploadNode, this.#handleUploadMutations.bind(this)),
8894
- () => dragAndDrop.destroy()
9487
+ () => dragAndDrop.destroy(),
9488
+ () => keyboardMove.destroy(),
9489
+ () => fakeSelection.destroy(),
9490
+ () => customAttachmentLabels.destroy()
8895
9491
  )
8896
9492
  }
8897
9493
  })
@@ -8989,6 +9585,16 @@ function $collapseAtGalleryEdge(anchor, backwards) {
8989
9585
  }
8990
9586
  }
8991
9587
 
9588
+ function $focusCaptionFromSelectedAttachment(event) {
9589
+ const node = $singleSelectedNode();
9590
+ if (!event.shiftKey && $isActionTextAttachmentNode(node) && node.focusCaption()) {
9591
+ event.preventDefault();
9592
+ return true
9593
+ } else {
9594
+ return false
9595
+ }
9596
+ }
9597
+
8992
9598
  // Manual selection handling to prevent Lexical merging the gallery with a <p> and unwrapping it
8993
9599
  function $moveSelectionBeforeGallery(anchor) {
8994
9600
  const previousNode = anchor.getNode().getPreviousSibling();
@@ -9720,6 +10326,7 @@ class LexicalEditorElement extends HTMLElement {
9720
10326
  #validity = new Map()
9721
10327
  #validationTextArea = document.createElement("textarea")
9722
10328
  #uploadRequests
10329
+ #liveRegion
9723
10330
 
9724
10331
  constructor() {
9725
10332
  super();
@@ -9750,6 +10357,10 @@ class LexicalEditorElement extends HTMLElement {
9750
10357
  this.clipboard = new Clipboard(this);
9751
10358
  this.#disposables.push(this.clipboard);
9752
10359
 
10360
+ this.#liveRegion = this.querySelector("lexxy-live-region") ?? createElement("lexxy-live-region");
10361
+ this.append(this.#liveRegion);
10362
+ this.#disposables.push(this.#liveRegion);
10363
+
9753
10364
  this.adapter = new BrowserAdapter();
9754
10365
  this.#uploadRequests = new UploadRequests();
9755
10366
 
@@ -9895,6 +10506,16 @@ class LexicalEditorElement extends HTMLElement {
9895
10506
  return dispatch(this, "lexxy:file-accept", { file }, true)
9896
10507
  }
9897
10508
 
10509
+ announce(message) {
10510
+ if (message) {
10511
+ if (typeof document.ariaNotify === "function") {
10512
+ document.ariaNotify(message, { priority: "high" });
10513
+ } else {
10514
+ this.#liveRegion?.announce(message);
10515
+ }
10516
+ }
10517
+ }
10518
+
9898
10519
  $generateNodesFromDOM(doc, { editor = this.editor } = {}) {
9899
10520
  let nodes = $generateNodesFromDOM(editor, doc);
9900
10521
  if ($hasUpdateTag(PASTE_TAG)) nodes = $convertInlineImageDataURIs(nodes, this);
@@ -10260,6 +10881,9 @@ class LexicalEditorElement extends HTMLElement {
10260
10881
  );
10261
10882
  this.#registerTableComponents();
10262
10883
  this.#registerCodeLanguagePicker();
10884
+ if (this.supportsAttachments) {
10885
+ this.#registerAttachmentToolbar();
10886
+ }
10263
10887
  if (this.supportsMarkdown) {
10264
10888
  const transformers = [ ...TRANSFORMERS, HORIZONTAL_DIVIDER ];
10265
10889
  registered.push(
@@ -10288,6 +10912,15 @@ class LexicalEditorElement extends HTMLElement {
10288
10912
  this.#disposables.push(codeLanguagePicker);
10289
10913
  }
10290
10914
 
10915
+ #registerAttachmentToolbar() {
10916
+ let attachmentToolbar = this.querySelector("lexxy-attachment-toolbar");
10917
+ attachmentToolbar ??= createElement("lexxy-attachment-toolbar");
10918
+ this.append(attachmentToolbar);
10919
+ this.#disposables.push(attachmentToolbar);
10920
+ this.captionEditor = new CaptionEditor(this);
10921
+ this.#disposables.push(this.captionEditor);
10922
+ }
10923
+
10291
10924
  #handleEnter() {
10292
10925
  // We can't prevent these externally using regular keydown because Lexical handles it first.
10293
10926
  this.#listeners.track(this.editor.registerCommand(
@@ -10496,21 +11129,22 @@ class LexicalEditorElement extends HTMLElement {
10496
11129
  // — triggering at most one forced reflow. The previous implementation interleaved
10497
11130
  // setProperty/getComputedStyle/removeProperty on the same element, forcing a style
10498
11131
  // recalc on every iteration during editor initialization.
10499
- #resolveColors(property, cssValues) {
11132
+ #resolveColors(property, buttons) {
10500
11133
  const container = document.createElement("span");
10501
11134
  container.style.display = "none";
10502
11135
 
10503
- const resolvers = cssValues.map(cssValue => {
11136
+ const resolvers = normalizeColorButtons(buttons).map(({ value, label }) => {
10504
11137
  const element = document.createElement("span");
10505
- element.style.setProperty(property, cssValue);
11138
+ element.style.setProperty(property, value);
10506
11139
  container.appendChild(element);
10507
- return { element, name: cssValue }
11140
+ return { element, name: value, label }
10508
11141
  });
10509
11142
 
10510
11143
  styleResolverRoot().appendChild(container);
10511
11144
 
10512
- const resolved = resolvers.map(({ element, name }) => ({
11145
+ const resolved = resolvers.map(({ element, name, label }) => ({
10513
11146
  name,
11147
+ label,
10514
11148
  value: window.getComputedStyle(element).getPropertyValue(property)
10515
11149
  }));
10516
11150
 
@@ -10574,6 +11208,39 @@ function $getReadableTextContent(node) {
10574
11208
  return node.getTextContent()
10575
11209
  }
10576
11210
 
11211
+ const REMOVAL_DELAY = 1000;
11212
+
11213
+ class LiveRegion extends HTMLElement {
11214
+ #additions = createElement("span", { ariaLive: "assertive", ariaRelevant: "additions" })
11215
+ #removalTimeouts = new Set()
11216
+
11217
+ connectedCallback() {
11218
+ this.replaceChildren(this.#additions);
11219
+ }
11220
+
11221
+ disconnectedCallback() {
11222
+ this.dispose();
11223
+ }
11224
+
11225
+ dispose() {
11226
+ for (const timeout of this.#removalTimeouts) clearTimeout(timeout);
11227
+ this.#removalTimeouts.clear();
11228
+ this.#additions.replaceChildren();
11229
+ this.replaceChildren();
11230
+ }
11231
+
11232
+ announce(message) {
11233
+ const announcement = createElement("div", { textContent: message });
11234
+ this.#additions.appendChild(announcement);
11235
+
11236
+ const timeout = setTimeout(() => {
11237
+ announcement.remove();
11238
+ this.#removalTimeouts.delete(timeout);
11239
+ }, REMOVAL_DELAY);
11240
+ this.#removalTimeouts.add(timeout);
11241
+ }
11242
+ }
11243
+
10577
11244
  class BaseSource {
10578
11245
  // Template method to override
10579
11246
  async buildListItems(filter = "") {
@@ -11460,46 +12127,233 @@ const DELETE_ICON = `<svg viewBox="0 0 18 18" xmlns="http://www.w3.org/2000/svg"
11460
12127
  <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
12128
  </svg>`;
11462
12129
 
11463
- class NodeDeleteButton extends HTMLElement {
12130
+ class AttachmentToolbar extends HTMLElement {
12131
+ #listeners = new ListenerBin()
12132
+ #buttons = []
12133
+ #currentNodeKey = null
12134
+ #reflowObserver = new ResizeObserver(() => this.#updatePosition())
12135
+ #presentationObserver = new MutationObserver(() => this.#refresh())
12136
+ #observedElement = null
12137
+
11464
12138
  connectedCallback() {
11465
- this.editorElement = this.closest("lexxy-editor");
11466
- this.editor = this.editorElement.editor;
11467
12139
  this.classList.add("lexxy-floating-controls");
12140
+ this.role = "toolbar";
12141
+ this.hidden = true;
11468
12142
 
11469
- if (!this.querySelector(".lexxy-node-delete")) {
11470
- this.#attachDeleteButton();
12143
+ if (this.#editor) {
12144
+ this.#setUpButtons();
12145
+ this.#monitorSelection();
12146
+ this.#followScrolling();
12147
+ this.#registerKeyboardShortcut();
11471
12148
  }
11472
12149
  }
11473
12150
 
11474
12151
  disconnectedCallback() {
11475
- this.editor = null;
11476
- this.editorElement = null;
12152
+ this.dispose();
12153
+ }
12154
+
12155
+ dispose() {
12156
+ this.#listeners.dispose();
12157
+ this.#reflowObserver.disconnect();
12158
+ this.#presentationObserver.disconnect();
12159
+ }
12160
+
12161
+ get #editor() {
12162
+ return this.#editorElement?.editor
12163
+ }
12164
+
12165
+ get #editorElement() {
12166
+ return this.closest("lexxy-editor")
11477
12167
  }
11478
12168
 
11479
- #attachDeleteButton() {
12169
+ #setUpButtons() {
12170
+ this.innerHTML = "";
12171
+
11480
12172
  const container = createElement("div", { className: "lexxy-floating-controls__group" });
12173
+ container.appendChild(this.#createRemoveButton());
12174
+ this.appendChild(container);
12175
+
12176
+ this.#buttons = Array.from(this.querySelectorAll("button"));
12177
+ this.#listeners.track(
12178
+ registerEventListener(this, "keydown", this.#navigateOrExit),
12179
+ registerEventListener(this, "focusout", this.#resetTabIndexOnExit)
12180
+ );
12181
+ }
11481
12182
 
11482
- this.deleteButton = createElement("button", {
11483
- className: "lexxy-node-delete",
12183
+ #createRemoveButton() {
12184
+ const button = createElement("button", {
11484
12185
  type: "button",
11485
- "aria-label": "Remove"
12186
+ className: "lexxy-attachment-toolbar__remove",
12187
+ ariaLabel: "Remove",
12188
+ tabIndex: -1
11486
12189
  });
11487
- this.deleteButton.tabIndex = -1;
11488
- this.deleteButton.innerHTML = DELETE_ICON;
12190
+ button.innerHTML = DELETE_ICON;
12191
+ this.#listeners.track(registerEventListener(button, "click", () => this.#removeSelectedNode()));
12192
+ return button
12193
+ }
11489
12194
 
11490
- this.handleDeleteClick = () => this.#deleteNode();
11491
- this.deleteButton.addEventListener("click", this.handleDeleteClick);
11492
- container.appendChild(this.deleteButton);
12195
+ #removeSelectedNode() {
12196
+ if (this.#hasSelectedNode) {
12197
+ this.#editor.update(() => $getNodeByKey(this.#currentNodeKey)?.remove());
12198
+ this.#editor.focus();
12199
+ }
12200
+ }
11493
12201
 
11494
- this.appendChild(container);
12202
+ get #hasSelectedNode() {
12203
+ return this.#currentNodeKey !== null
11495
12204
  }
11496
12205
 
11497
- #deleteNode() {
11498
- this.editor.update(() => {
11499
- const node = $getNearestNodeFromDOMNode(this);
11500
- node?.remove();
12206
+ #navigateOrExit = (event) => {
12207
+ if (event.key === "Escape") {
12208
+ event.preventDefault();
12209
+ event.stopPropagation();
12210
+ this.#returnFocusToEditor();
12211
+ } else {
12212
+ handleRollingTabIndex(this.#buttons, event);
12213
+ }
12214
+ }
12215
+
12216
+ #returnFocusToEditor() {
12217
+ this.#editor.getRootElement()?.focus({ preventScroll: true });
12218
+ if (this.#currentNodeKey) {
12219
+ const nodeKey = this.#currentNodeKey;
12220
+ this.#editor.update(() => {
12221
+ const node = $getNodeByKey(nodeKey);
12222
+ if (node) $setSelection($createNodeSelectionWith(node));
12223
+ });
12224
+ }
12225
+ }
12226
+
12227
+ #resetTabIndexOnExit = (event) => {
12228
+ if (!this.contains(event.relatedTarget)) {
12229
+ this.#buttons.forEach(button => button.tabIndex = -1);
12230
+ }
12231
+ }
12232
+
12233
+ #monitorSelection() {
12234
+ this.#listeners.track(
12235
+ registerLabelledDecoratorSelection(this.#editor, (target) => this.#updateForSelection(target))
12236
+ );
12237
+ }
12238
+
12239
+ #updateForSelection(target) {
12240
+ if (target) {
12241
+ this.#show(target.key);
12242
+ } else {
12243
+ this.#hide();
12244
+ }
12245
+ }
12246
+
12247
+ #show(nodeKey) {
12248
+ this.#currentNodeKey = nodeKey;
12249
+ this.#refresh();
12250
+ this.hidden = false;
12251
+ this.#reflowObserver.observe(this.#editorElement);
12252
+ }
12253
+
12254
+ #refresh() {
12255
+ if (this.#hasSelectedNode) {
12256
+ this.#watchPresentation(this.#currentNodeKey);
12257
+ this.#describeNode(this.#currentNodeKey);
12258
+ this.#updatePosition();
12259
+ }
12260
+ }
12261
+
12262
+ // Failed previews change presentation without a Lexical update, and a node
12263
+ // can replace its figure without changing its key.
12264
+ #watchPresentation(nodeKey) {
12265
+ const element = this.#editor.getElementByKey(nodeKey);
12266
+ if (element !== this.#observedElement) {
12267
+ this.#presentationObserver.disconnect();
12268
+ this.#observedElement = element;
12269
+ if (element) this.#presentationObserver.observe(element, { attributes: true, attributeFilter: [ "class" ] });
12270
+ }
12271
+ }
12272
+
12273
+ #describeNode(nodeKey) {
12274
+ const element = this.#editor.getElementByKey(nodeKey);
12275
+
12276
+ this.#editor.getEditorState().read(() => {
12277
+ const node = $getNodeByKey(nodeKey);
12278
+ this.dataset.nodeType = node.getType();
12279
+ this.dataset.presentation = this.#presentationOf(node, element);
12280
+
12281
+ if (node.contentType) {
12282
+ this.dataset.contentType = node.contentType;
12283
+ } else {
12284
+ delete this.dataset.contentType;
12285
+ }
11501
12286
  });
11502
12287
  }
12288
+
12289
+ #presentationOf(node, element) {
12290
+ if ($isImageGalleryNode(node.getParent())) {
12291
+ return "gallery"
12292
+ } else if (node.isInline() && this.#rendersInline(element)) {
12293
+ return "inline"
12294
+ } else if (element?.classList.contains("attachment--preview")) {
12295
+ return "preview"
12296
+ } else {
12297
+ return "block"
12298
+ }
12299
+ }
12300
+
12301
+ #rendersInline(element) {
12302
+ return element != null && getComputedStyle(element).display.startsWith("inline")
12303
+ }
12304
+
12305
+ #updatePosition() {
12306
+ const figureElement = this.#editor.getElementByKey(this.#currentNodeKey);
12307
+ if (figureElement) {
12308
+ const rect = figureElement.getBoundingClientRect();
12309
+ const editorRect = this.#editorElement.getBoundingClientRect();
12310
+ this.style.setProperty("--lexxy-anchor-top", `${rect.top - editorRect.top}px`);
12311
+ this.style.setProperty("--lexxy-anchor-left", `${rect.left - editorRect.left}px`);
12312
+ this.style.setProperty("--lexxy-anchor-width", `${rect.width}px`);
12313
+ this.style.setProperty("--lexxy-anchor-height", `${rect.height}px`);
12314
+ this.#flipWhenOverflowing(editorRect);
12315
+ }
12316
+ }
12317
+
12318
+ #flipWhenOverflowing(editorRect) {
12319
+ this.removeAttribute("data-overflow");
12320
+ if (this.getBoundingClientRect().right > editorRect.right) {
12321
+ this.setAttribute("data-overflow", "");
12322
+ }
12323
+ }
12324
+
12325
+ #hide() {
12326
+ if (this.#hasSelectedNode) {
12327
+ this.#currentNodeKey = null;
12328
+ this.hidden = true;
12329
+ this.#reflowObserver.disconnect();
12330
+ this.#presentationObserver.disconnect();
12331
+ this.#observedElement = null;
12332
+ }
12333
+ }
12334
+
12335
+ // Scrolling inside the editor moves the figure without resizing anything.
12336
+ #followScrolling() {
12337
+ this.#listeners.track(registerEventListener(this.#editorElement, "scroll", () => this.#updatePosition(), { capture: true, passive: true }));
12338
+ }
12339
+
12340
+ #registerKeyboardShortcut() {
12341
+ this.#listeners.track(this.#editor.registerCommand(
12342
+ KEY_DOWN_COMMAND,
12343
+ this.#focusToolbarOnAltF10,
12344
+ COMMAND_PRIORITY_HIGH
12345
+ ));
12346
+ }
12347
+
12348
+ #focusToolbarOnAltF10 = (event) => {
12349
+ if (this.#hasSelectedNode && event.altKey && event.key === "F10") {
12350
+ event.preventDefault();
12351
+ this.#buttons[0]?.focus();
12352
+ return true
12353
+ } else {
12354
+ return false
12355
+ }
12356
+ }
11503
12357
  }
11504
12358
 
11505
12359
  class TableController {
@@ -11904,7 +12758,7 @@ class TableTools extends HTMLElement {
11904
12758
  #listeners = new ListenerBin()
11905
12759
 
11906
12760
  connectedCallback() {
11907
- this.tableController = new TableController(this.#editorElement);
12761
+ this.tableController = new TableController(this.editorElement);
11908
12762
  this.classList.add("lexxy-floating-controls");
11909
12763
  this.role = "toolbar";
11910
12764
 
@@ -11925,16 +12779,34 @@ class TableTools extends HTMLElement {
11925
12779
  this.tableController = null;
11926
12780
  }
11927
12781
 
11928
- get #editor() {
11929
- return this.#editorElement.editor
12782
+ get editorElement() {
12783
+ return this.closest("lexxy-editor")
11930
12784
  }
11931
12785
 
11932
- get #editorElement() {
11933
- return this.closest("lexxy-editor")
12786
+ get editor() {
12787
+ return this.editorElement.editor
12788
+ }
12789
+
12790
+ getEditorElement() {
12791
+ return this.editorElement
12792
+ }
12793
+
12794
+ closeDropdowns({ except } = {}) {
12795
+ this.#moreMenus.forEach(menu => {
12796
+ if (menu !== except) menu.close({ focusEditor: false });
12797
+ });
12798
+ }
12799
+
12800
+ get #moreMenus() {
12801
+ return this.querySelectorAll("lexxy-toolbar-dropdown")
12802
+ }
12803
+
12804
+ get #hasOpenMoreMenu() {
12805
+ return Array.from(this.#moreMenus).some(menu => menu.isOpen)
11934
12806
  }
11935
12807
 
11936
12808
  get #tableToolsButtons() {
11937
- return Array.from(this.querySelectorAll("button, details > summary"))
12809
+ return Array.from(this.querySelectorAll("button")).filter(button => !button.closest("[data-dropdown-panel]"))
11938
12810
  }
11939
12811
 
11940
12812
  #setUpButtons() {
@@ -11953,23 +12825,37 @@ class TableTools extends HTMLElement {
11953
12825
  const plusButton = this.#createButton(`Add ${childType}`, { action: "insert", childType, direction: "after" }, "+");
11954
12826
  const minusButton = this.#createButton(`Remove ${childType}`, { action: "delete", childType }, "−");
11955
12827
 
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
12828
  container.appendChild(minusButton);
11967
- container.appendChild(dropdown);
12829
+ container.appendChild(this.#createMoreMenu(childType, setCountProperty, moreMenu));
11968
12830
  container.appendChild(plusButton);
11969
12831
 
11970
12832
  return container
11971
12833
  }
11972
12834
 
12835
+ #createMoreMenu(childType, setCountProperty, menu) {
12836
+ const dropdown = createElement("lexxy-toolbar-dropdown", { className: "lexxy-table-control__more-menu" });
12837
+
12838
+ const trigger = createElement("button", {
12839
+ type: "button",
12840
+ className: "lexxy-table-control__more-menu-trigger",
12841
+ dataset: { dropdownTrigger: "" },
12842
+ "aria-haspopup": "menu",
12843
+ "aria-expanded": "false"
12844
+ }, `_ ${childType}s`);
12845
+ trigger.tabIndex = -1;
12846
+ setCountProperty(trigger);
12847
+
12848
+ menu.dataset.dropdownPanel = "";
12849
+ menu.role = "menu";
12850
+ menu.setAttribute("aria-label", `${childType} options`);
12851
+ menu.hidden = true;
12852
+
12853
+ dropdown.appendChild(trigger);
12854
+ dropdown.appendChild(menu);
12855
+
12856
+ return dropdown
12857
+ }
12858
+
11973
12859
  #createRowButtonsContainer() {
11974
12860
  return this.#createButtonsContainer(
11975
12861
  "row",
@@ -11988,15 +12874,18 @@ class TableTools extends HTMLElement {
11988
12874
 
11989
12875
  #createMoreMenuSection(childType) {
11990
12876
  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
12877
 
11996
- section.appendChild(addBeforeButton);
11997
- section.appendChild(addAfterButton);
11998
- section.appendChild(toggleStyleButton);
11999
- section.appendChild(deleteButton);
12878
+ const items = [
12879
+ this.#createButton(`Add ${childType} before`, { action: "insert", childType, direction: "before" }),
12880
+ this.#createButton(`Add ${childType} after`, { action: "insert", childType, direction: "after" }),
12881
+ this.#createButton(`Toggle ${childType} style`, { action: "toggle", childType }),
12882
+ this.#createButton(`Remove ${childType}`, { action: "delete", childType })
12883
+ ];
12884
+
12885
+ items.forEach(item => {
12886
+ item.role = "menuitem";
12887
+ section.appendChild(item);
12888
+ });
12000
12889
 
12001
12890
  return section
12002
12891
  }
@@ -12037,7 +12926,7 @@ class TableTools extends HTMLElement {
12037
12926
  }
12038
12927
 
12039
12928
  #registerKeyboardShortcuts() {
12040
- this.#listeners.track(this.#editor.registerCommand(KEY_DOWN_COMMAND, this.#focusToolbarOnAltF10, COMMAND_PRIORITY_HIGH));
12929
+ this.#listeners.track(this.editor.registerCommand(KEY_DOWN_COMMAND, this.#focusToolbarOnAltF10, COMMAND_PRIORITY_HIGH));
12041
12930
  }
12042
12931
 
12043
12932
  #focusToolbarOnAltF10 = (event) => {
@@ -12068,9 +12957,9 @@ class TableTools extends HTMLElement {
12068
12957
  const cell = this.tableController.currentCell;
12069
12958
  if (!cell) return
12070
12959
 
12071
- this.#editor.update(() => {
12960
+ this.editor.update(() => {
12072
12961
  cell.select();
12073
- this.#editor.focus();
12962
+ this.editor.focus();
12074
12963
  });
12075
12964
 
12076
12965
  this.#update();
@@ -12107,7 +12996,7 @@ class TableTools extends HTMLElement {
12107
12996
  if (!cellsToHighlight) return
12108
12997
 
12109
12998
  cellsToHighlight.forEach(cell => {
12110
- const cellElement = this.#editor.getElementByKey(cell.getKey());
12999
+ const cellElement = this.editor.getElementByKey(cell.getKey());
12111
13000
  if (!cellElement) return
12112
13001
 
12113
13002
  cellElement.classList.toggle(theme.tableCellHighlight, true);
@@ -12116,8 +13005,8 @@ class TableTools extends HTMLElement {
12116
13005
  }
12117
13006
 
12118
13007
  #monitorForTableSelection() {
12119
- this.#listeners.track(this.#editor.registerUpdateListener(() => {
12120
- const tableNode = this.#editor.getRootElement() && this.tableController.updateSelectedTable();
13008
+ this.#listeners.track(this.editor.registerUpdateListener(() => {
13009
+ const tableNode = this.editor.getRootElement() && this.tableController.updateSelectedTable();
12121
13010
 
12122
13011
  if (tableNode) {
12123
13012
  this.#show();
@@ -12128,8 +13017,12 @@ class TableTools extends HTMLElement {
12128
13017
  }
12129
13018
 
12130
13019
  #executeTableCommand(command) {
13020
+ const fromMoreMenu = this.#hasOpenMoreMenu;
13021
+
12131
13022
  this.tableController.executeTableCommand(command);
12132
13023
  this.#update();
13024
+
13025
+ if (fromMoreMenu) this.editor.focus();
12133
13026
  }
12134
13027
 
12135
13028
  #show() {
@@ -12153,18 +13046,18 @@ class TableTools extends HTMLElement {
12153
13046
  }
12154
13047
 
12155
13048
  #closeMoreMenu() {
12156
- this.querySelector("details[open]")?.removeAttribute("open");
13049
+ this.closeDropdowns();
12157
13050
  }
12158
13051
 
12159
13052
  #updateButtonsPosition() {
12160
13053
  const tableNode = this.tableController.currentTableNode;
12161
13054
  if (!tableNode) return
12162
13055
 
12163
- const tableElement = this.#editor.getElementByKey(tableNode.getKey());
13056
+ const tableElement = this.editor.getElementByKey(tableNode.getKey());
12164
13057
  if (!tableElement) return
12165
13058
 
12166
13059
  const tableRect = tableElement.getBoundingClientRect();
12167
- const editorRect = this.#editorElement.getBoundingClientRect();
13060
+ const editorRect = this.editorElement.getBoundingClientRect();
12168
13061
 
12169
13062
  const relativeTop = tableRect.top - editorRect.top;
12170
13063
  const relativeCenter = (tableRect.left + tableRect.right) / 2 - editorRect.left;
@@ -12176,7 +13069,7 @@ class TableTools extends HTMLElement {
12176
13069
  const tableNode = this.tableController.currentTableNode;
12177
13070
  if (!tableNode) return
12178
13071
 
12179
- const tableElement = $getElementForTableNode(this.#editor, tableNode);
13072
+ const tableElement = $getElementForTableNode(this.editor, tableNode);
12180
13073
  if (!tableElement) return
12181
13074
 
12182
13075
  const rowCount = tableElement.rows;
@@ -12190,18 +13083,18 @@ class TableTools extends HTMLElement {
12190
13083
  const cell = this.tableController.currentCell;
12191
13084
  if (!cell) return
12192
13085
 
12193
- const cellElement = this.#editor.getElementByKey(cell.getKey());
12194
- if (!cellElement) return
13086
+ const cellElement = this.editor.getElementByKey(cell.getKey());
13087
+ if (!cellElement?.contains(document.getSelection()?.anchorNode)) return
12195
13088
 
12196
13089
  cellElement.classList.add(theme.tableCellFocus);
12197
13090
  }
12198
13091
 
12199
13092
  #clearCellStyles() {
12200
- this.#editorElement.querySelectorAll(`.${theme.tableCellFocus}`)?.forEach(cell => {
13093
+ this.editorElement.querySelectorAll(`.${theme.tableCellFocus}`)?.forEach(cell => {
12201
13094
  cell.classList.remove(theme.tableCellFocus);
12202
13095
  });
12203
13096
 
12204
- this.#editorElement.querySelectorAll(`.${theme.tableCellHighlight}`)?.forEach(cell => {
13097
+ this.editorElement.querySelectorAll(`.${theme.tableCellHighlight}`)?.forEach(cell => {
12205
13098
  cell.classList.remove(theme.tableCellHighlight);
12206
13099
  cell.removeAttribute("data-action");
12207
13100
  cell.removeAttribute("data-child-type");
@@ -12233,7 +13126,8 @@ function defineElements() {
12233
13126
  // Prompt must be registered AFTER Editor
12234
13127
  "lexxy-prompt": LexicalPromptElement,
12235
13128
  "lexxy-code-language-picker": CodeLanguagePicker,
12236
- "lexxy-node-delete-button": NodeDeleteButton,
13129
+ "lexxy-live-region": LiveRegion,
13130
+ "lexxy-attachment-toolbar": AttachmentToolbar,
12237
13131
  "lexxy-table-tools": TableTools
12238
13132
  };
12239
13133