@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 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
  }
@@ -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 (!blockquote || !$isQuoteNode(blockquote)) return false
7954
+ if ($isQuoteNode(blockquote)) {
7955
+ const nonEmptySiblings = paragraph.getNextSiblings().filter(sibling => !$isBlankNode(sibling));
7865
7956
 
7866
- const nonEmptySiblings = paragraph.getNextSiblings().filter(sibling => !$isBlankNode(sibling));
7957
+ if (nonEmptySiblings.length > 0) {
7958
+ $splitQuoteNode(blockquote, paragraph);
7959
+ } else {
7960
+ blockquote.insertAfter(paragraph);
7961
+ paragraph.selectStart();
7962
+ }
7867
7963
 
7868
- if (nonEmptySiblings.length > 0) {
7869
- $splitQuoteNode(blockquote, paragraph);
7870
- } else {
7871
- blockquote.insertAfter(paragraph);
7872
- paragraph.selectStart();
7964
+ return true
7873
7965
  }
7874
7966
 
7875
- return true
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
- 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
8278
+ if (dropPoint.decoratorKey) {
8279
+ this.#moveBesideNode(draggedNode, dropPoint);
8280
+ } else {
8281
+ this.#moveToCaret(draggedNode, dropPoint);
8282
+ }
8283
+ });
8284
+ }
8173
8285
 
8174
- $setSelection(selection);
8286
+ #moveBesideNode(draggedNode, { decoratorKey, placement }) {
8287
+ const targetNode = $getNodeByKey(decoratorKey);
8288
+ if (!targetNode || targetNode === draggedNode) return
8175
8289
 
8176
- draggedNode.remove();
8177
- selection.insertNodes([ draggedNode ]);
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({ node, offset }) {
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
- // 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.20",
4
4
  "description": "Lexxy - A modern rich text editor for Rails.",
5
5
  "module": "dist/lexxy.esm.js",
6
6
  "type": "module",