@37signals/lexxy 0.8.0-beta → 0.8.2-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
@@ -10,20 +10,20 @@ 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 } from '@lexical/selection';
13
- import { SKIP_DOM_SELECTION_TAG, $getSelection, $isRangeSelection, DecoratorNode, $createNodeSelection, HISTORY_MERGE_TAG, SKIP_SCROLL_INTO_VIEW_TAG, $isTextNode, $createParagraphNode, TextNode, createCommand, createState, defineExtension, COMMAND_PRIORITY_NORMAL, $getState, $setState, $hasUpdateTag, PASTE_TAG, FORMAT_TEXT_COMMAND, $createTextNode, $isRootOrShadowRoot, UNDO_COMMAND, REDO_COMMAND, PASTE_COMMAND, COMMAND_PRIORITY_LOW, KEY_TAB_COMMAND, OUTDENT_CONTENT_COMMAND, INDENT_CONTENT_COMMAND, $getEditor, $getNearestRootOrShadowRoot, $isNodeSelection, $getRoot, $isLineBreakNode, $isElementNode, KEY_ARROW_LEFT_COMMAND, KEY_ARROW_RIGHT_COMMAND, KEY_ARROW_UP_COMMAND, KEY_ARROW_DOWN_COMMAND, DELETE_CHARACTER_COMMAND, SELECTION_CHANGE_COMMAND, CLICK_COMMAND, isDOMNode, $getNearestNodeFromDOMNode, $isDecoratorNode, $setSelection, KEY_ENTER_COMMAND, COMMAND_PRIORITY_HIGH, $isParagraphNode, ElementNode, $splitNode, $getNodeByKey, $createLineBreakNode, ParagraphNode, RootNode, CLEAR_HISTORY_COMMAND, $addUpdateTag, 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, $createNodeSelection, HISTORY_MERGE_TAG, SKIP_SCROLL_INTO_VIEW_TAG, $createParagraphNode, TextNode, createCommand, createState, defineExtension, COMMAND_PRIORITY_NORMAL, $getState, $setState, $hasUpdateTag, PASTE_TAG, FORMAT_TEXT_COMMAND, $createTextNode, $isRootOrShadowRoot, 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, $isLineBreakNode, $isElementNode, KEY_ARROW_LEFT_COMMAND, KEY_ARROW_UP_COMMAND, KEY_ARROW_DOWN_COMMAND, DELETE_CHARACTER_COMMAND, SELECTION_CHANGE_COMMAND, CLICK_COMMAND, isDOMNode, $getNearestNodeFromDOMNode, $isDecoratorNode, KEY_ENTER_COMMAND, COMMAND_PRIORITY_HIGH, $isParagraphNode, ElementNode, $splitNode, $createLineBreakNode, $isRootNode, ParagraphNode, RootNode, CLEAR_HISTORY_COMMAND, $addUpdateTag, 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, $createListNode, registerList } from '@lexical/list';
16
16
  import { $createAutoLinkNode, $toggleLink, LinkNode, $createLinkNode, AutoLinkNode, $isLinkNode } from '@lexical/link';
17
17
  import { registerPlainText } from '@lexical/plain-text';
18
- import { RichTextExtension, $isQuoteNode, $createQuoteNode, $createHeadingNode, $isHeadingNode, QuoteNode, HeadingNode, registerRichText } from '@lexical/rich-text';
18
+ import { RichTextExtension, $createQuoteNode, $isQuoteNode, $createHeadingNode, $isHeadingNode, QuoteNode, HeadingNode, registerRichText } from '@lexical/rich-text';
19
19
  import { $generateNodesFromDOM, $generateHtmlFromNodes } from '@lexical/html';
20
- import { $isCodeNode, CodeNode, normalizeCodeLang, CodeHighlightNode, registerCodeHighlighting, CODE_LANGUAGE_FRIENDLY_NAME_MAP } from '@lexical/code';
20
+ import { $isCodeNode, CodeHighlightNode, $isCodeHighlightNode, CodeNode, normalizeCodeLang, registerCodeHighlighting, CODE_LANGUAGE_FRIENDLY_NAME_MAP } from '@lexical/code';
21
21
  import { registerMarkdownShortcuts, TRANSFORMERS } from '@lexical/markdown';
22
22
  import { createEmptyHistoryState, registerHistory } from '@lexical/history';
23
23
  import { createElement, createAttachmentFigure, isPreviewableImage, dispatch, parseHtml, addBlockSpacing, generateDomId } from './lexxy_helpers.esm.js';
24
24
  export { highlightCode as highlightAll, highlightCode } from './lexxy_helpers.esm.js';
25
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, mergeRegister, $descendantsMatching, $insertFirst, $unwrapAndFilterDescendants, $firstToLastIterator } from '@lexical/utils';
26
+ import { $getNearestNodeOfType, $wrapNodeInElement, mergeRegister, $insertFirst, $unwrapAndFilterDescendants, $firstToLastIterator, $descendantsMatching } from '@lexical/utils';
27
27
  import { marked } from 'marked';
28
28
  import { $insertDataTransferForRichText } from '@lexical/clipboard';
29
29
 
@@ -107,7 +107,7 @@ var Lexxy = {
107
107
  };
108
108
 
109
109
  const ALLOWED_HTML_TAGS = [ "a", "b", "blockquote", "br", "code", "div", "em",
110
- "figcaption", "figure", "h1", "h2", "h3", "h4", "h5", "h6", "hr", "i", "img", "li", "mark", "ol", "p", "pre", "q", "s", "strong", "ul", "table", "tbody", "tr", "th", "td" ];
110
+ "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" ];
111
111
 
112
112
  const ALLOWED_HTML_ATTRIBUTES = [ "alt", "caption", "class", "content", "content-type", "contenteditable",
113
113
  "data-direct-upload-id", "data-sgid", "filename", "filesize", "height", "href", "presentation",
@@ -426,6 +426,8 @@ class LexicalToolbarElement extends HTMLElement {
426
426
  this.editor.update(() => {
427
427
  this.editor.dispatchCommand(command, payload);
428
428
  }, { tag: isKeyboard ? SKIP_DOM_SELECTION_TAG : undefined });
429
+
430
+ if (!isKeyboard) this.editor.focus();
429
431
  }
430
432
 
431
433
  #bindHotkeys() {
@@ -730,6 +732,162 @@ class LexicalToolbarElement extends HTMLElement {
730
732
  }
731
733
  }
732
734
 
