@37signals/lexxy 0.9.2-beta → 0.9.4-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, $createNodeSelection, $isDecoratorNode, $isLineBreakNode, $isElementNode, HISTORY_MERGE_TAG, SKIP_SCROLL_INTO_VIEW_TAG, $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';
9
+ import { $getNearestNodeOfType, $wrapNodeInElement, $lastToFirstIterator, mergeRegister, $insertFirst, $unwrapAndFilterDescendants, $firstToLastIterator, $getNearestBlockElementAncestorOrThrow, $descendantsMatching } from '@lexical/utils';
17
10
  import { registerPlainText } from '@lexical/plain-text';
18
11
  import { RichTextExtension, $isQuoteNode, $isHeadingNode, $createHeadingNode, $createQuoteNode, QuoteNode, HeadingNode, registerRichText } from '@lexical/rich-text';
19
12
  import { $generateNodesFromDOM, $generateHtmlFromNodes } from '@lexical/html';
20
13
  import { $isCodeNode, CodeHighlightNode, CodeNode, $createCodeNode, $isCodeHighlightNode, $createCodeHighlightNode, normalizeCodeLang, registerCodeHighlighting, CODE_LANGUAGE_FRIENDLY_NAME_MAP } from '@lexical/code';
21
- import { registerMarkdownShortcuts, TRANSFORMERS } from '@lexical/markdown';
14
+ import { TRANSFORMERS, registerMarkdownShortcuts } from '@lexical/markdown';
22
15
  import { createEmptyHistoryState, registerHistory } from '@lexical/history';
23
- import { createElement, extractPlainTextFromHtml, createAttachmentFigure, isPreviewableImage, dispatch, parseHtml, addBlockSpacing, generateDomId } from './lexxy_helpers.esm.js';
24
- export { highlightCode as highlightAll, highlightCode } from './lexxy_helpers.esm.js';
25
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';
26
- import { $getNearestNodeOfType, $wrapNodeInElement, $lastToFirstIterator, mergeRegister, $insertFirst, $unwrapAndFilterDescendants, $firstToLastIterator, $descendantsMatching } from '@lexical/utils';
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 }
1121
+ const global = new Configuration({
1122
+ attachmentTagName: "action-text-attachment",
1123
+ attachmentContentTypeNamespace: "actiontext",
1124
+ authenticatedUploads: false,
1125
+ extensions: []
1126
+ });
1127
+
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
+ }
1146
+ }
1113
1147
  }
1148
+ });
1114
1149
 
1115
- exportJSON() {
1116
- return {
1117
- type: "horizontal_divider",
1118
- version: 1
1150
+ var Lexxy = {
1151
+ global,
1152
+ presets,
1153
+ configure({ global: newGlobal, ...newPresets }) {
1154
+ if (newGlobal) {
1155
+ global.merge(newGlobal);
1119
1156
  }
1157
+ presets.merge(newPresets);
1120
1158
  }
1159
+ };
1121
1160
 
1122
- decorate() {
1123
- return null
1124
- }
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);
@@ -1409,6 +1455,24 @@ function isSelectionHighlighted(selection) {
1409
1455
  }
1410
1456
  }
1411
1457
 
