@37signals/lexxy 0.8.0-beta → 0.8.5-beta

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/dist/lexxy.esm.js CHANGED
@@ -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, DRAGSTART_COMMAND, DROP_COMMAND, 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() {
@@ -630,7 +632,7 @@ class LexicalToolbarElement extends HTMLElement {
630
632
  }
631
633
 
632
634
  get #buttons() {
633
- return Array.from(this.querySelectorAll(":scope > button"))
635
+ return Array.from(this.querySelectorAll(":scope > button:not([data-prevent-overflow='true'])"))
634
636
  }
635
637
 
636
638
  get #focusableItems() {
@@ -700,7 +702,7 @@ class LexicalToolbarElement extends HTMLElement {
700
702
  ${ToolbarIcons.ol}
701
703
  </button>
702
704
 
703
- <button class="lexxy-editor__toolbar-button" type="button" name="upload" data-command="uploadAttachments" title="Upload file">
705
+ <button class="lexxy-editor__toolbar-button" type="button" name="upload" data-command="uploadAttachments" data-prevent-overflow="true" title="Upload file">
704
706
  ${ToolbarIcons.attachment}
705
707
  </button>
706
708
 
@@ -711,9 +713,9 @@ class LexicalToolbarElement extends HTMLElement {
711
713
  <button class="lexxy-editor__toolbar-button" type="button" name="divider" data-command="insertHorizontalDivider" title="Insert a divider">
712
714
  ${ToolbarIcons.hr}
713
715
  </button>
714
-
716
+
715
717
  <div class="lexxy-editor__toolbar-spacer" role="separator"></div>
716
-
718
+
717
719
  <button class="lexxy-editor__toolbar-button" type="button" name="undo" data-command="undo" title="Undo">
718
720
  ${ToolbarIcons.undo}
719
721
  </button>
@@ -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;
@@ -1375,24 +1675,34 @@ class CommandDispatcher {
1375
1675
  }
1376
1676
 
1377
1677
  #handleDragEnter(event) {
1678
+ if (this.#isInternalDrag(event)) return
1679
+
1378
1680
  this.dragCounter++;
1379
1681
  if (this.dragCounter === 1) {
1682
+ this.#saveSelectionBeforeDrag();
1380
1683
  this.editor.getRootElement().classList.add("lexxy-editor--drag-over");
1381
1684
  }
1382
1685
  }
1383
1686
 
1384
1687
  #handleDragLeave(event) {
1688
+ if (this.#isInternalDrag(event)) return
1689
+
1385
1690
  this.dragCounter--;
1386
1691
  if (this.dragCounter === 0) {
1692
+ this.#selectionBeforeDrag = null;
1387
1693
  this.editor.getRootElement().classList.remove("lexxy-editor--drag-over");
1388
1694
  }
1389
1695
  }
1390
1696
 
1391
1697
  #handleDragOver(event) {
1698
+ if (this.#isInternalDrag(event)) return
1699
+
1392
1700
  event.preventDefault();
1393
1701
  }
1394
1702
 
1395
1703
  #handleDrop(event) {
1704
+ if (this.#isInternalDrag(event)) return
1705
+
1396
1706
  event.preventDefault();
1397
1707
 
1398
1708
  this.dragCounter = 0;
@@ -1404,11 +1714,32 @@ class CommandDispatcher {
1404
1714
  const files = Array.from(dataTransfer.files);
1405
1715
  if (!files.length) return
1406
1716
 
1717
+ this.#restoreSelectionBeforeDrag();
1407
1718
  this.contents.uploadFiles(files, { selectLast: true });
1408
1719
 
1409
1720
  this.editor.focus();
1410
1721
  }
1411
1722
 
1723
+ #saveSelectionBeforeDrag() {
1724
+ this.editor.getEditorState().read(() => {
1725
+ this.#selectionBeforeDrag = $getSelection()?.clone();
1726
+ });
1727
+ }
1728
+
1729
+ #restoreSelectionBeforeDrag() {
1730
+ if (!this.#selectionBeforeDrag) return
1731
+
1732
+ this.editor.update(() => {
1733
+ $setSelection(this.#selectionBeforeDrag);
1734
+ });
1735
+
1736
+ this.#selectionBeforeDrag = null;
1737
+ }
1738
+
1739
+ #isInternalDrag(event) {
1740
+ return event.dataTransfer?.types.includes("application/x-lexxy-node-key")
1741
+ }
1742
+
1412
1743
  #handleTabKey(event) {
1413
1744
  if (this.selection.isInsideList) {
1414
1745
  return this.#handleTabForList(event)
@@ -1482,6 +1813,16 @@ function extractFileName(string) {
1482
1813
  return string.split("/").pop()
1483
1814
  }
1484
1815
 
1816
+ // Lexxy exports the content attribute as a JSON string (via JSON.stringify),
1817
+ // but Trix/ActionText stores it as raw HTML. Try JSON first, fall back to raw.
1818
+ function parseAttachmentContent(content) {
1819
+ try {
1820
+ return JSON.parse(content)
1821
+ } catch {
1822
+ return content
1823
+ }
1824
+ }
1825
+
1485
1826
  class ActionTextAttachmentNode extends DecoratorNode {
1486
1827
  static getType() {
1487
1828
  return "action_text_attachment"
@@ -1647,6 +1988,8 @@ class ActionTextAttachmentNode extends DecoratorNode {
1647
1988
 
1648
1989
  createAttachmentFigure() {
1649
1990
  const figure = createAttachmentFigure(this.contentType, this.isPreviewableAttachment, this.fileName);
1991
+ figure.draggable = true;
1992
+ figure.dataset.lexicalNodeKey = this.__key;
1650
1993
 
1651
1994
  const deleteButton = createElement("lexxy-node-delete-button");
1652
1995
  figure.appendChild(deleteButton);
@@ -1708,6 +2051,9 @@ class ActionTextAttachmentNode extends DecoratorNode {
1708
2051
  input.addEventListener("focusin", () => input.placeholder = "Add caption...");
1709
2052
  input.addEventListener("blur", (event) => this.#handleCaptionInputBlurred(event));
1710
2053
  input.addEventListener("keydown", (event) => this.#handleCaptionInputKeydown(event));
2054
+ input.addEventListener("copy", (event) => event.stopPropagation());
2055
+ input.addEventListener("cut", (event) => event.stopPropagation());
2056
+ input.addEventListener("paste", (event) => event.stopPropagation());
1711
2057
 
1712
2058
  caption.appendChild(input);
1713
2059
 
@@ -1728,7 +2074,6 @@ class ActionTextAttachmentNode extends DecoratorNode {
1728
2074
  #handleCaptionInputKeydown(event) {
1729
2075
  if (event.key === "Enter") {
1730
2076
  event.preventDefault();
1731
- event.stopPropagation();
1732
2077
  event.target.blur();
1733
2078
 
1734
2079
  this.editor.update(() => {
@@ -1739,6 +2084,10 @@ class ActionTextAttachmentNode extends DecoratorNode {
1739
2084
  });
1740
2085
  }
1741
2086
 
2087
+ // Stop all keydown events from bubbling to the Lexical root element.
2088
+ // The caption textarea is outside Lexical's content model and should
2089
+ // handle its own keyboard events natively (Ctrl+A, Ctrl+C, Ctrl+X, etc.).
2090
+ event.stopPropagation();
1742
2091
  }
1743
2092
  }
1744
2093
 
@@ -1920,6 +2269,10 @@ class Selection {
1920
2269
  }
1921
2270
 
1922
2271
  get isTableCellSelected() {
2272
+ const selection = $getSelection();
2273
+ const { anchor, focus } = selection;
2274
+ if (!$isRangeSelection(selection) || anchor.key !== focus.key) return false
2275
+
1923
2276
  return this.nearestNodeOfType(TableCellNode) !== null
1924
2277
  }
1925
2278
 
@@ -1949,7 +2302,12 @@ class Selection {
1949
2302
  if (!anchorNode) return null
1950
2303
 
1951
2304
  if ($isTextNode(anchorNode)) {
1952
- return this.#getNextNodeFromTextEnd(anchorNode)
2305
+ if (offset === anchorNode.getTextContentSize()) return this.#getNextNodeFromTextEnd(anchorNode)
2306
+ if (this.#isCursorOnLastVisualLineOfBlock(anchorNode)) {
2307
+ const topLevelElement = anchorNode.getTopLevelElement();
2308
+ return topLevelElement ? topLevelElement.getNextSibling() : null
2309
+ }
2310
+ return null
1953
2311
  }
1954
2312
 
1955
2313
  if ($isElementNode(anchorNode)) {
@@ -1979,7 +2337,12 @@ class Selection {
1979
2337
  if (!anchorNode) return null
1980
2338
 
1981
2339
  if ($isTextNode(anchorNode)) {
1982
- return this.#getPreviousNodeFromTextStart(anchorNode)
2340
+ if (offset === 0) return this.#getPreviousNodeFromTextStart(anchorNode)
2341
+ if (this.#isCursorOnFirstVisualLineOfBlock(anchorNode)) {
2342
+ const topLevelElement = anchorNode.getTopLevelElement();
2343
+ return topLevelElement ? topLevelElement.getPreviousSibling() : null
2344
+ }
2345
+ return null
1983
2346
  }
1984
2347
 
1985
2348
  if ($isElementNode(anchorNode)) {
@@ -2092,7 +2455,9 @@ class Selection {
2092
2455
  }
2093
2456
  }
2094
2457
 
2095
- async #selectPreviousNode() {
2458
+ async #selectPreviousNode(event) {
2459
+ if (event?.shiftKey) return false
2460
+
2096
2461
  if (this.hasNodeSelection) {
2097
2462
  return await this.#withCurrentNode((currentNode) => currentNode.selectPrevious())
2098
2463
  } else {
@@ -2100,7 +2465,9 @@ class Selection {
2100
2465
  }
2101
2466
  }
2102
2467
 
2103
- async #selectNextNode() {
2468
+ async #selectNextNode(event) {
2469
+ if (event?.shiftKey) return false
2470
+
2104
2471
  if (this.hasNodeSelection) {
2105
2472
  return await this.#withCurrentNode((currentNode) => currentNode.selectNext(0, 0))
2106
2473
  } else {
@@ -2199,12 +2566,46 @@ class Selection {
2199
2566
  const node = backwards ? this.nodeBeforeCursor : this.nodeAfterCursor;
2200
2567
  if (!$isDecoratorNode(node)) return false
2201
2568
 
2569
+ if (this.#collapseListItemToParagraph(node)) return true
2570
+
2202
2571
  this.#removeEmptyElementAnchorNode();
2203
2572
 
2204
2573
  const selection = this.#selectInLexical(node);
2205
2574
  return Boolean(selection)
2206
2575
  }
2207
2576
 
2577
+ // When the cursor is inside a list item, collapse the list item into a
2578
+ // paragraph instead of selecting the decorator. This lets the user
2579
+ // delete a list that immediately follows an attachment without the
2580
+ // attachment becoming selected. Only applies when the decorator is
2581
+ // outside the list item (e.g. a block attachment before the list),
2582
+ // not when it's an inline mention inside the list item.
2583
+ #collapseListItemToParagraph(decoratorNode) {
2584
+ const anchorNode = $getSelection()?.anchor?.getNode();
2585
+ const listItem = anchorNode && $getNearestNodeOfType(anchorNode, ListItemNode);
2586
+ if (!listItem) return false
2587
+
2588
+ if (listItem.isParentOf(decoratorNode)) return false
2589
+
2590
+ const listNode = $getNearestNodeOfType(listItem, ListNode);
2591
+ if (!listNode) return false
2592
+
2593
+ const paragraph = $createParagraphNode();
2594
+ const children = listItem.getChildren();
2595
+ children.forEach(child => paragraph.append(child));
2596
+
2597
+ if (listNode.getChildrenSize() === 1) {
2598
+ listNode.insertBefore(paragraph);
2599
+ listNode.remove();
2600
+ } else {
2601
+ listNode.insertBefore(paragraph);
2602
+ listItem.remove();
2603
+ }
2604
+
2605
+ paragraph.selectStart();
2606
+ return true
2607
+ }
2608
+
2208
2609
  #removeEmptyElementAnchorNode(anchor = $getSelection()?.anchor) {
2209
2610
  const anchorNode = anchor?.getNode();
2210
2611
  if ($isElementNode(anchorNode) && anchorNode?.isEmpty()) anchorNode.remove();
@@ -2311,8 +2712,12 @@ class Selection {
2311
2712
  }
2312
2713
 
2313
2714
  #getNextNodeFromTextEnd(anchorNode) {
2314
- if (anchorNode.getNextSibling() instanceof DecoratorNode) {
2315
- return anchorNode.getNextSibling()
2715
+ const nextSibling = anchorNode.getNextSibling();
2716
+ if ($isDecoratorNode(nextSibling)) {
2717
+ return nextSibling
2718
+ }
2719
+ if (nextSibling != null) {
2720
+ return null
2316
2721
  }
2317
2722
  const parent = anchorNode.getParent();
2318
2723
  return parent ? parent.getNextSibling() : null
@@ -2333,11 +2738,15 @@ class Selection {
2333
2738
  }
2334
2739
 
2335
2740
  #getPreviousNodeFromTextStart(anchorNode) {
2336
- if (anchorNode.getPreviousSibling() instanceof DecoratorNode) {
2337
- return anchorNode.getPreviousSibling()
2741
+ const previousSibling = anchorNode.getPreviousSibling();
2742
+ if ($isDecoratorNode(previousSibling)) {
2743
+ return previousSibling
2744
+ }
2745
+ if (previousSibling != null) {
2746
+ return null
2338
2747
  }
2339
2748
  const parent = anchorNode.getParent();
2340
- return parent.getPreviousSibling()
2749
+ return parent ? parent.getPreviousSibling() : null
2341
2750
  }
2342
2751
 
2343
2752
  #getNodeBeforeElementNode(anchorNode, offset) {
@@ -2362,6 +2771,63 @@ class Selection {
2362
2771
  }
2363
2772
  return current ? current.getPreviousSibling() : null
2364
2773
  }
2774
+
2775
+ #isCursorOnFirstVisualLineOfBlock(anchorNode) {
2776
+ return this.#isCursorOnEdgeLineOfBlock(anchorNode, "first")
2777
+ }
2778
+
2779
+ #isCursorOnLastVisualLineOfBlock(anchorNode) {
2780
+ return this.#isCursorOnEdgeLineOfBlock(anchorNode, "last")
2781
+ }
2782
+
2783
+ // Check whether the cursor sits on the first or last visual line of its
2784
+ // top-level block by comparing the Y position of the cursor with the Y
2785
+ // position of the block's start (first line) or end (last line).
2786
+ #isCursorOnEdgeLineOfBlock(anchorNode, edge) {
2787
+ const topLevelElement = anchorNode.getTopLevelElement();
2788
+ if (!topLevelElement) return false
2789
+
2790
+ const domElement = this.editor.getElementByKey(topLevelElement.getKey());
2791
+ if (!domElement) return false
2792
+
2793
+ const nativeSelection = window.getSelection();
2794
+ if (!nativeSelection?.rangeCount) return false
2795
+
2796
+ const cursorRect = this.#getReliableRectFromRange(nativeSelection.getRangeAt(0));
2797
+ if (!cursorRect || this.#isRectUnreliable(cursorRect)) return false
2798
+
2799
+ const edgeRect = this.#getEdgeCharRect(domElement, edge);
2800
+ if (!edgeRect || this.#isRectUnreliable(edgeRect)) return false
2801
+
2802
+ const tolerance = edgeRect.height > 0 ? edgeRect.height * 0.5 : 5;
2803
+ return Math.abs(cursorRect.top - edgeRect.top) < tolerance
2804
+ }
2805
+
2806
+ // Get a reliable bounding rect for the first or last character in a DOM
2807
+ // element by creating a non-collapsed range around it.
2808
+ #getEdgeCharRect(element, edge) {
2809
+ const walker = document.createTreeWalker(element, 4 /* NodeFilter.SHOW_TEXT */);
2810
+ let textNode;
2811
+
2812
+ if (edge === "first") {
2813
+ textNode = walker.nextNode();
2814
+ } else {
2815
+ while (walker.nextNode()) textNode = walker.currentNode;
2816
+ }
2817
+
2818
+ if (!textNode || textNode.length === 0) return null
2819
+
2820
+ const range = document.createRange();
2821
+ if (edge === "first") {
2822
+ range.setStart(textNode, 0);
2823
+ range.setEnd(textNode, 1);
2824
+ } else {
2825
+ range.setStart(textNode, textNode.length - 1);
2826
+ range.setEnd(textNode, textNode.length);
2827
+ }
2828
+
2829
+ return range.getBoundingClientRect()
2830
+ }
2365
2831
  }
2366
2832
 
2367
2833
  function sanitize(html) {
@@ -2469,11 +2935,11 @@ class CustomActionTextAttachmentNode extends DecoratorNode {
2469
2935
 
2470
2936
  nodes.push(new CustomActionTextAttachmentNode({
2471
2937
  sgid: attachment.getAttribute("sgid"),
2472
- innerHtml: JSON.parse(attachment.getAttribute("content")),
2938
+ innerHtml: parseAttachmentContent(attachment.getAttribute("content")),
2473
2939
  contentType: attachment.getAttribute("content-type")
2474
2940
  }));
2475
2941
 
2476
- nodes.push($createTextNode(" "));
2942
+ nodes.push($createTextNode("\u2060"));
2477
2943
 
2478
2944
  return { node: nodes }
2479
2945
  },
@@ -2514,6 +2980,10 @@ class CustomActionTextAttachmentNode extends DecoratorNode {
2514
2980
  }
2515
2981
 
2516
2982
  getTextContent() {
2983
+ return "\ufeff"
2984
+ }
2985
+
2986
+ getReadableTextContent() {
2517
2987
  return this.createDOM().textContent.trim() || `[${this.contentType}]`
2518
2988
  }
2519
2989
 
@@ -2545,6 +3015,7 @@ class CustomActionTextAttachmentNode extends DecoratorNode {
2545
3015
  decorate() {
2546
3016
  return null
2547
3017
  }
3018
+
2548
3019
  }
2549
3020
 
2550
3021
  class FormatEscaper {
@@ -2559,12 +3030,20 @@ class FormatEscaper {
2559
3030
  (event) => this.#handleEnterKey(event),
2560
3031
  COMMAND_PRIORITY_HIGH
2561
3032
  );
3033
+
3034
+ this.editor.registerCommand(
3035
+ KEY_ARROW_DOWN_COMMAND,
3036
+ (event) => this.#handleArrowDownInCodeBlock(event),
3037
+ COMMAND_PRIORITY_NORMAL
3038
+ );
2562
3039
  }
2563
3040
 
2564
3041
  #handleEnterKey(event) {
2565
3042
  const selection = $getSelection();
2566
3043
  if (!$isRangeSelection(selection)) return false
2567
3044
 
3045
+ if (this.#handleCodeBlocks(event, selection)) return true
3046
+
2568
3047
  const anchorNode = selection.anchor.getNode();
2569
3048
 
2570
3049
  if (!this.#isInsideBlockquote(anchorNode)) return false
@@ -2825,6 +3304,101 @@ class FormatEscaper {
2825
3304
 
2826
3305
  newParagraph.selectStart();
2827
3306
  }
3307
+
3308
+ // Code blocks
3309
+
3310
+ #handleCodeBlocks(event, selection) {
3311
+ if (!selection.isCollapsed()) return false
3312
+
3313
+ const codeNode = this.#getCodeNodeFromSelection(selection);
3314
+ if (!codeNode) return false
3315
+
3316
+ if (this.#isCursorOnEmptyLastLineOfCodeBlock(selection, codeNode)) {
3317
+ event?.preventDefault();
3318
+ this.#exitCodeBlock(codeNode);
3319
+ return true
3320
+ }
3321
+
3322
+ return false
3323
+ }
3324
+
3325
+ #handleArrowDownInCodeBlock(event) {
3326
+ const selection = $getSelection();
3327
+ if (!$isRangeSelection(selection) || !selection.isCollapsed()) return false
3328
+
3329
+ const codeNode = this.#getCodeNodeFromSelection(selection);
3330
+ if (!codeNode) return false
3331
+
3332
+ if (this.#isCursorOnLastLineOfCodeBlock(selection, codeNode) && !codeNode.getNextSibling()) {
3333
+ event?.preventDefault();
3334
+ const paragraph = $createParagraphNode();
3335
+ codeNode.insertAfter(paragraph);
3336
+ paragraph.selectStart();
3337
+ return true
3338
+ }
3339
+
3340
+ return false
3341
+ }
3342
+
3343
+ #getCodeNodeFromSelection(selection) {
3344
+ const anchorNode = selection.anchor.getNode();
3345
+ return $getNearestNodeOfType(anchorNode, CodeNode) || ($isCodeNode(anchorNode) ? anchorNode : null)
3346
+ }
3347
+
3348
+ #isCursorOnEmptyLastLineOfCodeBlock(selection, codeNode) {
3349
+ const children = codeNode.getChildren();
3350
+ if (children.length === 0) return true
3351
+
3352
+ const anchorNode = selection.anchor.getNode();
3353
+ const anchorOffset = selection.anchor.offset;
3354
+
3355
+ // Chromium: cursor on the CodeNode element after the last child (a line break)
3356
+ if ($isCodeNode(anchorNode) && anchorOffset === children.length) {
3357
+ return $isLineBreakNode(children[children.length - 1])
3358
+ }
3359
+
3360
+ // Firefox: cursor on an empty text node that follows a line break at the end
3361
+ if ($isTextNode(anchorNode) && anchorNode.getTextContentSize() === 0 && anchorOffset === 0) {
3362
+ const previousSibling = anchorNode.getPreviousSibling();
3363
+ return $isLineBreakNode(previousSibling) && anchorNode.getNextSibling() === null
3364
+ }
3365
+
3366
+ return false
3367
+ }
3368
+
3369
+ #isCursorOnLastLineOfCodeBlock(selection, codeNode) {
3370
+ const anchorNode = selection.anchor.getNode();
3371
+ const children = codeNode.getChildren();
3372
+ if (children.length === 0) return true
3373
+
3374
+ const lastChild = children[children.length - 1];
3375
+
3376
+ if ($isCodeNode(anchorNode) && selection.anchor.offset === children.length) return true
3377
+ if (anchorNode === lastChild) return true
3378
+
3379
+ const lastLineBreakIndex = children.findLastIndex(child => $isLineBreakNode(child));
3380
+ if (lastLineBreakIndex === -1) return true
3381
+
3382
+ const anchorIndex = children.indexOf(anchorNode);
3383
+ return anchorIndex > lastLineBreakIndex
3384
+ }
3385
+
3386
+ #exitCodeBlock(codeNode) {
3387
+ const children = codeNode.getChildren();
3388
+ const lastChild = children[children.length - 1];
3389
+
3390
+ if ($isTextNode(lastChild) && lastChild.getTextContentSize() === 0) {
3391
+ const previousSibling = lastChild.getPreviousSibling();
3392
+ lastChild.remove();
3393
+ if ($isLineBreakNode(previousSibling)) previousSibling.remove();
3394
+ } else if ($isLineBreakNode(lastChild)) {
3395
+ lastChild.remove();
3396
+ }
3397
+
3398
+ const paragraph = $createParagraphNode();
3399
+ codeNode.insertAfter(paragraph);
3400
+ paragraph.selectStart();
3401
+ }
2828
3402
  }
2829
3403
 
2830
3404
  async function loadFileIntoImage(file, image) {
@@ -2865,6 +3439,7 @@ class ActionTextAttachmentUploadNode extends ActionTextAttachmentNode {
2865
3439
  const { file, uploadUrl, blobUrlTemplate, progress, width, height, uploadError } = node;
2866
3440
  super({ ...node, contentType: file.type }, key);
2867
3441
  this.file = file;
3442
+ this.fileName = file.name;
2868
3443
  this.uploadUrl = uploadUrl;
2869
3444
  this.blobUrlTemplate = blobUrlTemplate;
2870
3445
  this.progress = progress ?? null;
@@ -2955,7 +3530,7 @@ class ActionTextAttachmentUploadNode extends ActionTextAttachmentNode {
2955
3530
  #createCaption() {
2956
3531
  const figcaption = createElement("figcaption", { className: "attachment__caption" });
2957
3532
 
2958
- const nameSpan = createElement("span", { className: "attachment__name", textContent: this.file.name || "" });
3533
+ const nameSpan = createElement("span", { className: "attachment__name", textContent: this.caption || this.file.name || "" });
2959
3534
  const sizeSpan = createElement("span", { className: "attachment__size", textContent: bytesToHumanSize(this.file.size) });
2960
3535
  figcaption.appendChild(nameSpan);
2961
3536
  figcaption.appendChild(sizeSpan);
@@ -2974,7 +3549,7 @@ class ActionTextAttachmentUploadNode extends ActionTextAttachmentNode {
2974
3549
  const writable = this.getWritable();
2975
3550
  writable.width = width;
2976
3551
  writable.height = height;
2977
- }, { tag: SILENT_UPDATE_TAGS });
3552
+ }, { tag: this.#backgroundUpdateTags });
2978
3553
  }
2979
3554
 
2980
3555
  get #hasDimensions() {
@@ -3033,20 +3608,47 @@ class ActionTextAttachmentUploadNode extends ActionTextAttachmentNode {
3033
3608
  #setProgress(progress) {
3034
3609
  this.editor.update(() => {
3035
3610
  this.getWritable().progress = progress;
3036
- }, { tag: SILENT_UPDATE_TAGS });
3611
+ }, { tag: this.#backgroundUpdateTags });
3037
3612
  }
3038
3613
 
3039
3614
  #handleUploadError(error) {
3040
3615
  console.warn(`Upload error for ${this.file?.name ?? "file"}: ${error}`);
3041
3616
  this.editor.update(() => {
3042
3617
  this.getWritable().uploadError = true;
3043
- }, { tag: SILENT_UPDATE_TAGS });
3618
+ }, { tag: this.#backgroundUpdateTags });
3044
3619
  }
3045
3620
 
3046
3621
  #showUploadedAttachment(blob) {
3622
+ const editorHasFocus = this.#editorHasFocus;
3623
+
3047
3624
  this.editor.update(() => {
3048
- this.replace(this.#toActionTextAttachmentNodeWith(blob));
3049
- }, { tag: SILENT_UPDATE_TAGS });
3625
+ const shouldTransferNodeSelection = editorHasFocus && this.isSelected();
3626
+
3627
+ const replacementNode = this.#toActionTextAttachmentNodeWith(blob);
3628
+ this.replace(replacementNode);
3629
+
3630
+ if (shouldTransferNodeSelection) {
3631
+ const nodeSelection = $createNodeSelectionWith(replacementNode);
3632
+ $setSelection(nodeSelection);
3633
+ }
3634
+ }, { tag: this.#backgroundUpdateTags });
3635
+ }
3636
+
3637
+ // Upload lifecycle methods (progress, completion, errors) run asynchronously and may
3638
+ // fire while the user is focused on another element (e.g., a title field). Without
3639
+ // SKIP_DOM_SELECTION_TAG, Lexical's reconciler would move the DOM selection back into
3640
+ // the editor, stealing focus from wherever the user is currently typing.
3641
+ get #backgroundUpdateTags() {
3642
+ if (this.#editorHasFocus) {
3643
+ return SILENT_UPDATE_TAGS
3644
+ } else {
3645
+ return [ ...SILENT_UPDATE_TAGS, SKIP_DOM_SELECTION_TAG ]
3646
+ }
3647
+ }
3648
+
3649
+ get #editorHasFocus() {
3650
+ const rootElement = this.editor.getRootElement();
3651
+ return rootElement !== null && rootElement.contains(document.activeElement)
3050
3652
  }
3051
3653
 
3052
3654
  #toActionTextAttachmentNodeWith(blob) {
@@ -3119,14 +3721,12 @@ class ImageGalleryNode extends ElementNode {
3119
3721
  static importDOM() {
3120
3722
  return {
3121
3723
  div: (element) => {
3122
- const containsAttachment = element.querySelector(`:scope > :is(${this.#attachmentTags.join()})`);
3123
- if (!containsAttachment) return null
3724
+ if (!this.#isGalleryElement(element)) return null
3124
3725
 
3125
3726
  return {
3126
3727
  conversion: () => {
3127
3728
  return {
3128
- node: $createImageGalleryNode(),
3129
- after: children => $descendantsMatching(children, this.isValidChild)
3729
+ node: $createImageGalleryNode()
3130
3730
  }
3131
3731
  },
3132
3732
  priority: 2
@@ -3143,6 +3743,13 @@ class ImageGalleryNode extends ElementNode {
3143
3743
  return $isActionTextAttachmentNode(node) && node.isPreviewableImage
3144
3744
  }
3145
3745
 
3746
+ static #isGalleryElement(element) {
3747
+ const attachmentChildren = element.querySelectorAll(`:scope > :is(${this.#attachmentTags.join()})`);
3748
+ return element.textContent.trim() === ""
3749
+ && attachmentChildren.length > 0
3750
+ && element.children.length === attachmentChildren.length
3751
+ }
3752
+
3146
3753
  static get #attachmentTags() {
3147
3754
  return Object.keys(ActionTextAttachmentNode.importDOM())
3148
3755
  }
@@ -3403,6 +4010,8 @@ class Contents {
3403
4010
  }
3404
4011
 
3405
4012
  insertDOM(doc, { tag } = {}) {
4013
+ this.#unwrapPlaceholderAnchors(doc);
4014
+
3406
4015
  this.editor.update(() => {
3407
4016
  const selection = $getSelection();
3408
4017
  if (!$isRangeSelection(selection)) return
@@ -3415,10 +4024,16 @@ class Contents {
3415
4024
  }
3416
4025
 
3417
4026
  insertAtCursor(node) {
3418
- const selection = $getSelection() ?? $getRoot().selectEnd();
4027
+ let selection = $getSelection() ?? $getRoot().selectEnd();
3419
4028
  const selectedNodes = selection?.getNodes();
3420
4029
 
3421
4030
  if ($isRangeSelection(selection)) {
4031
+ const anchorNode = selection.anchor.getNode();
4032
+ if ($isShadowRoot(anchorNode)) {
4033
+ const paragraph = $createParagraphNode();
4034
+ anchorNode.append(paragraph);
4035
+ selection = paragraph.selectStart();
4036
+ }
3422
4037
  selection.insertNodes([ node ]);
3423
4038
  } else if ($isNodeSelection(selection) && selectedNodes.length > 0) {
3424
4039
  // Overrides Lexical's default behavior of _removing_ the currently selected nodes
@@ -3426,7 +4041,7 @@ class Contents {
3426
4041
  const lastNode = selectedNodes.at(-1);
3427
4042
  lastNode.insertAfter(node);
3428
4043
  }
3429
- }
4044
+ }
3430
4045
 
3431
4046
  insertAtCursorEnsuringLineBelow(node) {
3432
4047
  this.insertAtCursor(node);
@@ -3507,6 +4122,41 @@ class Contents {
3507
4122
  return result
3508
4123
  }
3509
4124
 
4125
+ wrapSelectedSoftBreakLines(newNodeFn) {
4126
+ let paragraphKey = null;
4127
+ let selectedLineRange = null;
4128
+
4129
+ this.editor.getEditorState().read(() => {
4130
+ const selection = $getSelection();
4131
+ if (!$isRangeSelection(selection) || selection.isCollapsed()) return
4132
+
4133
+ const paragraph = this.#getSelectedParagraphWithSoftLineBreaks(selection);
4134
+ if (!paragraph) return
4135
+
4136
+ const lines = this.#splitParagraphIntoLines(paragraph);
4137
+ selectedLineRange = this.#getSelectedLineRange(lines, selection);
4138
+
4139
+ if (!selectedLineRange) return
4140
+
4141
+ const { start, end } = selectedLineRange;
4142
+ if (start === 0 && end === lines.length - 1) return
4143
+
4144
+ paragraphKey = paragraph.getKey();
4145
+ });
4146
+
4147
+ if (!paragraphKey || !selectedLineRange) return false
4148
+
4149
+ this.editor.update(() => {
4150
+ const paragraph = $getNodeByKey(paragraphKey);
4151
+ if (!paragraph || !$isParagraphNode(paragraph)) return
4152
+
4153
+ const lines = this.#splitParagraphIntoLines(paragraph);
4154
+ this.#replaceParagraphWithWrappedSelectedLines(paragraph, lines, selectedLineRange, newNodeFn);
4155
+ });
4156
+
4157
+ return true
4158
+ }
4159
+
3510
4160
  unwrapSelectedListItems() {
3511
4161
  this.editor.update(() => {
3512
4162
  const selection = $getSelection();
@@ -3600,15 +4250,14 @@ class Contents {
3600
4250
  replaceTextBackUntil(stringToReplace, replacementNodes) {
3601
4251
  replacementNodes = Array.isArray(replacementNodes) ? replacementNodes : [ replacementNodes ];
3602
4252
 
3603
- this.editor.update(() => {
3604
- const { anchorNode, offset } = this.#getTextAnchorData();
3605
- if (!anchorNode) return
4253
+ const selection = $getSelection();
4254
+ const { anchorNode, offset } = this.#getTextAnchorData();
4255
+ if (!anchorNode) return
3606
4256
 
3607
- const lastIndex = this.#findLastIndexBeforeCursor(anchorNode, offset, stringToReplace);
3608
- if (lastIndex === -1) return
4257
+ const lastIndex = this.#findLastIndexBeforeCursor(anchorNode, offset, stringToReplace);
4258
+ if (lastIndex === -1) return
3609
4259
 
3610
- this.#performTextReplacement(anchorNode, offset, lastIndex, replacementNodes);
3611
- });
4260
+ this.#performTextReplacement(anchorNode, selection, offset, lastIndex, replacementNodes);
3612
4261
  }
3613
4262
 
3614
4263
  createParagraphAfterNode(node, text) {
@@ -3649,6 +4298,7 @@ class Contents {
3649
4298
  if (selectLast && uploader.nodes?.length) {
3650
4299
  const lastNode = uploader.nodes.at(-1);
3651
4300
  lastNode.selectEnd();
4301
+ this.#normalizeSelectionInShadowRoot();
3652
4302
  }
3653
4303
  });
3654
4304
  }
@@ -3731,7 +4381,20 @@ class Contents {
3731
4381
  node.remove();
3732
4382
  }
3733
4383
 
3734
- #insertNodeWrappingAllSelectedNodes(newNodeFn) {
4384
+ // Anchors with non-meaningful hrefs (e.g. "#", "") appear in content copied
4385
+ // from rendered views where mentions and interactive elements are wrapped in
4386
+ // <a href="#"> tags. Unwrap them so their text content pastes as plain text
4387
+ // and real links are preserved.
4388
+ #unwrapPlaceholderAnchors(doc) {
4389
+ for (const anchor of doc.querySelectorAll("a")) {
4390
+ const href = anchor.getAttribute("href") || "";
4391
+ if (href === "" || href === "#") {
4392
+ anchor.replaceWith(...anchor.childNodes);
4393
+ }
4394
+ }
4395
+ }
4396
+
4397
+ #insertNodeWrappingAllSelectedNodes(newNodeFn) {
3735
4398
  this.editor.update(() => {
3736
4399
  const selection = $getSelection();
3737
4400
  if (!$isRangeSelection(selection)) return
@@ -3896,6 +4559,101 @@ class Contents {
3896
4559
  nodesToDelete.forEach((node) => node.remove());
3897
4560
  }
3898
4561
 
4562
+ #getSelectedParagraphWithSoftLineBreaks(selection) {
4563
+ const anchorParagraph = this.#getParagraphFromNode(selection.anchor.getNode());
4564
+ const focusParagraph = this.#getParagraphFromNode(selection.focus.getNode());
4565
+
4566
+ if (!anchorParagraph || anchorParagraph !== focusParagraph) return null
4567
+ if ($isQuoteNode(anchorParagraph.getParent())) return null
4568
+
4569
+ return this.#paragraphHasSoftLineBreaks(anchorParagraph) ? anchorParagraph : null
4570
+ }
4571
+
4572
+ #paragraphHasSoftLineBreaks(paragraph) {
4573
+ return paragraph.getChildren().some((child) => $isLineBreakNode(child))
4574
+ }
4575
+
4576
+ #splitParagraphIntoLines(paragraph) {
4577
+ const lines = [ [] ];
4578
+
4579
+ paragraph.getChildren().forEach((child) => {
4580
+ if ($isLineBreakNode(child)) {
4581
+ lines.push([]);
4582
+ } else {
4583
+ lines[lines.length - 1].push(child);
4584
+ }
4585
+ });
4586
+
4587
+ return lines
4588
+ }
4589
+
4590
+ #getSelectedLineRange(lines, selection) {
4591
+ const selectedNodeKeys = new Set(
4592
+ selection.getNodes().map((node) => node.getKey())
4593
+ );
4594
+
4595
+ selectedNodeKeys.add(selection.anchor.getNode().getKey());
4596
+ selectedNodeKeys.add(selection.focus.getNode().getKey());
4597
+
4598
+ const selectedLineIndexes = lines
4599
+ .map((lineNodes, index) => {
4600
+ return lineNodes.some((node) => selectedNodeKeys.has(node.getKey())) ? index : null
4601
+ })
4602
+ .filter((index) => index !== null);
4603
+
4604
+ if (selectedLineIndexes.length === 0) return null
4605
+
4606
+ return {
4607
+ start: selectedLineIndexes[0],
4608
+ end: selectedLineIndexes[selectedLineIndexes.length - 1]
4609
+ }
4610
+ }
4611
+
4612
+ #replaceParagraphWithWrappedSelectedLines(paragraph, lines, { start, end }, newNodeFn) {
4613
+ const insertedNodes = [];
4614
+
4615
+ this.#appendParagraphsForLines(insertedNodes, lines.slice(0, start));
4616
+
4617
+ const wrappingNode = newNodeFn();
4618
+ lines.slice(start, end + 1).forEach((lineNodes) => {
4619
+ wrappingNode.append(this.#createParagraphFromLine(lineNodes));
4620
+ });
4621
+ insertedNodes.push(wrappingNode);
4622
+
4623
+ this.#appendParagraphsForLines(insertedNodes, lines.slice(end + 1));
4624
+
4625
+ let previousNode = null;
4626
+ insertedNodes.forEach((node) => {
4627
+ if (previousNode) {
4628
+ previousNode.insertAfter(node);
4629
+ } else {
4630
+ paragraph.insertBefore(node);
4631
+ }
4632
+
4633
+ previousNode = node;
4634
+ });
4635
+
4636
+ paragraph.remove();
4637
+ }
4638
+
4639
+ #appendParagraphsForLines(insertedNodes, lines) {
4640
+ lines.forEach((lineNodes) => {
4641
+ insertedNodes.push(this.#createParagraphFromLine(lineNodes));
4642
+ });
4643
+ }
4644
+
4645
+ #createParagraphFromLine(lineNodes) {
4646
+ const paragraph = $createParagraphNode();
4647
+
4648
+ if (lineNodes.length === 0) {
4649
+ paragraph.append($createLineBreakNode());
4650
+ } else {
4651
+ paragraph.append(...lineNodes);
4652
+ }
4653
+
4654
+ return paragraph
4655
+ }
4656
+
3899
4657
  #collectSelectedListItems(selection) {
3900
4658
  const nodes = selection.getNodes();
3901
4659
  const listItems = new Set();
@@ -4010,13 +4768,14 @@ class Contents {
4010
4768
  return textBeforeCursor.lastIndexOf(stringToReplace)
4011
4769
  }
4012
4770
 
4013
- #performTextReplacement(anchorNode, offset, lastIndex, replacementNodes) {
4771
+ #performTextReplacement(anchorNode, selection, offset, lastIndex, replacementNodes) {
4014
4772
  const fullText = anchorNode.getTextContent();
4015
4773
  const textBeforeString = fullText.slice(0, lastIndex);
4016
4774
  const textAfterCursor = fullText.slice(offset);
4017
4775
 
4018
- const textNodeBefore = $createTextNode(textBeforeString);
4019
- const textNodeAfter = $createTextNode(textAfterCursor || " ");
4776
+ const trailingSpacer = this.#hasInlineDecoratorNode(replacementNodes) ? "\u2060" : " ";
4777
+ const textNodeBefore = this.#cloneTextNodeFormatting(anchorNode, selection, textBeforeString);
4778
+ const textNodeAfter = this.#cloneTextNodeFormatting(anchorNode, selection, textAfterCursor || trailingSpacer);
4020
4779
 
4021
4780
  anchorNode.replace(textNodeBefore);
4022
4781
 
@@ -4028,6 +4787,24 @@ class Contents {
4028
4787
  textNodeAfter.select(cursorOffset, cursorOffset);
4029
4788
  }
4030
4789
 
4790
+ #hasInlineDecoratorNode(nodes) {
4791
+ return nodes.some(node => node instanceof CustomActionTextAttachmentNode && node.isInline())
4792
+ }
4793
+
4794
+ #cloneTextNodeFormatting(anchorNode, selection, text) {
4795
+ const parent = anchorNode.getParent();
4796
+ const fallbackFormat = parent?.getTextFormat?.() || 0;
4797
+ const fallbackStyle = parent?.getTextStyle?.() || "";
4798
+ const format = $isRangeSelection(selection) && selection.format ? selection.format : (anchorNode.getFormat() || fallbackFormat);
4799
+ const style = $isRangeSelection(selection) && selection.style ? selection.style : (anchorNode.getStyle() || fallbackStyle);
4800
+
4801
+ return $createTextNode(text)
4802
+ .setFormat(format)
4803
+ .setDetail(anchorNode.getDetail())
4804
+ .setMode(anchorNode.getMode())
4805
+ .setStyle(style)
4806
+ }
4807
+
4031
4808
  #insertReplacementNodes(startNode, replacementNodes) {
4032
4809
  let previousNode = startNode;
4033
4810
  for (const node of replacementNodes) {
@@ -4067,6 +4844,29 @@ class Contents {
4067
4844
  #shouldUploadFile(file) {
4068
4845
  return dispatch(this.editorElement, "lexxy:file-accept", { file }, true)
4069
4846
  }
4847
+
4848
+ // When the selection anchor is on a shadow root (e.g. a table cell), Lexical's
4849
+ // insertNodes can't find a block parent and fails silently. Normalize the
4850
+ // selection to point inside the shadow root's content instead.
4851
+ #normalizeSelectionInShadowRoot() {
4852
+ const selection = $getSelection();
4853
+ if (!$isRangeSelection(selection)) return
4854
+
4855
+ const anchorNode = selection.anchor.getNode();
4856
+ if (!$isShadowRoot(anchorNode)) return
4857
+
4858
+ // Append a paragraph inside the shadow root so there's a valid text-level
4859
+ // target for subsequent insertions. This is necessary because decorator
4860
+ // nodes (e.g. attachments) at the end of a table cell leave the selection
4861
+ // on the cell itself with no block-level descendant to anchor to.
4862
+ const paragraph = $createParagraphNode();
4863
+ anchorNode.append(paragraph);
4864
+ paragraph.selectStart();
4865
+ }
4866
+ }
4867
+
4868
+ function $isShadowRoot(node) {
4869
+ return $isElementNode(node) && $isRootOrShadowRoot(node) && !$isRootNode(node)
4070
4870
  }
4071
4871
 
4072
4872
  class Clipboard {
@@ -4155,7 +4955,7 @@ class Clipboard {
4155
4955
  }
4156
4956
 
4157
4957
  #pasteMarkdown(text) {
4158
- const html = marked(text);
4958
+ const html = marked(text, { breaks: true });
4159
4959
  const doc = parseHtml(html);
4160
4960
  const detail = Object.freeze({
4161
4961
  markdown: text,
@@ -4178,19 +4978,38 @@ class Clipboard {
4178
4978
  if (!this.editorElement.supportsAttachments) return false
4179
4979
 
4180
4980
  const html = clipboardData.getData("text/html");
4981
+ const files = clipboardData.files;
4982
+
4983
+ if (files.length && this.#isCopiedImageHTML(html)) {
4984
+ this.#uploadFilesPreservingScroll(files);
4985
+ return true
4986
+ }
4987
+
4181
4988
  if (html) {
4182
4989
  this.contents.insertHtml(html, { tag: PASTE_TAG });
4183
4990
  return true
4184
4991
  }
4185
4992
 
4993
+ this.#uploadFilesPreservingScroll(files);
4994
+
4995
+ return true
4996
+ }
4997
+
4998
+ #isCopiedImageHTML(html) {
4999
+ if (!html) return false
5000
+
5001
+ const doc = parseHtml(html);
5002
+ const elementChildren = Array.from(doc.body.children);
5003
+
5004
+ return elementChildren.length === 1 && elementChildren[0].tagName === "IMG"
5005
+ }
5006
+
5007
+ #uploadFilesPreservingScroll(files) {
4186
5008
  this.#preservingScrollPosition(() => {
4187
- const files = clipboardData.files;
4188
5009
  if (files.length) {
4189
5010
  this.contents.uploadFiles(files, { selectLast: true });
4190
5011
  }
4191
5012
  });
4192
-
4193
- return true
4194
5013
  }
4195
5014
 
4196
5015
  // Deals with an issue in Safari where it scrolls to the tops after pasting attachments
@@ -4246,6 +5065,60 @@ class Extensions {
4246
5065
  }
4247
5066
  }
4248
5067
 
5068
+ // Custom TextNode exportDOM that avoids redundant bold/italic wrapping.
5069
+ //
5070
+ // Lexical's built-in TextNode.exportDOM() calls createDOM() which produces semantic tags
5071
+ // like <strong> for bold and <em> for italic, then unconditionally wraps the result
5072
+ // with presentational tags (<b>, <i>) for the same formats. This produces redundant markup
5073
+ // like <b><strong>text</strong></b>.
5074
+ //
5075
+ // This custom export skips <b> when <strong> is already present and <i> when <em> is
5076
+ // already present, while preserving <s> and <u> wrappers which have no semantic equivalents
5077
+ // in createDOM's output.
5078
+
5079
+ function exportTextNodeDOM(editor, textNode) {
5080
+ const element = textNode.createDOM(editor._config, editor);
5081
+ element.style.whiteSpace = "pre-wrap";
5082
+
5083
+ if (textNode.hasFormat("lowercase")) {
5084
+ element.style.textTransform = "lowercase";
5085
+ } else if (textNode.hasFormat("uppercase")) {
5086
+ element.style.textTransform = "uppercase";
5087
+ } else if (textNode.hasFormat("capitalize")) {
5088
+ element.style.textTransform = "capitalize";
5089
+ }
5090
+
5091
+ let result = element;
5092
+
5093
+ if (textNode.hasFormat("bold") && !containsTag(element, "strong")) {
5094
+ result = wrapWith(result, "b");
5095
+ }
5096
+ if (textNode.hasFormat("italic") && !containsTag(element, "em")) {
5097
+ result = wrapWith(result, "i");
5098
+ }
5099
+ if (textNode.hasFormat("strikethrough")) {
5100
+ result = wrapWith(result, "s");
5101
+ }
5102
+ if (textNode.hasFormat("underline")) {
5103
+ result = wrapWith(result, "u");
5104
+ }
5105
+
5106
+ return { element: result }
5107
+ }
5108
+
5109
+ function containsTag(element, tagName) {
5110
+ const upperTag = tagName.toUpperCase();
5111
+ if (element.tagName === upperTag) return true
5112
+
5113
+ return element.querySelector(tagName) !== null
5114
+ }
5115
+
5116
+ function wrapWith(element, tag) {
5117
+ const wrapper = document.createElement(tag);
5118
+ wrapper.appendChild(element);
5119
+ return wrapper
5120
+ }
5121
+
4249
5122
  class ProvisionalParagraphNode extends ParagraphNode {
4250
5123
  $config() {
4251
5124
  return this.config("provisonal_paragraph", {
@@ -4444,6 +5317,14 @@ class WrappedTableNode extends TableNode {
4444
5317
  return super.importDOM()
4445
5318
  }
4446
5319
 
5320
+ canInsertTextBefore() {
5321
+ return false
5322
+ }
5323
+
5324
+ canInsertTextAfter() {
5325
+ return false
5326
+ }
5327
+
4447
5328
  exportDOM(editor) {
4448
5329
  const superExport = super.exportDOM(editor);
4449
5330
 
@@ -4564,6 +5445,365 @@ class TablesExtension extends LexxyExtension {
4564
5445
  }
4565
5446
  }
4566
5447
 
5448
+ const MIME_TYPE = "application/x-lexxy-node-key";
5449
+
5450
+ class AttachmentDragAndDrop {
5451
+ #editor
5452
+ #draggedNodeKey = null
5453
+ #rafId = null
5454
+ #draggingRafId = null
5455
+ #cleanupFns = []
5456
+
5457
+ constructor(editor) {
5458
+ this.#editor = editor;
5459
+
5460
+ // Register Lexical commands at HIGH priority to intercept before the
5461
+ // base @lexical/rich-text handlers (which return true and consume the events).
5462
+ this.#cleanupFns.push(
5463
+ editor.registerCommand(DRAGSTART_COMMAND, (event) => this.#handleDragStart(event), COMMAND_PRIORITY_HIGH),
5464
+ editor.registerCommand(DROP_COMMAND, (event) => this.#handleDrop(event), COMMAND_PRIORITY_HIGH),
5465
+ );
5466
+
5467
+ // Use a root listener to register DOM-level dragover/dragend handlers
5468
+ // (these events need throttled rAF handling that works better as DOM listeners).
5469
+ const unregister = editor.registerRootListener((root, prevRoot) => {
5470
+ if (prevRoot) {
5471
+ prevRoot.removeEventListener("dragover", this.#onDragOver);
5472
+ prevRoot.removeEventListener("dragend", this.#onDragEnd);
5473
+ }
5474
+ if (root) {
5475
+ root.addEventListener("dragover", this.#onDragOver);
5476
+ root.addEventListener("dragend", this.#onDragEnd);
5477
+ }
5478
+ });
5479
+ this.#cleanupFns.push(unregister);
5480
+ }
5481
+
5482
+ destroy() {
5483
+ this.#cleanup();
5484
+ for (const fn of this.#cleanupFns) fn();
5485
+ this.#cleanupFns = [];
5486
+ }
5487
+
5488
+ // -- Event handlers --------------------------------------------------------
5489
+
5490
+ #handleDragStart(event) {
5491
+ if (event.target.closest("textarea")) return false
5492
+
5493
+ const figure = event.target.closest("figure.attachment[data-lexical-node-key]");
5494
+ if (!figure) return false
5495
+
5496
+ this.#draggedNodeKey = figure.dataset.lexicalNodeKey;
5497
+ event.dataTransfer.setData(MIME_TYPE, this.#draggedNodeKey);
5498
+ event.dataTransfer.effectAllowed = "move";
5499
+
5500
+ // Add dragging class after a tick so it doesn't affect the drag image
5501
+ this.#draggingRafId = requestAnimationFrame(() => {
5502
+ this.#draggingRafId = null;
5503
+ figure.classList.add("lexxy-dragging");
5504
+ });
5505
+
5506
+ return true
5507
+ }
5508
+
5509
+ #onDragOver = (event) => {
5510
+ if (!this.#draggedNodeKey) return
5511
+
5512
+ event.preventDefault();
5513
+ event.dataTransfer.dropEffect = "move";
5514
+
5515
+ if (!this.#rafId) {
5516
+ this.#rafId = requestAnimationFrame(() => {
5517
+ this.#rafId = null;
5518
+ this.#updateDropTarget(event);
5519
+ });
5520
+ }
5521
+ }
5522
+
5523
+ #handleDrop(event) {
5524
+ if (!this.#draggedNodeKey) return false
5525
+
5526
+ event.preventDefault();
5527
+
5528
+ const target = this.#resolveDropTarget(event);
5529
+ const draggedKey = this.#draggedNodeKey;
5530
+ this.#cleanup();
5531
+
5532
+ if (target) {
5533
+ this.#performDrop(draggedKey, target);
5534
+ }
5535
+ return true
5536
+ }
5537
+
5538
+ #onDragEnd = () => {
5539
+ this.#cleanup();
5540
+ }
5541
+
5542
+ // -- Drop target resolution -----------------------------------------------
5543
+
5544
+ #updateDropTarget(event) {
5545
+ this.#clearDropIndicators();
5546
+
5547
+ const target = this.#resolveDropTarget(event);
5548
+ if (!target) return
5549
+
5550
+ if (target.type === "gallery" || target.type === "gallery-reorder") {
5551
+ target.element.classList.add(`lexxy-drop-target--gallery-${target.position}`);
5552
+ } else if (target.type === "list-item") {
5553
+ target.element.classList.add(`lexxy-drop-target--list-${target.position}`);
5554
+ } else {
5555
+ target.element.classList.add(`lexxy-drop-target--block-${target.position}`);
5556
+ }
5557
+ }
5558
+
5559
+ #resolveDropTarget(event) {
5560
+ const element = document.elementFromPoint(event.clientX, event.clientY);
5561
+ if (!element) return null
5562
+
5563
+ const rootElement = this.#editor.getRootElement();
5564
+ if (!rootElement || !rootElement.contains(element)) return null
5565
+
5566
+ // Check if hovering over a previewable image (for gallery merge or reorder)
5567
+ const targetFigure = element.closest("figure.attachment--preview[data-lexical-node-key]");
5568
+ if (targetFigure && targetFigure.dataset.lexicalNodeKey !== this.#draggedNodeKey) {
5569
+ const targetGallery = targetFigure.closest(".attachment-gallery");
5570
+ if (targetGallery) {
5571
+ // If the dragged image is in the same gallery, this is a reorder
5572
+ const draggedFigure = rootElement.querySelector(`[data-lexical-node-key="${this.#draggedNodeKey}"]`);
5573
+ if (draggedFigure && targetGallery.contains(draggedFigure)) {
5574
+ const position = this.#computeHorizontalPosition(targetFigure, event.clientX);
5575
+ return { type: "gallery-reorder", element: targetFigure, nodeKey: targetFigure.dataset.lexicalNodeKey, position }
5576
+ }
5577
+ }
5578
+ const position = this.#computeHorizontalPosition(targetFigure, event.clientX);
5579
+ return { type: "gallery", element: targetFigure, nodeKey: targetFigure.dataset.lexicalNodeKey, position }
5580
+ }
5581
+
5582
+ // Hovering over the dragged image itself inside a gallery — treat as no-op
5583
+ // to prevent fallthrough to the block handler, which would eject it from the gallery.
5584
+ if (targetFigure && targetFigure.closest(".attachment-gallery")) return null
5585
+
5586
+ // Check if hovering over a gallery's empty space (for reorder within gallery)
5587
+ const targetGallery = element.closest(".attachment-gallery");
5588
+ if (targetGallery) {
5589
+ let galleryFigure = element.closest("figure.attachment[data-lexical-node-key]");
5590
+ if (!galleryFigure) {
5591
+ galleryFigure = this.#findNearestFigureInGallery(targetGallery, event.clientX);
5592
+ }
5593
+ if (galleryFigure && galleryFigure.dataset.lexicalNodeKey !== this.#draggedNodeKey) {
5594
+ const position = this.#computeHorizontalPosition(galleryFigure, event.clientX);
5595
+ return { type: "gallery-reorder", element: galleryFigure, nodeKey: galleryFigure.dataset.lexicalNodeKey, position }
5596
+ }
5597
+ // Nearest figure is the dragged image — no-op to avoid block handler fallthrough
5598
+ if (galleryFigure) return null
5599
+ }
5600
+
5601
+ // Check if hovering over a list item (for list splitting)
5602
+ const listItem = element.closest("li");
5603
+ if (listItem && rootElement.contains(listItem)) {
5604
+ const position = this.#computeVerticalPosition(listItem, event.clientY);
5605
+ return { type: "list-item", element: listItem, position }
5606
+ }
5607
+
5608
+ // Otherwise, find nearest block-level element for between-block insertion.
5609
+ // Normalize so each gap has exactly one indicator: prefer "after" on the
5610
+ // previous sibling, falling back to "before" only for the first block.
5611
+ const block = this.#findNearestBlock(element, rootElement, event.clientY);
5612
+ if (!block) return null
5613
+
5614
+ const position = this.#computeVerticalPosition(block, event.clientY);
5615
+ if (position === "before" && block.previousElementSibling) {
5616
+ return { type: "block", element: block.previousElementSibling, position: "after" }
5617
+ }
5618
+ return { type: "block", element: block, position }
5619
+ }
5620
+
5621
+ #findNearestBlock(element, rootElement, clientY) {
5622
+ let current = element;
5623
+ while (current && current !== rootElement) {
5624
+ if (current.parentElement === rootElement) return current
5625
+ current = current.parentElement;
5626
+ }
5627
+
5628
+ // elementFromPoint landed on the root itself (e.g. a margin gap between
5629
+ // blocks). Fall back to the nearest child by vertical distance.
5630
+ let nearest = null;
5631
+ let minDistance = Infinity;
5632
+ for (const child of rootElement.children) {
5633
+ const rect = child.getBoundingClientRect();
5634
+ const distance = Math.min(Math.abs(clientY - rect.top), Math.abs(clientY - rect.bottom));
5635
+ if (distance < minDistance) {
5636
+ minDistance = distance;
5637
+ nearest = child;
5638
+ }
5639
+ }
5640
+ return nearest
5641
+ }
5642
+
5643
+ #computeVerticalPosition(element, clientY) {
5644
+ const rect = element.getBoundingClientRect();
5645
+ return clientY < rect.top + rect.height / 2 ? "before" : "after"
5646
+ }
5647
+
5648
+ #computeHorizontalPosition(element, clientX) {
5649
+ const rect = element.getBoundingClientRect();
5650
+ return clientX < rect.left + rect.width / 2 ? "before" : "after"
5651
+ }
5652
+
5653
+ #findNearestFigureInGallery(gallery, clientX) {
5654
+ const figures = gallery.querySelectorAll("figure.attachment[data-lexical-node-key]");
5655
+ let nearest = null;
5656
+ let minDistance = Infinity;
5657
+ for (const figure of figures) {
5658
+ const rect = figure.getBoundingClientRect();
5659
+ const center = rect.left + rect.width / 2;
5660
+ const distance = Math.abs(clientX - center);
5661
+ if (distance < minDistance) {
5662
+ minDistance = distance;
5663
+ nearest = figure;
5664
+ }
5665
+ }
5666
+ return nearest
5667
+ }
5668
+
5669
+ // -- Drop indicator --------------------------------------------------------
5670
+
5671
+ static #DROP_CLASSES = [
5672
+ "lexxy-drop-target--gallery-before", "lexxy-drop-target--gallery-after",
5673
+ "lexxy-drop-target--list-before", "lexxy-drop-target--list-after",
5674
+ "lexxy-drop-target--block-before", "lexxy-drop-target--block-after",
5675
+ ]
5676
+
5677
+ #clearDropIndicators() {
5678
+ const rootElement = this.#editor.getRootElement();
5679
+ if (!rootElement) return
5680
+
5681
+ for (const el of rootElement.querySelectorAll("[class*='lexxy-drop-target--']")) {
5682
+ el.classList.remove(...AttachmentDragAndDrop.#DROP_CLASSES);
5683
+ }
5684
+ }
5685
+
5686
+ // -- Node operations -------------------------------------------------------
5687
+
5688
+ #performDrop(draggedKey, target) {
5689
+ const draggedNode = $getNodeByKey(draggedKey);
5690
+ if (!draggedNode || !$isActionTextAttachmentNode(draggedNode)) return
5691
+
5692
+ if (target.type === "gallery") {
5693
+ this.#dropOntoImage(draggedNode, target.nodeKey, target.position);
5694
+ } else if (target.type === "gallery-reorder") {
5695
+ this.#reorderInGallery(draggedNode, target.nodeKey, target.position);
5696
+ } else if (target.type === "list-item") {
5697
+ this.#dropIntoList(draggedNode, target);
5698
+ } else {
5699
+ this.#dropBetweenBlocks(draggedNode, target);
5700
+ }
5701
+
5702
+ // Clear selection to prevent a second history entry. Lexical dispatches
5703
+ // SELECTION_CHANGE_COMMAND during commit for non-range selections, which
5704
+ // creates a separate update. Null selection avoids that dispatch entirely
5705
+ // and also prevents Firefox's follow-up selectionchange from dirtying nodes.
5706
+ $setSelection(null);
5707
+ }
5708
+
5709
+ #dropOntoImage(draggedNode, targetKey, position) {
5710
+ const targetNode = $getNodeByKey(targetKey);
5711
+ if (!targetNode || !$isActionTextAttachmentNode(targetNode)) return
5712
+ if (draggedNode.is(targetNode)) return
5713
+
5714
+ draggedNode.remove();
5715
+
5716
+ const gallery = $findOrCreateGalleryForImage(targetNode);
5717
+ if (gallery) {
5718
+ if (position === "before") {
5719
+ targetNode.insertBefore(draggedNode);
5720
+ } else {
5721
+ targetNode.insertAfter(draggedNode);
5722
+ }
5723
+ }
5724
+ }
5725
+
5726
+ #reorderInGallery(draggedNode, targetKey, position) {
5727
+ const targetNode = $getNodeByKey(targetKey);
5728
+ if (!targetNode || draggedNode.is(targetNode)) return
5729
+
5730
+ draggedNode.remove();
5731
+
5732
+ if (position === "before") {
5733
+ targetNode.insertBefore(draggedNode);
5734
+ } else {
5735
+ targetNode.insertAfter(draggedNode);
5736
+ }
5737
+ }
5738
+
5739
+ #dropIntoList(draggedNode, target) {
5740
+ const listItemNode = $getNearestNodeFromDOMNode(target.element);
5741
+ if (!listItemNode || !$isListItemNode(listItemNode)) return
5742
+
5743
+ const listNode = listItemNode.getParent();
5744
+ if (!listNode || !$isListNode(listNode)) return
5745
+
5746
+ const children = listNode.getChildren();
5747
+ const index = children.indexOf(listItemNode);
5748
+ if (index === -1) return
5749
+
5750
+ const splitIndex = target.position === "before" ? index : index + 1;
5751
+
5752
+ draggedNode.remove();
5753
+
5754
+ if (splitIndex === 0) {
5755
+ listNode.insertBefore(draggedNode);
5756
+ } else if (splitIndex >= children.length) {
5757
+ listNode.insertAfter(draggedNode);
5758
+ } else {
5759
+ const [ , listAfter ] = $splitNode(listNode, splitIndex);
5760
+ listAfter.insertBefore(draggedNode);
5761
+ }
5762
+ }
5763
+
5764
+ #dropBetweenBlocks(draggedNode, target) {
5765
+ const targetNode = $getNearestNodeFromDOMNode(target.element);
5766
+ if (!targetNode) return
5767
+
5768
+ const topLevelTarget = targetNode.getTopLevelElement?.() || targetNode;
5769
+ if (draggedNode.is(topLevelTarget)) return
5770
+
5771
+ draggedNode.remove();
5772
+
5773
+ if (target.position === "before") {
5774
+ topLevelTarget.insertBefore(draggedNode);
5775
+ } else {
5776
+ topLevelTarget.insertAfter(draggedNode);
5777
+ }
5778
+ }
5779
+
5780
+ // -- Lifecycle helpers -----------------------------------------------------
5781
+
5782
+ #cleanup() {
5783
+ this.#clearDropIndicators();
5784
+
5785
+ if (this.#draggedNodeKey) {
5786
+ const rootElement = this.#editor.getRootElement();
5787
+ if (rootElement) {
5788
+ const figure = rootElement.querySelector(`[data-lexical-node-key="${this.#draggedNodeKey}"]`);
5789
+ figure?.classList.remove("lexxy-dragging");
5790
+ }
5791
+ }
5792
+
5793
+ this.#draggedNodeKey = null;
5794
+
5795
+ if (this.#rafId) {
5796
+ cancelAnimationFrame(this.#rafId);
5797
+ this.#rafId = null;
5798
+ }
5799
+
5800
+ if (this.#draggingRafId) {
5801
+ cancelAnimationFrame(this.#draggingRafId);
5802
+ this.#draggingRafId = null;
5803
+ }
5804
+ }
5805
+ }
5806
+
4567
5807
  class AttachmentsExtension extends LexxyExtension {
4568
5808
  get enabled() {
4569
5809
  return this.editorElement.supportsAttachments
@@ -4578,14 +5818,37 @@ class AttachmentsExtension extends LexxyExtension {
4578
5818
  ImageGalleryNode
4579
5819
  ],
4580
5820
  register(editor) {
5821
+ const dragAndDrop = new AttachmentDragAndDrop(editor);
5822
+
4581
5823
  return mergeRegister(
4582
- editor.registerCommand(DELETE_CHARACTER_COMMAND, $collapseIntoGallery, COMMAND_PRIORITY_NORMAL)
5824
+ editor.registerNodeTransform(ActionTextAttachmentNode, $extractAttachmentFromParagraph),
5825
+ editor.registerCommand(DELETE_CHARACTER_COMMAND, $collapseIntoGallery, COMMAND_PRIORITY_NORMAL),
5826
+ () => dragAndDrop.destroy()
4583
5827
  )
4584
5828
  }
4585
5829
  })
4586
5830
  }
