@37signals/lexxy 0.9.31 → 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,18 +1,18 @@
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';
14
14
  import { HistoryExtension } from '@lexical/history';
15
- import { $isCodeNode, CodeHighlightNode, PrismTokenizer, $isCodeHighlightNode, $createCodeNode, $createCodeHighlightNode, CodeNode, registerCodeHighlighting, normalizeCodeLang, CODE_LANGUAGE_FRIENDLY_NAME_MAP } from '@lexical/code';
15
+ import { $isCodeNode, CodeHighlightNode, $isCodeHighlightNode, $createCodeNode, $createCodeHighlightNode, CodeNode, registerCodeHighlighting, PrismTokenizer, normalizeCodeLang, CODE_LANGUAGE_FRIENDLY_NAME_MAP } from '@lexical/code';
16
16
  import { TRANSFORMERS, registerMarkdownShortcuts } from '@lexical/markdown';
17
17
  import { INSERT_TABLE_COMMAND, $getTableCellNodeFromLexicalNode, TableCellNode, TableNode, TableRowNode, setScrollableTablesActive, registerTablePlugin, registerTableSelectionObserver, TableCellHeaderStates, $insertTableRowAtSelection, $insertTableColumnAtSelection, $deleteTableRowAtSelection, $deleteTableColumnAtSelection, $findTableNode, $getTableRowIndexFromTableCellNode, $getTableColumnIndexFromTableCellNode, $findCellNode, $getElementForTableNode } from '@lexical/table';
18
18
  import { Marked } from 'marked';
@@ -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;
@@ -1908,12 +2111,11 @@ class CustomActionTextAttachmentNode extends DecoratorNode {
1908
2111
  return Lexxy.global.get("attachmentTagName")
1909
2112
  }
1910
2113
 
