@37signals/lexxy 0.8.0-beta → 0.8.2-beta
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/README.md +7 -1
- package/dist/lexxy.esm.js +916 -59
- package/dist/stylesheets/lexxy-content.css +41 -35
- package/dist/stylesheets/lexxy-editor.css +97 -39
- 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, 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() {
|
|
@@ -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;
|
|
@@ -1377,6 +1677,7 @@ class CommandDispatcher {
|
|
|
1377
1677
|
#handleDragEnter(event) {
|
|
1378
1678
|
this.dragCounter++;
|
|
1379
1679
|
if (this.dragCounter === 1) {
|
|
1680
|
+
this.#saveSelectionBeforeDrag();
|
|
1380
1681
|
this.editor.getRootElement().classList.add("lexxy-editor--drag-over");
|
|
1381
1682
|
}
|
|
1382
1683
|
}
|
|
@@ -1384,6 +1685,7 @@ class CommandDispatcher {
|
|
|
1384
1685
|
#handleDragLeave(event) {
|
|
1385
1686
|
this.dragCounter--;
|
|
1386
1687
|
if (this.dragCounter === 0) {
|
|
1688
|
+
this.#selectionBeforeDrag = null;
|
|
1387
1689
|
this.editor.getRootElement().classList.remove("lexxy-editor--drag-over");
|
|
1388
1690
|
}
|
|
1389
1691
|
}
|
|
@@ -1404,11 +1706,28 @@ class CommandDispatcher {
|
|
|
1404
1706
|
const files = Array.from(dataTransfer.files);
|
|
1405
1707
|
if (!files.length) return
|
|
1406
1708
|
|
|
1709
|
+
this.#restoreSelectionBeforeDrag();
|
|
1407
1710
|
this.contents.uploadFiles(files, { selectLast: true });
|
|
1408
1711
|
|
|
1409
1712
|
this.editor.focus();
|
|
1410
1713
|
}
|
|
1411
1714
|
|
|
1715
|
+
#saveSelectionBeforeDrag() {
|
|
1716
|
+
this.editor.getEditorState().read(() => {
|
|
1717
|
+
this.#selectionBeforeDrag = $getSelection()?.clone();
|
|
1718
|
+
});
|
|
1719
|
+
}
|
|
1720
|
+
|
|
1721
|
+
#restoreSelectionBeforeDrag() {
|
|
1722
|
+
if (!this.#selectionBeforeDrag) return
|
|
1723
|
+
|
|
1724
|
+
this.editor.update(() => {
|
|
1725
|
+
$setSelection(this.#selectionBeforeDrag);
|
|
1726
|
+
});
|
|
1727
|
+
|
|
1728
|
+
this.#selectionBeforeDrag = null;
|
|
1729
|
+
}
|
|
1730
|
+
|
|
1412
1731
|
#handleTabKey(event) {
|
|
1413
1732
|
if (this.selection.isInsideList) {
|
|
1414
1733
|
return this.#handleTabForList(event)
|
|
@@ -1482,6 +1801,16 @@ function extractFileName(string) {
|
|
|
1482
1801
|
return string.split("/").pop()
|
|
1483
1802
|
}
|
|
1484
1803
|
|
|
1804
|
+
// Lexxy exports the content attribute as a JSON string (via JSON.stringify),
|
|
1805
|
+
// but Trix/ActionText stores it as raw HTML. Try JSON first, fall back to raw.
|
|
1806
|
+
function parseAttachmentContent(content) {
|
|
1807
|
+
try {
|
|
1808
|
+
return JSON.parse(content)
|
|
1809
|
+
} catch {
|
|
1810
|
+
return content
|
|
1811
|
+
}
|
|
1812
|
+
}
|
|
1813
|
+
|
|
1485
1814
|
class ActionTextAttachmentNode extends DecoratorNode {
|
|
1486
1815
|
static getType() {
|
|
1487
1816
|
return "action_text_attachment"
|
|
@@ -1664,9 +1993,7 @@ class ActionTextAttachmentNode extends DecoratorNode {
|
|
|
1664
1993
|
|
|
1665
1994
|
#createDOMForImage(options = {}) {
|
|
1666
1995
|
const img = createElement("img", { src: this.src, draggable: false, alt: this.altText, ...this.#imageDimensions, ...options });
|
|
1667
|
-
|
|
1668
|
-
container.appendChild(img);
|
|
1669
|
-
return container
|
|
1996
|
+
return img
|
|
1670
1997
|
}
|
|
1671
1998
|
|
|
1672
1999
|
get #imageDimensions() {
|
|
@@ -1708,6 +2035,9 @@ class ActionTextAttachmentNode extends DecoratorNode {
|
|
|
1708
2035
|
input.addEventListener("focusin", () => input.placeholder = "Add caption...");
|
|
1709
2036
|
input.addEventListener("blur", (event) => this.#handleCaptionInputBlurred(event));
|
|
1710
2037
|
input.addEventListener("keydown", (event) => this.#handleCaptionInputKeydown(event));
|
|
2038
|
+
input.addEventListener("copy", (event) => event.stopPropagation());
|
|
2039
|
+
input.addEventListener("cut", (event) => event.stopPropagation());
|
|
2040
|
+
input.addEventListener("paste", (event) => event.stopPropagation());
|
|
1711
2041
|
|
|
1712
2042
|
caption.appendChild(input);
|
|
1713
2043
|
|
|
@@ -1728,7 +2058,6 @@ class ActionTextAttachmentNode extends DecoratorNode {
|
|
|
1728
2058
|
#handleCaptionInputKeydown(event) {
|
|
1729
2059
|
if (event.key === "Enter") {
|
|
1730
2060
|
event.preventDefault();
|
|
1731
|
-
event.stopPropagation();
|
|
1732
2061
|
event.target.blur();
|
|
1733
2062
|
|
|
1734
2063
|
this.editor.update(() => {
|
|
@@ -1739,6 +2068,10 @@ class ActionTextAttachmentNode extends DecoratorNode {
|
|
|
1739
2068
|
});
|
|
1740
2069
|
}
|
|
1741
2070
|
|
|
2071
|
+
// Stop all keydown events from bubbling to the Lexical root element.
|
|
2072
|
+
// The caption textarea is outside Lexical's content model and should
|
|
2073
|
+
// handle its own keyboard events natively (Ctrl+A, Ctrl+C, Ctrl+X, etc.).
|
|
2074
|
+
event.stopPropagation();
|
|
1742
2075
|
}
|
|
1743
2076
|
}
|
|
1744
2077
|
|
|
@@ -1920,6 +2253,10 @@ class Selection {
|
|
|
1920
2253
|
}
|
|
1921
2254
|
|
|
1922
2255
|
get isTableCellSelected() {
|
|
2256
|
+
const selection = $getSelection();
|
|
2257
|
+
const { anchor, focus } = selection;
|
|
2258
|
+
if (!$isRangeSelection(selection) || anchor.key !== focus.key) return false
|
|
2259
|
+
|
|
1923
2260
|
return this.nearestNodeOfType(TableCellNode) !== null
|
|
1924
2261
|
}
|
|
1925
2262
|
|
|
@@ -1949,6 +2286,7 @@ class Selection {
|
|
|
1949
2286
|
if (!anchorNode) return null
|
|
1950
2287
|
|
|
1951
2288
|
if ($isTextNode(anchorNode)) {
|
|
2289
|
+
if (offset < anchorNode.getTextContentSize()) return null
|
|
1952
2290
|
return this.#getNextNodeFromTextEnd(anchorNode)
|
|
1953
2291
|
}
|
|
1954
2292
|
|
|
@@ -1979,6 +2317,7 @@ class Selection {
|
|
|
1979
2317
|
if (!anchorNode) return null
|
|
1980
2318
|
|
|
1981
2319
|
if ($isTextNode(anchorNode)) {
|
|
2320
|
+
if (offset > 0) return null
|
|
1982
2321
|
return this.#getPreviousNodeFromTextStart(anchorNode)
|
|
1983
2322
|
}
|
|
1984
2323
|
|
|
@@ -2092,7 +2431,9 @@ class Selection {
|
|
|
2092
2431
|
}
|
|
2093
2432
|
}
|
|
2094
2433
|
|
|
2095
|
-
async #selectPreviousNode() {
|
|
2434
|
+
async #selectPreviousNode(event) {
|
|
2435
|
+
if (event?.shiftKey) return false
|
|
2436
|
+
|
|
2096
2437
|
if (this.hasNodeSelection) {
|
|
2097
2438
|
return await this.#withCurrentNode((currentNode) => currentNode.selectPrevious())
|
|
2098
2439
|
} else {
|
|
@@ -2100,7 +2441,9 @@ class Selection {
|
|
|
2100
2441
|
}
|
|
2101
2442
|
}
|
|
2102
2443
|
|
|
2103
|
-
async #selectNextNode() {
|
|
2444
|
+
async #selectNextNode(event) {
|
|
2445
|
+
if (event?.shiftKey) return false
|
|
2446
|
+
|
|
2104
2447
|
if (this.hasNodeSelection) {
|
|
2105
2448
|
return await this.#withCurrentNode((currentNode) => currentNode.selectNext(0, 0))
|
|
2106
2449
|
} else {
|
|
@@ -2199,12 +2542,42 @@ class Selection {
|
|
|
2199
2542
|
const node = backwards ? this.nodeBeforeCursor : this.nodeAfterCursor;
|
|
2200
2543
|
if (!$isDecoratorNode(node)) return false
|
|
2201
2544
|
|
|
2545
|
+
if (this.#collapseListItemToParagraph()) return true
|
|
2546
|
+
|
|
2202
2547
|
this.#removeEmptyElementAnchorNode();
|
|
2203
2548
|
|
|
2204
2549
|
const selection = this.#selectInLexical(node);
|
|
2205
2550
|
return Boolean(selection)
|
|
2206
2551
|
}
|
|
2207
2552
|
|
|
2553
|
+
// When the cursor is inside a list item, collapse the list item into a
|
|
2554
|
+
// paragraph instead of selecting the decorator. This lets the user
|
|
2555
|
+
// delete a list that immediately follows an attachment without the
|
|
2556
|
+
// attachment becoming selected.
|
|
2557
|
+
#collapseListItemToParagraph() {
|
|
2558
|
+
const anchorNode = $getSelection()?.anchor?.getNode();
|
|
2559
|
+
const listItem = anchorNode && $getNearestNodeOfType(anchorNode, ListItemNode);
|
|
2560
|
+
if (!listItem) return false
|
|
2561
|
+
|
|
2562
|
+
const listNode = $getNearestNodeOfType(listItem, ListNode);
|
|
2563
|
+
if (!listNode) return false
|
|
2564
|
+
|
|
2565
|
+
const paragraph = $createParagraphNode();
|
|
2566
|
+
const children = listItem.getChildren();
|
|
2567
|
+
children.forEach(child => paragraph.append(child));
|
|
2568
|
+
|
|
2569
|
+
if (listNode.getChildrenSize() === 1) {
|
|
2570
|
+
listNode.insertBefore(paragraph);
|
|
2571
|
+
listNode.remove();
|
|
2572
|
+
} else {
|
|
2573
|
+
listNode.insertBefore(paragraph);
|
|
2574
|
+
listItem.remove();
|
|
2575
|
+
}
|
|
2576
|
+
|
|
2577
|
+
paragraph.selectStart();
|
|
2578
|
+
return true
|
|
2579
|
+
}
|
|
2580
|
+
|
|
2208
2581
|
#removeEmptyElementAnchorNode(anchor = $getSelection()?.anchor) {
|
|
2209
2582
|
const anchorNode = anchor?.getNode();
|
|
2210
2583
|
if ($isElementNode(anchorNode) && anchorNode?.isEmpty()) anchorNode.remove();
|
|
@@ -2311,8 +2684,12 @@ class Selection {
|
|
|
2311
2684
|
}
|
|
2312
2685
|
|
|
2313
2686
|
#getNextNodeFromTextEnd(anchorNode) {
|
|
2314
|
-
|
|
2315
|
-
|
|
2687
|
+
const nextSibling = anchorNode.getNextSibling();
|
|
2688
|
+
if ($isDecoratorNode(nextSibling)) {
|
|
2689
|
+
return nextSibling
|
|
2690
|
+
}
|
|
2691
|
+
if (nextSibling != null) {
|
|
2692
|
+
return null
|
|
2316
2693
|
}
|
|
2317
2694
|
const parent = anchorNode.getParent();
|
|
2318
2695
|
return parent ? parent.getNextSibling() : null
|
|
@@ -2333,11 +2710,15 @@ class Selection {
|
|
|
2333
2710
|
}
|
|
2334
2711
|
|
|
2335
2712
|
#getPreviousNodeFromTextStart(anchorNode) {
|
|
2336
|
-
|
|
2337
|
-
|
|
2713
|
+
const previousSibling = anchorNode.getPreviousSibling();
|
|
2714
|
+
if ($isDecoratorNode(previousSibling)) {
|
|
2715
|
+
return previousSibling
|
|
2716
|
+
}
|
|
2717
|
+
if (previousSibling != null) {
|
|
2718
|
+
return null
|
|
2338
2719
|
}
|
|
2339
2720
|
const parent = anchorNode.getParent();
|
|
2340
|
-
return parent.getPreviousSibling()
|
|
2721
|
+
return parent ? parent.getPreviousSibling() : null
|
|
2341
2722
|
}
|
|
2342
2723
|
|
|
2343
2724
|
#getNodeBeforeElementNode(anchorNode, offset) {
|
|
@@ -2469,11 +2850,11 @@ class CustomActionTextAttachmentNode extends DecoratorNode {
|
|
|
2469
2850
|
|
|
2470
2851
|
nodes.push(new CustomActionTextAttachmentNode({
|
|
2471
2852
|
sgid: attachment.getAttribute("sgid"),
|
|
2472
|
-
innerHtml:
|
|
2853
|
+
innerHtml: parseAttachmentContent(attachment.getAttribute("content")),
|
|
2473
2854
|
contentType: attachment.getAttribute("content-type")
|
|
2474
2855
|
}));
|
|
2475
2856
|
|
|
2476
|
-
nodes.push($createTextNode("
|
|
2857
|
+
nodes.push($createTextNode("\u2060"));
|
|
2477
2858
|
|
|
2478
2859
|
return { node: nodes }
|
|
2479
2860
|
},
|
|
@@ -2514,6 +2895,10 @@ class CustomActionTextAttachmentNode extends DecoratorNode {
|
|
|
2514
2895
|
}
|
|
2515
2896
|
|
|
2516
2897
|
getTextContent() {
|
|
2898
|
+
return "\ufeff"
|
|
2899
|
+
}
|
|
2900
|
+
|
|
2901
|
+
getReadableTextContent() {
|
|
2517
2902
|
return this.createDOM().textContent.trim() || `[${this.contentType}]`
|
|
2518
2903
|
}
|
|
2519
2904
|
|
|
@@ -2545,6 +2930,7 @@ class CustomActionTextAttachmentNode extends DecoratorNode {
|
|
|
2545
2930
|
decorate() {
|
|
2546
2931
|
return null
|
|
2547
2932
|
}
|
|
2933
|
+
|
|
2548
2934
|
}
|
|
2549
2935
|
|
|
2550
2936
|
class FormatEscaper {
|
|
@@ -2559,12 +2945,20 @@ class FormatEscaper {
|
|
|
2559
2945
|
(event) => this.#handleEnterKey(event),
|
|
2560
2946
|
COMMAND_PRIORITY_HIGH
|
|
2561
2947
|
);
|
|
2948
|
+
|
|
2949
|
+
this.editor.registerCommand(
|
|
2950
|
+
KEY_ARROW_DOWN_COMMAND,
|
|
2951
|
+
(event) => this.#handleArrowDownInCodeBlock(event),
|
|
2952
|
+
COMMAND_PRIORITY_NORMAL
|
|
2953
|
+
);
|
|
2562
2954
|
}
|
|
2563
2955
|
|
|
2564
2956
|
#handleEnterKey(event) {
|
|
2565
2957
|
const selection = $getSelection();
|
|
2566
2958
|
if (!$isRangeSelection(selection)) return false
|
|
2567
2959
|
|
|
2960
|
+
if (this.#handleCodeBlocks(event, selection)) return true
|
|
2961
|
+
|
|
2568
2962
|
const anchorNode = selection.anchor.getNode();
|
|
2569
2963
|
|
|
2570
2964
|
if (!this.#isInsideBlockquote(anchorNode)) return false
|
|
@@ -2825,6 +3219,101 @@ class FormatEscaper {
|
|
|
2825
3219
|
|
|
2826
3220
|
newParagraph.selectStart();
|
|
2827
3221
|
}
|
|
3222
|
+
|
|
3223
|
+
// Code blocks
|
|
3224
|
+
|
|
3225
|
+
#handleCodeBlocks(event, selection) {
|
|
3226
|
+
if (!selection.isCollapsed()) return false
|
|
3227
|
+
|
|
3228
|
+
const codeNode = this.#getCodeNodeFromSelection(selection);
|
|
3229
|
+
if (!codeNode) return false
|
|
3230
|
+
|
|
3231
|
+
if (this.#isCursorOnEmptyLastLineOfCodeBlock(selection, codeNode)) {
|
|
3232
|
+
event?.preventDefault();
|
|
3233
|
+
this.#exitCodeBlock(codeNode);
|
|
3234
|
+
return true
|
|
3235
|
+
}
|
|
3236
|
+
|
|
3237
|
+
return false
|
|
3238
|
+
}
|
|
3239
|
+
|
|
3240
|
+
#handleArrowDownInCodeBlock(event) {
|
|
3241
|
+
const selection = $getSelection();
|
|
3242
|
+
if (!$isRangeSelection(selection) || !selection.isCollapsed()) return false
|
|
3243
|
+
|
|
3244
|
+
const codeNode = this.#getCodeNodeFromSelection(selection);
|
|
3245
|
+
if (!codeNode) return false
|
|
3246
|
+
|
|
3247
|
+
if (this.#isCursorOnLastLineOfCodeBlock(selection, codeNode) && !codeNode.getNextSibling()) {
|
|
3248
|
+
event?.preventDefault();
|
|
3249
|
+
const paragraph = $createParagraphNode();
|
|
3250
|
+
codeNode.insertAfter(paragraph);
|
|
3251
|
+
paragraph.selectStart();
|
|
3252
|
+
return true
|
|
3253
|
+
}
|
|
3254
|
+
|
|
3255
|
+
return false
|
|
3256
|
+
}
|
|
3257
|
+
|
|
3258
|
+
#getCodeNodeFromSelection(selection) {
|
|
3259
|
+
const anchorNode = selection.anchor.getNode();
|
|
3260
|
+
return $getNearestNodeOfType(anchorNode, CodeNode) || ($isCodeNode(anchorNode) ? anchorNode : null)
|
|
3261
|
+
}
|
|
3262
|
+
|
|
3263
|
+
#isCursorOnEmptyLastLineOfCodeBlock(selection, codeNode) {
|
|
3264
|
+
const children = codeNode.getChildren();
|
|
3265
|
+
if (children.length === 0) return true
|
|
3266
|
+
|
|
3267
|
+
const anchorNode = selection.anchor.getNode();
|
|
3268
|
+
const anchorOffset = selection.anchor.offset;
|
|
3269
|
+
|
|
3270
|
+
// Chromium: cursor on the CodeNode element after the last child (a line break)
|
|
3271
|
+
if ($isCodeNode(anchorNode) && anchorOffset === children.length) {
|
|
3272
|
+
return $isLineBreakNode(children[children.length - 1])
|
|
3273
|
+
}
|
|
3274
|
+
|
|
3275
|
+
// Firefox: cursor on an empty text node that follows a line break at the end
|
|
3276
|
+
if ($isTextNode(anchorNode) && anchorNode.getTextContentSize() === 0 && anchorOffset === 0) {
|
|
3277
|
+
const previousSibling = anchorNode.getPreviousSibling();
|
|
3278
|
+
return $isLineBreakNode(previousSibling) && anchorNode.getNextSibling() === null
|
|
3279
|
+
}
|
|
3280
|
+
|
|
3281
|
+
return false
|
|
3282
|
+
}
|
|
3283
|
+
|
|
3284
|
+
#isCursorOnLastLineOfCodeBlock(selection, codeNode) {
|
|
3285
|
+
const anchorNode = selection.anchor.getNode();
|
|
3286
|
+
const children = codeNode.getChildren();
|
|
3287
|
+
if (children.length === 0) return true
|
|
3288
|
+
|
|
3289
|
+
const lastChild = children[children.length - 1];
|
|
3290
|
+
|
|
3291
|
+
if ($isCodeNode(anchorNode) && selection.anchor.offset === children.length) return true
|
|
3292
|
+
if (anchorNode === lastChild) return true
|
|
3293
|
+
|
|
3294
|
+
const lastLineBreakIndex = children.findLastIndex(child => $isLineBreakNode(child));
|
|
3295
|
+
if (lastLineBreakIndex === -1) return true
|
|
3296
|
+
|
|
3297
|
+
const anchorIndex = children.indexOf(anchorNode);
|
|
3298
|
+
return anchorIndex > lastLineBreakIndex
|
|
3299
|
+
}
|
|
3300
|
+
|
|
3301
|
+
#exitCodeBlock(codeNode) {
|
|
3302
|
+
const children = codeNode.getChildren();
|
|
3303
|
+
const lastChild = children[children.length - 1];
|
|
3304
|
+
|
|
3305
|
+
if ($isTextNode(lastChild) && lastChild.getTextContentSize() === 0) {
|
|
3306
|
+
const previousSibling = lastChild.getPreviousSibling();
|
|
3307
|
+
lastChild.remove();
|
|
3308
|
+
if ($isLineBreakNode(previousSibling)) previousSibling.remove();
|
|
3309
|
+
} else if ($isLineBreakNode(lastChild)) {
|
|
3310
|
+
lastChild.remove();
|
|
3311
|
+
}
|
|
3312
|
+
|
|
3313
|
+
const paragraph = $createParagraphNode();
|
|
3314
|
+
codeNode.insertAfter(paragraph);
|
|
3315
|
+
paragraph.selectStart();
|
|
3316
|
+
}
|
|
2828
3317
|
}
|
|
2829
3318
|
|
|
2830
3319
|
async function loadFileIntoImage(file, image) {
|
|
@@ -2865,6 +3354,7 @@ class ActionTextAttachmentUploadNode extends ActionTextAttachmentNode {
|
|
|
2865
3354
|
const { file, uploadUrl, blobUrlTemplate, progress, width, height, uploadError } = node;
|
|
2866
3355
|
super({ ...node, contentType: file.type }, key);
|
|
2867
3356
|
this.file = file;
|
|
3357
|
+
this.fileName = file.name;
|
|
2868
3358
|
this.uploadUrl = uploadUrl;
|
|
2869
3359
|
this.blobUrlTemplate = blobUrlTemplate;
|
|
2870
3360
|
this.progress = progress ?? null;
|
|
@@ -2955,7 +3445,7 @@ class ActionTextAttachmentUploadNode extends ActionTextAttachmentNode {
|
|
|
2955
3445
|
#createCaption() {
|
|
2956
3446
|
const figcaption = createElement("figcaption", { className: "attachment__caption" });
|
|
2957
3447
|
|
|
2958
|
-
const nameSpan = createElement("span", { className: "attachment__name", textContent: this.file.name || "" });
|
|
3448
|
+
const nameSpan = createElement("span", { className: "attachment__name", textContent: this.caption || this.file.name || "" });
|
|
2959
3449
|
const sizeSpan = createElement("span", { className: "attachment__size", textContent: bytesToHumanSize(this.file.size) });
|
|
2960
3450
|
figcaption.appendChild(nameSpan);
|
|
2961
3451
|
figcaption.appendChild(sizeSpan);
|
|
@@ -2974,7 +3464,7 @@ class ActionTextAttachmentUploadNode extends ActionTextAttachmentNode {
|
|
|
2974
3464
|
const writable = this.getWritable();
|
|
2975
3465
|
writable.width = width;
|
|
2976
3466
|
writable.height = height;
|
|
2977
|
-
}, { tag:
|
|
3467
|
+
}, { tag: this.#backgroundUpdateTags });
|
|
2978
3468
|
}
|
|
2979
3469
|
|
|
2980
3470
|
get #hasDimensions() {
|
|
@@ -3033,20 +3523,47 @@ class ActionTextAttachmentUploadNode extends ActionTextAttachmentNode {
|
|
|
3033
3523
|
#setProgress(progress) {
|
|
3034
3524
|
this.editor.update(() => {
|
|
3035
3525
|
this.getWritable().progress = progress;
|
|
3036
|
-
}, { tag:
|
|
3526
|
+
}, { tag: this.#backgroundUpdateTags });
|
|
3037
3527
|
}
|
|
3038
3528
|
|
|
3039
3529
|
#handleUploadError(error) {
|
|
3040
3530
|
console.warn(`Upload error for ${this.file?.name ?? "file"}: ${error}`);
|
|
3041
3531
|
this.editor.update(() => {
|
|
3042
3532
|
this.getWritable().uploadError = true;
|
|
3043
|
-
}, { tag:
|
|
3533
|
+
}, { tag: this.#backgroundUpdateTags });
|
|
3044
3534
|
}
|
|
3045
3535
|
|
|
3046
3536
|
#showUploadedAttachment(blob) {
|
|
3537
|
+
const editorHasFocus = this.#editorHasFocus;
|
|
3538
|
+
|
|
3047
3539
|
this.editor.update(() => {
|
|
3048
|
-
this.
|
|
3049
|
-
|
|
3540
|
+
const shouldTransferNodeSelection = editorHasFocus && this.isSelected();
|
|
3541
|
+
|
|
3542
|
+
const replacementNode = this.#toActionTextAttachmentNodeWith(blob);
|
|
3543
|
+
this.replace(replacementNode);
|
|
3544
|
+
|
|
3545
|
+
if (shouldTransferNodeSelection) {
|
|
3546
|
+
const nodeSelection = $createNodeSelectionWith(replacementNode);
|
|
3547
|
+
$setSelection(nodeSelection);
|
|
3548
|
+
}
|
|
3549
|
+
}, { tag: this.#backgroundUpdateTags });
|
|
3550
|
+
}
|
|
3551
|
+
|
|
3552
|
+
// Upload lifecycle methods (progress, completion, errors) run asynchronously and may
|
|
3553
|
+
// fire while the user is focused on another element (e.g., a title field). Without
|
|
3554
|
+
// SKIP_DOM_SELECTION_TAG, Lexical's reconciler would move the DOM selection back into
|
|
3555
|
+
// the editor, stealing focus from wherever the user is currently typing.
|
|
3556
|
+
get #backgroundUpdateTags() {
|
|
3557
|
+
if (this.#editorHasFocus) {
|
|
3558
|
+
return SILENT_UPDATE_TAGS
|
|
3559
|
+
} else {
|
|
3560
|
+
return [ ...SILENT_UPDATE_TAGS, SKIP_DOM_SELECTION_TAG ]
|
|
3561
|
+
}
|
|
3562
|
+
}
|
|
3563
|
+
|
|
3564
|
+
get #editorHasFocus() {
|
|
3565
|
+
const rootElement = this.editor.getRootElement();
|
|
3566
|
+
return rootElement !== null && rootElement.contains(document.activeElement)
|
|
3050
3567
|
}
|
|
3051
3568
|
|
|
3052
3569
|
#toActionTextAttachmentNodeWith(blob) {
|
|
@@ -3126,7 +3643,7 @@ class ImageGalleryNode extends ElementNode {
|
|
|
3126
3643
|
conversion: () => {
|
|
3127
3644
|
return {
|
|
3128
3645
|
node: $createImageGalleryNode(),
|
|
3129
|
-
after: children =>
|
|
3646
|
+
after: children => children
|
|
3130
3647
|
}
|
|
3131
3648
|
},
|
|
3132
3649
|
priority: 2
|
|
@@ -3403,6 +3920,8 @@ class Contents {
|
|
|
3403
3920
|
}
|
|
3404
3921
|
|
|
3405
3922
|
insertDOM(doc, { tag } = {}) {
|
|
3923
|
+
this.#unwrapPlaceholderAnchors(doc);
|
|
3924
|
+
|
|
3406
3925
|
this.editor.update(() => {
|
|
3407
3926
|
const selection = $getSelection();
|
|
3408
3927
|
if (!$isRangeSelection(selection)) return
|
|
@@ -3415,10 +3934,16 @@ class Contents {
|
|
|
3415
3934
|
}
|
|
3416
3935
|
|
|
3417
3936
|
insertAtCursor(node) {
|
|
3418
|
-
|
|
3937
|
+
let selection = $getSelection() ?? $getRoot().selectEnd();
|
|
3419
3938
|
const selectedNodes = selection?.getNodes();
|
|
3420
3939
|
|
|
3421
3940
|
if ($isRangeSelection(selection)) {
|
|
3941
|
+
const anchorNode = selection.anchor.getNode();
|
|
3942
|
+
if ($isShadowRoot(anchorNode)) {
|
|
3943
|
+
const paragraph = $createParagraphNode();
|
|
3944
|
+
anchorNode.append(paragraph);
|
|
3945
|
+
selection = paragraph.selectStart();
|
|
3946
|
+
}
|
|
3422
3947
|
selection.insertNodes([ node ]);
|
|
3423
3948
|
} else if ($isNodeSelection(selection) && selectedNodes.length > 0) {
|
|
3424
3949
|
// Overrides Lexical's default behavior of _removing_ the currently selected nodes
|
|
@@ -3426,7 +3951,7 @@ class Contents {
|
|
|
3426
3951
|
const lastNode = selectedNodes.at(-1);
|
|
3427
3952
|
lastNode.insertAfter(node);
|
|
3428
3953
|
}
|
|
3429
|
-
}
|
|
3954
|
+
}
|
|
3430
3955
|
|
|
3431
3956
|
insertAtCursorEnsuringLineBelow(node) {
|
|
3432
3957
|
this.insertAtCursor(node);
|
|
@@ -3507,6 +4032,41 @@ class Contents {
|
|
|
3507
4032
|
return result
|
|
3508
4033
|
}
|
|
3509
4034
|
|
|
4035
|
+
wrapSelectedSoftBreakLines(newNodeFn) {
|
|
4036
|
+
let paragraphKey = null;
|
|
4037
|
+
let selectedLineRange = null;
|
|
4038
|
+
|
|
4039
|
+
this.editor.getEditorState().read(() => {
|
|
4040
|
+
const selection = $getSelection();
|
|
4041
|
+
if (!$isRangeSelection(selection) || selection.isCollapsed()) return
|
|
4042
|
+
|
|
4043
|
+
const paragraph = this.#getSelectedParagraphWithSoftLineBreaks(selection);
|
|
4044
|
+
if (!paragraph) return
|
|
4045
|
+
|
|
4046
|
+
const lines = this.#splitParagraphIntoLines(paragraph);
|
|
4047
|
+
selectedLineRange = this.#getSelectedLineRange(lines, selection);
|
|
4048
|
+
|
|
4049
|
+
if (!selectedLineRange) return
|
|
4050
|
+
|
|
4051
|
+
const { start, end } = selectedLineRange;
|
|
4052
|
+
if (start === 0 && end === lines.length - 1) return
|
|
4053
|
+
|
|
4054
|
+
paragraphKey = paragraph.getKey();
|
|
4055
|
+
});
|
|
4056
|
+
|
|
4057
|
+
if (!paragraphKey || !selectedLineRange) return false
|
|
4058
|
+
|
|
4059
|
+
this.editor.update(() => {
|
|
4060
|
+
const paragraph = $getNodeByKey(paragraphKey);
|
|
4061
|
+
if (!paragraph || !$isParagraphNode(paragraph)) return
|
|
4062
|
+
|
|
4063
|
+
const lines = this.#splitParagraphIntoLines(paragraph);
|
|
4064
|
+
this.#replaceParagraphWithWrappedSelectedLines(paragraph, lines, selectedLineRange, newNodeFn);
|
|
4065
|
+
});
|
|
4066
|
+
|
|
4067
|
+
return true
|
|
4068
|
+
}
|
|
4069
|
+
|
|
3510
4070
|
unwrapSelectedListItems() {
|
|
3511
4071
|
this.editor.update(() => {
|
|
3512
4072
|
const selection = $getSelection();
|
|
@@ -3600,15 +4160,14 @@ class Contents {
|
|
|
3600
4160
|
replaceTextBackUntil(stringToReplace, replacementNodes) {
|
|
3601
4161
|
replacementNodes = Array.isArray(replacementNodes) ? replacementNodes : [ replacementNodes ];
|
|
3602
4162
|
|
|
3603
|
-
|
|
3604
|
-
|
|
3605
|
-
|
|
4163
|
+
const selection = $getSelection();
|
|
4164
|
+
const { anchorNode, offset } = this.#getTextAnchorData();
|
|
4165
|
+
if (!anchorNode) return
|
|
3606
4166
|
|
|
3607
|
-
|
|
3608
|
-
|
|
4167
|
+
const lastIndex = this.#findLastIndexBeforeCursor(anchorNode, offset, stringToReplace);
|
|
4168
|
+
if (lastIndex === -1) return
|
|
3609
4169
|
|
|
3610
|
-
|
|
3611
|
-
});
|
|
4170
|
+
this.#performTextReplacement(anchorNode, selection, offset, lastIndex, replacementNodes);
|
|
3612
4171
|
}
|
|
3613
4172
|
|
|
3614
4173
|
createParagraphAfterNode(node, text) {
|
|
@@ -3649,6 +4208,7 @@ class Contents {
|
|
|
3649
4208
|
if (selectLast && uploader.nodes?.length) {
|
|
3650
4209
|
const lastNode = uploader.nodes.at(-1);
|
|
3651
4210
|
lastNode.selectEnd();
|
|
4211
|
+
this.#normalizeSelectionInShadowRoot();
|
|
3652
4212
|
}
|
|
3653
4213
|
});
|
|
3654
4214
|
}
|
|
@@ -3731,6 +4291,19 @@ class Contents {
|
|
|
3731
4291
|
node.remove();
|
|
3732
4292
|
}
|
|
3733
4293
|
|
|
4294
|
+
// Anchors with non-meaningful hrefs (e.g. "#", "") appear in content copied
|
|
4295
|
+
// from rendered views where mentions and interactive elements are wrapped in
|
|
4296
|
+
// <a href="#"> tags. Unwrap them so their text content pastes as plain text
|
|
4297
|
+
// and real links are preserved.
|
|
4298
|
+
#unwrapPlaceholderAnchors(doc) {
|
|
4299
|
+
for (const anchor of doc.querySelectorAll("a")) {
|
|
4300
|
+
const href = anchor.getAttribute("href") || "";
|
|
4301
|
+
if (href === "" || href === "#") {
|
|
4302
|
+
anchor.replaceWith(...anchor.childNodes);
|
|
4303
|
+
}
|
|
4304
|
+
}
|
|
4305
|
+
}
|
|
4306
|
+
|
|
3734
4307
|
#insertNodeWrappingAllSelectedNodes(newNodeFn) {
|
|
3735
4308
|
this.editor.update(() => {
|
|
3736
4309
|
const selection = $getSelection();
|
|
@@ -3896,6 +4469,101 @@ class Contents {
|
|
|
3896
4469
|
nodesToDelete.forEach((node) => node.remove());
|
|
3897
4470
|
}
|
|
3898
4471
|
|
|
4472
|
+
#getSelectedParagraphWithSoftLineBreaks(selection) {
|
|
4473
|
+
const anchorParagraph = this.#getParagraphFromNode(selection.anchor.getNode());
|
|
4474
|
+
const focusParagraph = this.#getParagraphFromNode(selection.focus.getNode());
|
|
4475
|
+
|
|
4476
|
+
if (!anchorParagraph || anchorParagraph !== focusParagraph) return null
|
|
4477
|
+
if ($isQuoteNode(anchorParagraph.getParent())) return null
|
|
4478
|
+
|
|
4479
|
+
return this.#paragraphHasSoftLineBreaks(anchorParagraph) ? anchorParagraph : null
|
|
4480
|
+
}
|
|
4481
|
+
|
|
4482
|
+
#paragraphHasSoftLineBreaks(paragraph) {
|
|
4483
|
+
return paragraph.getChildren().some((child) => $isLineBreakNode(child))
|
|
4484
|
+
}
|
|
4485
|
+
|
|
4486
|
+
#splitParagraphIntoLines(paragraph) {
|
|
4487
|
+
const lines = [ [] ];
|
|
4488
|
+
|
|
4489
|
+
paragraph.getChildren().forEach((child) => {
|
|
4490
|
+
if ($isLineBreakNode(child)) {
|
|
4491
|
+
lines.push([]);
|
|
4492
|
+
} else {
|
|
4493
|
+
lines[lines.length - 1].push(child);
|
|
4494
|
+
}
|
|
4495
|
+
});
|
|
4496
|
+
|
|
4497
|
+
return lines
|
|
4498
|
+
}
|
|
4499
|
+
|
|
4500
|
+
#getSelectedLineRange(lines, selection) {
|
|
4501
|
+
const selectedNodeKeys = new Set(
|
|
4502
|
+
selection.getNodes().map((node) => node.getKey())
|
|
4503
|
+
);
|
|
4504
|
+
|
|
4505
|
+
selectedNodeKeys.add(selection.anchor.getNode().getKey());
|
|
4506
|
+
selectedNodeKeys.add(selection.focus.getNode().getKey());
|
|
4507
|
+
|
|
4508
|
+
const selectedLineIndexes = lines
|
|
4509
|
+
.map((lineNodes, index) => {
|
|
4510
|
+
return lineNodes.some((node) => selectedNodeKeys.has(node.getKey())) ? index : null
|
|
4511
|
+
})
|
|
4512
|
+
.filter((index) => index !== null);
|
|
4513
|
+
|
|
4514
|
+
if (selectedLineIndexes.length === 0) return null
|
|
4515
|
+
|
|
4516
|
+
return {
|
|
4517
|
+
start: selectedLineIndexes[0],
|
|
4518
|
+
end: selectedLineIndexes[selectedLineIndexes.length - 1]
|
|
4519
|
+
}
|
|
4520
|
+
}
|
|
4521
|
+
|
|
4522
|
+
#replaceParagraphWithWrappedSelectedLines(paragraph, lines, { start, end }, newNodeFn) {
|
|
4523
|
+
const insertedNodes = [];
|
|
4524
|
+
|
|
4525
|
+
this.#appendParagraphsForLines(insertedNodes, lines.slice(0, start));
|
|
4526
|
+
|
|
4527
|
+
const wrappingNode = newNodeFn();
|
|
4528
|
+
lines.slice(start, end + 1).forEach((lineNodes) => {
|
|
4529
|
+
wrappingNode.append(this.#createParagraphFromLine(lineNodes));
|
|
4530
|
+
});
|
|
4531
|
+
insertedNodes.push(wrappingNode);
|
|
4532
|
+
|
|
4533
|
+
this.#appendParagraphsForLines(insertedNodes, lines.slice(end + 1));
|
|
4534
|
+
|
|
4535
|
+
let previousNode = null;
|
|
4536
|
+
insertedNodes.forEach((node) => {
|
|
4537
|
+
if (previousNode) {
|
|
4538
|
+
previousNode.insertAfter(node);
|
|
4539
|
+
} else {
|
|
4540
|
+
paragraph.insertBefore(node);
|
|
4541
|
+
}
|
|
4542
|
+
|
|
4543
|
+
previousNode = node;
|
|
4544
|
+
});
|
|
4545
|
+
|
|
4546
|
+
paragraph.remove();
|
|
4547
|
+
}
|
|
4548
|
+
|
|
4549
|
+
#appendParagraphsForLines(insertedNodes, lines) {
|
|
4550
|
+
lines.forEach((lineNodes) => {
|
|
4551
|
+
insertedNodes.push(this.#createParagraphFromLine(lineNodes));
|
|
4552
|
+
});
|
|
4553
|
+
}
|
|
4554
|
+
|
|
4555
|
+
#createParagraphFromLine(lineNodes) {
|
|
4556
|
+
const paragraph = $createParagraphNode();
|
|
4557
|
+
|
|
4558
|
+
if (lineNodes.length === 0) {
|
|
4559
|
+
paragraph.append($createLineBreakNode());
|
|
4560
|
+
} else {
|
|
4561
|
+
paragraph.append(...lineNodes);
|
|
4562
|
+
}
|
|
4563
|
+
|
|
4564
|
+
return paragraph
|
|
4565
|
+
}
|
|
4566
|
+
|
|
3899
4567
|
#collectSelectedListItems(selection) {
|
|
3900
4568
|
const nodes = selection.getNodes();
|
|
3901
4569
|
const listItems = new Set();
|
|
@@ -4010,13 +4678,14 @@ class Contents {
|
|
|
4010
4678
|
return textBeforeCursor.lastIndexOf(stringToReplace)
|
|
4011
4679
|
}
|
|
4012
4680
|
|
|
4013
|
-
#performTextReplacement(anchorNode, offset, lastIndex, replacementNodes) {
|
|
4681
|
+
#performTextReplacement(anchorNode, selection, offset, lastIndex, replacementNodes) {
|
|
4014
4682
|
const fullText = anchorNode.getTextContent();
|
|
4015
4683
|
const textBeforeString = fullText.slice(0, lastIndex);
|
|
4016
4684
|
const textAfterCursor = fullText.slice(offset);
|
|
4017
4685
|
|
|
4018
|
-
const
|
|
4019
|
-
const
|
|
4686
|
+
const trailingSpacer = this.#hasInlineDecoratorNode(replacementNodes) ? "\u2060" : " ";
|
|
4687
|
+
const textNodeBefore = this.#cloneTextNodeFormatting(anchorNode, selection, textBeforeString);
|
|
4688
|
+
const textNodeAfter = this.#cloneTextNodeFormatting(anchorNode, selection, textAfterCursor || trailingSpacer);
|
|
4020
4689
|
|
|
4021
4690
|
anchorNode.replace(textNodeBefore);
|
|
4022
4691
|
|
|
@@ -4028,6 +4697,24 @@ class Contents {
|
|
|
4028
4697
|
textNodeAfter.select(cursorOffset, cursorOffset);
|
|
4029
4698
|
}
|
|
4030
4699
|
|
|
4700
|
+
#hasInlineDecoratorNode(nodes) {
|
|
4701
|
+
return nodes.some(node => node instanceof CustomActionTextAttachmentNode && node.isInline())
|
|
4702
|
+
}
|
|
4703
|
+
|
|
4704
|
+
#cloneTextNodeFormatting(anchorNode, selection, text) {
|
|
4705
|
+
const parent = anchorNode.getParent();
|
|
4706
|
+
const fallbackFormat = parent?.getTextFormat?.() || 0;
|
|
4707
|
+
const fallbackStyle = parent?.getTextStyle?.() || "";
|
|
4708
|
+
const format = $isRangeSelection(selection) && selection.format ? selection.format : (anchorNode.getFormat() || fallbackFormat);
|
|
4709
|
+
const style = $isRangeSelection(selection) && selection.style ? selection.style : (anchorNode.getStyle() || fallbackStyle);
|
|
4710
|
+
|
|
4711
|
+
return $createTextNode(text)
|
|
4712
|
+
.setFormat(format)
|
|
4713
|
+
.setDetail(anchorNode.getDetail())
|
|
4714
|
+
.setMode(anchorNode.getMode())
|
|
4715
|
+
.setStyle(style)
|
|
4716
|
+
}
|
|
4717
|
+
|
|
4031
4718
|
#insertReplacementNodes(startNode, replacementNodes) {
|
|
4032
4719
|
let previousNode = startNode;
|
|
4033
4720
|
for (const node of replacementNodes) {
|
|
@@ -4067,6 +4754,29 @@ class Contents {
|
|
|
4067
4754
|
#shouldUploadFile(file) {
|
|
4068
4755
|
return dispatch(this.editorElement, "lexxy:file-accept", { file }, true)
|
|
4069
4756
|
}
|
|
4757
|
+
|
|
4758
|
+
// When the selection anchor is on a shadow root (e.g. a table cell), Lexical's
|
|
4759
|
+
// insertNodes can't find a block parent and fails silently. Normalize the
|
|
4760
|
+
// selection to point inside the shadow root's content instead.
|
|
4761
|
+
#normalizeSelectionInShadowRoot() {
|
|
4762
|
+
const selection = $getSelection();
|
|
4763
|
+
if (!$isRangeSelection(selection)) return
|
|
4764
|
+
|
|
4765
|
+
const anchorNode = selection.anchor.getNode();
|
|
4766
|
+
if (!$isShadowRoot(anchorNode)) return
|
|
4767
|
+
|
|
4768
|
+
// Append a paragraph inside the shadow root so there's a valid text-level
|
|
4769
|
+
// target for subsequent insertions. This is necessary because decorator
|
|
4770
|
+
// nodes (e.g. attachments) at the end of a table cell leave the selection
|
|
4771
|
+
// on the cell itself with no block-level descendant to anchor to.
|
|
4772
|
+
const paragraph = $createParagraphNode();
|
|
4773
|
+
anchorNode.append(paragraph);
|
|
4774
|
+
paragraph.selectStart();
|
|
4775
|
+
}
|
|
4776
|
+
}
|
|
4777
|
+
|
|
4778
|
+
function $isShadowRoot(node) {
|
|
4779
|
+
return $isElementNode(node) && $isRootOrShadowRoot(node) && !$isRootNode(node)
|
|
4070
4780
|
}
|
|
4071
4781
|
|
|
4072
4782
|
class Clipboard {
|
|
@@ -4155,7 +4865,7 @@ class Clipboard {
|
|
|
4155
4865
|
}
|
|
4156
4866
|
|
|
4157
4867
|
#pasteMarkdown(text) {
|
|
4158
|
-
const html = marked(text);
|
|
4868
|
+
const html = marked(text, { breaks: true });
|
|
4159
4869
|
const doc = parseHtml(html);
|
|
4160
4870
|
const detail = Object.freeze({
|
|
4161
4871
|
markdown: text,
|
|
@@ -4178,19 +4888,38 @@ class Clipboard {
|
|
|
4178
4888
|
if (!this.editorElement.supportsAttachments) return false
|
|
4179
4889
|
|
|
4180
4890
|
const html = clipboardData.getData("text/html");
|
|
4891
|
+
const files = clipboardData.files;
|
|
4892
|
+
|
|
4893
|
+
if (files.length && this.#isCopiedImageHTML(html)) {
|
|
4894
|
+
this.#uploadFilesPreservingScroll(files);
|
|
4895
|
+
return true
|
|
4896
|
+
}
|
|
4897
|
+
|
|
4181
4898
|
if (html) {
|
|
4182
4899
|
this.contents.insertHtml(html, { tag: PASTE_TAG });
|
|
4183
4900
|
return true
|
|
4184
4901
|
}
|
|
4185
4902
|
|
|
4903
|
+
this.#uploadFilesPreservingScroll(files);
|
|
4904
|
+
|
|
4905
|
+
return true
|
|
4906
|
+
}
|
|
4907
|
+
|
|
4908
|
+
#isCopiedImageHTML(html) {
|
|
4909
|
+
if (!html) return false
|
|
4910
|
+
|
|
4911
|
+
const doc = parseHtml(html);
|
|
4912
|
+
const elementChildren = Array.from(doc.body.children);
|
|
4913
|
+
|
|
4914
|
+
return elementChildren.length === 1 && elementChildren[0].tagName === "IMG"
|
|
4915
|
+
}
|
|
4916
|
+
|
|
4917
|
+
#uploadFilesPreservingScroll(files) {
|
|
4186
4918
|
this.#preservingScrollPosition(() => {
|
|
4187
|
-
const files = clipboardData.files;
|
|
4188
4919
|
if (files.length) {
|
|
4189
4920
|
this.contents.uploadFiles(files, { selectLast: true });
|
|
4190
4921
|
}
|
|
4191
4922
|
});
|
|
4192
|
-
|
|
4193
|
-
return true
|
|
4194
4923
|
}
|
|
4195
4924
|
|
|
4196
4925
|
// Deals with an issue in Safari where it scrolls to the tops after pasting attachments
|
|
@@ -4246,6 +4975,60 @@ class Extensions {
|
|
|
4246
4975
|
}
|
|
4247
4976
|
}
|
|
4248
4977
|
|
|
4978
|
+
// Custom TextNode exportDOM that avoids redundant bold/italic wrapping.
|
|
4979
|
+
//
|
|
4980
|
+
// Lexical's built-in TextNode.exportDOM() calls createDOM() which produces semantic tags
|
|
4981
|
+
// like <strong> for bold and <em> for italic, then unconditionally wraps the result
|
|
4982
|
+
// with presentational tags (<b>, <i>) for the same formats. This produces redundant markup
|
|
4983
|
+
// like <b><strong>text</strong></b>.
|
|
4984
|
+
//
|
|
4985
|
+
// This custom export skips <b> when <strong> is already present and <i> when <em> is
|
|
4986
|
+
// already present, while preserving <s> and <u> wrappers which have no semantic equivalents
|
|
4987
|
+
// in createDOM's output.
|
|
4988
|
+
|
|
4989
|
+
function exportTextNodeDOM(editor, textNode) {
|
|
4990
|
+
const element = textNode.createDOM(editor._config, editor);
|
|
4991
|
+
element.style.whiteSpace = "pre-wrap";
|
|
4992
|
+
|
|
4993
|
+
if (textNode.hasFormat("lowercase")) {
|
|
4994
|
+
element.style.textTransform = "lowercase";
|
|
4995
|
+
} else if (textNode.hasFormat("uppercase")) {
|
|
4996
|
+
element.style.textTransform = "uppercase";
|
|
4997
|
+
} else if (textNode.hasFormat("capitalize")) {
|
|
4998
|
+
element.style.textTransform = "capitalize";
|
|
4999
|
+
}
|
|
5000
|
+
|
|
5001
|
+
let result = element;
|
|
5002
|
+
|
|
5003
|
+
if (textNode.hasFormat("bold") && !containsTag(element, "strong")) {
|
|
5004
|
+
result = wrapWith(result, "b");
|
|
5005
|
+
}
|
|
5006
|
+
if (textNode.hasFormat("italic") && !containsTag(element, "em")) {
|
|
5007
|
+
result = wrapWith(result, "i");
|
|
5008
|
+
}
|
|
5009
|
+
if (textNode.hasFormat("strikethrough")) {
|
|
5010
|
+
result = wrapWith(result, "s");
|
|
5011
|
+
}
|
|
5012
|
+
if (textNode.hasFormat("underline")) {
|
|
5013
|
+
result = wrapWith(result, "u");
|
|
5014
|
+
}
|
|
5015
|
+
|
|
5016
|
+
return { element: result }
|
|
5017
|
+
}
|
|
5018
|
+
|
|
5019
|
+
function containsTag(element, tagName) {
|
|
5020
|
+
const upperTag = tagName.toUpperCase();
|
|
5021
|
+
if (element.tagName === upperTag) return true
|
|
5022
|
+
|
|
5023
|
+
return element.querySelector(tagName) !== null
|
|
5024
|
+
}
|
|
5025
|
+
|
|
5026
|
+
function wrapWith(element, tag) {
|
|
5027
|
+
const wrapper = document.createElement(tag);
|
|
5028
|
+
wrapper.appendChild(element);
|
|
5029
|
+
return wrapper
|
|
5030
|
+
}
|
|
5031
|
+
|
|
4249
5032
|
class ProvisionalParagraphNode extends ParagraphNode {
|
|
4250
5033
|
$config() {
|
|
4251
5034
|
return this.config("provisonal_paragraph", {
|
|
@@ -4444,6 +5227,14 @@ class WrappedTableNode extends TableNode {
|
|
|
4444
5227
|
return super.importDOM()
|
|
4445
5228
|
}
|
|
4446
5229
|
|
|
5230
|
+
canInsertTextBefore() {
|
|
5231
|
+
return false
|
|
5232
|
+
}
|
|
5233
|
+
|
|
5234
|
+
canInsertTextAfter() {
|
|
5235
|
+
return false
|
|
5236
|
+
}
|
|
5237
|
+
|
|
4447
5238
|
exportDOM(editor) {
|
|
4448
5239
|
const superExport = super.exportDOM(editor);
|
|
4449
5240
|
|
|
@@ -4713,7 +5504,7 @@ class LexicalEditorElement extends HTMLElement {
|
|
|
4713
5504
|
toString() {
|
|
4714
5505
|
if (!this.cachedStringValue) {
|
|
4715
5506
|
this.editor?.getEditorState().read(() => {
|
|
4716
|
-
this.cachedStringValue = $getRoot()
|
|
5507
|
+
this.cachedStringValue = $getReadableTextContent($getRoot());
|
|
4717
5508
|
});
|
|
4718
5509
|
}
|
|
4719
5510
|
|
|
@@ -4830,10 +5621,20 @@ class LexicalEditorElement extends HTMLElement {
|
|
|
4830
5621
|
const nodes = $generateNodesFromDOM(this.editor, parseHtml(`${html}`));
|
|
4831
5622
|
|
|
4832
5623
|
return nodes
|
|
5624
|
+
.filter(this.#isNotWhitespaceOnlyNode)
|
|
4833
5625
|
.map(this.#wrapTextNode)
|
|
4834
5626
|
.map(this.#unwrapDecoratorNode)
|
|
4835
5627
|
}
|
|
4836
5628
|
|
|
5629
|
+
// Whitespace-only text nodes (e.g. "\n" between block elements like <div>) and stray line break
|
|
5630
|
+
// nodes are formatting artifacts from the HTML source. They can't be appended to the root node
|
|
5631
|
+
// and have no semantic meaning, so we strip them during import.
|
|
5632
|
+
#isNotWhitespaceOnlyNode(node) {
|
|
5633
|
+
if ($isLineBreakNode(node)) return false
|
|
5634
|
+
if ($isTextNode(node) && node.getTextContent().trim() === "") return false
|
|
5635
|
+
return true
|
|
5636
|
+
}
|
|
5637
|
+
|
|
4837
5638
|
// Raw string values produce TextNodes which cannot be appended directly to the RootNode.
|
|
4838
5639
|
// We wrap those in <p>
|
|
4839
5640
|
#wrapTextNode(node) {
|
|
@@ -4874,7 +5675,10 @@ class LexicalEditorElement extends HTMLElement {
|
|
|
4874
5675
|
name: "lexxy/core",
|
|
4875
5676
|
namespace: "Lexxy",
|
|
4876
5677
|
theme: theme,
|
|
4877
|
-
nodes: this.#lexicalNodes
|
|
5678
|
+
nodes: this.#lexicalNodes,
|
|
5679
|
+
html: {
|
|
5680
|
+
export: new Map([ [ TextNode, exportTextNodeDOM ] ])
|
|
5681
|
+
}
|
|
4878
5682
|
},
|
|
4879
5683
|
...this.extensions.lexicalExtensions
|
|
4880
5684
|
);
|
|
@@ -4998,6 +5802,7 @@ class LexicalEditorElement extends HTMLElement {
|
|
|
4998
5802
|
this.#registerCodeHiglightingComponents();
|
|
4999
5803
|
if (this.supportsMarkdown) {
|
|
5000
5804
|
registerMarkdownShortcuts(this.editor, TRANSFORMERS);
|
|
5805
|
+
registerMarkdownLeadingTagHandler(this.editor, TRANSFORMERS);
|
|
5001
5806
|
}
|
|
5002
5807
|
} else {
|
|
5003
5808
|
registerPlainText(this.editor);
|
|
@@ -5160,6 +5965,29 @@ class LexicalEditorElement extends HTMLElement {
|
|
|
5160
5965
|
}
|
|
5161
5966
|
}
|
|
5162
5967
|
|
|
5968
|
+
// Like $getRoot().getTextContent() but uses readable text for custom attachment nodes
|
|
5969
|
+
// (e.g., mentions) instead of their single-character cursor placeholder.
|
|
5970
|
+
function $getReadableTextContent(node) {
|
|
5971
|
+
if (node instanceof CustomActionTextAttachmentNode) {
|
|
5972
|
+
return node.getReadableTextContent()
|
|
5973
|
+
}
|
|
5974
|
+
|
|
5975
|
+
if ($isElementNode(node)) {
|
|
5976
|
+
let text = "";
|
|
5977
|
+
const children = node.getChildren();
|
|
5978
|
+
for (let i = 0; i < children.length; i++) {
|
|
5979
|
+
const child = children[i];
|
|
5980
|
+
text += $getReadableTextContent(child);
|
|
5981
|
+
if ($isElementNode(child) && i !== children.length - 1 && !child.isInline()) {
|
|
5982
|
+
text += "\n\n";
|
|
5983
|
+
}
|
|
5984
|
+
}
|
|
5985
|
+
return text
|
|
5986
|
+
}
|
|
5987
|
+
|
|
5988
|
+
return node.getTextContent()
|
|
5989
|
+
}
|
|
5990
|
+
|
|
5163
5991
|
class ToolbarDropdown extends HTMLElement {
|
|
5164
5992
|
connectedCallback() {
|
|
5165
5993
|
this.container = this.closest("details");
|
|
@@ -5596,6 +6424,8 @@ class LexicalPromptElement extends HTMLElement {
|
|
|
5596
6424
|
#addTriggerListener() {
|
|
5597
6425
|
const unregister = this.#editor.registerUpdateListener(({ editorState }) => {
|
|
5598
6426
|
editorState.read(() => {
|
|
6427
|
+
if (this.#selection.isInsideCodeBlock) return
|
|
6428
|
+
|
|
5599
6429
|
const { node, offset } = this.#selection.selectedNodeWithOffset();
|
|
5600
6430
|
if (!node) return
|
|
5601
6431
|
|
|
@@ -5626,10 +6456,15 @@ class LexicalPromptElement extends HTMLElement {
|
|
|
5626
6456
|
}
|
|
5627
6457
|
|
|
5628
6458
|
#addCursorPositionListener() {
|
|
5629
|
-
this.cursorPositionListener = this.#editor.registerUpdateListener(() => {
|
|
6459
|
+
this.cursorPositionListener = this.#editor.registerUpdateListener(({ editorState }) => {
|
|
5630
6460
|
if (this.closed) return
|
|
5631
6461
|
|
|
5632
|
-
|
|
6462
|
+
editorState.read(() => {
|
|
6463
|
+
if (this.#selection.isInsideCodeBlock) {
|
|
6464
|
+
this.#hidePopover();
|
|
6465
|
+
return
|
|
6466
|
+
}
|
|
6467
|
+
|
|
5633
6468
|
const { node, offset } = this.#selection.selectedNodeWithOffset();
|
|
5634
6469
|
if (!node) return
|
|
5635
6470
|
|
|
@@ -5752,25 +6587,34 @@ class LexicalPromptElement extends HTMLElement {
|
|
|
5752
6587
|
const verticalOffset = contentRect.top - editorRect.top;
|
|
5753
6588
|
|
|
5754
6589
|
if (!this.popoverElement.hasAttribute("data-anchored")) {
|
|
5755
|
-
this
|
|
6590
|
+
this.#setPopoverOffsetX(x);
|
|
6591
|
+
this.#setPopoverOffsetY(y + verticalOffset);
|
|
5756
6592
|
this.popoverElement.toggleAttribute("data-anchored", true);
|
|
5757
6593
|
}
|
|
5758
6594
|
|
|
5759
|
-
this.popoverElement.style.top = `${y + verticalOffset}px`;
|
|
5760
|
-
this.popoverElement.style.bottom = "auto";
|
|
5761
|
-
|
|
5762
6595
|
const popoverRect = this.popoverElement.getBoundingClientRect();
|
|
5763
|
-
const isClippedAtBottom = popoverRect.bottom > window.innerHeight;
|
|
5764
6596
|
|
|
5765
|
-
if (
|
|
5766
|
-
this.popoverElement.
|
|
5767
|
-
|
|
6597
|
+
if (popoverRect.right > window.innerWidth) {
|
|
6598
|
+
this.popoverElement.toggleAttribute("data-clipped-at-right", true);
|
|
6599
|
+
}
|
|
6600
|
+
|
|
6601
|
+
if (popoverRect.bottom > window.innerHeight) {
|
|
6602
|
+
this.#setPopoverOffsetY(contentRect.height - y + fontSize);
|
|
5768
6603
|
this.popoverElement.toggleAttribute("data-clipped-at-bottom", true);
|
|
5769
6604
|
}
|
|
5770
6605
|
}
|
|
5771
6606
|
|
|
6607
|
+
#setPopoverOffsetX(value) {
|
|
6608
|
+
this.popoverElement.style.setProperty("--lexxy-prompt-offset-x", `${value}px`);
|
|
6609
|
+
}
|
|
6610
|
+
|
|
6611
|
+
#setPopoverOffsetY(value) {
|
|
6612
|
+
this.popoverElement.style.setProperty("--lexxy-prompt-offset-y", `${value}px`);
|
|
6613
|
+
}
|
|
6614
|
+
|
|
5772
6615
|
#resetPopoverPosition() {
|
|
5773
6616
|
this.popoverElement.removeAttribute("data-clipped-at-bottom");
|
|
6617
|
+
this.popoverElement.removeAttribute("data-clipped-at-right");
|
|
5774
6618
|
this.popoverElement.removeAttribute("data-anchored");
|
|
5775
6619
|
}
|
|
5776
6620
|
|
|
@@ -5841,6 +6685,16 @@ class LexicalPromptElement extends HTMLElement {
|
|
|
5841
6685
|
this.#hidePopover();
|
|
5842
6686
|
this.#editorElement.focus();
|
|
5843
6687
|
event.stopPropagation();
|
|
6688
|
+
} else if (event.key === ",") {
|
|
6689
|
+
event.preventDefault();
|
|
6690
|
+
event.stopPropagation();
|
|
6691
|
+
this.#optionWasSelected();
|
|
6692
|
+
this.#editor.update(() => {
|
|
6693
|
+
const selection = $getSelection();
|
|
6694
|
+
if ($isRangeSelection(selection)) {
|
|
6695
|
+
selection.insertText(",");
|
|
6696
|
+
}
|
|
6697
|
+
});
|
|
5844
6698
|
}
|
|
5845
6699
|
// Arrow keys are now handled via Lexical commands with HIGH priority
|
|
5846
6700
|
}
|
|
@@ -6781,8 +7635,11 @@ class TableTools extends HTMLElement {
|
|
|
6781
7635
|
}
|
|
6782
7636
|
|
|
6783
7637
|
#show() {
|
|
7638
|
+
this.#updateButtonsPosition();
|
|
6784
7639
|
this.style.display = "flex";
|
|
6785
|
-
this.#
|
|
7640
|
+
this.#updateRowColumnCount();
|
|
7641
|
+
this.#closeMoreMenu();
|
|
7642
|
+
this.#handleCommandButtonHover();
|
|
6786
7643
|
}
|
|
6787
7644
|
|
|
6788
7645
|
#hide() {
|