@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 CHANGED
@@ -172,8 +172,8 @@ function dispatch(element, eventName, detail = null, cancelable = false) {
172
172
  }
173
173
 
174
174
  function addBlockSpacing(doc) {
175
- const blocks = doc.querySelectorAll("body > :not(h1, h2, h3, h4, h5, h6) + *");
176
- for (const block of blocks) {
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().append($createTextNode(text));
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.#pastePlainText(clipboardData);
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
- // Safari URLs are copied as a text/plain + text/uri-list object
6461
- // App ShareSheet URLs are copied as solo text/uri-list object
6532
+ if (this.#isLexicalClipboardData(clipboardData)) return false
6533
+
6462
6534
  const types = Array.from(clipboardData.types);
6463
- return types.length
6464
- && types.length <= 2
6465
- && types.includes("text/uri-list")
6466
- && (types.length < 2 || types.includes("text/plain"))
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
- #pastePlainText(clipboardData) {
6501
- const item = clipboardData.items[0];
6588
+ #pastePlainTextOrURL(clipboardData) {
6589
+ const item = this.#plainTextOrURLItem(clipboardData);
6502
6590
  item.getAsString((text) => {
6503
- if (isAutolinkableURL(text) && this.contents.hasSelectedText()) {
6504
- this.contents.createLinkWithSelectedText(text);
6505
- } else if (isAutolinkableURL(text)) {
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 (!blockquote || !$isQuoteNode(blockquote)) return false
7986
+ if ($isQuoteNode(blockquote)) {
7987
+ const nonEmptySiblings = paragraph.getNextSiblings().filter(sibling => !$isBlankNode(sibling));
7865
7988
 
7866
- const nonEmptySiblings = paragraph.getNextSiblings().filter(sibling => !$isBlankNode(sibling));
7989
+ if (nonEmptySiblings.length > 0) {
7990
+ $splitQuoteNode(blockquote, paragraph);
7991
+ } else {
7992
+ blockquote.insertAfter(paragraph);
7993
+ paragraph.selectStart();
7994
+ }
7867
7995
 
7868
- if (nonEmptySiblings.length > 0) {
7869
- $splitQuoteNode(blockquote, paragraph);
7870
- } else {
7871
- blockquote.insertAfter(paragraph);
7872
- paragraph.selectStart();
7996
+ return true
7873
7997
  }
7874
7998
 
7875
- return true
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
- const selection = $createRangeSelectionFromDom({
8167
- anchorNode: dropPoint.node,
8168
- anchorOffset: dropPoint.offset,
8169
- focusNode: dropPoint.node,
8170
- focusOffset: dropPoint.offset
8171
- }, this.#editor);
8172
- if (!selection) return
8310
+ if (dropPoint.decoratorKey) {
8311
+ this.#moveBesideNode(draggedNode, dropPoint);
8312
+ } else {
8313
+ this.#moveToCaret(draggedNode, dropPoint);
8314
+ }
8315
+ });
8316
+ }
8173
8317
 
8174
- $setSelection(selection);
8318
+ #moveBesideNode(draggedNode, { decoratorKey, placement }) {
8319
+ const targetNode = $getNodeByKey(decoratorKey);
8320
+ if (!targetNode || targetNode === draggedNode) return
8175
8321
 
8176
- draggedNode.remove();
8177
- selection.insertNodes([ draggedNode ]);
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({ node, offset }) {
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
- // Extract highlight ranges before Prism destroys <mark> elements
49
- const highlights = extractHighlightRanges(preElement);
50
-
51
- // unescape HTML entities in the code block
52
- code = new DOMParser().parseFromString(code, "text/html").body.textContent || "";
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 DOM tree inside a <pre> element and build a list of
65
- // { start, end, style } ranges for every <mark> element found.
66
- function extractHighlightRanges(preElement) {
67
- const ranges = [];
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 offset = 0;
72
+ const highlights = [];
73
+ let code = "";
71
74
 
72
75
  function walk(node) {
73
76
  if (node.nodeType === Node.TEXT_NODE) {
74
- offset += node.textContent.length;
77
+ code += node.textContent;
75
78
  } else if (node.nodeType === Node.ELEMENT_NODE) {
76
79
  if (node.tagName === "BR") {
77
- offset += 1;
78
- return
79
- }
80
-
81
- const isMark = node.tagName === "MARK";
82
- const start = offset;
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
- ranges.push({ start, end: offset, style });
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 ranges
102
+ return { code, highlights }
102
103
  }
103
104
 
104
105
  function extractStyle(element) {
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@37signals/lexxy",
3
- "version": "0.9.19",
3
+ "version": "0.9.21",
4
4
  "description": "Lexxy - A modern rich text editor for Rails.",
5
5
  "module": "dist/lexxy.esm.js",
6
6
  "type": "module",