@37signals/lexxy 0.9.1-beta → 0.9.3-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.
Files changed (2) hide show
  1. package/dist/lexxy.esm.js +567 -122
  2. package/package.json +1 -1
package/dist/lexxy.esm.js CHANGED
@@ -10,10 +10,11 @@ import 'prismjs/components/prism-json';
10
10
  import 'prismjs/components/prism-diff';
11
11
  import DOMPurify from 'dompurify';
12
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, 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';
13
+ import { SKIP_DOM_SELECTION_TAG, $getSelection, $isRangeSelection, $getNodeByKey, $isTextNode, $createRangeSelection, $setSelection, DecoratorNode, $createTextNode, HISTORY_MERGE_TAG, SKIP_SCROLL_INTO_VIEW_TAG, $createNodeSelection, $isDecoratorNode, $isLineBreakNode, $isElementNode, $createParagraphNode, TextNode, createCommand, createState, defineExtension, COMMAND_PRIORITY_NORMAL, $getState, $setState, $hasUpdateTag, PASTE_TAG, FORMAT_TEXT_COMMAND, UNDO_COMMAND, REDO_COMMAND, PASTE_COMMAND, COMMAND_PRIORITY_LOW, KEY_ARROW_RIGHT_COMMAND, KEY_TAB_COMMAND, OUTDENT_CONTENT_COMMAND, INDENT_CONTENT_COMMAND, $getEditor, $getNearestRootOrShadowRoot, $isNodeSelection, $getRoot, mergeRegister as mergeRegister$1, KEY_ARROW_LEFT_COMMAND, KEY_ARROW_UP_COMMAND, KEY_ARROW_DOWN_COMMAND, DELETE_CHARACTER_COMMAND, SELECTION_CHANGE_COMMAND, CLICK_COMMAND, isDOMNode, $getNearestNodeFromDOMNode, $isRootOrShadowRoot, ElementNode, $splitNode, $isParagraphNode, $createLineBreakNode, $isRootNode, ParagraphNode, RootNode, COMMAND_PRIORITY_HIGH, DRAGSTART_COMMAND, DROP_COMMAND, INSERT_PARAGRAPH_COMMAND, CLEAR_HISTORY_COMMAND, $addUpdateTag, KEY_ENTER_COMMAND, COMMAND_PRIORITY_CRITICAL, KEY_SPACE_COMMAND, KEY_BACKSPACE_COMMAND, KEY_DOWN_COMMAND } from 'lexical';
14
14
  import { buildEditorFromExtensions } from '@lexical/extension';
15
15
  import { ListNode, INSERT_UNORDERED_LIST_COMMAND, INSERT_ORDERED_LIST_COMMAND, ListItemNode, $getListDepth, $isListItemNode, $isListNode, registerList } from '@lexical/list';
16
16
  import { $createAutoLinkNode, $toggleLink, LinkNode, $createLinkNode, AutoLinkNode, $isLinkNode } from '@lexical/link';
17
+ import { $getNearestNodeOfType, $wrapNodeInElement, $lastToFirstIterator, mergeRegister, $insertFirst, $unwrapAndFilterDescendants, $firstToLastIterator, $descendantsMatching } from '@lexical/utils';
17
18
  import { registerPlainText } from '@lexical/plain-text';
18
19
  import { RichTextExtension, $isQuoteNode, $isHeadingNode, $createHeadingNode, $createQuoteNode, QuoteNode, HeadingNode, registerRichText } from '@lexical/rich-text';
19
20
  import { $generateNodesFromDOM, $generateHtmlFromNodes } from '@lexical/html';
@@ -22,8 +23,7 @@ import { registerMarkdownShortcuts, TRANSFORMERS } from '@lexical/markdown';
22
23
  import { createEmptyHistoryState, registerHistory } from '@lexical/history';
23
24
  import { createElement, extractPlainTextFromHtml, createAttachmentFigure, isPreviewableImage, dispatch, parseHtml, addBlockSpacing, generateDomId } from './lexxy_helpers.esm.js';
24
25
  export { highlightCode as highlightAll, highlightCode } from './lexxy_helpers.esm.js';
