@37signals/lexxy 0.9.23 → 0.9.25

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
@@ -12,7 +12,7 @@ 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, 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';
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';
@@ -318,6 +318,11 @@ var ToolbarIcons = {
318
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"/>
319
319
  </svg>`,
320
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
+
321
326
  "h2":
322
327
  `<svg viewBox="0 0 18 18" xmlns="http://www.w3.org/2000/svg">
323
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"/>
@@ -333,6 +338,16 @@ var ToolbarIcons = {
333
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"/>
334
339
  </svg>`,
335
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
+
336
351
  "paragraph":
337
352
  `<svg viewBox="0 0 18 18" xmlns="http://www.w3.org/2000/svg">
338
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"/>
@@ -634,9 +649,9 @@ class LexicalToolbarElement extends HTMLElement {
634
649
 
635
650
  this.#setButtonPressed("format", isInHeading);
636
651
  this.#setButtonPressed("paragraph", !isInHeading);
637
- this.#setButtonPressed("heading-large", headingTag === "h2");
638
- this.#setButtonPressed("heading-medium", headingTag === "h3");
639
- this.#setButtonPressed("heading-small", headingTag === "h4");
652
+
653
+ this.querySelector("lexxy-heading-dropdown")
654
+ ?.updateActiveHeading(headingTag);
640
655
 
641
656
  this.#setButtonPressed("lists", isInList);
642
657
  this.#setButtonPressed("unordered-list", isInList && listType === "bullet");
@@ -815,15 +830,9 @@ class LexicalToolbarElement extends HTMLElement {
815
830
  <button type="button" name="paragraph" data-command="setFormatParagraph" title="Paragraph" role="menuitem">
816
831
  ${ToolbarIcons.paragraph} <span>Normal</span>
817
832
  </button>
818
- <button type="button" name="heading-large" data-command="setFormatHeadingLarge" title="Large heading" role="menuitem">
819
- ${ToolbarIcons.h2} <span>Large Heading</span>
820
- </button>
821
- <button type="button" name="heading-medium" data-command="setFormatHeadingMedium" title="Medium heading" role="menuitem">
822
- ${ToolbarIcons.h3} <span>Medium Heading</span>
823
- </button>
824
- <button class="lexxy-editor__toolbar-group-end" type="button" name="heading-small" data-command="setFormatHeadingSmall" title="Small heading" role="menuitem">
825
- ${ToolbarIcons.h4} <span>Small Heading</span>
826
- </button>
833
+ <lexxy-heading-dropdown>
834
+ <div class="lexxy-heading-options"></div>
835
+ </lexxy-heading-dropdown>
827
836
  <div class="lexxy-editor__toolbar-separator" role="separator"></div>
828
837
  <button type="button" name="clear-formatting" data-command="clearFormatting" title="Clear formatting" role="menuitem">
829
838
  ${ToolbarIcons.clearFormatting} <span>Clear formatting</span>
@@ -1049,6 +1058,150 @@ class ToolbarDropdown extends HTMLElement {
1049
1058
  }
1050
1059
  }
1051
1060
 
1061
+ const HEADING_BUTTON_SELECTOR = "button.lexxy-heading-button";
1062
+
1063
+ const HEADING_PRESETS = [
1064
+ { label: "Large Heading", name: "heading-large", command: "setFormatHeadingLarge" },
1065
+ { label: "Medium Heading", name: "heading-medium", command: "setFormatHeadingMedium" },
1066
+ { label: "Small Heading", name: "heading-small", command: "setFormatHeadingSmall" }
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
+ static commandFor(index) {
1097
+ if (index < HEADING_PRESETS.length) {
1098
+ return HEADING_PRESETS[index].command
1099
+ } else {
1100
+ return "applyHeadingFormat"
1101
+ }
1102
+ }
1103
+
1104
+ #listeners = new ListenerBin()
1105
+
1106
+ connectedCallback() {
1107
+ this.style.display = "contents";
1108
+
1109
+ this.#onToolbarEditor(() => {
1110
+ this.#buttonContainer.style.display = "contents";
1111
+ this.#setUpButtons();
1112
+ this.#registerButtonHandlers();
1113
+ this.#syncActiveHeading();
1114
+ });
1115
+ }
1116
+
1117
+ disconnectedCallback() {
1118
+ this.#listeners.dispose();
1119
+ }
1120
+
1121
+ updateActiveHeading(tag) {
1122
+ this.#headingButtons.forEach(button => {
1123
+ const next = (button.dataset.heading === tag).toString();
1124
+ if (button.getAttribute("aria-pressed") !== next) {
1125
+ button.setAttribute("aria-pressed", next);
1126
+ }
1127
+ });
1128
+ }
1129
+
1130
+ get #toolbar() {
1131
+ return this.closest("lexxy-toolbar")
1132
+ }
1133
+
1134
+ get #editorElement() {
1135
+ return this.#toolbar?.editorElement
1136
+ }
1137
+
1138
+ get #editor() {
1139
+ return this.#toolbar?.editor
1140
+ }
1141
+
1142
+ async #onToolbarEditor(callback) {
1143
+ const toolbar = this.#toolbar;
1144
+ if (!toolbar) return
1145
+
1146
+ await toolbar.getEditorElement();
1147
+ if (this.isConnected && this.#toolbar) callback();
1148
+ }
1149
+
1150
+ #setUpButtons() {
1151
+ this.#buttonContainer.innerHTML = "";
1152
+
1153
+ this.#configuredHeadings.forEach((tag, index) => {
1154
+ this.#buttonContainer.appendChild(this.#createButton(tag, index));
1155
+ });
1156
+ }
1157
+
1158
+ #createButton(tag, index) {
1159
+ const label = HeadingDropdown.labelFor(tag, index);
1160
+ const name = HeadingDropdown.nameFor(tag, index);
1161
+ const icon = ToolbarIcons[tag] || "";
1162
+
1163
+ const button = document.createElement("button");
1164
+ button.type = "button";
1165
+ button.dataset.heading = tag;
1166
+ button.dataset.command = HeadingDropdown.commandFor(index);
1167
+ button.classList.add("lexxy-heading-button");
1168
+ button.name = name;
1169
+ button.title = label;
1170
+ button.setAttribute("role", "menuitem");
1171
+ button.innerHTML = `${icon} <span>${label}</span>`;
1172
+ return button
1173
+ }
1174
+
1175
+ #registerButtonHandlers() {
1176
+ this.#headingButtons.forEach(button => {
1177
+ this.#listeners.track(registerEventListener(button, "click", (event) => {
1178
+ if (!this.#editor) return
1179
+
1180
+ event.preventDefault();
1181
+ this.#editor.dispatchCommand(button.dataset.command, button.dataset.heading);
1182
+ this.#editor.focus();
1183
+ }));
1184
+ });
1185
+ }
1186
+
1187
+ #syncActiveHeading() {
1188
+ const { headingTag } = this.#editor.read(() => this.#editorElement.selection.getFormat());
1189
+ this.updateActiveHeading(headingTag ?? null);
1190
+ }
1191
+
1192
+ get #configuredHeadings() {
1193
+ return this.#editorElement.config.get("headings")
1194
+ }
1195
+
1196
+ get #buttonContainer() {
1197
+ return this.querySelector(".lexxy-heading-options")
1198
+ }
1199
+
1200
+ get #headingButtons() {
1201
+ return Array.from(this.querySelectorAll(HEADING_BUTTON_SELECTOR))
1202
+ }
1203
+ }
1204
+
1052
1205
  const APPLY_HIGHLIGHT_SELECTOR = "button.lexxy-highlight-button";
