@37signals/lexxy 0.9.19 → 0.9.20
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/dist/lexxy.esm.js +172 -26
- package/dist/lexxy_helpers.esm.js +28 -27
- package/package.json +1 -1
package/dist/lexxy.esm.js
CHANGED
|
@@ -172,8 +172,8 @@ function dispatch(element, eventName, detail = null, cancelable = false) {
|
|
|
172
172
|
}
|
|
173
173
|
|
|
174
174
|
function addBlockSpacing(doc) {
|
|
175
|
-
const
|
|
176
|
-
for (const block of
|
|
175
|
+
const selector = "body > :not(h1, h2, h3, h4, h5, h6) + *, blockquote > :not(h1, h2, h3, h4, h5, h6) + *";
|
|
176
|
+
for (const block of doc.querySelectorAll(selector)) {
|
|
177
177
|
const spacer = doc.createElement("p");
|
|
178
178
|
spacer.appendChild(doc.createElement("br"));
|
|
179
179
|
block.before(spacer);
|
|
@@ -1742,6 +1742,7 @@ function $splitSelectedParagraphsAtInnerLineBreaks(selection) {
|
|
|
1742
1742
|
|
|
1743
1743
|
function $expandSelectionToLineBreaksAndSplitAtEdges(selection, fallbackAncestor = (node) => node.getTopLevelElement()) {
|
|
1744
1744
|
$ensureForwardRangeSelection(selection);
|
|
1745
|
+
$shrinkSelectionPastBlockEdges(selection);
|
|
1745
1746
|
|
|
1746
1747
|
const focusCaret = $caretFromPoint(selection.focus, "next");
|
|
1747
1748
|
const anchorCaret = $caretFromPoint(selection.anchor, "previous");
|
|
@@ -1773,6 +1774,52 @@ function $expandSelectionToLineBreaksAndSplitAtEdges(selection, fallbackAncestor
|
|
|
1773
1774
|
));
|
|
1774
1775
|
}
|
|
1775
1776
|
|
|
1777
|
+
// A selection whose anchor sits at the very end of one block while its focus
|
|
1778
|
+
// lives in a later block (e.g. selecting a pasted paragraph when the browser
|
|
1779
|
+
// anchors at the end of the line above) contributes nothing from the anchor's
|
|
1780
|
+
// block. Pull each endpoint that is flush against a block edge into the block
|
|
1781
|
+
// that actually holds the selected content, so we don't wrap the empty edge
|
|
1782
|
+
// block too.
|
|
1783
|
+
function $shrinkSelectionPastBlockEdges(selection) {
|
|
1784
|
+
if (selection.isCollapsed()) return
|
|
1785
|
+
|
|
1786
|
+
const anchorBlock = selection.anchor.getNode().getTopLevelElement();
|
|
1787
|
+
const focusBlock = selection.focus.getNode().getTopLevelElement();
|
|
1788
|
+
if (!anchorBlock || !focusBlock || anchorBlock.is(focusBlock)) return
|
|
1789
|
+
|
|
1790
|
+
if ($isAtBlockEnd(selection.anchor, anchorBlock)) {
|
|
1791
|
+
const nextBlock = anchorBlock.getNextSibling();
|
|
1792
|
+
if (nextBlock) selection.anchor.set(nextBlock.getKey(), 0, "element");
|
|
1793
|
+
}
|
|
1794
|
+
|
|
1795
|
+
if ($isAtBlockStart(selection.focus, focusBlock)) {
|
|
1796
|
+
const previousBlock = focusBlock.getPreviousSibling();
|
|
1797
|
+
if (previousBlock) selection.focus.set(previousBlock.getKey(), previousBlock.getChildrenSize(), "element");
|
|
1798
|
+
}
|
|
1799
|
+
}
|
|
1800
|
+
|
|
1801
|
+
function $isAtBlockEnd(point, block) {
|
|
1802
|
+
return $isAtBlockBoundary($caretFromPoint(point, "next"), block)
|
|
1803
|
+
}
|
|
1804
|
+
|
|
1805
|
+
function $isAtBlockStart(point, block) {
|
|
1806
|
+
return $isAtBlockBoundary($caretFromPoint(point, "previous"), block)
|
|
1807
|
+
}
|
|
1808
|
+
|
|
1809
|
+
// A text point sitting mid-node still has content ahead of it in the caret's
|
|
1810
|
+
// direction, even though that content is not a sibling node. $getNodeAtCaret
|
|
1811
|
+
// only sees siblings, so check the text edge before walking the block.
|
|
1812
|
+
function $isAtBlockBoundary(caret, block) {
|
|
1813
|
+
if ($isTextPointCaret(caret) && $isExtendableTextPointCaret(caret)) return false
|
|
1814
|
+
|
|
1815
|
+
let cursor = $normalizeCaret(caret);
|
|
1816
|
+
while (cursor && block.isParentOf(cursor.origin)) {
|
|
1817
|
+
if (cursor.getNodeAtCaret()) return false
|
|
1818
|
+
cursor = cursor.getParentCaret();
|
|
1819
|
+
}
|
|
1820
|
+
return true
|
|
1821
|
+
}
|
|
1822
|
+
|
|
1776
1823
|
function $getCaretAtLineBreakBoundary(caret, skipInwardEdge = false) {
|
|
1777
1824
|
const paragraph = caret.origin.getTopLevelElement();
|
|
1778
1825
|
if (!paragraph || !$isParagraphNode(paragraph)) return null
|
|
@@ -5693,6 +5740,7 @@ class PastedContentFormatter {
|
|
|
5693
5740
|
format() {
|
|
5694
5741
|
this.#unwrapPlaceholderAnchors();
|
|
5695
5742
|
this.#stripTableCellColorStyles();
|
|
5743
|
+
this.#nestStrayListChildren();
|
|
5696
5744
|
this.#stripStrayListChildren();
|
|
5697
5745
|
return this.doc
|
|
5698
5746
|
}
|
|
@@ -5721,6 +5769,22 @@ class PastedContentFormatter {
|
|
|
5721
5769
|
}
|
|
5722
5770
|
}
|
|
5723
5771
|
|
|
5772
|
+
// Some sources (e.g. Gmail) nest a sublist as a direct child of the parent
|
|
5773
|
+
// <ol>/<ul> instead of inside a <li>. Move each nested list into its
|
|
5774
|
+
// preceding <li> so the import preserves the nesting instead of dropping it.
|
|
5775
|
+
#nestStrayListChildren() {
|
|
5776
|
+
for (const list of this.doc.querySelectorAll("ol, ul")) {
|
|
5777
|
+
for (const child of Array.from(list.children)) {
|
|
5778
|
+
if (child.tagName !== "OL" && child.tagName !== "UL") continue
|
|
5779
|
+
|
|
5780
|
+
const previousItem = child.previousElementSibling;
|
|
5781
|
+
if (previousItem && previousItem.tagName === "LI") {
|
|
5782
|
+
previousItem.appendChild(child);
|
|
5783
|
+
}
|
|
5784
|
+
}
|
|
5785
|
+
}
|
|
5786
|
+
}
|
|
5787
|
+
|
|
5724
5788
|
// Only <li> is a valid child of a list; drop stray <br>/whitespace so the
|
|
5725
5789
|
// import doesn't wrap them into an empty leading item.
|
|
5726
5790
|
#stripStrayListChildren() {
|
|
@@ -5764,7 +5828,11 @@ class Contents {
|
|
|
5764
5828
|
|
|
5765
5829
|
insertText(text, { tag } = {}) {
|
|
5766
5830
|
this.editor.update(() => {
|
|
5767
|
-
const paragraph = $createParagraphNode()
|
|
5831
|
+
const paragraph = $createParagraphNode();
|
|
5832
|
+
text.split("\n").forEach((line, index) => {
|
|
5833
|
+
if (index > 0) paragraph.append($createLineBreakNode());
|
|
5834
|
+
paragraph.append($createTextNode(line));
|
|
5835
|
+
});
|
|
5768
5836
|
this.insertAtCursor(paragraph);
|
|
5769
5837
|
}, { tag });
|
|
5770
5838
|
}
|
|
@@ -6560,14 +6628,16 @@ class Clipboard {
|
|
|
6560
6628
|
|
|
6561
6629
|
// Markdown conversion collapses runs of whitespace and unescapes backslashes,
|
|
6562
6630
|
// silently corrupting plain text such as Windows/UNC file paths. When the text
|
|
6563
|
-
// carries no Markdown structure, paste it verbatim instead.
|
|
6631
|
+
// carries no Markdown structure, paste it verbatim instead. A path that wrapped
|
|
6632
|
+
// across lines renders as a single paragraph with <br> line breaks (marked runs
|
|
6633
|
+
// with breaks: true), which is still plain text we should preserve untouched.
|
|
6564
6634
|
#isPlainTextWithoutMarkdown(doc) {
|
|
6565
6635
|
const elements = Array.from(doc.body.children);
|
|
6566
6636
|
if (elements.length !== 1) return false
|
|
6567
6637
|
|
|
6568
6638
|
const paragraph = elements[0];
|
|
6569
6639
|
return paragraph.nodeName === "P"
|
|
6570
|
-
&& Array.from(paragraph.childNodes).every((node) => node.nodeType === Node.TEXT_NODE)
|
|
6640
|
+
&& Array.from(paragraph.childNodes).every((node) => node.nodeType === Node.TEXT_NODE || node.nodeName === "BR")
|
|
6571
6641
|
}
|
|
6572
6642
|
|
|
6573
6643
|
#pasteRichText(clipboardData) {
|
|
@@ -7855,24 +7925,46 @@ class FormatEscapeExtension extends LexxyExtension {
|
|
|
7855
7925
|
}
|
|
7856
7926
|
|
|
7857
7927
|
function $escapeFromBlockquote() {
|
|
7928
|
+
return $escapeBeforeBlockquoteStart() || $escapeFromBlankBlockquoteParagraph()
|
|
7929
|
+
}
|
|
7930
|
+
|
|
7931
|
+
function $escapeBeforeBlockquoteStart() {
|
|
7932
|
+
const selection = $getSelection();
|
|
7933
|
+
if (!$isRangeSelection(selection) || !selection.isCollapsed() || selection.anchor.offset !== 0) return false
|
|
7934
|
+
|
|
7935
|
+
const paragraph = $getNearestNodeOfType(selection.anchor.getNode(), ParagraphNode);
|
|
7936
|
+
if (paragraph && !$isBlankNode(paragraph) && !paragraph.getPreviousSibling()) {
|
|
7937
|
+
const blockquote = paragraph.getParent();
|
|
7938
|
+
if ($isQuoteNode(blockquote)) {
|
|
7939
|
+
blockquote.insertBefore($createParagraphNode());
|
|
7940
|
+
return true
|
|
7941
|
+
}
|
|
7942
|
+
}
|
|
7943
|
+
|
|
7944
|
+
return false
|
|
7945
|
+
}
|
|
7946
|
+
|
|
7947
|
+
function $escapeFromBlankBlockquoteParagraph() {
|
|
7858
7948
|
const anchorNode = $getSelection().anchor.getNode();
|
|
7859
7949
|
|
|
7860
7950
|
const paragraph = $getNearestNodeOfType(anchorNode, ParagraphNode);
|
|
7861
7951
|
if (!paragraph || !$isBlankNode(paragraph)) return false
|
|
7862
7952
|
|
|
7863
7953
|
const blockquote = paragraph.getParent();
|
|
7864
|
-
if (
|
|
7954
|
+
if ($isQuoteNode(blockquote)) {
|
|
7955
|
+
const nonEmptySiblings = paragraph.getNextSiblings().filter(sibling => !$isBlankNode(sibling));
|
|
7865
7956
|
|
|
7866
|
-
|
|
7957
|
+
if (nonEmptySiblings.length > 0) {
|
|
7958
|
+
$splitQuoteNode(blockquote, paragraph);
|
|
7959
|
+
} else {
|
|
7960
|
+
blockquote.insertAfter(paragraph);
|
|
7961
|
+
paragraph.selectStart();
|
|
7962
|
+
}
|
|
7867
7963
|
|
|
7868
|
-
|
|
7869
|
-
$splitQuoteNode(blockquote, paragraph);
|
|
7870
|
-
} else {
|
|
7871
|
-
blockquote.insertAfter(paragraph);
|
|
7872
|
-
paragraph.selectStart();
|
|
7964
|
+
return true
|
|
7873
7965
|
}
|
|
7874
7966
|
|
|
7875
|
-
return
|
|
7967
|
+
return false
|
|
7876
7968
|
}
|
|
7877
7969
|
|
|
7878
7970
|
function $splitQuoteNode(node, paragraph) {
|
|
@@ -8130,6 +8222,15 @@ class CustomAttachmentDragAndDrop {
|
|
|
8130
8222
|
// they only drop onto an existing line, so snap to the nearest one.
|
|
8131
8223
|
if (caret.node === rootElement) {
|
|
8132
8224
|
return this.#nearestLineCaret(rootElement, event.clientY)
|
|
8225
|
+
}
|
|
8226
|
+
|
|
8227
|
+
// When mentions sit next to each other with no text between them, the caret
|
|
8228
|
+
// lands inside the neighbouring decorator's DOM. Lexical can't resolve a point
|
|
8229
|
+
// inside a decorator to an editable position, and the cursor has no business
|
|
8230
|
+
// showing there anyway, so snap to just before or after that mention.
|
|
8231
|
+
const decorator = this.#decoratorElementContaining(caret.node);
|
|
8232
|
+
if (decorator) {
|
|
8233
|
+
return this.#dropPointBesideDecorator(decorator, event.clientX)
|
|
8133
8234
|
} else {
|
|
8134
8235
|
return caret
|
|
8135
8236
|
}
|
|
@@ -8158,24 +8259,55 @@ class CustomAttachmentDragAndDrop {
|
|
|
8158
8259
|
}
|
|
8159
8260
|
}
|
|
8160
8261
|
|
|
8262
|
+
#decoratorElementContaining(node) {
|
|
8263
|
+
const element = node.nodeType === Node.ELEMENT_NODE ? node : node.parentElement;
|
|
8264
|
+
return element?.closest("[data-lexxy-decorator][data-lexical-node-key]")
|
|
8265
|
+
}
|
|
8266
|
+
|
|
8267
|
+
#dropPointBesideDecorator(decorator, clientX) {
|
|
8268
|
+
const rect = decorator.getBoundingClientRect();
|
|
8269
|
+
const placement = clientX > rect.left + rect.width / 2 ? "after" : "before";
|
|
8270
|
+
return { decoratorKey: decorator.dataset.lexicalNodeKey, placement }
|
|
8271
|
+
}
|
|
8272
|
+
|
|
8161
8273
|
#moveAttachment(draggedKey, dropPoint) {
|
|
8162
8274
|
this.#editor.update(() => {
|
|
8163
8275
|
const draggedNode = $getNodeByKey(draggedKey);
|
|
8164
8276
|
if (!$isCustomActionTextAttachmentNode(draggedNode)) return
|
|
8165
8277
|
|
|
8166
|
-
|
|
8167
|
-
|
|
8168
|
-
|
|
8169
|
-
|
|
8170
|
-
|
|
8171
|
-
|
|
8172
|
-
|
|
8278
|
+
if (dropPoint.decoratorKey) {
|
|
8279
|
+
this.#moveBesideNode(draggedNode, dropPoint);
|
|
8280
|
+
} else {
|
|
8281
|
+
this.#moveToCaret(draggedNode, dropPoint);
|
|
8282
|
+
}
|
|
8283
|
+
});
|
|
8284
|
+
}
|
|
8173
8285
|
|
|
8174
|
-
|
|
8286
|
+
#moveBesideNode(draggedNode, { decoratorKey, placement }) {
|
|
8287
|
+
const targetNode = $getNodeByKey(decoratorKey);
|
|
8288
|
+
if (!targetNode || targetNode === draggedNode) return
|
|
8175
8289
|
|
|
8176
|
-
|
|
8177
|
-
|
|
8178
|
-
|
|
8290
|
+
draggedNode.remove();
|
|
8291
|
+
if (placement === "after") {
|
|
8292
|
+
targetNode.insertAfter(draggedNode);
|
|
8293
|
+
} else {
|
|
8294
|
+
targetNode.insertBefore(draggedNode);
|
|
8295
|
+
}
|
|
8296
|
+
}
|
|
8297
|
+
|
|
8298
|
+
#moveToCaret(draggedNode, dropPoint) {
|
|
8299
|
+
const selection = $createRangeSelectionFromDom({
|
|
8300
|
+
anchorNode: dropPoint.node,
|
|
8301
|
+
anchorOffset: dropPoint.offset,
|
|
8302
|
+
focusNode: dropPoint.node,
|
|
8303
|
+
focusOffset: dropPoint.offset
|
|
8304
|
+
}, this.#editor);
|
|
8305
|
+
if (!selection) return
|
|
8306
|
+
|
|
8307
|
+
$setSelection(selection);
|
|
8308
|
+
|
|
8309
|
+
draggedNode.remove();
|
|
8310
|
+
selection.insertNodes([ draggedNode ]);
|
|
8179
8311
|
}
|
|
8180
8312
|
|
|
8181
8313
|
#updateDropIndicator(event) {
|
|
@@ -8185,7 +8317,12 @@ class CustomAttachmentDragAndDrop {
|
|
|
8185
8317
|
if (dropPoint) this.#showCaret(this.#caretRectFor(dropPoint));
|
|
8186
8318
|
}
|
|
8187
8319
|
|
|
8188
|
-
#caretRectFor(
|
|
8320
|
+
#caretRectFor(dropPoint) {
|
|
8321
|
+
if (dropPoint.decoratorKey) {
|
|
8322
|
+
return this.#decoratorEdgeRect(dropPoint)
|
|
8323
|
+
}
|
|
8324
|
+
|
|
8325
|
+
const { node, offset } = dropPoint;
|
|
8189
8326
|
const rect = caretRect(node, offset);
|
|
8190
8327
|
if (rect) return rect
|
|
8191
8328
|
|
|
@@ -8197,6 +8334,15 @@ class CustomAttachmentDragAndDrop {
|
|
|
8197
8334
|
return { left: lineRect.left, top: lineRect.top, height: lineRect.height }
|
|
8198
8335
|
}
|
|
8199
8336
|
|
|
8337
|
+
#decoratorEdgeRect({ decoratorKey, placement }) {
|
|
8338
|
+
const decorator = this.#editor.getRootElement()?.querySelector(`[data-lexical-node-key="${decoratorKey}"]`);
|
|
8339
|
+
if (!decorator) return null
|
|
8340
|
+
|
|
8341
|
+
const rect = decorator.getBoundingClientRect();
|
|
8342
|
+
const left = placement === "after" ? rect.right : rect.left;
|
|
8343
|
+
return { left, top: rect.top, height: rect.height }
|
|
8344
|
+
}
|
|
8345
|
+
|
|
8200
8346
|
#showCaret(rect) {
|
|
8201
8347
|
if (!rect) return
|
|
8202
8348
|
|
|
@@ -10902,4 +11048,4 @@ const configure = Lexxy.configure;
|
|
|
10902
11048
|
// Pushing elements definition to after the current call stack to allow global configuration to take place first
|
|
10903
11049
|
setTimeout(defineElements, 0);
|
|
10904
11050
|
|
|
10905
|
-
export { $createActionTextAttachmentNode, $createActionTextAttachmentUploadNode, $isActionTextAttachmentNode, $isCustomActionTextAttachmentNode, ActionTextAttachmentNode, ActionTextAttachmentUploadNode, CustomActionTextAttachmentNode, LexxyExtension as Extension, HorizontalDividerNode, NativeAdapter, configure };
|
|
11051
|
+
export { $createActionTextAttachmentNode, $createActionTextAttachmentUploadNode, $isActionTextAttachmentNode, $isCustomActionTextAttachmentNode, ActionTextAttachmentNode, ActionTextAttachmentUploadNode, CustomActionTextAttachmentNode, LexxyExtension as Extension, HorizontalDividerNode, NativeAdapter, REWRITE_HISTORY_COMMAND, configure };
|
|
@@ -40,16 +40,16 @@ function highlightElement(preElement) {
|
|
|
40
40
|
if (preElement.dataset.highlighted === "true") return
|
|
41
41
|
|
|
42
42
|
const language = preElement.getAttribute("data-language");
|
|
43
|
-
let code = preElement.innerHTML.replace(/<br\s*\/?>/gi, "\n");
|
|
44
43
|
|
|
45
44
|
const grammar = Prism.languages?.[language];
|
|
46
45
|
if (!grammar) return
|
|
47
46
|
|
|
48
|
-
//
|
|
49
|
-
|
|
50
|
-
|
|
51
|
-
//
|
|
52
|
-
|
|
47
|
+
// Read the source text and <mark> ranges in a single walk, before Prism
|
|
48
|
+
// rewrites the element. Sharing one traversal keeps the highlight offsets
|
|
49
|
+
// aligned with the code string and preserves leading whitespace — deriving
|
|
50
|
+
// either of them separately (e.g. textContent through DOMParser) collapses
|
|
51
|
+
// leading whitespace and shifts every range, re-indenting the rendered block.
|
|
52
|
+
const { code, highlights } = extractCodeAndHighlights(preElement);
|
|
53
53
|
|
|
54
54
|
const highlightedHtml = Prism.highlight(code, grammar, language);
|
|
55
55
|
preElement.innerHTML = highlightedHtml;
|
|
@@ -61,34 +61,35 @@ function highlightElement(preElement) {
|
|
|
61
61
|
preElement.dataset.highlighted = "true";
|
|
62
62
|
}
|
|
63
63
|
|
|
64
|
-
// Walk the
|
|
65
|
-
//
|
|
66
|
-
|
|
67
|
-
|
|
64
|
+
// Walk the <pre> once, building Prism's source text and the <mark> ranges
|
|
65
|
+
// together: a text node contributes its text verbatim, a <br> contributes a
|
|
66
|
+
// newline, and a <mark> records the slice of code it covers. Because both
|
|
67
|
+
// outputs come from the same walk, every range offset is just a position in
|
|
68
|
+
// `code` — so the highlights can't drift out of sync with the source, and the
|
|
69
|
+
// block's leading whitespace survives (HTML parsing would collapse it).
|
|
70
|
+
function extractCodeAndHighlights(preElement) {
|
|
68
71
|
const root = preElement.querySelector("code") || preElement;
|
|
69
|
-
|
|
70
|
-
let
|
|
72
|
+
const highlights = [];
|
|
73
|
+
let code = "";
|
|
71
74
|
|
|
72
75
|
function walk(node) {
|
|
73
76
|
if (node.nodeType === Node.TEXT_NODE) {
|
|
74
|
-
|
|
77
|
+
code += node.textContent;
|
|
75
78
|
} else if (node.nodeType === Node.ELEMENT_NODE) {
|
|
76
79
|
if (node.tagName === "BR") {
|
|
77
|
-
|
|
78
|
-
|
|
79
|
-
|
|
80
|
-
|
|
81
|
-
|
|
82
|
-
|
|
83
|
-
|
|
84
|
-
for (const child of node.childNodes) {
|
|
85
|
-
walk(child);
|
|
86
|
-
}
|
|
87
|
-
|
|
88
|
-
if (isMark) {
|
|
80
|
+
code += "\n";
|
|
81
|
+
} else if (node.tagName === "MARK") {
|
|
82
|
+
const start = code.length;
|
|
83
|
+
for (const child of node.childNodes) {
|
|
84
|
+
walk(child);
|
|
85
|
+
}
|
|
89
86
|
const style = extractStyle(node);
|
|
90
87
|
if (style) {
|
|
91
|
-
|
|
88
|
+
highlights.push({ start, end: code.length, style });
|
|
89
|
+
}
|
|
90
|
+
} else {
|
|
91
|
+
for (const child of node.childNodes) {
|
|
92
|
+
walk(child);
|
|
92
93
|
}
|
|
93
94
|
}
|
|
94
95
|
}
|
|
@@ -98,7 +99,7 @@ function extractHighlightRanges(preElement) {
|
|
|
98
99
|
walk(child);
|
|
99
100
|
}
|
|
100
101
|
|
|
101
|
-
return
|
|
102
|
+
return { code, highlights }
|
|
102
103
|
}
|
|
103
104
|
|
|
104
105
|
function extractStyle(element) {
|