@37signals/lexxy 0.9.3-beta → 0.9.5-beta

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,119 +1,33 @@
1
- import 'prismjs';
2
- import 'prismjs/components/prism-clike';
3
- import 'prismjs/components/prism-markup';
4
- import 'prismjs/components/prism-markup-templating';
5
- import 'prismjs/components/prism-ruby';
6
- import 'prismjs/components/prism-php';
7
- import 'prismjs/components/prism-go';
8
- import 'prismjs/components/prism-bash';
9
- import 'prismjs/components/prism-json';
10
- import 'prismjs/components/prism-diff';
1
+ import { createElement, extractPlainTextFromHtml, createAttachmentFigure, isPreviewableImage, dispatch, parseHtml, addBlockSpacing, generateDomId } from './lexxy_helpers.esm.js';
2
+ export { highlightCode } from './lexxy_helpers.esm.js';
11
3
  import DOMPurify from 'dompurify';
12
- import { getStyleObjectFromCSS, getCSSFromStyleObject, $isAtNodeEnd, $getSelectionStyleValueForProperty, $patchStyleText, $setBlocksType } from '@lexical/selection';
13
- import { SKIP_DOM_SELECTION_TAG, $getSelection, $isRangeSelection, $getNodeByKey, $isTextNode, $createRangeSelection, $setSelection, DecoratorNode, $createTextNode, HISTORY_MERGE_TAG, SKIP_SCROLL_INTO_VIEW_TAG, $createNodeSelection, $isDecoratorNode, $isLineBreakNode, $isElementNode, $createParagraphNode, TextNode, createCommand, createState, defineExtension, COMMAND_PRIORITY_NORMAL, $getState, $setState, $hasUpdateTag, PASTE_TAG, FORMAT_TEXT_COMMAND, UNDO_COMMAND, REDO_COMMAND, PASTE_COMMAND, COMMAND_PRIORITY_LOW, KEY_ARROW_RIGHT_COMMAND, KEY_TAB_COMMAND, OUTDENT_CONTENT_COMMAND, INDENT_CONTENT_COMMAND, $getEditor, $getNearestRootOrShadowRoot, $isNodeSelection, $getRoot, mergeRegister as mergeRegister$1, KEY_ARROW_LEFT_COMMAND, KEY_ARROW_UP_COMMAND, KEY_ARROW_DOWN_COMMAND, DELETE_CHARACTER_COMMAND, SELECTION_CHANGE_COMMAND, CLICK_COMMAND, isDOMNode, $getNearestNodeFromDOMNode, $isRootOrShadowRoot, ElementNode, $splitNode, $isParagraphNode, $createLineBreakNode, $isRootNode, ParagraphNode, RootNode, COMMAND_PRIORITY_HIGH, DRAGSTART_COMMAND, DROP_COMMAND, INSERT_PARAGRAPH_COMMAND, CLEAR_HISTORY_COMMAND, $addUpdateTag, KEY_ENTER_COMMAND, COMMAND_PRIORITY_CRITICAL, KEY_SPACE_COMMAND, KEY_BACKSPACE_COMMAND, KEY_DOWN_COMMAND } from 'lexical';
4
+ import { getStyleObjectFromCSS, getCSSFromStyleObject, $isAtNodeEnd, $getSelectionStyleValueForProperty, $patchStyleText, $setBlocksType, $forEachSelectedTextNode, $ensureForwardRangeSelection } from '@lexical/selection';
5
+ import { SKIP_DOM_SELECTION_TAG, $getSelection, $isRangeSelection, DecoratorNode, $createParagraphNode, $getNodeByKey, $isTextNode, $createRangeSelection, $setSelection, $createTextNode, HISTORY_MERGE_TAG, SKIP_SCROLL_INTO_VIEW_TAG, $createNodeSelection, $isDecoratorNode, $isLineBreakNode, $isElementNode, TextNode, createCommand, createState, defineExtension, COMMAND_PRIORITY_NORMAL, $getState, $setState, $hasUpdateTag, PASTE_TAG, FORMAT_TEXT_COMMAND, UNDO_COMMAND, REDO_COMMAND, PASTE_COMMAND, COMMAND_PRIORITY_LOW, KEY_ARROW_RIGHT_COMMAND, KEY_TAB_COMMAND, OUTDENT_CONTENT_COMMAND, INDENT_CONTENT_COMMAND, $getEditor, $getNearestRootOrShadowRoot, $isNodeSelection, $getRoot, KEY_ARROW_LEFT_COMMAND, KEY_ARROW_UP_COMMAND, KEY_ARROW_DOWN_COMMAND, DELETE_CHARACTER_COMMAND, SELECTION_CHANGE_COMMAND, CLICK_COMMAND, isDOMNode, $getNearestNodeFromDOMNode, ParagraphNode, $isRootOrShadowRoot, ElementNode, $splitNode, $isParagraphNode, $createLineBreakNode, $isRootNode, $getChildCaretAtIndex, RootNode, COMMAND_PRIORITY_HIGH, DRAGSTART_COMMAND, DROP_COMMAND, INSERT_PARAGRAPH_COMMAND, CLEAR_HISTORY_COMMAND, $addUpdateTag, KEY_ENTER_COMMAND, COMMAND_PRIORITY_CRITICAL, KEY_SPACE_COMMAND, KEY_BACKSPACE_COMMAND, KEY_DOWN_COMMAND } from 'lexical';
14
6
  import { buildEditorFromExtensions } from '@lexical/extension';
15
- import { ListNode, INSERT_UNORDERED_LIST_COMMAND, INSERT_ORDERED_LIST_COMMAND, ListItemNode, $getListDepth, $isListItemNode, $isListNode, registerList } from '@lexical/list';
7
+ import { ListNode, ListItemNode, $getListDepth, INSERT_UNORDERED_LIST_COMMAND, INSERT_ORDERED_LIST_COMMAND, $isListItemNode, $isListNode, registerList } from '@lexical/list';
16
8
  import { $createAutoLinkNode, $toggleLink, LinkNode, $createLinkNode, AutoLinkNode, $isLinkNode } from '@lexical/link';
17
- import { $getNearestNodeOfType, $wrapNodeInElement, $lastToFirstIterator, mergeRegister, $insertFirst, $unwrapAndFilterDescendants, $firstToLastIterator, $descendantsMatching } from '@lexical/utils';
9
+ import { $getNearestNodeOfType, $wrapNodeInElement, $lastToFirstIterator, mergeRegister, $insertFirst, $unwrapAndFilterDescendants, $firstToLastIterator, $getNearestBlockElementAncestorOrThrow, $descendantsMatching } from '@lexical/utils';
18
10
  import { registerPlainText } from '@lexical/plain-text';
19
11
  import { RichTextExtension, $isQuoteNode, $isHeadingNode, $createHeadingNode, $createQuoteNode, QuoteNode, HeadingNode, registerRichText } from '@lexical/rich-text';
20
12
  import { $generateNodesFromDOM, $generateHtmlFromNodes } from '@lexical/html';
21
13
  import { $isCodeNode, CodeHighlightNode, CodeNode, $createCodeNode, $isCodeHighlightNode, $createCodeHighlightNode, normalizeCodeLang, registerCodeHighlighting, CODE_LANGUAGE_FRIENDLY_NAME_MAP } from '@lexical/code';
22
- import { registerMarkdownShortcuts, TRANSFORMERS } from '@lexical/markdown';
14
+ import { TRANSFORMERS, registerMarkdownShortcuts } from '@lexical/markdown';
23
15
  import { createEmptyHistoryState, registerHistory } from '@lexical/history';
24
- import { createElement, extractPlainTextFromHtml, createAttachmentFigure, isPreviewableImage, dispatch, parseHtml, addBlockSpacing, generateDomId } from './lexxy_helpers.esm.js';
25
- export { highlightCode as highlightAll, highlightCode } from './lexxy_helpers.esm.js';
26
16
  import { INSERT_TABLE_COMMAND, $getTableCellNodeFromLexicalNode, TableCellNode, TableNode, TableRowNode, setScrollableTablesActive, registerTablePlugin, registerTableSelectionObserver, TableCellHeaderStates, $insertTableRowAtSelection, $insertTableColumnAtSelection, $deleteTableRowAtSelection, $deleteTableColumnAtSelection, $findTableNode, $getTableRowIndexFromTableCellNode, $getTableColumnIndexFromTableCellNode, $findCellNode, $getElementForTableNode } from '@lexical/table';
27
17
  import { marked } from 'marked';
28
18
  import { $insertDataTransferForRichText } from '@lexical/clipboard';
19
+ import 'prismjs';
20
+ import 'prismjs/components/prism-clike';
21
+ import 'prismjs/components/prism-markup';
22
+ import 'prismjs/components/prism-markup-templating';
23
+ import 'prismjs/components/prism-ruby';
24
+ import 'prismjs/components/prism-php';
25
+ import 'prismjs/components/prism-go';
26
+ import 'prismjs/components/prism-bash';
27
+ import 'prismjs/components/prism-json';
28
+ import 'prismjs/components/prism-diff';
29
29
 
30
- // Configure Prism for manual highlighting mode
31
- // This must be set before importing prismjs
32
- window.Prism = window.Prism || {};
33
- window.Prism.manual = true;
34
-
35
- function deepMerge(target, source) {
36
- const result = { ...target, ...source };
37
- for (const [ key, value ] of Object.entries(source)) {
38
- if (arePlainHashes(target[key], value)) {
39
- result[key] = deepMerge(target[key], value);
40
- }
41
- }
42
-
43
- return result
44
- }
45
-
46
- function arePlainHashes(...values) {
47
- return values.every(value => value && value.constructor == Object)
48
- }
49
-
50
- class Configuration {
51
- #tree = {}
52
-
53
- constructor(...configs) {
54
- this.merge(...configs);
55
- }
56
-
57
- merge(...configs) {
58
- return this.#tree = configs.reduce(deepMerge, this.#tree)
59
- }
60
-
61
- get(path) {
62
- const keys = path.split(".");
63
- return keys.reduce((node, key) => node[key], this.#tree)
64
- }
65
- }
66
-
67
- function range(from, to) {
68
- return [ ...Array(1 + to - from).keys() ].map(i => i + from)
69
- }
70
-
71
- const global = new Configuration({
72
- attachmentTagName: "action-text-attachment",
73
- attachmentContentTypeNamespace: "actiontext",
74
- authenticatedUploads: false,
75
- extensions: []
76
- });
77
-
78
- const presets = new Configuration({
79
- default: {
80
- attachments: true,
81
- markdown: true,
82
- multiLine: true,
83
- richText: true,
84
- toolbar: {
85
- upload: "both"
86
- },
87
- highlight: {
88
- buttons: {
89
- color: range(1, 9).map(n => `var(--highlight-${n})`),
90
- "background-color": range(1, 9).map(n => `var(--highlight-bg-${n})`),
91
- },
92
- permit: {
93
- color: [],
94
- "background-color": []
95
- }
96
- }
97
- }
98
- });
99
-
100
- var Lexxy = {
101
- global,
102
- presets,
103
- configure({ global: newGlobal, ...newPresets }) {
104
- if (newGlobal) {
105
- global.merge(newGlobal);
106
- }
107
- presets.merge(newPresets);
108
- }
109
- };
110
-
111
- const ALLOWED_HTML_TAGS = [ "a", "b", "blockquote", "br", "code", "div", "em",
112
- "figcaption", "figure", "h1", "h2", "h3", "h4", "h5", "h6", "hr", "i", "img", "li", "mark", "ol", "p", "pre", "q", "s", "strong", "u", "ul", "table", "tbody", "tr", "th", "td" ];
113
-
114
- const ALLOWED_HTML_ATTRIBUTES = [ "alt", "caption", "class", "content", "content-type", "contenteditable",
115
- "data-direct-upload-id", "data-sgid", "filename", "filesize", "height", "href", "presentation",
116
- "previewable", "sgid", "src", "style", "title", "url", "width" ];
30
+ const ALLOWED_HTML_ATTRIBUTES = [ "class", "contenteditable", "href", "src", "style", "title" ];
117
31
 
118
32
  const ALLOWED_STYLE_PROPERTIES = [ "color", "background-color" ];
119
33
 
@@ -144,10 +58,22 @@ DOMPurify.addHook("uponSanitizeElement", (node, data) => {
144
58
  }
145
59
  });
146
60
 