1053
1206
  const REMOVE_HIGHLIGHT_SELECTOR = "[data-command='removeHighlight']";
1054
1207
 
@@ -1256,6 +1409,7 @@ const presets = new Configuration({
1256
1409
  toolbar: {
1257
1410
  upload: "both"
1258
1411
  },
1412
+ headings: [ "h2", "h3", "h4" ],
1259
1413
  highlight: {
1260
1414
  buttons: {
1261
1415
  color: range(1, 9).map(n => `var(--highlight-${n})`),
@@ -1793,14 +1947,29 @@ function $shrinkSelectionPastBlockEdges(selection) {
1793
1947
  if (!anchorBlock || !focusBlock || anchorBlock.is(focusBlock)) return
1794
1948
 
1795
1949
  if ($isAtBlockEnd(selection.anchor, anchorBlock)) {
1796
- const nextBlock = anchorBlock.getNextSibling();
1950
+ const nextBlock = $nearestElementSibling(anchorBlock, "next");
1797
1951
  if (nextBlock) selection.anchor.set(nextBlock.getKey(), 0, "element");
1798
1952
  }
1799
1953
 
1800
1954
  if ($isAtBlockStart(selection.focus, focusBlock)) {
1801
- const previousBlock = focusBlock.getPreviousSibling();
1955
+ const previousBlock = $nearestElementSibling(focusBlock, "previous");
1802
1956
  if (previousBlock) selection.focus.set(previousBlock.getKey(), previousBlock.getChildrenSize(), "element");
1803
1957
  }
1958
+
1959
+ // Shrinking moves the endpoints toward each other, so they can cross when
1960
+ // both were flush against block edges; callers assume a forward selection.
1961
+ $ensureForwardRangeSelection(selection);
1962
+ }
1963
+
1964
+ // Top-level decorator blocks (attachments, horizontal dividers) can't hold a
1965
+ // selection point, so walk past them to the nearest element sibling. Returns
1966
+ // null when no element sibling exists in that direction.
1967
+ function $nearestElementSibling(block, direction) {
1968
+ let sibling = block;
1969
+ do {
1970
+ sibling = direction === "next" ? sibling.getNextSibling() : sibling.getPreviousSibling();
1971
+ } while (sibling && !$isElementNode(sibling))
1972
+ return sibling
1804
1973
  }
1805
1974
 
1806
1975
  function $isAtBlockEnd(point, block) {
@@ -3104,9 +3273,9 @@ const hasPastedStylesState = createState("hasPastedStyles", {
3104
3273
  });
3105
3274
 
3106
3275
  // Stores pending highlight ranges extracted during HTML import, keyed by CodeNode key.
3107
- // After the code retokenizer creates fresh CodeHighlightNodes, a mutation listener
3108
- // reads this map and re-applies the highlight styles. Scoped per editor instance
3109
- // so entries don't leak across editors or outlive a torn-down editor.
3276
+ // The highlight-preserving tokenizer consumes this map when the code retokenizer
3277
+ // first tokenizes the block. Scoped per editor instance so entries don't leak
3278
+ // across editors or outlive a torn-down editor.
3110
3279
  const pendingCodeHighlights = new WeakMap();
3111
3280
 
3112
3281
  class HighlightExtension extends LexxyExtension {
@@ -3141,10 +3310,7 @@ class HighlightExtension extends LexxyExtension {
3141
3310
  editor.registerCommand(REMOVE_HIGHLIGHT_COMMAND, () => $toggleSelectionStyles(editor, BLANK_STYLES), COMMAND_PRIORITY_NORMAL),
3142
3311
  editor.registerNodeTransform(TextNode, $syncHighlightWithStyle),
3143
3312
  editor.registerNodeTransform(CodeHighlightNode, $syncHighlightWithCodeHighlightNode),
3144
- editor.registerNodeTransform(TextNode, (textNode) => $canonicalizePastedStyles(textNode, canonicalizers)),
3145
- editor.registerMutationListener(CodeNode, (mutations) => {
3146
- $applyPendingCodeHighlights(editor, mutations);
3147
- }, { skipInitialization: true })
3313
+ editor.registerNodeTransform(TextNode, (textNode) => $canonicalizePastedStyles(textNode, canonicalizers))
3148
3314
  )
3149
3315
  }
3150
3316
  });
@@ -3192,7 +3358,7 @@ function $registerPreConversion(editor) {
3192
3358
  // Returns a <pre> converter factory scoped to a specific editor instance.
3193
3359
  // The factory extracts highlight ranges from <mark> elements before the code
3194
3360
  // retokenizer can destroy them. The ranges are stored in pendingCodeHighlights
3195
- // and applied after retokenization via a mutation listener.
3361
+ // and restored by the highlight-preserving tokenizer during retokenization.
3196
3362
  function $preConversionWithHighlightsFactory(editor) {
3197
3363
  return function $preConversionWithHighlights(domNode) {
3198
3364
  const highlights = extractHighlightRanges(domNode);
@@ -3268,98 +3434,116 @@ function extractHighlightStyleFromElement(element) {
3268
3434
  return css.length > 0 ? css : null
3269
3435
  }
3270
3436
 
3271
- // Called from the CodeNode mutation listener after the retokenizer has
3272
- // replaced TextNodes with fresh CodeHighlightNodes.
3273
- function $applyPendingCodeHighlights(editor, mutations) {
3274
- const pending = $getPendingHighlights(editor);
3275
- const keysToProcess = [];
3276
-
3277
- for (const [ key, type ] of mutations) {
3278
- if (type !== "destroyed" && pending.has(key)) {
3279
- keysToProcess.push(key);
3437
+ // The code retokenizer replaces a code block's children with freshly created
3438
+ // tokens that carry no styles, which would drop color highlights on every
3439
+ // edit. This tokenizer wraps the stock Prism tokenizer to restore them: it
3440
+ // recovers the block's highlight ranges — staged during HTML import, or read
3441
+ // from the children the fresh tokens are about to replace — and reapplies
3442
+ // them to the fresh tokens before the retokenizer splices them in.
3443
+ function buildHighlightPreservingTokenizer(editor) {
3444
+ return {
3445
+ defaultLanguage: PrismTokenizer.defaultLanguage,
3446
+ tokenize(code, language) {
3447
+ return PrismTokenizer.tokenize(code, language)
3448
+ },
3449
+ $tokenize(codeNode, language) {
3450
+ const tokens = PrismTokenizer.$tokenize(codeNode, language);
3451
+ const highlights = $takeHighlightRanges(editor, codeNode);
3452
+ return $applyHighlightRangesToTokens(tokens, highlights)
3280
3453
  }
3281
3454
  }
3455
+ }
3282
3456
 
3283
- if (keysToProcess.length === 0) return
3457
+ function $takeHighlightRanges(editor, codeNode) {
3458
+ const pending = $getPendingHighlights(editor);
3459
+ const key = codeNode.getKey();
3460
+
3461
+ if (pending.has(key)) {
3462
+ const highlights = pending.get(key);
3463
+ pending.delete(key);
3464
+ return highlights
3465
+ } else if (codeNode.getChildren().some($isCodeHighlightNode)) {
3466
+ return $extractHighlightRangesFromCodeNode(codeNode)
3467
+ } else {
3468
+ // A block that was never tokenized has only plain text children. Styles
3469
+ // on those come from import paths that didn't stage ranges (e.g. colored
3470
+ // spans in Trix HTML), and the first tokenization discards them.
3471
+ return []
3472
+ }
3473
+ }
3284
3474
 
3285
- // Use a deferred update so the retokenizer has finished its
3286
- // skipTransforms update before we touch the nodes.
3287
- editor.update(() => {
3288
- for (const key of keysToProcess) {
3289
- const highlights = pending.get(key);
3290
- pending.delete(key);
3291
- if (!highlights) continue
3475
+ function $applyHighlightRangesToTokens(tokens, highlights) {
3476
+ if (highlights.length === 0) return tokens
3292
3477
 
3293
- const codeNode = $getNodeByKey(key);
3294
- if (!codeNode || !$isCodeNode(codeNode)) continue
3478
+ const styledTokens = [];
3479
+ let offset = 0;
3295
3480
 
3296
- $applyHighlightRangesToCodeNode(codeNode, highlights);
3481
+ for (const token of tokens) {
3482
+ if ($isCodeHighlightNode(token)) {
3483
+ styledTokens.push(...$splitTokenAtHighlightBoundaries(token, offset, highlights));
3484
+ } else {
3485
+ styledTokens.push(token);
3297
3486
  }
3298
- }, { skipTransforms: true, discrete: true });
3487
+ offset += token.getTextContentSize();
3488
+ }
3489
+
3490
+ return styledTokens
3299
3491
  }
3300
3492
 
3301
- // Apply saved highlight ranges to the CodeHighlightNode children
3302
- // of a CodeNode, splitting nodes at range boundaries as needed.
3303
- // We can't use TextNode.splitText() because it creates TextNode
3304
- // instances (not CodeHighlightNodes) for the split parts. Instead,
3305
- // we manually create CodeHighlightNode replacements.
3306
- function $applyHighlightRangesToCodeNode(codeNode, highlights) {
3307
- if (highlights.length === 0) return
3308
-
3309
- for (const { start: hlStart, end: hlEnd, style } of highlights) {
3310
- // Rebuild the child-to-offset mapping for each highlight range because
3311
- // earlier ranges may have split nodes, invalidating previous mappings.
3312
- const childRanges = $buildChildRanges(codeNode);
3313
-
3314
- for (const { node, start: nodeStart, end: nodeEnd } of childRanges) {
3315
- // Skip plain TextNodes: only CodeHighlightNodes can be split into
3316
- // styled replacements here. The retokenizer normally converts any
3317
- // TextNode children back to CodeHighlightNodes before this runs,
3318
- // but the iteration over $buildChildRanges has to keep counting
3319
- // them so character offsets stay aligned with the saved ranges.
3320
- if (!$isCodeHighlightNode(node)) continue
3321
-
3322
- // Check if this child overlaps with the highlight range
3323
- const overlapStart = Math.max(hlStart, nodeStart);
3324
- const overlapEnd = Math.min(hlEnd, nodeEnd);
3325
-
3326
- if (overlapStart >= overlapEnd) continue
3327
-
3328
- // Calculate offsets relative to this node
3329
- const relStart = overlapStart - nodeStart;
3330
- const relEnd = overlapEnd - nodeStart;
3331
- const nodeLength = nodeEnd - nodeStart;
3332
-
3333
- if (relStart === 0 && relEnd === nodeLength) {
3334
- // Entire node is highlighted - apply style directly
3335
- node.setStyle(style);
3336
- $setCodeHighlightFormat(node, true);
3337
- } else {
3338
- // Need to split: replace the node with 2 or 3 CodeHighlightNodes
3339
- const text = node.getTextContent();
3340
- const highlightType = node.getHighlightType();
3341
- const replacements = [];
3493
+ // Split a token into segments at highlight boundaries, styling the covered
3494
+ // segments. We can't use TextNode.splitText() because the fresh tokens aren't
3495
+ // attached to the tree yet, so we create CodeHighlightNode replacements.
3496
+ function $splitTokenAtHighlightBoundaries(token, tokenStart, highlights) {
3497
+ const text = token.getTextContent();
3498
+ const segments = segmentTextByHighlights(text, tokenStart, highlights);
3342
3499
 
3343
- if (relStart > 0) {
3344
- replacements.push($createCodeHighlightNode(text.slice(0, relStart), highlightType));
3345
- }
3500
+ if (segments.length === 1) {
3501
+ const [ segment ] = segments;
3502
+ if (segment.style) {
3503
+ $applyHighlightStyleToToken(token, segment.style);
3504
+ }
3505
+ return [ token ]
3506
+ } else {
3507
+ return segments.map((segment) => {
3508
+ const segmentToken = $createCodeHighlightNode(text.slice(segment.start, segment.end), token.getHighlightType());
3509
+ if (segment.style) {
3510
+ $applyHighlightStyleToToken(segmentToken, segment.style);
3511
+ }
3512
+ return segmentToken
3513
+ })
3514
+ }
3515
+ }
3346
3516
 
3347
- const styledNode = $createCodeHighlightNode(text.slice(relStart, relEnd), highlightType);
3348
- styledNode.setStyle(style);
3349
- $setCodeHighlightFormat(styledNode, true);
3350
- replacements.push(styledNode);
3517
+ // Partition a token's text into consecutive { start, end, style } segments,
3518
+ // where offsets are relative to the token and style is null for the stretches
3519
+ // no highlight covers.
3520
+ function segmentTextByHighlights(text, tokenStart, highlights) {
3521
+ const segments = [];
3522
+ let cursor = 0;
3351
3523
 
3352
- if (relEnd < nodeLength) {
3353
- replacements.push($createCodeHighlightNode(text.slice(relEnd), highlightType));
3354
- }
3524
+ for (const { start, end, style } of highlights) {
3525
+ const from = Math.max(start - tokenStart, cursor);
3526
+ const to = Math.min(end - tokenStart, text.length);
3355
3527
 
3356
- for (const replacement of replacements) {
3357
- node.insertBefore(replacement);
3358
- }
3359
- node.remove();
3528
+ if (from < to) {
3529
+ if (from > cursor) {
3530
+ segments.push({ start: cursor, end: from, style: null });
3360
3531
  }
3532
+ segments.push({ start: from, end: to, style });
3533
+ cursor = to;
3361
3534
  }
3362
3535
  }
3536
+
3537
+ if (cursor < text.length) {
3538
+ segments.push({ start: cursor, end: text.length, style: null });
3539
+ }
3540
+
3541
+ return segments
3542
+ }
3543
+
3544
+ function $applyHighlightStyleToToken(token, style) {
3545
+ token.setStyle(style);
3546
+ $setCodeHighlightFormat(token, true);
3363
3547
  }
3364
3548
 
3365
3549
  function $buildChildRanges(codeNode) {
@@ -3448,14 +3632,10 @@ function $patchCodeHighlightStyles(editor, selection, patch) {
3448
3632
  textSize: node.getTextContentSize()
3449
3633
  }));
3450
3634
 
3451
- // Use skipTransforms to prevent the code highlighting system from
3452
- // re-tokenizing and wiping out the style changes we apply.
3453
- // Use discrete to force a synchronous commit, ensuring the changes
3454
- // are committed before editor.focus() triggers a second update cycle
3455
- // that would re-run transforms and wipe out the styles.
3635
+ // Use skipTransforms so the retokenizer doesn't rebuild the block in the
3636
+ // middle of the toggle, and discrete to force a synchronous commit so the
3637
+ // styles are in place before editor.focus() triggers a second update cycle.
3456
3638
  editor.update(() => {
3457
- const affectedCodeNodes = new Set();
3458
-
3459
3639
  for (const { key, startOffset, endOffset, textSize } of nodeKeys) {
3460
3640
  const node = $getNodeByKey(key);
3461
3641
  if (!node) continue
@@ -3464,8 +3644,6 @@ function $patchCodeHighlightStyles(editor, selection, patch) {
3464
3644
  if (!$isCodeNode(parent)) continue
3465
3645
  if (startOffset === endOffset) continue
3466
3646
 
3467
- affectedCodeNodes.add(parent);
3468
-
3469
3647
  if (startOffset === 0 && endOffset === textSize) {
3470
3648
  $applyStylePatchToNode(node, patch);
3471
3649
  } else {
@@ -3474,17 +3652,6 @@ function $patchCodeHighlightStyles(editor, selection, patch) {
3474
3652
  $applyStylePatchToNode(targetNode, patch);
3475
3653
  }
3476
3654
  }
3477
-
3478
- // After applying styles, save highlight ranges for each affected CodeNode.
3479
- // The code retokenizer will replace the styled nodes with fresh unstyled
3480
- // tokens when transforms run. The pending highlights are picked up by the
3481
- // CodeNode mutation listener and reapplied after retokenization.
3482
- for (const codeNode of affectedCodeNodes) {
3483
- const ranges = $extractHighlightRangesFromCodeNode(codeNode);
3484
- if (ranges.length > 0) {
3485
- $getPendingHighlights(editor).set(codeNode.getKey(), ranges);
3486
- }
3487
- }
3488
3655
  }, { skipTransforms: true, discrete: true });
3489
3656
  }
3490
3657
 
@@ -3608,6 +3775,7 @@ const COMMANDS = [
3608
3775
  "setFormatHeadingMedium",
3609
3776
  "setFormatHeadingSmall",
3610
3777
  "setFormatParagraph",
3778
+ "applyHeadingFormat",
3611
3779
  "clearFormatting",
3612
3780
  "insertUnorderedList",
3613
3781
  "insertOrderedList",
@@ -3800,21 +3968,25 @@ class CommandDispatcher {
3800
3968
  }
3801
3969
 
3802
3970
  dispatchSetFormatHeadingLarge() {
3803
- this.contents.applyHeadingFormat("h2");
3971
+ this.#applyConfiguredHeadingFormat(0);
3804
3972
  }
3805
3973
 
3806
3974
  dispatchSetFormatHeadingMedium() {
3807
- this.contents.applyHeadingFormat("h3");
3975
+ this.#applyConfiguredHeadingFormat(1);
3808
3976
  }
3809
3977
 
3810
3978
  dispatchSetFormatHeadingSmall() {
3811
- this.contents.applyHeadingFormat("h4");
3979
+ this.#applyConfiguredHeadingFormat(2);
3812
3980
  }
3813
3981
 
3814
3982
  dispatchSetFormatParagraph() {
3815
3983
  this.contents.applyParagraphFormat();
3816
3984
  }
3817
3985
 
3986
+ dispatchApplyHeadingFormat(tag) {
3987
+ this.contents.applyHeadingFormat(tag);
3988
+ }
3989
+
3818
3990
  dispatchClearFormatting() {
3819
3991
  this.contents.clearFormatting();
3820
3992
  }
@@ -3863,6 +4035,11 @@ class CommandDispatcher {
3863
4035
  this.#listeners.dispose();
3864
4036
  }
3865
4037
 
4038
+ #applyConfiguredHeadingFormat(index) {
4039
+ const tag = this.editorElement.config.get("headings")[index];
4040
+ if (tag) this.contents.applyHeadingFormat(tag);
4041
+ }
4042
+
3866
4043
  #registerCommands() {
3867
4044
  for (const command of COMMANDS) {
3868
4045
  const methodName = `dispatch${capitalize(command)}`;
@@ -5433,11 +5610,11 @@ class ActionTextAttachmentUploadNode extends ActionTextAttachmentNode {
5433
5610
  }
5434
5611
 
5435
5612
  #forgetUploadRequest() {
5436
- this.#editorElement.uploadRequests.forget(this.getKey());
5613
+ this.#editorElement?.uploadRequests.forget(this.getKey());
5437
5614
  }
5438
5615
 
5439
5616
  #rememberUploadRequest(request) {
5440
- this.#editorElement.uploadRequests.track(this.getKey(), request);
5617
+ this.#editorElement?.uploadRequests.track(this.getKey(), request);
5441
5618
  }
5442
5619
 
5443
5620
  get #editorElement() {
@@ -5743,6 +5920,7 @@ class PastedContentFormatter {
5743
5920
  }
5744
5921
 
5745
5922
  format() {
5923
+ this.#stripStyleElements();
5746
5924
  this.#unwrapPlaceholderAnchors();
5747
5925
  this.#stripTableCellColorStyles();
5748
5926
  this.#nestStrayListChildren();
@@ -5750,6 +5928,18 @@ class PastedContentFormatter {
5750
5928
  return this.doc
5751
5929
  }
5752
5930
 
5931
+ // Spreadsheets (e.g. Excel) copy a <style> block whose rules (td { color:
5932
+ // black }, .xlNN { ... }) cascade onto the imported text. That color rides
5933
+ // in through the cascade rather than an inline style, so it slips past both
5934
+ // the cell-level stripping below and the paste style canonicalizer, leaving
5935
+ // pasted tables with foreign text colors that don't adapt to the theme. Drop
5936
+ // foreign style sheets so nothing cascades into imported content.
5937
+ #stripStyleElements() {
5938
+ for (const style of this.doc.querySelectorAll("style")) {
5939
+ style.remove();
5940
+ }
5941
+ }
5942
+
5753
5943
  // Anchors with non-meaningful hrefs (e.g. "#", "") appear in content copied
5754
5944
  // from rendered views where mentions and interactive elements are wrapped in
5755
5945
  // <a href="#"> tags. Unwrap them so their text content pastes as plain text
@@ -6379,17 +6569,15 @@ class Contents {
6379
6569
  return { anchorNode, offset: anchor.offset }
6380
6570
  }
6381
6571
 
6382
- // The replaced span can straddle the cursor (e.g. "@Jack" when "@" was just
6383
- // inserted before "Jack"), so we anchor on the trigger before the cursor and
6384
- // verify the whole string matches there rather than searching text up to it.
6572
+ // The replaced span must start before the cursor but can extend past it
6573
+ // (e.g. "@Jack" when "@" was just inserted before "Jack"), so we bound the
6574
+ // match's start with lastIndexOf's fromIndex rather than slicing the text.
6385
6575
  #findReplacementStart(anchorNode, offset, stringToReplace) {
6386
- const fullText = anchorNode.getTextContent();
6387
- const triggerIndex = fullText.slice(0, offset).lastIndexOf(stringToReplace[0]);
6388
-
6389
- if (triggerIndex !== -1 && fullText.startsWith(stringToReplace, triggerIndex)) {
6390
- return triggerIndex
6391
- } else {
6576
+ if (offset === 0) {
6577
+ // A negative fromIndex clamps to 0 and could match at the cursor
6392
6578
  return -1
6579
+ } else {
6580
+ return anchorNode.getTextContent().lastIndexOf(stringToReplace, offset - 1)
6393
6581
  }
6394
6582
  }
6395
6583
 
@@ -7108,7 +7296,7 @@ class CodeHighlightingExtension extends LexxyExtension {
7108
7296
  return defineExtension({
7109
7297
  name: "lexxy/code-highlighting",
7110
7298
  register(editor) {
7111
- return registerCodeHighlighting(editor)
7299
+ return registerCodeHighlighting(editor, buildHighlightPreservingTokenizer(editor))
7112
7300
  }
7113
7301
  })
7114
7302
  }
@@ -9276,11 +9464,14 @@ class LexicalEditorElement extends HTMLElement {
9276
9464
  get #supportedHeadingFormats() {
9277
9465
  if (!this.supportsRichText) return []
9278
9466
 
9467
+ const headings = this.config.get("headings");
9279
9468
  return [
9280
9469
  { label: "Normal", command: "setFormatParagraph", tag: null },
9281
- { label: "Large heading", command: "setFormatHeadingLarge", tag: "h2" },
9282
- { label: "Medium heading", command: "setFormatHeadingMedium", tag: "h3" },
9283
- { label: "Small heading", command: "setFormatHeadingSmall", tag: "h4" },
9470
+ ...headings.map((tag, index) => ({
9471
+ label: HeadingDropdown.labelFor(tag, index),
9472
+ command: HeadingDropdown.commandFor(index),
9473
+ tag
9474
+ }))
9284
9475
  ]
9285
9476
  }
9286
9477
 
@@ -10830,13 +11021,15 @@ class TableTools extends HTMLElement {
10830
11021
  }
10831
11022
 
10832
11023
  #registerKeyboardShortcuts() {
10833
- this.#listeners.track(this.#editor.registerCommand(KEY_DOWN_COMMAND, this.#focusToolbarOnShortcut, COMMAND_PRIORITY_HIGH));
11024
+ this.#listeners.track(this.#editor.registerCommand(KEY_DOWN_COMMAND, this.#focusToolbarOnAltF10, COMMAND_PRIORITY_HIGH));
10834
11025
  }
10835
11026
 
10836
- #focusToolbarOnShortcut = (event) => {
10837
- if (this.#hasSelectedTable && this.#isFocusToolbarShortcut(event)) {
11027
+ #focusToolbarOnAltF10 = (event) => {
11028
+ if (this.#hasSelectedTable && event.altKey && event.key === "F10") {
10838
11029
  event.preventDefault();
10839
- this.#tableToolsButtons[0]?.focus();
11030
+ // Ask for the ring explicitly: a programmatic focus coming from a mouse-focused
11031
+ // editor otherwise inherits the mouse modality and paints no focus ring.
11032
+ this.#tableToolsButtons[0]?.focus({ focusVisible: true });
10840
11033
  return true
10841
11034
  } else {
10842
11035
  return false
@@ -10847,11 +11040,6 @@ class TableTools extends HTMLElement {
10847
11040
  return this.tableController?.currentTableNodeKey != null
10848
11041
  }
10849
11042
 
10850
- #isFocusToolbarShortcut(event) {
10851
- if (event.key !== "F10") return false
10852
- return event.altKey || ((event.ctrlKey || event.metaKey) && event.shiftKey)
10853
- }
10854
-
10855
11043
  #handleToolsKeydown = (event) => {
10856
11044
  if (event.key === "Escape") {
10857
11045
  this.#handleEscapeKey();
@@ -11020,6 +11208,7 @@ function defineElements() {
11020
11208
  // Toolbar must be registered BEFORE Editor
11021
11209
  "lexxy-toolbar": LexicalToolbarElement,
11022
11210
  "lexxy-toolbar-dropdown": ToolbarDropdown,
11211
+ "lexxy-heading-dropdown": HeadingDropdown,
11023
11212
  "lexxy-highlight-dropdown": HighlightDropdown,
11024
11213
  "lexxy-link-dropdown": LinkDropdown,
11025
11214
 
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@37signals/lexxy",
3
- "version": "0.9.23",
3
+ "version": "0.9.25",
4
4
  "description": "Lexxy - A modern rich text editor for Rails.",
5
5
  "module": "dist/lexxy.esm.js",
6
6
  "type": "module",