@37signals/lexxy 0.9.22 → 0.9.24

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/README.md CHANGED
@@ -2,7 +2,7 @@
2
2
 
3
3
  A modern rich text editor for Rails.
4
4
 
5
- [Documentation](https://basecamp.github.io/lexxy/docs/) | <mark>[Try it out!](https://basecamp.github.io/lexxy/sandbox/)</mark>
5
+ [Documentation](https://lexxy.dev/docs/) | <mark>[Try it out!](https://lexxy.dev/sandbox/)</mark>
6
6
 
7
7
  ## Features
8
8
 
package/dist/lexxy.esm.js CHANGED
@@ -1,7 +1,9 @@
1
1
  export { highlightCode, highlightElement } from './lexxy_helpers.esm.js';
2
2
  import DOMPurify 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, $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, defineExtension, COMMAND_PRIORITY_EDITOR, $getEditor, $getNodeByKey, HISTORY_MERGE_TAG, SKIP_SCROLL_INTO_VIEW_TAG, $cloneWithProperties, $getNearestRootOrShadowRoot, $createRangeSelection, $setSelection, createState, COMMAND_PRIORITY_NORMAL, $getState, $setState, $hasUpdateTag, PASTE_TAG, FORMAT_TEXT_COMMAND, UNDO_COMMAND, REDO_COMMAND, KEY_ARROW_RIGHT_COMMAND, KEY_TAB_COMMAND, 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, CLICK_COMMAND, isDOMNode, $getNearestNodeFromDOMNode, $addUpdateTag, 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, COMMAND_PRIORITY_CRITICAL, 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, 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, CLICK_COMMAND, isDOMNode, $getNearestNodeFromDOMNode, $addUpdateTag, 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, COMMAND_PRIORITY_CRITICAL, KEY_SPACE_COMMAND, INPUT_COMMAND, KEY_BACKSPACE_COMMAND, KEY_DOWN_COMMAND } from 'lexical';
5
+ import * as lexical from 'lexical';
6
+ export { lexical as Lexical };
5
7
  import { LinkNode, $createAutoLinkNode, $toggleLink, $createLinkNode, $isLinkNode, AutoLinkNode } from '@lexical/link';
6
8
  import { buildEditorFromExtensions } from '@lexical/extension';
7
9
  import { ListNode, ListItemNode, $getListDepth, $isListNode, $isListItemNode, INSERT_UNORDERED_LIST_COMMAND, INSERT_ORDERED_LIST_COMMAND, $createListNode, $createListItemNode, registerList } from '@lexical/list';
@@ -10,7 +12,7 @@ import { registerPlainText } from '@lexical/plain-text';
10
12
  import { RichTextExtension, $isQuoteNode, QuoteNode, $isHeadingNode, $createHeadingNode, $createQuoteNode, HeadingNode, registerRichText } from '@lexical/rich-text';
11
13
  import { $generateNodesFromDOM, $generateHtmlFromNodes } from '@lexical/html';
12
14
  import { HistoryExtension } from '@lexical/history';
13
- import { $isCodeNode, CodeHighlightNode, CodeNode, $createCodeNode, $isCodeHighlightNode, $createCodeHighlightNode, registerCodeHighlighting, normalizeCodeLang, CODE_LANGUAGE_FRIENDLY_NAME_MAP } from '@lexical/code';
15
+ import { $isCodeNode, CodeHighlightNode, PrismTokenizer, $isCodeHighlightNode, $createCodeNode, $createCodeHighlightNode, CodeNode, registerCodeHighlighting, normalizeCodeLang, CODE_LANGUAGE_FRIENDLY_NAME_MAP } from '@lexical/code';
14
16
  import { TRANSFORMERS, registerMarkdownShortcuts } from '@lexical/markdown';
15
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';
16
18
  import { marked } from 'marked';
@@ -316,6 +318,11 @@ var ToolbarIcons = {
316
318
  <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"/>
317
319
  </svg>`,
318
320
 
321
+ "h1":
322
+ `<svg viewBox="0 0 18 18" xmlns="http://www.w3.org/2000/svg">
323
+ <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.8418 4.125H14.8271C15.543 4.125 16.0449 4.56934 16.0449 5.23828V12.7207C16.0449 13.4111 15.6348 13.8486 14.9854 13.8486C14.336 13.8486 13.9395 13.418 13.9395 12.7207V6.11133H13.8887L12.9541 6.74219C12.7354 6.88574 12.5303 6.95312 12.3047 6.95312C11.7646 6.95312 11.3955 6.58398 11.3955 6.06445C11.3955 5.69531 11.5801 5.42578 11.9766 5.15625L13.4023 4.32031C13.5459 4.23633 13.6826 4.17578 13.8418 4.125Z"/>
324
+ </svg>`,
325
+
319
326
  "h2":
320
327
  `<svg viewBox="0 0 18 18" xmlns="http://www.w3.org/2000/svg">
321
328
  <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"/>
@@ -331,6 +338,16 @@ var ToolbarIcons = {
331
338
  <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"/>
332
339
  </svg>`,
333
340
 
341
+ "h5":
342
+ `<svg viewBox="0 0 18 18" xmlns="http://www.w3.org/2000/svg">
343
+ <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.30078ZM16.1191 4.30078C16.6934 4.30078 17.0762 4.63672 17.0762 5.16602C17.0762 5.69531 16.6934 6.03125 16.1191 6.03125H13.3457L12.9219 8.25586H13.0176C13.248 8.03711 13.6855 7.84375 14.2871 7.84375C15.9277 7.84375 17.1445 9.07422 17.1445 10.7832C17.1445 12.5605 15.8184 13.9053 13.7695 13.9053C11.9512 13.9053 10.6934 12.9277 10.5566 11.7178C10.5566 11.2021 10.9121 10.8193 11.4277 10.8193C11.8379 10.8193 12.1328 11.0244 12.3516 11.4893C12.625 12.0361 13.1133 12.2549 13.7285 12.2549C14.5488 12.2549 15.1367 11.6533 15.1367 10.7695C15.1367 9.92188 14.5625 9.31348 13.7559 9.31348C13.1953 9.31348 12.7578 9.56055 12.4629 10.0254C12.2168 10.3887 11.9355 10.5527 11.5527 10.5527C10.9648 10.5527 10.5684 10.1699 10.5684 9.55469C10.5684 9.42773 10.582 9.30762 10.6094 9.17383L11.2383 5.55273C11.3516 4.88379 11.7207 4.30078 12.6797 4.30078H16.1191Z"/>
344
+ </svg>`,
345
+
346
+ "h6":
347
+ `<svg viewBox="0 0 18 18" xmlns="http://www.w3.org/2000/svg">
348
+ <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.6719 13.9258C15.5518 13.9258 17.1445 12.37306 17.1445 10.35451V10.05763C17.1445 8.10744 16.6016 6.59574 15.5928 5.58694C14.8613 4.85544 13.8975 4.42384 12.7285 4.25294C12.4199 4.21194 12.1592 4.19824 11.8643 4.19824C11.2627 4.19824 10.8389 4.59474 10.8389 5.14164C10.8389 5.65724 11.2354 6.02054 11.7783 6.04784C12.1758 6.06834 12.4844 6.10944 12.7656 6.19144C13.5176 6.42384 14.0742 6.95314 14.3965 7.75974L14.4922 8.00584H14.3828C14.0605 7.56834 13.4863 7.25974 12.6523 7.25974C11.1406 7.25974 10.0674 8.42384 10.0674 10.02345V10.27736C10.0674 12.2549 11.4668 13.9258 13.6719 13.9258ZM13.5215 12.24123C12.6797 12.24123 12.0918 11.48244 12.0918 10.4131V10.1592C12.0918 9.26174 12.6523 8.71291 13.5352 8.71291C14.3965 8.71291 14.9844 9.27638 14.9844 10.1592V10.4131C14.9844 11.48244 14.3828 12.24123 13.5215 12.24123Z"/>
349
+ </svg>`,
350
+
334
351
  "paragraph":
335
352
  `<svg viewBox="0 0 18 18" xmlns="http://www.w3.org/2000/svg">
336
353
  <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"/>
@@ -632,9 +649,9 @@ class LexicalToolbarElement extends HTMLElement {
632
649
 
633
650
  this.#setButtonPressed("format", isInHeading);
634
651
  this.#setButtonPressed("paragraph", !isInHeading);
635
- this.#setButtonPressed("heading-large", headingTag === "h2");
636
- this.#setButtonPressed("heading-medium", headingTag === "h3");
637
- this.#setButtonPressed("heading-small", headingTag === "h4");
652
+
653
+ this.querySelector("lexxy-heading-dropdown")
654
+ ?.updateActiveHeading(headingTag);
638
655
 
639
656
  this.#setButtonPressed("lists", isInList);
640
657
  this.#setButtonPressed("unordered-list", isInList && listType === "bullet");
@@ -813,15 +830,9 @@ class LexicalToolbarElement extends HTMLElement {
813
830
  <button type="button" name="paragraph" data-command="setFormatParagraph" title="Paragraph" role="menuitem">
814
831
  ${ToolbarIcons.paragraph} <span>Normal</span>
815
832
  </button>
816
- <button type="button" name="heading-large" data-command="setFormatHeadingLarge" title="Large heading" role="menuitem">
817
- ${ToolbarIcons.h2} <span>Large Heading</span>
818
- </button>
819
- <button type="button" name="heading-medium" data-command="setFormatHeadingMedium" title="Medium heading" role="menuitem">
820
- ${ToolbarIcons.h3} <span>Medium Heading</span>
821
- </button>
822
- <button class="lexxy-editor__toolbar-group-end" type="button" name="heading-small" data-command="setFormatHeadingSmall" title="Small heading" role="menuitem">
823
- ${ToolbarIcons.h4} <span>Small Heading</span>
824
- </button>
833
+ <lexxy-heading-dropdown>
834
+ <div class="lexxy-heading-options"></div>
835
+ </lexxy-heading-dropdown>
825
836
  <div class="lexxy-editor__toolbar-separator" role="separator"></div>
826
837
  <button type="button" name="clear-formatting" data-command="clearFormatting" title="Clear formatting" role="menuitem">
827
838
  ${ToolbarIcons.clearFormatting} <span>Clear formatting</span>
@@ -1047,6 +1058,141 @@ class ToolbarDropdown extends HTMLElement {
1047
1058
  }
1048
1059
  }
1049
1060
 
1061
+ const HEADING_BUTTON_SELECTOR = "button.lexxy-heading-button";
1062
+
1063
+ const HEADING_PRESETS = [
1064
+ { label: "Large Heading", name: "heading-large" },
1065
+ { label: "Medium Heading", name: "heading-medium" },
1066
+ { label: "Small Heading", name: "heading-small" }
1067
+ ];
1068
+
1069
+ class HeadingDropdown extends HTMLElement {
1070
+ static labelFor(tag, index) {
1071
+ if (index < HEADING_PRESETS.length) {
1072
+ return HEADING_PRESETS[index].label
1073
+ } else {
1074
+ const level = tag.match(/^h(\d+)$/)?.[1];
1075
+ if (level) {
1076
+ return `Heading ${level}`
1077
+ } else {
1078
+ return tag.toUpperCase()
1079
+ }
1080
+ }
1081
+ }
1082
+
1083
+ static nameFor(tag, index) {
1084
+ if (index < HEADING_PRESETS.length) {
1085
+ return HEADING_PRESETS[index].name
1086
+ } else {
1087
+ const level = tag.match(/^h(\d+)$/)?.[1];
1088
+ if (level) {
1089
+ return `heading-${level}`
1090
+ } else {
1091
+ return `heading-${tag}`
1092
+ }
1093
+ }
1094
+ }
1095
+
1096
+ #listeners = new ListenerBin()
1097
+
1098
+ connectedCallback() {
1099
+ this.style.display = "contents";
1100
+
1101
+ this.#onToolbarEditor(() => {
1102
+ this.#buttonContainer.style.display = "contents";
1103
+ this.#setUpButtons();
1104
+ this.#registerButtonHandlers();
1105
+ this.#syncActiveHeading();
1106
+ });
1107
+ }
1108
+
1109
+ disconnectedCallback() {
1110
+ this.#listeners.dispose();
1111
+ }
1112
+
1113
+ updateActiveHeading(tag) {
1114
+ this.#headingButtons.forEach(button => {
1115
+ const next = (button.dataset.heading === tag).toString();
1116
+ if (button.getAttribute("aria-pressed") !== next) {
1117
+ button.setAttribute("aria-pressed", next);
1118
+ }
1119
+ });
1120
+ }
1121
+
1122
+ get #toolbar() {
1123
+ return this.closest("lexxy-toolbar")
1124
+ }
1125
+
1126
+ get #editorElement() {
1127
+ return this.#toolbar?.editorElement
1128
+ }
1129
+
1130
+ get #editor() {
1131
+ return this.#toolbar?.editor
1132
+ }
1133
+
1134
+ async #onToolbarEditor(callback) {
1135
+ const toolbar = this.#toolbar;
1136
+ if (!toolbar) return
1137
+
1138
+ await toolbar.getEditorElement();
1139
+ if (this.isConnected && this.#toolbar) callback();
1140
+ }
1141
+
1142
+ #setUpButtons() {
1143
+ this.#buttonContainer.innerHTML = "";
1144
+
1145
+ this.#configuredHeadings.forEach((tag, index) => {
1146
+ this.#buttonContainer.appendChild(this.#createButton(tag, index));
1147
+ });
1148
+ }
1149
+
1150
+ #createButton(tag, index) {
1151
+ const label = HeadingDropdown.labelFor(tag, index);
1152
+ const name = HeadingDropdown.nameFor(tag, index);
1153
+ const icon = ToolbarIcons[tag] || "";
1154
+
1155
+ const button = document.createElement("button");
1156
+ button.type = "button";
1157
+ button.dataset.heading = tag;
1158
+ button.classList.add("lexxy-heading-button");
1159
+ button.name = name;
1160
+ button.title = label;
1161
+ button.setAttribute("role", "menuitem");
1162
+ button.innerHTML = `${icon} <span>${label}</span>`;
1163
+ return button
1164
+ }
1165
+
1166
+ #registerButtonHandlers() {
1167
+ this.#headingButtons.forEach(button => {
1168
+ this.#listeners.track(registerEventListener(button, "click", (event) => {
1169
+ if (!this.#editor) return
1170
+
1171
+ event.preventDefault();
1172
+ this.#editor.dispatchCommand("applyHeadingFormat", button.dataset.heading);
1173
+ this.#editor.focus();
1174
+ }));
1175
+ });
1176
+ }
1177
+
1178
+ #syncActiveHeading() {
1179
+ const { headingTag } = this.#editor.read(() => this.#editorElement.selection.getFormat());
1180
+ this.updateActiveHeading(headingTag ?? null);
1181
+ }
1182
+
1183
+ get #configuredHeadings() {
1184
+ return this.#editorElement.config.get("headings")
1185
+ }
1186
+
1187
+ get #buttonContainer() {
1188
+ return this.querySelector(".lexxy-heading-options")
1189
+ }
1190
+
1191
+ get #headingButtons() {
1192
+ return Array.from(this.querySelectorAll(HEADING_BUTTON_SELECTOR))
1193
+ }
1194
+ }
1195
+
1050
1196
  const APPLY_HIGHLIGHT_SELECTOR = "button.lexxy-highlight-button";