147
- function buildConfig() {
61
+ function buildConfig(allowedElements) {
62
+ const tagAttributes = {};
63
+
64
+ for (const element of allowedElements) {
65
+ if (typeof element === "string") {
66
+ tagAttributes[element] ||= [];
67
+ } else {
68
+ tagAttributes[element.tag] ||= [];
69
+ tagAttributes[element.tag].push(...element.attributes);
70
+ }
71
+ }
72
+
148
73
  return {
149
- ALLOWED_TAGS: ALLOWED_HTML_TAGS.concat(Lexxy.global.get("attachmentTagName")),
74
+ ALLOWED_TAGS: Object.keys(tagAttributes),
150
75
  ALLOWED_ATTR: ALLOWED_HTML_ATTRIBUTES,
76
+ ADD_ATTR: (attribute, tag) => tagAttributes[tag]?.includes(attribute),
151
77
  ADD_URI_SAFE_ATTR: [ "caption", "filename" ],
152
78
  SAFE_FOR_XML: false // So that it does not strip attributes that contains serialized HTML (like content)
153
79
  }
@@ -158,6 +84,34 @@ function getNonce() {
158
84
  return element?.content
159
85
  }
160
86
 
87
+ // Register an event listener with a return function to deregister the listener. Both the element and
88
+ // the listener are WeakRefs so neither is pinned in memory by the deregister function.
89
+ function registerEventListener(element, type, listener, options) {
90
+ element.addEventListener(type, listener, options);
91
+ const elementRef = new WeakRef(element);
92
+ const listenerRef = new WeakRef(listener);
93
+
94
+ return function deregisterListener() {
95
+ const listener = listenerRef.deref();
96
+ if (listener) elementRef.deref()?.removeEventListener(type, listener, options);
97
+ }
98
+ }
99
+
100
+ class ListenerBin {
101
+ #listeners = []
102
+
103
+ track(...listeners) {
104
+ this.#listeners.push(...listeners);
105
+ }
106
+
107
+ dispose() {
108
+ while (this.#listeners.length) {
109
+ const teardown = this.#listeners.pop();
110
+ teardown();
111
+ }
112
+ }
113
+ }
114
+
161
115
  function handleRollingTabIndex(elements, event) {
162
116
  const previousActiveElement = document.activeElement;
163
117
 
@@ -293,6 +247,11 @@ var ToolbarIcons = {
293
247
  <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"/>
294
248
  </svg>`,
295
249
 
250
+ "clearFormatting":
251
+ `<svg viewBox="0 0 18 18" xmlns="http://www.w3.org/2000/svg">
252
+ <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"/>
253
+ </svg>`,
254
+
296
255
  "highlight":
297
256
  `<svg viewBox="0 0 18 18" xmlns="http://www.w3.org/2000/svg">
298
257
  <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"/>
@@ -366,6 +325,7 @@ var ToolbarIcons = {
366
325
 
367
326
  class LexicalToolbarElement extends HTMLElement {
368
327
  static observedAttributes = [ "connected" ]
328
+ #listeners = new ListenerBin()
369
329
 
370
330
  constructor() {
371
331
  super();
@@ -386,12 +346,7 @@ class LexicalToolbarElement extends HTMLElement {
386
346
  }
387
347
 
388
348
  dispose() {
389
- this.#uninstallResizeObserver();
390
- this.#unbindButtons();
391
- this.#unbindHotkeys();
392
- this.#unbindFocusListeners();
393
- this.unregisterSelectionListener?.();
394
- this.unregisterHistoryListener?.();
349
+ this.#listeners.dispose();
395
350
 
396
351
  this.editorElement = null;
397
352
  this.editor = null;
@@ -450,23 +405,13 @@ class LexicalToolbarElement extends HTMLElement {
450
405
  }
451
406
 
452
407
  #installResizeObserver() {
453
- this.resizeObserver = new ResizeObserver(() => this.#refreshToolbarOverflow());
454
- this.resizeObserver.observe(this);
455
- }
456
-
457
- #uninstallResizeObserver() {
458
- if (this.resizeObserver) {
459
- this.resizeObserver.disconnect();
460
- this.resizeObserver = null;
461
- }
408
+ const resizeObserver = new ResizeObserver(() => this.#refreshToolbarOverflow());
409
+ resizeObserver.observe(this);
410
+ this.#listeners.track(() => resizeObserver.disconnect());
462
411
  }
463
412
 
464
413
  #bindButtons() {
465
- this.addEventListener("click", this.#handleButtonClicked);
466
- }
467
-
468
- #unbindButtons() {
469
- this.removeEventListener("click", this.#handleButtonClicked);
414
+ this.#listeners.track(registerEventListener(this, "click", this.#handleButtonClicked));
470
415
  }
471
416
 
472
417
  #handleButtonClicked = (event) => {
@@ -491,11 +436,7 @@ class LexicalToolbarElement extends HTMLElement {
491
436
  }
492
437
 
493
438
  #bindHotkeys() {
494
- this.editorElement.addEventListener("keydown", this.#handleHotkey);
495
- }
496
-
497
- #unbindHotkeys() {
498
- this.editorElement?.removeEventListener("keydown", this.#handleHotkey);
439
+ this.#listeners.track(registerEventListener(this.editorElement, "keydown", this.#handleHotkey));
499
440
  }
500
441
 
501
442
  #handleHotkey = (event) => {
@@ -523,15 +464,11 @@ class LexicalToolbarElement extends HTMLElement {
523
464
  }
524
465
 
525
466
  #bindFocusListeners() {
526
- this.editorElement.addEventListener("lexxy:focus", this.#handleEditorFocus);
527
- this.editorElement.addEventListener("lexxy:blur", this.#handleEditorBlur);
528
- this.addEventListener("keydown", this.#handleKeydown);
529
- }
530
-
531
- #unbindFocusListeners() {
532
- this.editorElement?.removeEventListener("lexxy:focus", this.#handleEditorFocus);
533
- this.editorElement?.removeEventListener("lexxy:blur", this.#handleEditorBlur);
534
- this.removeEventListener("keydown", this.#handleKeydown);
467
+ this.#listeners.track(
468
+ registerEventListener(this.editorElement, "lexxy:focus", this.#handleEditorFocus),
469
+ registerEventListener(this.editorElement, "lexxy:blur", this.#handleEditorBlur),
470
+ registerEventListener(this, "keydown", this.#handleKeydown)
471
+ );
535
472
  }
536
473
 
537
474
  #handleEditorFocus = () => {
@@ -554,18 +491,18 @@ class LexicalToolbarElement extends HTMLElement {
554
491
  }
555
492
 
556
493
  #monitorSelectionChanges() {
557
- this.unregisterSelectionListener = this.editor.registerUpdateListener(() => {
494
+ this.#listeners.track(this.editor.registerUpdateListener(() => {
558
495
  this.editor.getEditorState().read(() => {
559
496
  this.#updateButtonStates();
560
497
  this.#closeDropdowns();
561
498
  });
562
- });
499
+ }));
563
500
  }
564
501
 
565
502
  #monitorHistoryChanges() {
566
- this.unregisterHistoryListener = this.editor.registerUpdateListener(() => {
503
+ this.#listeners.track(this.editor.registerUpdateListener(() => {
567
504
  this.#updateUndoRedoButtonStates();
568
- });
505
+ }));
569
506
  }
570
507
 
571
508
  #updateUndoRedoButtonStates() {
@@ -755,6 +692,10 @@ class LexicalToolbarElement extends HTMLElement {
755
692
  <button type="button" name="underline" data-command="underline" title="Underline">
756
693
  ${ToolbarIcons.underline} <span>Underline</span>
757
694
  </button>
695
+ <div class="lexxy-editor__toolbar-separator" role="separator"></div>
696
+ <button type="button" name="clear-formatting" data-command="clearFormatting" title="Clear formatting">
697
+ ${ToolbarIcons.clearFormatting} <span>Clear formatting</span>
698
+ </button>
758
699
  </div>
759
700
  </details>
760
701
 
@@ -773,13 +714,11 @@ class LexicalToolbarElement extends HTMLElement {
773
714
  ${ToolbarIcons.link}
774
715
  </summary>
775
716
  <lexxy-link-dropdown class="lexxy-editor__toolbar-dropdown-content">
776
- <form method="dialog">
777
- <input type="url" placeholder="Enter a URL…" class="input">
778
- <div class="lexxy-editor__toolbar-dropdown-actions">
779
- <button type="submit" class="lexxy-editor__toolbar-button" value="link">Link</button>
780
- <button type="button" class="lexxy-editor__toolbar-button" value="unlink">Unlink</button>
781
- </div>
782
- </form>
717
+ <input type="url" placeholder="Enter a URL…" class="input">
718
+ <div class="lexxy-editor__toolbar-dropdown-actions">
719
+ <button type="button" class="lexxy-editor__toolbar-button" value="link">Link</button>
720
+ <button type="button" class="lexxy-editor__toolbar-button" value="unlink">Unlink</button>
721
+ </div>
783
722
  </lexxy-link-dropdown>
784
723
  </details>
785
724
 
@@ -824,6 +763,96 @@ class LexicalToolbarElement extends HTMLElement {
824
763
  }
825
764
  }
826
765
 
766
+ class HorizontalDividerNode extends DecoratorNode {
767
+ static getType() {
768
+ return "horizontal_divider"
769
+ }
770
+
771
+ static clone(node) {
772
+ return new HorizontalDividerNode(node.__key)
773
+ }
774
+
775
+ static importJSON(serializedNode) {
776
+ return new HorizontalDividerNode()
777
+ }
778
+
779
+ static importDOM() {
780
+ return {
781
+ "hr": (hr) => {
782
+ return {
783
+ conversion: () => ({
784
+ node: new HorizontalDividerNode()
785
+ }),
786
+ priority: 1
787
+ }
788
+ }
789
+ }
790
+ }
791
+
792
+ constructor(key) {
793
+ super(key);
794
+ }
795
+
796
+ createDOM() {
797
+ const figure = createElement("figure", { className: "horizontal-divider" });
798
+ const hr = createElement("hr");
799
+
800
+ figure.appendChild(hr);
801
+
802
+ const deleteButton = createElement("lexxy-node-delete-button");
803
+ figure.appendChild(deleteButton);
804
+
805
+ return figure
806
+ }
807
+
808
+ updateDOM() {
809
+ return true
810
+ }
811
+
812
+ getTextContent() {
813
+ return "┄\n\n"
814
+ }
815
+
816
+ isInline() {
817
+ return false
818
+ }
819
+
820
+ exportDOM() {
821
+ const hr = createElement("hr");
822
+ return { element: hr }
823
+ }
824
+
825
+ exportJSON() {
826
+ return {
827
+ type: "horizontal_divider",
828
+ version: 1
829
+ }
830
+ }
831
+
832
+ decorate() {
833
+ return null
834
+ }
835
+ }
836
+
837
+ const HORIZONTAL_DIVIDER = {
838
+ dependencies: [ HorizontalDividerNode ],
839
+ export: (node) => {
840
+ return node instanceof HorizontalDividerNode ? "---" : null
841
+ },
842
+ regExpStart: /^-{3,}\s?$/,
843
+ replace: (parentNode, children, match, endMatch, linesInBetween, isImport) => {
844
+ const hrNode = new HorizontalDividerNode();
845
+ parentNode.replace(hrNode);
846
+
847
+ if (!isImport) {
848
+ const paragraph = $createParagraphNode();
849
+ hrNode.insertAfter(paragraph);
850
+ paragraph.select();
851
+ }
852
+ },
853
+ type: "multiline-element"
854
+ };
855
+
827
856
  const PUNCTUATION_OR_SPACE = /[^\w]/;
828
857
 
829
858
  // Supplements Lexical's built-in registerMarkdownShortcuts to handle the case
@@ -1053,75 +1082,92 @@ var theme = {
1053
1082
  }
1054
1083
  };
1055
1084
 
1056
- class HorizontalDividerNode extends DecoratorNode {
1057
- static getType() {
1058
- return "horizontal_divider"
1059
- }
1060
-
1061
- static clone(node) {
1062
- return new HorizontalDividerNode(node.__key)
1063
- }
1064
-
1065
- static importJSON(serializedNode) {
1066
- return new HorizontalDividerNode()
1067
- }
1068
-
1069
- static importDOM() {
1070
- return {
1071
- "hr": (hr) => {
1072
- return {
1073
- conversion: () => ({
1074
- node: new HorizontalDividerNode()
1075
- }),
1076
- priority: 1
1077
- }
1078
- }
1085
+ function deepMerge(target, source) {
1086
+ const result = { ...target, ...source };
1087
+ for (const [ key, value ] of Object.entries(source)) {
1088
+ if (arePlainHashes(target[key], value)) {
1089
+ result[key] = deepMerge(target[key], value);
1079
1090
  }
1080
1091
  }
1081
1092
 
1082
- constructor(key) {
1083
- super(key);
1084
- }
1085
-
1086
- createDOM() {
1087
- const figure = createElement("figure", { className: "horizontal-divider" });
1088
- const hr = createElement("hr");
1093
+ return result
1094
+ }
1089
1095
 
1090
- figure.appendChild(hr);
1096
+ function arePlainHashes(...values) {
1097
+ return values.every(value => value && value.constructor == Object)
1098
+ }
1091
1099
 
1092
- const deleteButton = createElement("lexxy-node-delete-button");
1093
- figure.appendChild(deleteButton);
1100
+ class Configuration {
1101
+ #tree = {}
1094
1102
 
1095
- return figure
1103
+ constructor(...configs) {
1104
+ this.merge(...configs);
1096
1105
  }
1097
1106
 
1098
- updateDOM() {
1099
- return true
1107
+ merge(...configs) {
1108
+ return this.#tree = configs.reduce(deepMerge, this.#tree)
1100
1109
  }
1101
1110
 
1102
- getTextContent() {
1103
- return "┄\n\n"
1111
+ get(path) {
1112
+ const keys = path.split(".");
1113
+ return keys.reduce((node, key) => node[key], this.#tree)
1104
1114
  }
1115
+ }
1105
1116
 
1106
- isInline() {
1107
- return false
1108
- }
1117
+ function range(from, to) {
1118
+ return [ ...Array(1 + to - from).keys() ].map(i => i + from)
1119
+ }
1109
1120
 
1110
- exportDOM() {
1111
- const hr = createElement("hr");
1112
- return { element: hr }
1113
- }
1121
+ const global = new Configuration({
1122
+ attachmentTagName: "action-text-attachment",
1123
+ attachmentContentTypeNamespace: "actiontext",
1124
+ authenticatedUploads: false,
1125
+ extensions: []
1126
+ });
1114
1127
 
1115
- exportJSON() {
1116
- return {
1117
- type: "horizontal_divider",
1118
- version: 1
1128
+ const presets = new Configuration({
1129
+ default: {
1130
+ attachments: true,
1131
+ markdown: true,
1132
+ multiLine: true,
1133
+ richText: true,
1134
+ toolbar: {
1135
+ upload: "both"
1136
+ },
1137
+ highlight: {
1138
+ buttons: {
1139
+ color: range(1, 9).map(n => `var(--highlight-${n})`),
1140
+ "background-color": range(1, 9).map(n => `var(--highlight-bg-${n})`),
1141
+ },
1142
+ permit: {
1143
+ color: [],
1144
+ "background-color": []
1145
+ }
1119
1146
  }
1120
1147
  }
1148
+ });
1121
1149
 
1122
- decorate() {
1123
- return null
1150
+ var Lexxy = {
1151
+ global,
1152
+ presets,
1153
+ configure({ global: newGlobal, ...newPresets }) {
1154
+ if (newGlobal) {
1155
+ global.merge(newGlobal);
1156
+ }
1157
+ presets.merge(newPresets);
1124
1158
  }
1159
+ };
1160
+
1161
+ function sanitize(html, allowedElements) {
1162
+ return DOMPurify.sanitize(html, buildConfig(allowedElements))
1163
+ }
1164
+
1165
+ // Sanitize HTML for custom attachment content (mentions, cards, etc.).
1166
+ // Uses DOMPurify defaults to strip XSS vectors (scripts, event handlers)
1167
+ // while preserving the richer tag set that server-rendered attachment
1168
+ // content legitimately uses (e.g. <span>, <div>, <img>).
1169
+ function sanitizeAttachmentContent(html) {
1170
+ return DOMPurify.sanitize(html)
1125
1171
  }
1126
1172
 
1127
1173
  function bytesToHumanSize(bytes) {
@@ -1216,7 +1262,7 @@ class CustomActionTextAttachmentNode extends DecoratorNode {
1216
1262
  createDOM() {
1217
1263
  const figure = createElement(this.tagName, { "content-type": this.contentType, "data-lexxy-decorator": true });
1218
1264
 
1219
- figure.insertAdjacentHTML("beforeend", this.innerHtml);
1265
+ figure.insertAdjacentHTML("beforeend", sanitizeAttachmentContent(this.innerHtml));
1220
1266
 
1221
1267
  const deleteButton = createElement("lexxy-node-delete-button");
1222
1268
  figure.appendChild(deleteButton);
@@ -1514,6 +1560,10 @@ class LexxyExtension {
1514
1560
  return null
1515
1561
  }
1516
1562
 
1563
+ get allowedElements() {
1564
+ return []
1565
+ }
1566
+
1517
1567
  initializeToolbar(_lexxyToolbar) {
1518
1568
 
1519
1569
  }
@@ -1736,6 +1786,13 @@ function $applyHighlightRangesToCodeNode(codeNode, highlights) {
1736
1786
  const childRanges = $buildChildRanges(codeNode);
1737
1787
 
1738
1788
  for (const { node, start: nodeStart, end: nodeEnd } of childRanges) {
1789
+ // Skip plain TextNodes: only CodeHighlightNodes can be split into
1790
+ // styled replacements here. The retokenizer normally converts any
1791
+ // TextNode children back to CodeHighlightNodes before this runs,
1792
+ // but the iteration over $buildChildRanges has to keep counting
1793
+ // them so character offsets stay aligned with the saved ranges.
1794
+ if (!$isCodeHighlightNode(node)) continue
1795
+
1739
1796
  // Check if this child overlaps with the highlight range
1740
1797
  const overlapStart = Math.max(hlStart, nodeStart);
1741
1798
  const overlapEnd = Math.min(hlEnd, nodeEnd);
@@ -1784,7 +1841,7 @@ function $buildChildRanges(codeNode) {
1784
1841
  let charOffset = 0;
1785
1842
 
1786
1843
  for (const child of codeNode.getChildren()) {
1787
- if ($isCodeHighlightNode(child)) {
1844
+ if ($isCodeHighlightNode(child) || $isTextNode(child)) {
1788
1845
  const text = child.getTextContent();
1789
1846
  childRanges.push({ node: child, start: charOffset, end: charOffset + text.length });
1790
1847
  charOffset += text.length;
@@ -1797,6 +1854,23 @@ function $buildChildRanges(codeNode) {
1797
1854
  return childRanges
1798
1855
  }
1799
1856
 
1857
+ // Extract highlight ranges from the Lexical node tree of a CodeNode.
1858
+ // This mirrors extractHighlightRanges (which works on DOM elements during
1859
+ // HTML import) but reads from live CodeHighlightNode children instead.
1860
+ function $extractHighlightRangesFromCodeNode(codeNode) {
1861
+ const ranges = [];
1862
+ const childRanges = $buildChildRanges(codeNode);
1863
+
1864
+ for (const { node, start, end } of childRanges) {
1865
+ const style = node.getStyle();
1866
+ if (style && hasHighlightStyles(style)) {
1867
+ ranges.push({ start, end, style });
1868
+ }
1869
+ }
1870
+
1871
+ return ranges
1872
+ }
1873
+
1800
1874
  function buildCanonicalizers(config) {
1801
1875
  return [
1802
1876
  new StyleCanonicalizer("color", [ ...config.buttons.color, ...config.permit.color ]),
@@ -1824,15 +1898,23 @@ function $toggleSelectionStyles(editor, styles) {
1824
1898
  function $selectionIsInCodeBlock(selection) {
1825
1899
  const nodes = selection.getNodes();
1826
1900
  return nodes.some((node) => {
1827
- const parent = $isCodeHighlightNode(node) ? node.getParent() : node;
1828
- return $isCodeNode(parent)
1901
+ // A text node inside a code block may be either a CodeHighlightNode
1902
+ // (after retokenization) or a plain TextNode (after splitText or before
1903
+ // the retokenizer has run). Check the parent in both cases.
1904
+ if ($isCodeHighlightNode(node) || $isTextNode(node)) {
1905
+ return $isCodeNode(node.getParent())
1906
+ }
1907
+ return $isCodeNode(node)
1829
1908
  })
1830
1909
  }
1831
1910
 
1832
1911
  function $patchCodeHighlightStyles(editor, selection, patch) {
1833
- // Capture selection state and node keys before the nested update
1912
+ // Capture selection state and node keys before the nested update.
1913
+ // Accept both CodeHighlightNode and TextNode children of a CodeNode
1914
+ // because splitText creates TextNode instances and the retokenizer
1915
+ // may not have converted them back to CodeHighlightNodes yet.
1834
1916
  const nodeKeys = selection.getNodes()
1835
- .filter((node) => $isCodeHighlightNode(node))
1917
+ .filter((node) => ($isCodeHighlightNode(node) || $isTextNode(node)) && $isCodeNode(node.getParent()))
1836
1918
  .map((node) => ({
1837
1919
  key: node.getKey(),
1838
1920
  startOffset: $getNodeSelectionOffsets(node, selection)[0],
@@ -1846,14 +1928,18 @@ function $patchCodeHighlightStyles(editor, selection, patch) {
1846
1928
  // are committed before editor.focus() triggers a second update cycle
1847
1929
  // that would re-run transforms and wipe out the styles.
1848
1930
  editor.update(() => {
1931
+ const affectedCodeNodes = new Set();
1932
+
1849
1933
  for (const { key, startOffset, endOffset, textSize } of nodeKeys) {
1850
1934
  const node = $getNodeByKey(key);
1851
- if (!node || !$isCodeHighlightNode(node)) continue
1935
+ if (!node) continue
1852
1936
 
1853
1937
  const parent = node.getParent();
1854
1938
  if (!$isCodeNode(parent)) continue
1855
1939
  if (startOffset === endOffset) continue
1856
1940
 
1941
+ affectedCodeNodes.add(parent);
1942
+
1857
1943
  if (startOffset === 0 && endOffset === textSize) {
1858
1944
  $applyStylePatchToNode(node, patch);
1859
1945
  } else {
@@ -1862,6 +1948,17 @@ function $patchCodeHighlightStyles(editor, selection, patch) {
1862
1948
  $applyStylePatchToNode(targetNode, patch);
1863
1949
  }
1864
1950
  }
1951
+
1952
+ // After applying styles, save highlight ranges for each affected CodeNode.
1953
+ // The code retokenizer will replace the styled nodes with fresh unstyled
1954
+ // tokens when transforms run. The pending highlights are picked up by the
1955
+ // CodeNode mutation listener and reapplied after retokenization.
1956
+ for (const codeNode of affectedCodeNodes) {
1957
+ const ranges = $extractHighlightRangesFromCodeNode(codeNode);
1958
+ if (ranges.length > 0) {
1959
+ $getPendingHighlights(editor).set(codeNode.getKey(), ranges);
1960
+ }
1961
+ }
1865
1962
  }, { skipTransforms: true, discrete: true });
1866
1963
  }
1867
1964
 
@@ -1985,6 +2082,7 @@ const COMMANDS = [
1985
2082
  "setFormatHeadingMedium",
1986
2083
  "setFormatHeadingSmall",
1987
2084
  "setFormatParagraph",
2085
+ "clearFormatting",
1988
2086
  "insertUnorderedList",
1989
2087
  "insertOrderedList",
1990
2088
  "insertQuoteBlock",
@@ -2002,7 +2100,7 @@ const COMMANDS = [
2002
2100
 
2003
2101
  class CommandDispatcher {
2004
2102
  #selectionBeforeDrag = null
2005
- #unregister = []
2103
+ #listeners = new ListenerBin()
2006
2104
 
2007
2105
  static configureFor(editorElement) {
2008
2106
  return new CommandDispatcher(editorElement)
@@ -2084,7 +2182,7 @@ class CommandDispatcher {
2084
2182
  if (this.selection.isInsideList && anchorNode && getListType(anchorNode) === "bullet") {
2085
2183
  this.contents.applyParagraphFormat();
2086
2184
  } else {
2087
- this.editor.dispatchCommand(INSERT_UNORDERED_LIST_COMMAND, undefined);
2185
+ this.contents.applyUnorderedListFormat();
2088
2186
  }
2089
2187
  }
2090
2188
 
@@ -2097,7 +2195,7 @@ class CommandDispatcher {
2097
2195
  if (this.selection.isInsideList && anchorNode && getListType(anchorNode) === "number") {
2098
2196
  this.contents.applyParagraphFormat();
2099
2197
  } else {
2100
- this.editor.dispatchCommand(INSERT_ORDERED_LIST_COMMAND, undefined);
2198
+ this.contents.applyOrderedListFormat();
2101
2199
  }
2102
2200
  }
2103
2201
 
@@ -2195,6 +2293,10 @@ class CommandDispatcher {
2195
2293
  this.contents.applyParagraphFormat();
2196
2294
  }
2197
2295
 
2296
+ dispatchClearFormatting() {
2297
+ this.contents.clearFormatting();
2298
+ }
2299
+
2198
2300
  dispatchUploadImage() {
2199
2301
  this.#dispatchUploadAttachment("image/*,video/*");
2200
2302
  }
@@ -2236,10 +2338,7 @@ class CommandDispatcher {
2236
2338
  }
2237
2339
 
2238
2340
  dispose() {
2239
- while (this.#unregister.length) {
2240
- const unregister = this.#unregister.pop();
2241
- unregister();
2242
- }
2341
+ this.#listeners.dispose();
2243
2342
  }
2244
2343
 
2245
2344
  #registerCommands() {
@@ -2252,7 +2351,7 @@ class CommandDispatcher {
2252
2351
  }
2253
2352
 
2254
2353
  #registerCommandHandler(command, priority, handler) {
2255
- this.#unregister.push(this.editor.registerCommand(command, handler, priority));
2354
+ this.#listeners.track(this.editor.registerCommand(command, handler, priority));
2256
2355
  }
2257
2356
 
2258
2357
  #registerKeyboardCommands() {
@@ -2277,10 +2376,13 @@ class CommandDispatcher {
2277
2376
  #registerDragAndDropHandlers() {
2278
2377
  if (this.editorElement.supportsAttachments) {
2279
2378
  this.dragCounter = 0;
2280
- this.editor.getRootElement().addEventListener("dragover", this.#handleDragOver.bind(this));
2281
- this.editor.getRootElement().addEventListener("drop", this.#handleDrop.bind(this));
2282
- this.editor.getRootElement().addEventListener("dragenter", this.#handleDragEnter.bind(this));
2283
- this.editor.getRootElement().addEventListener("dragleave", this.#handleDragLeave.bind(this));
2379
+ const root = this.editor.getRootElement();
2380
+ this.#listeners.track(
2381
+ registerEventListener(root, "dragover", this.#handleDragOver.bind(this)),
2382
+ registerEventListener(root, "drop", this.#handleDrop.bind(this)),
2383
+ registerEventListener(root, "dragenter", this.#handleDragEnter.bind(this)),
2384
+ registerEventListener(root, "dragleave", this.#handleDragLeave.bind(this))
2385
+ );
2284
2386
  }
2285
2387
  }
2286
2388
 
@@ -2509,13 +2611,15 @@ class ActionTextAttachmentNode extends DecoratorNode {
2509
2611
  return Lexxy.global.get("attachmentTagName")
2510
2612
  }
2511
2613
 
2512
- constructor({ tagName, sgid, src, previewable, altText, caption, contentType, fileName, fileSize, width, height }, key) {
2614
+ constructor({ tagName, sgid, src, previewSrc, previewable, pendingPreview, altText, caption, contentType, fileName, fileSize, width, height, uploadError }, key) {
2513
2615
  super(key);
2514
2616
 
2515
2617
  this.tagName = tagName || ActionTextAttachmentNode.TAG_NAME;
2516
2618
  this.sgid = sgid;
2517
2619
  this.src = src;
2620
+ this.previewSrc = previewSrc;
2518
2621
  this.previewable = parseBoolean(previewable);
2622
+ this.pendingPreview = pendingPreview;
2519
2623
  this.altText = altText || "";
2520
2624
  this.caption = caption || "";
2521
2625
  this.contentType = contentType || "";
@@ -2523,16 +2627,23 @@ class ActionTextAttachmentNode extends DecoratorNode {
2523
2627
  this.fileSize = fileSize;
2524
2628
  this.width = width;
2525
2629
  this.height = height;
2630
+ this.uploadError = uploadError;
2526
2631
 
2527
2632
  this.editor = $getEditor();
2528
2633
  }
2529
2634
 
2530
2635
  createDOM() {
2636
+ if (this.uploadError) return this.createDOMForError()
2637
+ if (this.pendingPreview) return this.#createDOMForPendingPreview()
2638
+
2531
2639
  const figure = this.createAttachmentFigure();
2532
2640
 
2533
2641
  if (this.isPreviewableAttachment) {
2534
2642
  figure.appendChild(this.#createDOMForImage());
2535
2643
  figure.appendChild(this.#createEditableCaption());
2644
+ } else if (this.isVideo) {
2645
+ figure.appendChild(this.#createDOMForFile());
2646
+ figure.appendChild(this.#createEditableCaption());
2536
2647
  } else {
2537
2648
  figure.appendChild(this.#createDOMForFile());
2538
2649
  figure.appendChild(this.#createDOMForNotImage());
@@ -2541,7 +2652,9 @@ class ActionTextAttachmentNode extends DecoratorNode {
2541
2652
  return figure
2542
2653
  }
2543
2654
 
2544
- updateDOM(_prevNode, dom) {
2655
+ updateDOM(prevNode, dom) {
2656
+ if (this.uploadError !== prevNode.uploadError) return true
2657
+
2545
2658
  const caption = dom.querySelector("figcaption textarea");
2546
2659
  if (caption && this.caption) {
2547
2660
  caption.value = this.caption;
@@ -2598,6 +2711,13 @@ class ActionTextAttachmentNode extends DecoratorNode {
2598
2711
  return null
2599
2712
  }
2600
2713
 
2714
+ createDOMForError() {
2715
+ const figure = this.createAttachmentFigure();
2716
+ figure.classList.add("attachment--error");
2717
+ figure.appendChild(createElement("div", { innerText: `Error uploading ${this.fileName || "file"}` }));
2718
+ return figure
2719
+ }
2720
+
2601
2721
  createAttachmentFigure(previewable = this.isPreviewableAttachment) {
2602
2722
  const figure = createAttachmentFigure(this.contentType, previewable, this.fileName);
2603
2723
  figure.draggable = true;
@@ -2617,32 +2737,147 @@ class ActionTextAttachmentNode extends DecoratorNode {
2617
2737
  return isPreviewableImage(this.contentType)
2618
2738
  }
2619
2739
 
2740
+ get isVideo() {
2741
+ return this.contentType.startsWith("video/")
2742
+ }
2743
+
2744
+ #createDOMForPendingPreview() {
2745
+ const figure = this.createAttachmentFigure(false);
2746
+ figure.appendChild(this.#createDOMForFile());
2747
+ figure.appendChild(this.#createDOMForNotImage());
2748
+ this.#pollForPreview(figure);
2749
+ return figure
2750
+ }
2751
+
2620
2752
  #createDOMForImage(options = {}) {
2621
- const img = createElement("img", { src: this.src, draggable: false, alt: this.altText, ...this.#imageDimensions, ...options });
2753
+ const initialSrc = this.previewSrc || this.src;
2754
+ const img = createElement("img", { src: initialSrc, draggable: false, alt: this.altText, ...this.#imageDimensions, ...options });
2622
2755
 
2623
2756
  if (this.previewable && !this.isPreviewableImage) {
2624
2757
  img.onerror = () => this.#swapPreviewToFileDOM(img);
2625
2758
  }
2626
2759
 
2760
+ if (this.previewSrc) {
2761
+ this.#preloadAndSwapSrc(img);
2762
+ }
2763
+
2627
2764
  const container = createElement("div", { className: "attachment__container" });
2628
2765
  container.appendChild(img);
2629
2766
  return container
2630
2767
  }
2631
2768
 
2769
+ #preloadAndSwapSrc(img) {
2770
+ const previewSrc = this.previewSrc;
2771
+ const serverImage = new Image();
2772
+
2773
+ serverImage.onload = () => this.#handleImageLoaded(img, previewSrc);
2774
+ serverImage.onerror = () => this.#handleImageLoadError(previewSrc);
2775
+ serverImage.src = this.src;
2776
+ }
2777
+
2778
+ #handleImageLoaded(img, previewSrc) {
2779
+ img.src = this.src;
2780
+ this.editor.update(() => {
2781
+ if (this.isAttached()) this.getWritable().previewSrc = null;
2782
+ }, { tag: this.#backgroundUpdateTags });
2783
+ this.#revokePreviewSrc(previewSrc);
2784
+ }
2785
+
2786
+ #handleImageLoadError(previewSrc) {
2787
+ this.editor.update(() => {
2788
+ if (this.isAttached()) {
2789
+ this.getWritable().previewSrc = null;
2790
+ this.getWritable().uploadError = true;
2791
+ }
2792
+ }, { tag: this.#backgroundUpdateTags });
2793
+ this.#revokePreviewSrc(previewSrc);
2794
+ }
2795
+
2796
+ get #backgroundUpdateTags() {
2797
+ const rootElement = this.editor.getRootElement();
2798
+ const editorHasFocus = rootElement !== null && rootElement.contains(document.activeElement);
2799
+
2800
+ if (editorHasFocus) {
2801
+ return SILENT_UPDATE_TAGS
2802
+ } else {
2803
+ return [ ...SILENT_UPDATE_TAGS, SKIP_DOM_SELECTION_TAG ]
2804
+ }
2805
+ }
2806
+
2807
+ #revokePreviewSrc(previewSrc) {
2808
+ if (previewSrc?.startsWith("blob:")) URL.revokeObjectURL(previewSrc);
2809
+ }
2810
+
2632
2811
  #swapPreviewToFileDOM(img) {
2633
2812
  const figure = img.closest("figure.attachment");
2634
2813
  if (!figure) return
2635
2814
 
2636
- figure.className = figure.className.replace("attachment--preview", "attachment--file");
2815
+ this.#swapFigureContent(figure, "attachment--preview", "attachment--file", () => {
2816
+ figure.appendChild(this.#createDOMForFile());
2817
+ figure.appendChild(this.#createDOMForNotImage());
2818
+ });
2819
+ }
2820
+
2821
+ #pollForPreview(figure) {
2822
+ let attempt = 0;
2823
+ const maxAttempts = 10;
2637
2824
 
2638
- const container = figure.querySelector(".attachment__container");
2639
- if (container) container.remove();
2825
+ const tryLoad = () => {
2826
+ if (!this.editor.read(() => this.isAttached())) return
2640
2827
 
2641
- const caption = figure.querySelector("figcaption");
2642
- if (caption) caption.remove();
2828
+ const img = new Image();
2829
+ const cacheBustedSrc = `${this.src}${this.src.includes("?") ? "&" : "?"}_=${Date.now()}`;
2643
2830
 
2644
- figure.appendChild(this.#createDOMForFile());
2645
- figure.appendChild(this.#createDOMForNotImage());
2831
+ img.onload = () => {
2832
+ if (!this.editor.read(() => this.isAttached())) return
2833
+
2834
+ // The placeholder is a file-type icon SVG (86×100). A real thumbnail
2835
+ // generated from PDF/video content is significantly larger.
2836
+ if (img.naturalWidth > 150 && img.naturalHeight > 150) {
2837
+ this.#swapToPreviewDOM(figure, cacheBustedSrc);
2838
+ } else {
2839
+ retry();
2840
+ }
2841
+ };
2842
+ img.onerror = () => retry();
2843
+ img.src = cacheBustedSrc;
2844
+ };
2845
+
2846
+ const retry = () => {
2847
+ attempt++;
2848
+ if (attempt < maxAttempts && this.editor.read(() => this.isAttached())) {
2849
+ const delay = Math.min(2000 * Math.pow(1.5, attempt), 15000);
2850
+ setTimeout(tryLoad, delay);
2851
+ }
2852
+ };
2853
+
2854
+ // Give the server time to start processing before the first attempt
2855
+ setTimeout(tryLoad, 3000);
2856
+ }
2857
+
2858
+ #swapToPreviewDOM(figure, previewSrc) {
2859
+ this.#swapFigureContent(figure, "attachment--file", "attachment--preview", () => {
2860
+ const img = createElement("img", { src: previewSrc, draggable: false, alt: this.altText });
2861
+ img.onerror = () => this.#swapPreviewToFileDOM(img);
2862
+ const container = createElement("div", { className: "attachment__container" });
2863
+ container.appendChild(img);
2864
+ figure.appendChild(container);
2865
+ figure.appendChild(this.#createEditableCaption());
2866
+ });
2867
+
2868
+ this.editor.update(() => {
2869
+ if (this.isAttached()) this.getWritable().pendingPreview = false;
2870
+ }, { tag: this.#backgroundUpdateTags });
2871
+ }
2872
+
2873
+ #swapFigureContent(figure, fromClass, toClass, renderContent) {
2874
+ figure.className = figure.className.replace(fromClass, toClass);
2875
+
2876
+ for (const child of [ ...figure.querySelectorAll(".attachment__container, .attachment__icon, figcaption") ]) {
2877
+ child.remove();
2878
+ }
2879
+
2880
+ renderContent();
2646
2881
  }
2647
2882
 
2648
2883
  get #imageDimensions() {
@@ -2733,7 +2968,7 @@ function $isActionTextAttachmentNode(node) {
2733
2968
  }
2734
2969
 
2735
2970
  class Selection {
2736
- #unregister = []
2971
+ #listeners = new ListenerBin()
2737
2972
 
2738
2973
  constructor(editorElement) {
2739
2974
  this.editorElement = editorElement;
@@ -2884,6 +3119,15 @@ class Selection {
2884
3119
  const anchorElement = anchorNode.getTopLevelElement();
2885
3120
  if (!anchorElement) return false
2886
3121
 
3122
+ // When anchor and focus are in different block-level children of the same
3123
+ // top-level element (e.g. two paragraphs inside a blockquote), this is a
3124
+ // multi-line selection, not a single-line one.
3125
+ const anchorBlock = $isElementNode(anchorNode) ? anchorNode : anchorNode.getParent();
3126
+ const focusBlock = $isElementNode(focusNode) ? focusNode : focusNode.getParent();
3127
+ if (anchorBlock !== focusBlock && anchorBlock !== anchorElement) {
3128
+ return false
3129
+ }
3130
+
2887
3131
  const nodes = selection.getNodes();
2888
3132
  for (const node of nodes) {
2889
3133
  if ($isLineBreakNode(node)) {
@@ -2997,10 +3241,7 @@ class Selection {
2997
3241
  this.editor = null;
2998
3242
  this.previouslySelectedKeys = null;
2999
3243
 
3000
- while (this.#unregister.length) {
3001
- const unregister = this.#unregister.pop();
3002
- unregister();
3003
- }
3244
+ this.#listeners.dispose();
3004
3245
  }
3005
3246
 
3006
3247
  // When all inline code text is deleted, Lexical's selection retains the stale
@@ -3018,7 +3259,7 @@ class Selection {
3018
3259
  // detects that stale state and clears it so newly typed text won't be
3019
3260
  // code-formatted.
3020
3261
  #clearStaleInlineCodeFormat() {
3021
- this.#unregister.push(this.editor.registerUpdateListener(({ editorState, tags }) => {
3262
+ this.#listeners.track(this.editor.registerUpdateListener(({ editorState, tags }) => {
3022
3263
  if (tags.has("history-merge") || tags.has("skip-dom-selection")) return
3023
3264
 
3024
3265
  let isStale = false;
@@ -3066,7 +3307,7 @@ class Selection {
3066
3307
  }
3067
3308
 
3068
3309
  #processSelectionChangeCommands() {
3069
- this.#unregister.push(mergeRegister$1(
3310
+ this.#listeners.track(
3070
3311
  this.editor.registerCommand(KEY_ARROW_LEFT_COMMAND, this.#selectPreviousNode.bind(this), COMMAND_PRIORITY_LOW),
3071
3312
  this.editor.registerCommand(KEY_ARROW_RIGHT_COMMAND, this.#selectNextNode.bind(this), COMMAND_PRIORITY_LOW),
3072
3313
  this.editor.registerCommand(KEY_ARROW_UP_COMMAND, this.#selectPreviousTopLevelNode.bind(this), COMMAND_PRIORITY_LOW),
@@ -3077,21 +3318,21 @@ class Selection {
3077
3318
  this.editor.registerCommand(SELECTION_CHANGE_COMMAND, () => {
3078
3319
  this.current = $getSelection();
3079
3320
  }, COMMAND_PRIORITY_LOW)
3080
- ));
3321
+ );
3081
3322
  }
3082
3323
 
3083
3324
  #listenForNodeSelections() {
3084
- this.#unregister.push(this.editor.registerCommand(CLICK_COMMAND, ({ target }) => {
3325
+ this.#listeners.track(this.editor.registerCommand(CLICK_COMMAND, ({ target }) => {
3085
3326
  if (!isDOMNode(target)) return false
3086
3327
 
3087
3328
  const targetNode = $getNearestNodeFromDOMNode(target);
3088
3329
  return $isDecoratorNode(targetNode) && this.#selectInLexical(targetNode)
3089
3330
  }, COMMAND_PRIORITY_LOW));
3090
3331
 
3091
- const moveNextLineHandler = () => this.#selectOrAppendNextLine();
3092
3332
  const rootElement = this.editor.getRootElement();
3093
- rootElement.addEventListener("lexxy:internal:move-to-next-line", moveNextLineHandler);
3094
- this.#unregister.push(() => rootElement.removeEventListener("lexxy:internal:move-to-next-line", moveNextLineHandler));
3333
+ this.#listeners.track(
3334
+ registerEventListener(rootElement, "lexxy:internal:move-to-next-line", () => this.#selectOrAppendNextLine())
3335
+ );
3095
3336
  }
3096
3337
 
3097
3338
  #containEditorFocus() {
@@ -3296,13 +3537,20 @@ class Selection {
3296
3537
  }
3297
3538
 
3298
3539
  // When backspace is pressed on an empty list item that has siblings,
3299
- // remove the empty item and place the cursor appropriately. Without this,
3300
- // Lexical's default collapseAtStart converts the empty item into a paragraph
3301
- // above the list, causing the cursor to jump away from the list content.
3540
+ // handle the deletion appropriately:
3541
+ //
3542
+ // - Middle/end items (has previous sibling): remove the empty item and
3543
+ // place the cursor at the end of the previous sibling. Without this,
3544
+ // Lexical's default collapseAtStart converts the empty item into a
3545
+ // paragraph above the list, causing the cursor to jump away.
3546
+ //
3547
+ // - First item (no previous sibling): convert to a paragraph above the
3548
+ // list, matching the standard "unwrap list formatting" behavior that
3549
+ // users expect from pressing backspace at the start of a list item.
3302
3550
  //
3303
- // This only applies when there IS a next sibling — if the empty item is the
3304
- // last one in the list, Lexical's default (convert to paragraph) provides
3305
- // the standard "exit list" behavior.
3551
+ // When the empty item is the last/only one in the list, we return false
3552
+ // and let Lexical's default (convert to paragraph) provide the standard
3553
+ // "exit list" behavior.
3306
3554
  #removeEmptyListItem() {
3307
3555
  const selection = $getSelection();
3308
3556
  if (!$isRangeSelection(selection) || !selection.isCollapsed()) return false
@@ -3319,11 +3567,17 @@ class Selection {
3319
3567
  const previousSibling = listItem.getPreviousSibling();
3320
3568
  if (previousSibling) {
3321
3569
  previousSibling.selectEnd();
3322
- } else {
3323
- nextSibling.selectStart();
3570
+ listItem.remove();
3571
+ return true
3324
3572
  }
3325
3573
 
3574
+ const listNode = $getNearestNodeOfType(listItem, ListNode);
3575
+ if (!listNode) return false
3576
+
3577
+ const paragraph = $createParagraphNode();
3578
+ listNode.insertBefore(paragraph);
3326
3579
  listItem.remove();
3580
+ paragraph.selectStart();
3327
3581
  return true
3328
3582
  }
3329
3583
 
@@ -3583,10 +3837,6 @@ class Selection {
3583
3837
  }
3584
3838
  }
3585
3839
 
3586
- function sanitize(html) {
3587
- return DOMPurify.sanitize(html, buildConfig())
3588
- }
3589
-
3590
3840
  class EditorConfiguration {
3591
3841
  #editorElement
3592
3842
  #config
@@ -3629,6 +3879,107 @@ class EditorConfiguration {
3629
3879
  }
3630
3880
  }
3631
3881
 
3882
+ class ProvisionalParagraphNode extends ParagraphNode {
3883
+ $config() {
3884
+ return this.config("provisonal_paragraph", {
3885
+ extends: ParagraphNode,
3886
+ importDOM: () => null,
3887
+ $transform: (node) => {
3888
+ node.concretizeIfEdited(node);
3889
+ node.removeUnlessRequired(node);
3890
+ }
3891
+ })
3892
+ }
3893
+
3894
+ static neededBetween(nodeBefore, nodeAfter) {
3895
+ return !$isSelectableElement(nodeBefore, "next")
3896
+ && !$isSelectableElement(nodeAfter, "previous")
3897
+ }
3898
+
3899
+ createDOM(editor) {
3900
+ const p = super.createDOM(editor);
3901
+ const selected = this.isSelected($getSelection());
3902
+ p.classList.add("provisional-paragraph");
3903
+ p.classList.toggle("hidden", !selected);
3904
+ return p
3905
+ }
3906
+
3907
+ updateDOM(_prevNode, dom) {
3908
+ const selected = this.isSelected($getSelection());
3909
+ dom.classList.toggle("hidden", !selected);
3910
+ return false
3911
+ }
3912
+
3913
+ getTextContent() {
3914
+ return ""
3915
+ }
3916
+
3917
+ exportDOM() {
3918
+ return {
3919
+ element: null
3920
+ }
3921
+ }
3922
+
3923
+ // override as Lexical has an interesting view of collapsed selection in ElementNodes
3924
+ // https://github.com/facebook/lexical/blob/f1e4f66014377b1f2595aec2b0ee17f5b7ef4dfc/packages/lexical/src/LexicalNode.ts#L646
3925
+ isSelected(selection = null) {
3926
+ const targetSelection = selection || $getSelection();
3927
+ if (!targetSelection) return false
3928
+
3929
+ if (targetSelection.getNodes().some(node => node.is(this) || this.isParentOf(node))) return true
3930
+
3931
+ // A collapsed range selection on the parent element at an offset adjacent to
3932
+ // this node means the caret is visually at this paragraph's position. Treat it
3933
+ // as selected so the paragraph is visible and the caret renders correctly.
3934
+ //
3935
+ // Both the offset matching our index (cursor just before us) and index + 1
3936
+ // (cursor just after us) count, because the provisional paragraph is an
3937
+ // invisible spacer: the browser resolves both offsets to the same visual spot.
3938
+ if ($isRangeSelection(targetSelection) && targetSelection.isCollapsed()) {
3939
+ const { anchor } = targetSelection;
3940
+ const parent = this.getParent();
3941
+ if (parent && anchor.getNode().is(parent) && anchor.type === "element") {
3942
+ const index = this.getIndexWithinParent();
3943
+ return anchor.offset === index || anchor.offset === index + 1
3944
+ }
3945
+ }
3946
+
3947
+ return false
3948
+ }
3949
+
3950
+ removeUnlessRequired(self = this.getLatest()) {
3951
+ if (!self.required) self.remove();
3952
+ }
3953
+
3954
+ concretizeIfEdited(self = this.getLatest()) {
3955
+ if (self.getTextContentSize() > 0) {
3956
+ self.replace($createParagraphNode(), true);
3957
+ }
3958
+ }
3959
+
3960
+
3961
+ get required() {
3962
+ return this.isDirectRootChild && ProvisionalParagraphNode.neededBetween(...this.immediateSiblings)
3963
+ }
3964
+
3965
+ get isDirectRootChild() {
3966
+ const parent = this.getParent();
3967
+ return $isRootOrShadowRoot(parent)
3968
+ }
3969
+
3970
+ get immediateSiblings() {
3971
+ return [ this.getPreviousSibling(), this.getNextSibling() ]
3972
+ }
3973
+ }
3974
+
3975
+ function $isProvisionalParagraphNode(node) {
3976
+ return node instanceof ProvisionalParagraphNode
3977
+ }
3978
+
3979
+ function $isSelectableElement(node, direction) {
3980
+ return $isElementNode(node) && (direction === "next" ? node.canInsertTextBefore() : node.canInsertTextAfter())
3981
+ }
3982
+
3632
3983
  async function loadFileIntoImage(file, image) {
3633
3984
  return new Promise((resolve) => {
3634
3985
  const reader = new FileReader();
@@ -3677,7 +4028,7 @@ class ActionTextAttachmentUploadNode extends ActionTextAttachmentNode {
3677
4028
  }
3678
4029
 
3679
4030
  createDOM() {
3680
- if (this.uploadError) return this.#createDOMForError()
4031
+ if (this.uploadError) return this.createDOMForError()
3681
4032
 
3682
4033
  // This side-effect is trigged on DOM load to fire only once and avoid multiple
3683
4034
  // uploads through cloning. The upload is guarded from restarting in case the
@@ -3737,13 +4088,6 @@ class ActionTextAttachmentUploadNode extends ActionTextAttachmentNode {
3737
4088
  return this.progress !== null
3738
4089
  }
3739
4090
 
3740
- #createDOMForError() {
3741
- const figure = this.createAttachmentFigure();
3742
- figure.classList.add("attachment--error");
3743
- figure.appendChild(createElement("div", { innerText: `Error uploading ${this.file?.name ?? "file"}` }));
3744
- return figure
3745
- }
3746
-
3747
4091
  #createDOMForImage() {
3748
4092
  return createElement("img")
3749
4093
  }
@@ -3853,10 +4197,13 @@ class ActionTextAttachmentUploadNode extends ActionTextAttachmentNode {
3853
4197
  }
3854
4198
 
3855
4199
  showUploadedAttachment(blob) {
3856
- const replacementNode = this.#toActionTextAttachmentNodeWith(blob);
4200
+ const previewSrc = this.isPreviewableImage && this.file ? URL.createObjectURL(this.file) : null;
4201
+
4202
+ const replacementNode = this.#toActionTextAttachmentNodeWith(blob, previewSrc);
4203
+ const shouldSelectAfterReplacement = this.#selectionIncludesUploadNode;
3857
4204
  this.replace(replacementNode);
3858
4205
 
3859
- if ($isRootOrShadowRoot(replacementNode.getParent())) {
4206
+ if (shouldSelectAfterReplacement && $isRootOrShadowRoot(replacementNode.getParent())) {
3860
4207
  replacementNode.selectNext();
3861
4208
  }
3862
4209
 
@@ -3880,8 +4227,22 @@ class ActionTextAttachmentUploadNode extends ActionTextAttachmentNode {
3880
4227
  return rootElement !== null && rootElement.contains(document.activeElement)
3881
4228
  }
3882
4229
 
3883
- #toActionTextAttachmentNodeWith(blob) {
3884
- const conversion = new AttachmentNodeConversion(this, blob);
4230
+ get #selectionIncludesUploadNode() {
4231
+ const selection = $getSelection();
4232
+ if (selection === null) return false
4233
+
4234
+ if (selection.getNodes().some((node) => node.is(this))) return true
4235
+ if (!$isRangeSelection(selection) || !selection.isCollapsed()) return false
4236
+
4237
+ const anchorNode = selection.anchor.getNode();
4238
+ if (!$isProvisionalParagraphNode(anchorNode) || !anchorNode.isEmpty()) return false
4239
+
4240
+ const previousSibling = anchorNode.getPreviousSibling();
4241
+ return previousSibling !== null && previousSibling.is(this)
4242
+ }
4243
+
4244
+ #toActionTextAttachmentNodeWith(blob, previewSrc) {
4245
+ const conversion = new AttachmentNodeConversion(this, blob, previewSrc);
3885
4246
  return conversion.toAttachmentNode()
3886
4247
  }
3887
4248
 
@@ -3892,16 +4253,19 @@ class ActionTextAttachmentUploadNode extends ActionTextAttachmentNode {
3892
4253
  }
3893
4254
 
3894
4255
  class AttachmentNodeConversion {
3895
- constructor(uploadNode, blob) {
4256
+ constructor(uploadNode, blob, previewSrc) {
3896
4257
  this.uploadNode = uploadNode;
3897
4258
  this.blob = blob;
4259
+ this.previewSrc = previewSrc;
3898
4260
  }
3899
4261
 
3900
4262
  toAttachmentNode() {
3901
4263
  return new ActionTextAttachmentNode({
3902
4264
  ...this.uploadNode,
3903
4265
  ...this.#propertiesFromBlob,
3904
- src: this.#src
4266
+ src: this.#src,
4267
+ previewSrc: this.previewSrc,
4268
+ pendingPreview: this.blob.previewable && !this.uploadNode.isPreviewableImage
3905
4269
  })
3906
4270
  }
3907
4271
 
@@ -4140,7 +4504,7 @@ class Uploader {
4140
4504
  }
4141
4505
 
4142
4506
  $insertUploadNodes() {
4143
- this.nodes.forEach(this.contents.insertAtCursor);
4507
+ this.contents.insertAtCursor(...this.nodes);
4144
4508
  }
4145
4509
 
4146
4510
  get #nodeUrlProperties() {
@@ -4237,43 +4601,30 @@ class Contents {
4237
4601
  this.editor = null;
4238
4602
  }
4239
4603
 
4604
+ get selection() { return this.editorElement.selection }
4605
+
4240
4606
  insertHtml(html, { tag } = {}) {
4241
4607
  this.insertDOM(parseHtml(html), { tag });
4242
4608
  }
4243
4609
 
4244
4610
  insertDOM(doc, { tag } = {}) {
4245
4611
  this.#unwrapPlaceholderAnchors(doc);
4246
- if (tag === PASTE_TAG) this.#stripTableCellColorStyles(doc);
4247
4612
 
4248
4613
  this.editor.update(() => {
4249
- const selection = $getSelection();
4250
- if (!$isRangeSelection(selection)) return
4614
+ if ($hasUpdateTag(PASTE_TAG)) this.#stripTableCellColorStyles(doc);
4251
4615
 
4252
4616
  const nodes = $generateNodesFromDOM(this.editor, doc);
4253
4617
  if (!this.#insertUploadNodes(nodes)) {
4254
- selection.insertNodes(nodes);
4618
+ this.insertAtCursor(...nodes);
4255
4619
  }
4256
4620
  }, { tag });
4257
4621
  }
4258
4622
 
4259
- insertAtCursor(node) {
4260
- let selection = $getSelection() ?? $getRoot().selectEnd();
4261
- const selectedNodes = selection?.getNodes();
4623
+ insertAtCursor(...nodes) {
4624
+ const selection = $getSelection() ?? $getRoot().selectEnd();
4625
+ const inserter = NodeInserter.for(selection);
4262
4626
 
4263
- if ($isRangeSelection(selection)) {
4264
- const anchorNode = selection.anchor.getNode();
4265
- if ($isShadowRoot(anchorNode)) {
4266
- const paragraph = $createParagraphNode();
4267
- anchorNode.append(paragraph);
4268
- selection = paragraph.selectStart();
4269
- }
4270
- selection.insertNodes([ node ]);
4271
- } else if ($isNodeSelection(selection) && selectedNodes.length > 0) {
4272
- // Overrides Lexical's default behavior of _removing_ the currently selected nodes
4273
- // https://github.com/facebook/lexical/blob/v0.38.2/packages/lexical/src/LexicalSelection.ts#L412
4274
- const lastNode = selectedNodes.at(-1);
4275
- lastNode.insertAfter(node);
4276
- }
4627
+ inserter.insertNodes(nodes);
4277
4628
  }
4278
4629
 
4279
4630
  insertAtCursorEnsuringLineBelow(node) {
@@ -4295,11 +4646,30 @@ class Contents {
4295
4646
  $setBlocksType(selection, () => $createHeadingNode(tag));
4296
4647
  }
4297
4648
 
4298
- #applyCodeBlockFormat() {
4649
+ applyUnorderedListFormat() {
4650
+ this.#splitParagraphsAtLineBreaksUnlessInsideList();
4651
+ this.editor.dispatchCommand(INSERT_UNORDERED_LIST_COMMAND, undefined);
4652
+ }
4653
+
4654
+ applyOrderedListFormat() {
4655
+ this.#splitParagraphsAtLineBreaksUnlessInsideList();
4656
+ this.editor.dispatchCommand(INSERT_ORDERED_LIST_COMMAND, undefined);
4657
+ }
4658
+
4659
+ clearFormatting() {
4299
4660
  const selection = $getSelection();
4300
4661
  if (!$isRangeSelection(selection)) return
4301
4662
 
4302
- $setBlocksType(selection, () => $createCodeNode("plain"));
4663
+ $forEachSelectedTextNode(node => {
4664
+ node.setFormat(0);
4665
+ node.setStyle("");
4666
+ });
4667
+
4668
+ $toggleLink(null);
4669
+
4670
+ this.#topLevelElementsInSelection(selection).filter($isQuoteNode).forEach(node => this.#unwrap(node));
4671
+
4672
+ $setBlocksType(selection, () => $createParagraphNode());
4303
4673
  }
4304
4674
 
4305
4675
  toggleCodeBlock() {
@@ -4308,12 +4678,16 @@ class Contents {
4308
4678
 
4309
4679
  if (this.#insertNodeIfRoot($createCodeNode("plain"))) return
4310
4680
 
4311
- const topLevelElement = selection.anchor.getNode().getTopLevelElementOrThrow();
4681
+ const blockElements = this.#blockLevelElementsInSelection(selection);
4682
+ const allCode = blockElements.every($isCodeNode);
4312
4683
 
4313
- if (topLevelElement && !$isCodeNode(topLevelElement)) {
4314
- this.#applyCodeBlockFormat();
4684
+ if (allCode) {
4685
+ blockElements.forEach(node => this.#unwrapCodeBlock(node));
4315
4686
  } else {
4316
- this.applyParagraphFormat();
4687
+ const codeNode = $createCodeNode("plain");
4688
+ blockElements.at(-1).insertAfter(codeNode);
4689
+ codeNode.selectEnd();
4690
+ this.insertAtCursor(...blockElements);
4317
4691
  }
4318
4692
  }
4319
4693
 
@@ -4562,9 +4936,42 @@ class Contents {
4562
4936
  return false
4563
4937
  }
4564
4938
 
4939
+ #unwrapCodeBlock(codeNode) {
4940
+ const children = codeNode.getChildren();
4941
+ const groups = [ [] ];
4942
+
4943
+ for (const child of children) {
4944
+ if ($isLineBreakNode(child)) {
4945
+ groups.push([]);
4946
+ } else {
4947
+ groups[groups.length - 1].push(child.getTextContent());
4948
+ }
4949
+ }
4950
+
4951
+ for (const group of groups) {
4952
+ const paragraph = $createParagraphNode();
4953
+ const text = group.join("");
4954
+ if (text) {
4955
+ paragraph.append($createTextNode(text));
4956
+ }
4957
+ codeNode.insertBefore(paragraph);
4958
+ }
4959
+
4960
+ codeNode.remove();
4961
+ }
4962
+
4963
+ #splitParagraphsAtLineBreaksUnlessInsideList() {
4964
+ if (this.selection.isInsideList) return
4965
+
4966
+ const selection = $getSelection();
4967
+ if (!$isRangeSelection(selection)) return
4968
+
4969
+ this.#splitParagraphsAtLineBreaks(selection);
4970
+ }
4971
+
4565
4972
  #splitParagraphsAtLineBreaks(selection) {
4566
- const anchorKey = selection.anchor.getNode().getKey();
4567
- const focusKey = selection.focus.getNode().getKey();
4973
+ const anchorTopLevel = selection.anchor.getNode().getTopLevelElement();
4974
+ const focusTopLevel = selection.focus.getNode().getTopLevelElement();
4568
4975
  const topLevelElements = this.#topLevelElementsInSelection(selection);
4569
4976
 
4570
4977
  for (const element of topLevelElements) {
@@ -4576,10 +4983,9 @@ class Contents {
4576
4983
  // Check whether this paragraph needs splitting: skip only if neither
4577
4984
  // selection endpoint is inside it (meaning it's a middle paragraph
4578
4985
  // fully between anchor and focus with no partial lines to split off).
4579
- const hasEndpoint = children.some(child =>
4580
- child.getKey() === anchorKey || child.getKey() === focusKey
4581
- );
4582
- if (!hasEndpoint) continue
4986
+ // Compare top-level elements so endpoints inside nested inline nodes
4987
+ // (e.g. text inside a LinkNode) are still recognized.
4988
+ if (element !== anchorTopLevel && element !== focusTopLevel) continue
4583
4989
 
4584
4990
  const groups = [ [] ];
4585
4991
  for (const child of children) {
@@ -4601,6 +5007,15 @@ class Contents {
4601
5007
  }
4602
5008
  }
4603
5009
 
5010
+ #blockLevelElementsInSelection(selection) {
5011
+ const blocks = new Set();
5012
+ for (const node of selection.getNodes()) {
5013
+ blocks.add($getNearestBlockElementAncestorOrThrow(node));
5014
+ }
5015
+
5016
+ return Array.from(blocks)
5017
+ }
5018
+
4604
5019
  #topLevelElementsInSelection(selection) {
4605
5020
  const elements = new Set();
4606
5021
  for (const node of selection.getNodes()) {
@@ -4788,6 +5203,109 @@ function $isShadowRoot(node) {
4788
5203
  return $isElementNode(node) && $isRootOrShadowRoot(node) && !$isRootNode(node)
4789
5204
  }
4790
5205
 
5206
+ class NodeInserter {
5207
+ static for(selection) {
5208
+ const INSERTERS = [
5209
+ CodeNodeInserter,
5210
+ QuoteNodeInserter,
5211
+ ShadowRootNodeInserter,
5212
+ NodeSelectionNodeInserter
5213
+ ];
5214
+ const Inserter = INSERTERS.find(inserter => inserter.handles(selection));
5215
+ return Inserter ? new Inserter(selection) : selection
5216
+ }
5217
+
5218
+ constructor(selection) {
5219
+ this.selection = selection;
5220
+ }
5221
+ }
5222
+
5223
+ class CodeNodeInserter extends NodeInserter {
5224
+ static handles(selection) {
5225
+ return $getNearestNodeOfType(selection.anchor?.getNode(), CodeNode)
5226
+ }
5227
+
5228
+ insertNodes(nodes) {
5229
+ if (!this.selection.isCollapsed()) { this.selection.removeText(); }
5230
+
5231
+ $ensureForwardRangeSelection(this.selection);
5232
+ const focusNode = this.selection.focus.getNode();
5233
+ const codeNode = $getNearestNodeOfType(focusNode, CodeNode);
5234
+ const insertionIndex = focusNode.is(codeNode) ? 0 : focusNode.getIndexWithinParent();
5235
+
5236
+ const caret = $getChildCaretAtIndex(codeNode, insertionIndex + 1, "previous");
5237
+
5238
+ for (const node of nodes) {
5239
+ if (!node.isAttached()) continue
5240
+ if (caret.getNodeAtCaret() && $isElementNode(node)) { caret.insert($createLineBreakNode()); }
5241
+
5242
+ caret.insert(this.#convertNodeToCodeChild(node));
5243
+ }
5244
+
5245
+ caret.getNodeAtCaret().selectEnd();
5246
+ }
5247
+
5248
+ #convertNodeToCodeChild(node) {
5249
+ if ($isLineBreakNode(node)) {
5250
+ return node
5251
+ } else {
5252
+ node.remove();
5253
+ return $createTextNode(node.getTextContent())
5254
+ }
5255
+ }
5256
+
5257
+ }
5258
+
5259
+ // Lexical will split a QuoteNode when inserting other Elements - we want them simply inserted as-is
5260
+ class QuoteNodeInserter extends NodeInserter {
5261
+ static handles(selection) {
5262
+ return $getNearestNodeOfType(selection.anchor?.getNode(), QuoteNode)
5263
+ }
5264
+
5265
+ insertNodes(nodes) {
5266
+ if (!this.selection.isCollapsed()) { this.selection.removeText(); }
5267
+
5268
+ $ensureForwardRangeSelection(this.selection);
5269
+ let lastNode = this.selection.focus.getNode();
5270
+ for (const node of nodes) {
5271
+ lastNode = lastNode.insertAfter(node);
5272
+ }
5273
+
5274
+ lastNode.selectEnd();
5275
+ }
5276
+ }
5277
+
5278
+ class ShadowRootNodeInserter extends NodeInserter {
5279
+ static handles(selection) {
5280
+ return $isShadowRoot(selection?.anchor.getNode())
5281
+ }
5282
+
5283
+ insertNodes(nodes) {
5284
+ const anchorNode = this.selection.anchor.getNode();
5285
+ const paragraph = $createParagraphNode();
5286
+ anchorNode.append(paragraph);
5287
+
5288
+ paragraph.selectStart().insertNodes(nodes);
5289
+ }
5290
+ }
5291
+
5292
+ class NodeSelectionNodeInserter extends NodeInserter {
5293
+ static handles(selection) {
5294
+ return $isNodeSelection(selection)
5295
+ }
5296
+
5297
+ insertNodes(nodes) {
5298
+ const selectedNodes = this.selection.getNodes();
5299
+
5300
+ // Overrides Lexical's default behavior of _removing_ the currently selected nodes
5301
+ // https://github.com/facebook/lexical/blob/v0.38.2/packages/lexical/src/LexicalSelection.ts#L412
5302
+ let lastNode = selectedNodes.at(-1);
5303
+ for (const node of nodes) {
5304
+ lastNode = lastNode.insertAfter(node);
5305
+ }
5306
+ }
5307
+ }
5308
+
4791
5309
  class Clipboard {
4792
5310
  constructor(editorElement) {
4793
5311
  this.editorElement = editorElement;
@@ -4967,9 +5485,31 @@ class Extensions {
4967
5485
  }
4968
5486
 
4969
5487
  initializeToolbars() {
4970
- if (this.#lexxyToolbar) {
4971
- this.enabledExtensions.forEach(ext => ext.initializeToolbar(this.#lexxyToolbar));
4972
- }
5488
+ const toolbar = this.#lexxyToolbar;
5489
+ if (!toolbar) return
5490
+
5491
+ this.#clearPreviousExtensionToolbarButtons(toolbar);
5492
+ this.#addExtensionToolbarButtons(toolbar);
5493
+ }
5494
+
5495
+ #clearPreviousExtensionToolbarButtons(toolbar) {
5496
+ toolbar.querySelectorAll("[data-lexxy-extension]").forEach(el => el.remove());
5497
+ }
5498
+
5499
+ #addExtensionToolbarButtons(toolbar) {
5500
+ this.enabledExtensions.forEach(ext => {
5501
+ const childrenBefore = new Set(toolbar.children);
5502
+ ext.initializeToolbar(toolbar);
5503
+ for (const child of toolbar.children) {
5504
+ if (!childrenBefore.has(child)) {
5505
+ child.setAttribute("data-lexxy-extension", "");
5506
+ }
5507
+ }
5508
+ });
5509
+ }
5510
+
5511
+ get allowedElements() {
5512
+ return this.enabledExtensions.flatMap(ext => ext.allowedElements)
4973
5513
  }
4974
5514
 
4975
5515
  get #lexxyToolbar() {
@@ -5005,7 +5545,7 @@ class BrowserAdapter {
5005
5545
  }
5006
5546
  }
5007
5547
 
5008
- // Custom TextNode exportDOM that avoids redundant bold/italic wrapping.
5548
+ // Custom TextNode exportDOM that avoids redundant wrapping.
5009
5549
  //
5010
5550
  // Lexical's built-in TextNode.exportDOM() calls createDOM() which produces semantic tags
5011
5551
  // like <strong> for bold and <em> for italic, then unconditionally wraps the result
@@ -5015,6 +5555,9 @@ class BrowserAdapter {
5015
5555
  // This custom export skips <b> when <strong> is already present and <i> when <em> is
5016
5556
  // already present, while preserving <s> and <u> wrappers which have no semantic equivalents
5017
5557
  // in createDOM's output.
5558
+ //
5559
+ // Any <span> elements produced by createDOM() are unwrapped, since they only carry
5560
+ // editor classes that aren't meaningful in exported HTML.
5018
5561
 
5019
5562
  function exportTextNodeDOM(editor, textNode) {
5020
5563
  const element = textNode.createDOM(editor._config, editor);
@@ -5043,7 +5586,7 @@ function exportTextNodeDOM(editor, textNode) {
5043
5586
  result = wrapWith(result, "u");
5044
5587
  }
5045
5588
 
5046
- return { element: result }
5589
+ return { element: unwrapSpans(result) }
5047
5590
  }
5048
5591
 
5049
5592
  function containsTag(element, tagName) {
@@ -5059,105 +5602,14 @@ function wrapWith(element, tag) {
5059
5602
  return wrapper
5060
5603
  }
5061
5604
 
5062
- class ProvisionalParagraphNode extends ParagraphNode {
5063
- $config() {
5064
- return this.config("provisonal_paragraph", {
5065
- extends: ParagraphNode,
5066
- importDOM: () => null,
5067
- $transform: (node) => {
5068
- node.concretizeIfEdited(node);
5069
- node.removeUnlessRequired(node);
5070
- }
5071
- })
5072
- }
5073
-
5074
- static neededBetween(nodeBefore, nodeAfter) {
5075
- return !$isSelectableElement(nodeBefore, "next")
5076
- && !$isSelectableElement(nodeAfter, "previous")
5077
- }
5078
-
5079
- createDOM(editor) {
5080
- const p = super.createDOM(editor);
5081
- const selected = this.isSelected($getSelection());
5082
- p.classList.add("provisional-paragraph");
5083
- p.classList.toggle("hidden", !selected);
5084
- return p
5085
- }
5086
-
5087
- updateDOM(_prevNode, dom) {
5088
- const selected = this.isSelected($getSelection());
5089
- dom.classList.toggle("hidden", !selected);
5090
- return false
5091
- }
5092
-
5093
- getTextContent() {
5094
- return ""
5095
- }
5096
-
5097
- exportDOM() {
5098
- return {
5099
- element: null
5100
- }
5101
- }
5102
-
5103
- // override as Lexical has an interesting view of collapsed selection in ElementNodes
5104
- // https://github.com/facebook/lexical/blob/f1e4f66014377b1f2595aec2b0ee17f5b7ef4dfc/packages/lexical/src/LexicalNode.ts#L646
5105
- isSelected(selection = null) {
5106
- const targetSelection = selection || $getSelection();
5107
- if (!targetSelection) return false
5108
-
5109
- if (targetSelection.getNodes().some(node => node.is(this) || this.isParentOf(node))) return true
5110
-
5111
- // A collapsed range selection on the parent element at an offset adjacent to
5112
- // this node means the caret is visually at this paragraph's position. Treat it
5113
- // as selected so the paragraph is visible and the caret renders correctly.
5114
- //
5115
- // Both the offset matching our index (cursor just before us) and index + 1
5116
- // (cursor just after us) count, because the provisional paragraph is an
5117
- // invisible spacer: the browser resolves both offsets to the same visual spot.
5118
- if ($isRangeSelection(targetSelection) && targetSelection.isCollapsed()) {
5119
- const { anchor } = targetSelection;
5120
- const parent = this.getParent();
5121
- if (parent && anchor.getNode().is(parent) && anchor.type === "element") {
5122
- const index = this.getIndexWithinParent();
5123
- return anchor.offset === index || anchor.offset === index + 1
5124
- }
5125
- }
5126
-
5127
- return false
5128
- }
5129
-
5130
- removeUnlessRequired(self = this.getLatest()) {
5131
- if (!self.required) self.remove();
5132
- }
5133
-
5134
- concretizeIfEdited(self = this.getLatest()) {
5135
- if (self.getTextContentSize() > 0) {
5136
- self.replace($createParagraphNode(), true);
5137
- }
5138
- }
5139
-
5140
-
5141
- get required() {
5142
- return this.isDirectRootChild && ProvisionalParagraphNode.neededBetween(...this.immediateSiblings)
5143
- }
5144
-
5145
- get isDirectRootChild() {
5146
- const parent = this.getParent();
5147
- return $isRootOrShadowRoot(parent)
5148
- }
5605
+ function unwrapSpans(element) {
5606
+ if (element.tagName === "SPAN") return element.firstChild
5149
5607
 
5150
- get immediateSiblings() {
5151
- return [ this.getPreviousSibling(), this.getNextSibling() ]
5608
+ for (const span of element.querySelectorAll("span")) {
5609
+ span.replaceWith(...span.childNodes);
5152
5610
  }
5153
- }
5154
-
5155
- function $isProvisionalParagraphNode(node) {
5156
- return node instanceof ProvisionalParagraphNode
5157
- }
5158
5611
 
5159
- function $isSelectableElement(node, direction) {
5160
- return $isElementNode(node) && (direction === "next" ? node.canInsertTextBefore() : node.canInsertTextAfter())
5612
+ return element
5161
5613
  }
5162
5614
 
5163
5615
  class ProvisionalParagraphExtension extends LexxyExtension {
@@ -5310,6 +5762,10 @@ class TablesExtension extends LexxyExtension {
5310
5762
  return this.editorElement.supportsRichText
5311
5763
  }
5312
5764
 
5765
+ get allowedElements() {
5766
+ return [ "figure", "tbody" ]
5767
+ }
5768
+
5313
5769
  get lexicalExtension() {
5314
5770
  return defineExtension({
5315
5771
  name: "lexxy/tables",
@@ -5413,21 +5869,21 @@ class AttachmentDragAndDrop {
5413
5869
  #draggedNodeKey = null
5414
5870
  #rafId = null
5415
5871
  #draggingRafId = null
5416
- #cleanupFns = []
5872
+ #listeners = new ListenerBin()
5417
5873
 
5418
5874
  constructor(editor) {
5419
5875
  this.#editor = editor;
5420
5876
 
5421
5877
  // Register Lexical commands at HIGH priority to intercept before the
5422
5878
  // base @lexical/rich-text handlers (which return true and consume the events).
5423
- this.#cleanupFns.push(
5879
+ this.#listeners.track(
5424
5880
  editor.registerCommand(DRAGSTART_COMMAND, (event) => this.#handleDragStart(event), COMMAND_PRIORITY_HIGH),
5425
5881
  editor.registerCommand(DROP_COMMAND, (event) => this.#handleDrop(event), COMMAND_PRIORITY_HIGH),
5426
5882
  );
5427
5883
 
5428
5884
  // Use a root listener to register DOM-level dragover/dragend handlers
5429
5885
  // (these events need throttled rAF handling that works better as DOM listeners).
5430
- const unregister = editor.registerRootListener((root, prevRoot) => {
5886
+ this.#listeners.track(editor.registerRootListener((root, prevRoot) => {
5431
5887
  if (prevRoot) {
5432
5888
  prevRoot.removeEventListener("dragover", this.#onDragOver);
5433
5889
  prevRoot.removeEventListener("dragend", this.#onDragEnd);
@@ -5436,14 +5892,12 @@ class AttachmentDragAndDrop {
5436
5892
  root.addEventListener("dragover", this.#onDragOver);
5437
5893
  root.addEventListener("dragend", this.#onDragEnd);
5438
5894
  }
5439
- });
5440
- this.#cleanupFns.push(unregister);
5895
+ }));
5441
5896
  }
5442
5897
 
5443
5898
  destroy() {
5444
5899
  this.#cleanup();
5445
- for (const fn of this.#cleanupFns) fn();
5446
- this.#cleanupFns = [];
5900
+ this.#listeners.dispose();
5447
5901
  }
5448
5902
 
5449
5903
  // -- Event handlers --------------------------------------------------------
@@ -5765,11 +6219,18 @@ class AttachmentDragAndDrop {
5765
6219
  }
5766
6220
  }
5767
6221
 
6222
+ const ATTACHMENT_ATTRIBUTES = [ "alt", "caption", "content", "content-type", "data-direct-upload-id",
6223
+ "data-sgid", "filename", "filesize", "height", "presentation", "previewable", "sgid", "url", "width" ];
6224
+
5768
6225
  class AttachmentsExtension extends LexxyExtension {
5769
6226
  get enabled() {
5770
6227
  return this.editorElement.supportsAttachments
5771
6228
  }
5772
6229
 
6230
+ get allowedElements() {
6231
+ return [ { tag: ActionTextAttachmentNode.TAG_NAME, attributes: ATTACHMENT_ATTRIBUTES } ]
6232
+ }
6233
+
5773
6234
  get lexicalExtension() {
5774
6235
  return defineExtension({
5775
6236
  name: "lexxy/action-text-attachments",
@@ -6057,6 +6518,7 @@ class LexicalEditorElement extends HTMLElement {
6057
6518
  #initialValue = ""
6058
6519
  #validationTextArea = document.createElement("textarea")
6059
6520
  #editorInitializedRafId = null
6521
+ #listeners = new ListenerBin()
6060
6522
  #disposables = []
6061
6523
 
6062
6524
  constructor() {
@@ -6072,6 +6534,7 @@ class LexicalEditorElement extends HTMLElement {
6072
6534
 
6073
6535
  this.editor = this.#createEditor();
6074
6536
  this.#disposables.push(this.editor);
6537
+ this.#disposables.push(this.#listeners);
6075
6538
 
6076
6539
  this.contents = new Contents(this);
6077
6540
  this.#disposables.push(this.contents);
@@ -6235,7 +6698,7 @@ class LexicalEditorElement extends HTMLElement {
6235
6698
  get value() {
6236
6699
  if (!this.cachedValue) {
6237
6700
  this.editor?.getEditorState().read(() => {
6238
- this.cachedValue = sanitize($generateHtmlFromNodes(this.editor, null));
6701
+ this.cachedValue = sanitize($generateHtmlFromNodes(this.editor, null), this.#allowedElements);
6239
6702
  });
6240
6703
  }
6241
6704
 
@@ -6308,7 +6771,7 @@ class LexicalEditorElement extends HTMLElement {
6308
6771
  theme: theme,
6309
6772
  nodes: this.#lexicalNodes,
6310
6773
  html: {
6311
- export: new Map([ [ TextNode, exportTextNodeDOM ] ])
6774
+ export: new Map([ [ TextNode, exportTextNodeDOM ], [ CodeHighlightNode, exportTextNodeDOM ] ])
6312
6775
  }
6313
6776
  },
6314
6777
  ...this.extensions.lexicalExtensions
@@ -6392,7 +6855,9 @@ class LexicalEditorElement extends HTMLElement {
6392
6855
  }
6393
6856
 
6394
6857
  #resetBeforeTurboCaches() {
6395
- document.addEventListener("turbo:before-cache", this.#handleTurboBeforeCache);
6858
+ this.#listeners.track(
6859
+ registerEventListener(document, "turbo:before-cache", this.#handleTurboBeforeCache)
6860
+ );
6396
6861
  }
6397
6862
 
6398
6863
  #handleTurboBeforeCache = (event) => {
@@ -6400,7 +6865,7 @@ class LexicalEditorElement extends HTMLElement {
6400
6865
  }
6401
6866
 
6402
6867
  #synchronizeWithChanges() {
6403
- this.#addUnregisterHandler(this.editor.registerUpdateListener(({ editorState }) => {
6868
+ this.#listeners.track(this.editor.registerUpdateListener(({ editorState }) => {
6404
6869
  this.#clearCachedValues();
6405
6870
  this.#internalFormValue = this.value;
6406
6871
  this.#toggleEmptyStatus();
@@ -6414,18 +6879,6 @@ class LexicalEditorElement extends HTMLElement {
6414
6879
  this.cachedStringValue = null;
6415
6880
  }
6416
6881
 
6417
- #addUnregisterHandler(handler) {
6418
- this.unregisterHandlers = this.unregisterHandlers || [];
6419
- this.unregisterHandlers.push(handler);
6420
- }
6421
-
6422
- #unregisterHandlers() {
6423
- this.unregisterHandlers?.forEach((handler) => {
6424
- handler();
6425
- });
6426
- this.unregisterHandlers = null;
6427
- }
6428
-
6429
6882
  #registerComponents() {
6430
6883
  const registered = [];
6431
6884
 
@@ -6437,9 +6890,10 @@ class LexicalEditorElement extends HTMLElement {
6437
6890
  this.#registerTableComponents();
6438
6891
  this.#registerCodeHiglightingComponents();
6439
6892
  if (this.supportsMarkdown) {
6440
- registered.push(
6441
- registerMarkdownShortcuts(this.editor, TRANSFORMERS),
6442
- registerMarkdownLeadingTagHandler(this.editor, TRANSFORMERS)
6893
+ const transformers = [ ...TRANSFORMERS, HORIZONTAL_DIVIDER ];
6894
+ registered.push(
6895
+ registerMarkdownShortcuts(this.editor, transformers),
6896
+ registerMarkdownLeadingTagHandler(this.editor, transformers)
6443
6897
  );
6444
6898
  }
6445
6899
  } else {
@@ -6448,7 +6902,7 @@ class LexicalEditorElement extends HTMLElement {
6448
6902
  this.historyState = createEmptyHistoryState();
6449
6903
  registered.push(registerHistory(this.editor, this.historyState, 20));
6450
6904
 
6451
- this.#addUnregisterHandler(mergeRegister$1(...registered));
6905
+ this.#listeners.track(...registered);
6452
6906
  }
6453
6907
 
6454
6908
  #registerTableComponents() {
@@ -6468,7 +6922,7 @@ class LexicalEditorElement extends HTMLElement {
6468
6922
 
6469
6923
  #handleEnter() {
6470
6924
  // We can't prevent these externally using regular keydown because Lexical handles it first.
6471
- this.#addUnregisterHandler(this.editor.registerCommand(
6925
+ this.#listeners.track(this.editor.registerCommand(
6472
6926
  KEY_ENTER_COMMAND,
6473
6927
  (event) => {
6474
6928
  // Prevent CTRL+ENTER
@@ -6490,13 +6944,10 @@ class LexicalEditorElement extends HTMLElement {
6490
6944
  }
6491
6945
 
6492
6946
  #registerFocusEvents() {
6493
- this.addEventListener("focusin", this.#handleFocusIn);
6494
- this.addEventListener("focusout", this.#handleFocusOut);
6495
-
6496
- this.#addUnregisterHandler(() => {
6497
- this.removeEventListener("focusin", this.#handleFocusIn);
6498
- this.removeEventListener("focusout", this.#handleFocusOut);
6499
- });
6947
+ this.#listeners.track(
6948
+ registerEventListener(this, "focusin", this.#handleFocusIn),
6949
+ registerEventListener(this, "focusout", this.#handleFocusOut)
6950
+ );
6500
6951
  }
6501
6952
 
6502
6953
  #handleFocusIn(event) {
@@ -6582,6 +7033,15 @@ class LexicalEditorElement extends HTMLElement {
6582
7033
  }
6583
7034
  }
6584
7035
 
7036
+ get #allowedElements() {
7037
+ return this.#importableTags.concat(this.extensions.allowedElements)
7038
+ }
7039
+
7040
+ get #importableTags() {
7041
+ const tags = Array.from(this.editor._htmlConversions.keys());
7042
+ return tags.filter(tag => !tag.startsWith("#"))
7043
+ }
7044
+
6585
7045
  #dispatchAttributesChange() {
6586
7046
  let attributes = null;
6587
7047
  let linkHref = null;
@@ -6698,10 +7158,6 @@ class LexicalEditorElement extends HTMLElement {
6698
7158
  }
6699
7159
 
6700
7160
  #dispose() {
6701
- this.#unregisterHandlers();
6702
- this.adapter = null;
6703
- document.removeEventListener("turbo:before-cache", this.#handleTurboBeforeCache);
6704
-
6705
7161
  while (this.#disposables.length) {
6706
7162
  this.#disposables.pop().dispose();
6707
7163
  }
@@ -6742,18 +7198,21 @@ function $getReadableTextContent(node) {
6742
7198
  }
6743
7199
 
6744
7200
  class ToolbarDropdown extends HTMLElement {
7201
+ #listeners = new ListenerBin()
7202
+
6745
7203
  connectedCallback() {
6746
7204
  this.container = this.closest("details");
6747
7205
 
6748
- this.container.addEventListener("toggle", this.#handleToggle);
6749
- this.container.addEventListener("keydown", this.#handleKeyDown);
7206
+ this.#listeners.track(
7207
+ registerEventListener(this.container, "toggle", this.#handleToggle),
7208
+ registerEventListener(this.container, "keydown", this.#handleKeyDown)
7209
+ );
6750
7210
 
6751
7211
  this.#onToolbarEditor(this.initialize.bind(this));
6752
7212
  }
6753
7213
 
6754
7214
  disconnectedCallback() {
6755
- this.container?.removeEventListener("toggle", this.#handleToggle);
6756
- this.container?.removeEventListener("keydown", this.#handleKeyDown);
7215
+ this.#listeners.dispose();
6757
7216
  }
6758
7217
 
6759
7218
  get toolbar() {
@@ -6768,6 +7227,10 @@ class ToolbarDropdown extends HTMLElement {
6768
7227
  return this.toolbar.editor
6769
7228
  }
6770
7229
 
7230
+ track(...listeners) {
7231
+ this.#listeners.track(...listeners);
7232
+ }
7233
+
6771
7234
  initialize() {
6772
7235
  // Any post-editor initialization
6773
7236
  }
@@ -6819,18 +7282,23 @@ class ToolbarDropdown extends HTMLElement {
6819
7282
  class LinkDropdown extends ToolbarDropdown {
6820
7283
  connectedCallback() {
6821
7284
  super.connectedCallback();
7285
+
6822
7286
  this.input = this.querySelector("input");
6823
7287
 
6824
- this.container.addEventListener("toggle", this.#handleToggle);
6825
- this.addEventListener("submit", this.#handleSubmit);
6826
- this.querySelector("[value='unlink']").addEventListener("click", this.#handleUnlink);
7288
+ this.track(
7289
+ registerEventListener(this.container, "toggle", this.#handleToggle),
7290
+ registerEventListener(this.input, "keydown", this.#handleEnter),
7291
+ registerEventListener(this.linkButton, "click", this.#handleLink),
7292
+ registerEventListener(this.unlinkButton, "click", this.#handleUnlink)
7293
+ );
6827
7294
  }
6828
7295
 
6829
- disconnectedCallback() {
6830
- this.container?.removeEventListener("toggle", this.#handleToggle);
6831
- this.removeEventListener("submit", this.#handleSubmit);
6832
- this.querySelector("[value='unlink']")?.removeEventListener("click", this.#handleUnlink);
6833
- super.disconnectedCallback();
7296
+ get linkButton() {
7297
+ return this.querySelector("[value='link']")
7298
+ }
7299
+
7300
+ get unlinkButton() {
7301
+ return this.querySelector("[value='unlink']")
6834
7302
  }
6835
7303
 
6836
7304
  #handleToggle = ({ newState }) => {
@@ -6838,9 +7306,21 @@ class LinkDropdown extends ToolbarDropdown {
6838
7306
  this.input.required = newState === "open";
6839
7307
  }
6840
7308
 
6841
- #handleSubmit = (event) => {
6842
- const command = event.submitter?.value;
6843
- this.editor.dispatchCommand(command, this.input.value);
7309
+ #handleEnter = (event) => {
7310
+ if (event.key === "Enter") {
7311
+ event.preventDefault();
7312
+ event.stopPropagation();
7313
+ this.#handleLink(event);
7314
+ }
7315
+ }
7316
+
7317
+ #handleLink = () => {
7318
+ if (!this.input.checkValidity()) {
7319
+ this.input.reportValidity();
7320
+ return
7321
+ }
7322
+
7323
+ this.editor.dispatchCommand("link", this.input.value);
6844
7324
  this.close();
6845
7325
  }
6846
7326
 
@@ -6850,23 +7330,10 @@ class LinkDropdown extends ToolbarDropdown {
6850
7330
  }
6851
7331
 
6852
7332
  get #selectedLinkUrl() {
6853
- let url = "";
6854
-
6855
- this.editor.getEditorState().read(() => {
6856
- const selection = $getSelection();
6857
- if (!$isRangeSelection(selection)) return
6858
-
6859
- let node = selection.getNodes()[0];
6860
- while (node && node.getParent()) {
6861
- if ($isLinkNode(node)) {
6862
- url = node.getURL();
6863
- break
6864
- }
6865
- node = node.getParent();
6866
- }
6867
- });
6868
-
6869
- return url
7333
+ return this.editor.getEditorState().read(() => {
7334
+ const linkNode = this.editorElement.selection.nearestNodeOfType(LinkNode);
7335
+ return linkNode?.getUrl() ?? null
7336
+ })
6870
7337
  }
6871
7338
  }
6872
7339
 
@@ -6886,23 +7353,14 @@ class HighlightDropdown extends ToolbarDropdown {
6886
7353
 
6887
7354
  connectedCallback() {
6888
7355
  super.connectedCallback();
6889
- this.container.addEventListener("toggle", this.#handleToggle);
6890
- }
6891
-
6892
- disconnectedCallback() {
6893
- this.container?.removeEventListener("toggle", this.#handleToggle);
6894
- this.#removeButtonHandlers();
6895
- super.disconnectedCallback();
7356
+ this.track(registerEventListener(this.container, "toggle", this.#handleToggle));
6896
7357
  }
6897
7358
 
6898
7359
  #registerButtonHandlers() {
6899
- this.#colorButtons.forEach(button => button.addEventListener("click", this.#handleColorButtonClick));
6900
- this.querySelector(REMOVE_HIGHLIGHT_SELECTOR).addEventListener("click", this.#handleRemoveHighlightClick);
6901
- }
6902
-
6903
- #removeButtonHandlers() {
6904
- this.#colorButtons.forEach(button => button.removeEventListener("click", this.#handleColorButtonClick));
6905
- this.querySelector(REMOVE_HIGHLIGHT_SELECTOR)?.removeEventListener("click", this.#handleRemoveHighlightClick);
7360
+ this.#colorButtons.forEach(button => {
7361
+ this.track(registerEventListener(button, "click", this.#handleColorButtonClick));
7362
+ });
7363
+ this.track(registerEventListener(this.querySelector(REMOVE_HIGHLIGHT_SELECTOR), "click", this.#handleRemoveHighlightClick));
6906
7364
  }
6907
7365
 
6908
7366
  #setUpButtons() {
@@ -7124,9 +7582,11 @@ class RemoteFilterSource extends BaseSource {
7124
7582
  const NOTHING_FOUND_DEFAULT_MESSAGE = "Nothing found";
7125
7583
 
7126
7584
  class LexicalPromptElement extends HTMLElement {
7585
+ #globalListeners = new ListenerBin()
7586
+ #popoverListeners = new ListenerBin()
7587
+
7127
7588
  constructor() {
7128
7589
  super();
7129
- this.keyListeners = [];
7130
7590
  this.showPopoverId = 0;
7131
7591
  }
7132
7592
 
@@ -7140,6 +7600,8 @@ class LexicalPromptElement extends HTMLElement {
7140
7600
  }
7141
7601
 
7142
7602
  disconnectedCallback() {
7603
+ this.#popoverListeners.dispose();
7604
+ this.#globalListeners.dispose();
7143
7605
  this.source = null;
7144
7606
  this.popoverElement = null;
7145
7607
  }
@@ -7189,7 +7651,7 @@ class LexicalPromptElement extends HTMLElement {
7189
7651
  }
7190
7652
 
7191
7653
  #addTriggerListener() {
7192
- const unregister = this.#editor.registerUpdateListener(({ editorState }) => {
7654
+ this.#popoverListeners.track(this.#editor.registerUpdateListener(({ editorState }) => {
7193
7655
  editorState.read(() => {
7194
7656
  if (this.#selection.isInsideCodeBlock) return
7195
7657
 
@@ -7212,18 +7674,18 @@ class LexicalPromptElement extends HTMLElement {
7212
7674
  const isPrecededBySpaceOrNewline = charBeforeTrigger === " " || charBeforeTrigger === "\n";
7213
7675
 
7214
7676
  if (isAtStart || isPrecededBySpaceOrNewline) {
7215
- unregister();
7677
+ this.#popoverListeners.dispose();
7216
7678
  this.#showPopover();
7217
7679
  }
7218
7680
  }
7219
7681
  }
7220
7682
  }
7221
7683
  });
7222
- });
7684
+ }));
7223
7685
  }
7224
7686
 
7225
7687
  #addCursorPositionListener() {
7226
- this.cursorPositionListener = this.#editor.registerUpdateListener(({ editorState }) => {
7688
+ this.#popoverListeners.track(this.#editor.registerUpdateListener(({ editorState }) => {
7227
7689
  if (this.closed) return
7228
7690
 
7229
7691
  editorState.read(() => {
@@ -7250,14 +7712,7 @@ class LexicalPromptElement extends HTMLElement {
7250
7712
  this.#hidePopover();
7251
7713
  }
7252
7714
  });
7253
- });
7254
- }
7255
-
7256
- #removeCursorPositionListener() {
7257
- if (this.cursorPositionListener) {
7258
- this.cursorPositionListener();
7259
- this.cursorPositionListener = null;
7260
- }
7715
+ }));
7261
7716
  }
7262
7717
 
7263
7718
  get #editor() {
@@ -7284,8 +7739,10 @@ class LexicalPromptElement extends HTMLElement {
7284
7739
  this.popoverElement.classList.toggle("lexxy-prompt-menu--visible", true);
7285
7740
  this.#selectFirstOption();
7286
7741
 
7287
- this.#editorElement.addEventListener("keydown", this.#handleKeydownOnPopover);
7288
- this.#editorElement.addEventListener("lexxy:change", this.#filterOptions);
7742
+ this.#popoverListeners.track(
7743
+ registerEventListener(this.#editorElement, "keydown", this.#handleKeydownOnPopover),
7744
+ registerEventListener(this.#editorElement, "lexxy:change", this.#filterOptions)
7745
+ );
7289
7746
 
7290
7747
  this.#registerKeyListeners();
7291
7748
  this.#addCursorPositionListener();
@@ -7293,16 +7750,20 @@ class LexicalPromptElement extends HTMLElement {
7293
7750
 
7294
7751
  #registerKeyListeners() {
7295
7752
  // We can't use a regular keydown for Enter as Lexical handles it first
7296
- this.keyListeners.push(this.#editor.registerCommand(KEY_ENTER_COMMAND, this.#handleSelectedOption.bind(this), COMMAND_PRIORITY_CRITICAL));
7297
- this.keyListeners.push(this.#editor.registerCommand(KEY_TAB_COMMAND, this.#handleSelectedOption.bind(this), COMMAND_PRIORITY_CRITICAL));
7753
+ this.#popoverListeners.track(
7754
+ this.#editor.registerCommand(KEY_ENTER_COMMAND, this.#handleSelectedOption.bind(this), COMMAND_PRIORITY_CRITICAL),
7755
+ this.#editor.registerCommand(KEY_TAB_COMMAND, this.#handleSelectedOption.bind(this), COMMAND_PRIORITY_CRITICAL)
7756
+ );
7298
7757
 
7299
7758
  if (this.#doesSpaceSelect) {
7300
- this.keyListeners.push(this.#editor.registerCommand(KEY_SPACE_COMMAND, this.#handleSelectedOption.bind(this), COMMAND_PRIORITY_CRITICAL));
7759
+ this.#popoverListeners.track(this.#editor.registerCommand(KEY_SPACE_COMMAND, this.#handleSelectedOption.bind(this), COMMAND_PRIORITY_CRITICAL));
7301
7760
  }
7302
7761
 
7303
7762
  // Register arrow keys with CRITICAL priority to prevent Lexical's selection handlers from running
7304
- this.keyListeners.push(this.#editor.registerCommand(KEY_ARROW_UP_COMMAND, this.#handleArrowUp.bind(this), COMMAND_PRIORITY_CRITICAL));
7305
- this.keyListeners.push(this.#editor.registerCommand(KEY_ARROW_DOWN_COMMAND, this.#handleArrowDown.bind(this), COMMAND_PRIORITY_CRITICAL));
7763
+ this.#popoverListeners.track(
7764
+ this.#editor.registerCommand(KEY_ARROW_UP_COMMAND, this.#handleArrowUp.bind(this), COMMAND_PRIORITY_CRITICAL),
7765
+ this.#editor.registerCommand(KEY_ARROW_DOWN_COMMAND, this.#handleArrowDown.bind(this), COMMAND_PRIORITY_CRITICAL)
7766
+ );
7306
7767
  }
7307
7768
 
7308
7769
  #handleArrowUp(event) {
@@ -7394,21 +7855,12 @@ class LexicalPromptElement extends HTMLElement {
7394
7855
  this.showPopoverId++;
7395
7856
  this.#clearSelection();
7396
7857
  this.popoverElement.classList.toggle("lexxy-prompt-menu--visible", false);
7397
- this.#editorElement.removeEventListener("lexxy:change", this.#filterOptions);
7398
- this.#editorElement.removeEventListener("keydown", this.#handleKeydownOnPopover);
7399
-
7400
- this.#unregisterKeyListeners();
7401
- this.#removeCursorPositionListener();
7858
+ this.#popoverListeners.dispose();
7402
7859
 
7403
7860
  await nextFrame();
7404
7861
  this.#addTriggerListener();
7405
7862
  }
7406
7863
 
7407
- #unregisterKeyListeners() {
7408
- this.keyListeners.forEach((unregister) => unregister());
7409
- this.keyListeners = [];
7410
- }
7411
-
7412
7864
  #filterOptions = async () => {
7413
7865
  if (this.initialPrompt) {
7414
7866
  this.initialPrompt = false;
@@ -7585,7 +8037,7 @@ class LexicalPromptElement extends HTMLElement {
7585
8037
  popoverContainer.style.position = "absolute";
7586
8038
  popoverContainer.setAttribute("nonce", getNonce());
7587
8039
  popoverContainer.append(...await this.source.buildListItems());
7588
- popoverContainer.addEventListener("click", this.#handlePopoverClick);
8040
+ this.#globalListeners.track(registerEventListener(popoverContainer, "click", this.#handlePopoverClick));
7589
8041
  this.#editorElement.appendChild(popoverContainer);
7590
8042
  return popoverContainer
7591
8043
  }
@@ -7606,12 +8058,14 @@ class LexicalPromptElement extends HTMLElement {
7606
8058
 
7607
8059
  class CodeLanguagePicker extends HTMLElement {
7608
8060
  #abortController = null
8061
+ #listeners = new ListenerBin()
7609
8062
 
7610
8063
  connectedCallback() {
7611
8064
  this.editorElement = this.closest("lexxy-editor");
7612
8065
  this.editor = this.editorElement.editor;
7613
8066
  this.classList.add("lexxy-floating-controls");
7614
8067
  this.#abortController = new AbortController();
8068
+ this.#listeners.track(() => this.#abortController?.abort());
7615
8069
 
7616
8070
  this.#attachLanguagePicker();
7617
8071
  this.#hide();
@@ -7623,10 +8077,7 @@ class CodeLanguagePicker extends HTMLElement {
7623
8077
  }
7624
8078
 
7625
8079
  dispose() {
7626
- this.#abortController?.abort();
7627
- this.#abortController = null;
7628
- this.unregisterUpdateListener?.();
7629
- this.unregisterUpdateListener = null;
8080
+ this.#listeners.dispose();
7630
8081
  }
7631
8082
 
7632
8083
  #attachLanguagePicker() {
@@ -7634,13 +8085,13 @@ class CodeLanguagePicker extends HTMLElement {
7634
8085
 
7635
8086
  const signal = this.#abortController.signal;
7636
8087
 
7637
- this.languagePickerElement.addEventListener("change", () => {
8088
+ this.#listeners.track(registerEventListener(this.languagePickerElement, "change", () => {
7638
8089
  this.#updateCodeBlockLanguage(this.languagePickerElement.value);
7639
- }, { signal });
8090
+ }, { signal }));
7640
8091
 
7641
- this.languagePickerElement.addEventListener("mousedown", (event) => {
8092
+ this.#listeners.track(registerEventListener(this.languagePickerElement, "mousedown", (event) => {
7642
8093
  this.#dispatchOpenEvent(event);
7643
- }, { signal });
8094
+ }, { signal }));
7644
8095
 
7645
8096
  this.languagePickerElement.setAttribute("nonce", getNonce());
7646
8097
  this.appendChild(this.languagePickerElement);
@@ -7666,20 +8117,18 @@ class CodeLanguagePicker extends HTMLElement {
7666
8117
  get #languages() {
7667
8118
  const languages = { ...CODE_LANGUAGE_FRIENDLY_NAME_MAP };
7668
8119
 
7669
- if (!languages.ruby) languages.ruby = "Ruby";
7670
- if (!languages.php) languages.php = "PHP";
7671
- if (!languages.go) languages.go = "Go";
7672
- if (!languages.bash) languages.bash = "Bash";
7673
- if (!languages.json) languages.json = "JSON";
7674
- if (!languages.diff) languages.diff = "Diff";
7675
-
7676
- const sortedEntries = Object.entries(languages)
7677
- .sort(([ , a ], [ , b ]) => a.localeCompare(b));
8120
+ languages.ruby ||= "Ruby";
8121
+ languages.php ||= "PHP";
8122
+ languages.go ||= "Go";
8123
+ languages.bash ||= "Bash";
8124
+ languages.json ||= "JSON";
8125
+ languages.diff ||= "Diff";
7678
8126
 
7679
8127
  // Place the "plain" entry first, then the rest of language sorted alphabetically
7680
- const plainIndex = sortedEntries.findIndex(([ key ]) => key === "plain");
7681
- const plainEntry = sortedEntries.splice(plainIndex, 1)[0];
7682
- return Object.fromEntries([ plainEntry, ...sortedEntries ])
8128
+ delete languages.plain;
8129
+ const sortedEntries = Object.entries(languages)
8130
+ .sort((a, b) => a[1].localeCompare(b[1]));
8131
+ return { plain: "Plain text", ...Object.fromEntries(sortedEntries) }
7683
8132
  }
7684
8133
 
7685
8134
  #dispatchOpenEvent(event) {
@@ -7708,8 +8157,8 @@ class CodeLanguagePicker extends HTMLElement {
7708
8157
  }
7709
8158
 
7710
8159
  #monitorForCodeBlockSelection() {
7711
- this.unregisterUpdateListener = this.editor.registerUpdateListener(() => {
7712
- this.editor.getEditorState().read(() => {
8160
+ this.#listeners.track(this.editor.registerUpdateListener(({ editorState }) => {
8161
+ editorState.read(() => {
7713
8162
  const codeNode = this.#getCurrentCodeNode();
7714
8163
 
7715
8164
  if (codeNode) {
@@ -7718,26 +8167,11 @@ class CodeLanguagePicker extends HTMLElement {
7718
8167
  this.#hide();
7719
8168
  }
7720
8169
  });
7721
- });
8170
+ }));
7722
8171
  }
7723
8172
 
7724
8173
  #getCurrentCodeNode() {
7725
- const selection = $getSelection();
7726
-
7727
- if (!$isRangeSelection(selection)) {
7728
- return null
7729
- }
7730
-
7731
- const anchorNode = selection.anchor.getNode();
7732
- const parentNode = anchorNode.getParent();
7733
-
7734
- if ($isCodeNode(anchorNode)) {
7735
- return anchorNode
7736
- } else if ($isCodeNode(parentNode)) {
7737
- return parentNode
7738
- }
7739
-
7740
- return null
8174
+ return this.editorElement.selection.nearestNodeOfType(CodeNode)
7741
8175
  }
7742
8176
 
7743
8177
  #codeNodeWasSelected(codeNode) {
@@ -7824,6 +8258,8 @@ class NodeDeleteButton extends HTMLElement {
7824
8258
  }
7825
8259
 
7826
8260
  class TableController {
8261
+ #listeners = new ListenerBin()
8262
+
7827
8263
  constructor(editorElement) {
7828
8264
  this.editor = editorElement.editor;
7829
8265
  this.contents = editorElement.contents;
@@ -7839,7 +8275,7 @@ class TableController {
7839
8275
  this.currentTableNodeKey = null;
7840
8276
  this.currentCellKey = null;
7841
8277
 
7842
- this.#unregisterKeyHandlers();
8278
+ this.#listeners.dispose();
7843
8279
  }
7844
8280
 
7845
8281
  get currentCell() {
@@ -8121,16 +8557,10 @@ class TableController {
8121
8557
 
8122
8558
  #registerKeyHandlers() {
8123
8559
  // We can't prevent these externally using regular keydown because Lexical handles it first.
8124
- this.unregisterBackspaceKeyHandler = this.editor.registerCommand(KEY_BACKSPACE_COMMAND, (event) => this.#handleBackspaceKey(event), COMMAND_PRIORITY_HIGH);
8125
- this.unregisterEnterKeyHandler = this.editor.registerCommand(KEY_ENTER_COMMAND, (event) => this.#handleEnterKey(event), COMMAND_PRIORITY_HIGH);
8126
- }
8127
-
8128
- #unregisterKeyHandlers() {
8129
- this.unregisterBackspaceKeyHandler?.();
8130
- this.unregisterEnterKeyHandler?.();
8131
-
8132
- this.unregisterBackspaceKeyHandler = null;
8133
- this.unregisterEnterKeyHandler = null;
8560
+ this.#listeners.track(
8561
+ this.editor.registerCommand(KEY_BACKSPACE_COMMAND, (event) => this.#handleBackspaceKey(event), COMMAND_PRIORITY_HIGH),
8562
+ this.editor.registerCommand(KEY_ENTER_COMMAND, (event) => this.#handleEnterKey(event), COMMAND_PRIORITY_HIGH)
8563
+ );
8134
8564
  }
8135
8565
 
8136
8566
  #handleBackspaceKey(event) {
@@ -8224,6 +8654,8 @@ var TableIcons = {
8224
8654
  };
8225
8655
 
8226
8656
  class TableTools extends HTMLElement {
8657
+ #listeners = new ListenerBin()
8658
+
8227
8659
  connectedCallback() {
8228
8660
  this.tableController = new TableController(this.#editorElement);
8229
8661
  this.classList.add("lexxy-floating-controls");
@@ -8239,12 +8671,7 @@ class TableTools extends HTMLElement {
8239
8671
  }
8240
8672
 
8241
8673
  dispose() {
8242
- this.#unregisterKeyboardShortcuts();
8243
-
8244
- this.unregisterUpdateListener?.();
8245
- this.unregisterUpdateListener = null;
8246
-
8247
- this.removeEventListener("keydown", this.#handleToolsKeydown);
8674
+ this.#listeners.dispose();
8248
8675
 
8249
8676
  this.tableController?.destroy();
8250
8677
  this.tableController = null;
@@ -8269,7 +8696,7 @@ class TableTools extends HTMLElement {
8269
8696
  this.appendChild(this.#createColumnButtonsContainer());
8270
8697
 
8271
8698
  this.appendChild(this.#createDeleteTableButton());
8272
- this.addEventListener("keydown", this.#handleToolsKeydown);
8699
+ this.#listeners.track(registerEventListener(this, "keydown", this.#handleToolsKeydown));
8273
8700
  }
8274
8701
 
8275
8702
  #createButtonsContainer(childType, setCountProperty, moreMenu) {
@@ -8362,12 +8789,7 @@ class TableTools extends HTMLElement {
8362
8789
  }
8363
8790
 
8364
8791
  #registerKeyboardShortcuts() {
8365
- this.unregisterKeyboardShortcuts = this.#editor.registerCommand(KEY_DOWN_COMMAND, this.#handleAccessibilityShortcutKey, COMMAND_PRIORITY_HIGH);
8366
- }
8367
-
8368
- #unregisterKeyboardShortcuts() {
8369
- this.unregisterKeyboardShortcuts?.();
8370
- this.unregisterKeyboardShortcuts = null;
8792
+ this.#listeners.track(this.#editor.registerCommand(KEY_DOWN_COMMAND, this.#handleAccessibilityShortcutKey, COMMAND_PRIORITY_HIGH));
8371
8793
  }
8372
8794
 
8373
8795
  #handleAccessibilityShortcutKey = (event) => {
@@ -8437,7 +8859,7 @@ class TableTools extends HTMLElement {
8437
8859
  }
8438
8860
 
8439
8861
  #monitorForTableSelection() {
8440
- this.unregisterUpdateListener = this.#editor.registerUpdateListener(() => {
8862
+ this.#listeners.track(this.#editor.registerUpdateListener(() => {
8441
8863
  this.tableController.updateSelectedTable();
8442
8864
 
8443
8865
  const tableNode = this.tableController.currentTableNode;
@@ -8446,7 +8868,7 @@ class TableTools extends HTMLElement {
8446
8868
  } else {
8447
8869
  this.#hide();
8448
8870
  }
8449
- });
8871
+ }));
8450
8872
  }
8451
8873
 
8452
8874
  #executeTableCommand(command) {