@37signals/lexxy 0.9.19 → 0.9.21
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 +218 -40
- 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
|
}
|
|
@@ -6418,7 +6486,7 @@ class Clipboard {
|
|
|
6418
6486
|
}
|
|
6419
6487
|
|
|
6420
6488
|
if (this.#isPlainTextOrURLPasted(clipboardData)) {
|
|
6421
|
-
this.#
|
|
6489
|
+
this.#pastePlainTextOrURL(clipboardData);
|
|
6422
6490
|
event.preventDefault();
|
|
6423
6491
|
return true
|
|
6424
6492
|
}
|
|
@@ -6456,14 +6524,34 @@ class Clipboard {
|
|
|
6456
6524
|
return types.length === 1 && types[0] === "text/plain"
|
|
6457
6525
|
}
|
|
6458
6526
|
|
|
6527
|
+
// Browsers expose a copied URL in several shapes:
|
|
6528
|
+
// Safari [ text/plain, text/uri-list ]
|
|
6529
|
+
// App ShareSheet [ text/uri-list ]
|
|
6530
|
+
// Chromium macOS [ text/plain, text/html, (?:text/link-preview) ]
|
|
6459
6531
|
#isOnlyURLPasted(clipboardData) {
|
|
6460
|
-
|
|
6461
|
-
|
|
6532
|
+
if (this.#isLexicalClipboardData(clipboardData)) return false
|
|
6533
|
+
|
|
6462
6534
|
const types = Array.from(clipboardData.types);
|
|
6463
|
-
|
|
6464
|
-
|
|
6465
|
-
|
|
6466
|
-
|
|
6535
|
+
if (types.includes("text/uri-list")) {
|
|
6536
|
+
return types.every(type => type === "text/plain" || type === "text/uri-list")
|
|
6537
|
+
}
|
|
6538
|
+
|
|
6539
|
+
if (clipboardData.files.length) return false
|
|
6540
|
+
|
|
6541
|
+
const text = clipboardData.getData("text/plain").trim();
|
|
6542
|
+
if (!isAutolinkableURL(text)) return false
|
|
6543
|
+
|
|
6544
|
+
const html = clipboardData.getData("text/html");
|
|
6545
|
+
return !html || this.#htmlIsBareLinkToURL(html, text)
|
|
6546
|
+
}
|
|
6547
|
+
|
|
6548
|
+
#htmlIsBareLinkToURL(html, url) {
|
|
6549
|
+
const doc = parseHtml(html);
|
|
6550
|
+
if (doc.body.textContent.trim() !== url) return false
|
|
6551
|
+
|
|
6552
|
+
const links = doc.body.querySelectorAll("a");
|
|
6553
|
+
if (links.length === 0) return true
|
|
6554
|
+
return links.length === 1 && links[0].getAttribute("href") === url
|
|
6467
6555
|
}
|
|
6468
6556
|
|
|
6469
6557
|
#isPastingIntoCodeBlock() {
|
|
@@ -6497,14 +6585,12 @@ class Clipboard {
|
|
|
6497
6585
|
}, { tag: PASTE_TAG });
|
|
6498
6586
|
}
|
|
6499
6587
|
|
|
6500
|
-
#
|
|
6501
|
-
const item = clipboardData
|
|
6588
|
+
#pastePlainTextOrURL(clipboardData) {
|
|
6589
|
+
const item = this.#plainTextOrURLItem(clipboardData);
|
|
6502
6590
|
item.getAsString((text) => {
|
|
6503
|
-
|
|
6504
|
-
|
|
6505
|
-
|
|
6506
|
-
const nodeKey = this.contents.createLink(text);
|
|
6507
|
-
this.#dispatchLinkInsertEvent(nodeKey, { url: text });
|
|
6591
|
+
const url = text.trim();
|
|
6592
|
+
if (isAutolinkableURL(url)) {
|
|
6593
|
+
this.#pasteURL(url);
|
|
6508
6594
|
} else if (this.editorElement.supportsMarkdown) {
|
|
6509
6595
|
this.#pasteMarkdown(text);
|
|
6510
6596
|
} else {
|
|
@@ -6513,6 +6599,20 @@ class Clipboard {
|
|
|
6513
6599
|
});
|
|
6514
6600
|
}
|
|
6515
6601
|
|
|
6602
|
+
#plainTextOrURLItem(clipboardData) {
|
|
6603
|
+
const items = Array.from(clipboardData.items);
|
|
6604
|
+
return items.find((item) => item.type === "text/plain") || items.find((item) => item.type === "text/uri-list")
|
|
6605
|
+
}
|
|
6606
|
+
|
|
6607
|
+
#pasteURL(url) {
|
|
6608
|
+
if (this.contents.hasSelectedText()) {
|
|
6609
|
+
this.contents.createLinkWithSelectedText(url);
|
|
6610
|
+
} else {
|
|
6611
|
+
const nodeKey = this.contents.createLink(url);
|
|
6612
|
+
this.#dispatchLinkInsertEvent(nodeKey, { url });
|
|
6613
|
+
}
|
|
6614
|
+
}
|
|
6615
|
+
|
|
6516
6616
|
#insertSingleLinkAt(selection, url) {
|
|
6517
6617
|
if (!$isRangeSelection(selection)) return
|
|
6518
6618
|
|
|
@@ -6560,14 +6660,16 @@ class Clipboard {
|
|
|
6560
6660
|
|
|
6561
6661
|
// Markdown conversion collapses runs of whitespace and unescapes backslashes,
|
|
6562
6662
|
// silently corrupting plain text such as Windows/UNC file paths. When the text
|
|
6563
|
-
// carries no Markdown structure, paste it verbatim instead.
|
|
6663
|
+
// carries no Markdown structure, paste it verbatim instead. A path that wrapped
|
|
6664
|
+
// across lines renders as a single paragraph with <br> line breaks (marked runs
|
|
6665
|
+
// with breaks: true), which is still plain text we should preserve untouched.
|
|
6564
6666
|
#isPlainTextWithoutMarkdown(doc) {
|
|
6565
6667
|
const elements = Array.from(doc.body.children);
|
|
6566
6668
|
if (elements.length !== 1) return false
|
|
6567
6669
|
|
|
6568
6670
|
const paragraph = elements[0];
|
|
6569
6671
|
return paragraph.nodeName === "P"
|
|
6570
|
-
&& Array.from(paragraph.childNodes).every((node) => node.nodeType === Node.TEXT_NODE)
|
|
6672
|
+
&& Array.from(paragraph.childNodes).every((node) => node.nodeType === Node.TEXT_NODE || node.nodeName === "BR")
|
|
6571
6673
|
}
|
|
6572
6674
|
|
|
6573
6675
|
#pasteRichText(clipboardData) {
|
|
@@ -7855,24 +7957,46 @@ class FormatEscapeExtension extends LexxyExtension {
|
|
|
7855
7957
|
}
|
|
7856
7958
|
|
|
7857
7959
|
function $escapeFromBlockquote() {
|
|
7960
|
+
return $escapeBeforeBlockquoteStart() || $escapeFromBlankBlockquoteParagraph()
|
|
7961
|
+
}
|
|
7962
|
+
|
|
7963
|
+
function $escapeBeforeBlockquoteStart() {
|
|
7964
|
+
const selection = $getSelection();
|
|
7965
|
+
if (!$isRangeSelection(selection) || !selection.isCollapsed() || selection.anchor.offset !== 0) return false
|
|
7966
|
+
|
|
7967
|
+
const paragraph = $getNearestNodeOfType(selection.anchor.getNode(), ParagraphNode);
|
|
7968
|
+
if (paragraph && !$isBlankNode(paragraph) && !paragraph.getPreviousSibling()) {
|
|
7969
|
+
const blockquote = paragraph.getParent();
|
|
7970
|
+
if ($isQuoteNode(blockquote)) {
|
|
7971
|
+
blockquote.insertBefore($createParagraphNode());
|
|
7972
|
+
return true
|
|
7973
|
+
}
|
|
7974
|
+
}
|
|
7975
|
+
|
|
7976
|
+
return false
|
|
7977
|
+
}
|
|
7978
|
+
|
|
7979
|
+
function $escapeFromBlankBlockquoteParagraph() {
|
|
7858
7980
|
const anchorNode = $getSelection().anchor.getNode();
|
|
7859
7981
|
|
|
7860
7982
|
const paragraph = $getNearestNodeOfType(anchorNode, ParagraphNode);
|
|
7861
7983
|
if (!paragraph || !$isBlankNode(paragraph)) return false
|
|
7862
7984
|
|
|
7863
7985
|
const blockquote = paragraph.getParent();
|
|
7864
|
-
if (
|
|
7986
|
+
if ($isQuoteNode(blockquote)) {
|
|
7987
|
+
const nonEmptySiblings = paragraph.getNextSiblings().filter(sibling => !$isBlankNode(sibling));
|
|
7865
7988
|
|
|
7866
|
-
|
|
7989
|
+
if (nonEmptySiblings.length > 0) {
|
|
7990
|
+
$splitQuoteNode(blockquote, paragraph);
|
|
7991
|
+
} else {
|
|
7992
|
+
blockquote.insertAfter(paragraph);
|
|
7993
|
+
paragraph.selectStart();
|
|
7994
|
+
}
|
|
7867
7995
|
|
|
7868
|
-
|
|
7869
|
-
$splitQuoteNode(blockquote, paragraph);
|
|
7870
|
-
} else {
|
|
7871
|
-
blockquote.insertAfter(paragraph);
|
|
7872
|
-
paragraph.selectStart();
|
|
7996
|
+
return true
|
|
7873
7997
|
}
|
|
7874
7998
|
|
|
7875
|
-
return
|
|
7999
|
+
return false
|
|
7876
8000
|
}
|
|
7877
8001
|
|
|
7878
8002
|
function $splitQuoteNode(node, paragraph) {
|
|
@@ -8130,6 +8254,15 @@ class CustomAttachmentDragAndDrop {
|
|
|
8130
8254
|
// they only drop onto an existing line, so snap to the nearest one.
|
|
8131
8255
|
if (caret.node === rootElement) {
|
|
8132
8256
|
return this.#nearestLineCaret(rootElement, event.clientY)
|
|
8257
|
+
}
|
|
8258
|
+
|
|
8259
|
+
// When mentions sit next to each other with no text between them, the caret
|
|
8260
|
+
// lands inside the neighbouring decorator's DOM. Lexical can't resolve a point
|
|
8261
|
+
// inside a decorator to an editable position, and the cursor has no business
|
|
8262
|
+
// showing there anyway, so snap to just before or after that mention.
|
|
8263
|
+
const decorator = this.#decoratorElementContaining(caret.node);
|
|
8264
|
+
if (decorator) {
|
|
8265
|
+
return this.#dropPointBesideDecorator(decorator, event.clientX)
|
|
8133
8266
|
} else {
|
|
8134
8267
|
return caret
|
|
8135
8268
|
}
|
|
@@ -8158,24 +8291,55 @@ class CustomAttachmentDragAndDrop {
|
|
|
8158
8291
|
}
|
|
8159
8292
|
}
|
|
8160
8293
|
|
|
8294
|
+
#decoratorElementContaining(node) {
|
|
8295
|
+
const element = node.nodeType === Node.ELEMENT_NODE ? node : node.parentElement;
|
|
8296
|
+
return element?.closest("[data-lexxy-decorator][data-lexical-node-key]")
|
|
8297
|
+
}
|
|
8298
|
+
|
|
8299
|
+
#dropPointBesideDecorator(decorator, clientX) {
|
|
8300
|
+
const rect = decorator.getBoundingClientRect();
|
|
8301
|
+
const placement = clientX > rect.left + rect.width / 2 ? "after" : "before";
|
|
8302
|
+
return { decoratorKey: decorator.dataset.lexicalNodeKey, placement }
|
|
8303
|
+
}
|
|
8304
|
+
|
|
8161
8305
|
#moveAttachment(draggedKey, dropPoint) {
|
|
8162
8306
|
this.#editor.update(() => {
|
|
8163
8307
|
const draggedNode = $getNodeByKey(draggedKey);
|
|
8164
8308
|
if (!$isCustomActionTextAttachmentNode(draggedNode)) return
|
|
8165
8309
|
|
|
8166
|
-
|
|
8167
|
-
|
|
8168
|
-
|
|
8169
|
-
|
|
8170
|
-
|
|
8171
|
-
|
|
8172
|
-
|
|
8310
|
+
if (dropPoint.decoratorKey) {
|
|
8311
|
+
this.#moveBesideNode(draggedNode, dropPoint);
|
|
8312
|
+
} else {
|
|
8313
|
+
this.#moveToCaret(draggedNode, dropPoint);
|
|
8314
|
+
}
|
|
8315
|
+
});
|
|
8316
|
+
}
|
|
8173
8317
|
|
|
8174
|
-
|
|
8318
|
+
#moveBesideNode(draggedNode, { decoratorKey, placement }) {
|
|
8319
|
+
const targetNode = $getNodeByKey(decoratorKey);
|
|
8320
|
+
if (!targetNode || targetNode === draggedNode) return
|
|
8175
8321
|
|
|
8176
|
-
|
|
8177
|
-
|
|
8178
|
-
|
|
8322
|
+
draggedNode.remove();
|
|
8323
|
+
if (placement === "after") {
|
|
8324
|
+
targetNode.insertAfter(draggedNode);
|
|
8325
|
+
} else {
|
|
8326
|
+
targetNode.insertBefore(draggedNode);
|
|
8327
|
+
}
|
|
8328
|
+
}
|
|
8329
|
+
|
|
8330
|
+
#moveToCaret(draggedNode, dropPoint) {
|
|
8331
|
+
const selection = $createRangeSelectionFromDom({
|
|
8332
|
+
anchorNode: dropPoint.node,
|
|
8333
|
+
anchorOffset: dropPoint.offset,
|
|
8334
|
+
focusNode: dropPoint.node,
|
|
8335
|
+
focusOffset: dropPoint.offset
|
|
8336
|
+
}, this.#editor);
|
|
8337
|
+
if (!selection) return
|
|
8338
|
+
|
|
8339
|
+
$setSelection(selection);
|
|
8340
|
+
|
|
8341
|
+
draggedNode.remove();
|
|
8342
|
+
selection.insertNodes([ draggedNode ]);
|
|
8179
8343
|
}
|
|
8180
8344
|
|
|
8181
8345
|
#updateDropIndicator(event) {
|
|
@@ -8185,7 +8349,12 @@ class CustomAttachmentDragAndDrop {
|
|
|
8185
8349
|
if (dropPoint) this.#showCaret(this.#caretRectFor(dropPoint));
|
|
8186
8350
|
}
|
|
8187
8351
|
|
|
8188
|
-
#caretRectFor(
|
|
8352
|
+
#caretRectFor(dropPoint) {
|
|
8353
|
+
if (dropPoint.decoratorKey) {
|
|
8354
|
+
return this.#decoratorEdgeRect(dropPoint)
|
|
8355
|
+
}
|
|
8356
|
+
|
|
8357
|
+
const { node, offset } = dropPoint;
|
|
8189
8358
|
const rect = caretRect(node, offset);
|
|
8190
8359
|
if (rect) return rect
|
|
8191
8360
|
|
|
@@ -8197,6 +8366,15 @@ class CustomAttachmentDragAndDrop {
|
|
|
8197
8366
|
return { left: lineRect.left, top: lineRect.top, height: lineRect.height }
|
|
8198
8367
|
}
|
|
8199
8368
|
|
|
8369
|
+
#decoratorEdgeRect({ decoratorKey, placement }) {
|
|
8370
|
+
const decorator = this.#editor.getRootElement()?.querySelector(`[data-lexical-node-key="${decoratorKey}"]`);
|
|
8371
|
+
if (!decorator) return null
|
|
8372
|
+
|
|
8373
|
+
const rect = decorator.getBoundingClientRect();
|
|
8374
|
+
const left = placement === "after" ? rect.right : rect.left;
|
|
8375
|
+
return { left, top: rect.top, height: rect.height }
|
|
8376
|
+
}
|
|
8377
|
+
|
|
8200
8378
|
#showCaret(rect) {
|
|
8201
8379
|
if (!rect) return
|
|
8202
8380
|
|
|
@@ -10902,4 +11080,4 @@ const configure = Lexxy.configure;
|
|
|
10902
11080
|
// Pushing elements definition to after the current call stack to allow global configuration to take place first
|
|
10903
11081
|
setTimeout(defineElements, 0);
|
|
10904
11082
|
|
|
10905
|
-
export { $createActionTextAttachmentNode, $createActionTextAttachmentUploadNode, $isActionTextAttachmentNode, $isCustomActionTextAttachmentNode, ActionTextAttachmentNode, ActionTextAttachmentUploadNode, CustomActionTextAttachmentNode, LexxyExtension as Extension, HorizontalDividerNode, NativeAdapter, configure };
|
|
11083
|
+
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) {
|