4587
5831
  }
4588
5832
 
5833
+ // Decorator nodes can be wrapped in a Paragraph Node by Lexical when contained in a <div>
5834
+ // We remove them, splitting the node as needed
5835
+ function $extractAttachmentFromParagraph(attachmentNode) {
5836
+ const parentNode = attachmentNode.getParent();
5837
+ if (!$isParagraphNode(parentNode)) return
5838
+
5839
+ if (parentNode.getChildrenSize() === 1) {
5840
+ parentNode.replace(attachmentNode);
5841
+ } else {
5842
+ const index = attachmentNode.getIndexWithinParent();
5843
+ const [ topParagraph, bottomParagraph ] = $splitNode(parentNode, index);
5844
+ topParagraph.insertAfter(attachmentNode);
5845
+
5846
+ for (const p of [ topParagraph, bottomParagraph ]) {
5847
+ if (p.isEmpty()) p.remove();
5848
+ }
5849
+ }
5850
+ }
5851
+
4589
5852
  function $collapseIntoGallery(backwards) {
4590
5853
  const anchor = $getSelection()?.anchor;
4591
5854
  if (!anchor) return false
@@ -4713,7 +5976,7 @@ class LexicalEditorElement extends HTMLElement {
4713
5976
  toString() {
4714
5977
  if (!this.cachedStringValue) {
4715
5978
  this.editor?.getEditorState().read(() => {
4716
- this.cachedStringValue = $getRoot().getTextContent();
5979
+ this.cachedStringValue = $getReadableTextContent($getRoot());
4717
5980
  });
4718
5981
  }
4719
5982
 
@@ -4830,8 +6093,17 @@ class LexicalEditorElement extends HTMLElement {
4830
6093
  const nodes = $generateNodesFromDOM(this.editor, parseHtml(`${html}`));
4831
6094
 
4832
6095
  return nodes
6096
+ .filter(this.#isNotWhitespaceOnlyNode)
4833
6097
  .map(this.#wrapTextNode)
4834
- .map(this.#unwrapDecoratorNode)
6098
+ }
6099
+
6100
+ // Whitespace-only text nodes (e.g. "\n" between block elements like <div>) and stray line break
6101
+ // nodes are formatting artifacts from the HTML source. They can't be appended to the root node
6102
+ // and have no semantic meaning, so we strip them during import.
6103
+ #isNotWhitespaceOnlyNode(node) {
6104
+ if ($isLineBreakNode(node)) return false
6105
+ if ($isTextNode(node) && node.getTextContent().trim() === "") return false
6106
+ return true
4835
6107
  }
4836
6108
 
4837
6109
  // Raw string values produce TextNodes which cannot be appended directly to the RootNode.
@@ -4844,18 +6116,6 @@ class LexicalEditorElement extends HTMLElement {
4844
6116
  return paragraph
4845
6117
  }
4846
6118
 
4847
- // Custom decorator block elements such as action-text-attachments get wrapped into <p> automatically by Lexical.
4848
- // We unwrap those.
4849
- #unwrapDecoratorNode(node) {
4850
- if ($isParagraphNode(node) && node.getChildrenSize() === 1) {
4851
- const child = node.getFirstChild();
4852
- if ($isDecoratorNode(child) && !child.isInline()) {
4853
- return child
4854
- }
4855
- }
4856
- return node
4857
- }
4858
-
4859
6119
  #initialize() {
4860
6120
  this.#synchronizeWithChanges();
4861
6121
  this.#registerComponents();
@@ -4874,7 +6134,10 @@ class LexicalEditorElement extends HTMLElement {
4874
6134
  name: "lexxy/core",
4875
6135
  namespace: "Lexxy",
4876
6136
  theme: theme,
4877
- nodes: this.#lexicalNodes
6137
+ nodes: this.#lexicalNodes,
6138
+ html: {
6139
+ export: new Map([ [ TextNode, exportTextNodeDOM ] ])
6140
+ }
4878
6141
  },
4879
6142
  ...this.extensions.lexicalExtensions
4880
6143
  );
@@ -4998,6 +6261,7 @@ class LexicalEditorElement extends HTMLElement {
4998
6261
  this.#registerCodeHiglightingComponents();
4999
6262
  if (this.supportsMarkdown) {
5000
6263
  registerMarkdownShortcuts(this.editor, TRANSFORMERS);
6264
+ registerMarkdownLeadingTagHandler(this.editor, TRANSFORMERS);
5001
6265
  }
5002
6266
  } else {
5003
6267
  registerPlainText(this.editor);
@@ -5160,6 +6424,29 @@ class LexicalEditorElement extends HTMLElement {
5160
6424
  }
5161
6425
  }
5162
6426
 
6427
+ // Like $getRoot().getTextContent() but uses readable text for custom attachment nodes
6428
+ // (e.g., mentions) instead of their single-character cursor placeholder.
6429
+ function $getReadableTextContent(node) {
6430
+ if (node instanceof CustomActionTextAttachmentNode) {
6431
+ return node.getReadableTextContent()
6432
+ }
6433
+
6434
+ if ($isElementNode(node)) {
6435
+ let text = "";
6436
+ const children = node.getChildren();
6437
+ for (let i = 0; i < children.length; i++) {
6438
+ const child = children[i];
6439
+ text += $getReadableTextContent(child);
6440
+ if ($isElementNode(child) && i !== children.length - 1 && !child.isInline()) {
6441
+ text += "\n\n";
6442
+ }
6443
+ }
6444
+ return text
6445
+ }
6446
+
6447
+ return node.getTextContent()
6448
+ }
6449
+
5163
6450
  class ToolbarDropdown extends HTMLElement {
5164
6451
  connectedCallback() {
5165
6452
  this.container = this.closest("details");
@@ -5596,6 +6883,8 @@ class LexicalPromptElement extends HTMLElement {
5596
6883
  #addTriggerListener() {
5597
6884
  const unregister = this.#editor.registerUpdateListener(({ editorState }) => {
5598
6885
  editorState.read(() => {
6886
+ if (this.#selection.isInsideCodeBlock) return
6887
+
5599
6888
  const { node, offset } = this.#selection.selectedNodeWithOffset();
5600
6889
  if (!node) return
5601
6890
 
@@ -5626,10 +6915,15 @@ class LexicalPromptElement extends HTMLElement {
5626
6915
  }
5627
6916
 
5628
6917
  #addCursorPositionListener() {
5629
- this.cursorPositionListener = this.#editor.registerUpdateListener(() => {
6918
+ this.cursorPositionListener = this.#editor.registerUpdateListener(({ editorState }) => {
5630
6919
  if (this.closed) return
5631
6920
 
5632
- this.#editor.read(() => {
6921
+ editorState.read(() => {
6922
+ if (this.#selection.isInsideCodeBlock) {
6923
+ this.#hidePopover();
6924
+ return
6925
+ }
6926
+
5633
6927
  const { node, offset } = this.#selection.selectedNodeWithOffset();
5634
6928
  if (!node) return
5635
6929
 
@@ -5752,25 +7046,34 @@ class LexicalPromptElement extends HTMLElement {
5752
7046
  const verticalOffset = contentRect.top - editorRect.top;
5753
7047
 
5754
7048
  if (!this.popoverElement.hasAttribute("data-anchored")) {
5755
- this.popoverElement.style.left = `${x}px`;
7049
+ this.#setPopoverOffsetX(x);
7050
+ this.#setPopoverOffsetY(y + verticalOffset);
5756
7051
  this.popoverElement.toggleAttribute("data-anchored", true);
5757
7052
  }
5758
7053
 
5759
- this.popoverElement.style.top = `${y + verticalOffset}px`;
5760
- this.popoverElement.style.bottom = "auto";
5761
-
5762
7054
  const popoverRect = this.popoverElement.getBoundingClientRect();
5763
- const isClippedAtBottom = popoverRect.bottom > window.innerHeight;
5764
7055
 
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";
7056
+ if (popoverRect.right > window.innerWidth) {
7057
+ this.popoverElement.toggleAttribute("data-clipped-at-right", true);
7058
+ }
7059
+
7060
+ if (popoverRect.bottom > window.innerHeight) {
7061
+ this.#setPopoverOffsetY(contentRect.height - y + fontSize);
5768
7062
  this.popoverElement.toggleAttribute("data-clipped-at-bottom", true);
5769
7063
  }
5770
7064
  }
5771
7065
 
7066
+ #setPopoverOffsetX(value) {
7067
+ this.popoverElement.style.setProperty("--lexxy-prompt-offset-x", `${value}px`);
7068
+ }
7069
+
7070
+ #setPopoverOffsetY(value) {
7071
+ this.popoverElement.style.setProperty("--lexxy-prompt-offset-y", `${value}px`);
7072
+ }
7073
+
5772
7074
  #resetPopoverPosition() {
5773
7075
  this.popoverElement.removeAttribute("data-clipped-at-bottom");
7076
+ this.popoverElement.removeAttribute("data-clipped-at-right");
5774
7077
  this.popoverElement.removeAttribute("data-anchored");
5775
7078
  }
5776
7079
 
@@ -5841,6 +7144,16 @@ class LexicalPromptElement extends HTMLElement {
5841
7144
  this.#hidePopover();
5842
7145
  this.#editorElement.focus();
5843
7146
  event.stopPropagation();
7147
+ } else if (event.key === ",") {
7148
+ event.preventDefault();
7149
+ event.stopPropagation();
7150
+ this.#optionWasSelected();
7151
+ this.#editor.update(() => {
7152
+ const selection = $getSelection();
7153
+ if ($isRangeSelection(selection)) {
7154
+ selection.insertText(",");
7155
+ }
7156
+ });
5844
7157
  }
5845
7158
  // Arrow keys are now handled via Lexical commands with HIGH priority
5846
7159
  }
@@ -6114,21 +7427,16 @@ class NodeDeleteButton extends HTMLElement {
6114
7427
  this.editor = this.editorElement.editor;
6115
7428
  this.classList.add("lexxy-floating-controls");
6116
7429
 
6117
- if (!this.deleteButton) {
7430
+ if (!this.querySelector(".lexxy-node-delete")) {
6118
7431
  this.#attachDeleteButton();
6119
7432
  }
6120
7433
  }
6121
7434
 
6122
7435
  disconnectedCallback() {
6123
- if (this.deleteButton && this.handleDeleteClick) {
6124
- this.deleteButton.removeEventListener("click", this.handleDeleteClick);
6125
- }
6126
-
6127
- this.handleDeleteClick = null;
6128
- this.deleteButton = null;
6129
7436
  this.editor = null;
6130
7437
  this.editorElement = null;
6131
7438
  }
7439
+
6132
7440
  #attachDeleteButton() {
6133
7441
  const container = createElement("div", { className: "lexxy-floating-controls__group" });
6134
7442
 
@@ -6781,8 +8089,11 @@ class TableTools extends HTMLElement {
6781
8089
  }
6782
8090
 
6783
8091
  #show() {
8092
+ this.#updateButtonsPosition();
6784
8093
  this.style.display = "flex";
6785
- this.#update();
8094
+ this.#updateRowColumnCount();
8095
+ this.#closeMoreMenu();
8096
+ this.#handleCommandButtonHover();
6786
8097
  }
6787
8098
 
6788
8099
  #hide() {