1911
- constructor({ tagName, sgid, contentType, innerHtml, plainText } = {}, key) {
2114
+ constructor({ sgid, contentType, innerHtml, plainText } = {}, key) {
1912
2115
  super(key);
1913
2116
 
1914
2117
  const contentTypeNamespace = Lexxy.global.get("attachmentContentTypeNamespace");
1915
2118
 
1916
- this.tagName = tagName || CustomActionTextAttachmentNode.TAG_NAME;
1917
2119
  this.sgid = sgid;
1918
2120
  this.contentType = contentType || `application/vnd.${contentTypeNamespace}.unknown`;
1919
2121
  this.innerHtml = innerHtml;
@@ -1921,7 +2123,7 @@ class CustomActionTextAttachmentNode extends DecoratorNode {
1921
2123
  }
1922
2124
 
1923
2125
  createDOM(_config, editor) {
1924
- const figure = createElement(this.tagName, { "content-type": this.contentType, "data-lexxy-decorator": true, draggable: true });
2126
+ const figure = createElement(CustomActionTextAttachmentNode.TAG_NAME, { "content-type": this.contentType, "data-lexxy-decorator": true, draggable: true });
1925
2127
  figure.dataset.lexicalNodeKey = this.__key;
1926
2128
 
1927
2129
  // Resolved from the editor so this content is sanitized with its own
@@ -1940,13 +2142,35 @@ class CustomActionTextAttachmentNode extends DecoratorNode {
1940
2142
  // is cosmetic: it is a nested attachment's rendering inside this one, not the
1941
2143
  // attribute anything re-imports from.
1942
2144
  figure.insertAdjacentHTML("beforeend", EditorSanitizer.for(editor).sanitize(this.innerHtml, { safeForXml: true }));
1943
-
1944
- const deleteButton = createElement("lexxy-node-delete-button");
1945
- figure.appendChild(deleteButton);
2145
+ this.#markImagesAsDecorative(figure);
2146
+ this.#tagLabelImage(figure);
2147
+ this.#tagLabelMirrors(figure);
1946
2148
 
1947
2149
  return figure
1948
2150
  }
1949
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
+
1950
2174
  updateDOM() {
1951
2175
  return false
1952
2176
  }
@@ -1964,7 +2188,7 @@ class CustomActionTextAttachmentNode extends DecoratorNode {
1964
2188
  }
1965
2189
 
1966
2190
  exportDOM() {
1967
- const attachment = createElement(this.tagName, {
2191
+ const attachment = createElement(CustomActionTextAttachmentNode.TAG_NAME, {
1968
2192
  sgid: this.sgid,
1969
2193
  content: this.innerHtml,
1970
2194
  "content-type": this.contentType
@@ -1977,7 +2201,6 @@ class CustomActionTextAttachmentNode extends DecoratorNode {
1977
2201
  return {
1978
2202
  type: "custom_action_text_attachment",
1979
2203
  version: 1,
1980
- tagName: this.tagName,
1981
2204
  sgid: this.sgid,
1982
2205
  contentType: this.contentType,
1983
2206
  innerHtml: this.innerHtml,
@@ -1988,6 +2211,34 @@ class CustomActionTextAttachmentNode extends DecoratorNode {
1988
2211
  decorate() {
1989
2212
  return null
1990
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
+ }
1991
2242
  }
1992
2243
 
1993
2244
  function $isCustomActionTextAttachmentNode(node) {
@@ -2091,6 +2342,35 @@ function $createNodeSelectionWith(...nodes) {
2091
2342
  return selection
2092
2343
  }
2093
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
+
2094
2374
  function $isShadowRoot(node) {
2095
2375
  return $isElementNode(node) && $isRootOrShadowRoot(node) && !$isRootNode(node)
2096
2376
  }
@@ -2116,6 +2396,10 @@ function getListType(node) {
2116
2396
  return list?.getListType() ?? null
2117
2397
  }
2118
2398
 
2399
+ function announceFromEditor(editor, message) {
2400
+ editor.getRootElement()?.closest("lexxy-editor")?.announce(message);
2401
+ }
2402
+
2119
2403
  function isEditorFocused(editor) {
2120
2404
  const rootElement = editor.getRootElement();
2121
2405
  return rootElement !== null && rootElement.contains(document.activeElement)
@@ -2724,10 +3008,9 @@ class ActionTextAttachmentNode extends DecoratorNode {
2724
3008
  return Lexxy.global.get("attachmentTagName")
2725
3009
  }
2726
3010
 
2727
- constructor({ tagName, sgid, src, previewSrc, previewable, previewStatusUrl, pendingPreview, altText, caption, contentType, fileName, fileSize, width, height, uploadError } = {}, key) {
3011
+ constructor({ sgid, src, previewSrc, previewable, previewStatusUrl, pendingPreview, altText, caption, contentType, fileName, fileSize, width, height, uploadError } = {}, key) {
2728
3012
  super(key);
2729
3013
 
2730
- this.tagName = tagName || ActionTextAttachmentNode.TAG_NAME;
2731
3014
  this.sgid = sgid;
2732
3015
  this.src = src;
2733
3016
  this.previewSrc = previewSrc;
@@ -2769,14 +3052,27 @@ class ActionTextAttachmentNode extends DecoratorNode {
2769
3052
  updateDOM(prevNode, dom) {
2770
3053
  if (this.uploadError !== prevNode.uploadError) return true
2771
3054
 
2772
- const caption = dom.querySelector("figcaption textarea");
2773
- if (caption && this.caption) {
2774
- 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;
2775
3067
  }
2776
3068
 
2777
3069
  return false
2778
3070
  }
2779
3071
 
3072
+ get captionLabel() {
3073
+ return this.caption || this.fileName || ""
3074
+ }
3075
+
2780
3076
  getTextContent() {
2781
3077
  return `[${this.caption || this.fileName}]\n\n`
2782
3078
  }
@@ -2786,7 +3082,7 @@ class ActionTextAttachmentNode extends DecoratorNode {
2786
3082
  }
2787
3083
 
2788
3084
  exportDOM() {
2789
- const attachment = createElement(this.tagName, {
3085
+ const attachment = createElement(ActionTextAttachmentNode.TAG_NAME, {
2790
3086
  sgid: this.sgid,
2791
3087
  previewable: this.previewable || null,
2792
3088
  url: this.src,
@@ -2807,7 +3103,6 @@ class ActionTextAttachmentNode extends DecoratorNode {
2807
3103
  return {
2808
3104
  type: "action_text_attachment",
2809
3105
  version: 1,
2810
- tagName: this.tagName,
2811
3106
  sgid: this.sgid,
2812
3107
  src: this.src,
2813
3108
  previewable: this.previewable,
@@ -2839,9 +3134,6 @@ class ActionTextAttachmentNode extends DecoratorNode {
2839
3134
  figure.draggable = true;
2840
3135
  figure.dataset.lexicalNodeKey = this.__key;
2841
3136
 
2842
- const deleteButton = createElement("lexxy-node-delete-button");
2843
- figure.appendChild(deleteButton);
2844
-
2845
3137
  return figure
2846
3138
  }
2847
3139
 
@@ -2857,6 +3149,18 @@ class ActionTextAttachmentNode extends DecoratorNode {
2857
3149
  return this.contentType.startsWith("video/")
2858
3150
  }
2859
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
+
2860
3164
  #createDOMForPendingPreview() {
2861
3165
  const figure = this.createAttachmentFigure(false);
2862
3166
  figure.appendChild(this.#createDOMForFile());
@@ -3047,55 +3351,18 @@ class ActionTextAttachmentNode extends DecoratorNode {
3047
3351
  }
3048
3352
 
3049
3353
  #createEditableCaption() {
3050
- const caption = createElement("figcaption", { className: "attachment__caption" });
3051
- const input = createElement("textarea", {
3052
- value: this.caption,
3053
- placeholder: this.fileName,
3054
- 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();
3055
3362
  });
3056
-
3057
- input.addEventListener("focusin", () => input.placeholder = "Add caption...");
3058
- input.addEventListener("blur", (event) => this.#handleCaptionInputBlurred(event));
3059
- input.addEventListener("keydown", (event) => this.#handleCaptionInputKeydown(event));
3060
- input.addEventListener("copy", (event) => event.stopPropagation());
3061
- input.addEventListener("cut", (event) => event.stopPropagation());
3062
- input.addEventListener("paste", (event) => event.stopPropagation());
3063
-
3064
- caption.appendChild(input);
3065
-
3066
3363
  return caption
3067
3364
  }
3068
-
3069
- #handleCaptionInputBlurred(event) {
3070
- this.#updateCaptionValueFromInput(event.target);
3071
- }
3072
-
3073
- #updateCaptionValueFromInput(input) {
3074
- input.placeholder = this.fileName;
3075
- this.editor.update(() => {
3076
- this.getWritable().caption = input.value;
3077
- });
3078
- }
3079
-
3080
- #handleCaptionInputKeydown(event) {
3081
- if (event.key === "Enter") {
3082
- event.preventDefault();
3083
- event.target.blur();
3084
-
3085
- this.editor.update(() => {
3086
- // Place the cursor after the current image
3087
- this.selectNext(0, 0);
3088
- }, {
3089
- tag: HISTORY_MERGE_TAG
3090
- });
3091
- }
3092
-
3093
- // Stop all keydown events from bubbling to the Lexical root element.
3094
- // The caption textarea is outside Lexical's content model and should
3095
- // handle its own keyboard events natively (Ctrl+A, Ctrl+C, Ctrl+X, etc.).
3096
- event.stopPropagation();
3097
- }
3098
- }
3365
+ }
3099
3366
 
3100
3367
  function $createActionTextAttachmentNode(...args) {
3101
3368
  return new ActionTextAttachmentNode(...args)
@@ -3214,14 +3481,11 @@ class HorizontalDividerNode extends DecoratorNode {
3214
3481
  super(key);
3215
3482
  }
3216
3483
 
3484
+ // The hr already provides separator semantics; a figure boundary would be redundant.
3217
3485
  createDOM() {
3218
- const figure = createElement("figure", { className: "horizontal-divider" });
3219
- const hr = createElement("hr");
3220
-
3221
- figure.appendChild(hr);
3222
-
3223
- const deleteButton = createElement("lexxy-node-delete-button");
3224
- figure.appendChild(deleteButton);
3486
+ const figure = createElement("figure", { className: "horizontal-divider", role: "presentation" });
3487
+ figure.appendChild(createElement("hr"));
3488
+ figure.dataset.lexicalNodeKey = this.__key;
3225
3489
 
3226
3490
  return figure
3227
3491
  }
@@ -3238,6 +3502,10 @@ class HorizontalDividerNode extends DecoratorNode {
3238
3502
  return false
3239
3503
  }
3240
3504
 
3505
+ get label() {
3506
+ return "Horizontal divider"
3507
+ }
3508
+
3241
3509
  exportDOM() {
3242
3510
  const hr = createElement("hr");
3243
3511
  return { element: hr }
@@ -3503,161 +3771,366 @@ var theme = {
3503
3771
  }
3504
3772
  };
3505
3773
 
3506
- class UploadRequests {
3507
- #requestsByKey = new Map()
3774
+ class ImageGalleryNode extends ElementNode {
3775
+ $config() {
3776
+ return this.config("image_gallery", {
3777
+ extends: ElementNode,
3778
+ })
3779
+ }
3508
3780
 
3509
- track(key, request) {
3510
- this.#requestsByKey.set(key, request);
3781
+ static transform() {
3782
+ return (gallery) => {
3783
+ gallery.unwrapEmptyNode()
3784
+ || gallery.replaceWithSingularChild()
3785
+ || gallery.splitAroundInvalidChild();
3786
+ }
3511
3787
  }
3512
3788
 
3513
- forget(key) {
3514
- this.#requestsByKey.delete(key);
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
+ }
3803
+ }
3515
3804
  }
3516
3805
 
3517
- abort(key) {
3518
- const request = this.#requestsByKey.get(key);
3519
- if (request) {
3520
- this.#requestsByKey.delete(key);
3521
- request.abort();
3806
+ static canCollapseWith(node) {
3807
+ return $isImageGalleryNode(node) || this.isValidChild(node)
3808
+ }
3809
+
3810
+ static isValidChild(node) {
3811
+ return $isActionTextAttachmentNode(node) && node.isPreviewableImage
3812
+ }
3813
+
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
+ }
3820
+
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
3522
3856
  }
3857
+
3858
+ const newNode = $createImageGalleryNode();
3859
+ this.insertAfter(newNode, restoreSelection);
3860
+ return newNode
3523
3861
  }
3524
3862
 
3525
- clear() {
3526
- this.#requestsByKey.clear();
3863
+ getImageAttachments() {
3864
+ const children = this.getChildren();
3865
+ return children.filter($isActionTextAttachmentNode)
3527
3866
  }
3528
- }
3529
3867
 
3530
- // Shared, strictly-contained element used to attach ephemeral nodes when we
3531
- // need to read computed styles (e.g. canonicalizing style values, resolving
3532
- // CSS custom properties). The container is created once and attached to
3533
- // `document.body` once; subsequent child mutations happen *inside* the
3534
- // contained subtree so they do not invalidate style on the rest of the page.
3535
- //
3536
- // Without this, `document.body.appendChild(...)` / `element.remove()` calls
3537
- // forced the browser to re-evaluate every ancestor-dependent selector (`:has()`,
3538
- // descendant combinators, universal sibling rules) across the document on each
3539
- // invocation — a 13,000+ element style recalc per call on a typical Basecamp
3540
- // page.
3868
+ exportDOM() {
3869
+ const div = document.createElement("div");
3870
+ div.className = this.#galleryClassNames;
3871
+ return { element: div }
3872
+ }
3541
3873
 
3542
- let resolverRoot = null;
3874
+ collapseWith(node, backwards) {
3875
+ if (!ImageGalleryNode.canCollapseWith(node)) return false
3543
3876
 
3544
- function styleResolverRoot() {
3545
- if (resolverRoot && resolverRoot.isConnected) return resolverRoot
3877
+ if (backwards) {
3878
+ $insertFirst(this, node);
3879
+ } else {
3880
+ this.append(node);
3881
+ }
3546
3882
 
3547
- resolverRoot = document.createElement("div");
3548
- resolverRoot.setAttribute("aria-hidden", "true");
3549
- resolverRoot.setAttribute("data-lexxy-style-resolver", "");
3550
- // `contain: strict` (size, layout, paint, style) isolates everything.
3551
- // The root itself paints nothing (visibility hidden), has zero
3552
- // geometric impact (position fixed, intrinsic size via contain), and
3553
- // never leaks style invalidation to its ancestors.
3554
- resolverRoot.style.cssText = "contain: strict; position: fixed; top: 0; left: 0; visibility: hidden; pointer-events: none; width: 0; height: 0;";
3555
- document.body.appendChild(resolverRoot);
3556
- return resolverRoot
3557
- }
3883
+ $unwrapAndFilterDescendants(this, ImageGalleryNode.isValidChild);
3558
3884
 
3559
- function isSelectionHighlighted(selection) {
3560
- if (!$isRangeSelection(selection)) return false
3885
+ return true
3886
+ }
3561
3887
 
3562
- if (selection.isCollapsed()) {
3563
- return hasHighlightStyles(selection.style)
3564
- } else {
3565
- return selection.hasFormat("highlight")
3888
+ unwrapEmptyNode() {
3889
+ if (this.isEmpty()) {
3890
+ const paragraph = $createParagraphNode();
3891
+ return this.replace(paragraph)
3892
+ }
3566
3893
  }
3567
- }
3568
3894
 
3569
- function getHighlightStyles(selection) {
3570
- if (!$isRangeSelection(selection)) return null
3895
+ replaceWithSingularChild() {
3896
+ if (this.#hasSingularChild) {
3897
+ const child = this.getFirstChild();
3898
+ return this.replace($makeSafeForRoot(child))
3899
+ }
3900
+ }
3571
3901
 
3572
- let styles = getStyleObjectFromCSS(selection.style);
3573
- if (!styles.color && !styles["background-color"]) {
3574
- const anchorNode = selection.anchor.getNode();
3575
- if ($isTextNode(anchorNode)) {
3576
- styles = getStyleObjectFromCSS(anchorNode.getStyle());
3902
+ splitAroundInvalidChild() {
3903
+ for (const child of $firstToLastIterator(this)) {
3904
+ if (ImageGalleryNode.isValidChild(child)) continue
3905
+
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
3577
3915
  }
3578
3916
  }
3579
3917
 
3580
- const color = styles.color || null;
3581
- const backgroundColor = styles["background-color"] || null;
3582
- if (!color && !backgroundColor) return null
3918
+ splitAtIndex(index) {
3919
+ return $splitNode(this, index)
3920
+ }
3583
3921
 
3584
- 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
+ }
3585
3929
  }
3586
3930
 
3587
- function hasHighlightStyles(cssOrStyles) {
3588
- const styles = typeof cssOrStyles === "string" ? getStyleObjectFromCSS(cssOrStyles) : cssOrStyles;
3589
- return !!(styles.color || styles["background-color"])
3931
+ function $createImageGalleryNode() {
3932
+ return new ImageGalleryNode()
3590
3933
  }
3591
3934
 
3592
- function applyCanonicalizers(styles, canonicalizers = []) {
3593
- return canonicalizers.reduce((css, canonicalizer) => {
3594
- return canonicalizer.applyCanonicalization(css)
3595
- }, styles)
3935
+ function $isImageGalleryNode(node) {
3936
+ return node instanceof ImageGalleryNode
3596
3937
  }
3597
3938
 
3598
- class StyleCanonicalizer {
3599
- constructor(property, allowedValues= []) {
3600
- this._property = property;
3601
- this._allowedValues = allowedValues;
3602
- 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
+ );
3603
3966
  }
3604
3967
 
3605
- applyCanonicalization(css) {
3606
- const styles = { ...getStyleObjectFromCSS(css) };
3968
+ dispose() {
3969
+ this.#listeners.dispose();
3970
+ this.#close();
3971
+ this.#input.remove();
3972
+ }
3607
3973
 
3608
- styles[this._property] = this.getCanonicalAllowedValue(styles[this._property]);
3609
- if (!styles[this._property]) {
3610
- delete styles[this._property];
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
3611
3983
  }
3984
+ }
3612
3985
 
3613
- return getCSSFromStyleObject(styles)
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
+ }
3614
3996
  }
3615
3997
 
3616
- getCanonicalAllowedValue(value) {
3617
- return this._canonicalValues[value] ||= this.#resolveCannonicalValue(value)
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;
3618
4007
  }
3619
4008
 
3620
- // Private
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();
4031
+ }
3621
4032
 
3622
- get #allowedValuesIdentityObject() {
3623
- return this._allowedValues.reduce((object, value) => ({ ...object, [value]: value }), {})
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`;
4044
+ }
3624
4045
  }
3625
4046
 
3626
- #resolveCannonicalValue(value) {
3627
- let index = this.#computedAllowedValues.indexOf(value);
3628
- if (index === -1) {
3629
- index = this.#computedAllowedValues.indexOf(computeStyleValues(this._property, [ value ])[0]);
4047
+ #checkAttachment() {
4048
+ if (this.#nodeKey && this.#editor.getElementByKey(this.#nodeKey)?.querySelector("figcaption") !== this.#caption) {
4049
+ this.#close();
4050
+ } else {
4051
+ this.#updatePosition();
4052
+ }
4053
+ }
4054
+
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 });
4081
+ }
4082
+ }
4083
+
4084
+ class UploadRequests {
4085
+ #requestsByKey = new Map()
4086
+
4087
+ track(key, request) {
4088
+ this.#requestsByKey.set(key, request);
4089
+ }
4090
+
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();
3630
4100
  }
3631
- return index === -1 ? null : this._allowedValues[index]
3632
4101
  }
3633
4102
 
3634
- get #computedAllowedValues() {
3635
- return this._computedAllowedValues ||= computeStyleValues(this._property, this._allowedValues)
4103
+ clear() {
4104
+ this.#requestsByKey.clear();
3636
4105
  }
3637
4106
  }
3638
4107
 
3639
- // Separates DOM writes from layout reads to avoid forced reflows, and attaches
3640
- // resolver elements to a strictly-contained root (outside the normal document
3641
- // flow) so neither the attach nor the detach invalidate styles on the rest of
3642
- // the page. Without containment, appending to `document.body` triggered a
3643
- // page-wide style recalc on every canonicalization pass.
3644
- function computeStyleValues(property, values) {
3645
- 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;
3646
4115
 
3647
- const elements = values.map(value => {
3648
- const element = createElement("span", { style: `display: none; ${property}: ${value};` });
3649
- fragment.appendChild(element);
3650
- return element
3651
- });
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);
3652
4119
 
3653
- 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
+ }
3654
4128
 
3655
- const computed = elements.map(element =>
3656
- window.getComputedStyle(element).getPropertyValue(property)
3657
- );
4129
+ if (cursor < text.length) {
4130
+ segments.push({ start: cursor, end: text.length, range: null });
4131
+ }
3658
4132
 
3659
- elements.forEach(element => element.remove());
3660
- return computed
4133
+ return segments
3661
4134
  }
3662
4135
 
3663
4136
  const TOGGLE_HIGHLIGHT_COMMAND = createCommand();
@@ -3830,26 +4303,9 @@ function extractHighlightStyleFromElement(element) {
3830
4303
  return css.length > 0 ? css : null
3831
4304
  }
3832
4305
 
3833
- // The code retokenizer replaces a code block's children with freshly created
3834
- // tokens that carry no styles, which would drop color highlights on every
3835
- // edit. This tokenizer wraps the stock Prism tokenizer to restore them: it
3836
- // recovers the block's highlight ranges — staged during HTML import, or read
3837
- // from the children the fresh tokens are about to replace — and reapplies
3838
- // them to the fresh tokens before the retokenizer splices them in.
3839
- function buildHighlightPreservingTokenizer(editor) {
3840
- return {
3841
- defaultLanguage: PrismTokenizer.defaultLanguage,
3842
- tokenize(code, language) {
3843
- return PrismTokenizer.tokenize(code, language)
3844
- },
3845
- $tokenize(codeNode, language) {
3846
- const tokens = PrismTokenizer.$tokenize(codeNode, language);
3847
- const highlights = $takeHighlightRanges(editor, codeNode);
3848
- return $applyHighlightRangesToTokens(tokens, highlights)
3849
- }
3850
- }
3851
- }
3852
-
4306
+ // Recover the highlight ranges to reapply after a retokenization: the ranges
4307
+ // staged during HTML import, or the ones read from the children the fresh
4308
+ // tokens are about to replace.
3853
4309
  function $takeHighlightRanges(editor, codeNode) {
3854
4310
  const pending = $getPendingHighlights(editor);
3855
4311
  const key = codeNode.getKey();
@@ -3891,52 +4347,25 @@ function $applyHighlightRangesToTokens(tokens, highlights) {
3891
4347
  // attached to the tree yet, so we create CodeHighlightNode replacements.
3892
4348
  function $splitTokenAtHighlightBoundaries(token, tokenStart, highlights) {
3893
4349
  const text = token.getTextContent();
3894
- const segments = segmentTextByHighlights(text, tokenStart, highlights);
4350
+ const segments = segmentTextByRanges(text, tokenStart, highlights);
3895
4351
 
3896
4352
  if (segments.length === 1) {
3897
4353
  const [ segment ] = segments;
3898
- if (segment.style) {
3899
- $applyHighlightStyleToToken(token, segment.style);
4354
+ if (segment.range) {
4355
+ $applyHighlightStyleToToken(token, segment.range.style);
3900
4356
  }
3901
4357
  return [ token ]
3902
4358
  } else {
3903
4359
  return segments.map((segment) => {
3904
4360
  const segmentToken = $createCodeHighlightNode(text.slice(segment.start, segment.end), token.getHighlightType());
3905
- if (segment.style) {
3906
- $applyHighlightStyleToToken(segmentToken, segment.style);
4361
+ if (segment.range) {
4362
+ $applyHighlightStyleToToken(segmentToken, segment.range.style);
3907
4363
  }
3908
4364
  return segmentToken
3909
4365
  })
3910
4366
  }
3911
4367
  }
3912
4368
 
3913
- // Partition a token's text into consecutive { start, end, style } segments,
3914
- // where offsets are relative to the token and style is null for the stretches
3915
- // no highlight covers.
3916
- function segmentTextByHighlights(text, tokenStart, highlights) {
3917
- const segments = [];
3918
- let cursor = 0;
3919
-
3920
- for (const { start, end, style } of highlights) {
3921
- const from = Math.max(start - tokenStart, cursor);
3922
- const to = Math.min(end - tokenStart, text.length);
3923
-
3924
- if (from < to) {
3925
- if (from > cursor) {
3926
- segments.push({ start: cursor, end: from, style: null });
3927
- }
3928
- segments.push({ start: from, end: to, style });
3929
- cursor = to;
3930
- }
3931
- }
3932
-
3933
- if (cursor < text.length) {
3934
- segments.push({ start: cursor, end: text.length, style: null });
3935
- }
3936
-
3937
- return segments
3938
- }
3939
-
3940
4369
  function $applyHighlightStyleToToken(token, style) {
3941
4370
  token.setStyle(style);
3942
4371
  $setCodeHighlightFormat(token, true);
@@ -3946,17 +4375,24 @@ function $buildChildRanges(codeNode) {
3946
4375
  const childRanges = [];
3947
4376
  let charOffset = 0;
3948
4377
 
3949
- for (const child of codeNode.getChildren()) {
3950
- if ($isCodeHighlightNode(child) || $isTextNode(child)) {
3951
- const text = child.getTextContent();
3952
- childRanges.push({ node: child, start: charOffset, end: charOffset + text.length });
3953
- charOffset += text.length;
3954
- } else {
3955
- // LineBreakNode, TabNode - count as 1 character each (\n, \t)
3956
- charOffset += 1;
4378
+ function walk(node) {
4379
+ for (const child of node.getChildren()) {
4380
+ if ($isElementNode(child)) {
4381
+ // e.g. a LinkNode awaiting its first retokenization
4382
+ walk(child);
4383
+ } else if ($isCodeHighlightNode(child) || $isTextNode(child)) {
4384
+ const text = child.getTextContent();
4385
+ childRanges.push({ node: child, start: charOffset, end: charOffset + text.length });
4386
+ charOffset += text.length;
4387
+ } else {
4388
+ // LineBreakNode - counts as 1 character (\n)
4389
+ charOffset += 1;
4390
+ }
3957
4391
  }
3958
4392
  }
3959
4393
 
4394
+ walk(codeNode);
4395
+
3960
4396
  return childRanges
3961
4397
  }
3962
4398
 
@@ -3979,11 +4415,15 @@ function $extractHighlightRangesFromCodeNode(codeNode) {
3979
4415
 
3980
4416
  function buildCanonicalizers(config) {
3981
4417
  return [
3982
- new StyleCanonicalizer("color", [ ...config.buttons.color, ...config.permit.color ]),
3983
- 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"] ])
3984
4420
  ]
3985
4421
  }
3986
4422
 
4423
+ function colorValues(buttons) {
4424
+ return normalizeColorButtons(buttons).map((button) => button.value)
4425
+ }
4426
+
3987
4427
  function $toggleSelectionStyles(editor, styles) {
3988
4428
  const selection = $getSelection();
3989
4429
  if (!$isRangeSelection(selection)) return
@@ -4922,29 +5362,25 @@ class Selection {
4922
5362
  }, COMMAND_PRIORITY_LOW),
4923
5363
 
4924
5364
  this.editor.registerCommand(SELECTION_CHANGE_COMMAND, () => {
4925
- this.#preserveFocusOfOtherFields();
5365
+ this.#preserveFocusOutsideEditor();
4926
5366
  return false
4927
5367
  }, COMMAND_PRIORITY_CRITICAL)
4928
5368
  );
4929
5369
  }
4930
5370
 
4931
- // Firefox (152+) keeps window.getSelection() anchored inside the editor after focus
4932
- // moves to another field, and fires document selectionchange for every keystroke
4933
- // typed there. Lexical reads that stale selection, reconciles, and pulls focus back
4934
- // into the editor — stealing it mid-typing. When another text-entry field owns focus,
4935
- // tag the update so Lexical leaves the DOM selection (and with it, focus) alone.
4936
- #preserveFocusOfOtherFields() {
4937
- 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) {
4938
5375
  $addUpdateTag(SKIP_DOM_SELECTION_TAG);
4939
5376
  }
4940
5377
  }
4941
5378
 
4942
- get #anotherFieldIsFocused() {
5379
+ get #isAnotherElementFocused() {
4943
5380
  const rootElement = this.editor.getRootElement();
4944
5381
  const activeElement = rootElement?.ownerDocument.activeElement;
4945
5382
 
4946
- return activeElement && !rootElement.contains(activeElement) &&
4947
- (activeElement.tagName === "INPUT" || activeElement.tagName === "TEXTAREA" || activeElement.isContentEditable)
5383
+ return activeElement && activeElement !== rootElement.ownerDocument.body && !rootElement.contains(activeElement)
4948
5384
  }
4949
5385
 
4950
5386
  #listenForNodeSelections() {
@@ -5076,10 +5512,17 @@ class Selection {
5076
5512
  || this.#selectInLexical(this.topLevelNodeAfterCursor)
5077
5513
  }
5078
5514
 
5515
+ // hasNodeSelection reads the committed state, while $getSelection() resolves
5516
+ // against the pending one — so the selection here can be missing, no longer a
5517
+ // NodeSelection, or reference a node that no longer exists (empty editor,
5518
+ // just-deleted node, decorator boundary). Only invoke the callback when a
5519
+ // node actually resolves; returning false lets the callers fall back —
5520
+ // #selectInLexical for plain arrows, native pass-through for shift-arrows.
5079
5521
  #withCurrentNodeSelectionNode(fn) {
5080
- if (this.hasNodeSelection) {
5081
- return fn($getSelection().getNodes()[0])
5082
- }
5522
+ const selection = this.hasNodeSelection ? $getSelection() : null;
5523
+ const currentNode = $isNodeSelection(selection) ? selection.getNodes()[0] : undefined;
5524
+
5525
+ return currentNode ? fn(currentNode) : false
5083
5526
  }
5084
5527
 
5085
5528
  #rangeSelectDecorator(node, direction = "forward") {
@@ -5534,178 +5977,6 @@ class EditorConfiguration {
5534
5977
  }
5535
5978
  }
5536
5979
 
5537
- class ImageGalleryNode extends ElementNode {
5538
- $config() {
5539
- return this.config("image_gallery", {
5540
- extends: ElementNode,
5541
- })
5542
- }
5543
-
5544
- static transform() {
5545
- return (gallery) => {
5546
- gallery.unwrapEmptyNode()
5547
- || gallery.replaceWithSingularChild()
5548
- || gallery.splitAroundInvalidChild();
5549
- }
5550
- }
5551
-
5552
- static importDOM() {
5553
- return {
5554
- div: (element) => {
5555
- if (!this.#isGalleryElement(element)) return null
5556
-
5557
- return {
5558
- conversion: () => {
5559
- return {
5560
- node: $createImageGalleryNode()
5561
- }
5562
- },
5563
- priority: 2
5564
- }
5565
- }
5566
- }
5567
- }
5568
-
5569
- static canCollapseWith(node) {
5570
- return $isImageGalleryNode(node) || this.isValidChild(node)
5571
- }
5572
-
5573
- static isValidChild(node) {
5574
- return $isActionTextAttachmentNode(node) && node.isPreviewableImage
5575
- }
5576
-
5577
- static #isGalleryElement(element) {
5578
- const attachmentChildren = element.querySelectorAll(`:scope > :is(${this.#attachmentTags.join()})`);
5579
- return element.textContent.trim() === ""
5580
- && attachmentChildren.length > 0
5581
- && element.children.length === attachmentChildren.length
5582
- }
5583
-
5584
- static get #attachmentTags() {
5585
- return Object.keys(ActionTextAttachmentNode.importDOM())
5586
- }
5587
-
5588
- createDOM() {
5589
- const div = document.createElement("div");
5590
- div.className = this.#galleryClassNames;
5591
- return div
5592
- }
5593
-
5594
- updateDOM(_prevNode, dom) {
5595
- dom.className = this.#galleryClassNames;
5596
- return false
5597
- }
5598
-
5599
- canBeEmpty() {
5600
- // Return `true` to conform to `$isBlock(node)`
5601
- // We clean-up empty galleries with a transform
5602
- return true
5603
- }
5604
-
5605
- collapseAtStart(_selection) {
5606
- return true
5607
- }
5608
-
5609
- insertNewAfter(selection, restoreSelection) {
5610
- const selectionBeforeLastChild = selection.anchor.getNode().is(this) && selection.anchor.offset == this.getChildrenSize() - 1;
5611
- if (selectionBeforeLastChild) {
5612
- const paragraph = $createParagraphNode();
5613
- this.insertAfter(paragraph, false);
5614
- paragraph.insertAfter(this.getLastChild(), false);
5615
- paragraph.selectEnd();
5616
-
5617
- // return null as selection has been managed
5618
- return null
5619
- }
5620
-
5621
- const newNode = $createImageGalleryNode();
5622
- this.insertAfter(newNode, restoreSelection);
5623
- return newNode
5624
- }
5625
-
5626
- getImageAttachments() {
5627
- const children = this.getChildren();
5628
- return children.filter($isActionTextAttachmentNode)
5629
- }
5630
-
5631
- exportDOM() {
5632
- const div = document.createElement("div");
5633
- div.className = this.#galleryClassNames;
5634
- return { element: div }
5635
- }
5636
-
5637
- collapseWith(node, backwards) {
5638
- if (!ImageGalleryNode.canCollapseWith(node)) return false
5639
-
5640
- if (backwards) {
5641
- $insertFirst(this, node);
5642
- } else {
5643
- this.append(node);
5644
- }
5645
-
5646
- $unwrapAndFilterDescendants(this, ImageGalleryNode.isValidChild);
5647
-
5648
- return true
5649
- }
5650
-
5651
- unwrapEmptyNode() {
5652
- if (this.isEmpty()) {
5653
- const paragraph = $createParagraphNode();
5654
- return this.replace(paragraph)
5655
- }
5656
- }
5657
-
5658
- replaceWithSingularChild() {
5659
- if (this.#hasSingularChild) {
5660
- const child = this.getFirstChild();
5661
- return this.replace($makeSafeForRoot(child))
5662
- }
5663
- }
5664
-
5665
- splitAroundInvalidChild() {
5666
- for (const child of $firstToLastIterator(this)) {
5667
- if (ImageGalleryNode.isValidChild(child)) continue
5668
-
5669
- const poppedNode = $makeSafeForRoot(child);
5670
- const [ topGallery, secondGallery ] = this.splitAtIndex(poppedNode.getIndexWithinParent());
5671
- topGallery.insertAfter(poppedNode);
5672
- poppedNode.selectEnd();
5673
-
5674
- // remove an empty gallery rather than let it unwrap to a paragraph
5675
- if (secondGallery.isEmpty()) secondGallery.remove();
5676
-
5677
- break
5678
- }
5679
- }
5680
-
5681
- splitAtIndex(index) {
5682
- return $splitNode(this, index)
5683
- }
5684
-
5685
- get #hasSingularChild() {
5686
- return this.getChildrenSize() === 1
5687
- }
5688
-
5689
- get #galleryClassNames() {
5690
- return `attachment-gallery attachment-gallery--${this.getChildrenSize()}`
5691
- }
5692
- }
5693
-
5694
- function $createImageGalleryNode() {
5695
- return new ImageGalleryNode()
5696
- }
5697
-
5698
- function $isImageGalleryNode(node) {
5699
- return node instanceof ImageGalleryNode
5700
- }
5701
-
5702
- function $findOrCreateGalleryForImage(node) {
5703
- if (!ImageGalleryNode.canCollapseWith(node)) return null
5704
-
5705
- const existingGallery = $getNearestNodeOfType(node, ImageGalleryNode);
5706
- return existingGallery ?? $wrapNodeInElement(node, $createImageGalleryNode)
5707
- }
5708
-
5709
5980
  class Uploader {
5710
5981
  #files
5711
5982
 
@@ -6266,15 +6537,18 @@ class ListItemNodeInserter extends BaseNodeInserter {
6266
6537
  // very corruption we are avoiding. The block must land at the list's level.
6267
6538
  const anchorNode = this.selection.anchor.getNode();
6268
6539
  const outerList = this.#outermostList(anchorNode);
6269
- const topItem = this.#topLevelItemFor(anchorNode, outerList);
6270
6540
 
6271
- // A blank top-level bullet is just the insertion point (e.g. the user pressed
6272
- // Enter to leave the list); break out of it entirely. A bullet with content —
6273
- // including one wrapping a nested list — splits so its content stays in the list.
6274
- const splitAfterItem = $isBlankNode(topItem) ? topItem.getPreviousSibling() : topItem;
6275
- const splitIndex = splitAfterItem ? splitAfterItem.getIndexWithinParent() + 1 : 0;
6276
- const [ listBefore, listAfter ] = $splitNode(outerList, splitIndex);
6277
- if ($isBlankNode(topItem)) { topItem.remove(); }
6541
+ // removeText() can relocate the anchor before we read it back — clean out
6542
+ // of the list when the removed range spanned it. With no list left to
6543
+ // split, default insertion applies.
6544
+ if (!outerList) {
6545
+ this.selection.insertNodes(nodes);
6546
+ return
6547
+ }
6548
+
6549
+ const topItem = this.#topLevelItemFor(anchorNode, outerList);
6550
+ const [ listBefore, listAfter ] = $splitNode(outerList, this.#splitIndexFor(topItem));
6551
+ if (topItem && $isBlankNode(topItem)) { topItem.remove(); }
6278
6552
 
6279
6553
  let anchor = listBefore ?? listAfter;
6280
6554
  for (const node of nodes) {
@@ -6287,6 +6561,18 @@ class ListItemNodeInserter extends BaseNodeInserter {
6287
6561
  nodes.at(-1).selectNext();
6288
6562
  }
6289
6563
 
6564
+ // A blank top-level bullet is just the insertion point (e.g. the user pressed
6565
+ // Enter to leave the list); break out of it entirely. A bullet with content —
6566
+ // including one wrapping a nested list — splits so its content stays in the
6567
+ // list. With no top-level bullet at all — removeText() relocated the anchor
6568
+ // onto the list node itself — the anchor offset is the boundary between items.
6569
+ #splitIndexFor(topItem) {
6570
+ if (!topItem) return this.selection.anchor.offset
6571
+
6572
+ const splitAfterItem = $isBlankNode(topItem) ? topItem.getPreviousSibling() : topItem;
6573
+ return splitAfterItem ? splitAfterItem.getIndexWithinParent() + 1 : 0
6574
+ }
6575
+
6290
6576
  #outermostList(node) {
6291
6577
  return [ node, ...node.getParents() ].reverse().find($isListNode)
6292
6578
  }
@@ -7386,11 +7672,93 @@ class Contents {
7386
7672
  }
7387
7673
  }
7388
7674
 
7675
+ function parsePastedMarkdown(text) {
7676
+ return pasteMarked.parse(text)
7677
+ }
7678
+
7679
+ // A marked instance scoped to the paste flow. A fresh Marked starts from
7680
+ // marked's stock defaults — gfm stays on and tables still parse — and is
7681
+ // isolated from the global `marked` singleton, so a host bundle sharing the
7682
+ // deduped module can't leak `marked.use(...)` customizations into pasting.
7683
+ // The additions are breaks: true, matching the render pass Clipboard always
7684
+ // used, the `html` renderer, and the dropped `code` tokenizer.
7685
+ //
7389
7686
  // Markdown reads any line indented by a tab or four spaces as a code block. Pasted
7390
7687
  // plain text is full of incidental indentation — outlines, notes, logs — and nobody
7391
7688
  // indents a note meaning "this is code"; they fence it. Keep fences, drop the
7392
7689
  // indentation rule.
7393
- const pastedMarkdown = new Marked({ breaks: true }).use({ tokenizer: { code: () => undefined } });
7690
+ //
7691
+ // Following CommonMark, marked tokenizes a bare "<tag>" in prose as raw inline
7692
+ // (or block) HTML. That is intentional for recognized tags — pasted plain text
7693
+ // carrying <span style>, <mark>, <b>… is styled on import — but for an
7694
+ // *unrecognized* element the Lexical importer has no converter and silently
7695
+ // unwraps it, dropping the tag. WEBVTT speaker cues such as
7696
+ // "<v Nabila Abdel Nabi>", where the name lives in what the HTML parser reads
7697
+ // as attributes, thus vanish entirely.
7698
+ //
7699
+ // Classifying at marked's html renderer rides its public token grammar instead
7700
+ // of shadowing it: marked routes code (`code`/`codespan`), autolinks and
7701
+ // [text](<dest>) links to *other* token types that never reach this renderer,
7702
+ // so their literal "<...>" content is preserved for free — no pre-escaping,
7703
+ // code-region collection, or lexer-option syncing required. This renderer fires
7704
+ // only for genuine raw-HTML tokens, and marked inserts its return value verbatim
7705
+ // (it does not re-encode), so we escape unsupported tags to literal text here.
7706
+ const pasteMarked = new Marked({
7707
+ breaks: true,
7708
+ renderer: { html: renderPastedHtmlToken }
7709
+ }).use({ tokenizer: { code: () => undefined } });
7710
+
7711
+ // Matches a single HTML tag lexeme: an optional "/", a tag name, then attribute
7712
+ // characters up to the closing ">". Quoted attribute values may contain ">" —
7713
+ // the HTML tokenizer consumes them as part of the value, and marked's tag
7714
+ // grammar matches them into the same raw token — so the attribute run matches
7715
+ // quoted strings whole. Otherwise a lexeme like <v title="a > b"> would end at
7716
+ // the inner ">", escaping only the prefix and leaving the tail for DOMParser
7717
+ // to decode.
7718
+ const HTML_TAG_LEXEME = /<(\/?)([a-zA-Z][a-zA-Z0-9-]*)((?:"[^"]*"|'[^']*'|[^'">])*)>/g;
7719
+
7720
+ // Escape unknown tags *within* the raw-HTML token, keeping known tags intact.
7721
+ // Lexeme-level (not whole-token) escaping matters because marked can emit a
7722
+ // single coarse block-HTML token whose first tag is known but which nests an
7723
+ // unknown one — e.g. "<div><v Name> Hello</div>" arrives as one token. We keep
7724
+ // the <div> so it still renders, and escape the nested <v Name> so it survives
7725
+ // as literal text rather than being unwrapped and dropped by the importer.
7726
+ function renderPastedHtmlToken(token) {
7727
+ return token.text.replace(HTML_TAG_LEXEME, (lexeme, _slash, name) => {
7728
+ return isSupportedHtmlElement(name) ? lexeme : escapeHtml(lexeme)
7729
+ })
7730
+ }
7731
+
7732
+ // The renderer's output is final, so escape the full lexeme — "&", "<" and ">" —
7733
+ // for an exact round-trip (e.g. "<v A&nbsp;B>" → "&lt;v A&amp;nbsp;B&gt;").
7734
+ function escapeHtml(text) {
7735
+ return text.replace(/&/g, "&amp;").replace(/</g, "&lt;").replace(/>/g, "&gt;")
7736
+ }
7737
+
7738
+ // "Supported" — safe to pass through for the importer to handle — is decided from
7739
+ // the element taxonomy, not the importer's converter registry. The importer both
7740
+ // converts tags it has a converter for (span→mark, b→bold, tr/td→table cells) and
7741
+ // transparently traverses standard structural wrappers it has *no* converter for
7742
+ // (thead, tbody, tfoot, colgroup, caption), keeping their children. A converter
7743
+ // registry (editor._htmlConversions) lists only the former, so classifying by it
7744
+ // escaped those wrappers to literal text and corrupted pasted tables — foster
7745
+ // parenting hoisted "&lt;thead&gt;…" out as a stray paragraph. The taxonomy covers
7746
+ // both: a standard HTML element is either converted or losslessly unwrapped.
7747
+ //
7748
+ // Two exclusions escape the tag to literal text instead:
7749
+ // - Invented tags (WEBVTT's "<v Nabila Abdel Nabi>", "<foo>"): document
7750
+ // .createElement returns HTMLUnknownElement. The importer would unwrap them,
7751
+ // dropping the tag — and for <v Name> the name lives in what parses as
7752
+ // attributes, so it vanishes entirely.
7753
+ // - Custom elements (turbo-frame, bc-attachment, action-text-attachment): a
7754
+ // hyphenated name is a custom element (HTML spec; no standard element has a
7755
+ // hyphen). They're excluded even when the importer registers a converter, so
7756
+ // a plain-text paste can never materialize an attachment/widget. Legitimate
7757
+ // attachments arrive through the rich-HTML paste path, not here.
7758
+ function isSupportedHtmlElement(name) {
7759
+ const tag = name.toLowerCase();
7760
+ return !(document.createElement(tag) instanceof HTMLUnknownElement) && !tag.includes("-")
7761
+ }
7394
7762
 
7395
7763
  class Clipboard {
7396
7764
  #listeners = new ListenerBin()
@@ -7578,7 +7946,7 @@ class Clipboard {
7578
7946
  }
7579
7947
 
7580
7948
  #pasteMarkdown(text) {
7581
- const html = pastedMarkdown.parse(text);
7949
+ const html = parsePastedMarkdown(text);
7582
7950
  const doc = parseHtml(html);
7583
7951
 
7584
7952
  if (this.#isPlainTextWithoutMarkdown(doc)) {
@@ -7777,6 +8145,134 @@ class BrowserAdapter {
7777
8145
  }
7778
8146
  }
7779
8147
 
8148
+ // The code retokenizer only knows CodeHighlightNode, LineBreakNode and
8149
+ // TabNode, so a LinkNode inside a code block — Trix-authored documents allow
8150
+ // links in code — is destroyed on the first retokenization. Links survive as
8151
+ // state on the tokens instead: this state records the link attributes on each
8152
+ // CodeHighlightNode the link covers, the extraction below reads it (or a
8153
+ // still-untokenized LinkNode) back into ranges before every retokenization,
8154
+ // and the tokenizer reapplies it to the fresh tokens.
8155
+ const codeLinkState = createState("codeLink", {
8156
+ parse: (value) => (value && typeof value.url === "string" ? value : null)
8157
+ });
8158
+
8159
+ function $getCodeLink(node) {
8160
+ return $getState(node, codeLinkState)
8161
+ }
8162
+
8163
+ function $setCodeLink(node, link) {
8164
+ $setState(node, codeLinkState, link);
8165
+ }
8166
+
8167
+ // Build the list of { start, end, link } ranges covering every link in a code
8168
+ // block, with offsets into the block's text content. Links appear either as
8169
+ // LinkNode descendants (imported or just created, before their first
8170
+ // retokenization) or as link state on tokens (after it). The walk recurses
8171
+ // through nested elements because `<pre><code>` imports of multi-line content
8172
+ // briefly produce a CodeNode nested inside another before the outer one's
8173
+ // retokenization flattens them.
8174
+ function $extractLinkRangesFromCodeNode(codeNode) {
8175
+ const ranges = [];
8176
+ let offset = 0;
8177
+
8178
+ function walk(node) {
8179
+ for (const child of node.getChildren()) {
8180
+ if ($isLinkNode(child)) {
8181
+ const size = child.getTextContent().length;
8182
+ appendRange(ranges, { start: offset, end: offset + size, link: linkAttributesFrom(child) });
8183
+ offset += size;
8184
+ } else if ($isElementNode(child)) {
8185
+ walk(child);
8186
+ } else {
8187
+ const size = child.getTextContent().length;
8188
+ if ($isTextNode(child)) {
8189
+ const link = $getCodeLink(child);
8190
+ if (link) {
8191
+ appendRange(ranges, { start: offset, end: offset + size, link });
8192
+ }
8193
+ }
8194
+ offset += size;
8195
+ }
8196
+ }
8197
+ }
8198
+
8199
+ walk(codeNode);
8200
+
8201
+ return ranges
8202
+ }
8203
+
8204
+ function $applyLinkRangesToTokens(tokens, ranges) {
8205
+ if (ranges.length === 0) return tokens
8206
+
8207
+ const linkedTokens = [];
8208
+ let offset = 0;
8209
+
8210
+ for (const token of tokens) {
8211
+ if ($isCodeHighlightNode(token)) {
8212
+ linkedTokens.push(...$splitTokenAtLinkBoundaries(token, offset, ranges));
8213
+ } else {
8214
+ linkedTokens.push(token);
8215
+ }
8216
+ offset += token.getTextContentSize();
8217
+ }
8218
+
8219
+ return linkedTokens
8220
+ }
8221
+
8222
+ function $splitTokenAtLinkBoundaries(token, tokenStart, ranges) {
8223
+ const text = token.getTextContent();
8224
+ const segments = segmentTextByRanges(text, tokenStart, ranges);
8225
+
8226
+ if (segments.length === 1) {
8227
+ const [ segment ] = segments;
8228
+ if (segment.range) {
8229
+ $setCodeLink(token, segment.range.link);
8230
+ }
8231
+ return [ token ]
8232
+ } else {
8233
+ return segments.map((segment) => {
8234
+ const segmentToken = $cloneTokenSlice(token, text.slice(segment.start, segment.end));
8235
+ if (segment.range) {
8236
+ $setCodeLink(segmentToken, segment.range.link);
8237
+ }
8238
+ return segmentToken
8239
+ })
8240
+ }
8241
+ }
8242
+
8243
+ function $cloneTokenSlice(token, text) {
8244
+ const segmentToken = $createCodeHighlightNode(text, token.getHighlightType());
8245
+ segmentToken.setStyle(token.getStyle());
8246
+ // CodeHighlightNode.setFormat is a no-op, so carry the format over directly
8247
+ segmentToken.getWritable().__format = token.getFormat();
8248
+ return segmentToken
8249
+ }
8250
+
8251
+ // Consecutive children carrying the same link merge into a single range, so a
8252
+ // link split across tokens by an earlier retokenization keeps covering fresh
8253
+ // tokens as one contiguous stretch however they retokenize.
8254
+ function appendRange(ranges, range) {
8255
+ const previous = ranges[ranges.length - 1];
8256
+
8257
+ if (previous && previous.end === range.start && sameLink(previous.link, range.link)) {
8258
+ previous.end = range.end;
8259
+ } else {
8260
+ ranges.push(range);
8261
+ }
8262
+ }
8263
+
8264
+ function sameLink(a, b) {
8265
+ return a.url === b.url && a.target === b.target && a.rel === b.rel && a.title === b.title
8266
+ }
8267
+
8268
+ function linkAttributesFrom(linkNode) {
8269
+ const link = { url: linkNode.getURL() };
8270
+ if (linkNode.getTarget()) link.target = linkNode.getTarget();
8271
+ if (linkNode.getRel()) link.rel = linkNode.getRel();
8272
+ if (linkNode.getTitle()) link.title = linkNode.getTitle();
8273
+ return link
8274
+ }
8275
+
7780
8276
  // Custom TextNode exportDOM that avoids redundant wrapping.
7781
8277
  //
7782
8278
  // Lexical's built-in TextNode.exportDOM() calls createDOM() which produces semantic tags
@@ -7791,6 +8287,30 @@ class BrowserAdapter {
7791
8287
  // Any <span> elements produced by createDOM() are unwrapped, since they only carry
7792
8288
  // editor classes that aren't meaningful in exported HTML.
7793
8289
 
8290
+ // A CodeHighlightNode exports like any other text node, plus an <a> wrapper
8291
+ // when it carries link state — that's how a link survives inside a code
8292
+ // block, whose retokenizer would destroy an actual LinkNode child.
8293
+ function exportCodeHighlightNodeDOM(editor, codeHighlightNode) {
8294
+ const output = exportTextNodeDOM(editor, codeHighlightNode);
8295
+ const link = $getCodeLink(codeHighlightNode);
8296
+
8297
+ if (link) {
8298
+ return { element: wrapWithAnchor(output.element, link) }
8299
+ } else {
8300
+ return output
8301
+ }
8302
+ }
8303
+
8304
+ function wrapWithAnchor(element, link) {
8305
+ const anchor = document.createElement("a");
8306
+ anchor.setAttribute("href", link.url);
8307
+ if (link.target) anchor.setAttribute("target", link.target);
8308
+ if (link.rel) anchor.setAttribute("rel", link.rel);
8309
+ if (link.title) anchor.setAttribute("title", link.title);
8310
+ anchor.appendChild(element);
8311
+ return anchor
8312
+ }
8313
+
7794
8314
  function exportTextNodeDOM(editor, textNode) {
7795
8315
  const element = textNode.createDOM(editor._config, editor);
7796
8316
  element.style.whiteSpace = "pre-wrap";
@@ -8022,12 +8542,34 @@ class CodeHighlightingExtension extends LexxyExtension {
8022
8542
  return defineExtension({
8023
8543
  name: "lexxy/code-highlighting",
8024
8544
  register(editor) {
8025
- return registerCodeHighlighting(editor, buildHighlightPreservingTokenizer(editor))
8545
+ return registerCodeHighlighting(editor, buildMarkupPreservingTokenizer(editor))
8026
8546
  }
8027
8547
  })
8028
8548
  }
8029
8549
  }
8030
8550
 
8551
+ // The code retokenizer replaces a code block's children with freshly created
8552
+ // tokens that carry no styles or links, which would drop color highlights and
8553
+ // hyperlinks on every edit. This tokenizer wraps the stock Prism tokenizer to
8554
+ // restore both: it recovers the block's highlight and link ranges — staged
8555
+ // during HTML import, or read from the children the fresh tokens are about to
8556
+ // replace — and reapplies them to the fresh tokens before the retokenizer
8557
+ // splices them in.
8558
+ function buildMarkupPreservingTokenizer(editor) {
8559
+ return {
8560
+ defaultLanguage: PrismTokenizer.defaultLanguage,
8561
+ tokenize(code, language) {
8562
+ return PrismTokenizer.tokenize(code, language)
8563
+ },
8564
+ $tokenize(codeNode, language) {
8565
+ const linkRanges = $extractLinkRangesFromCodeNode(codeNode);
8566
+ const highlightRanges = $takeHighlightRanges(editor, codeNode);
8567
+ const tokens = PrismTokenizer.$tokenize(codeNode, language);
8568
+ return $applyLinkRangesToTokens($applyHighlightRangesToTokens(tokens, highlightRanges), linkRanges)
8569
+ }
8570
+ }
8571
+ }
8572
+
8031
8573
  const TRIX_LANGUAGE_ATTR = "language";
8032
8574
 
8033
8575
  class TrixContentExtension extends LexxyExtension {
@@ -8523,75 +9065,387 @@ class AttachmentDragAndDrop {
8523
9065
  if (position === "before") {
8524
9066
  targetNode.insertBefore(draggedNode);
8525
9067
  } else {
8526
- 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"
8527
9340
  }
8528
9341
  }
8529
9342
 
8530
- #dropIntoList(draggedNode, target) {
8531
- const listItemNode = $getNearestNodeFromDOMNode(target.element);
8532
- 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
+ }
8533
9351
 
8534
- const listNode = listItemNode.getParent();
8535
- 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
8536
9358
 
8537
- const children = listNode.getChildren();
8538
- const index = children.indexOf(listItemNode);
8539
- 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
+ }
8540
9371
 
8541
- const splitIndex = target.position === "before" ? index : index + 1;
9372
+ destroy() {
9373
+ this.#listeners.dispose();
9374
+ this.#restoreLabel();
9375
+ }
8542
9376
 
8543
- 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);
8544
9382
 
8545
- if (splitIndex === 0) {
8546
- listNode.insertBefore(draggedNode);
8547
- } else if (splitIndex >= children.length) {
8548
- listNode.insertAfter(draggedNode);
9383
+ if (attachment) {
9384
+ this.#exposeLabelOn(attachment);
9385
+ } else {
9386
+ this.#restoreLabel();
9387
+ }
9388
+ });
8549
9389
  } else {
8550
- const [ , listAfter ] = $splitNode(listNode, splitIndex);
8551
- listAfter.insertBefore(draggedNode);
9390
+ this.#restoreLabel();
8552
9391
  }
8553
9392
  }
8554
9393
 
8555
- #dropBetweenBlocks(draggedNode, target) {
8556
- const targetNode = $getNearestNodeFromDOMNode(target.element);
8557
- if (!targetNode) return
8558
-
8559
- const topLevelTarget = targetNode.getTopLevelElement?.() || targetNode;
8560
- 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
+ }
8561
9402
 
8562
- 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
+ }
8563
9411
 
8564
- if (target.position === "before") {
8565
- 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]
8566
9417
  } else {
8567
- topLevelTarget.insertAfter(draggedNode);
9418
+ return null
8568
9419
  }
9420
+ } else {
9421
+ return null
8569
9422
  }
9423
+ }
8570
9424
 
8571
- // -- Lifecycle helpers -----------------------------------------------------
8572
-
8573
- #cleanup() {
8574
- 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 = [];
8575
9432
 
8576
- if (this.#draggedNodeKey) {
8577
- const rootElement = this.#editor.getRootElement();
8578
- if (rootElement) {
8579
- const figure = rootElement.querySelector(`[data-lexical-node-key="${this.#draggedNodeKey}"]`);
8580
- figure?.classList.remove("lexxy-dragging");
8581
- }
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 });
8582
9440
  }
8583
9441
 
8584
- this.#draggedNodeKey = null;
8585
-
8586
- if (this.#rafId) {
8587
- cancelAnimationFrame(this.#rafId);
8588
- this.#rafId = null;
8589
- }
9442
+ const nearest = candidates
9443
+ .filter(({ node }) => $isCustomActionTextAttachmentNode(node))
9444
+ .sort((a, b) => a.distance - b.distance)[0];
8590
9445
 
8591
- if (this.#draggingRafId) {
8592
- cancelAnimationFrame(this.#draggingRafId);
8593
- this.#draggingRafId = null;
8594
- }
9446
+ return nearest?.node ?? null
9447
+ } else {
9448
+ return null
8595
9449
  }
8596
9450
  }
8597
9451
 
@@ -8621,12 +9475,19 @@ class AttachmentsExtension extends LexxyExtension {
8621
9475
  ],
8622
9476
  register: (editor) => {
8623
9477
  const dragAndDrop = new AttachmentDragAndDrop(editor);
9478
+ const keyboardMove = new AttachmentKeyboardMove(editor);
9479
+ const fakeSelection = new AttachmentFakeSelection(editor);
9480
+ const customAttachmentLabels = new CustomAttachmentLabels(editor);
8624
9481
 
8625
9482
  return mergeRegister(
8626
9483
  editor.registerNodeTransform(ActionTextAttachmentNode, $extractAttachmentFromParagraph),
8627
9484
  editor.registerCommand(DELETE_CHARACTER_COMMAND, $collapseIntoGallery, COMMAND_PRIORITY_NORMAL),
9485
+ editor.registerCommand(KEY_TAB_COMMAND, $focusCaptionFromSelectedAttachment, COMMAND_PRIORITY_HIGH),
8628
9486
  editor.registerMutationListener(ActionTextAttachmentUploadNode, this.#handleUploadMutations.bind(this)),
8629
- () => dragAndDrop.destroy()
9487
+ () => dragAndDrop.destroy(),
9488
+ () => keyboardMove.destroy(),
9489
+ () => fakeSelection.destroy(),
9490
+ () => customAttachmentLabels.destroy()
8630
9491
  )
8631
9492
  }
8632
9493
  })
@@ -8724,6 +9585,16 @@ function $collapseAtGalleryEdge(anchor, backwards) {
8724
9585
  }
8725
9586
  }
8726
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
+
8727
9598
  // Manual selection handling to prevent Lexical merging the gallery with a <p> and unwrapping it
8728
9599
  function $moveSelectionBeforeGallery(anchor) {
8729
9600
  const previousNode = anchor.getNode().getPreviousSibling();
@@ -9455,6 +10326,7 @@ class LexicalEditorElement extends HTMLElement {
9455
10326
  #validity = new Map()
9456
10327
  #validationTextArea = document.createElement("textarea")
9457
10328
  #uploadRequests
10329
+ #liveRegion
9458
10330
 
9459
10331
  constructor() {
9460
10332
  super();
@@ -9485,6 +10357,10 @@ class LexicalEditorElement extends HTMLElement {
9485
10357
  this.clipboard = new Clipboard(this);
9486
10358
  this.#disposables.push(this.clipboard);
9487
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
+
9488
10364
  this.adapter = new BrowserAdapter();
9489
10365
  this.#uploadRequests = new UploadRequests();
9490
10366
 
@@ -9630,6 +10506,16 @@ class LexicalEditorElement extends HTMLElement {
9630
10506
  return dispatch(this, "lexxy:file-accept", { file }, true)
9631
10507
  }
9632
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
+
9633
10519
  $generateNodesFromDOM(doc, { editor = this.editor } = {}) {
9634
10520
  let nodes = $generateNodesFromDOM(editor, doc);
9635
10521
  if ($hasUpdateTag(PASTE_TAG)) nodes = $convertInlineImageDataURIs(nodes, this);
@@ -9814,7 +10700,7 @@ class LexicalEditorElement extends HTMLElement {
9814
10700
  theme: theme,
9815
10701
  nodes: this.#lexicalNodes,
9816
10702
  html: {
9817
- export: new Map([ [ TextNode, exportTextNodeDOM ], [ CodeHighlightNode, exportTextNodeDOM ] ])
10703
+ export: new Map([ [ TextNode, exportTextNodeDOM ], [ CodeHighlightNode, exportCodeHighlightNodeDOM ] ])
9818
10704
  },
9819
10705
  $initialEditorState: (editor) => {
9820
10706
  this.#configureSanitizer(editor);
@@ -9995,6 +10881,9 @@ class LexicalEditorElement extends HTMLElement {
9995
10881
  );
9996
10882
  this.#registerTableComponents();
9997
10883
  this.#registerCodeLanguagePicker();
10884
+ if (this.supportsAttachments) {
10885
+ this.#registerAttachmentToolbar();
10886
+ }
9998
10887
  if (this.supportsMarkdown) {
9999
10888
  const transformers = [ ...TRANSFORMERS, HORIZONTAL_DIVIDER ];
10000
10889
  registered.push(
@@ -10023,6 +10912,15 @@ class LexicalEditorElement extends HTMLElement {
10023
10912
  this.#disposables.push(codeLanguagePicker);
10024
10913
  }
10025
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
+
10026
10924
  #handleEnter() {
10027
10925
  // We can't prevent these externally using regular keydown because Lexical handles it first.
10028
10926
  this.#listeners.track(this.editor.registerCommand(
@@ -10231,21 +11129,22 @@ class LexicalEditorElement extends HTMLElement {
10231
11129
  // — triggering at most one forced reflow. The previous implementation interleaved
10232
11130
  // setProperty/getComputedStyle/removeProperty on the same element, forcing a style
10233
11131
  // recalc on every iteration during editor initialization.
10234
- #resolveColors(property, cssValues) {
11132
+ #resolveColors(property, buttons) {
10235
11133
  const container = document.createElement("span");
10236
11134
  container.style.display = "none";
10237
11135
 
10238
- const resolvers = cssValues.map(cssValue => {
11136
+ const resolvers = normalizeColorButtons(buttons).map(({ value, label }) => {
10239
11137
  const element = document.createElement("span");
10240
- element.style.setProperty(property, cssValue);
11138
+ element.style.setProperty(property, value);
10241
11139
  container.appendChild(element);
10242
- return { element, name: cssValue }
11140
+ return { element, name: value, label }
10243
11141
  });
10244
11142
 
10245
11143
  styleResolverRoot().appendChild(container);
10246
11144
 
10247
- const resolved = resolvers.map(({ element, name }) => ({
11145
+ const resolved = resolvers.map(({ element, name, label }) => ({
10248
11146
  name,
11147
+ label,
10249
11148
  value: window.getComputedStyle(element).getPropertyValue(property)
10250
11149
  }));
10251
11150
 
@@ -10309,6 +11208,39 @@ function $getReadableTextContent(node) {
10309
11208
  return node.getTextContent()
10310
11209
  }
10311
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
+
10312
11244
  class BaseSource {
10313
11245
  // Template method to override
10314
11246
  async buildListItems(filter = "") {
@@ -11195,46 +12127,233 @@ const DELETE_ICON = `<svg viewBox="0 0 18 18" xmlns="http://www.w3.org/2000/svg"
11195
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"/>
11196
12128
  </svg>`;
11197
12129
 
11198
- 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
+
11199
12138
  connectedCallback() {
11200
- this.editorElement = this.closest("lexxy-editor");
11201
- this.editor = this.editorElement.editor;
11202
12139
  this.classList.add("lexxy-floating-controls");
12140
+ this.role = "toolbar";
12141
+ this.hidden = true;
11203
12142
 
11204
- if (!this.querySelector(".lexxy-node-delete")) {
11205
- this.#attachDeleteButton();
12143
+ if (this.#editor) {
12144
+ this.#setUpButtons();
12145
+ this.#monitorSelection();
12146
+ this.#followScrolling();
12147
+ this.#registerKeyboardShortcut();
11206
12148
  }
11207
12149
  }
11208
12150
 
11209
12151
  disconnectedCallback() {
11210
- this.editor = null;
11211
- 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")
11212
12167
  }
11213
12168
 
11214
- #attachDeleteButton() {
12169
+ #setUpButtons() {
12170
+ this.innerHTML = "";
12171
+
11215
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
+ }
11216
12182
 
11217
- this.deleteButton = createElement("button", {
11218
- className: "lexxy-node-delete",
12183
+ #createRemoveButton() {
12184
+ const button = createElement("button", {
11219
12185
  type: "button",
11220
- "aria-label": "Remove"
12186
+ className: "lexxy-attachment-toolbar__remove",
12187
+ ariaLabel: "Remove",
12188
+ tabIndex: -1
11221
12189
  });
11222
- this.deleteButton.tabIndex = -1;
11223
- this.deleteButton.innerHTML = DELETE_ICON;
12190
+ button.innerHTML = DELETE_ICON;
12191
+ this.#listeners.track(registerEventListener(button, "click", () => this.#removeSelectedNode()));
12192
+ return button
12193
+ }
11224
12194
 
11225
- this.handleDeleteClick = () => this.#deleteNode();
11226
- this.deleteButton.addEventListener("click", this.handleDeleteClick);
11227
- container.appendChild(this.deleteButton);
12195
+ #removeSelectedNode() {
12196
+ if (this.#hasSelectedNode) {
12197
+ this.#editor.update(() => $getNodeByKey(this.#currentNodeKey)?.remove());
12198
+ this.#editor.focus();
12199
+ }
12200
+ }
11228
12201
 
11229
- this.appendChild(container);
12202
+ get #hasSelectedNode() {
12203
+ return this.#currentNodeKey !== null
11230
12204
  }
11231
12205
 
11232
- #deleteNode() {
11233
- this.editor.update(() => {
11234
- const node = $getNearestNodeFromDOMNode(this);
11235
- 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
+ }
11236
12286
  });
11237
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
+ }
11238
12357
  }
11239
12358
 
11240
12359
  class TableController {
@@ -11639,7 +12758,7 @@ class TableTools extends HTMLElement {
11639
12758
  #listeners = new ListenerBin()
11640
12759
 
11641
12760
  connectedCallback() {
11642
- this.tableController = new TableController(this.#editorElement);
12761
+ this.tableController = new TableController(this.editorElement);
11643
12762
  this.classList.add("lexxy-floating-controls");
11644
12763
  this.role = "toolbar";
11645
12764
 
@@ -11660,16 +12779,34 @@ class TableTools extends HTMLElement {
11660
12779
  this.tableController = null;
11661
12780
  }
11662
12781
 
11663
- get #editor() {
11664
- return this.#editorElement.editor
12782
+ get editorElement() {
12783
+ return this.closest("lexxy-editor")
11665
12784
  }
11666
12785
 
11667
- get #editorElement() {
11668
- 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)
11669
12806
  }
11670
12807
 
11671
12808
  get #tableToolsButtons() {
11672
- return Array.from(this.querySelectorAll("button, details > summary"))
12809
+ return Array.from(this.querySelectorAll("button")).filter(button => !button.closest("[data-dropdown-panel]"))
11673
12810
  }
11674
12811
 
11675
12812
  #setUpButtons() {
@@ -11688,23 +12825,37 @@ class TableTools extends HTMLElement {
11688
12825
  const plusButton = this.#createButton(`Add ${childType}`, { action: "insert", childType, direction: "after" }, "+");
11689
12826
  const minusButton = this.#createButton(`Remove ${childType}`, { action: "delete", childType }, "−");
11690
12827
 
11691
- const dropdown = createElement("details", { className: "lexxy-table-control__more-menu" });
11692
- dropdown.setAttribute("name", "lexxy-dropdown");
11693
- dropdown.tabIndex = -1;
11694
-
11695
- const count = createElement("summary", {}, `_ ${childType}s`);
11696
- setCountProperty(count);
11697
- dropdown.appendChild(count);
11698
-
11699
- dropdown.appendChild(moreMenu);
11700
-
11701
12828
  container.appendChild(minusButton);
11702
- container.appendChild(dropdown);
12829
+ container.appendChild(this.#createMoreMenu(childType, setCountProperty, moreMenu));
11703
12830
  container.appendChild(plusButton);
11704
12831
 
11705
12832
  return container
11706
12833
  }
11707
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
+
11708
12859
  #createRowButtonsContainer() {
11709
12860
  return this.#createButtonsContainer(
11710
12861
  "row",
@@ -11723,15 +12874,18 @@ class TableTools extends HTMLElement {
11723
12874
 
11724
12875
  #createMoreMenuSection(childType) {
11725
12876
  const section = createElement("div", { className: "lexxy-floating-controls__group lexxy-table-control__more-menu-details" });
11726
- const addBeforeButton = this.#createButton(`Add ${childType} before`, { action: "insert", childType, direction: "before" });
11727
- const addAfterButton = this.#createButton(`Add ${childType} after`, { action: "insert", childType, direction: "after" });
11728
- const toggleStyleButton = this.#createButton(`Toggle ${childType} style`, { action: "toggle", childType });
11729
- const deleteButton = this.#createButton(`Remove ${childType}`, { action: "delete", childType });
11730
12877
 
11731
- section.appendChild(addBeforeButton);
11732
- section.appendChild(addAfterButton);
11733
- section.appendChild(toggleStyleButton);
11734
- 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
+ });
11735
12889
 
11736
12890
  return section
11737
12891
  }
@@ -11772,7 +12926,7 @@ class TableTools extends HTMLElement {
11772
12926
  }
11773
12927
 
11774
12928
  #registerKeyboardShortcuts() {
11775
- 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));
11776
12930
  }
11777
12931
 
11778
12932
  #focusToolbarOnAltF10 = (event) => {
@@ -11803,9 +12957,9 @@ class TableTools extends HTMLElement {
11803
12957
  const cell = this.tableController.currentCell;
11804
12958
  if (!cell) return
11805
12959
 
11806
- this.#editor.update(() => {
12960
+ this.editor.update(() => {
11807
12961
  cell.select();
11808
- this.#editor.focus();
12962
+ this.editor.focus();
11809
12963
  });
11810
12964
 
11811
12965
  this.#update();
@@ -11842,7 +12996,7 @@ class TableTools extends HTMLElement {
11842
12996
  if (!cellsToHighlight) return
11843
12997
 
11844
12998
  cellsToHighlight.forEach(cell => {
11845
- const cellElement = this.#editor.getElementByKey(cell.getKey());
12999
+ const cellElement = this.editor.getElementByKey(cell.getKey());
11846
13000
  if (!cellElement) return
11847
13001
 
11848
13002
  cellElement.classList.toggle(theme.tableCellHighlight, true);
@@ -11851,8 +13005,8 @@ class TableTools extends HTMLElement {
11851
13005
  }
11852
13006
 
11853
13007
  #monitorForTableSelection() {
11854
- this.#listeners.track(this.#editor.registerUpdateListener(() => {
11855
- const tableNode = this.#editor.getRootElement() && this.tableController.updateSelectedTable();
13008
+ this.#listeners.track(this.editor.registerUpdateListener(() => {
13009
+ const tableNode = this.editor.getRootElement() && this.tableController.updateSelectedTable();
11856
13010
 
11857
13011
  if (tableNode) {
11858
13012
  this.#show();
@@ -11863,8 +13017,12 @@ class TableTools extends HTMLElement {
11863
13017
  }
11864
13018
 
11865
13019
  #executeTableCommand(command) {
13020
+ const fromMoreMenu = this.#hasOpenMoreMenu;
13021
+
11866
13022
  this.tableController.executeTableCommand(command);
11867
13023
  this.#update();
13024
+
13025
+ if (fromMoreMenu) this.editor.focus();
11868
13026
  }
11869
13027
 
11870
13028
  #show() {
@@ -11888,18 +13046,18 @@ class TableTools extends HTMLElement {
11888
13046
  }
11889
13047
 
11890
13048
  #closeMoreMenu() {
11891
- this.querySelector("details[open]")?.removeAttribute("open");
13049
+ this.closeDropdowns();
11892
13050
  }
11893
13051
 
11894
13052
  #updateButtonsPosition() {
11895
13053
  const tableNode = this.tableController.currentTableNode;
11896
13054
  if (!tableNode) return
11897
13055
 
11898
- const tableElement = this.#editor.getElementByKey(tableNode.getKey());
13056
+ const tableElement = this.editor.getElementByKey(tableNode.getKey());
11899
13057
  if (!tableElement) return
11900
13058
 
11901
13059
  const tableRect = tableElement.getBoundingClientRect();
11902
- const editorRect = this.#editorElement.getBoundingClientRect();
13060
+ const editorRect = this.editorElement.getBoundingClientRect();
11903
13061
 
11904
13062
  const relativeTop = tableRect.top - editorRect.top;
11905
13063
  const relativeCenter = (tableRect.left + tableRect.right) / 2 - editorRect.left;
@@ -11911,7 +13069,7 @@ class TableTools extends HTMLElement {
11911
13069
  const tableNode = this.tableController.currentTableNode;
11912
13070
  if (!tableNode) return
11913
13071
 
11914
- const tableElement = $getElementForTableNode(this.#editor, tableNode);
13072
+ const tableElement = $getElementForTableNode(this.editor, tableNode);
11915
13073
  if (!tableElement) return
11916
13074
 
11917
13075
  const rowCount = tableElement.rows;
@@ -11925,18 +13083,18 @@ class TableTools extends HTMLElement {
11925
13083
  const cell = this.tableController.currentCell;
11926
13084
  if (!cell) return
11927
13085
 
11928
- const cellElement = this.#editor.getElementByKey(cell.getKey());
11929
- if (!cellElement) return
13086
+ const cellElement = this.editor.getElementByKey(cell.getKey());
13087
+ if (!cellElement?.contains(document.getSelection()?.anchorNode)) return
11930
13088
 
11931
13089
  cellElement.classList.add(theme.tableCellFocus);
11932
13090
  }
11933
13091
 
11934
13092
  #clearCellStyles() {
11935
- this.#editorElement.querySelectorAll(`.${theme.tableCellFocus}`)?.forEach(cell => {
13093
+ this.editorElement.querySelectorAll(`.${theme.tableCellFocus}`)?.forEach(cell => {
11936
13094
  cell.classList.remove(theme.tableCellFocus);
11937
13095
  });
11938
13096
 
11939
- this.#editorElement.querySelectorAll(`.${theme.tableCellHighlight}`)?.forEach(cell => {
13097
+ this.editorElement.querySelectorAll(`.${theme.tableCellHighlight}`)?.forEach(cell => {
11940
13098
  cell.classList.remove(theme.tableCellHighlight);
11941
13099
  cell.removeAttribute("data-action");
11942
13100
  cell.removeAttribute("data-child-type");
@@ -11968,7 +13126,8 @@ function defineElements() {
11968
13126
  // Prompt must be registered AFTER Editor
11969
13127
  "lexxy-prompt": LexicalPromptElement,
11970
13128
  "lexxy-code-language-picker": CodeLanguagePicker,
11971
- "lexxy-node-delete-button": NodeDeleteButton,
13129
+ "lexxy-live-region": LiveRegion,
13130
+ "lexxy-attachment-toolbar": AttachmentToolbar,
11972
13131
  "lexxy-table-tools": TableTools
11973
13132
  };
11974
13133