1051
1197
  const REMOVE_HIGHLIGHT_SELECTOR = "[data-command='removeHighlight']";
1052
1198
 
@@ -1254,6 +1400,7 @@ const presets = new Configuration({
1254
1400
  toolbar: {
1255
1401
  upload: "both"
1256
1402
  },
1403
+ headings: [ "h2", "h3", "h4" ],
1257
1404
  highlight: {
1258
1405
  buttons: {
1259
1406
  color: range(1, 9).map(n => `var(--highlight-${n})`),
@@ -1509,8 +1656,11 @@ class LexxyExtension {
1509
1656
  return []
1510
1657
  }
1511
1658
 
1512
- initializeToolbar(_lexxyToolbar) {
1659
+ defineExtension(...args) {
1660
+ return defineExtension(...args)
1661
+ }
1513
1662
 
1663
+ initializeToolbar(_lexxyToolbar) {
1514
1664
  }
1515
1665
 
1516
1666
  setEditorValidity(flags, message) {
@@ -3099,9 +3249,9 @@ const hasPastedStylesState = createState("hasPastedStyles", {
3099
3249
  });
3100
3250
 
3101
3251
  // Stores pending highlight ranges extracted during HTML import, keyed by CodeNode key.
3102
- // After the code retokenizer creates fresh CodeHighlightNodes, a mutation listener
3103
- // reads this map and re-applies the highlight styles. Scoped per editor instance
3104
- // so entries don't leak across editors or outlive a torn-down editor.
3252
+ // The highlight-preserving tokenizer consumes this map when the code retokenizer
3253
+ // first tokenizes the block. Scoped per editor instance so entries don't leak
3254
+ // across editors or outlive a torn-down editor.
3105
3255
  const pendingCodeHighlights = new WeakMap();
3106
3256
 
3107
3257
  class HighlightExtension extends LexxyExtension {
@@ -3136,10 +3286,7 @@ class HighlightExtension extends LexxyExtension {
3136
3286
  editor.registerCommand(REMOVE_HIGHLIGHT_COMMAND, () => $toggleSelectionStyles(editor, BLANK_STYLES), COMMAND_PRIORITY_NORMAL),
3137
3287
  editor.registerNodeTransform(TextNode, $syncHighlightWithStyle),
3138
3288
  editor.registerNodeTransform(CodeHighlightNode, $syncHighlightWithCodeHighlightNode),
3139
- editor.registerNodeTransform(TextNode, (textNode) => $canonicalizePastedStyles(textNode, canonicalizers)),
3140
- editor.registerMutationListener(CodeNode, (mutations) => {
3141
- $applyPendingCodeHighlights(editor, mutations);
3142
- }, { skipInitialization: true })
3289
+ editor.registerNodeTransform(TextNode, (textNode) => $canonicalizePastedStyles(textNode, canonicalizers))
3143
3290
  )
3144
3291
  }
3145
3292
  });
@@ -3187,7 +3334,7 @@ function $registerPreConversion(editor) {
3187
3334
  // Returns a <pre> converter factory scoped to a specific editor instance.
3188
3335
  // The factory extracts highlight ranges from <mark> elements before the code
3189
3336
  // retokenizer can destroy them. The ranges are stored in pendingCodeHighlights
3190
- // and applied after retokenization via a mutation listener.
3337
+ // and restored by the highlight-preserving tokenizer during retokenization.
3191
3338
  function $preConversionWithHighlightsFactory(editor) {
3192
3339
  return function $preConversionWithHighlights(domNode) {
3193
3340
  const highlights = extractHighlightRanges(domNode);
@@ -3263,98 +3410,116 @@ function extractHighlightStyleFromElement(element) {
3263
3410
  return css.length > 0 ? css : null
3264
3411
  }
3265
3412
 
3266
- // Called from the CodeNode mutation listener after the retokenizer has
3267
- // replaced TextNodes with fresh CodeHighlightNodes.
3268
- function $applyPendingCodeHighlights(editor, mutations) {
3269
- const pending = $getPendingHighlights(editor);
3270
- const keysToProcess = [];
3271
-
3272
- for (const [ key, type ] of mutations) {
3273
- if (type !== "destroyed" && pending.has(key)) {
3274
- keysToProcess.push(key);
3413
+ // The code retokenizer replaces a code block's children with freshly created
3414
+ // tokens that carry no styles, which would drop color highlights on every
3415
+ // edit. This tokenizer wraps the stock Prism tokenizer to restore them: it
3416
+ // recovers the block's highlight ranges — staged during HTML import, or read
3417
+ // from the children the fresh tokens are about to replace — and reapplies
3418
+ // them to the fresh tokens before the retokenizer splices them in.
3419
+ function buildHighlightPreservingTokenizer(editor) {
3420
+ return {
3421
+ defaultLanguage: PrismTokenizer.defaultLanguage,
3422
+ tokenize(code, language) {
3423
+ return PrismTokenizer.tokenize(code, language)
3424
+ },
3425
+ $tokenize(codeNode, language) {
3426
+ const tokens = PrismTokenizer.$tokenize(codeNode, language);
3427
+ const highlights = $takeHighlightRanges(editor, codeNode);
3428
+ return $applyHighlightRangesToTokens(tokens, highlights)
3275
3429
  }
3276
3430
  }
3431
+ }
3277
3432
 
3278
- if (keysToProcess.length === 0) return
3433
+ function $takeHighlightRanges(editor, codeNode) {
3434
+ const pending = $getPendingHighlights(editor);
3435
+ const key = codeNode.getKey();
3436
+
3437
+ if (pending.has(key)) {
3438
+ const highlights = pending.get(key);
3439
+ pending.delete(key);
3440
+ return highlights
3441
+ } else if (codeNode.getChildren().some($isCodeHighlightNode)) {
3442
+ return $extractHighlightRangesFromCodeNode(codeNode)
3443
+ } else {
3444
+ // A block that was never tokenized has only plain text children. Styles
3445
+ // on those come from import paths that didn't stage ranges (e.g. colored
3446
+ // spans in Trix HTML), and the first tokenization discards them.
3447
+ return []
3448
+ }
3449
+ }
3279
3450
 
3280
- // Use a deferred update so the retokenizer has finished its
3281
- // skipTransforms update before we touch the nodes.
3282
- editor.update(() => {
3283
- for (const key of keysToProcess) {
3284
- const highlights = pending.get(key);
3285
- pending.delete(key);
3286
- if (!highlights) continue
3451
+ function $applyHighlightRangesToTokens(tokens, highlights) {
3452
+ if (highlights.length === 0) return tokens
3287
3453
 
3288
- const codeNode = $getNodeByKey(key);
3289
- if (!codeNode || !$isCodeNode(codeNode)) continue
3454
+ const styledTokens = [];
3455
+ let offset = 0;
3290
3456
 
3291
- $applyHighlightRangesToCodeNode(codeNode, highlights);
3457
+ for (const token of tokens) {
3458
+ if ($isCodeHighlightNode(token)) {
3459
+ styledTokens.push(...$splitTokenAtHighlightBoundaries(token, offset, highlights));
3460
+ } else {
3461
+ styledTokens.push(token);
3292
3462
  }
3293
- }, { skipTransforms: true, discrete: true });
3463
+ offset += token.getTextContentSize();
3464
+ }
3465
+
3466
+ return styledTokens
3294
3467
  }
3295
3468
 
3296
- // Apply saved highlight ranges to the CodeHighlightNode children
3297
- // of a CodeNode, splitting nodes at range boundaries as needed.
3298
- // We can't use TextNode.splitText() because it creates TextNode
3299
- // instances (not CodeHighlightNodes) for the split parts. Instead,
3300
- // we manually create CodeHighlightNode replacements.
3301
- function $applyHighlightRangesToCodeNode(codeNode, highlights) {
3302
- if (highlights.length === 0) return
3303
-
3304
- for (const { start: hlStart, end: hlEnd, style } of highlights) {
3305
- // Rebuild the child-to-offset mapping for each highlight range because
3306
- // earlier ranges may have split nodes, invalidating previous mappings.
3307
- const childRanges = $buildChildRanges(codeNode);
3308
-
3309
- for (const { node, start: nodeStart, end: nodeEnd } of childRanges) {
3310
- // Skip plain TextNodes: only CodeHighlightNodes can be split into
3311
- // styled replacements here. The retokenizer normally converts any
3312
- // TextNode children back to CodeHighlightNodes before this runs,
3313
- // but the iteration over $buildChildRanges has to keep counting
3314
- // them so character offsets stay aligned with the saved ranges.
3315
- if (!$isCodeHighlightNode(node)) continue
3316
-
3317
- // Check if this child overlaps with the highlight range
3318
- const overlapStart = Math.max(hlStart, nodeStart);
3319
- const overlapEnd = Math.min(hlEnd, nodeEnd);
3320
-
3321
- if (overlapStart >= overlapEnd) continue
3322
-
3323
- // Calculate offsets relative to this node
3324
- const relStart = overlapStart - nodeStart;
3325
- const relEnd = overlapEnd - nodeStart;
3326
- const nodeLength = nodeEnd - nodeStart;
3327
-
3328
- if (relStart === 0 && relEnd === nodeLength) {
3329
- // Entire node is highlighted - apply style directly
3330
- node.setStyle(style);
3331
- $setCodeHighlightFormat(node, true);
3332
- } else {
3333
- // Need to split: replace the node with 2 or 3 CodeHighlightNodes
3334
- const text = node.getTextContent();
3335
- const highlightType = node.getHighlightType();
3336
- const replacements = [];
3469
+ // Split a token into segments at highlight boundaries, styling the covered
3470
+ // segments. We can't use TextNode.splitText() because the fresh tokens aren't
3471
+ // attached to the tree yet, so we create CodeHighlightNode replacements.
3472
+ function $splitTokenAtHighlightBoundaries(token, tokenStart, highlights) {
3473
+ const text = token.getTextContent();
3474
+ const segments = segmentTextByHighlights(text, tokenStart, highlights);
3337
3475
 
3338
- if (relStart > 0) {
3339
- replacements.push($createCodeHighlightNode(text.slice(0, relStart), highlightType));
3340
- }
3476
+ if (segments.length === 1) {
3477
+ const [ segment ] = segments;
3478
+ if (segment.style) {
3479
+ $applyHighlightStyleToToken(token, segment.style);
3480
+ }
3481
+ return [ token ]
3482
+ } else {
3483
+ return segments.map((segment) => {
3484
+ const segmentToken = $createCodeHighlightNode(text.slice(segment.start, segment.end), token.getHighlightType());
3485
+ if (segment.style) {
3486
+ $applyHighlightStyleToToken(segmentToken, segment.style);
3487
+ }
3488
+ return segmentToken
3489
+ })
3490
+ }
3491
+ }
3341
3492
 
3342
- const styledNode = $createCodeHighlightNode(text.slice(relStart, relEnd), highlightType);
3343
- styledNode.setStyle(style);
3344
- $setCodeHighlightFormat(styledNode, true);
3345
- replacements.push(styledNode);
3493
+ // Partition a token's text into consecutive { start, end, style } segments,
3494
+ // where offsets are relative to the token and style is null for the stretches
3495
+ // no highlight covers.
3496
+ function segmentTextByHighlights(text, tokenStart, highlights) {
3497
+ const segments = [];
3498
+ let cursor = 0;
3346
3499
 
3347
- if (relEnd < nodeLength) {
3348
- replacements.push($createCodeHighlightNode(text.slice(relEnd), highlightType));
3349
- }
3500
+ for (const { start, end, style } of highlights) {
3501
+ const from = Math.max(start - tokenStart, cursor);
3502
+ const to = Math.min(end - tokenStart, text.length);
3350
3503
 
3351
- for (const replacement of replacements) {
3352
- node.insertBefore(replacement);
3353
- }
3354
- node.remove();
3504
+ if (from < to) {
3505
+ if (from > cursor) {
3506
+ segments.push({ start: cursor, end: from, style: null });
3355
3507
  }
3508
+ segments.push({ start: from, end: to, style });
3509
+ cursor = to;
3356
3510
  }
3357
3511
  }
3512
+
3513
+ if (cursor < text.length) {
3514
+ segments.push({ start: cursor, end: text.length, style: null });
3515
+ }
3516
+
3517
+ return segments
3518
+ }
3519
+
3520
+ function $applyHighlightStyleToToken(token, style) {
3521
+ token.setStyle(style);
3522
+ $setCodeHighlightFormat(token, true);
3358
3523
  }
3359
3524
 
3360
3525
  function $buildChildRanges(codeNode) {
@@ -3443,14 +3608,10 @@ function $patchCodeHighlightStyles(editor, selection, patch) {
3443
3608
  textSize: node.getTextContentSize()
3444
3609
  }));
3445
3610
 
3446
- // Use skipTransforms to prevent the code highlighting system from
3447
- // re-tokenizing and wiping out the style changes we apply.
3448
- // Use discrete to force a synchronous commit, ensuring the changes
3449
- // are committed before editor.focus() triggers a second update cycle
3450
- // that would re-run transforms and wipe out the styles.
3611
+ // Use skipTransforms so the retokenizer doesn't rebuild the block in the
3612
+ // middle of the toggle, and discrete to force a synchronous commit so the
3613
+ // styles are in place before editor.focus() triggers a second update cycle.
3451
3614
  editor.update(() => {
3452
- const affectedCodeNodes = new Set();
3453
-
3454
3615
  for (const { key, startOffset, endOffset, textSize } of nodeKeys) {
3455
3616
  const node = $getNodeByKey(key);
3456
3617
  if (!node) continue
@@ -3459,8 +3620,6 @@ function $patchCodeHighlightStyles(editor, selection, patch) {
3459
3620
  if (!$isCodeNode(parent)) continue
3460
3621
  if (startOffset === endOffset) continue
3461
3622
 
3462
- affectedCodeNodes.add(parent);
3463
-
3464
3623
  if (startOffset === 0 && endOffset === textSize) {
3465
3624
  $applyStylePatchToNode(node, patch);
3466
3625
  } else {
@@ -3469,17 +3628,6 @@ function $patchCodeHighlightStyles(editor, selection, patch) {
3469
3628
  $applyStylePatchToNode(targetNode, patch);
3470
3629
  }
3471
3630
  }
3472
-
3473
- // After applying styles, save highlight ranges for each affected CodeNode.
3474
- // The code retokenizer will replace the styled nodes with fresh unstyled
3475
- // tokens when transforms run. The pending highlights are picked up by the
3476
- // CodeNode mutation listener and reapplied after retokenization.
3477
- for (const codeNode of affectedCodeNodes) {
3478
- const ranges = $extractHighlightRangesFromCodeNode(codeNode);
3479
- if (ranges.length > 0) {
3480
- $getPendingHighlights(editor).set(codeNode.getKey(), ranges);
3481
- }
3482
- }
3483
3631
  }, { skipTransforms: true, discrete: true });
3484
3632
  }
3485
3633
 
@@ -3599,10 +3747,8 @@ const COMMANDS = [
3599
3747
  "unlink",
3600
3748
  "toggleHighlight",
3601
3749
  "removeHighlight",
3602
- "setFormatHeadingLarge",
3603
- "setFormatHeadingMedium",
3604
- "setFormatHeadingSmall",
3605
3750
  "setFormatParagraph",
3751
+ "applyHeadingFormat",
3606
3752
  "clearFormatting",
3607
3753
  "insertUnorderedList",
3608
3754
  "insertOrderedList",
@@ -3794,22 +3940,14 @@ class CommandDispatcher {
3794
3940
  $insertNodeToNearestRoot(new HorizontalDividerNode);
3795
3941
  }
3796
3942
 
3797
- dispatchSetFormatHeadingLarge() {
3798
- this.contents.applyHeadingFormat("h2");
3799
- }
3800
-
3801
- dispatchSetFormatHeadingMedium() {
3802
- this.contents.applyHeadingFormat("h3");
3803
- }
3804
-
3805
- dispatchSetFormatHeadingSmall() {
3806
- this.contents.applyHeadingFormat("h4");
3807
- }
3808
-
3809
3943
  dispatchSetFormatParagraph() {
3810
3944
  this.contents.applyParagraphFormat();
3811
3945
  }
3812
3946
 
3947
+ dispatchApplyHeadingFormat(tag) {
3948
+ this.contents.applyHeadingFormat(tag);
3949
+ }
3950
+
3813
3951
  dispatchClearFormatting() {
3814
3952
  this.contents.clearFormatting();
3815
3953
  }
@@ -5994,16 +6132,34 @@ class Contents {
5994
6132
  return result
5995
6133
  }
5996
6134
 
5997
- // The query runs from the trigger up to the next whitespace, even when the
6135
+ // The query runs from the trigger up to the next boundary, even when the
5998
6136
  // cursor sits inside an existing word — inserting "@" before "Jack" must
5999
6137
  // filter by "Jack" rather than treating the prompt as empty.
6000
6138
  #endOffsetAt(fullText, cursorOffset) {
6001
- const whitespaceOffset = fullText.slice(cursorOffset).search(/\s/);
6002
- if (whitespaceOffset === -1) {
6003
- return fullText.length
6004
- } else {
6005
- return cursorOffset + whitespaceOffset
6139
+ for (let offset = cursorOffset; offset < fullText.length; offset++) {
6140
+ if (this.#isQueryBoundary(fullText, offset)) {
6141
+ return offset
6142
+ }
6006
6143
  }
6144
+
6145
+ return fullText.length
6146
+ }
6147
+
6148
+ // Whitespace always ends the query. Punctuation ends it too: with "@" typed
6149
+ // right before a period, extending across it would glue "." onto everything
6150
+ // typed ("B" would query "B."), matching the wrong names or nothing at all.
6151
+ // But punctuation joining two word characters is part of a name — "@" before
6152
+ // "Anne-Marie" or "O'Connor" must keep the whole name as the query.
6153
+ #isQueryBoundary(fullText, offset) {
6154
+ const character = fullText[offset];
6155
+ if (/\s/.test(character)) return true
6156
+ if (!/\p{P}/u.test(character)) return false
6157
+
6158
+ return !(this.#isWordCharacter(fullText[offset - 1]) && this.#isWordCharacter(fullText[offset + 1]))
6159
+ }
6160
+
6161
+ #isWordCharacter(character) {
6162
+ return character != null && /[\p{L}\p{N}]/u.test(character)
6007
6163
  }
6008
6164
 
6009
6165
  containsTextBackUntil(string) {
@@ -6356,17 +6512,15 @@ class Contents {
6356
6512
  return { anchorNode, offset: anchor.offset }
6357
6513
  }
6358
6514
 
6359
- // The replaced span can straddle the cursor (e.g. "@Jack" when "@" was just
6360
- // inserted before "Jack"), so we anchor on the trigger before the cursor and
6361
- // verify the whole string matches there rather than searching text up to it.
6515
+ // The replaced span must start before the cursor but can extend past it
6516
+ // (e.g. "@Jack" when "@" was just inserted before "Jack"), so we bound the
6517
+ // match's start with lastIndexOf's fromIndex rather than slicing the text.
6362
6518
  #findReplacementStart(anchorNode, offset, stringToReplace) {
6363
- const fullText = anchorNode.getTextContent();
6364
- const triggerIndex = fullText.slice(0, offset).lastIndexOf(stringToReplace[0]);
6365
-
6366
- if (triggerIndex !== -1 && fullText.startsWith(stringToReplace, triggerIndex)) {
6367
- return triggerIndex
6368
- } else {
6519
+ if (offset === 0) {
6520
+ // A negative fromIndex clamps to 0 and could match at the cursor
6369
6521
  return -1
6522
+ } else {
6523
+ return anchorNode.getTextContent().lastIndexOf(stringToReplace, offset - 1)
6370
6524
  }
6371
6525
  }
6372
6526
 
@@ -7085,7 +7239,7 @@ class CodeHighlightingExtension extends LexxyExtension {
7085
7239
  return defineExtension({
7086
7240
  name: "lexxy/code-highlighting",
7087
7241
  register(editor) {
7088
- return registerCodeHighlighting(editor)
7242
+ return registerCodeHighlighting(editor, buildHighlightPreservingTokenizer(editor))
7089
7243
  }
7090
7244
  })
7091
7245
  }
@@ -7222,9 +7376,13 @@ class TablesExtension extends LexxyExtension {
7222
7376
  }
7223
7377
  }),
7224
7378
 
7225
- // Bug fix: Prevent hardcoded background color (Lexical #8089)
7379
+ // A table in Lexxy is a Lexxy table: cell shading can't be set in the
7380
+ // editor, so any cell background only ever comes from foreign content
7381
+ // (pasted spreadsheets, loaded documents). Normalize every cell to no
7382
+ // background so it adopts the current theme. This also clears
7383
+ // Lexical's hardcoded default header background (Lexical #8089).
7226
7384
  editor.registerNodeTransform(TableCellNode, (node) => {
7227
- if (node.getBackgroundColor() === null) {
7385
+ if (node.getBackgroundColor() !== "") {
7228
7386
  node.setBackgroundColor("");
7229
7387
  }
7230
7388
  }),
@@ -9249,11 +9407,14 @@ class LexicalEditorElement extends HTMLElement {
9249
9407
  get #supportedHeadingFormats() {
9250
9408
  if (!this.supportsRichText) return []
9251
9409
 
9410
+ const headings = this.config.get("headings");
9252
9411
  return [
9253
9412
  { label: "Normal", command: "setFormatParagraph", tag: null },
9254
- { label: "Large heading", command: "setFormatHeadingLarge", tag: "h2" },
9255
- { label: "Medium heading", command: "setFormatHeadingMedium", tag: "h3" },
9256
- { label: "Small heading", command: "setFormatHeadingSmall", tag: "h4" },
9413
+ ...headings.map((tag, index) => ({
9414
+ label: HeadingDropdown.labelFor(tag, index),
9415
+ command: "applyHeadingFormat",
9416
+ tag
9417
+ }))
9257
9418
  ]
9258
9419
  }
9259
9420
 
@@ -10672,6 +10833,7 @@ class TableTools extends HTMLElement {
10672
10833
  connectedCallback() {
10673
10834
  this.tableController = new TableController(this.#editorElement);
10674
10835
  this.classList.add("lexxy-floating-controls");
10836
+ this.role = "toolbar";
10675
10837
 
10676
10838
  this.#setUpButtons();
10677
10839
  this.#hide();
@@ -10802,16 +10964,25 @@ class TableTools extends HTMLElement {
10802
10964
  }
10803
10965
 
10804
10966
  #registerKeyboardShortcuts() {
10805
- this.#listeners.track(this.#editor.registerCommand(KEY_DOWN_COMMAND, this.#handleAccessibilityShortcutKey, COMMAND_PRIORITY_HIGH));
10967
+ this.#listeners.track(this.#editor.registerCommand(KEY_DOWN_COMMAND, this.#focusToolbarOnAltF10, COMMAND_PRIORITY_HIGH));
10806
10968
  }
10807
10969
 
10808
- #handleAccessibilityShortcutKey = (event) => {
10809
- if ((event.ctrlKey || event.metaKey) && event.shiftKey && event.key === "F10") {
10810
- const firstButton = this.querySelector("button, [tabindex]:not([tabindex='-1'])");
10811
- firstButton?.focus();
10970
+ #focusToolbarOnAltF10 = (event) => {
10971
+ if (this.#hasSelectedTable && event.altKey && event.key === "F10") {
10972
+ event.preventDefault();
10973
+ // Ask for the ring explicitly: a programmatic focus coming from a mouse-focused
10974
+ // editor otherwise inherits the mouse modality and paints no focus ring.
10975
+ this.#tableToolsButtons[0]?.focus({ focusVisible: true });
10976
+ return true
10977
+ } else {
10978
+ return false
10812
10979
  }
10813
10980
  }
10814
10981
 
10982
+ get #hasSelectedTable() {
10983
+ return this.tableController?.currentTableNodeKey != null
10984
+ }
10985
+
10815
10986
  #handleToolsKeydown = (event) => {
10816
10987
  if (event.key === "Escape") {
10817
10988
  this.#handleEscapeKey();
@@ -10980,6 +11151,7 @@ function defineElements() {
10980
11151
  // Toolbar must be registered BEFORE Editor
10981
11152
  "lexxy-toolbar": LexicalToolbarElement,
10982
11153
  "lexxy-toolbar-dropdown": ToolbarDropdown,
11154
+ "lexxy-heading-dropdown": HeadingDropdown,
10983
11155
  "lexxy-highlight-dropdown": HighlightDropdown,
10984
11156
  "lexxy-link-dropdown": LinkDropdown,
10985
11157
 
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@37signals/lexxy",
3
- "version": "0.9.22",
3
+ "version": "0.9.24",
4
4
  "description": "Lexxy - A modern rich text editor for Rails.",
5
5
  "module": "dist/lexxy.esm.js",
6
6
  "type": "module",