@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/README.md +7 -1
- package/dist/lexxy.esm.js +1398 -87
- package/dist/stylesheets/lexxy-content.css +33 -31
- package/dist/stylesheets/lexxy-editor.css +188 -38
- package/package.json +27 -16
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, $
|
|
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, $
|
|
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,
|
|
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, $
|
|
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
|
-
$
|
|
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.
|
|
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
|
-
|
|
2315
|
-
|
|
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
|
-
|
|
2337
|
-
|
|
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:
|
|
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:
|
|
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:
|
|
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:
|
|
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.
|
|
3049
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
3604
|
-
|
|
3605
|
-
|
|
4253
|
+
const selection = $getSelection();
|
|
4254
|
+
const { anchorNode, offset } = this.#getTextAnchorData();
|
|
4255
|
+
if (!anchorNode) return
|
|
3606
4256
|
|
|
3607
|
-
|
|
3608
|
-
|
|
4257
|
+
const lastIndex = this.#findLastIndexBeforeCursor(anchorNode, offset, stringToReplace);
|
|
4258
|
+
if (lastIndex === -1) return
|
|
3609
4259
|
|
|
3610
|
-
|
|
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
|
-
#
|
|
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
|
|
4019
|
-
const
|
|
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.
|
|
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()
|
|
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
|
-
|
|
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
|
-
|
|
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
|
|
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 (
|
|
5766
|
-
this.popoverElement.
|
|
5767
|
-
|
|
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.
|
|
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.#
|
|
8094
|
+
this.#updateRowColumnCount();
|
|
8095
|
+
this.#closeMoreMenu();
|
|
8096
|
+
this.#handleCommandButtonHover();
|
|
6786
8097
|
}
|
|
6787
8098
|
|
|
6788
8099
|
#hide() {
|