25
- import { INSERT_TABLE_COMMAND, $getTableCellNodeFromLexicalNode, TableCellNode, TableNode, TableRowNode, registerTablePlugin, registerTableSelectionObserver, setScrollableTablesActive, 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';
26
+ import { INSERT_TABLE_COMMAND, $getTableCellNodeFromLexicalNode, TableCellNode, TableNode, TableRowNode, setScrollableTablesActive, registerTablePlugin, registerTableSelectionObserver, TableCellHeaderStates, $insertTableRowAtSelection, $insertTableColumnAtSelection, $deleteTableRowAtSelection, $deleteTableColumnAtSelection, $findTableNode, $getTableRowIndexFromTableCellNode, $getTableColumnIndexFromTableCellNode, $findCellNode, $getElementForTableNode } from '@lexical/table';
27
27
  import { marked } from 'marked';
28
28
  import { $insertDataTransferForRichText } from '@lexical/clipboard';
29
29
 
@@ -382,9 +382,22 @@ class LexicalToolbarElement extends HTMLElement {
382
382
  }
383
383
 
384
384
  disconnectedCallback() {
385
+ this.dispose();
386
+ }
387
+
388
+ dispose() {
385
389
  this.#uninstallResizeObserver();
390
+ this.#unbindButtons();
386
391
  this.#unbindHotkeys();
387
392
  this.#unbindFocusListeners();
393
+ this.unregisterSelectionListener?.();
394
+ this.unregisterHistoryListener?.();
395
+
396
+ this.editorElement = null;
397
+ this.editor = null;
398
+ this.selection = null;
399
+
400
+ this.#createEditorPromise();
388
401
  }
389
402
 
390
403
  attributeChangedCallback(name, oldValue, newValue) {
@@ -428,10 +441,12 @@ class LexicalToolbarElement extends HTMLElement {
428
441
  this.connectedCallback();
429
442
  }
430
443
 
431
- #createEditorPromise() {
444
+ async #createEditorPromise() {
432
445
  this.editorPromise = new Promise((resolve) => {
433
446
  this.resolveEditorPromise = resolve;
434
447
  });
448
+
449
+ this.editorElement = await this.editorPromise;
435
450
  }
436
451
 
437
452
  #installResizeObserver() {
@@ -447,10 +462,14 @@ class LexicalToolbarElement extends HTMLElement {
447
462
  }
448
463
 
449
464
  #bindButtons() {
450
- this.addEventListener("click", this.#handleButtonClicked.bind(this));
465
+ this.addEventListener("click", this.#handleButtonClicked);
451
466
  }
452
467
 
453
- #handleButtonClicked(event) {
468
+ #unbindButtons() {
469
+ this.removeEventListener("click", this.#handleButtonClicked);
470
+ }
471
+
472
+ #handleButtonClicked = (event) => {
454
473
  this.#handleTargetClicked(event, "[data-command]", this.#dispatchButtonCommand.bind(this));
455
474
  }
456
475
 
@@ -510,8 +529,8 @@ class LexicalToolbarElement extends HTMLElement {
510
529
  }
511
530
 
512
531
  #unbindFocusListeners() {
513
- this.editorElement.removeEventListener("lexxy:focus", this.#handleEditorFocus);
514
- this.editorElement.removeEventListener("lexxy:blur", this.#handleEditorBlur);
532
+ this.editorElement?.removeEventListener("lexxy:focus", this.#handleEditorFocus);
533
+ this.editorElement?.removeEventListener("lexxy:blur", this.#handleEditorBlur);
515
534
  this.removeEventListener("keydown", this.#handleKeydown);
516
535
  }
517
536
 
@@ -535,7 +554,7 @@ class LexicalToolbarElement extends HTMLElement {
535
554
  }
536
555
 
537
556
  #monitorSelectionChanges() {
538
- this.editor.registerUpdateListener(() => {
557
+ this.unregisterSelectionListener = this.editor.registerUpdateListener(() => {
539
558
  this.editor.getEditorState().read(() => {
540
559
  this.#updateButtonStates();
541
560
  this.#closeDropdowns();
@@ -544,7 +563,7 @@ class LexicalToolbarElement extends HTMLElement {
544
563
  }
545
564
 
546
565
  #monitorHistoryChanges() {
547
- this.editor.registerUpdateListener(() => {
566
+ this.unregisterHistoryListener = this.editor.registerUpdateListener(() => {
548
567
  this.#updateUndoRedoButtonStates();
549
568
  });
550
569
  }
@@ -1390,6 +1409,24 @@ function isSelectionHighlighted(selection) {
1390
1409
  }
1391
1410
  }
1392
1411
 
1412
+ function getHighlightStyles(selection) {
1413
+ if (!$isRangeSelection(selection)) return null
1414
+
1415
+ let styles = getStyleObjectFromCSS(selection.style);
1416
+ if (!styles.color && !styles["background-color"]) {
1417
+ const anchorNode = selection.anchor.getNode();
1418
+ if ($isTextNode(anchorNode)) {
1419
+ styles = getStyleObjectFromCSS(anchorNode.getStyle());
1420
+ }
1421
+ }
1422
+
1423
+ const color = styles.color || null;
1424
+ const backgroundColor = styles["background-color"] || null;
1425
+ if (!color && !backgroundColor) return null
1426
+
1427
+ return { color, backgroundColor }
1428
+ }
1429
+
1393
1430
  function hasHighlightStyles(cssOrStyles) {
1394
1431
  const styles = typeof cssOrStyles === "string" ? getStyleObjectFromCSS(cssOrStyles) : cssOrStyles;
1395
1432
  return !!(styles.color || styles["background-color"])
@@ -1952,6 +1989,7 @@ const COMMANDS = [
1952
1989
  "insertOrderedList",
1953
1990
  "insertQuoteBlock",
1954
1991
  "insertCodeBlock",
1992
+ "setCodeLanguage",
1955
1993
  "insertHorizontalDivider",
1956
1994
  "uploadImage",
1957
1995
  "uploadFile",
@@ -1964,9 +2002,10 @@ const COMMANDS = [
1964
2002
 
1965
2003
  class CommandDispatcher {
1966
2004
  #selectionBeforeDrag = null
2005
+ #unregister = []
1967
2006
 
1968
2007
  static configureFor(editorElement) {
1969
- new CommandDispatcher(editorElement);
2008
+ return new CommandDispatcher(editorElement)
1970
2009
  }
1971
2010
 
1972
2011
  constructor(editorElement) {
@@ -2026,7 +2065,14 @@ class CommandDispatcher {
2026
2065
  }
2027
2066
 
2028
2067
  dispatchUnlink() {
2029
- this.#toggleLink(null);
2068
+ this.editor.update(() => {
2069
+ // Let adapters signal whether unlink should target a frozen link key.
2070
+ if (this.editorElement.adapter.unlinkFrozenNode?.()) {
2071
+ return
2072
+ }
2073
+
2074
+ $toggleLink(null);
2075
+ });
2030
2076
  }
2031
2077
 
2032
2078
  dispatchInsertUnorderedList() {
@@ -2117,6 +2163,17 @@ class CommandDispatcher {
2117
2163
  }
2118
2164
  }
2119
2165
 
2166
+ dispatchSetCodeLanguage(language) {
2167
+ this.editor.update(() => {
2168
+ if (!this.selection.isInsideCodeBlock) return
2169
+
2170
+ const codeNode = this.selection.nearestNodeOfType(CodeNode);
2171
+ if (!codeNode) return
2172
+
2173
+ codeNode.setLanguage(language);
2174
+ });
2175
+ }
2176
+
2120
2177
  dispatchInsertHorizontalDivider() {
2121
2178
  this.contents.insertAtCursorEnsuringLineBelow(new HorizontalDividerNode());
2122
2179
  this.editor.focus();
@@ -2178,6 +2235,13 @@ class CommandDispatcher {
2178
2235
  this.editor.dispatchCommand(REDO_COMMAND, undefined);
2179
2236
  }
2180
2237
 
2238
+ dispose() {
2239
+ while (this.#unregister.length) {
2240
+ const unregister = this.#unregister.pop();
2241
+ unregister();
2242
+ }
2243
+ }
2244
+
2181
2245
  #registerCommands() {
2182
2246
  for (const command of COMMANDS) {
2183
2247
  const methodName = `dispatch${capitalize(command)}`;
@@ -2188,12 +2252,12 @@ class CommandDispatcher {
2188
2252
  }
2189
2253
 
2190
2254
  #registerCommandHandler(command, priority, handler) {
2191
- this.editor.registerCommand(command, handler, priority);
2255
+ this.#unregister.push(this.editor.registerCommand(command, handler, priority));
2192
2256
  }
2193
2257
 
2194
2258
  #registerKeyboardCommands() {
2195
- this.editor.registerCommand(KEY_ARROW_RIGHT_COMMAND, this.#handleArrowRightKey.bind(this), COMMAND_PRIORITY_NORMAL);
2196
- this.editor.registerCommand(KEY_TAB_COMMAND, this.#handleTabKey.bind(this), COMMAND_PRIORITY_NORMAL);
2259
+ this.#registerCommandHandler(KEY_ARROW_RIGHT_COMMAND, COMMAND_PRIORITY_NORMAL, this.#handleArrowRightKey.bind(this));
2260
+ this.#registerCommandHandler(KEY_TAB_COMMAND, COMMAND_PRIORITY_NORMAL, this.#handleTabKey.bind(this));
2197
2261
  }
2198
2262
 
2199
2263
  #handleArrowRightKey(event) {
@@ -2308,16 +2372,6 @@ class CommandDispatcher {
2308
2372
  return $isRangeSelection(selection) && selection.isCollapsed()
2309
2373
  }
2310
2374
 
2311
- // Not using TOGGLE_LINK_COMMAND because it's not handled unless you use React/LinkPlugin
2312
- #toggleLink(url) {
2313
- this.editor.update(() => {
2314
- if (url === null) {
2315
- $toggleLink(null);
2316
- } else {
2317
- $toggleLink(url);
2318
- }
2319
- });
2320
- }
2321
2375
  }
2322
2376
 
2323
2377
  function capitalize(str) {
@@ -2544,8 +2598,8 @@ class ActionTextAttachmentNode extends DecoratorNode {
2544
2598
  return null
2545
2599
  }
2546
2600
 
2547
- createAttachmentFigure() {
2548
- const figure = createAttachmentFigure(this.contentType, this.isPreviewableAttachment, this.fileName);
2601
+ createAttachmentFigure(previewable = this.isPreviewableAttachment) {
2602
+ const figure = createAttachmentFigure(this.contentType, previewable, this.fileName);
2549
2603
  figure.draggable = true;
2550
2604
  figure.dataset.lexicalNodeKey = this.__key;
2551
2605
 
@@ -2679,6 +2733,8 @@ function $isActionTextAttachmentNode(node) {
2679
2733
  }
2680
2734
 
2681
2735
  class Selection {
2736
+ #unregister = []
2737
+
2682
2738
  constructor(editorElement) {
2683
2739
  this.editorElement = editorElement;
2684
2740
  this.editorContentElement = editorElement.editorContentElement;
@@ -2935,6 +2991,18 @@ class Selection {
2935
2991
  return this.#findPreviousSiblingUp(anchorNode)
2936
2992
  }
2937
2993
 
2994
+ dispose() {
2995
+ this.editorElement = null;
2996
+ this.editorContentElement = null;
2997
+ this.editor = null;
2998
+ this.previouslySelectedKeys = null;
2999
+
3000
+ while (this.#unregister.length) {
3001
+ const unregister = this.#unregister.pop();
3002
+ unregister();
3003
+ }
3004
+ }
3005
+
2938
3006
  // When all inline code text is deleted, Lexical's selection retains the stale
2939
3007
  // code format flag. Verify the flag is backed by actual code-formatted content:
2940
3008
  // a code block ancestor or a text node that carries the code format.
@@ -2950,7 +3018,7 @@ class Selection {
2950
3018
  // detects that stale state and clears it so newly typed text won't be
2951
3019
  // code-formatted.
2952
3020
  #clearStaleInlineCodeFormat() {
2953
- this.editor.registerUpdateListener(({ editorState, tags }) => {
3021
+ this.#unregister.push(this.editor.registerUpdateListener(({ editorState, tags }) => {
2954
3022
  if (tags.has("history-merge") || tags.has("skip-dom-selection")) return
2955
3023
 
2956
3024
  let isStale = false;
@@ -2979,7 +3047,7 @@ class Selection {
2979
3047
  });
2980
3048
  }, 0);
2981
3049
  }
2982
- });
3050
+ }));
2983
3051
  }
2984
3052
 
2985
3053
  get #currentlySelectedKeys() {
@@ -2998,29 +3066,32 @@ class Selection {
2998
3066
  }
2999
3067
 
3000
3068
  #processSelectionChangeCommands() {
3001
- this.editor.registerCommand(KEY_ARROW_LEFT_COMMAND, this.#selectPreviousNode.bind(this), COMMAND_PRIORITY_LOW);
3002
- this.editor.registerCommand(KEY_ARROW_RIGHT_COMMAND, this.#selectNextNode.bind(this), COMMAND_PRIORITY_LOW);
3003
- this.editor.registerCommand(KEY_ARROW_UP_COMMAND, this.#selectPreviousTopLevelNode.bind(this), COMMAND_PRIORITY_LOW);
3004
- this.editor.registerCommand(KEY_ARROW_DOWN_COMMAND, this.#selectNextTopLevelNode.bind(this), COMMAND_PRIORITY_LOW);
3069
+ this.#unregister.push(mergeRegister$1(
3070
+ this.editor.registerCommand(KEY_ARROW_LEFT_COMMAND, this.#selectPreviousNode.bind(this), COMMAND_PRIORITY_LOW),
3071
+ this.editor.registerCommand(KEY_ARROW_RIGHT_COMMAND, this.#selectNextNode.bind(this), COMMAND_PRIORITY_LOW),
3072
+ this.editor.registerCommand(KEY_ARROW_UP_COMMAND, this.#selectPreviousTopLevelNode.bind(this), COMMAND_PRIORITY_LOW),
3073
+ this.editor.registerCommand(KEY_ARROW_DOWN_COMMAND, this.#selectNextTopLevelNode.bind(this), COMMAND_PRIORITY_LOW),
3005
3074
 
3006
- this.editor.registerCommand(DELETE_CHARACTER_COMMAND, this.#selectDecoratorNodeBeforeDeletion.bind(this), COMMAND_PRIORITY_LOW);
3075
+ this.editor.registerCommand(DELETE_CHARACTER_COMMAND, this.#selectDecoratorNodeBeforeDeletion.bind(this), COMMAND_PRIORITY_LOW),
3007
3076
 
3008
- this.editor.registerCommand(SELECTION_CHANGE_COMMAND, () => {
3009
- this.current = $getSelection();
3010
- }, COMMAND_PRIORITY_LOW);
3077
+ this.editor.registerCommand(SELECTION_CHANGE_COMMAND, () => {
3078
+ this.current = $getSelection();
3079
+ }, COMMAND_PRIORITY_LOW)
3080
+ ));
3011
3081
  }
3012
3082
 
3013
3083
  #listenForNodeSelections() {
3014
- this.editor.registerCommand(CLICK_COMMAND, ({ target }) => {
3084
+ this.#unregister.push(this.editor.registerCommand(CLICK_COMMAND, ({ target }) => {
3015
3085
  if (!isDOMNode(target)) return false
3016
3086
 
3017
3087
  const targetNode = $getNearestNodeFromDOMNode(target);
3018
3088
  return $isDecoratorNode(targetNode) && this.#selectInLexical(targetNode)
3019
- }, COMMAND_PRIORITY_LOW);
3089
+ }, COMMAND_PRIORITY_LOW));
3020
3090
 
3021
- this.editor.getRootElement().addEventListener("lexxy:internal:move-to-next-line", (event) => {
3022
- this.#selectOrAppendNextLine();
3023
- });
3091
+ const moveNextLineHandler = () => this.#selectOrAppendNextLine();
3092
+ const rootElement = this.editor.getRootElement();
3093
+ rootElement.addEventListener("lexxy:internal:move-to-next-line", moveNextLineHandler);
3094
+ this.#unregister.push(() => rootElement.removeEventListener("lexxy:internal:move-to-next-line", moveNextLineHandler));
3024
3095
  }
3025
3096
 
3026
3097
  #containEditorFocus() {
@@ -3613,9 +3684,12 @@ class ActionTextAttachmentUploadNode extends ActionTextAttachmentNode {
3613
3684
  // node is reloaded from saved state such as from history.
3614
3685
  this.#startUploadIfNeeded();
3615
3686
 
3616
- const figure = this.createAttachmentFigure();
3687
+ // Bridge-managed uploads (uploadUrl is null) don't have file data to show
3688
+ // an image preview, so always show the file icon during upload.
3689
+ const canPreviewFile = this.isPreviewableAttachment && this.uploadUrl != null;
3690
+ const figure = this.createAttachmentFigure(canPreviewFile);
3617
3691
 
3618
- if (this.isPreviewableAttachment) {
3692
+ if (canPreviewFile) {
3619
3693
  const img = figure.appendChild(this.#createDOMForImage());
3620
3694
 
3621
3695
  // load file locally to set dimensions and prevent vertical shifting
@@ -3715,6 +3789,7 @@ class ActionTextAttachmentUploadNode extends ActionTextAttachmentNode {
3715
3789
 
3716
3790
  async #startUploadIfNeeded() {
3717
3791
  if (this.#uploadStarted) return
3792
+ if (!this.uploadUrl) return // Bridge-managed upload — skip DirectUpload
3718
3793
 
3719
3794
  this.#setUploadStarted();
3720
3795
 
@@ -3731,7 +3806,9 @@ class ActionTextAttachmentUploadNode extends ActionTextAttachmentNode {
3731
3806
  this.#handleUploadError(error);
3732
3807
  } else {
3733
3808
  this.#dispatchEvent("lexxy:upload-end", { file: this.file, error: null });
3734
- this.#showUploadedAttachment(blob);
3809
+ this.editor.update(() => {
3810
+ this.showUploadedAttachment(blob);
3811
+ }, { tag: this.#backgroundUpdateTags });
3735
3812
  }
3736
3813
  });
3737
3814
  }
@@ -3775,17 +3852,15 @@ class ActionTextAttachmentUploadNode extends ActionTextAttachmentNode {
3775
3852
  }, { tag: this.#backgroundUpdateTags });
3776
3853
  }
3777
3854
 
3778
- #showUploadedAttachment(blob) {
3779
- const editorHasFocus = this.#editorHasFocus;
3855
+ showUploadedAttachment(blob) {
3856
+ const replacementNode = this.#toActionTextAttachmentNodeWith(blob);
3857
+ this.replace(replacementNode);
3780
3858
 
3781
- this.editor.update(() => {
3782
- const replacementNode = this.#toActionTextAttachmentNodeWith(blob);
3783
- this.replace(replacementNode);
3859
+ if ($isRootOrShadowRoot(replacementNode.getParent())) {
3860
+ replacementNode.selectNext();
3861
+ }
3784
3862
 
3785
- if (editorHasFocus && $isRootOrShadowRoot(replacementNode.getParent())) {
3786
- replacementNode.selectNext();
3787
- }
3788
- }, { tag: this.#backgroundUpdateTags });
3863
+ return replacementNode.getKey()
3789
3864
  }
3790
3865
 
3791
3866
  // Upload lifecycle methods (progress, completion, errors) run asynchronously and may
@@ -4155,7 +4230,11 @@ class Contents {
4155
4230
  constructor(editorElement) {
4156
4231
  this.editorElement = editorElement;
4157
4232
  this.editor = editorElement.editor;
4233
+ }
4158
4234
 
4235
+ dispose() {
4236
+ this.editorElement = null;
4237
+ this.editor = null;
4159
4238
  }
4160
4239
 
4161
4240
  insertHtml(html, { tag } = {}) {
@@ -4383,6 +4462,53 @@ class Contents {
4383
4462
  });
4384
4463
  }
4385
4464
 
4465
+ insertPendingAttachment(file) {
4466
+ if (!this.editorElement.supportsAttachments) return null
4467
+
4468
+ let nodeKey = null;
4469
+ this.editor.update(() => {
4470
+ const uploadNode = new ActionTextAttachmentUploadNode({
4471
+ file,
4472
+ uploadUrl: null,
4473
+ blobUrlTemplate: this.editorElement.blobUrlTemplate,
4474
+ editor: this.editor
4475
+ });
4476
+ this.insertAtCursor(uploadNode);
4477
+ nodeKey = uploadNode.getKey();
4478
+ }, { tag: HISTORY_MERGE_TAG });
4479
+
4480
+ if (!nodeKey) return null
4481
+
4482
+ const editor = this.editor;
4483
+ return {
4484
+ setAttributes(blob) {
4485
+ editor.update(() => {
4486
+ const node = $getNodeByKey(nodeKey);
4487
+ if (!(node instanceof ActionTextAttachmentUploadNode)) return
4488
+
4489
+ const replacementNodeKey = node.showUploadedAttachment(blob);
4490
+ if (replacementNodeKey) {
4491
+ nodeKey = replacementNodeKey;
4492
+ }
4493
+ }, { tag: HISTORY_MERGE_TAG });
4494
+ },
4495
+ setUploadProgress(progress) {
4496
+ editor.update(() => {
4497
+ const node = $getNodeByKey(nodeKey);
4498
+ if (!(node instanceof ActionTextAttachmentUploadNode)) return
4499
+
4500
+ node.getWritable().progress = progress;
4501
+ }, { tag: HISTORY_MERGE_TAG });
4502
+ },
4503
+ remove() {
4504
+ editor.update(() => {
4505
+ const node = $getNodeByKey(nodeKey);
4506
+ if (node) node.remove();
4507
+ });
4508
+ }
4509
+ }
4510
+ }
4511
+
4386
4512
  replaceNodeWithHTML(nodeKey, html, options = {}) {
4387
4513
  this.editor.update(() => {
4388
4514
  const node = $getNodeByKey(nodeKey);
@@ -4867,6 +4993,18 @@ class Extensions {
4867
4993
  }
4868
4994
  }
4869
4995
 
4996
+ class BrowserAdapter {
4997
+ frozenLinkKey = null
4998
+
4999
+ dispatchAttributesChange(attributes, linkHref, highlight, headingTag) {}
5000
+ dispatchEditorInitialized(detail) {}
5001
+ freeze() {}
5002
+ thaw() {}
5003
+ unlinkFrozenNode() {
5004
+ return false
5005
+ }
5006
+ }
5007
+
4870
5008
  // Custom TextNode exportDOM that avoids redundant bold/italic wrapping.
4871
5009
  //
4872
5010
  // Lexical's built-in TextNode.exportDOM() calls createDOM() which produces semantic tags
@@ -5186,11 +5324,12 @@ class TablesExtension extends LexxyExtension {
5186
5324
  TableRowNode
5187
5325
  ],
5188
5326
  register(editor) {
5327
+ setScrollableTablesActive(editor, true);
5328
+
5189
5329
  return mergeRegister(
5190
5330
  // Register Lexical table plugins
5191
5331
  registerTablePlugin(editor),
5192
5332
  registerTableSelectionObserver(editor, true),
5193
- setScrollableTablesActive(editor, true),
5194
5333
 
5195
5334
  // Bug fix: Prevent hardcoded background color (Lexical #8089)
5196
5335
  editor.registerNodeTransform(TableCellNode, (node) => {
@@ -5917,6 +6056,8 @@ class LexicalEditorElement extends HTMLElement {
5917
6056
 
5918
6057
  #initialValue = ""
5919
6058
  #validationTextArea = document.createElement("textarea")
6059
+ #editorInitializedRafId = null
6060
+ #disposables = []
5920
6061
 
5921
6062
  constructor() {
5922
6063
  super();
@@ -5925,20 +6066,28 @@ class LexicalEditorElement extends HTMLElement {
5925
6066
  }
5926
6067
 
5927
6068
  connectedCallback() {
5928
- this.id ??= generateDomId("lexxy-editor");
6069
+ this.id ||= generateDomId("lexxy-editor");
5929
6070
  this.config = new EditorConfiguration(this);
5930
6071
  this.extensions = new Extensions(this);
5931
6072
 
5932
6073
  this.editor = this.#createEditor();
6074
+ this.#disposables.push(this.editor);
5933
6075
 
5934
6076
  this.contents = new Contents(this);
6077
+ this.#disposables.push(this.contents);
6078
+
5935
6079
  this.selection = new Selection(this);
6080
+ this.#disposables.push(this.selection);
6081
+
5936
6082
  this.clipboard = new Clipboard(this);
6083
+ this.adapter = new BrowserAdapter();
6084
+
6085
+ const commandDispatcher = CommandDispatcher.configureFor(this);
6086
+ this.#disposables.push(commandDispatcher);
5937
6087
 
5938
- CommandDispatcher.configureFor(this);
5939
6088
  this.#initialize();
5940
6089
 
5941
- requestAnimationFrame(() => dispatch(this, "lexxy:initialize"));
6090
+ this.#scheduleEditorInitializedDispatch();
5942
6091
  this.toggleAttribute("connected", true);
5943
6092
 
5944
6093
  this.#handleAutofocus();
@@ -5947,6 +6096,7 @@ class LexicalEditorElement extends HTMLElement {
5947
6096
  }
5948
6097
 
5949
6098
  disconnectedCallback() {
6099
+ this.#cancelEditorInitializedDispatch();
5950
6100
  this.valueBeforeDisconnect = this.value;
5951
6101
  this.#reset(); // Prevent hangs with Safari when morphing
5952
6102
  }
@@ -5988,7 +6138,7 @@ class LexicalEditorElement extends HTMLElement {
5988
6138
  get toolbarElement() {
5989
6139
  if (!this.#hasToolbar) return null
5990
6140
 
5991
- this.toolbar = this.toolbar || this.#findOrCreateDefaultToolbar();
6141
+ this.toolbar ??= this.#findOrCreateDefaultToolbar();
5992
6142
  return this.toolbar
5993
6143
  }
5994
6144
 
@@ -6043,6 +6193,32 @@ class LexicalEditorElement extends HTMLElement {
6043
6193
  return this.config.get("richText")
6044
6194
  }
6045
6195
 
6196
+ registerAdapter(adapter) {
6197
+ this.adapter = adapter;
6198
+
6199
+ if (!this.editor) return
6200
+
6201
+ this.#cancelEditorInitializedDispatch();
6202
+ this.#dispatchEditorInitialized();
6203
+ this.#dispatchAttributesChange();
6204
+ }
6205
+
6206
+ freezeSelection() {
6207
+ this.adapter.freeze();
6208
+ }
6209
+
6210
+ thawSelection() {
6211
+ this.adapter.thaw();
6212
+ }
6213
+
6214
+ dispatchAttributesChange() {
6215
+ this.#dispatchAttributesChange();
6216
+ }
6217
+
6218
+ dispatchEditorInitialized() {
6219
+ this.#dispatchEditorInitialized();
6220
+ }
6221
+
6046
6222
  // TODO: Deprecate `single-line` attribute
6047
6223
  get isSingleLineMode() {
6048
6224
  return this.hasAttribute("single-line")
@@ -6124,6 +6300,7 @@ class LexicalEditorElement extends HTMLElement {
6124
6300
 
6125
6301
  #createEditor() {
6126
6302
  this.editorContentElement ||= this.#createEditorContentElement();
6303
+ this.appendChild(this.editorContentElement);
6127
6304
 
6128
6305
  const editor = buildEditorFromExtensions({
6129
6306
  name: "lexxy/core",
@@ -6166,6 +6343,7 @@ class LexicalEditorElement extends HTMLElement {
6166
6343
  const editorContentElement = createElement("div", {
6167
6344
  classList: "lexxy-editor__content",
6168
6345
  contenteditable: true,
6346
+ autocapitalize: "none",
6169
6347
  role: "textbox",
6170
6348
  "aria-multiline": true,
6171
6349
  "aria-label": this.#labelText,
@@ -6173,7 +6351,6 @@ class LexicalEditorElement extends HTMLElement {
6173
6351
  });
6174
6352
  editorContentElement.id = `${this.id}-content`;
6175
6353
  this.#ariaAttributes.forEach(attribute => editorContentElement.setAttribute(attribute.name, attribute.value));
6176
- this.appendChild(editorContentElement);
6177
6354
 
6178
6355
  if (this.getAttribute("tabindex")) {
6179
6356
  editorContentElement.setAttribute("tabindex", this.getAttribute("tabindex"));
@@ -6228,6 +6405,7 @@ class LexicalEditorElement extends HTMLElement {
6228
6405
  this.#internalFormValue = this.value;
6229
6406
  this.#toggleEmptyStatus();
6230
6407
  this.#setValidity();
6408
+ this.#dispatchAttributesChange();
6231
6409
  }));
6232
6410
  }
6233
6411
 
@@ -6249,36 +6427,48 @@ class LexicalEditorElement extends HTMLElement {
6249
6427
  }
6250
6428
 
6251
6429
  #registerComponents() {
6430
+ const registered = [];
6431
+
6252
6432
  if (this.supportsRichText) {
6253
- registerRichText(this.editor);
6254
- registerList(this.editor);
6433
+ registered.push(
6434
+ registerRichText(this.editor),
6435
+ registerList(this.editor)
6436
+ );
6255
6437
  this.#registerTableComponents();
6256
6438
  this.#registerCodeHiglightingComponents();
6257
6439
  if (this.supportsMarkdown) {
6258
- registerMarkdownShortcuts(this.editor, TRANSFORMERS);
6259
- registerMarkdownLeadingTagHandler(this.editor, TRANSFORMERS);
6440
+ registered.push(
6441
+ registerMarkdownShortcuts(this.editor, TRANSFORMERS),
6442
+ registerMarkdownLeadingTagHandler(this.editor, TRANSFORMERS)
6443
+ );
6260
6444
  }
6261
6445
  } else {
6262
- registerPlainText(this.editor);
6446
+ registered.push(registerPlainText(this.editor));
6263
6447
  }
6264
6448
  this.historyState = createEmptyHistoryState();
6265
- registerHistory(this.editor, this.historyState, 20);
6449
+ registered.push(registerHistory(this.editor, this.historyState, 20));
6450
+
6451
+ this.#addUnregisterHandler(mergeRegister$1(...registered));
6266
6452
  }
6267
6453
 
6268
6454
  #registerTableComponents() {
6269
- this.tableTools = createElement("lexxy-table-tools");
6270
- this.append(this.tableTools);
6455
+ let tableTools = this.querySelector("lexxy-table-tools");
6456
+ tableTools ??= createElement("lexxy-table-tools");
6457
+ this.append(tableTools);
6458
+ this.#disposables.push(tableTools);
6271
6459
  }
6272
6460
 
6273
6461
  #registerCodeHiglightingComponents() {
6274
6462
  registerCodeHighlighting(this.editor);
6275
- this.codeLanguagePicker = createElement("lexxy-code-language-picker");
6276
- this.append(this.codeLanguagePicker);
6463
+ let codeLanguagePicker = this.querySelector("lexxy-code-language-picker");
6464
+ codeLanguagePicker ??= createElement("lexxy-code-language-picker");
6465
+ this.append(codeLanguagePicker);
6466
+ this.#disposables.push(codeLanguagePicker);
6277
6467
  }
6278
6468
 
6279
6469
  #handleEnter() {
6280
6470
  // We can't prevent these externally using regular keydown because Lexical handles it first.
6281
- this.editor.registerCommand(
6471
+ this.#addUnregisterHandler(this.editor.registerCommand(
6282
6472
  KEY_ENTER_COMMAND,
6283
6473
  (event) => {
6284
6474
  // Prevent CTRL+ENTER
@@ -6296,16 +6486,22 @@ class LexicalEditorElement extends HTMLElement {
6296
6486
  return false
6297
6487
  },
6298
6488
  COMMAND_PRIORITY_NORMAL
6299
- );
6489
+ ));
6300
6490
  }
6301
6491
 
6302
6492
  #registerFocusEvents() {
6303
6493
  this.addEventListener("focusin", this.#handleFocusIn);
6304
6494
  this.addEventListener("focusout", this.#handleFocusOut);
6495
+
6496
+ this.#addUnregisterHandler(() => {
6497
+ this.removeEventListener("focusin", this.#handleFocusIn);
6498
+ this.removeEventListener("focusout", this.#handleFocusOut);
6499
+ });
6305
6500
  }
6306
6501
 
6307
6502
  #handleFocusIn(event) {
6308
6503
  if (this.#elementInEditorOrToolbar(event.target) && !this.currentlyFocused) {
6504
+ this.#dispatchAttributesChange();
6309
6505
  dispatch(this, "lexxy:focus");
6310
6506
  this.currentlyFocused = true;
6311
6507
  }
@@ -6344,6 +6540,10 @@ class LexicalEditorElement extends HTMLElement {
6344
6540
  #attachToolbar() {
6345
6541
  if (this.#hasToolbar) {
6346
6542
  this.toolbarElement.setEditor(this);
6543
+ if (typeof this.toolbarElement.dispose === "function") {
6544
+ this.#disposables.push(this.toolbarElement);
6545
+ }
6546
+
6347
6547
  this.extensions.initializeToolbars();
6348
6548
  }
6349
6549
  }
@@ -6353,7 +6553,7 @@ class LexicalEditorElement extends HTMLElement {
6353
6553
  if (typeof toolbarConfig === "string") {
6354
6554
  return document.getElementById(toolbarConfig)
6355
6555
  } else {
6356
- return this.#createDefaultToolbar()
6556
+ return this.querySelector("lexxy-toolbar") ?? this.#createDefaultToolbar()
6357
6557
  }
6358
6558
  }
6359
6559
 
@@ -6382,35 +6582,129 @@ class LexicalEditorElement extends HTMLElement {
6382
6582
  }
6383
6583
  }
6384
6584
 
6385
- #reset() {
6386
- this.#unregisterHandlers();
6585
+ #dispatchAttributesChange() {
6586
+ let attributes = null;
6587
+ let linkHref = null;
6588
+ let highlight = null;
6589
+ let headingTag = null;
6387
6590
 
6388
- if (this.editorContentElement) {
6389
- this.editorContentElement.remove();
6390
- this.editorContentElement = null;
6391
- }
6591
+ this.editor.getEditorState().read(() => {
6592
+ const selection = $getSelection();
6593
+ if (!$isRangeSelection(selection)) return
6392
6594
 
6393
- this.contents = null;
6394
- this.editor = null;
6595
+ const format = this.selection.getFormat();
6596
+ if (Object.keys(format).length === 0) return
6395
6597
 
6396
- if (this.toolbar) {
6397
- if (!this.getAttribute("toolbar")) { this.toolbar.remove(); }
6398
- this.toolbar = null;
6399
- }
6598
+ const anchorNode = selection.anchor.getNode();
6599
+ const linkNode = $getNearestNodeOfType(anchorNode, LinkNode);
6600
+
6601
+ attributes = {
6602
+ bold: { active: format.isBold, enabled: true },
6603
+ italic: { active: format.isItalic, enabled: true },
6604
+ strikethrough: { active: format.isStrikethrough, enabled: true },
6605
+ code: { active: format.isInCode, enabled: true },
6606
+ highlight: { active: format.isHighlight, enabled: true },
6607
+ link: { active: format.isInLink, enabled: true },
6608
+ quote: { active: format.isInQuote, enabled: true },
6609
+ heading: { active: format.isInHeading, enabled: true },
6610
+ "unordered-list": { active: format.isInList && format.listType === "bullet", enabled: true },
6611
+ "ordered-list": { active: format.isInList && format.listType === "number", enabled: true },
6612
+ undo: { active: false, enabled: this.historyState?.undoStack.length > 0 },
6613
+ redo: { active: false, enabled: this.historyState?.redoStack.length > 0 }
6614
+ };
6615
+
6616
+ linkHref = linkNode ? linkNode.getURL() : null;
6617
+ highlight = format.isHighlight ? getHighlightStyles(selection) : null;
6618
+ headingTag = format.headingTag ?? null;
6619
+ });
6400
6620
 
6401
- if (this.codeLanguagePicker) {
6402
- this.codeLanguagePicker.remove();
6403
- this.codeLanguagePicker = null;
6621
+ if (attributes) {
6622
+ this.adapter.dispatchAttributesChange(attributes, linkHref, highlight, headingTag);
6404
6623
  }
6624
+ }
6405
6625
 
6406
- if (this.tableHandler) {
6407
- this.tableHandler.remove();
6408
- this.tableHandler = null;
6409
- }
6626
+ #dispatchEditorInitialized() {
6627
+ if (!this.adapter) return
6410
6628
 
6411
- this.selection = null;
6629
+ this.adapter.dispatchEditorInitialized({
6630
+ highlightColors: this.#resolvedHighlightColors,
6631
+ headingFormats: this.#supportedHeadingFormats
6632
+ });
6633
+ }
6634
+
6635
+ #scheduleEditorInitializedDispatch() {
6636
+ this.#cancelEditorInitializedDispatch();
6637
+ this.#editorInitializedRafId = requestAnimationFrame(() => {
6638
+ this.#editorInitializedRafId = null;
6639
+ if (!this.isConnected || !this.adapter) return
6640
+
6641
+ dispatch(this, "lexxy:initialize");
6642
+ this.#dispatchEditorInitialized();
6643
+ });
6644
+ }
6645
+
6646
+ #cancelEditorInitializedDispatch() {
6647
+ if (this.#editorInitializedRafId == null) return
6648
+
6649
+ cancelAnimationFrame(this.#editorInitializedRafId);
6650
+ this.#editorInitializedRafId = null;
6651
+ }
6652
+
6653
+ get #resolvedHighlightColors() {
6654
+ const buttons = this.config.get("highlight.buttons");
6655
+ if (!buttons) return null
6656
+
6657
+ const colors = this.#resolveColors("color", buttons.color || []);
6658
+ const backgroundColors = this.#resolveColors("background-color", buttons["background-color"] || []);
6659
+ return { colors, backgroundColors }
6660
+ }
6661
+
6662
+ get #supportedHeadingFormats() {
6663
+ if (!this.supportsRichText) return []
6664
+
6665
+ return [
6666
+ { label: "Normal", command: "setFormatParagraph", tag: null },
6667
+ { label: "Large heading", command: "setFormatHeadingLarge", tag: "h2" },
6668
+ { label: "Medium heading", command: "setFormatHeadingMedium", tag: "h3" },
6669
+ { label: "Small heading", command: "setFormatHeadingSmall", tag: "h4" },
6670
+ ]
6671
+ }
6672
+
6673
+ #resolveColors(property, cssValues) {
6674
+ const resolver = document.createElement("span");
6675
+ resolver.style.display = "none";
6676
+ this.appendChild(resolver);
6677
+
6678
+ const resolved = cssValues.map(cssValue => {
6679
+ resolver.style.setProperty(property, cssValue);
6680
+ const value = window.getComputedStyle(resolver).getPropertyValue(property);
6681
+ resolver.style.removeProperty(property);
6682
+ return { name: cssValue, value }
6683
+ });
6684
+
6685
+ resolver.remove();
6686
+ return resolved
6687
+ }
6688
+
6689
+ #reset() {
6690
+ this.#cancelEditorInitializedDispatch();
6691
+ this.#dispose();
6692
+ this.editorContentElement?.remove();
6693
+ this.editorContentElement = null;
6412
6694
 
6695
+ // Prevents issues with turbo morphing receiving an empty <lexxy-editor> which wipes
6696
+ // out the DOM for the tools, and the old toolbar reference will cause issues
6697
+ this.toolbar = null;
6698
+ }
6699
+
6700
+ #dispose() {
6701
+ this.#unregisterHandlers();
6702
+ this.adapter = null;
6413
6703
  document.removeEventListener("turbo:before-cache", this.#handleTurboBeforeCache);
6704
+
6705
+ while (this.#disposables.length) {
6706
+ this.#disposables.pop().dispose();
6707
+ }
6414
6708
  }
6415
6709
 
6416
6710
  #reconnect() {
@@ -6451,14 +6745,15 @@ class ToolbarDropdown extends HTMLElement {
6451
6745
  connectedCallback() {
6452
6746
  this.container = this.closest("details");
6453
6747
 
6454
- this.container.addEventListener("toggle", this.#handleToggle.bind(this));
6455
- this.container.addEventListener("keydown", this.#handleKeyDown.bind(this));
6748
+ this.container.addEventListener("toggle", this.#handleToggle);
6749
+ this.container.addEventListener("keydown", this.#handleKeyDown);
6456
6750
 
6457
6751
  this.#onToolbarEditor(this.initialize.bind(this));
6458
6752
  }
6459
6753
 
6460
6754
  disconnectedCallback() {
6461
- this.container.removeEventListener("keydown", this.#handleKeyDown.bind(this));
6755
+ this.container?.removeEventListener("toggle", this.#handleToggle);
6756
+ this.container?.removeEventListener("keydown", this.#handleKeyDown);
6462
6757
  }
6463
6758
 
6464
6759
  get toolbar() {
@@ -6483,11 +6778,11 @@ class ToolbarDropdown extends HTMLElement {
6483
6778
  }
6484
6779
 
6485
6780
  async #onToolbarEditor(callback) {
6486
- await this.toolbar.editorConnected;
6781
+ await this.toolbar.editorElement;
6487
6782
  callback();
6488
6783
  }
6489
6784
 
6490
- #handleToggle() {
6785
+ #handleToggle = () => {
6491
6786
  if (this.container.open) {
6492
6787
  this.#handleOpen();
6493
6788
  }
@@ -6498,7 +6793,7 @@ class ToolbarDropdown extends HTMLElement {
6498
6793
  this.#resetTabIndexValues();
6499
6794
  }
6500
6795
 
6501
- #handleKeyDown(event) {
6796
+ #handleKeyDown = (event) => {
6502
6797
  if (event.key === "Escape") {
6503
6798
  event.stopPropagation();
6504
6799
  this.close();
@@ -6526,27 +6821,30 @@ class LinkDropdown extends ToolbarDropdown {
6526
6821
  super.connectedCallback();
6527
6822
  this.input = this.querySelector("input");
6528
6823
 
6529
- this.#registerHandlers();
6824
+ this.container.addEventListener("toggle", this.#handleToggle);
6825
+ this.addEventListener("submit", this.#handleSubmit);
6826
+ this.querySelector("[value='unlink']").addEventListener("click", this.#handleUnlink);
6530
6827
  }
6531
6828
 
6532
- #registerHandlers() {
6533
- this.container.addEventListener("toggle", this.#handleToggle.bind(this));
6534
- this.addEventListener("submit", this.#handleSubmit.bind(this));
6535
- this.querySelector("[value='unlink']").addEventListener("click", this.#handleUnlink.bind(this));
6829
+ disconnectedCallback() {
6830
+ this.container?.removeEventListener("toggle", this.#handleToggle);
6831
+ this.removeEventListener("submit", this.#handleSubmit);
6832
+ this.querySelector("[value='unlink']")?.removeEventListener("click", this.#handleUnlink);
6833
+ super.disconnectedCallback();
6536
6834
  }
6537
6835
 
6538
- #handleToggle({ newState }) {
6836
+ #handleToggle = ({ newState }) => {
6539
6837
  this.input.value = this.#selectedLinkUrl;
6540
6838
  this.input.required = newState === "open";
6541
6839
  }
6542
6840
 
6543
- #handleSubmit(event) {
6841
+ #handleSubmit = (event) => {
6544
6842
  const command = event.submitter?.value;
6545
6843
  this.editor.dispatchCommand(command, this.input.value);
6546
6844
  this.close();
6547
6845
  }
6548
6846
 
6549
- #handleUnlink() {
6847
+ #handleUnlink = () => {
6550
6848
  this.editor.dispatchCommand("unlink");
6551
6849
  this.close();
6552
6850
  }
@@ -6581,26 +6879,35 @@ const REMOVE_HIGHLIGHT_SELECTOR = "[data-command='removeHighlight']";
6581
6879
  const NO_STYLE = Symbol("no_style");
6582
6880
 
6583
6881
  class HighlightDropdown extends ToolbarDropdown {
6584
- connectedCallback() {
6585
- super.connectedCallback();
6586
- this.#registerToggleHandler();
6587
- }
6588
-
6589
6882
  initialize() {
6590
6883
  this.#setUpButtons();
6591
6884
  this.#registerButtonHandlers();
6592
6885
  }
6593
6886
 
6594
- #registerToggleHandler() {
6595
- this.container.addEventListener("toggle", this.#handleToggle.bind(this));
6887
+ connectedCallback() {
6888
+ super.connectedCallback();
6889
+ this.container.addEventListener("toggle", this.#handleToggle);
6890
+ }
6891
+
6892
+ disconnectedCallback() {
6893
+ this.container?.removeEventListener("toggle", this.#handleToggle);
6894
+ this.#removeButtonHandlers();
6895
+ super.disconnectedCallback();
6596
6896
  }
6597
6897
 
6598
6898
  #registerButtonHandlers() {
6599
- this.#colorButtons.forEach(button => button.addEventListener("click", this.#handleColorButtonClick.bind(this)));
6600
- this.querySelector(REMOVE_HIGHLIGHT_SELECTOR).addEventListener("click", this.#handleRemoveHighlightClick.bind(this));
6899
+ this.#colorButtons.forEach(button => button.addEventListener("click", this.#handleColorButtonClick));
6900
+ this.querySelector(REMOVE_HIGHLIGHT_SELECTOR).addEventListener("click", this.#handleRemoveHighlightClick);
6901
+ }
6902
+
6903
+ #removeButtonHandlers() {
6904
+ this.#colorButtons.forEach(button => button.removeEventListener("click", this.#handleColorButtonClick));
6905
+ this.querySelector(REMOVE_HIGHLIGHT_SELECTOR)?.removeEventListener("click", this.#handleRemoveHighlightClick);
6601
6906
  }
6602
6907
 
6603
6908
  #setUpButtons() {
6909
+ this.#buttonContainer.innerHTML = "";
6910
+
6604
6911
  const colorGroups = this.editorElement.config.get("highlight.buttons");
6605
6912
 
6606
6913
  this.#populateButtonGroup("color", colorGroups.color);
@@ -6626,7 +6933,7 @@ class HighlightDropdown extends ToolbarDropdown {
6626
6933
  return button
6627
6934
  }
6628
6935
 
6629
- #handleToggle({ newState }) {
6936
+ #handleToggle = ({ newState }) => {
6630
6937
  if (newState === "open") {
6631
6938
  this.editor.getEditorState().read(() => {
6632
6939
  this.#updateColorButtonStates($getSelection());
@@ -6634,7 +6941,7 @@ class HighlightDropdown extends ToolbarDropdown {
6634
6941
  }
6635
6942
  }
6636
6943
 
6637
- #handleColorButtonClick(event) {
6944
+ #handleColorButtonClick = (event) => {
6638
6945
  event.preventDefault();
6639
6946
 
6640
6947
  const button = event.target.closest(APPLY_HIGHLIGHT_SELECTOR);
@@ -6647,7 +6954,7 @@ class HighlightDropdown extends ToolbarDropdown {
6647
6954
  this.close();
6648
6955
  }
6649
6956
 
6650
- #handleRemoveHighlightClick(event) {
6957
+ #handleRemoveHighlightClick = (event) => {
6651
6958
  event.preventDefault();
6652
6959
 
6653
6960
  this.editor.dispatchCommand("removeHighlight");
@@ -7298,10 +7605,13 @@ class LexicalPromptElement extends HTMLElement {
7298
7605
  }
7299
7606
 
7300
7607
  class CodeLanguagePicker extends HTMLElement {
7608
+ #abortController = null
7609
+
7301
7610
  connectedCallback() {
7302
7611
  this.editorElement = this.closest("lexxy-editor");
7303
7612
  this.editor = this.editorElement.editor;
7304
7613
  this.classList.add("lexxy-floating-controls");
7614
+ this.#abortController = new AbortController();
7305
7615
 
7306
7616
  this.#attachLanguagePicker();
7307
7617
  this.#hide();
@@ -7309,21 +7619,37 @@ class CodeLanguagePicker extends HTMLElement {
7309
7619
  }
7310
7620
 
7311
7621
  disconnectedCallback() {
7622
+ this.dispose();
7623
+ }
7624
+
7625
+ dispose() {
7626
+ this.#abortController?.abort();
7627
+ this.#abortController = null;
7312
7628
  this.unregisterUpdateListener?.();
7313
7629
  this.unregisterUpdateListener = null;
7314
7630
  }
7315
7631
 
7316
7632
  #attachLanguagePicker() {
7317
- this.languagePickerElement = this.#createLanguagePicker();
7633
+ this.languagePickerElement = this.#findLanguagePicker() ?? this.#createLanguagePicker();
7634
+
7635
+ const signal = this.#abortController.signal;
7318
7636
 
7319
7637
  this.languagePickerElement.addEventListener("change", () => {
7320
7638
  this.#updateCodeBlockLanguage(this.languagePickerElement.value);
7321
- });
7639
+ }, { signal });
7640
+
7641
+ this.languagePickerElement.addEventListener("mousedown", (event) => {
7642
+ this.#dispatchOpenEvent(event);
7643
+ }, { signal });
7322
7644
 
7323
7645
  this.languagePickerElement.setAttribute("nonce", getNonce());
7324
7646
  this.appendChild(this.languagePickerElement);
7325
7647
  }
7326
7648
 
7649
+ #findLanguagePicker() {
7650
+ return this.querySelector("select")
7651
+ }
7652
+
7327
7653
  #createLanguagePicker() {
7328
7654
  const selectElement = createElement("select", { className: "lexxy-code-language-picker", "aria-label": "Pick a language…", name: "lexxy-code-language" });
7329
7655
 
@@ -7356,6 +7682,21 @@ class CodeLanguagePicker extends HTMLElement {
7356
7682
  return Object.fromEntries([ plainEntry, ...sortedEntries ])
7357
7683
  }
7358
7684
 
7685
+ #dispatchOpenEvent(event) {
7686
+ const handled = !dispatch(this.editorElement, "lexxy:code-language-picker-open", {
7687
+ languages: this.#bridgeLanguages,
7688
+ currentLanguage: this.languagePickerElement.value
7689
+ }, true);
7690
+
7691
+ if (handled) {
7692
+ event.preventDefault();
7693
+ }
7694
+ }
7695
+
7696
+ get #bridgeLanguages() {
7697
+ return Object.entries(this.#languages).map(([ key, name ]) => ({ key, name }))
7698
+ }
7699
+
7359
7700
  #updateCodeBlockLanguage(language) {
7360
7701
  this.editor.update(() => {
7361
7702
  const codeNode = this.#getCurrentCodeNode();
@@ -7894,6 +8235,10 @@ class TableTools extends HTMLElement {
7894
8235
  }
7895
8236
 
7896
8237
  disconnectedCallback() {
8238
+ this.dispose();
8239
+ }
8240
+
8241
+ dispose() {
7897
8242
  this.#unregisterKeyboardShortcuts();
7898
8243
 
7899
8244
  this.unregisterUpdateListener?.();
@@ -7918,6 +8263,8 @@ class TableTools extends HTMLElement {
7918
8263
  }
7919
8264
 
7920
8265
  #setUpButtons() {
8266
+ this.innerHTML = "";
8267
+
7921
8268
  this.appendChild(this.#createRowButtonsContainer());
7922
8269
  this.appendChild(this.#createColumnButtonsContainer());
7923
8270
 
@@ -8211,9 +8558,107 @@ function defineElements() {
8211
8558
  });
8212
8559
  }
8213
8560
 
8561
+ class NativeAdapter {
8562
+ frozenLinkKey = null
8563
+
8564
+ constructor(editorElement) {
8565
+ this.editorElement = editorElement;
8566
+ this.editorContentElement = editorElement.editorContentElement;
8567
+ }
8568
+
8569
+ dispatchAttributesChange(attributes, linkHref, highlight, headingTag) {
8570
+ dispatch(this.editorElement, "lexxy:attributes-change", {
8571
+ attributes,
8572
+ link: linkHref ? { href: linkHref } : null,
8573
+ highlight,
8574
+ headingTag
8575
+ });
8576
+ }
8577
+
8578
+ dispatchEditorInitialized(detail) {
8579
+ dispatch(this.editorElement, "lexxy:editor-initialized", detail);
8580
+ }
8581
+
8582
+ freeze() {
8583
+ let frozenLinkKey = null;
8584
+ this.editorElement.editor?.getEditorState().read(() => {
8585
+ const selection = $getSelection();
8586
+ if (!$isRangeSelection(selection)) return
8587
+
8588
+ const linkNode = $getNearestNodeOfType(selection.anchor.getNode(), LinkNode);
8589
+ if (linkNode) {
8590
+ frozenLinkKey = linkNode.getKey();
8591
+ }
8592
+ });
8593
+
8594
+ this.frozenLinkKey = frozenLinkKey;
8595
+ this.editorContentElement.contentEditable = "false";
8596
+ }
8597
+
8598
+ thaw() {
8599
+ this.editorContentElement.contentEditable = "true";
8600
+ }
8601
+
8602
+ unlinkFrozenNode() {
8603
+ const key = this.frozenLinkKey;
8604
+ if (!key) return false
8605
+
8606
+ const linkNode = $getNodeByKey(key);
8607
+ if (!$isLinkNode(linkNode)) {
8608
+ this.frozenLinkKey = null;
8609
+ return false
8610
+ }
8611
+
8612
+ const children = linkNode.getChildren();
8613
+ for (const child of children) {
8614
+ linkNode.insertBefore(child);
8615
+ }
8616
+ linkNode.remove();
8617
+
8618
+ // Select the former link text so a follow-up createLink can re-wrap it.
8619
+ const firstText = this.#findFirstTextDescendant(children);
8620
+ const lastText = this.#findLastTextDescendant(children);
8621
+ if (firstText && lastText) {
8622
+ const selection = $getSelection();
8623
+ if ($isRangeSelection(selection)) {
8624
+ selection.anchor.set(firstText.getKey(), 0, "text");
8625
+ selection.focus.set(lastText.getKey(), lastText.getTextContent().length, "text");
8626
+ }
8627
+ }
8628
+
8629
+ this.frozenLinkKey = null;
8630
+ return true
8631
+ }
8632
+
8633
+ #findFirstTextDescendant(nodes) {
8634
+ for (const node of nodes) {
8635
+ if ($isTextNode(node)) return node
8636
+ if ($isElementNode(node)) {
8637
+ const nestedTextNode = this.#findFirstTextDescendant(node.getChildren());
8638
+ if (nestedTextNode) return nestedTextNode
8639
+ }
8640
+ }
8641
+
8642
+ return null
8643
+ }
8644
+
8645
+ #findLastTextDescendant(nodes) {
8646
+ for (let index = nodes.length - 1; index >= 0; index--) {
8647
+ const node = nodes[index];
8648
+ if ($isTextNode(node)) return node
8649
+ if ($isElementNode(node)) {
8650
+ const nestedTextNode = this.#findLastTextDescendant(node.getChildren());
8651
+ if (nestedTextNode) return nestedTextNode
8652
+ }
8653
+ }
8654
+
8655
+ return null
8656
+ }
8657
+ }
8658
+
8214
8659
  const configure = Lexxy.configure;
8215
8660
 
8216
8661
  // Pushing elements definition to after the current call stack to allow global configuration to take place first
8217
8662
  setTimeout(defineElements, 0);
8218
8663
 
8219
- export { $createActionTextAttachmentNode, $createActionTextAttachmentUploadNode, $isActionTextAttachmentNode, ActionTextAttachmentNode, ActionTextAttachmentUploadNode, CustomActionTextAttachmentNode, LexxyExtension as Extension, HorizontalDividerNode, configure };
8664
+ export { $createActionTextAttachmentNode, $createActionTextAttachmentUploadNode, $isActionTextAttachmentNode, ActionTextAttachmentNode, ActionTextAttachmentUploadNode, CustomActionTextAttachmentNode, LexxyExtension as Extension, HorizontalDividerNode, NativeAdapter, configure };
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@37signals/lexxy",
3
- "version": "0.9.1-beta",
3
+ "version": "0.9.3-beta",
4
4
  "description": "Lexxy - A modern rich text editor for Rails.",
5
5
  "module": "dist/lexxy.esm.js",
6
6
  "type": "module",