1458
+ function getHighlightStyles(selection) {
1459
+ if (!$isRangeSelection(selection)) return null
1460
+
1461
+ let styles = getStyleObjectFromCSS(selection.style);
1462
+ if (!styles.color && !styles["background-color"]) {
1463
+ const anchorNode = selection.anchor.getNode();
1464
+ if ($isTextNode(anchorNode)) {
1465
+ styles = getStyleObjectFromCSS(anchorNode.getStyle());
1466
+ }
1467
+ }
1468
+
1469
+ const color = styles.color || null;
1470
+ const backgroundColor = styles["background-color"] || null;
1471
+ if (!color && !backgroundColor) return null
1472
+
1473
+ return { color, backgroundColor }
1474
+ }
1475
+
1412
1476
  function hasHighlightStyles(cssOrStyles) {
1413
1477
  const styles = typeof cssOrStyles === "string" ? getStyleObjectFromCSS(cssOrStyles) : cssOrStyles;
1414
1478
  return !!(styles.color || styles["background-color"])
@@ -1496,6 +1560,10 @@ class LexxyExtension {
1496
1560
  return null
1497
1561
  }
1498
1562
 
1563
+ get allowedElements() {
1564
+ return []
1565
+ }
1566
+
1499
1567
  initializeToolbar(_lexxyToolbar) {
1500
1568
 
1501
1569
  }
@@ -1718,6 +1786,13 @@ function $applyHighlightRangesToCodeNode(codeNode, highlights) {
1718
1786
  const childRanges = $buildChildRanges(codeNode);
1719
1787
 
1720
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
+
1721
1796
  // Check if this child overlaps with the highlight range
1722
1797
  const overlapStart = Math.max(hlStart, nodeStart);
1723
1798
  const overlapEnd = Math.min(hlEnd, nodeEnd);
@@ -1766,7 +1841,7 @@ function $buildChildRanges(codeNode) {
1766
1841
  let charOffset = 0;
1767
1842
 
1768
1843
  for (const child of codeNode.getChildren()) {
1769
- if ($isCodeHighlightNode(child)) {
1844
+ if ($isCodeHighlightNode(child) || $isTextNode(child)) {
1770
1845
  const text = child.getTextContent();
1771
1846
  childRanges.push({ node: child, start: charOffset, end: charOffset + text.length });
1772
1847
  charOffset += text.length;
@@ -1779,6 +1854,23 @@ function $buildChildRanges(codeNode) {
1779
1854
  return childRanges
1780
1855
  }
1781
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
+
1782
1874
  function buildCanonicalizers(config) {
1783
1875
  return [
1784
1876
  new StyleCanonicalizer("color", [ ...config.buttons.color, ...config.permit.color ]),
@@ -1806,15 +1898,23 @@ function $toggleSelectionStyles(editor, styles) {
1806
1898
  function $selectionIsInCodeBlock(selection) {
1807
1899
  const nodes = selection.getNodes();
1808
1900
  return nodes.some((node) => {
1809
- const parent = $isCodeHighlightNode(node) ? node.getParent() : node;
1810
- 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)
1811
1908
  })
1812
1909
  }
1813
1910
 
1814
1911
  function $patchCodeHighlightStyles(editor, selection, patch) {
1815
- // 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.
1816
1916
  const nodeKeys = selection.getNodes()
1817
- .filter((node) => $isCodeHighlightNode(node))
1917
+ .filter((node) => ($isCodeHighlightNode(node) || $isTextNode(node)) && $isCodeNode(node.getParent()))
1818
1918
  .map((node) => ({
1819
1919
  key: node.getKey(),
1820
1920
  startOffset: $getNodeSelectionOffsets(node, selection)[0],
@@ -1828,14 +1928,18 @@ function $patchCodeHighlightStyles(editor, selection, patch) {
1828
1928
  // are committed before editor.focus() triggers a second update cycle
1829
1929
  // that would re-run transforms and wipe out the styles.
1830
1930
  editor.update(() => {
1931
+ const affectedCodeNodes = new Set();
1932
+
1831
1933
  for (const { key, startOffset, endOffset, textSize } of nodeKeys) {
1832
1934
  const node = $getNodeByKey(key);
1833
- if (!node || !$isCodeHighlightNode(node)) continue
1935
+ if (!node) continue
1834
1936
 
1835
1937
  const parent = node.getParent();
1836
1938
  if (!$isCodeNode(parent)) continue
1837
1939
  if (startOffset === endOffset) continue
1838
1940
 
1941
+ affectedCodeNodes.add(parent);
1942
+
1839
1943
  if (startOffset === 0 && endOffset === textSize) {
1840
1944
  $applyStylePatchToNode(node, patch);
1841
1945
  } else {
@@ -1844,6 +1948,17 @@ function $patchCodeHighlightStyles(editor, selection, patch) {
1844
1948
  $applyStylePatchToNode(targetNode, patch);
1845
1949
  }
1846
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
+ }
1847
1962
  }, { skipTransforms: true, discrete: true });
1848
1963
  }
1849
1964
 
@@ -1967,10 +2082,12 @@ const COMMANDS = [
1967
2082
  "setFormatHeadingMedium",
1968
2083
  "setFormatHeadingSmall",
1969
2084
  "setFormatParagraph",
2085
+ "clearFormatting",
1970
2086
  "insertUnorderedList",
1971
2087
  "insertOrderedList",
1972
2088
  "insertQuoteBlock",
1973
2089
  "insertCodeBlock",
2090
+ "setCodeLanguage",
1974
2091
  "insertHorizontalDivider",
1975
2092
  "uploadImage",
1976
2093
  "uploadFile",
@@ -1983,7 +2100,7 @@ const COMMANDS = [
1983
2100
 
1984
2101
  class CommandDispatcher {
1985
2102
  #selectionBeforeDrag = null
1986
- #unregister = []
2103
+ #listeners = new ListenerBin()
1987
2104
 
1988
2105
  static configureFor(editorElement) {
1989
2106
  return new CommandDispatcher(editorElement)
@@ -2046,7 +2163,14 @@ class CommandDispatcher {
2046
2163
  }
2047
2164
 
2048
2165
  dispatchUnlink() {
2049
- this.#toggleLink(null);
2166
+ this.editor.update(() => {
2167
+ // Let adapters signal whether unlink should target a frozen link key.
2168
+ if (this.editorElement.adapter.unlinkFrozenNode?.()) {
2169
+ return
2170
+ }
2171
+
2172
+ $toggleLink(null);
2173
+ });
2050
2174
  }
2051
2175
 
2052
2176
  dispatchInsertUnorderedList() {
@@ -2058,7 +2182,7 @@ class CommandDispatcher {
2058
2182
  if (this.selection.isInsideList && anchorNode && getListType(anchorNode) === "bullet") {
2059
2183
  this.contents.applyParagraphFormat();
2060
2184
  } else {
2061
- this.editor.dispatchCommand(INSERT_UNORDERED_LIST_COMMAND, undefined);
2185
+ this.contents.applyUnorderedListFormat();
2062
2186
  }
2063
2187
  }
2064
2188
 
@@ -2071,7 +2195,7 @@ class CommandDispatcher {
2071
2195
  if (this.selection.isInsideList && anchorNode && getListType(anchorNode) === "number") {
2072
2196
  this.contents.applyParagraphFormat();
2073
2197
  } else {
2074
- this.editor.dispatchCommand(INSERT_ORDERED_LIST_COMMAND, undefined);
2198
+ this.contents.applyOrderedListFormat();
2075
2199
  }
2076
2200
  }
2077
2201
 
@@ -2137,6 +2261,17 @@ class CommandDispatcher {
2137
2261
  }
2138
2262
  }
2139
2263
 
2264
+ dispatchSetCodeLanguage(language) {
2265
+ this.editor.update(() => {
2266
+ if (!this.selection.isInsideCodeBlock) return
2267
+
2268
+ const codeNode = this.selection.nearestNodeOfType(CodeNode);
2269
+ if (!codeNode) return
2270
+
2271
+ codeNode.setLanguage(language);
2272
+ });
2273
+ }
2274
+
2140
2275
  dispatchInsertHorizontalDivider() {
2141
2276
  this.contents.insertAtCursorEnsuringLineBelow(new HorizontalDividerNode());
2142
2277
  this.editor.focus();
@@ -2158,6 +2293,10 @@ class CommandDispatcher {
2158
2293
  this.contents.applyParagraphFormat();
2159
2294
  }
2160
2295
 
2296
+ dispatchClearFormatting() {
2297
+ this.contents.clearFormatting();
2298
+ }
2299
+
2161
2300
  dispatchUploadImage() {
2162
2301
  this.#dispatchUploadAttachment("image/*,video/*");
2163
2302
  }
@@ -2199,10 +2338,7 @@ class CommandDispatcher {
2199
2338
  }
2200
2339
 
2201
2340
  dispose() {
2202
- while (this.#unregister.length) {
2203
- const unregister = this.#unregister.pop();
2204
- unregister();
2205
- }
2341
+ this.#listeners.dispose();
2206
2342
  }
2207
2343
 
2208
2344
  #registerCommands() {
@@ -2215,7 +2351,7 @@ class CommandDispatcher {
2215
2351
  }
2216
2352
 
2217
2353
  #registerCommandHandler(command, priority, handler) {
2218
- this.#unregister.push(this.editor.registerCommand(command, handler, priority));
2354
+ this.#listeners.track(this.editor.registerCommand(command, handler, priority));
2219
2355
  }
2220
2356
 
2221
2357
  #registerKeyboardCommands() {
@@ -2240,10 +2376,13 @@ class CommandDispatcher {
2240
2376
  #registerDragAndDropHandlers() {
2241
2377
  if (this.editorElement.supportsAttachments) {
2242
2378
  this.dragCounter = 0;
2243
- this.editor.getRootElement().addEventListener("dragover", this.#handleDragOver.bind(this));
2244
- this.editor.getRootElement().addEventListener("drop", this.#handleDrop.bind(this));
2245
- this.editor.getRootElement().addEventListener("dragenter", this.#handleDragEnter.bind(this));
2246
- 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
+ );
2247
2386
  }
2248
2387
  }
2249
2388
 
@@ -2335,16 +2474,6 @@ class CommandDispatcher {
2335
2474
  return $isRangeSelection(selection) && selection.isCollapsed()
2336
2475
  }
2337
2476
 
2338
- // Not using TOGGLE_LINK_COMMAND because it's not handled unless you use React/LinkPlugin
2339
- #toggleLink(url) {
2340
- this.editor.update(() => {
2341
- if (url === null) {
2342
- $toggleLink(null);
2343
- } else {
2344
- $toggleLink(url);
2345
- }
2346
- });
2347
- }
2348
2477
  }
2349
2478
 
2350
2479
  function capitalize(str) {
@@ -2482,13 +2611,15 @@ class ActionTextAttachmentNode extends DecoratorNode {
2482
2611
  return Lexxy.global.get("attachmentTagName")
2483
2612
  }
2484
2613
 
2485
- 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) {
2486
2615
  super(key);
2487
2616
 
2488
2617
  this.tagName = tagName || ActionTextAttachmentNode.TAG_NAME;
2489
2618
  this.sgid = sgid;
2490
2619
  this.src = src;
2620
+ this.previewSrc = previewSrc;
2491
2621
  this.previewable = parseBoolean(previewable);
2622
+ this.pendingPreview = pendingPreview;
2492
2623
  this.altText = altText || "";
2493
2624
  this.caption = caption || "";
2494
2625
  this.contentType = contentType || "";
@@ -2496,16 +2627,23 @@ class ActionTextAttachmentNode extends DecoratorNode {
2496
2627
  this.fileSize = fileSize;
2497
2628
  this.width = width;
2498
2629
  this.height = height;
2630
+ this.uploadError = uploadError;
2499
2631
 
2500
2632
  this.editor = $getEditor();
2501
2633
  }
2502
2634
 
2503
2635
  createDOM() {
2636
+ if (this.uploadError) return this.createDOMForError()
2637
+ if (this.pendingPreview) return this.#createDOMForPendingPreview()
2638
+
2504
2639
  const figure = this.createAttachmentFigure();
2505
2640
 
2506
2641
  if (this.isPreviewableAttachment) {
2507
2642
  figure.appendChild(this.#createDOMForImage());
2508
2643
  figure.appendChild(this.#createEditableCaption());
2644
+ } else if (this.isVideo) {
2645
+ figure.appendChild(this.#createDOMForFile());
2646
+ figure.appendChild(this.#createEditableCaption());
2509
2647
  } else {
2510
2648
  figure.appendChild(this.#createDOMForFile());
2511
2649
  figure.appendChild(this.#createDOMForNotImage());
@@ -2514,7 +2652,9 @@ class ActionTextAttachmentNode extends DecoratorNode {
2514
2652
  return figure
2515
2653
  }
2516
2654
 
2517
- updateDOM(_prevNode, dom) {
2655
+ updateDOM(prevNode, dom) {
2656
+ if (this.uploadError !== prevNode.uploadError) return true
2657
+
2518
2658
  const caption = dom.querySelector("figcaption textarea");
2519
2659
  if (caption && this.caption) {
2520
2660
  caption.value = this.caption;
@@ -2571,8 +2711,15 @@ class ActionTextAttachmentNode extends DecoratorNode {
2571
2711
  return null
2572
2712
  }
2573
2713
 
2574
- createAttachmentFigure() {
2575
- const figure = createAttachmentFigure(this.contentType, this.isPreviewableAttachment, this.fileName);
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
+
2721
+ createAttachmentFigure(previewable = this.isPreviewableAttachment) {
2722
+ const figure = createAttachmentFigure(this.contentType, previewable, this.fileName);
2576
2723
  figure.draggable = true;
2577
2724
  figure.dataset.lexicalNodeKey = this.__key;
2578
2725
 
@@ -2590,32 +2737,147 @@ class ActionTextAttachmentNode extends DecoratorNode {
2590
2737
  return isPreviewableImage(this.contentType)
2591
2738
  }
2592
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
+
2593
2752
  #createDOMForImage(options = {}) {
2594
- 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 });
2595
2755
 
2596
2756
  if (this.previewable && !this.isPreviewableImage) {
2597
2757
  img.onerror = () => this.#swapPreviewToFileDOM(img);
2598
2758
  }
2599
2759
 
2760
+ if (this.previewSrc) {
2761
+ this.#preloadAndSwapSrc(img);
2762
+ }
2763
+
2600
2764
  const container = createElement("div", { className: "attachment__container" });
2601
2765
  container.appendChild(img);
2602
2766
  return container
2603
2767
  }
2604
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
+
2605
2811
  #swapPreviewToFileDOM(img) {
2606
2812
  const figure = img.closest("figure.attachment");
2607
2813
  if (!figure) return
2608
2814
 
2609
- 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;
2610
2824
 
2611
- const container = figure.querySelector(".attachment__container");
2612
- if (container) container.remove();
2825
+ const tryLoad = () => {
2826
+ if (!this.editor.read(() => this.isAttached())) return
2613
2827
 
2614
- const caption = figure.querySelector("figcaption");
2615
- if (caption) caption.remove();
2828
+ const img = new Image();
2829
+ const cacheBustedSrc = `${this.src}${this.src.includes("?") ? "&" : "?"}_=${Date.now()}`;
2616
2830
 
2617
- figure.appendChild(this.#createDOMForFile());
2618
- 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();
2619
2881
  }
2620
2882
 
2621
2883
  get #imageDimensions() {
@@ -2706,7 +2968,7 @@ function $isActionTextAttachmentNode(node) {
2706
2968
  }
2707
2969
 
2708
2970
  class Selection {
2709
- #unregister = []
2971
+ #listeners = new ListenerBin()
2710
2972
 
2711
2973
  constructor(editorElement) {
2712
2974
  this.editorElement = editorElement;
@@ -2857,6 +3119,15 @@ class Selection {
2857
3119
  const anchorElement = anchorNode.getTopLevelElement();
2858
3120
  if (!anchorElement) return false
2859
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
+
2860
3131
  const nodes = selection.getNodes();
2861
3132
  for (const node of nodes) {
2862
3133
  if ($isLineBreakNode(node)) {
@@ -2970,10 +3241,7 @@ class Selection {
2970
3241
  this.editor = null;
2971
3242
  this.previouslySelectedKeys = null;
2972
3243
 
2973
- while (this.#unregister.length) {
2974
- const unregister = this.#unregister.pop();
2975
- unregister();
2976
- }
3244
+ this.#listeners.dispose();
2977
3245
  }
2978
3246
 
2979
3247
  // When all inline code text is deleted, Lexical's selection retains the stale
@@ -2991,7 +3259,7 @@ class Selection {
2991
3259
  // detects that stale state and clears it so newly typed text won't be
2992
3260
  // code-formatted.
2993
3261
  #clearStaleInlineCodeFormat() {
2994
- this.#unregister.push(this.editor.registerUpdateListener(({ editorState, tags }) => {
3262
+ this.#listeners.track(this.editor.registerUpdateListener(({ editorState, tags }) => {
2995
3263
  if (tags.has("history-merge") || tags.has("skip-dom-selection")) return
2996
3264
 
2997
3265
  let isStale = false;
@@ -3039,7 +3307,7 @@ class Selection {
3039
3307
  }
3040
3308
 
3041
3309
  #processSelectionChangeCommands() {
3042
- this.#unregister.push(mergeRegister$1(
3310
+ this.#listeners.track(
3043
3311
  this.editor.registerCommand(KEY_ARROW_LEFT_COMMAND, this.#selectPreviousNode.bind(this), COMMAND_PRIORITY_LOW),
3044
3312
  this.editor.registerCommand(KEY_ARROW_RIGHT_COMMAND, this.#selectNextNode.bind(this), COMMAND_PRIORITY_LOW),
3045
3313
  this.editor.registerCommand(KEY_ARROW_UP_COMMAND, this.#selectPreviousTopLevelNode.bind(this), COMMAND_PRIORITY_LOW),
@@ -3050,21 +3318,21 @@ class Selection {
3050
3318
  this.editor.registerCommand(SELECTION_CHANGE_COMMAND, () => {
3051
3319
  this.current = $getSelection();
3052
3320
  }, COMMAND_PRIORITY_LOW)
3053
- ));
3321
+ );
3054
3322
  }
3055
3323
 
3056
3324
  #listenForNodeSelections() {
3057
- this.#unregister.push(this.editor.registerCommand(CLICK_COMMAND, ({ target }) => {
3325
+ this.#listeners.track(this.editor.registerCommand(CLICK_COMMAND, ({ target }) => {
3058
3326
  if (!isDOMNode(target)) return false
3059
3327
 
3060
3328
  const targetNode = $getNearestNodeFromDOMNode(target);
3061
3329
  return $isDecoratorNode(targetNode) && this.#selectInLexical(targetNode)
3062
3330
  }, COMMAND_PRIORITY_LOW));
3063
3331
 
3064
- const moveNextLineHandler = () => this.#selectOrAppendNextLine();
3065
3332
  const rootElement = this.editor.getRootElement();
3066
- rootElement.addEventListener("lexxy:internal:move-to-next-line", moveNextLineHandler);
3067
- 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
+ );
3068
3336
  }
3069
3337
 
3070
3338
  #containEditorFocus() {
@@ -3269,13 +3537,20 @@ class Selection {
3269
3537
  }
3270
3538
 
3271
3539
  // When backspace is pressed on an empty list item that has siblings,
3272
- // remove the empty item and place the cursor appropriately. Without this,
3273
- // Lexical's default collapseAtStart converts the empty item into a paragraph
3274
- // 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.
3275
3546
  //
3276
- // This only applies when there IS a next sibling if the empty item is the
3277
- // last one in the list, Lexical's default (convert to paragraph) provides
3278
- // the standard "exit list" behavior.
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.
3550
+ //
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.
3279
3554
  #removeEmptyListItem() {
3280
3555
  const selection = $getSelection();
3281
3556
  if (!$isRangeSelection(selection) || !selection.isCollapsed()) return false
@@ -3292,11 +3567,17 @@ class Selection {
3292
3567
  const previousSibling = listItem.getPreviousSibling();
3293
3568
  if (previousSibling) {
3294
3569
  previousSibling.selectEnd();
3295
- } else {
3296
- nextSibling.selectStart();
3570
+ listItem.remove();
3571
+ return true
3297
3572
  }
3298
3573
 
3574
+ const listNode = $getNearestNodeOfType(listItem, ListNode);
3575
+ if (!listNode) return false
3576
+
3577
+ const paragraph = $createParagraphNode();
3578
+ listNode.insertBefore(paragraph);
3299
3579
  listItem.remove();
3580
+ paragraph.selectStart();
3300
3581
  return true
3301
3582
  }
3302
3583
 
@@ -3556,10 +3837,6 @@ class Selection {
3556
3837
  }
3557
3838
  }
3558
3839
 
3559
- function sanitize(html) {
3560
- return DOMPurify.sanitize(html, buildConfig())
3561
- }
3562
-
3563
3840
  class EditorConfiguration {
3564
3841
  #editorElement
3565
3842
  #config
@@ -3602,6 +3879,107 @@ class EditorConfiguration {
3602
3879
  }
3603
3880
  }
3604
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
+
3605
3983
  async function loadFileIntoImage(file, image) {
3606
3984
  return new Promise((resolve) => {
3607
3985
  const reader = new FileReader();
@@ -3650,16 +4028,19 @@ class ActionTextAttachmentUploadNode extends ActionTextAttachmentNode {
3650
4028
  }
3651
4029
 
3652
4030
  createDOM() {
3653
- if (this.uploadError) return this.#createDOMForError()
4031
+ if (this.uploadError) return this.createDOMForError()
3654
4032
 
3655
4033
  // This side-effect is trigged on DOM load to fire only once and avoid multiple
3656
4034
  // uploads through cloning. The upload is guarded from restarting in case the
3657
4035
  // node is reloaded from saved state such as from history.
3658
4036
  this.#startUploadIfNeeded();
3659
4037
 
3660
- const figure = this.createAttachmentFigure();
4038
+ // Bridge-managed uploads (uploadUrl is null) don't have file data to show
4039
+ // an image preview, so always show the file icon during upload.
4040
+ const canPreviewFile = this.isPreviewableAttachment && this.uploadUrl != null;
4041
+ const figure = this.createAttachmentFigure(canPreviewFile);
3661
4042
 
3662
- if (this.isPreviewableAttachment) {
4043
+ if (canPreviewFile) {
3663
4044
  const img = figure.appendChild(this.#createDOMForImage());
3664
4045
 
3665
4046
  // load file locally to set dimensions and prevent vertical shifting
@@ -3707,13 +4088,6 @@ class ActionTextAttachmentUploadNode extends ActionTextAttachmentNode {
3707
4088
  return this.progress !== null
3708
4089
  }
3709
4090
 
3710
- #createDOMForError() {
3711
- const figure = this.createAttachmentFigure();
3712
- figure.classList.add("attachment--error");
3713
- figure.appendChild(createElement("div", { innerText: `Error uploading ${this.file?.name ?? "file"}` }));
3714
- return figure
3715
- }
3716
-
3717
4091
  #createDOMForImage() {
3718
4092
  return createElement("img")
3719
4093
  }
@@ -3759,6 +4133,7 @@ class ActionTextAttachmentUploadNode extends ActionTextAttachmentNode {
3759
4133
 
3760
4134
  async #startUploadIfNeeded() {
3761
4135
  if (this.#uploadStarted) return
4136
+ if (!this.uploadUrl) return // Bridge-managed upload — skip DirectUpload
3762
4137
 
3763
4138
  this.#setUploadStarted();
3764
4139
 
@@ -3775,7 +4150,9 @@ class ActionTextAttachmentUploadNode extends ActionTextAttachmentNode {
3775
4150
  this.#handleUploadError(error);
3776
4151
  } else {
3777
4152
  this.#dispatchEvent("lexxy:upload-end", { file: this.file, error: null });
3778
- this.#showUploadedAttachment(blob);
4153
+ this.editor.update(() => {
4154
+ this.showUploadedAttachment(blob);
4155
+ }, { tag: this.#backgroundUpdateTags });
3779
4156
  }
3780
4157
  });
3781
4158
  }
@@ -3819,17 +4196,18 @@ class ActionTextAttachmentUploadNode extends ActionTextAttachmentNode {
3819
4196
  }, { tag: this.#backgroundUpdateTags });
3820
4197
  }
3821
4198
 
3822
- #showUploadedAttachment(blob) {
3823
- const editorHasFocus = this.#editorHasFocus;
4199
+ showUploadedAttachment(blob) {
4200
+ const previewSrc = this.isPreviewableImage && this.file ? URL.createObjectURL(this.file) : null;
3824
4201
 
3825
- this.editor.update(() => {
3826
- const replacementNode = this.#toActionTextAttachmentNodeWith(blob);
3827
- this.replace(replacementNode);
4202
+ const replacementNode = this.#toActionTextAttachmentNodeWith(blob, previewSrc);
4203
+ const shouldSelectAfterReplacement = this.#selectionIncludesUploadNode;
4204
+ this.replace(replacementNode);
3828
4205
 
3829
- if (editorHasFocus && $isRootOrShadowRoot(replacementNode.getParent())) {
3830
- replacementNode.selectNext();
3831
- }
3832
- }, { tag: this.#backgroundUpdateTags });
4206
+ if (shouldSelectAfterReplacement && $isRootOrShadowRoot(replacementNode.getParent())) {
4207
+ replacementNode.selectNext();
4208
+ }
4209
+
4210
+ return replacementNode.getKey()
3833
4211
  }
3834
4212
 
3835
4213
  // Upload lifecycle methods (progress, completion, errors) run asynchronously and may
@@ -3849,8 +4227,22 @@ class ActionTextAttachmentUploadNode extends ActionTextAttachmentNode {
3849
4227
  return rootElement !== null && rootElement.contains(document.activeElement)
3850
4228
  }
3851
4229
 
3852
- #toActionTextAttachmentNodeWith(blob) {
3853
- 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);
3854
4246
  return conversion.toAttachmentNode()
3855
4247
  }
3856
4248
 
@@ -3861,16 +4253,19 @@ class ActionTextAttachmentUploadNode extends ActionTextAttachmentNode {
3861
4253
  }
3862
4254
 
3863
4255
  class AttachmentNodeConversion {
3864
- constructor(uploadNode, blob) {
4256
+ constructor(uploadNode, blob, previewSrc) {
3865
4257
  this.uploadNode = uploadNode;
3866
4258
  this.blob = blob;
4259
+ this.previewSrc = previewSrc;
3867
4260
  }
3868
4261
 
3869
4262
  toAttachmentNode() {
3870
4263
  return new ActionTextAttachmentNode({
3871
4264
  ...this.uploadNode,
3872
4265
  ...this.#propertiesFromBlob,
3873
- src: this.#src
4266
+ src: this.#src,
4267
+ previewSrc: this.previewSrc,
4268
+ pendingPreview: this.blob.previewable && !this.uploadNode.isPreviewableImage
3874
4269
  })
3875
4270
  }
3876
4271
 
@@ -4109,7 +4504,7 @@ class Uploader {
4109
4504
  }
4110
4505
 
4111
4506
  $insertUploadNodes() {
4112
- this.nodes.forEach(this.contents.insertAtCursor);
4507
+ this.contents.insertAtCursor(...this.nodes);
4113
4508
  }
4114
4509
 
4115
4510
  get #nodeUrlProperties() {
@@ -4206,43 +4601,30 @@ class Contents {
4206
4601
  this.editor = null;
4207
4602
  }
4208
4603
 
4604
+ get selection() { return this.editorElement.selection }
4605
+
4209
4606
  insertHtml(html, { tag } = {}) {
4210
4607
  this.insertDOM(parseHtml(html), { tag });
4211
4608
  }
4212
4609
 
4213
4610
  insertDOM(doc, { tag } = {}) {
4214
4611
  this.#unwrapPlaceholderAnchors(doc);
4215
- if (tag === PASTE_TAG) this.#stripTableCellColorStyles(doc);
4216
4612
 
4217
4613
  this.editor.update(() => {
4218
- const selection = $getSelection();
4219
- if (!$isRangeSelection(selection)) return
4614
+ if ($hasUpdateTag(PASTE_TAG)) this.#stripTableCellColorStyles(doc);
4220
4615
 
4221
4616
  const nodes = $generateNodesFromDOM(this.editor, doc);
4222
4617
  if (!this.#insertUploadNodes(nodes)) {
4223
- selection.insertNodes(nodes);
4618
+ this.insertAtCursor(...nodes);
4224
4619
  }
4225
4620
  }, { tag });
4226
4621
  }
4227
4622
 
4228
- insertAtCursor(node) {
4229
- let selection = $getSelection() ?? $getRoot().selectEnd();
4230
- const selectedNodes = selection?.getNodes();
4623
+ insertAtCursor(...nodes) {
4624
+ const selection = $getSelection() ?? $getRoot().selectEnd();
4625
+ const inserter = NodeInserter.for(selection);
4231
4626
 
4232
- if ($isRangeSelection(selection)) {
4233
- const anchorNode = selection.anchor.getNode();
4234
- if ($isShadowRoot(anchorNode)) {
4235
- const paragraph = $createParagraphNode();
4236
- anchorNode.append(paragraph);
4237
- selection = paragraph.selectStart();
4238
- }
4239
- selection.insertNodes([ node ]);
4240
- } else if ($isNodeSelection(selection) && selectedNodes.length > 0) {
4241
- // Overrides Lexical's default behavior of _removing_ the currently selected nodes
4242
- // https://github.com/facebook/lexical/blob/v0.38.2/packages/lexical/src/LexicalSelection.ts#L412
4243
- const lastNode = selectedNodes.at(-1);
4244
- lastNode.insertAfter(node);
4245
- }
4627
+ inserter.insertNodes(nodes);
4246
4628
  }
4247
4629
 
4248
4630
  insertAtCursorEnsuringLineBelow(node) {
@@ -4264,11 +4646,30 @@ class Contents {
4264
4646
  $setBlocksType(selection, () => $createHeadingNode(tag));
4265
4647
  }
4266
4648
 
4267
- #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() {
4268
4660
  const selection = $getSelection();
4269
4661
  if (!$isRangeSelection(selection)) return
4270
4662
 
4271
- $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());
4272
4673
  }
4273
4674
 
4274
4675
  toggleCodeBlock() {
@@ -4277,12 +4678,16 @@ class Contents {
4277
4678
 
4278
4679
  if (this.#insertNodeIfRoot($createCodeNode("plain"))) return
4279
4680
 
4280
- const topLevelElement = selection.anchor.getNode().getTopLevelElementOrThrow();
4681
+ const blockElements = this.#blockLevelElementsInSelection(selection);
4682
+ const allCode = blockElements.every($isCodeNode);
4281
4683
 
4282
- if (topLevelElement && !$isCodeNode(topLevelElement)) {
4283
- this.#applyCodeBlockFormat();
4684
+ if (allCode) {
4685
+ blockElements.forEach(node => this.#unwrapCodeBlock(node));
4284
4686
  } else {
4285
- this.applyParagraphFormat();
4687
+ const codeNode = $createCodeNode("plain");
4688
+ blockElements.at(-1).insertAfter(codeNode);
4689
+ codeNode.selectEnd();
4690
+ this.insertAtCursor(...blockElements);
4286
4691
  }
4287
4692
  }
4288
4693
 
@@ -4431,6 +4836,53 @@ class Contents {
4431
4836
  });
4432
4837
  }
4433
4838
 
4839
+ insertPendingAttachment(file) {
4840
+ if (!this.editorElement.supportsAttachments) return null
4841
+
4842
+ let nodeKey = null;
4843
+ this.editor.update(() => {
4844
+ const uploadNode = new ActionTextAttachmentUploadNode({
4845
+ file,
4846
+ uploadUrl: null,
4847
+ blobUrlTemplate: this.editorElement.blobUrlTemplate,
4848
+ editor: this.editor
4849
+ });
4850
+ this.insertAtCursor(uploadNode);
4851
+ nodeKey = uploadNode.getKey();
4852
+ }, { tag: HISTORY_MERGE_TAG });
4853
+
4854
+ if (!nodeKey) return null
4855
+
4856
+ const editor = this.editor;
4857
+ return {
4858
+ setAttributes(blob) {
4859
+ editor.update(() => {
4860
+ const node = $getNodeByKey(nodeKey);
4861
+ if (!(node instanceof ActionTextAttachmentUploadNode)) return
4862
+
4863
+ const replacementNodeKey = node.showUploadedAttachment(blob);
4864
+ if (replacementNodeKey) {
4865
+ nodeKey = replacementNodeKey;
4866
+ }
4867
+ }, { tag: HISTORY_MERGE_TAG });
4868
+ },
4869
+ setUploadProgress(progress) {
4870
+ editor.update(() => {
4871
+ const node = $getNodeByKey(nodeKey);
4872
+ if (!(node instanceof ActionTextAttachmentUploadNode)) return
4873
+
4874
+ node.getWritable().progress = progress;
4875
+ }, { tag: HISTORY_MERGE_TAG });
4876
+ },
4877
+ remove() {
4878
+ editor.update(() => {
4879
+ const node = $getNodeByKey(nodeKey);
4880
+ if (node) node.remove();
4881
+ });
4882
+ }
4883
+ }
4884
+ }
4885
+
4434
4886
  replaceNodeWithHTML(nodeKey, html, options = {}) {
4435
4887
  this.editor.update(() => {
4436
4888
  const node = $getNodeByKey(nodeKey);
@@ -4484,9 +4936,42 @@ class Contents {
4484
4936
  return false
4485
4937
  }
4486
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
+
4487
4972
  #splitParagraphsAtLineBreaks(selection) {
4488
- const anchorKey = selection.anchor.getNode().getKey();
4489
- const focusKey = selection.focus.getNode().getKey();
4973
+ const anchorTopLevel = selection.anchor.getNode().getTopLevelElement();
4974
+ const focusTopLevel = selection.focus.getNode().getTopLevelElement();
4490
4975
  const topLevelElements = this.#topLevelElementsInSelection(selection);
4491
4976
 
4492
4977
  for (const element of topLevelElements) {
@@ -4498,10 +4983,9 @@ class Contents {
4498
4983
  // Check whether this paragraph needs splitting: skip only if neither
4499
4984
  // selection endpoint is inside it (meaning it's a middle paragraph
4500
4985
  // fully between anchor and focus with no partial lines to split off).
4501
- const hasEndpoint = children.some(child =>
4502
- child.getKey() === anchorKey || child.getKey() === focusKey
4503
- );
4504
- 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
4505
4989
 
4506
4990
  const groups = [ [] ];
4507
4991
  for (const child of children) {
@@ -4523,6 +5007,15 @@ class Contents {
4523
5007
  }
4524
5008
  }
4525
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
+
4526
5019
  #topLevelElementsInSelection(selection) {
4527
5020
  const elements = new Set();
4528
5021
  for (const node of selection.getNodes()) {
@@ -4710,6 +5203,109 @@ function $isShadowRoot(node) {
4710
5203
  return $isElementNode(node) && $isRootOrShadowRoot(node) && !$isRootNode(node)
4711
5204
  }
4712
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
+
4713
5309
  class Clipboard {
4714
5310
  constructor(editorElement) {
4715
5311
  this.editorElement = editorElement;
@@ -4889,9 +5485,31 @@ class Extensions {
4889
5485
  }
4890
5486
 
4891
5487
  initializeToolbars() {
4892
- if (this.#lexxyToolbar) {
4893
- this.enabledExtensions.forEach(ext => ext.initializeToolbar(this.#lexxyToolbar));
4894
- }
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)
4895
5513
  }
4896
5514
 
4897
5515
  get #lexxyToolbar() {
@@ -4915,7 +5533,19 @@ class Extensions {
4915
5533
  }
4916
5534
  }
4917
5535
 
4918
- // Custom TextNode exportDOM that avoids redundant bold/italic wrapping.
5536
+ class BrowserAdapter {
5537
+ frozenLinkKey = null
5538
+
5539
+ dispatchAttributesChange(attributes, linkHref, highlight, headingTag) {}
5540
+ dispatchEditorInitialized(detail) {}
5541
+ freeze() {}
5542
+ thaw() {}
5543
+ unlinkFrozenNode() {
5544
+ return false
5545
+ }
5546
+ }
5547
+
5548
+ // Custom TextNode exportDOM that avoids redundant wrapping.
4919
5549
  //
4920
5550
  // Lexical's built-in TextNode.exportDOM() calls createDOM() which produces semantic tags
4921
5551
  // like <strong> for bold and <em> for italic, then unconditionally wraps the result
@@ -4925,6 +5555,9 @@ class Extensions {
4925
5555
  // This custom export skips <b> when <strong> is already present and <i> when <em> is
4926
5556
  // already present, while preserving <s> and <u> wrappers which have no semantic equivalents
4927
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.
4928
5561
 
4929
5562
  function exportTextNodeDOM(editor, textNode) {
4930
5563
  const element = textNode.createDOM(editor._config, editor);
@@ -4932,142 +5565,51 @@ function exportTextNodeDOM(editor, textNode) {
4932
5565
 
4933
5566
  if (textNode.hasFormat("lowercase")) {
4934
5567
  element.style.textTransform = "lowercase";
4935
- } else if (textNode.hasFormat("uppercase")) {
4936
- element.style.textTransform = "uppercase";
4937
- } else if (textNode.hasFormat("capitalize")) {
4938
- element.style.textTransform = "capitalize";
4939
- }
4940
-
4941
- let result = element;
4942
-
4943
- if (textNode.hasFormat("bold") && !containsTag(element, "strong")) {
4944
- result = wrapWith(result, "b");
4945
- }
4946
- if (textNode.hasFormat("italic") && !containsTag(element, "em")) {
4947
- result = wrapWith(result, "i");
4948
- }
4949
- if (textNode.hasFormat("strikethrough")) {
4950
- result = wrapWith(result, "s");
4951
- }
4952
- if (textNode.hasFormat("underline")) {
4953
- result = wrapWith(result, "u");
4954
- }
4955
-
4956
- return { element: result }
4957
- }
4958
-
4959
- function containsTag(element, tagName) {
4960
- const upperTag = tagName.toUpperCase();
4961
- if (element.tagName === upperTag) return true
4962
-
4963
- return element.querySelector(tagName) !== null
4964
- }
4965
-
4966
- function wrapWith(element, tag) {
4967
- const wrapper = document.createElement(tag);
4968
- wrapper.appendChild(element);
4969
- return wrapper
4970
- }
4971
-
4972
- class ProvisionalParagraphNode extends ParagraphNode {
4973
- $config() {
4974
- return this.config("provisonal_paragraph", {
4975
- extends: ParagraphNode,
4976
- importDOM: () => null,
4977
- $transform: (node) => {
4978
- node.concretizeIfEdited(node);
4979
- node.removeUnlessRequired(node);
4980
- }
4981
- })
4982
- }
4983
-
4984
- static neededBetween(nodeBefore, nodeAfter) {
4985
- return !$isSelectableElement(nodeBefore, "next")
4986
- && !$isSelectableElement(nodeAfter, "previous")
4987
- }
4988
-
4989
- createDOM(editor) {
4990
- const p = super.createDOM(editor);
4991
- const selected = this.isSelected($getSelection());
4992
- p.classList.add("provisional-paragraph");
4993
- p.classList.toggle("hidden", !selected);
4994
- return p
4995
- }
4996
-
4997
- updateDOM(_prevNode, dom) {
4998
- const selected = this.isSelected($getSelection());
4999
- dom.classList.toggle("hidden", !selected);
5000
- return false
5001
- }
5002
-
5003
- getTextContent() {
5004
- return ""
5005
- }
5006
-
5007
- exportDOM() {
5008
- return {
5009
- element: null
5010
- }
5011
- }
5012
-
5013
- // override as Lexical has an interesting view of collapsed selection in ElementNodes
5014
- // https://github.com/facebook/lexical/blob/f1e4f66014377b1f2595aec2b0ee17f5b7ef4dfc/packages/lexical/src/LexicalNode.ts#L646
5015
- isSelected(selection = null) {
5016
- const targetSelection = selection || $getSelection();
5017
- if (!targetSelection) return false
5018
-
5019
- if (targetSelection.getNodes().some(node => node.is(this) || this.isParentOf(node))) return true
5020
-
5021
- // A collapsed range selection on the parent element at an offset adjacent to
5022
- // this node means the caret is visually at this paragraph's position. Treat it
5023
- // as selected so the paragraph is visible and the caret renders correctly.
5024
- //
5025
- // Both the offset matching our index (cursor just before us) and index + 1
5026
- // (cursor just after us) count, because the provisional paragraph is an
5027
- // invisible spacer: the browser resolves both offsets to the same visual spot.
5028
- if ($isRangeSelection(targetSelection) && targetSelection.isCollapsed()) {
5029
- const { anchor } = targetSelection;
5030
- const parent = this.getParent();
5031
- if (parent && anchor.getNode().is(parent) && anchor.type === "element") {
5032
- const index = this.getIndexWithinParent();
5033
- return anchor.offset === index || anchor.offset === index + 1
5034
- }
5035
- }
5036
-
5037
- return false
5038
- }
5039
-
5040
- removeUnlessRequired(self = this.getLatest()) {
5041
- if (!self.required) self.remove();
5042
- }
5043
-
5044
- concretizeIfEdited(self = this.getLatest()) {
5045
- if (self.getTextContentSize() > 0) {
5046
- self.replace($createParagraphNode(), true);
5047
- }
5568
+ } else if (textNode.hasFormat("uppercase")) {
5569
+ element.style.textTransform = "uppercase";
5570
+ } else if (textNode.hasFormat("capitalize")) {
5571
+ element.style.textTransform = "capitalize";
5048
5572
  }
5049
5573
 
5574
+ let result = element;
5050
5575
 
5051
- get required() {
5052
- return this.isDirectRootChild && ProvisionalParagraphNode.neededBetween(...this.immediateSiblings)
5576
+ if (textNode.hasFormat("bold") && !containsTag(element, "strong")) {
5577
+ result = wrapWith(result, "b");
5053
5578
  }
5054
-
5055
- get isDirectRootChild() {
5056
- const parent = this.getParent();
5057
- return $isRootOrShadowRoot(parent)
5579
+ if (textNode.hasFormat("italic") && !containsTag(element, "em")) {
5580
+ result = wrapWith(result, "i");
5058
5581
  }
5059
-
5060
- get immediateSiblings() {
5061
- return [ this.getPreviousSibling(), this.getNextSibling() ]
5582
+ if (textNode.hasFormat("strikethrough")) {
5583
+ result = wrapWith(result, "s");
5062
5584
  }
5585
+ if (textNode.hasFormat("underline")) {
5586
+ result = wrapWith(result, "u");
5587
+ }
5588
+
5589
+ return { element: unwrapSpans(result) }
5063
5590
  }
5064
5591
 
5065
- function $isProvisionalParagraphNode(node) {
5066
- return node instanceof ProvisionalParagraphNode
5592
+ function containsTag(element, tagName) {
5593
+ const upperTag = tagName.toUpperCase();
5594
+ if (element.tagName === upperTag) return true
5595
+
5596
+ return element.querySelector(tagName) !== null
5067
5597
  }
5068
5598
 
5069
- function $isSelectableElement(node, direction) {
5070
- return $isElementNode(node) && (direction === "next" ? node.canInsertTextBefore() : node.canInsertTextAfter())
5599
+ function wrapWith(element, tag) {
5600
+ const wrapper = document.createElement(tag);
5601
+ wrapper.appendChild(element);
5602
+ return wrapper
5603
+ }
5604
+
5605
+ function unwrapSpans(element) {
5606
+ if (element.tagName === "SPAN") return element.firstChild
5607
+
5608
+ for (const span of element.querySelectorAll("span")) {
5609
+ span.replaceWith(...span.childNodes);
5610
+ }
5611
+
5612
+ return element
5071
5613
  }
5072
5614
 
5073
5615
  class ProvisionalParagraphExtension extends LexxyExtension {
@@ -5220,6 +5762,10 @@ class TablesExtension extends LexxyExtension {
5220
5762
  return this.editorElement.supportsRichText
5221
5763
  }
5222
5764
 
5765
+ get allowedElements() {
5766
+ return [ "figure", "tbody" ]
5767
+ }
5768
+
5223
5769
  get lexicalExtension() {
5224
5770
  return defineExtension({
5225
5771
  name: "lexxy/tables",
@@ -5323,21 +5869,21 @@ class AttachmentDragAndDrop {
5323
5869
  #draggedNodeKey = null
5324
5870
  #rafId = null
5325
5871
  #draggingRafId = null
5326
- #cleanupFns = []
5872
+ #listeners = new ListenerBin()
5327
5873
 
5328
5874
  constructor(editor) {
5329
5875
  this.#editor = editor;
5330
5876
 
5331
5877
  // Register Lexical commands at HIGH priority to intercept before the
5332
5878
  // base @lexical/rich-text handlers (which return true and consume the events).
5333
- this.#cleanupFns.push(
5879
+ this.#listeners.track(
5334
5880
  editor.registerCommand(DRAGSTART_COMMAND, (event) => this.#handleDragStart(event), COMMAND_PRIORITY_HIGH),
5335
5881
  editor.registerCommand(DROP_COMMAND, (event) => this.#handleDrop(event), COMMAND_PRIORITY_HIGH),
5336
5882
  );
5337
5883
 
5338
5884
  // Use a root listener to register DOM-level dragover/dragend handlers
5339
5885
  // (these events need throttled rAF handling that works better as DOM listeners).
5340
- const unregister = editor.registerRootListener((root, prevRoot) => {
5886
+ this.#listeners.track(editor.registerRootListener((root, prevRoot) => {
5341
5887
  if (prevRoot) {
5342
5888
  prevRoot.removeEventListener("dragover", this.#onDragOver);
5343
5889
  prevRoot.removeEventListener("dragend", this.#onDragEnd);
@@ -5346,14 +5892,12 @@ class AttachmentDragAndDrop {
5346
5892
  root.addEventListener("dragover", this.#onDragOver);
5347
5893
  root.addEventListener("dragend", this.#onDragEnd);
5348
5894
  }
5349
- });
5350
- this.#cleanupFns.push(unregister);
5895
+ }));
5351
5896
  }
5352
5897
 
5353
5898
  destroy() {
5354
5899
  this.#cleanup();
5355
- for (const fn of this.#cleanupFns) fn();
5356
- this.#cleanupFns = [];
5900
+ this.#listeners.dispose();
5357
5901
  }
5358
5902
 
5359
5903
  // -- Event handlers --------------------------------------------------------
@@ -5675,11 +6219,18 @@ class AttachmentDragAndDrop {
5675
6219
  }
5676
6220
  }
5677
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
+
5678
6225
  class AttachmentsExtension extends LexxyExtension {
5679
6226
  get enabled() {
5680
6227
  return this.editorElement.supportsAttachments
5681
6228
  }
5682
6229
 
6230
+ get allowedElements() {
6231
+ return [ { tag: ActionTextAttachmentNode.TAG_NAME, attributes: ATTACHMENT_ATTRIBUTES } ]
6232
+ }
6233
+
5683
6234
  get lexicalExtension() {
5684
6235
  return defineExtension({
5685
6236
  name: "lexxy/action-text-attachments",
@@ -5966,6 +6517,8 @@ class LexicalEditorElement extends HTMLElement {
5966
6517
 
5967
6518
  #initialValue = ""
5968
6519
  #validationTextArea = document.createElement("textarea")
6520
+ #editorInitializedRafId = null
6521
+ #listeners = new ListenerBin()
5969
6522
  #disposables = []
5970
6523
 
5971
6524
  constructor() {
@@ -5975,12 +6528,13 @@ class LexicalEditorElement extends HTMLElement {
5975
6528
  }
5976
6529
 
5977
6530
  connectedCallback() {
5978
- this.id ??= generateDomId("lexxy-editor");
6531
+ this.id ||= generateDomId("lexxy-editor");
5979
6532
  this.config = new EditorConfiguration(this);
5980
6533
  this.extensions = new Extensions(this);
5981
6534
 
5982
6535
  this.editor = this.#createEditor();
5983
6536
  this.#disposables.push(this.editor);
6537
+ this.#disposables.push(this.#listeners);
5984
6538
 
5985
6539
  this.contents = new Contents(this);
5986
6540
  this.#disposables.push(this.contents);
@@ -5989,13 +6543,14 @@ class LexicalEditorElement extends HTMLElement {
5989
6543
  this.#disposables.push(this.selection);
5990
6544
 
5991
6545
  this.clipboard = new Clipboard(this);
6546
+ this.adapter = new BrowserAdapter();
5992
6547
 
5993
6548
  const commandDispatcher = CommandDispatcher.configureFor(this);
5994
6549
  this.#disposables.push(commandDispatcher);
5995
6550
 
5996
6551
  this.#initialize();
5997
6552
 
5998
- requestAnimationFrame(() => dispatch(this, "lexxy:initialize"));
6553
+ this.#scheduleEditorInitializedDispatch();
5999
6554
  this.toggleAttribute("connected", true);
6000
6555
 
6001
6556
  this.#handleAutofocus();
@@ -6004,6 +6559,7 @@ class LexicalEditorElement extends HTMLElement {
6004
6559
  }
6005
6560
 
6006
6561
  disconnectedCallback() {
6562
+ this.#cancelEditorInitializedDispatch();
6007
6563
  this.valueBeforeDisconnect = this.value;
6008
6564
  this.#reset(); // Prevent hangs with Safari when morphing
6009
6565
  }
@@ -6100,6 +6656,32 @@ class LexicalEditorElement extends HTMLElement {
6100
6656
  return this.config.get("richText")
6101
6657
  }
6102
6658
 
6659
+ registerAdapter(adapter) {
6660
+ this.adapter = adapter;
6661
+
6662
+ if (!this.editor) return
6663
+
6664
+ this.#cancelEditorInitializedDispatch();
6665
+ this.#dispatchEditorInitialized();
6666
+ this.#dispatchAttributesChange();
6667
+ }
6668
+
6669
+ freezeSelection() {
6670
+ this.adapter.freeze();
6671
+ }
6672
+
6673
+ thawSelection() {
6674
+ this.adapter.thaw();
6675
+ }
6676
+
6677
+ dispatchAttributesChange() {
6678
+ this.#dispatchAttributesChange();
6679
+ }
6680
+
6681
+ dispatchEditorInitialized() {
6682
+ this.#dispatchEditorInitialized();
6683
+ }
6684
+
6103
6685
  // TODO: Deprecate `single-line` attribute
6104
6686
  get isSingleLineMode() {
6105
6687
  return this.hasAttribute("single-line")
@@ -6116,7 +6698,7 @@ class LexicalEditorElement extends HTMLElement {
6116
6698
  get value() {
6117
6699
  if (!this.cachedValue) {
6118
6700
  this.editor?.getEditorState().read(() => {
6119
- this.cachedValue = sanitize($generateHtmlFromNodes(this.editor, null));
6701
+ this.cachedValue = sanitize($generateHtmlFromNodes(this.editor, null), this.#allowedElements);
6120
6702
  });
6121
6703
  }
6122
6704
 
@@ -6189,7 +6771,7 @@ class LexicalEditorElement extends HTMLElement {
6189
6771
  theme: theme,
6190
6772
  nodes: this.#lexicalNodes,
6191
6773
  html: {
6192
- export: new Map([ [ TextNode, exportTextNodeDOM ] ])
6774
+ export: new Map([ [ TextNode, exportTextNodeDOM ], [ CodeHighlightNode, exportTextNodeDOM ] ])
6193
6775
  }
6194
6776
  },
6195
6777
  ...this.extensions.lexicalExtensions
@@ -6224,6 +6806,7 @@ class LexicalEditorElement extends HTMLElement {
6224
6806
  const editorContentElement = createElement("div", {
6225
6807
  classList: "lexxy-editor__content",
6226
6808
  contenteditable: true,
6809
+ autocapitalize: "none",
6227
6810
  role: "textbox",
6228
6811
  "aria-multiline": true,
6229
6812
  "aria-label": this.#labelText,
@@ -6272,7 +6855,9 @@ class LexicalEditorElement extends HTMLElement {
6272
6855
  }
6273
6856
 
6274
6857
  #resetBeforeTurboCaches() {
6275
- document.addEventListener("turbo:before-cache", this.#handleTurboBeforeCache);
6858
+ this.#listeners.track(
6859
+ registerEventListener(document, "turbo:before-cache", this.#handleTurboBeforeCache)
6860
+ );
6276
6861
  }
6277
6862
 
6278
6863
  #handleTurboBeforeCache = (event) => {
@@ -6280,11 +6865,12 @@ class LexicalEditorElement extends HTMLElement {
6280
6865
  }
6281
6866
 
6282
6867
  #synchronizeWithChanges() {
6283
- this.#addUnregisterHandler(this.editor.registerUpdateListener(({ editorState }) => {
6868
+ this.#listeners.track(this.editor.registerUpdateListener(({ editorState }) => {
6284
6869
  this.#clearCachedValues();
6285
6870
  this.#internalFormValue = this.value;
6286
6871
  this.#toggleEmptyStatus();
6287
6872
  this.#setValidity();
6873
+ this.#dispatchAttributesChange();
6288
6874
  }));
6289
6875
  }
6290
6876
 
@@ -6293,18 +6879,6 @@ class LexicalEditorElement extends HTMLElement {
6293
6879
  this.cachedStringValue = null;
6294
6880
  }
6295
6881
 
6296
- #addUnregisterHandler(handler) {
6297
- this.unregisterHandlers = this.unregisterHandlers || [];
6298
- this.unregisterHandlers.push(handler);
6299
- }
6300
-
6301
- #unregisterHandlers() {
6302
- this.unregisterHandlers?.forEach((handler) => {
6303
- handler();
6304
- });
6305
- this.unregisterHandlers = null;
6306
- }
6307
-
6308
6882
  #registerComponents() {
6309
6883
  const registered = [];
6310
6884
 
@@ -6316,9 +6890,10 @@ class LexicalEditorElement extends HTMLElement {
6316
6890
  this.#registerTableComponents();
6317
6891
  this.#registerCodeHiglightingComponents();
6318
6892
  if (this.supportsMarkdown) {
6319
- registered.push(
6320
- registerMarkdownShortcuts(this.editor, TRANSFORMERS),
6321
- registerMarkdownLeadingTagHandler(this.editor, TRANSFORMERS)
6893
+ const transformers = [ ...TRANSFORMERS, HORIZONTAL_DIVIDER ];
6894
+ registered.push(
6895
+ registerMarkdownShortcuts(this.editor, transformers),
6896
+ registerMarkdownLeadingTagHandler(this.editor, transformers)
6322
6897
  );
6323
6898
  }
6324
6899
  } else {
@@ -6327,7 +6902,7 @@ class LexicalEditorElement extends HTMLElement {
6327
6902
  this.historyState = createEmptyHistoryState();
6328
6903
  registered.push(registerHistory(this.editor, this.historyState, 20));
6329
6904
 
6330
- this.#addUnregisterHandler(mergeRegister$1(...registered));
6905
+ this.#listeners.track(...registered);
6331
6906
  }
6332
6907
 
6333
6908
  #registerTableComponents() {
@@ -6347,7 +6922,7 @@ class LexicalEditorElement extends HTMLElement {
6347
6922
 
6348
6923
  #handleEnter() {
6349
6924
  // We can't prevent these externally using regular keydown because Lexical handles it first.
6350
- this.#addUnregisterHandler(this.editor.registerCommand(
6925
+ this.#listeners.track(this.editor.registerCommand(
6351
6926
  KEY_ENTER_COMMAND,
6352
6927
  (event) => {
6353
6928
  // Prevent CTRL+ENTER
@@ -6369,17 +6944,15 @@ class LexicalEditorElement extends HTMLElement {
6369
6944
  }
6370
6945
 
6371
6946
  #registerFocusEvents() {
6372
- this.addEventListener("focusin", this.#handleFocusIn);
6373
- this.addEventListener("focusout", this.#handleFocusOut);
6374
-
6375
- this.#addUnregisterHandler(() => {
6376
- this.removeEventListener("focusin", this.#handleFocusIn);
6377
- this.removeEventListener("focusout", this.#handleFocusOut);
6378
- });
6947
+ this.#listeners.track(
6948
+ registerEventListener(this, "focusin", this.#handleFocusIn),
6949
+ registerEventListener(this, "focusout", this.#handleFocusOut)
6950
+ );
6379
6951
  }
6380
6952
 
6381
6953
  #handleFocusIn(event) {
6382
6954
  if (this.#elementInEditorOrToolbar(event.target) && !this.currentlyFocused) {
6955
+ this.#dispatchAttributesChange();
6383
6956
  dispatch(this, "lexxy:focus");
6384
6957
  this.currentlyFocused = true;
6385
6958
  }
@@ -6460,7 +7033,121 @@ class LexicalEditorElement extends HTMLElement {
6460
7033
  }
6461
7034
  }
6462
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
+
7045
+ #dispatchAttributesChange() {
7046
+ let attributes = null;
7047
+ let linkHref = null;
7048
+ let highlight = null;
7049
+ let headingTag = null;
7050
+
7051
+ this.editor.getEditorState().read(() => {
7052
+ const selection = $getSelection();
7053
+ if (!$isRangeSelection(selection)) return
7054
+
7055
+ const format = this.selection.getFormat();
7056
+ if (Object.keys(format).length === 0) return
7057
+
7058
+ const anchorNode = selection.anchor.getNode();
7059
+ const linkNode = $getNearestNodeOfType(anchorNode, LinkNode);
7060
+
7061
+ attributes = {
7062
+ bold: { active: format.isBold, enabled: true },
7063
+ italic: { active: format.isItalic, enabled: true },
7064
+ strikethrough: { active: format.isStrikethrough, enabled: true },
7065
+ code: { active: format.isInCode, enabled: true },
7066
+ highlight: { active: format.isHighlight, enabled: true },
7067
+ link: { active: format.isInLink, enabled: true },
7068
+ quote: { active: format.isInQuote, enabled: true },
7069
+ heading: { active: format.isInHeading, enabled: true },
7070
+ "unordered-list": { active: format.isInList && format.listType === "bullet", enabled: true },
7071
+ "ordered-list": { active: format.isInList && format.listType === "number", enabled: true },
7072
+ undo: { active: false, enabled: this.historyState?.undoStack.length > 0 },
7073
+ redo: { active: false, enabled: this.historyState?.redoStack.length > 0 }
7074
+ };
7075
+
7076
+ linkHref = linkNode ? linkNode.getURL() : null;
7077
+ highlight = format.isHighlight ? getHighlightStyles(selection) : null;
7078
+ headingTag = format.headingTag ?? null;
7079
+ });
7080
+
7081
+ if (attributes) {
7082
+ this.adapter.dispatchAttributesChange(attributes, linkHref, highlight, headingTag);
7083
+ }
7084
+ }
7085
+
7086
+ #dispatchEditorInitialized() {
7087
+ if (!this.adapter) return
7088
+
7089
+ this.adapter.dispatchEditorInitialized({
7090
+ highlightColors: this.#resolvedHighlightColors,
7091
+ headingFormats: this.#supportedHeadingFormats
7092
+ });
7093
+ }
7094
+
7095
+ #scheduleEditorInitializedDispatch() {
7096
+ this.#cancelEditorInitializedDispatch();
7097
+ this.#editorInitializedRafId = requestAnimationFrame(() => {
7098
+ this.#editorInitializedRafId = null;
7099
+ if (!this.isConnected || !this.adapter) return
7100
+
7101
+ dispatch(this, "lexxy:initialize");
7102
+ this.#dispatchEditorInitialized();
7103
+ });
7104
+ }
7105
+
7106
+ #cancelEditorInitializedDispatch() {
7107
+ if (this.#editorInitializedRafId == null) return
7108
+
7109
+ cancelAnimationFrame(this.#editorInitializedRafId);
7110
+ this.#editorInitializedRafId = null;
7111
+ }
7112
+
7113
+ get #resolvedHighlightColors() {
7114
+ const buttons = this.config.get("highlight.buttons");
7115
+ if (!buttons) return null
7116
+
7117
+ const colors = this.#resolveColors("color", buttons.color || []);
7118
+ const backgroundColors = this.#resolveColors("background-color", buttons["background-color"] || []);
7119
+ return { colors, backgroundColors }
7120
+ }
7121
+
7122
+ get #supportedHeadingFormats() {
7123
+ if (!this.supportsRichText) return []
7124
+
7125
+ return [
7126
+ { label: "Normal", command: "setFormatParagraph", tag: null },
7127
+ { label: "Large heading", command: "setFormatHeadingLarge", tag: "h2" },
7128
+ { label: "Medium heading", command: "setFormatHeadingMedium", tag: "h3" },
7129
+ { label: "Small heading", command: "setFormatHeadingSmall", tag: "h4" },
7130
+ ]
7131
+ }
7132
+
7133
+ #resolveColors(property, cssValues) {
7134
+ const resolver = document.createElement("span");
7135
+ resolver.style.display = "none";
7136
+ this.appendChild(resolver);
7137
+
7138
+ const resolved = cssValues.map(cssValue => {
7139
+ resolver.style.setProperty(property, cssValue);
7140
+ const value = window.getComputedStyle(resolver).getPropertyValue(property);
7141
+ resolver.style.removeProperty(property);
7142
+ return { name: cssValue, value }
7143
+ });
7144
+
7145
+ resolver.remove();
7146
+ return resolved
7147
+ }
7148
+
6463
7149
  #reset() {
7150
+ this.#cancelEditorInitializedDispatch();
6464
7151
  this.#dispose();
6465
7152
  this.editorContentElement?.remove();
6466
7153
  this.editorContentElement = null;
@@ -6471,9 +7158,6 @@ class LexicalEditorElement extends HTMLElement {
6471
7158
  }
6472
7159
 
6473
7160
  #dispose() {
6474
- this.#unregisterHandlers();
6475
- document.removeEventListener("turbo:before-cache", this.#handleTurboBeforeCache);
6476
-
6477
7161
  while (this.#disposables.length) {
6478
7162
  this.#disposables.pop().dispose();
6479
7163
  }
@@ -6514,18 +7198,21 @@ function $getReadableTextContent(node) {
6514
7198
  }
6515
7199
 
6516
7200
  class ToolbarDropdown extends HTMLElement {
7201
+ #listeners = new ListenerBin()
7202
+
6517
7203
  connectedCallback() {
6518
7204
  this.container = this.closest("details");
6519
7205
 
6520
- this.container.addEventListener("toggle", this.#handleToggle);
6521
- 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
+ );
6522
7210
 
6523
7211
  this.#onToolbarEditor(this.initialize.bind(this));
6524
7212
  }
6525
7213
 
6526
7214
  disconnectedCallback() {
6527
- this.container?.removeEventListener("toggle", this.#handleToggle);
6528
- this.container?.removeEventListener("keydown", this.#handleKeyDown);
7215
+ this.#listeners.dispose();
6529
7216
  }
6530
7217
 
6531
7218
  get toolbar() {
@@ -6540,6 +7227,10 @@ class ToolbarDropdown extends HTMLElement {
6540
7227
  return this.toolbar.editor
6541
7228
  }
6542
7229
 
7230
+ track(...listeners) {
7231
+ this.#listeners.track(...listeners);
7232
+ }
7233
+
6543
7234
  initialize() {
6544
7235
  // Any post-editor initialization
6545
7236
  }
@@ -6591,18 +7282,23 @@ class ToolbarDropdown extends HTMLElement {
6591
7282
  class LinkDropdown extends ToolbarDropdown {
6592
7283
  connectedCallback() {
6593
7284
  super.connectedCallback();
7285
+
6594
7286
  this.input = this.querySelector("input");
6595
7287
 
6596
- this.container.addEventListener("toggle", this.#handleToggle);
6597
- this.addEventListener("submit", this.#handleSubmit);
6598
- 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
+ );
6599
7294
  }
6600
7295
 
6601
- disconnectedCallback() {
6602
- this.container?.removeEventListener("toggle", this.#handleToggle);
6603
- this.removeEventListener("submit", this.#handleSubmit);
6604
- this.querySelector("[value='unlink']")?.removeEventListener("click", this.#handleUnlink);
6605
- super.disconnectedCallback();
7296
+ get linkButton() {
7297
+ return this.querySelector("[value='link']")
7298
+ }
7299
+
7300
+ get unlinkButton() {
7301
+ return this.querySelector("[value='unlink']")
6606
7302
  }
6607
7303
 
6608
7304
  #handleToggle = ({ newState }) => {
@@ -6610,9 +7306,21 @@ class LinkDropdown extends ToolbarDropdown {
6610
7306
  this.input.required = newState === "open";
6611
7307
  }
6612
7308
 
6613
- #handleSubmit = (event) => {
6614
- const command = event.submitter?.value;
6615
- 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);
6616
7324
  this.close();
6617
7325
  }
6618
7326
 
@@ -6622,23 +7330,10 @@ class LinkDropdown extends ToolbarDropdown {
6622
7330
  }
6623
7331
 
6624
7332
  get #selectedLinkUrl() {
6625
- let url = "";
6626
-
6627
- this.editor.getEditorState().read(() => {
6628
- const selection = $getSelection();
6629
- if (!$isRangeSelection(selection)) return
6630
-
6631
- let node = selection.getNodes()[0];
6632
- while (node && node.getParent()) {
6633
- if ($isLinkNode(node)) {
6634
- url = node.getURL();
6635
- break
6636
- }
6637
- node = node.getParent();
6638
- }
6639
- });
6640
-
6641
- return url
7333
+ return this.editor.getEditorState().read(() => {
7334
+ const linkNode = this.editorElement.selection.nearestNodeOfType(LinkNode);
7335
+ return linkNode?.getUrl() ?? null
7336
+ })
6642
7337
  }
6643
7338
  }
6644
7339
 
@@ -6658,23 +7353,14 @@ class HighlightDropdown extends ToolbarDropdown {
6658
7353
 
6659
7354
  connectedCallback() {
6660
7355
  super.connectedCallback();
6661
- this.container.addEventListener("toggle", this.#handleToggle);
6662
- }
6663
-
6664
- disconnectedCallback() {
6665
- this.container?.removeEventListener("toggle", this.#handleToggle);
6666
- this.#removeButtonHandlers();
6667
- super.disconnectedCallback();
7356
+ this.track(registerEventListener(this.container, "toggle", this.#handleToggle));
6668
7357
  }
6669
7358
 
6670
7359
  #registerButtonHandlers() {
6671
- this.#colorButtons.forEach(button => button.addEventListener("click", this.#handleColorButtonClick));
6672
- this.querySelector(REMOVE_HIGHLIGHT_SELECTOR).addEventListener("click", this.#handleRemoveHighlightClick);
6673
- }
6674
-
6675
- #removeButtonHandlers() {
6676
- this.#colorButtons.forEach(button => button.removeEventListener("click", this.#handleColorButtonClick));
6677
- 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));
6678
7364
  }
6679
7365
 
6680
7366
  #setUpButtons() {
@@ -6896,9 +7582,11 @@ class RemoteFilterSource extends BaseSource {
6896
7582
  const NOTHING_FOUND_DEFAULT_MESSAGE = "Nothing found";
6897
7583
 
6898
7584
  class LexicalPromptElement extends HTMLElement {
7585
+ #globalListeners = new ListenerBin()
7586
+ #popoverListeners = new ListenerBin()
7587
+
6899
7588
  constructor() {
6900
7589
  super();
6901
- this.keyListeners = [];
6902
7590
  this.showPopoverId = 0;
6903
7591
  }
6904
7592
 
@@ -6912,6 +7600,8 @@ class LexicalPromptElement extends HTMLElement {
6912
7600
  }
6913
7601
 
6914
7602
  disconnectedCallback() {
7603
+ this.#popoverListeners.dispose();
7604
+ this.#globalListeners.dispose();
6915
7605
  this.source = null;
6916
7606
  this.popoverElement = null;
6917
7607
  }
@@ -6961,7 +7651,7 @@ class LexicalPromptElement extends HTMLElement {
6961
7651
  }
6962
7652
 
6963
7653
  #addTriggerListener() {
6964
- const unregister = this.#editor.registerUpdateListener(({ editorState }) => {
7654
+ this.#popoverListeners.track(this.#editor.registerUpdateListener(({ editorState }) => {
6965
7655
  editorState.read(() => {
6966
7656
  if (this.#selection.isInsideCodeBlock) return
6967
7657
 
@@ -6984,18 +7674,18 @@ class LexicalPromptElement extends HTMLElement {
6984
7674
  const isPrecededBySpaceOrNewline = charBeforeTrigger === " " || charBeforeTrigger === "\n";
6985
7675
 
6986
7676
  if (isAtStart || isPrecededBySpaceOrNewline) {
6987
- unregister();
7677
+ this.#popoverListeners.dispose();
6988
7678
  this.#showPopover();
6989
7679
  }
6990
7680
  }
6991
7681
  }
6992
7682
  }
6993
7683
  });
6994
- });
7684
+ }));
6995
7685
  }
6996
7686
 
6997
7687
  #addCursorPositionListener() {
6998
- this.cursorPositionListener = this.#editor.registerUpdateListener(({ editorState }) => {
7688
+ this.#popoverListeners.track(this.#editor.registerUpdateListener(({ editorState }) => {
6999
7689
  if (this.closed) return
7000
7690
 
7001
7691
  editorState.read(() => {
@@ -7022,14 +7712,7 @@ class LexicalPromptElement extends HTMLElement {
7022
7712
  this.#hidePopover();
7023
7713
  }
7024
7714
  });
7025
- });
7026
- }
7027
-
7028
- #removeCursorPositionListener() {
7029
- if (this.cursorPositionListener) {
7030
- this.cursorPositionListener();
7031
- this.cursorPositionListener = null;
7032
- }
7715
+ }));
7033
7716
  }
7034
7717
 
7035
7718
  get #editor() {
@@ -7056,8 +7739,10 @@ class LexicalPromptElement extends HTMLElement {
7056
7739
  this.popoverElement.classList.toggle("lexxy-prompt-menu--visible", true);
7057
7740
  this.#selectFirstOption();
7058
7741
 
7059
- this.#editorElement.addEventListener("keydown", this.#handleKeydownOnPopover);
7060
- 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
+ );
7061
7746
 
7062
7747
  this.#registerKeyListeners();
7063
7748
  this.#addCursorPositionListener();
@@ -7065,16 +7750,20 @@ class LexicalPromptElement extends HTMLElement {
7065
7750
 
7066
7751
  #registerKeyListeners() {
7067
7752
  // We can't use a regular keydown for Enter as Lexical handles it first
7068
- this.keyListeners.push(this.#editor.registerCommand(KEY_ENTER_COMMAND, this.#handleSelectedOption.bind(this), COMMAND_PRIORITY_CRITICAL));
7069
- 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
+ );
7070
7757
 
7071
7758
  if (this.#doesSpaceSelect) {
7072
- 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));
7073
7760
  }
7074
7761
 
7075
7762
  // Register arrow keys with CRITICAL priority to prevent Lexical's selection handlers from running
7076
- this.keyListeners.push(this.#editor.registerCommand(KEY_ARROW_UP_COMMAND, this.#handleArrowUp.bind(this), COMMAND_PRIORITY_CRITICAL));
7077
- 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
+ );
7078
7767
  }
7079
7768
 
7080
7769
  #handleArrowUp(event) {
@@ -7166,21 +7855,12 @@ class LexicalPromptElement extends HTMLElement {
7166
7855
  this.showPopoverId++;
7167
7856
  this.#clearSelection();
7168
7857
  this.popoverElement.classList.toggle("lexxy-prompt-menu--visible", false);
7169
- this.#editorElement.removeEventListener("lexxy:change", this.#filterOptions);
7170
- this.#editorElement.removeEventListener("keydown", this.#handleKeydownOnPopover);
7171
-
7172
- this.#unregisterKeyListeners();
7173
- this.#removeCursorPositionListener();
7858
+ this.#popoverListeners.dispose();
7174
7859
 
7175
7860
  await nextFrame();
7176
7861
  this.#addTriggerListener();
7177
7862
  }
7178
7863
 
7179
- #unregisterKeyListeners() {
7180
- this.keyListeners.forEach((unregister) => unregister());
7181
- this.keyListeners = [];
7182
- }
7183
-
7184
7864
  #filterOptions = async () => {
7185
7865
  if (this.initialPrompt) {
7186
7866
  this.initialPrompt = false;
@@ -7357,7 +8037,7 @@ class LexicalPromptElement extends HTMLElement {
7357
8037
  popoverContainer.style.position = "absolute";
7358
8038
  popoverContainer.setAttribute("nonce", getNonce());
7359
8039
  popoverContainer.append(...await this.source.buildListItems());
7360
- popoverContainer.addEventListener("click", this.#handlePopoverClick);
8040
+ this.#globalListeners.track(registerEventListener(popoverContainer, "click", this.#handlePopoverClick));
7361
8041
  this.#editorElement.appendChild(popoverContainer);
7362
8042
  return popoverContainer
7363
8043
  }
@@ -7377,10 +8057,15 @@ class LexicalPromptElement extends HTMLElement {
7377
8057
  }
7378
8058
 
7379
8059
  class CodeLanguagePicker extends HTMLElement {
8060
+ #abortController = null
8061
+ #listeners = new ListenerBin()
8062
+
7380
8063
  connectedCallback() {
7381
8064
  this.editorElement = this.closest("lexxy-editor");
7382
8065
  this.editor = this.editorElement.editor;
7383
8066
  this.classList.add("lexxy-floating-controls");
8067
+ this.#abortController = new AbortController();
8068
+ this.#listeners.track(() => this.#abortController?.abort());
7384
8069
 
7385
8070
  this.#attachLanguagePicker();
7386
8071
  this.#hide();
@@ -7392,13 +8077,24 @@ class CodeLanguagePicker extends HTMLElement {
7392
8077
  }
7393
8078
 
7394
8079
  dispose() {
7395
- this.unregisterUpdateListener?.();
7396
- this.unregisterUpdateListener = null;
8080
+ this.#listeners.dispose();
7397
8081
  }
7398
8082
 
7399
8083
  #attachLanguagePicker() {
7400
8084
  this.languagePickerElement = this.#findLanguagePicker() ?? this.#createLanguagePicker();
7401
- this.append(this.languagePickerElement);
8085
+
8086
+ const signal = this.#abortController.signal;
8087
+
8088
+ this.#listeners.track(registerEventListener(this.languagePickerElement, "change", () => {
8089
+ this.#updateCodeBlockLanguage(this.languagePickerElement.value);
8090
+ }, { signal }));
8091
+
8092
+ this.#listeners.track(registerEventListener(this.languagePickerElement, "mousedown", (event) => {
8093
+ this.#dispatchOpenEvent(event);
8094
+ }, { signal }));
8095
+
8096
+ this.languagePickerElement.setAttribute("nonce", getNonce());
8097
+ this.appendChild(this.languagePickerElement);
7402
8098
  }
7403
8099
 
7404
8100
  #findLanguagePicker() {
@@ -7415,32 +8111,39 @@ class CodeLanguagePicker extends HTMLElement {
7415
8111
  selectElement.appendChild(option);
7416
8112
  }
7417
8113
 
7418
- selectElement.addEventListener("change", () => {
7419
- this.#updateCodeBlockLanguage(this.languagePickerElement.value);
7420
- });
7421
-
7422
- selectElement.setAttribute("nonce", getNonce());
7423
-
7424
8114
  return selectElement
7425
8115
  }
7426
8116
 
7427
8117
  get #languages() {
7428
8118
  const languages = { ...CODE_LANGUAGE_FRIENDLY_NAME_MAP };
7429
8119
 
7430
- if (!languages.ruby) languages.ruby = "Ruby";
7431
- if (!languages.php) languages.php = "PHP";
7432
- if (!languages.go) languages.go = "Go";
7433
- if (!languages.bash) languages.bash = "Bash";
7434
- if (!languages.json) languages.json = "JSON";
7435
- if (!languages.diff) languages.diff = "Diff";
8120
+ languages.ruby ||= "Ruby";
8121
+ languages.php ||= "PHP";
8122
+ languages.go ||= "Go";
8123
+ languages.bash ||= "Bash";
8124
+ languages.json ||= "JSON";
8125
+ languages.diff ||= "Diff";
7436
8126
 
8127
+ // Place the "plain" entry first, then the rest of language sorted alphabetically
8128
+ delete languages.plain;
7437
8129
  const sortedEntries = Object.entries(languages)
7438
- .sort(([ , a ], [ , b ]) => a.localeCompare(b));
8130
+ .sort((a, b) => a[1].localeCompare(b[1]));
8131
+ return { plain: "Plain text", ...Object.fromEntries(sortedEntries) }
8132
+ }
7439
8133
 
7440
- // Place the "plain" entry first, then the rest of language sorted alphabetically
7441
- const plainIndex = sortedEntries.findIndex(([ key ]) => key === "plain");
7442
- const plainEntry = sortedEntries.splice(plainIndex, 1)[0];
7443
- return Object.fromEntries([ plainEntry, ...sortedEntries ])
8134
+ #dispatchOpenEvent(event) {
8135
+ const handled = !dispatch(this.editorElement, "lexxy:code-language-picker-open", {
8136
+ languages: this.#bridgeLanguages,
8137
+ currentLanguage: this.languagePickerElement.value
8138
+ }, true);
8139
+
8140
+ if (handled) {
8141
+ event.preventDefault();
8142
+ }
8143
+ }
8144
+
8145
+ get #bridgeLanguages() {
8146
+ return Object.entries(this.#languages).map(([ key, name ]) => ({ key, name }))
7444
8147
  }
7445
8148
 
7446
8149
  #updateCodeBlockLanguage(language) {
@@ -7454,8 +8157,8 @@ class CodeLanguagePicker extends HTMLElement {
7454
8157
  }
7455
8158
 
7456
8159
  #monitorForCodeBlockSelection() {
7457
- this.unregisterUpdateListener = this.editor.registerUpdateListener(() => {
7458
- this.editor.getEditorState().read(() => {
8160
+ this.#listeners.track(this.editor.registerUpdateListener(({ editorState }) => {
8161
+ editorState.read(() => {
7459
8162
  const codeNode = this.#getCurrentCodeNode();
7460
8163
 
7461
8164
  if (codeNode) {
@@ -7464,26 +8167,11 @@ class CodeLanguagePicker extends HTMLElement {
7464
8167
  this.#hide();
7465
8168
  }
7466
8169
  });
7467
- });
8170
+ }));
7468
8171
  }
7469
8172
 
7470
8173
  #getCurrentCodeNode() {
7471
- const selection = $getSelection();
7472
-
7473
- if (!$isRangeSelection(selection)) {
7474
- return null
7475
- }
7476
-
7477
- const anchorNode = selection.anchor.getNode();
7478
- const parentNode = anchorNode.getParent();
7479
-
7480
- if ($isCodeNode(anchorNode)) {
7481
- return anchorNode
7482
- } else if ($isCodeNode(parentNode)) {
7483
- return parentNode
7484
- }
7485
-
7486
- return null
8174
+ return this.editorElement.selection.nearestNodeOfType(CodeNode)
7487
8175
  }
7488
8176
 
7489
8177
  #codeNodeWasSelected(codeNode) {
@@ -7570,6 +8258,8 @@ class NodeDeleteButton extends HTMLElement {
7570
8258
  }
7571
8259
 
7572
8260
  class TableController {
8261
+ #listeners = new ListenerBin()
8262
+
7573
8263
  constructor(editorElement) {
7574
8264
  this.editor = editorElement.editor;
7575
8265
  this.contents = editorElement.contents;
@@ -7585,7 +8275,7 @@ class TableController {
7585
8275
  this.currentTableNodeKey = null;
7586
8276
  this.currentCellKey = null;
7587
8277
 
7588
- this.#unregisterKeyHandlers();
8278
+ this.#listeners.dispose();
7589
8279
  }
7590
8280
 
7591
8281
  get currentCell() {
@@ -7867,16 +8557,10 @@ class TableController {
7867
8557
 
7868
8558
  #registerKeyHandlers() {
7869
8559
  // We can't prevent these externally using regular keydown because Lexical handles it first.
7870
- this.unregisterBackspaceKeyHandler = this.editor.registerCommand(KEY_BACKSPACE_COMMAND, (event) => this.#handleBackspaceKey(event), COMMAND_PRIORITY_HIGH);
7871
- this.unregisterEnterKeyHandler = this.editor.registerCommand(KEY_ENTER_COMMAND, (event) => this.#handleEnterKey(event), COMMAND_PRIORITY_HIGH);
7872
- }
7873
-
7874
- #unregisterKeyHandlers() {
7875
- this.unregisterBackspaceKeyHandler?.();
7876
- this.unregisterEnterKeyHandler?.();
7877
-
7878
- this.unregisterBackspaceKeyHandler = null;
7879
- 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
+ );
7880
8564
  }
7881
8565
 
7882
8566
  #handleBackspaceKey(event) {
@@ -7970,6 +8654,8 @@ var TableIcons = {
7970
8654
  };
7971
8655
 
7972
8656
  class TableTools extends HTMLElement {
8657
+ #listeners = new ListenerBin()
8658
+
7973
8659
  connectedCallback() {
7974
8660
  this.tableController = new TableController(this.#editorElement);
7975
8661
  this.classList.add("lexxy-floating-controls");
@@ -7985,12 +8671,7 @@ class TableTools extends HTMLElement {
7985
8671
  }
7986
8672
 
7987
8673
  dispose() {
7988
- this.#unregisterKeyboardShortcuts();
7989
-
7990
- this.unregisterUpdateListener?.();
7991
- this.unregisterUpdateListener = null;
7992
-
7993
- this.removeEventListener("keydown", this.#handleToolsKeydown);
8674
+ this.#listeners.dispose();
7994
8675
 
7995
8676
  this.tableController?.destroy();
7996
8677
  this.tableController = null;
@@ -8015,7 +8696,7 @@ class TableTools extends HTMLElement {
8015
8696
  this.appendChild(this.#createColumnButtonsContainer());
8016
8697
 
8017
8698
  this.appendChild(this.#createDeleteTableButton());
8018
- this.addEventListener("keydown", this.#handleToolsKeydown);
8699
+ this.#listeners.track(registerEventListener(this, "keydown", this.#handleToolsKeydown));
8019
8700
  }
8020
8701
 
8021
8702
  #createButtonsContainer(childType, setCountProperty, moreMenu) {
@@ -8108,12 +8789,7 @@ class TableTools extends HTMLElement {
8108
8789
  }
8109
8790
 
8110
8791
  #registerKeyboardShortcuts() {
8111
- this.unregisterKeyboardShortcuts = this.#editor.registerCommand(KEY_DOWN_COMMAND, this.#handleAccessibilityShortcutKey, COMMAND_PRIORITY_HIGH);
8112
- }
8113
-
8114
- #unregisterKeyboardShortcuts() {
8115
- this.unregisterKeyboardShortcuts?.();
8116
- this.unregisterKeyboardShortcuts = null;
8792
+ this.#listeners.track(this.#editor.registerCommand(KEY_DOWN_COMMAND, this.#handleAccessibilityShortcutKey, COMMAND_PRIORITY_HIGH));
8117
8793
  }
8118
8794
 
8119
8795
  #handleAccessibilityShortcutKey = (event) => {
@@ -8183,7 +8859,7 @@ class TableTools extends HTMLElement {
8183
8859
  }
8184
8860
 
8185
8861
  #monitorForTableSelection() {
8186
- this.unregisterUpdateListener = this.#editor.registerUpdateListener(() => {
8862
+ this.#listeners.track(this.#editor.registerUpdateListener(() => {
8187
8863
  this.tableController.updateSelectedTable();
8188
8864
 
8189
8865
  const tableNode = this.tableController.currentTableNode;
@@ -8192,7 +8868,7 @@ class TableTools extends HTMLElement {
8192
8868
  } else {
8193
8869
  this.#hide();
8194
8870
  }
8195
- });
8871
+ }));
8196
8872
  }
8197
8873
 
8198
8874
  #executeTableCommand(command) {
@@ -8304,9 +8980,107 @@ function defineElements() {
8304
8980
  });
8305
8981
  }
8306
8982
 
8983
+ class NativeAdapter {
8984
+ frozenLinkKey = null
8985
+
8986
+ constructor(editorElement) {
8987
+ this.editorElement = editorElement;
8988
+ this.editorContentElement = editorElement.editorContentElement;
8989
+ }
8990
+
8991
+ dispatchAttributesChange(attributes, linkHref, highlight, headingTag) {
8992
+ dispatch(this.editorElement, "lexxy:attributes-change", {
8993
+ attributes,
8994
+ link: linkHref ? { href: linkHref } : null,
8995
+ highlight,
8996
+ headingTag
8997
+ });
8998
+ }
8999
+
9000
+ dispatchEditorInitialized(detail) {
9001
+ dispatch(this.editorElement, "lexxy:editor-initialized", detail);
9002
+ }
9003
+
9004
+ freeze() {
9005
+ let frozenLinkKey = null;
9006
+ this.editorElement.editor?.getEditorState().read(() => {
9007
+ const selection = $getSelection();
9008
+ if (!$isRangeSelection(selection)) return
9009
+
9010
+ const linkNode = $getNearestNodeOfType(selection.anchor.getNode(), LinkNode);
9011
+ if (linkNode) {
9012
+ frozenLinkKey = linkNode.getKey();
9013
+ }
9014
+ });
9015
+
9016
+ this.frozenLinkKey = frozenLinkKey;
9017
+ this.editorContentElement.contentEditable = "false";
9018
+ }
9019
+
9020
+ thaw() {
9021
+ this.editorContentElement.contentEditable = "true";
9022
+ }
9023
+
9024
+ unlinkFrozenNode() {
9025
+ const key = this.frozenLinkKey;
9026
+ if (!key) return false
9027
+
9028
+ const linkNode = $getNodeByKey(key);
9029
+ if (!$isLinkNode(linkNode)) {
9030
+ this.frozenLinkKey = null;
9031
+ return false
9032
+ }
9033
+
9034
+ const children = linkNode.getChildren();
9035
+ for (const child of children) {
9036
+ linkNode.insertBefore(child);
9037
+ }
9038
+ linkNode.remove();
9039
+
9040
+ // Select the former link text so a follow-up createLink can re-wrap it.
9041
+ const firstText = this.#findFirstTextDescendant(children);
9042
+ const lastText = this.#findLastTextDescendant(children);
9043
+ if (firstText && lastText) {
9044
+ const selection = $getSelection();
9045
+ if ($isRangeSelection(selection)) {
9046
+ selection.anchor.set(firstText.getKey(), 0, "text");
9047
+ selection.focus.set(lastText.getKey(), lastText.getTextContent().length, "text");
9048
+ }
9049
+ }
9050
+
9051
+ this.frozenLinkKey = null;
9052
+ return true
9053
+ }
9054
+
9055
+ #findFirstTextDescendant(nodes) {
9056
+ for (const node of nodes) {
9057
+ if ($isTextNode(node)) return node
9058
+ if ($isElementNode(node)) {
9059
+ const nestedTextNode = this.#findFirstTextDescendant(node.getChildren());
9060
+ if (nestedTextNode) return nestedTextNode
9061
+ }
9062
+ }
9063
+
9064
+ return null
9065
+ }
9066
+
9067
+ #findLastTextDescendant(nodes) {
9068
+ for (let index = nodes.length - 1; index >= 0; index--) {
9069
+ const node = nodes[index];
9070
+ if ($isTextNode(node)) return node
9071
+ if ($isElementNode(node)) {
9072
+ const nestedTextNode = this.#findLastTextDescendant(node.getChildren());
9073
+ if (nestedTextNode) return nestedTextNode
9074
+ }
9075
+ }
9076
+
9077
+ return null
9078
+ }
9079
+ }
9080
+
8307
9081
  const configure = Lexxy.configure;
8308
9082
 
8309
9083
  // Pushing elements definition to after the current call stack to allow global configuration to take place first
8310
9084
  setTimeout(defineElements, 0);
8311
9085
 
8312
- export { $createActionTextAttachmentNode, $createActionTextAttachmentUploadNode, $isActionTextAttachmentNode, ActionTextAttachmentNode, ActionTextAttachmentUploadNode, CustomActionTextAttachmentNode, LexxyExtension as Extension, HorizontalDividerNode, configure };
9086
+ export { $createActionTextAttachmentNode, $createActionTextAttachmentUploadNode, $isActionTextAttachmentNode, ActionTextAttachmentNode, ActionTextAttachmentUploadNode, CustomActionTextAttachmentNode, LexxyExtension as Extension, HorizontalDividerNode, NativeAdapter, configure };