735
+ const PUNCTUATION_OR_SPACE = /[^\w]/;
736
+
737
+ // Supplements Lexical's built-in registerMarkdownShortcuts to handle the case
738
+ // where a user types a leading tag before text that already ends with a
739
+ // trailing tag (e.g. typing ` before `hello`` or ** before **hello**).
740
+ //
741
+ // Lexical's markdown shortcut handler only triggers format transformations when
742
+ // the closing tag is the character just typed. When the opening tag is typed
743
+ // instead (e.g. typing ` before `hello`` to form ``hello``), the built-in
744
+ // handler doesn't match because it looks backward from the cursor for an
745
+ // opening tag, but the cursor is right after it.
746
+ //
747
+ // This listener detects that scenario for ALL text format transformers
748
+ // (backtick, bold, italic, strikethrough, etc.) and applies the appropriate
749
+ // format.
750
+ function registerMarkdownLeadingTagHandler(editor, transformers) {
751
+ const textFormatTransformers = transformers
752
+ .filter(t => t.type === "text-format")
753
+ .sort((a, b) => b.tag.length - a.tag.length); // Longer tags first
754
+
755
+ return editor.registerUpdateListener(({ tags, dirtyLeaves, editorState, prevEditorState }) => {
756
+ if (tags.has("historic") || tags.has("collaboration")) return
757
+ if (editor.isComposing()) return
758
+
759
+ const selection = editorState.read($getSelection);
760
+ const prevSelection = prevEditorState.read($getSelection);
761
+
762
+ if (!$isRangeSelection(prevSelection) || !$isRangeSelection(selection) || !selection.isCollapsed()) return
763
+
764
+ const anchorKey = selection.anchor.key;
765
+ const anchorOffset = selection.anchor.offset;
766
+
767
+ if (!dirtyLeaves.has(anchorKey)) return
768
+
769
+ const anchorNode = editorState.read(() => $getNodeByKey(anchorKey));
770
+ if (!$isTextNode(anchorNode)) return
771
+
772
+ // Only trigger when cursor moved forward (typing)
773
+ const prevOffset = prevSelection.anchor.key === anchorKey ? prevSelection.anchor.offset : 0;
774
+ if (anchorOffset <= prevOffset) return
775
+
776
+ const textContent = editorState.read(() => anchorNode.getTextContent());
777
+
778
+ // Try each transformer, longest tags first
779
+ for (const transformer of textFormatTransformers) {
780
+ const tag = transformer.tag;
781
+ const tagLen = tag.length;
782
+
783
+ // The typed characters must end at the cursor position and form the opening tag
784
+ const openTagStart = anchorOffset - tagLen;
785
+ if (openTagStart < 0) continue
786
+
787
+ const candidateOpenTag = textContent.slice(openTagStart, anchorOffset);
788
+ if (candidateOpenTag !== tag) continue
789
+
790
+ // Disambiguate from longer tags: if the character before the opening tag
791
+ // is the same as the tag character, this might be part of a longer tag
792
+ // (e.g. seeing `*` when the user is actually typing `**`)
793
+ const tagChar = tag[0];
794
+ if (openTagStart > 0 && textContent[openTagStart - 1] === tagChar) continue
795
+
796
+ // Check intraword constraint: if intraword is false, the character before
797
+ // the opening tag must be a space, punctuation, or the start of the text
798
+ if (transformer.intraword === false && openTagStart > 0) {
799
+ const beforeChar = textContent[openTagStart - 1];
800
+ if (beforeChar && !PUNCTUATION_OR_SPACE.test(beforeChar)) continue
801
+ }
802
+
803
+ // Search forward for a closing tag in the same text node
804
+ const searchStart = anchorOffset;
805
+ const closeTagIndex = textContent.indexOf(tag, searchStart);
806
+ if (closeTagIndex < 0) continue
807
+
808
+ // Disambiguate closing tag from longer tags: if the character right after
809
+ // the closing tag is the same as the tag character, skip
810
+ // (e.g. `*hello**` — the first `*` at index 6 is part of `**`)
811
+ if (textContent[closeTagIndex + tagLen] === tagChar) continue
812
+
813
+ // Also check if the character before the closing tag start is the same
814
+ // tag character (e.g. the closing tag might be a suffix of a longer sequence)
815
+ if (closeTagIndex > 0 && textContent[closeTagIndex - 1] === tagChar) continue
816
+
817
+ // There must be content between the tags (not just empty or whitespace-adjacent)
818
+ const innerStart = anchorOffset;
819
+ const innerEnd = closeTagIndex;
820
+ if (innerEnd <= innerStart) continue
821
+
822
+ // No space immediately after opening tag
823
+ if (textContent[innerStart] === " ") continue
824
+
825
+ // No space immediately before closing tag
826
+ if (textContent[innerEnd - 1] === " ") continue
827
+
828
+ // Check intraword constraint for closing tag
829
+ if (transformer.intraword === false) {
830
+ const afterCloseChar = textContent[closeTagIndex + tagLen];
831
+ if (afterCloseChar && !PUNCTUATION_OR_SPACE.test(afterCloseChar)) continue
832
+ }
833
+
834
+ editor.update(() => {
835
+ const node = $getNodeByKey(anchorKey);
836
+ if (!node || !$isTextNode(node)) return
837
+
838
+ const parent = node.getParent();
839
+ if (parent === null || $isCodeNode(parent)) return
840
+
841
+ $applyFormatFromLeadingTag(node, openTagStart, transformer);
842
+ });
843
+
844
+ break // Only apply the first (longest) matching transformer
845
+ }
846
+ })
847
+ }
848
+
849
+ function $applyFormatFromLeadingTag(anchorNode, openTagStart, transformer) {
850
+ const tag = transformer.tag;
851
+ const tagLen = tag.length;
852
+ const textContent = anchorNode.getTextContent();
853
+
854
+ const innerStart = openTagStart + tagLen;
855
+ const closeTagIndex = textContent.indexOf(tag, innerStart);
856
+ if (closeTagIndex < 0) return
857
+
858
+ const inner = textContent.slice(innerStart, closeTagIndex);
859
+ if (inner.length === 0) return
860
+
861
+ // Remove both tags and apply format
862
+ const before = textContent.slice(0, openTagStart);
863
+ const after = textContent.slice(closeTagIndex + tagLen);
864
+
865
+ anchorNode.setTextContent(before + inner + after);
866
+
867
+ const nextSelection = $createRangeSelection();
868
+ $setSelection(nextSelection);
869
+
870
+ // Select the inner text to apply formatting
871
+ nextSelection.anchor.set(anchorNode.getKey(), openTagStart, "text");
872
+ nextSelection.focus.set(anchorNode.getKey(), openTagStart + inner.length, "text");
873
+
874
+ for (const format of transformer.format) {
875
+ if (!nextSelection.hasFormat(format)) {
876
+ nextSelection.formatText(format);
877
+ }
878
+ }
879
+
880
+ // Collapse selection to end of formatted text and clear the format
881
+ // so subsequent typing is plain text
882
+ nextSelection.anchor.set(nextSelection.focus.key, nextSelection.focus.offset, nextSelection.focus.type);
883
+
884
+ for (const format of transformer.format) {
885
+ if (nextSelection.hasFormat(format)) {
886
+ nextSelection.toggleFormat(format);
887
+ }
888
+ }
889
+ }
890
+
733
891
  var theme = {
734
892
  text: {
735
893
  bold: "lexxy-content__bold",
@@ -1074,9 +1232,10 @@ class HighlightExtension extends LexxyExtension {
1074
1232
  const canonicalizers = buildCanonicalizers(config);
1075
1233
 
1076
1234
  return mergeRegister(
1077
- editor.registerCommand(TOGGLE_HIGHLIGHT_COMMAND, $toggleSelectionStyles, COMMAND_PRIORITY_NORMAL),
1078
- editor.registerCommand(REMOVE_HIGHLIGHT_COMMAND, () => $toggleSelectionStyles(BLANK_STYLES), COMMAND_PRIORITY_NORMAL),
1235
+ editor.registerCommand(TOGGLE_HIGHLIGHT_COMMAND, (styles) => $toggleSelectionStyles(editor, styles), COMMAND_PRIORITY_NORMAL),
1236
+ editor.registerCommand(REMOVE_HIGHLIGHT_COMMAND, () => $toggleSelectionStyles(editor, BLANK_STYLES), COMMAND_PRIORITY_NORMAL),
1079
1237
  editor.registerNodeTransform(TextNode, $syncHighlightWithStyle),
1238
+ editor.registerNodeTransform(CodeHighlightNode, $syncHighlightWithCodeHighlightNode),
1080
1239
  editor.registerNodeTransform(TextNode, (textNode) => $canonicalizePastedStyles(textNode, canonicalizers))
1081
1240
  )
1082
1241
  }
@@ -1114,7 +1273,7 @@ function buildCanonicalizers(config) {
1114
1273
  ]
1115
1274
  }
1116
1275
 
1117
- function $toggleSelectionStyles(styles) {
1276
+ function $toggleSelectionStyles(editor, styles) {
1118
1277
  const selection = $getSelection();
1119
1278
  if (!$isRangeSelection(selection)) return
1120
1279
 
@@ -1124,7 +1283,117 @@ function $toggleSelectionStyles(styles) {
1124
1283
  patch[property] = toggleOrReplace(oldValue, styles[property]);
1125
1284
  }
1126
1285
 
1127
- $patchStyleText(selection, patch);
1286
+ if ($selectionIsInCodeBlock(selection)) {
1287
+ $patchCodeHighlightStyles(editor, selection, patch);
1288
+ } else {
1289
+ $patchStyleText(selection, patch);
1290
+ }
1291
+ }
1292
+
1293
+ function $selectionIsInCodeBlock(selection) {
1294
+ const nodes = selection.getNodes();
1295
+ return nodes.some((node) => {
1296
+ const parent = $isCodeHighlightNode(node) ? node.getParent() : node;
1297
+ return $isCodeNode(parent)
1298
+ })
1299
+ }
1300
+
1301
+ function $patchCodeHighlightStyles(editor, selection, patch) {
1302
+ // Capture selection state and node keys before the nested update
1303
+ const nodeKeys = selection.getNodes()
1304
+ .filter((node) => $isCodeHighlightNode(node))
1305
+ .map((node) => ({
1306
+ key: node.getKey(),
1307
+ startOffset: $getNodeSelectionOffsets(node, selection)[0],
1308
+ endOffset: $getNodeSelectionOffsets(node, selection)[1],
1309
+ textSize: node.getTextContentSize()
1310
+ }));
1311
+
1312
+ // Use skipTransforms to prevent the code highlighting system from
1313
+ // re-tokenizing and wiping out the style changes we apply.
1314
+ // Use discrete to force a synchronous commit, ensuring the changes
1315
+ // are committed before editor.focus() triggers a second update cycle
1316
+ // that would re-run transforms and wipe out the styles.
1317
+ editor.update(() => {
1318
+ for (const { key, startOffset, endOffset, textSize } of nodeKeys) {
1319
+ const node = $getNodeByKey(key);
1320
+ if (!node || !$isCodeHighlightNode(node)) continue
1321
+
1322
+ const parent = node.getParent();
1323
+ if (!$isCodeNode(parent)) continue
1324
+ if (startOffset === endOffset) continue
1325
+
1326
+ if (startOffset === 0 && endOffset === textSize) {
1327
+ $applyStylePatchToNode(node, patch);
1328
+ } else {
1329
+ const splitNodes = node.splitText(startOffset, endOffset);
1330
+ const targetNode = splitNodes[startOffset === 0 ? 0 : 1];
1331
+ $applyStylePatchToNode(targetNode, patch);
1332
+ }
1333
+ }
1334
+ }, { skipTransforms: true, discrete: true });
1335
+ }
1336
+
1337
+ function $getNodeSelectionOffsets(node, selection) {
1338
+ const nodeKey = node.getKey();
1339
+ const anchorKey = selection.anchor.key;
1340
+ const focusKey = selection.focus.key;
1341
+ const textSize = node.getTextContentSize();
1342
+
1343
+ const isAnchor = nodeKey === anchorKey;
1344
+ const isFocus = nodeKey === focusKey;
1345
+
1346
+ // Determine if selection is forward or backward
1347
+ const isForward = selection.isBackward() === false;
1348
+
1349
+ let start = 0;
1350
+ let end = textSize;
1351
+
1352
+ if (isForward) {
1353
+ if (isAnchor) start = selection.anchor.offset;
1354
+ if (isFocus) end = selection.focus.offset;
1355
+ } else {
1356
+ if (isFocus) start = selection.focus.offset;
1357
+ if (isAnchor) end = selection.anchor.offset;
1358
+ }
1359
+
1360
+ return [ start, end ]
1361
+ }
1362
+
1363
+ function $applyStylePatchToNode(node, patch) {
1364
+ const prevStyles = getStyleObjectFromCSS(node.getStyle());
1365
+ const newStyles = { ...prevStyles };
1366
+
1367
+ for (const [ key, value ] of Object.entries(patch)) {
1368
+ if (value === null) {
1369
+ delete newStyles[key];
1370
+ } else {
1371
+ newStyles[key] = value;
1372
+ }
1373
+ }
1374
+
1375
+ const newCSSText = getCSSFromStyleObject(newStyles);
1376
+ node.setStyle(newCSSText);
1377
+
1378
+ // Sync the highlight format using TextNode's setFormat to bypass
1379
+ // CodeHighlightNode's no-op override
1380
+ const shouldHaveHighlight = hasHighlightStyles(newCSSText);
1381
+ const hasHighlight = node.hasFormat("highlight");
1382
+
1383
+ if (shouldHaveHighlight !== hasHighlight) {
1384
+ $setCodeHighlightFormat(node, shouldHaveHighlight);
1385
+ }
1386
+ }
1387
+
1388
+ function $setCodeHighlightFormat(node, shouldHaveHighlight) {
1389
+ const writable = node.getWritable();
1390
+ const IS_HIGHLIGHT = 1 << 7;
1391
+
1392
+ if (shouldHaveHighlight) {
1393
+ writable.__format |= IS_HIGHLIGHT;
1394
+ } else {
1395
+ writable.__format &= ~IS_HIGHLIGHT;
1396
+ }
1128
1397
  }
1129
1398
 
1130
1399
  function toggleOrReplace(oldValue, newValue) {
@@ -1137,6 +1406,18 @@ function $syncHighlightWithStyle(textNode) {
1137
1406
  }
1138
1407
  }
1139
1408
 
1409
+ function $syncHighlightWithCodeHighlightNode(node) {
1410
+ const parent = node.getParent();
1411
+ if (!$isCodeNode(parent)) return
1412
+
1413
+ const shouldHaveHighlight = hasHighlightStyles(node.getStyle());
1414
+ const hasHighlight = node.hasFormat("highlight");
1415
+
1416
+ if (shouldHaveHighlight !== hasHighlight) {
1417
+ $setCodeHighlightFormat(node, shouldHaveHighlight);
1418
+ }
1419
+ }
1420
+
1140
1421
  function $canonicalizePastedStyles(textNode, canonicalizers = []) {
1141
1422
  if ($hasPastedStyles(textNode)) {
1142
1423
  $setPastedStyles(textNode, false);
@@ -1183,6 +1464,8 @@ const COMMANDS = [
1183
1464
  ];
1184
1465
 
1185
1466
  class CommandDispatcher {
1467
+ #selectionBeforeDrag = null
1468
+
1186
1469
  static configureFor(editorElement) {
1187
1470
  new CommandDispatcher(editorElement);
1188
1471
  }
@@ -1270,7 +1553,9 @@ class CommandDispatcher {
1270
1553
  }
1271
1554
 
1272
1555
  dispatchInsertQuoteBlock() {
1273
- this.contents.toggleNodeWrappingAllSelectedNodes((node) => $isQuoteNode(node), () => $createQuoteNode());
1556
+ if (!this.contents.wrapSelectedSoftBreakLines(() => $createQuoteNode())) {
1557
+ this.contents.toggleNodeWrappingAllSelectedNodes((node) => $isQuoteNode(node), () => $createQuoteNode());
1558
+ }
1274
1559
  }
1275
1560
 
1276
1561
  dispatchInsertCodeBlock() {
@@ -1361,9 +1646,24 @@ class CommandDispatcher {
1361
1646
  }
1362
1647
 
1363
1648
  #registerKeyboardCommands() {
1649
+ this.editor.registerCommand(KEY_ARROW_RIGHT_COMMAND, this.#handleArrowRightKey.bind(this), COMMAND_PRIORITY_NORMAL);
1364
1650
  this.editor.registerCommand(KEY_TAB_COMMAND, this.#handleTabKey.bind(this), COMMAND_PRIORITY_NORMAL);
1365
1651
  }
1366
1652
 
1653
+ #handleArrowRightKey(event) {
1654
+ const selection = $getSelection();
1655
+ if (!$isRangeSelection(selection) || !selection.isCollapsed()) return false
1656
+ if (this.selection.isInsideCodeBlock || !selection.hasFormat("code")) return false
1657
+
1658
+ const anchorNode = selection.anchor.getNode();
1659
+ if (!$isTextNode(anchorNode) || selection.anchor.offset !== anchorNode.getTextContentSize()) return false
1660
+ if (anchorNode.getNextSibling() !== null) return false
1661
+
1662
+ event.preventDefault();
1663
+ selection.toggleFormat("code");
1664
+ return true
1665
+ }
1666
+
1367
1667
  #registerDragAndDropHandlers() {
1368
1668
  if (this.editorElement.supportsAttachments) {
1369
1669
  this.dragCounter = 0;
@@ -1377,6 +1677,7 @@ class CommandDispatcher {
1377
1677
  #handleDragEnter(event) {
1378
1678
  this.dragCounter++;
1379
1679
  if (this.dragCounter === 1) {
1680
+ this.#saveSelectionBeforeDrag();
1380
1681
  this.editor.getRootElement().classList.add("lexxy-editor--drag-over");
1381
1682
  }
1382
1683
  }
@@ -1384,6 +1685,7 @@ class CommandDispatcher {
1384
1685
  #handleDragLeave(event) {
1385
1686
  this.dragCounter--;
1386
1687
  if (this.dragCounter === 0) {
1688
+ this.#selectionBeforeDrag = null;
1387
1689
  this.editor.getRootElement().classList.remove("lexxy-editor--drag-over");
1388
1690
  }
1389
1691
  }
@@ -1404,11 +1706,28 @@ class CommandDispatcher {
1404
1706
  const files = Array.from(dataTransfer.files);
1405
1707
  if (!files.length) return
1406
1708
 
1709
+ this.#restoreSelectionBeforeDrag();
1407
1710
  this.contents.uploadFiles(files, { selectLast: true });
1408
1711
 
1409
1712
  this.editor.focus();
1410
1713
  }
1411
1714
 
1715
+ #saveSelectionBeforeDrag() {
1716
+ this.editor.getEditorState().read(() => {
1717
+ this.#selectionBeforeDrag = $getSelection()?.clone();
1718
+ });
1719
+ }
1720
+
1721
+ #restoreSelectionBeforeDrag() {
1722
+ if (!this.#selectionBeforeDrag) return
1723
+
1724
+ this.editor.update(() => {
1725
+ $setSelection(this.#selectionBeforeDrag);
1726
+ });
1727
+
1728
+ this.#selectionBeforeDrag = null;
1729
+ }
1730
+
1412
1731
  #handleTabKey(event) {
1413
1732
  if (this.selection.isInsideList) {
1414
1733
  return this.#handleTabForList(event)
@@ -1482,6 +1801,16 @@ function extractFileName(string) {
1482
1801
  return string.split("/").pop()
1483
1802
  }
1484
1803
 
1804
+ // Lexxy exports the content attribute as a JSON string (via JSON.stringify),
1805
+ // but Trix/ActionText stores it as raw HTML. Try JSON first, fall back to raw.
1806
+ function parseAttachmentContent(content) {
1807
+ try {
1808
+ return JSON.parse(content)
1809
+ } catch {
1810
+ return content
1811
+ }
1812
+ }
1813
+
1485
1814
  class ActionTextAttachmentNode extends DecoratorNode {
1486
1815
  static getType() {
1487
1816
  return "action_text_attachment"
@@ -1664,9 +1993,7 @@ class ActionTextAttachmentNode extends DecoratorNode {
1664
1993
 
1665
1994
  #createDOMForImage(options = {}) {
1666
1995
  const img = createElement("img", { src: this.src, draggable: false, alt: this.altText, ...this.#imageDimensions, ...options });
1667
- const container = createElement("div", { className: "attachment__container" });
1668
- container.appendChild(img);
1669
- return container
1996
+ return img
1670
1997
  }
1671
1998
 
1672
1999
  get #imageDimensions() {
@@ -1708,6 +2035,9 @@ class ActionTextAttachmentNode extends DecoratorNode {
1708
2035
  input.addEventListener("focusin", () => input.placeholder = "Add caption...");
1709
2036
  input.addEventListener("blur", (event) => this.#handleCaptionInputBlurred(event));
1710
2037
  input.addEventListener("keydown", (event) => this.#handleCaptionInputKeydown(event));
2038
+ input.addEventListener("copy", (event) => event.stopPropagation());
2039
+ input.addEventListener("cut", (event) => event.stopPropagation());
2040
+ input.addEventListener("paste", (event) => event.stopPropagation());
1711
2041
 
1712
2042
  caption.appendChild(input);
1713
2043
 
@@ -1728,7 +2058,6 @@ class ActionTextAttachmentNode extends DecoratorNode {
1728
2058
  #handleCaptionInputKeydown(event) {
1729
2059
  if (event.key === "Enter") {
1730
2060
  event.preventDefault();
1731
- event.stopPropagation();
1732
2061
  event.target.blur();
1733
2062
 
1734
2063
  this.editor.update(() => {
@@ -1739,6 +2068,10 @@ class ActionTextAttachmentNode extends DecoratorNode {
1739
2068
  });
1740
2069
  }
1741
2070
 
2071
+ // Stop all keydown events from bubbling to the Lexical root element.
2072
+ // The caption textarea is outside Lexical's content model and should
2073
+ // handle its own keyboard events natively (Ctrl+A, Ctrl+C, Ctrl+X, etc.).
2074
+ event.stopPropagation();
1742
2075
  }
1743
2076
  }
1744
2077
 
@@ -1920,6 +2253,10 @@ class Selection {
1920
2253
  }
1921
2254
 
1922
2255
  get isTableCellSelected() {
2256
+ const selection = $getSelection();
2257
+ const { anchor, focus } = selection;
2258
+ if (!$isRangeSelection(selection) || anchor.key !== focus.key) return false
2259
+
1923
2260
  return this.nearestNodeOfType(TableCellNode) !== null
1924
2261
  }
1925
2262
 
@@ -1949,6 +2286,7 @@ class Selection {
1949
2286
  if (!anchorNode) return null
1950
2287
 
1951
2288
  if ($isTextNode(anchorNode)) {
2289
+ if (offset < anchorNode.getTextContentSize()) return null
1952
2290
  return this.#getNextNodeFromTextEnd(anchorNode)
1953
2291
  }
1954
2292
 
@@ -1979,6 +2317,7 @@ class Selection {
1979
2317
  if (!anchorNode) return null
1980
2318
 
1981
2319
  if ($isTextNode(anchorNode)) {
2320
+ if (offset > 0) return null
1982
2321
  return this.#getPreviousNodeFromTextStart(anchorNode)
1983
2322
  }
1984
2323
 
@@ -2092,7 +2431,9 @@ class Selection {
2092
2431
  }
2093
2432
  }
2094
2433
 
2095
- async #selectPreviousNode() {
2434
+ async #selectPreviousNode(event) {
2435
+ if (event?.shiftKey) return false
2436
+
2096
2437
  if (this.hasNodeSelection) {
2097
2438
  return await this.#withCurrentNode((currentNode) => currentNode.selectPrevious())
2098
2439
  } else {
@@ -2100,7 +2441,9 @@ class Selection {
2100
2441
  }
2101
2442
  }
2102
2443
 
2103
- async #selectNextNode() {
2444
+ async #selectNextNode(event) {
2445
+ if (event?.shiftKey) return false
2446
+
2104
2447
  if (this.hasNodeSelection) {
2105
2448
  return await this.#withCurrentNode((currentNode) => currentNode.selectNext(0, 0))
2106
2449
  } else {
@@ -2199,12 +2542,42 @@ class Selection {
2199
2542
  const node = backwards ? this.nodeBeforeCursor : this.nodeAfterCursor;
2200
2543
  if (!$isDecoratorNode(node)) return false
2201
2544
 
2545
+ if (this.#collapseListItemToParagraph()) return true
2546
+
2202
2547
  this.#removeEmptyElementAnchorNode();
2203
2548
 
2204
2549
  const selection = this.#selectInLexical(node);
2205
2550
  return Boolean(selection)
2206
2551
  }
2207
2552
 
2553
+ // When the cursor is inside a list item, collapse the list item into a
2554
+ // paragraph instead of selecting the decorator. This lets the user
2555
+ // delete a list that immediately follows an attachment without the
2556
+ // attachment becoming selected.
2557
+ #collapseListItemToParagraph() {
2558
+ const anchorNode = $getSelection()?.anchor?.getNode();
2559
+ const listItem = anchorNode && $getNearestNodeOfType(anchorNode, ListItemNode);
2560
+ if (!listItem) return false
2561
+
2562
+ const listNode = $getNearestNodeOfType(listItem, ListNode);
2563
+ if (!listNode) return false
2564
+
2565
+ const paragraph = $createParagraphNode();
2566
+ const children = listItem.getChildren();
2567
+ children.forEach(child => paragraph.append(child));
2568
+
2569
+ if (listNode.getChildrenSize() === 1) {
2570
+ listNode.insertBefore(paragraph);
2571
+ listNode.remove();
2572
+ } else {
2573
+ listNode.insertBefore(paragraph);
2574
+ listItem.remove();
2575
+ }
2576
+
2577
+ paragraph.selectStart();
2578
+ return true
2579
+ }
2580
+
2208
2581
  #removeEmptyElementAnchorNode(anchor = $getSelection()?.anchor) {
2209
2582
  const anchorNode = anchor?.getNode();
2210
2583
  if ($isElementNode(anchorNode) && anchorNode?.isEmpty()) anchorNode.remove();
@@ -2311,8 +2684,12 @@ class Selection {
2311
2684
  }
2312
2685
 
2313
2686
  #getNextNodeFromTextEnd(anchorNode) {
2314
- if (anchorNode.getNextSibling() instanceof DecoratorNode) {
2315
- return anchorNode.getNextSibling()
2687
+ const nextSibling = anchorNode.getNextSibling();
2688
+ if ($isDecoratorNode(nextSibling)) {
2689
+ return nextSibling
2690
+ }
2691
+ if (nextSibling != null) {
2692
+ return null
2316
2693
  }
2317
2694
  const parent = anchorNode.getParent();
2318
2695
  return parent ? parent.getNextSibling() : null
@@ -2333,11 +2710,15 @@ class Selection {
2333
2710
  }
2334
2711
 
2335
2712
  #getPreviousNodeFromTextStart(anchorNode) {
2336
- if (anchorNode.getPreviousSibling() instanceof DecoratorNode) {
2337
- return anchorNode.getPreviousSibling()
2713
+ const previousSibling = anchorNode.getPreviousSibling();
2714
+ if ($isDecoratorNode(previousSibling)) {
2715
+ return previousSibling
2716
+ }
2717
+ if (previousSibling != null) {
2718
+ return null
2338
2719
  }
2339
2720
  const parent = anchorNode.getParent();
2340
- return parent.getPreviousSibling()
2721
+ return parent ? parent.getPreviousSibling() : null
2341
2722
  }
2342
2723
 
2343
2724
  #getNodeBeforeElementNode(anchorNode, offset) {
@@ -2469,11 +2850,11 @@ class CustomActionTextAttachmentNode extends DecoratorNode {
2469
2850
 
2470
2851
  nodes.push(new CustomActionTextAttachmentNode({
2471
2852
  sgid: attachment.getAttribute("sgid"),
2472
- innerHtml: JSON.parse(attachment.getAttribute("content")),
2853
+ innerHtml: parseAttachmentContent(attachment.getAttribute("content")),
2473
2854
  contentType: attachment.getAttribute("content-type")
2474
2855
  }));
2475
2856
 
2476
- nodes.push($createTextNode(" "));
2857
+ nodes.push($createTextNode("\u2060"));
2477
2858
 
2478
2859
  return { node: nodes }
2479
2860
  },
@@ -2514,6 +2895,10 @@ class CustomActionTextAttachmentNode extends DecoratorNode {
2514
2895
  }
2515
2896
 
2516
2897
  getTextContent() {
2898
+ return "\ufeff"
2899
+ }
2900
+
2901
+ getReadableTextContent() {
2517
2902
  return this.createDOM().textContent.trim() || `[${this.contentType}]`
2518
2903
  }
2519
2904
 
@@ -2545,6 +2930,7 @@ class CustomActionTextAttachmentNode extends DecoratorNode {
2545
2930
  decorate() {
2546
2931
  return null
2547
2932
  }
2933
+
2548
2934
  }
2549
2935
 
2550
2936
  class FormatEscaper {
@@ -2559,12 +2945,20 @@ class FormatEscaper {
2559
2945
  (event) => this.#handleEnterKey(event),
2560
2946
  COMMAND_PRIORITY_HIGH
2561
2947
  );
2948
+
2949
+ this.editor.registerCommand(
2950
+ KEY_ARROW_DOWN_COMMAND,
2951
+ (event) => this.#handleArrowDownInCodeBlock(event),
2952
+ COMMAND_PRIORITY_NORMAL
2953
+ );
2562
2954
  }
2563
2955
 
2564
2956
  #handleEnterKey(event) {
2565
2957
  const selection = $getSelection();
2566
2958
  if (!$isRangeSelection(selection)) return false
2567
2959
 
2960
+ if (this.#handleCodeBlocks(event, selection)) return true
2961
+
2568
2962
  const anchorNode = selection.anchor.getNode();
2569
2963
 
2570
2964
  if (!this.#isInsideBlockquote(anchorNode)) return false
@@ -2825,6 +3219,101 @@ class FormatEscaper {
2825
3219
 
2826
3220
  newParagraph.selectStart();
2827
3221
  }
3222
+
3223
+ // Code blocks
3224
+
3225
+ #handleCodeBlocks(event, selection) {
3226
+ if (!selection.isCollapsed()) return false
3227
+
3228
+ const codeNode = this.#getCodeNodeFromSelection(selection);
3229
+ if (!codeNode) return false
3230
+
3231
+ if (this.#isCursorOnEmptyLastLineOfCodeBlock(selection, codeNode)) {
3232
+ event?.preventDefault();
3233
+ this.#exitCodeBlock(codeNode);
3234
+ return true
3235
+ }
3236
+
3237
+ return false
3238
+ }
3239
+
3240
+ #handleArrowDownInCodeBlock(event) {
3241
+ const selection = $getSelection();
3242
+ if (!$isRangeSelection(selection) || !selection.isCollapsed()) return false
3243
+
3244
+ const codeNode = this.#getCodeNodeFromSelection(selection);
3245
+ if (!codeNode) return false
3246
+
3247
+ if (this.#isCursorOnLastLineOfCodeBlock(selection, codeNode) && !codeNode.getNextSibling()) {
3248
+ event?.preventDefault();
3249
+ const paragraph = $createParagraphNode();
3250
+ codeNode.insertAfter(paragraph);
3251
+ paragraph.selectStart();
3252
+ return true
3253
+ }
3254
+
3255
+ return false
3256
+ }
3257
+
3258
+ #getCodeNodeFromSelection(selection) {
3259
+ const anchorNode = selection.anchor.getNode();
3260
+ return $getNearestNodeOfType(anchorNode, CodeNode) || ($isCodeNode(anchorNode) ? anchorNode : null)
3261
+ }
3262
+
3263
+ #isCursorOnEmptyLastLineOfCodeBlock(selection, codeNode) {
3264
+ const children = codeNode.getChildren();
3265
+ if (children.length === 0) return true
3266
+
3267
+ const anchorNode = selection.anchor.getNode();
3268
+ const anchorOffset = selection.anchor.offset;
3269
+
3270
+ // Chromium: cursor on the CodeNode element after the last child (a line break)
3271
+ if ($isCodeNode(anchorNode) && anchorOffset === children.length) {
3272
+ return $isLineBreakNode(children[children.length - 1])
3273
+ }
3274
+
3275
+ // Firefox: cursor on an empty text node that follows a line break at the end
3276
+ if ($isTextNode(anchorNode) && anchorNode.getTextContentSize() === 0 && anchorOffset === 0) {
3277
+ const previousSibling = anchorNode.getPreviousSibling();
3278
+ return $isLineBreakNode(previousSibling) && anchorNode.getNextSibling() === null
3279
+ }
3280
+
3281
+ return false
3282
+ }
3283
+
3284
+ #isCursorOnLastLineOfCodeBlock(selection, codeNode) {
3285
+ const anchorNode = selection.anchor.getNode();
3286
+ const children = codeNode.getChildren();
3287
+ if (children.length === 0) return true
3288
+
3289
+ const lastChild = children[children.length - 1];
3290
+
3291
+ if ($isCodeNode(anchorNode) && selection.anchor.offset === children.length) return true
3292
+ if (anchorNode === lastChild) return true
3293
+
3294
+ const lastLineBreakIndex = children.findLastIndex(child => $isLineBreakNode(child));
3295
+ if (lastLineBreakIndex === -1) return true
3296
+
3297
+ const anchorIndex = children.indexOf(anchorNode);
3298
+ return anchorIndex > lastLineBreakIndex
3299
+ }
3300
+
3301
+ #exitCodeBlock(codeNode) {
3302
+ const children = codeNode.getChildren();
3303
+ const lastChild = children[children.length - 1];
3304
+
3305
+ if ($isTextNode(lastChild) && lastChild.getTextContentSize() === 0) {
3306
+ const previousSibling = lastChild.getPreviousSibling();
3307
+ lastChild.remove();
3308
+ if ($isLineBreakNode(previousSibling)) previousSibling.remove();
3309
+ } else if ($isLineBreakNode(lastChild)) {
3310
+ lastChild.remove();
3311
+ }
3312
+
3313
+ const paragraph = $createParagraphNode();
3314
+ codeNode.insertAfter(paragraph);
3315
+ paragraph.selectStart();
3316
+ }
2828
3317
  }
2829
3318
 
2830
3319
  async function loadFileIntoImage(file, image) {
@@ -2865,6 +3354,7 @@ class ActionTextAttachmentUploadNode extends ActionTextAttachmentNode {
2865
3354
  const { file, uploadUrl, blobUrlTemplate, progress, width, height, uploadError } = node;
2866
3355
  super({ ...node, contentType: file.type }, key);
2867
3356
  this.file = file;
3357
+ this.fileName = file.name;
2868
3358
  this.uploadUrl = uploadUrl;
2869
3359
  this.blobUrlTemplate = blobUrlTemplate;
2870
3360
  this.progress = progress ?? null;
@@ -2955,7 +3445,7 @@ class ActionTextAttachmentUploadNode extends ActionTextAttachmentNode {
2955
3445
  #createCaption() {
2956
3446
  const figcaption = createElement("figcaption", { className: "attachment__caption" });
2957
3447
 
2958
- const nameSpan = createElement("span", { className: "attachment__name", textContent: this.file.name || "" });
3448
+ const nameSpan = createElement("span", { className: "attachment__name", textContent: this.caption || this.file.name || "" });
2959
3449
  const sizeSpan = createElement("span", { className: "attachment__size", textContent: bytesToHumanSize(this.file.size) });
2960
3450
  figcaption.appendChild(nameSpan);
2961
3451
  figcaption.appendChild(sizeSpan);
@@ -2974,7 +3464,7 @@ class ActionTextAttachmentUploadNode extends ActionTextAttachmentNode {
2974
3464
  const writable = this.getWritable();
2975
3465
  writable.width = width;
2976
3466
  writable.height = height;
2977
- }, { tag: SILENT_UPDATE_TAGS });
3467
+ }, { tag: this.#backgroundUpdateTags });
2978
3468
  }
2979
3469
 
2980
3470
  get #hasDimensions() {
@@ -3033,20 +3523,47 @@ class ActionTextAttachmentUploadNode extends ActionTextAttachmentNode {
3033
3523
  #setProgress(progress) {
3034
3524
  this.editor.update(() => {
3035
3525
  this.getWritable().progress = progress;
3036
- }, { tag: SILENT_UPDATE_TAGS });
3526
+ }, { tag: this.#backgroundUpdateTags });
3037
3527
  }
3038
3528
 
3039
3529
  #handleUploadError(error) {
3040
3530
  console.warn(`Upload error for ${this.file?.name ?? "file"}: ${error}`);
3041
3531
  this.editor.update(() => {
3042
3532
  this.getWritable().uploadError = true;
3043
- }, { tag: SILENT_UPDATE_TAGS });
3533
+ }, { tag: this.#backgroundUpdateTags });
3044
3534
  }
3045
3535
 
3046
3536
  #showUploadedAttachment(blob) {
3537
+ const editorHasFocus = this.#editorHasFocus;
3538
+
3047
3539
  this.editor.update(() => {
3048
- this.replace(this.#toActionTextAttachmentNodeWith(blob));
3049
- }, { tag: SILENT_UPDATE_TAGS });
3540
+ const shouldTransferNodeSelection = editorHasFocus && this.isSelected();
3541
+
3542
+ const replacementNode = this.#toActionTextAttachmentNodeWith(blob);
3543
+ this.replace(replacementNode);
3544
+
3545
+ if (shouldTransferNodeSelection) {
3546
+ const nodeSelection = $createNodeSelectionWith(replacementNode);
3547
+ $setSelection(nodeSelection);
3548
+ }
3549
+ }, { tag: this.#backgroundUpdateTags });
3550
+ }
3551
+
3552
+ // Upload lifecycle methods (progress, completion, errors) run asynchronously and may
3553
+ // fire while the user is focused on another element (e.g., a title field). Without
3554
+ // SKIP_DOM_SELECTION_TAG, Lexical's reconciler would move the DOM selection back into
3555
+ // the editor, stealing focus from wherever the user is currently typing.
3556
+ get #backgroundUpdateTags() {
3557
+ if (this.#editorHasFocus) {
3558
+ return SILENT_UPDATE_TAGS
3559
+ } else {
3560
+ return [ ...SILENT_UPDATE_TAGS, SKIP_DOM_SELECTION_TAG ]
3561
+ }
3562
+ }
3563
+
3564
+ get #editorHasFocus() {
3565
+ const rootElement = this.editor.getRootElement();
3566
+ return rootElement !== null && rootElement.contains(document.activeElement)
3050
3567
  }
3051
3568
 
3052
3569
  #toActionTextAttachmentNodeWith(blob) {
@@ -3126,7 +3643,7 @@ class ImageGalleryNode extends ElementNode {
3126
3643
  conversion: () => {
3127
3644
  return {
3128
3645
  node: $createImageGalleryNode(),
3129
- after: children => $descendantsMatching(children, this.isValidChild)
3646
+ after: children => children
3130
3647
  }
3131
3648
  },
3132
3649
  priority: 2
@@ -3403,6 +3920,8 @@ class Contents {
3403
3920
  }
3404
3921
 
3405
3922
  insertDOM(doc, { tag } = {}) {
3923
+ this.#unwrapPlaceholderAnchors(doc);
3924
+
3406
3925
  this.editor.update(() => {
3407
3926
  const selection = $getSelection();
3408
3927
  if (!$isRangeSelection(selection)) return
@@ -3415,10 +3934,16 @@ class Contents {
3415
3934
  }
3416
3935
 
3417
3936
  insertAtCursor(node) {
3418
- const selection = $getSelection() ?? $getRoot().selectEnd();
3937
+ let selection = $getSelection() ?? $getRoot().selectEnd();
3419
3938
  const selectedNodes = selection?.getNodes();
3420
3939
 
3421
3940
  if ($isRangeSelection(selection)) {
3941
+ const anchorNode = selection.anchor.getNode();
3942
+ if ($isShadowRoot(anchorNode)) {
3943
+ const paragraph = $createParagraphNode();
3944
+ anchorNode.append(paragraph);
3945
+ selection = paragraph.selectStart();
3946
+ }
3422
3947
  selection.insertNodes([ node ]);
3423
3948
  } else if ($isNodeSelection(selection) && selectedNodes.length > 0) {
3424
3949
  // Overrides Lexical's default behavior of _removing_ the currently selected nodes
@@ -3426,7 +3951,7 @@ class Contents {
3426
3951
  const lastNode = selectedNodes.at(-1);
3427
3952
  lastNode.insertAfter(node);
3428
3953
  }
3429
- }
3954
+ }
3430
3955
 
3431
3956
  insertAtCursorEnsuringLineBelow(node) {
3432
3957
  this.insertAtCursor(node);
@@ -3507,6 +4032,41 @@ class Contents {
3507
4032
  return result
3508
4033
  }
3509
4034
 
4035
+ wrapSelectedSoftBreakLines(newNodeFn) {
4036
+ let paragraphKey = null;
4037
+ let selectedLineRange = null;
4038
+
4039
+ this.editor.getEditorState().read(() => {
4040
+ const selection = $getSelection();
4041
+ if (!$isRangeSelection(selection) || selection.isCollapsed()) return
4042
+
4043
+ const paragraph = this.#getSelectedParagraphWithSoftLineBreaks(selection);
4044
+ if (!paragraph) return
4045
+
4046
+ const lines = this.#splitParagraphIntoLines(paragraph);
4047
+ selectedLineRange = this.#getSelectedLineRange(lines, selection);
4048
+
4049
+ if (!selectedLineRange) return
4050
+
4051
+ const { start, end } = selectedLineRange;
4052
+ if (start === 0 && end === lines.length - 1) return
4053
+
4054
+ paragraphKey = paragraph.getKey();
4055
+ });
4056
+
4057
+ if (!paragraphKey || !selectedLineRange) return false
4058
+
4059
+ this.editor.update(() => {
4060
+ const paragraph = $getNodeByKey(paragraphKey);
4061
+ if (!paragraph || !$isParagraphNode(paragraph)) return
4062
+
4063
+ const lines = this.#splitParagraphIntoLines(paragraph);
4064
+ this.#replaceParagraphWithWrappedSelectedLines(paragraph, lines, selectedLineRange, newNodeFn);
4065
+ });
4066
+
4067
+ return true
4068
+ }
4069
+
3510
4070
  unwrapSelectedListItems() {
3511
4071
  this.editor.update(() => {
3512
4072
  const selection = $getSelection();
@@ -3600,15 +4160,14 @@ class Contents {
3600
4160
  replaceTextBackUntil(stringToReplace, replacementNodes) {
3601
4161
  replacementNodes = Array.isArray(replacementNodes) ? replacementNodes : [ replacementNodes ];
3602
4162
 
3603
- this.editor.update(() => {
3604
- const { anchorNode, offset } = this.#getTextAnchorData();
3605
- if (!anchorNode) return
4163
+ const selection = $getSelection();
4164
+ const { anchorNode, offset } = this.#getTextAnchorData();
4165
+ if (!anchorNode) return
3606
4166
 
3607
- const lastIndex = this.#findLastIndexBeforeCursor(anchorNode, offset, stringToReplace);
3608
- if (lastIndex === -1) return
4167
+ const lastIndex = this.#findLastIndexBeforeCursor(anchorNode, offset, stringToReplace);
4168
+ if (lastIndex === -1) return
3609
4169
 
3610
- this.#performTextReplacement(anchorNode, offset, lastIndex, replacementNodes);
3611
- });
4170
+ this.#performTextReplacement(anchorNode, selection, offset, lastIndex, replacementNodes);
3612
4171
  }
3613
4172
 
3614
4173
  createParagraphAfterNode(node, text) {
@@ -3649,6 +4208,7 @@ class Contents {
3649
4208
  if (selectLast && uploader.nodes?.length) {
3650
4209
  const lastNode = uploader.nodes.at(-1);
3651
4210
  lastNode.selectEnd();
4211
+ this.#normalizeSelectionInShadowRoot();
3652
4212
  }
3653
4213
  });
3654
4214
  }
@@ -3731,6 +4291,19 @@ class Contents {
3731
4291
  node.remove();
3732
4292
  }
3733
4293
 
4294
+ // Anchors with non-meaningful hrefs (e.g. "#", "") appear in content copied
4295
+ // from rendered views where mentions and interactive elements are wrapped in
4296
+ // <a href="#"> tags. Unwrap them so their text content pastes as plain text
4297
+ // and real links are preserved.
4298
+ #unwrapPlaceholderAnchors(doc) {
4299
+ for (const anchor of doc.querySelectorAll("a")) {
4300
+ const href = anchor.getAttribute("href") || "";
4301
+ if (href === "" || href === "#") {
4302
+ anchor.replaceWith(...anchor.childNodes);
4303
+ }
4304
+ }
4305
+ }
4306
+
3734
4307
  #insertNodeWrappingAllSelectedNodes(newNodeFn) {
3735
4308
  this.editor.update(() => {
3736
4309
  const selection = $getSelection();
@@ -3896,6 +4469,101 @@ class Contents {
3896
4469
  nodesToDelete.forEach((node) => node.remove());
3897
4470
  }
3898
4471
 
4472
+ #getSelectedParagraphWithSoftLineBreaks(selection) {
4473
+ const anchorParagraph = this.#getParagraphFromNode(selection.anchor.getNode());
4474
+ const focusParagraph = this.#getParagraphFromNode(selection.focus.getNode());
4475
+
4476
+ if (!anchorParagraph || anchorParagraph !== focusParagraph) return null
4477
+ if ($isQuoteNode(anchorParagraph.getParent())) return null
4478
+
4479
+ return this.#paragraphHasSoftLineBreaks(anchorParagraph) ? anchorParagraph : null
4480
+ }
4481
+
4482
+ #paragraphHasSoftLineBreaks(paragraph) {
4483
+ return paragraph.getChildren().some((child) => $isLineBreakNode(child))
4484
+ }
4485
+
4486
+ #splitParagraphIntoLines(paragraph) {
4487
+ const lines = [ [] ];
4488
+
4489
+ paragraph.getChildren().forEach((child) => {
4490
+ if ($isLineBreakNode(child)) {
4491
+ lines.push([]);
4492
+ } else {
4493
+ lines[lines.length - 1].push(child);
4494
+ }
4495
+ });
4496
+
4497
+ return lines
4498
+ }
4499
+
4500
+ #getSelectedLineRange(lines, selection) {
4501
+ const selectedNodeKeys = new Set(
4502
+ selection.getNodes().map((node) => node.getKey())
4503
+ );
4504
+
4505
+ selectedNodeKeys.add(selection.anchor.getNode().getKey());
4506
+ selectedNodeKeys.add(selection.focus.getNode().getKey());
4507
+
4508
+ const selectedLineIndexes = lines
4509
+ .map((lineNodes, index) => {
4510
+ return lineNodes.some((node) => selectedNodeKeys.has(node.getKey())) ? index : null
4511
+ })
4512
+ .filter((index) => index !== null);
4513
+
4514
+ if (selectedLineIndexes.length === 0) return null
4515
+
4516
+ return {
4517
+ start: selectedLineIndexes[0],
4518
+ end: selectedLineIndexes[selectedLineIndexes.length - 1]
4519
+ }
4520
+ }
4521
+
4522
+ #replaceParagraphWithWrappedSelectedLines(paragraph, lines, { start, end }, newNodeFn) {
4523
+ const insertedNodes = [];
4524
+
4525
+ this.#appendParagraphsForLines(insertedNodes, lines.slice(0, start));
4526
+
4527
+ const wrappingNode = newNodeFn();
4528
+ lines.slice(start, end + 1).forEach((lineNodes) => {
4529
+ wrappingNode.append(this.#createParagraphFromLine(lineNodes));
4530
+ });
4531
+ insertedNodes.push(wrappingNode);
4532
+
4533
+ this.#appendParagraphsForLines(insertedNodes, lines.slice(end + 1));
4534
+
4535
+ let previousNode = null;
4536
+ insertedNodes.forEach((node) => {
4537
+ if (previousNode) {
4538
+ previousNode.insertAfter(node);
4539
+ } else {
4540
+ paragraph.insertBefore(node);
4541
+ }
4542
+
4543
+ previousNode = node;
4544
+ });
4545
+
4546
+ paragraph.remove();
4547
+ }
4548
+
4549
+ #appendParagraphsForLines(insertedNodes, lines) {
4550
+ lines.forEach((lineNodes) => {
4551
+ insertedNodes.push(this.#createParagraphFromLine(lineNodes));
4552
+ });
4553
+ }
4554
+
4555
+ #createParagraphFromLine(lineNodes) {
4556
+ const paragraph = $createParagraphNode();
4557
+
4558
+ if (lineNodes.length === 0) {
4559
+ paragraph.append($createLineBreakNode());
4560
+ } else {
4561
+ paragraph.append(...lineNodes);
4562
+ }
4563
+
4564
+ return paragraph
4565
+ }
4566
+
3899
4567
  #collectSelectedListItems(selection) {
3900
4568
  const nodes = selection.getNodes();
3901
4569
  const listItems = new Set();
@@ -4010,13 +4678,14 @@ class Contents {
4010
4678
  return textBeforeCursor.lastIndexOf(stringToReplace)
4011
4679
  }
4012
4680
 
4013
- #performTextReplacement(anchorNode, offset, lastIndex, replacementNodes) {
4681
+ #performTextReplacement(anchorNode, selection, offset, lastIndex, replacementNodes) {
4014
4682
  const fullText = anchorNode.getTextContent();
4015
4683
  const textBeforeString = fullText.slice(0, lastIndex);
4016
4684
  const textAfterCursor = fullText.slice(offset);
4017
4685
 
4018
- const textNodeBefore = $createTextNode(textBeforeString);
4019
- const textNodeAfter = $createTextNode(textAfterCursor || " ");
4686
+ const trailingSpacer = this.#hasInlineDecoratorNode(replacementNodes) ? "\u2060" : " ";
4687
+ const textNodeBefore = this.#cloneTextNodeFormatting(anchorNode, selection, textBeforeString);
4688
+ const textNodeAfter = this.#cloneTextNodeFormatting(anchorNode, selection, textAfterCursor || trailingSpacer);
4020
4689
 
4021
4690
  anchorNode.replace(textNodeBefore);
4022
4691
 
@@ -4028,6 +4697,24 @@ class Contents {
4028
4697
  textNodeAfter.select(cursorOffset, cursorOffset);
4029
4698
  }
4030
4699
 
4700
+ #hasInlineDecoratorNode(nodes) {
4701
+ return nodes.some(node => node instanceof CustomActionTextAttachmentNode && node.isInline())
4702
+ }
4703
+
4704
+ #cloneTextNodeFormatting(anchorNode, selection, text) {
4705
+ const parent = anchorNode.getParent();
4706
+ const fallbackFormat = parent?.getTextFormat?.() || 0;
4707
+ const fallbackStyle = parent?.getTextStyle?.() || "";
4708
+ const format = $isRangeSelection(selection) && selection.format ? selection.format : (anchorNode.getFormat() || fallbackFormat);
4709
+ const style = $isRangeSelection(selection) && selection.style ? selection.style : (anchorNode.getStyle() || fallbackStyle);
4710
+
4711
+ return $createTextNode(text)
4712
+ .setFormat(format)
4713
+ .setDetail(anchorNode.getDetail())
4714
+ .setMode(anchorNode.getMode())
4715
+ .setStyle(style)
4716
+ }
4717
+
4031
4718
  #insertReplacementNodes(startNode, replacementNodes) {
4032
4719
  let previousNode = startNode;
4033
4720
  for (const node of replacementNodes) {
@@ -4067,6 +4754,29 @@ class Contents {
4067
4754
  #shouldUploadFile(file) {
4068
4755
  return dispatch(this.editorElement, "lexxy:file-accept", { file }, true)
4069
4756
  }
4757
+
4758
+ // When the selection anchor is on a shadow root (e.g. a table cell), Lexical's
4759
+ // insertNodes can't find a block parent and fails silently. Normalize the
4760
+ // selection to point inside the shadow root's content instead.
4761
+ #normalizeSelectionInShadowRoot() {
4762
+ const selection = $getSelection();
4763
+ if (!$isRangeSelection(selection)) return
4764
+
4765
+ const anchorNode = selection.anchor.getNode();
4766
+ if (!$isShadowRoot(anchorNode)) return
4767
+
4768
+ // Append a paragraph inside the shadow root so there's a valid text-level
4769
+ // target for subsequent insertions. This is necessary because decorator
4770
+ // nodes (e.g. attachments) at the end of a table cell leave the selection
4771
+ // on the cell itself with no block-level descendant to anchor to.
4772
+ const paragraph = $createParagraphNode();
4773
+ anchorNode.append(paragraph);
4774
+ paragraph.selectStart();
4775
+ }
4776
+ }
4777
+
4778
+ function $isShadowRoot(node) {
4779
+ return $isElementNode(node) && $isRootOrShadowRoot(node) && !$isRootNode(node)
4070
4780
  }
4071
4781
 
4072
4782
  class Clipboard {
@@ -4155,7 +4865,7 @@ class Clipboard {
4155
4865
  }
4156
4866
 
4157
4867
  #pasteMarkdown(text) {
4158
- const html = marked(text);
4868
+ const html = marked(text, { breaks: true });
4159
4869
  const doc = parseHtml(html);
4160
4870
  const detail = Object.freeze({
4161
4871
  markdown: text,
@@ -4178,19 +4888,38 @@ class Clipboard {
4178
4888
  if (!this.editorElement.supportsAttachments) return false
4179
4889
 
4180
4890
  const html = clipboardData.getData("text/html");
4891
+ const files = clipboardData.files;
4892
+
4893
+ if (files.length && this.#isCopiedImageHTML(html)) {
4894
+ this.#uploadFilesPreservingScroll(files);
4895
+ return true
4896
+ }
4897
+
4181
4898
  if (html) {
4182
4899
  this.contents.insertHtml(html, { tag: PASTE_TAG });
4183
4900
  return true
4184
4901
  }
4185
4902
 
4903
+ this.#uploadFilesPreservingScroll(files);
4904
+
4905
+ return true
4906
+ }
4907
+
4908
+ #isCopiedImageHTML(html) {
4909
+ if (!html) return false
4910
+
4911
+ const doc = parseHtml(html);
4912
+ const elementChildren = Array.from(doc.body.children);
4913
+
4914
+ return elementChildren.length === 1 && elementChildren[0].tagName === "IMG"
4915
+ }
4916
+
4917
+ #uploadFilesPreservingScroll(files) {
4186
4918
  this.#preservingScrollPosition(() => {
4187
- const files = clipboardData.files;
4188
4919
  if (files.length) {
4189
4920
  this.contents.uploadFiles(files, { selectLast: true });
4190
4921
  }
4191
4922
  });
4192
-
4193
- return true
4194
4923
  }
4195
4924
 
4196
4925
  // Deals with an issue in Safari where it scrolls to the tops after pasting attachments
@@ -4246,6 +4975,60 @@ class Extensions {
4246
4975
  }
4247
4976
  }
4248
4977
 
4978
+ // Custom TextNode exportDOM that avoids redundant bold/italic wrapping.
4979
+ //
4980
+ // Lexical's built-in TextNode.exportDOM() calls createDOM() which produces semantic tags
4981
+ // like <strong> for bold and <em> for italic, then unconditionally wraps the result
4982
+ // with presentational tags (<b>, <i>) for the same formats. This produces redundant markup
4983
+ // like <b><strong>text</strong></b>.
4984
+ //
4985
+ // This custom export skips <b> when <strong> is already present and <i> when <em> is
4986
+ // already present, while preserving <s> and <u> wrappers which have no semantic equivalents
4987
+ // in createDOM's output.
4988
+
4989
+ function exportTextNodeDOM(editor, textNode) {
4990
+ const element = textNode.createDOM(editor._config, editor);
4991
+ element.style.whiteSpace = "pre-wrap";
4992
+
4993
+ if (textNode.hasFormat("lowercase")) {
4994
+ element.style.textTransform = "lowercase";
4995
+ } else if (textNode.hasFormat("uppercase")) {
4996
+ element.style.textTransform = "uppercase";
4997
+ } else if (textNode.hasFormat("capitalize")) {
4998
+ element.style.textTransform = "capitalize";
4999
+ }
5000
+
5001
+ let result = element;
5002
+
5003
+ if (textNode.hasFormat("bold") && !containsTag(element, "strong")) {
5004
+ result = wrapWith(result, "b");
5005
+ }
5006
+ if (textNode.hasFormat("italic") && !containsTag(element, "em")) {
5007
+ result = wrapWith(result, "i");
5008
+ }
5009
+ if (textNode.hasFormat("strikethrough")) {
5010
+ result = wrapWith(result, "s");
5011
+ }
5012
+ if (textNode.hasFormat("underline")) {
5013
+ result = wrapWith(result, "u");
5014
+ }
5015
+
5016
+ return { element: result }
5017
+ }
5018
+
5019
+ function containsTag(element, tagName) {
5020
+ const upperTag = tagName.toUpperCase();
5021
+ if (element.tagName === upperTag) return true
5022
+
5023
+ return element.querySelector(tagName) !== null
5024
+ }
5025
+
5026
+ function wrapWith(element, tag) {
5027
+ const wrapper = document.createElement(tag);
5028
+ wrapper.appendChild(element);
5029
+ return wrapper
5030
+ }
5031
+
4249
5032
  class ProvisionalParagraphNode extends ParagraphNode {
4250
5033
  $config() {
4251
5034
  return this.config("provisonal_paragraph", {
@@ -4444,6 +5227,14 @@ class WrappedTableNode extends TableNode {
4444
5227
  return super.importDOM()
4445
5228
  }
4446
5229
 
5230
+ canInsertTextBefore() {
5231
+ return false
5232
+ }
5233
+
5234
+ canInsertTextAfter() {
5235
+ return false
5236
+ }
5237
+
4447
5238
  exportDOM(editor) {
4448
5239
  const superExport = super.exportDOM(editor);
4449
5240
 
@@ -4713,7 +5504,7 @@ class LexicalEditorElement extends HTMLElement {
4713
5504
  toString() {
4714
5505
  if (!this.cachedStringValue) {
4715
5506
  this.editor?.getEditorState().read(() => {
4716
- this.cachedStringValue = $getRoot().getTextContent();
5507
+ this.cachedStringValue = $getReadableTextContent($getRoot());
4717
5508
  });
4718
5509
  }
4719
5510
 
@@ -4830,10 +5621,20 @@ class LexicalEditorElement extends HTMLElement {
4830
5621
  const nodes = $generateNodesFromDOM(this.editor, parseHtml(`${html}`));
4831
5622
 
4832
5623
  return nodes
5624
+ .filter(this.#isNotWhitespaceOnlyNode)
4833
5625
  .map(this.#wrapTextNode)
4834
5626
  .map(this.#unwrapDecoratorNode)
4835
5627
  }
4836
5628
 
5629
+ // Whitespace-only text nodes (e.g. "\n" between block elements like <div>) and stray line break
5630
+ // nodes are formatting artifacts from the HTML source. They can't be appended to the root node
5631
+ // and have no semantic meaning, so we strip them during import.
5632
+ #isNotWhitespaceOnlyNode(node) {
5633
+ if ($isLineBreakNode(node)) return false
5634
+ if ($isTextNode(node) && node.getTextContent().trim() === "") return false
5635
+ return true
5636
+ }
5637
+
4837
5638
  // Raw string values produce TextNodes which cannot be appended directly to the RootNode.
4838
5639
  // We wrap those in <p>
4839
5640
  #wrapTextNode(node) {
@@ -4874,7 +5675,10 @@ class LexicalEditorElement extends HTMLElement {
4874
5675
  name: "lexxy/core",
4875
5676
  namespace: "Lexxy",
4876
5677
  theme: theme,
4877
- nodes: this.#lexicalNodes
5678
+ nodes: this.#lexicalNodes,
5679
+ html: {
5680
+ export: new Map([ [ TextNode, exportTextNodeDOM ] ])
5681
+ }
4878
5682
  },
4879
5683
  ...this.extensions.lexicalExtensions
4880
5684
  );
@@ -4998,6 +5802,7 @@ class LexicalEditorElement extends HTMLElement {
4998
5802
  this.#registerCodeHiglightingComponents();
4999
5803
  if (this.supportsMarkdown) {
5000
5804
  registerMarkdownShortcuts(this.editor, TRANSFORMERS);
5805
+ registerMarkdownLeadingTagHandler(this.editor, TRANSFORMERS);
5001
5806
  }
5002
5807
  } else {
5003
5808
  registerPlainText(this.editor);
@@ -5160,6 +5965,29 @@ class LexicalEditorElement extends HTMLElement {
5160
5965
  }
5161
5966
  }
5162
5967
 
5968
+ // Like $getRoot().getTextContent() but uses readable text for custom attachment nodes
5969
+ // (e.g., mentions) instead of their single-character cursor placeholder.
5970
+ function $getReadableTextContent(node) {
5971
+ if (node instanceof CustomActionTextAttachmentNode) {
5972
+ return node.getReadableTextContent()
5973
+ }
5974
+
5975
+ if ($isElementNode(node)) {
5976
+ let text = "";
5977
+ const children = node.getChildren();
5978
+ for (let i = 0; i < children.length; i++) {
5979
+ const child = children[i];
5980
+ text += $getReadableTextContent(child);
5981
+ if ($isElementNode(child) && i !== children.length - 1 && !child.isInline()) {
5982
+ text += "\n\n";
5983
+ }
5984
+ }
5985
+ return text
5986
+ }
5987
+
5988
+ return node.getTextContent()
5989
+ }
5990
+
5163
5991
  class ToolbarDropdown extends HTMLElement {
5164
5992
  connectedCallback() {
5165
5993
  this.container = this.closest("details");
@@ -5596,6 +6424,8 @@ class LexicalPromptElement extends HTMLElement {
5596
6424
  #addTriggerListener() {
5597
6425
  const unregister = this.#editor.registerUpdateListener(({ editorState }) => {
5598
6426
  editorState.read(() => {
6427
+ if (this.#selection.isInsideCodeBlock) return
6428
+
5599
6429
  const { node, offset } = this.#selection.selectedNodeWithOffset();
5600
6430
  if (!node) return
5601
6431
 
@@ -5626,10 +6456,15 @@ class LexicalPromptElement extends HTMLElement {
5626
6456
  }
5627
6457
 
5628
6458
  #addCursorPositionListener() {
5629
- this.cursorPositionListener = this.#editor.registerUpdateListener(() => {
6459
+ this.cursorPositionListener = this.#editor.registerUpdateListener(({ editorState }) => {
5630
6460
  if (this.closed) return
5631
6461
 
5632
- this.#editor.read(() => {
6462
+ editorState.read(() => {
6463
+ if (this.#selection.isInsideCodeBlock) {
6464
+ this.#hidePopover();
6465
+ return
6466
+ }
6467
+
5633
6468
  const { node, offset } = this.#selection.selectedNodeWithOffset();
5634
6469
  if (!node) return
5635
6470
 
@@ -5752,25 +6587,34 @@ class LexicalPromptElement extends HTMLElement {
5752
6587
  const verticalOffset = contentRect.top - editorRect.top;
5753
6588
 
5754
6589
  if (!this.popoverElement.hasAttribute("data-anchored")) {
5755
- this.popoverElement.style.left = `${x}px`;
6590
+ this.#setPopoverOffsetX(x);
6591
+ this.#setPopoverOffsetY(y + verticalOffset);
5756
6592
  this.popoverElement.toggleAttribute("data-anchored", true);
5757
6593
  }
5758
6594
 
5759
- this.popoverElement.style.top = `${y + verticalOffset}px`;
5760
- this.popoverElement.style.bottom = "auto";
5761
-
5762
6595
  const popoverRect = this.popoverElement.getBoundingClientRect();
5763
- const isClippedAtBottom = popoverRect.bottom > window.innerHeight;
5764
6596
 
5765
- if (isClippedAtBottom || this.popoverElement.hasAttribute("data-clipped-at-bottom")) {
5766
- this.popoverElement.style.top = `${y + verticalOffset - popoverRect.height - fontSize}px`;
5767
- this.popoverElement.style.bottom = "auto";
6597
+ if (popoverRect.right > window.innerWidth) {
6598
+ this.popoverElement.toggleAttribute("data-clipped-at-right", true);
6599
+ }
6600
+
6601
+ if (popoverRect.bottom > window.innerHeight) {
6602
+ this.#setPopoverOffsetY(contentRect.height - y + fontSize);
5768
6603
  this.popoverElement.toggleAttribute("data-clipped-at-bottom", true);
5769
6604
  }
5770
6605
  }
5771
6606
 
6607
+ #setPopoverOffsetX(value) {
6608
+ this.popoverElement.style.setProperty("--lexxy-prompt-offset-x", `${value}px`);
6609
+ }
6610
+
6611
+ #setPopoverOffsetY(value) {
6612
+ this.popoverElement.style.setProperty("--lexxy-prompt-offset-y", `${value}px`);
6613
+ }
6614
+
5772
6615
  #resetPopoverPosition() {
5773
6616
  this.popoverElement.removeAttribute("data-clipped-at-bottom");
6617
+ this.popoverElement.removeAttribute("data-clipped-at-right");
5774
6618
  this.popoverElement.removeAttribute("data-anchored");
5775
6619
  }
5776
6620
 
@@ -5841,6 +6685,16 @@ class LexicalPromptElement extends HTMLElement {
5841
6685
  this.#hidePopover();
5842
6686
  this.#editorElement.focus();
5843
6687
  event.stopPropagation();
6688
+ } else if (event.key === ",") {
6689
+ event.preventDefault();
6690
+ event.stopPropagation();
6691
+ this.#optionWasSelected();
6692
+ this.#editor.update(() => {
6693
+ const selection = $getSelection();
6694
+ if ($isRangeSelection(selection)) {
6695
+ selection.insertText(",");
6696
+ }
6697
+ });
5844
6698
  }
5845
6699
  // Arrow keys are now handled via Lexical commands with HIGH priority
5846
6700
  }
@@ -6781,8 +7635,11 @@ class TableTools extends HTMLElement {
6781
7635
  }
6782
7636
 
6783
7637
  #show() {
7638
+ this.#updateButtonsPosition();
6784
7639
  this.style.display = "flex";
6785
- this.#update();
7640
+ this.#updateRowColumnCount();
7641
+ this.#closeMoreMenu();
7642
+ this.#handleCommandButtonHover();
6786
7643
  }
6787
7644
 
6788
7645
  #hide() {