@37signals/lexxy 0.9.15-beta.next-0 → 0.9.16

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.
@@ -0,0 +1,344 @@
1
+ import { $getRoot, $caretFromPoint, $setSelectionFromCaretRange, $getCaretRange, $normalizeCaret, $getChildCaret, $getCaretInDirection, $isParagraphNode, $isLineBreakNode, $createParagraphNode, $isElementNode, $isRootOrShadowRoot, $isRootNode, $createNodeSelection, $isDecoratorNode, $isTextNode, $getSiblingCaret, $rewindSiblingCaret, $splitAtPointCaretNext, $getSelection, $isChildCaret, $isTextPointCaret, $isExtendableTextPointCaret, $isSiblingCaret, $isRangeSelection, $getCommonAncestor, $findMatchingParent, TextNode } from 'lexical';
2
+ export * from 'lexical';
3
+ import { ListNode } from '@lexical/list';
4
+ import { $getNearestNodeOfType, $wrapNodeInElement, $lastToFirstIterator } from '@lexical/utils';
5
+ import { $ensureForwardRangeSelection, $isAtNodeEnd } from '@lexical/selection';
6
+
7
+ /*** Only import from lexical packages in this file to prevent breaking npm package export chunking ***/
8
+
9
+ function $containsRangeSelection(node, selection = $getSelection()) {
10
+ if ($isRangeSelection(selection)) {
11
+ const { commonAncestor } = $getCommonAncestor(selection.focus.getNode(), selection.anchor.getNode());
12
+ return $findMatchingParent(commonAncestor, parent => parent.is(node))
13
+ } else {
14
+ return false
15
+ }
16
+ }
17
+
18
+ function $createNodeSelectionWith(...nodes) {
19
+ const selection = $createNodeSelection();
20
+ nodes.forEach(node => selection.add(node.getKey()));
21
+ return selection
22
+ }
23
+
24
+ function $isShadowRoot(node) {
25
+ return $isElementNode(node) && $isRootOrShadowRoot(node) && !$isRootNode(node)
26
+ }
27
+
28
+ function $isSafeForRoot(node) {
29
+ return ($isElementNode(node) || $isDecoratorNode(node)) && !node.isParentRequired()
30
+ }
31
+
32
+ function $makeSafeForRoot(node) {
33
+ if ($isSafeForRoot(node)) {
34
+ return node
35
+ } else {
36
+ return $wrapNodeInElement(node, () => node.createParentElementNode())
37
+ }
38
+ }
39
+
40
+ function getListType(node) {
41
+ const list = $getNearestNodeOfType(node, ListNode);
42
+ return list?.getListType() ?? null
43
+ }
44
+
45
+ function isEditorFocused(editor) {
46
+ const rootElement = editor.getRootElement();
47
+ return rootElement !== null && rootElement.contains(document.activeElement)
48
+ }
49
+
50
+ function $isAtNodeEdge(point, atStart = null) {
51
+ if (atStart === null) {
52
+ return $isAtNodeEdge(point, true) || $isAtNodeEdge(point, false)
53
+ } else {
54
+ return atStart ? $isAtNodeStart(point) : $isAtNodeEnd(point)
55
+ }
56
+ }
57
+
58
+ function $isAtNodeStart(point) {
59
+ return point.offset === 0
60
+ }
61
+
62
+ function extendTextNodeConversion(conversionName, ...callbacks) {
63
+ return extendConversion(TextNode, conversionName, (conversionOutput, element) => ({
64
+ ...conversionOutput,
65
+ forChild: (lexicalNode, parentNode) => {
66
+ const originalForChild = conversionOutput?.forChild ?? (x => x);
67
+ let childNode = originalForChild(lexicalNode, parentNode);
68
+
69
+
70
+ if ($isTextNode(childNode)) {
71
+ childNode = callbacks.reduce(
72
+ (childNode, callback) => callback(childNode, element) ?? childNode,
73
+ childNode
74
+ );
75
+ return childNode
76
+ }
77
+ }
78
+ }))
79
+ }
80
+
81
+ function extendConversion(nodeKlass, conversionName, callback = (output => output)) {
82
+ return (element) => {
83
+ const converter = nodeKlass.importDOM()?.[conversionName]?.(element);
84
+ if (!converter) return null
85
+
86
+ const conversionOutput = converter.conversion(element);
87
+ if (!conversionOutput) return conversionOutput
88
+
89
+ return callback(conversionOutput, element) ?? conversionOutput
90
+ }
91
+ }
92
+
93
+ function $isCursorOnLastLine(selection) {
94
+ const anchorNode = selection.anchor.getNode();
95
+ const elementNode = $isElementNode(anchorNode) ? anchorNode : anchorNode.getParentOrThrow();
96
+ const children = elementNode.getChildren();
97
+ if (children.length === 0) return true
98
+
99
+ const lastChild = children[children.length - 1];
100
+
101
+ if (anchorNode === elementNode.getLatest() && selection.anchor.offset === children.length) return true
102
+ if (anchorNode === lastChild) return true
103
+
104
+ const lastLineBreakIndex = children.findLastIndex(child => $isLineBreakNode(child));
105
+ if (lastLineBreakIndex === -1) return true
106
+
107
+ const anchorIndex = children.indexOf(anchorNode);
108
+ return anchorIndex > lastLineBreakIndex
109
+ }
110
+
111
+ function $isBlankNode(node) {
112
+ if (node.getTextContent().trim() !== "") return false
113
+
114
+ const children = node.getChildren?.();
115
+ if (!children || children.length === 0) return true
116
+
117
+ return children.every(child => {
118
+ if ($isLineBreakNode(child)) return true
119
+ return $isBlankNode(child)
120
+ })
121
+ }
122
+
123
+ function $trimTrailingBlankNodes(parent) {
124
+ for (const child of $lastToFirstIterator(parent)) {
125
+ if ($isBlankNode(child)) {
126
+ child.remove();
127
+ } else {
128
+ break
129
+ }
130
+ }
131
+ }
132
+
133
+ // A list item is structurally empty if it contains no meaningful content.
134
+ // Unlike getTextContent().trim() === "", this walks descendants to ensure
135
+ // decorator nodes (mentions, attachments whose getTextContent() may return
136
+ // invisible characters like \ufeff) are treated as non-empty content.
137
+ function $isListItemStructurallyEmpty(listItem) {
138
+ const children = listItem.getChildren();
139
+ for (const child of children) {
140
+ if ($isDecoratorNode(child)) return false
141
+ if ($isLineBreakNode(child)) continue
142
+ if ($isTextNode(child)) {
143
+ if (child.getTextContent().trim() !== "") return false
144
+ } else if ($isElementNode(child)) {
145
+ if (child.getTextContent().trim() !== "") return false
146
+ }
147
+ }
148
+ return true
149
+ }
150
+
151
+ // Returns the document text up to `offset` inside `targetNode`. Non-inline
152
+ // element siblings are joined with `\n\n`, matching Lexical's own
153
+ // ElementNode.getTextContent behavior.
154
+ function $textBeforeOffset(targetNode, offset) {
155
+ const parts = [];
156
+ let done = false;
157
+
158
+ function visit(node) {
159
+ if (done) return
160
+ if (node === targetNode) {
161
+ parts.push(node.getTextContent().slice(0, offset));
162
+ done = true;
163
+ return
164
+ }
165
+ if ($isElementNode(node)) {
166
+ const children = node.getChildren();
167
+ for (let i = 0; i < children.length; i++) {
168
+ visit(children[i]);
169
+ if (done) return
170
+ const child = children[i];
171
+ if ($isElementNode(child) && !child.isInline() && i < children.length - 1) {
172
+ parts.push("\n\n");
173
+ }
174
+ }
175
+ } else {
176
+ parts.push(node.getTextContent());
177
+ }
178
+ }
179
+
180
+ visit($getRoot());
181
+ return parts.join("")
182
+ }
183
+
184
+ function $splitSelectedParagraphsAtInnerLineBreaks(selection) {
185
+ const topLevelElements = new Set();
186
+ for (const node of selection.getNodes()) {
187
+ const topLevel = node.getTopLevelElement();
188
+ if (topLevel) topLevelElements.add(topLevel);
189
+ }
190
+
191
+ for (const element of topLevelElements) {
192
+ if (!$isParagraphNode(element)) continue
193
+
194
+ const children = element.getChildren();
195
+ if (!children.some($isLineBreakNode)) continue
196
+
197
+ const groups = [ [] ];
198
+ for (const child of children) {
199
+ if ($isLineBreakNode(child)) {
200
+ groups.push([]);
201
+ child.remove();
202
+ } else {
203
+ groups[groups.length - 1].push(child);
204
+ }
205
+ }
206
+
207
+ for (const group of groups) {
208
+ if (group.length === 0) continue
209
+ const paragraph = $createParagraphNode();
210
+ group.forEach(child => paragraph.append(child));
211
+ element.insertBefore(paragraph);
212
+ }
213
+ if (groups.some(group => group.length > 0)) element.remove();
214
+ }
215
+ }
216
+
217
+ function $expandSelectionToLineBreaksAndSplitAtEdges(selection) {
218
+ $ensureForwardRangeSelection(selection);
219
+
220
+ const focusCaret = $caretFromPoint(selection.focus, "next");
221
+ const anchorCaret = $caretFromPoint(selection.anchor, "previous");
222
+
223
+ // A collapsed cursor adjacent to a <br> would claim it from both sides via
224
+ // inward-edge; force outward-only walks so each side finds its own boundary.
225
+ const skipInwardEdge = selection.isCollapsed();
226
+ const focusBrCaret = $getCaretAtLineBreakBoundary(focusCaret, skipInwardEdge);
227
+ let anchorBrCaret = $getCaretAtLineBreakBoundary(anchorCaret, skipInwardEdge);
228
+
229
+ if (focusBrCaret?.origin.is(anchorBrCaret?.origin)) {
230
+ anchorBrCaret = null;
231
+ }
232
+
233
+ // Splitting focus first keeps the anchor <br>'s position stable.
234
+ const focusOuter = focusBrCaret && $splitAroundLineBreak(focusBrCaret);
235
+ const anchorOuter = anchorBrCaret && $splitAroundLineBreak(anchorBrCaret);
236
+
237
+ const innerStart = anchorOuter?.getNextSibling() ?? selection.anchor.getNode().getTopLevelElement();
238
+ const innerEnd = focusOuter?.getPreviousSibling() ?? selection.focus.getNode().getTopLevelElement();
239
+ if (!innerStart || !innerEnd) return
240
+
241
+ $setSelectionFromCaretRange($getCaretRange(
242
+ $normalizeCaret($getChildCaret(innerStart, "next")),
243
+ $getCaretInDirection(
244
+ $normalizeCaret($getChildCaret(innerEnd, "previous")),
245
+ "next",
246
+ ),
247
+ ));
248
+ }
249
+
250
+ function $getCaretAtLineBreakBoundary(caret, skipInwardEdge = false) {
251
+ const paragraph = caret.origin.getTopLevelElement();
252
+ if (!paragraph || !$isParagraphNode(paragraph)) return null
253
+
254
+ const lineBreak = (skipInwardEdge ? null : $inwardEdgeLineBreak(caret, paragraph))
255
+ ?? $outwardLineBreak(caret, paragraph);
256
+
257
+ return lineBreak ? $getSiblingCaret(lineBreak, caret.direction) : null
258
+ }
259
+
260
+ // Prefer a <br> the cursor is sitting flush against, except when a further <br>
261
+ // also exists outward — that one is the real paragraph break for this side.
262
+ function $inwardEdgeLineBreak(caret, paragraph) {
263
+ let candidateCaret;
264
+
265
+ if (
266
+ ($isChildCaret(caret) && caret.origin.is(paragraph)) ||
267
+ ($isTextPointCaret(caret) && $isExtendableTextPointCaret(caret.getFlipped()))
268
+ ) {
269
+ candidateCaret = null;
270
+ } else if ($isSiblingCaret(caret) && caret.getParentAtCaret().is(paragraph)) {
271
+ candidateCaret = caret;
272
+ } else {
273
+ const childCaret = $paragraphChildCaretAtInwardEdge(caret, paragraph);
274
+ candidateCaret = childCaret ? $rewindSiblingCaret(childCaret) : null;
275
+ }
276
+
277
+ if (candidateCaret && $isLineBreakNode(candidateCaret.origin)) {
278
+ return $candidateUnlessShadowed(candidateCaret)
279
+ } else {
280
+ return null
281
+ }
282
+ }
283
+
284
+ function $candidateUnlessShadowed(candidateCaret) {
285
+ const outward = candidateCaret.getNodeAtCaret();
286
+ return $isLineBreakNode(outward) ? null : candidateCaret.origin
287
+ }
288
+
289
+ function $outwardLineBreak(caret, paragraph) {
290
+ const startCaret = $outwardWalkStartCaret(caret, paragraph);
291
+ if (!startCaret) return null
292
+
293
+ for (const { origin } of startCaret) {
294
+ if (!origin.getParent().is(paragraph)) break
295
+ if ($isLineBreakNode(origin)) return origin
296
+ }
297
+ return null
298
+ }
299
+
300
+ function $outwardWalkStartCaret(caret, paragraph) {
301
+ if (caret.getParentAtCaret().is(paragraph)) {
302
+ return caret
303
+ } else {
304
+ return $paragraphChildCaretContaining(caret, paragraph)
305
+ }
306
+ }
307
+
308
+ function $paragraphChildCaretContaining(caret, paragraph) {
309
+ let cursor = caret.getSiblingCaret();
310
+ while (cursor && !cursor.origin.getParent()?.is(paragraph)) {
311
+ cursor = cursor.getParentCaret();
312
+ }
313
+ return cursor?.origin.getParent()?.is(paragraph) ? cursor : null
314
+ }
315
+
316
+ // Only succeeds when the cursor is flush against the inward edge of every
317
+ // ancestor between itself and the paragraph child.
318
+ function $paragraphChildCaretAtInwardEdge(caret, paragraph) {
319
+ let cursor = caret.getSiblingCaret();
320
+ while (cursor && !cursor.origin.getParent()?.is(paragraph)) {
321
+ if (cursor.getNodeAtCaret()) return null
322
+ cursor = cursor.getParentCaret();
323
+ }
324
+ return cursor?.origin.getParent()?.is(paragraph) ? cursor : null
325
+ }
326
+
327
+ function $splitAroundLineBreak(lineBreakCaret) {
328
+ let outer = null;
329
+
330
+ if (lineBreakCaret.getNodeAtCaret() === null) {
331
+ lineBreakCaret.origin.remove();
332
+ } else {
333
+ const lineBreak = lineBreakCaret.origin;
334
+ const splitCaret = $getCaretInDirection($rewindSiblingCaret(lineBreakCaret), "next");
335
+
336
+ $splitAtPointCaretNext(splitCaret);
337
+ outer = lineBreak.getTopLevelElement();
338
+ lineBreak.remove();
339
+ }
340
+
341
+ return outer
342
+ }
343
+
344
+ export { $containsRangeSelection, $createNodeSelectionWith, $expandSelectionToLineBreaksAndSplitAtEdges, $isAtNodeEdge, $isAtNodeStart, $isBlankNode, $isCursorOnLastLine, $isListItemStructurallyEmpty, $isSafeForRoot, $isShadowRoot, $makeSafeForRoot, $splitSelectedParagraphsAtInnerLineBreaks, $textBeforeOffset, $trimTrailingBlankNodes, extendConversion, extendTextNodeConversion, getListType, isEditorFocused };
package/dist/lexxy.esm.js CHANGED
@@ -1,11 +1,11 @@
1
- export { highlightCode } from './lexxy_helpers.esm.js';
1
+ export { highlightCode, highlightElement } from './lexxy_helpers.esm.js';
2
2
  import DOMPurify from 'dompurify';
3
3
  import { getStyleObjectFromCSS, getCSSFromStyleObject, $getSelectionStyleValueForProperty, $ensureForwardRangeSelection, $isAtNodeEnd, $patchStyleText, $setBlocksType, $forEachSelectedTextNode } from '@lexical/selection';
4
- import { SKIP_DOM_SELECTION_TAG, CAN_UNDO_COMMAND, COMMAND_PRIORITY_LOW, CAN_REDO_COMMAND, $getSelection, $isRangeSelection, DecoratorNode, $createTextNode, $caretFromPoint, $setSelectionFromCaretRange, $getCaretRange, $normalizeCaret, $getChildCaret, $getCaretInDirection, $isParagraphNode, $isLineBreakNode, $createParagraphNode, $isElementNode, $isRootOrShadowRoot, $isRootNode, $createNodeSelection, $isDecoratorNode, $isTextNode, $getSiblingCaret, $rewindSiblingCaret, $splitAtPointCaretNext, $isChildCaret, $isTextPointCaret, $isExtendableTextPointCaret, $isSiblingCaret, $getCommonAncestor, $findMatchingParent, TextNode, createCommand, defineExtension, COMMAND_PRIORITY_EDITOR, $getEditor, $getNodeByKey, HISTORY_MERGE_TAG, SKIP_SCROLL_INTO_VIEW_TAG, $cloneWithProperties, $getNearestRootOrShadowRoot, $createRangeSelection, $setSelection, createState, COMMAND_PRIORITY_NORMAL, $getState, $setState, $hasUpdateTag, PASTE_TAG, FORMAT_TEXT_COMMAND, UNDO_COMMAND, REDO_COMMAND, KEY_ARROW_RIGHT_COMMAND, KEY_TAB_COMMAND, OUTDENT_CONTENT_COMMAND, INDENT_CONTENT_COMMAND, $isNodeSelection, $getRoot, KEY_ARROW_LEFT_COMMAND, KEY_ARROW_UP_COMMAND, KEY_ARROW_DOWN_COMMAND, DELETE_CHARACTER_COMMAND, SELECTION_CHANGE_COMMAND, CLICK_COMMAND, isDOMNode, $getNearestNodeFromDOMNode, $addUpdateTag, ElementNode, $splitNode, $getChildCaretAtIndex, $createLineBreakNode, PASTE_COMMAND, SELECTION_INSERT_CLIPBOARD_NODES_COMMAND, ParagraphNode, RootNode, COMMAND_PRIORITY_HIGH, DRAGSTART_COMMAND, DROP_COMMAND, INSERT_PARAGRAPH_COMMAND, mergeRegister as mergeRegister$1, CLEAR_HISTORY_COMMAND, $onUpdate, KEY_ENTER_COMMAND, COMMAND_PRIORITY_CRITICAL, KEY_SPACE_COMMAND, INPUT_COMMAND, KEY_BACKSPACE_COMMAND, KEY_DOWN_COMMAND } from 'lexical';
4
+ import { SKIP_DOM_SELECTION_TAG, CAN_UNDO_COMMAND, COMMAND_PRIORITY_LOW, CAN_REDO_COMMAND, $getSelection, $isRangeSelection, DecoratorNode, $createTextNode, $getRoot, $caretFromPoint, $setSelectionFromCaretRange, $getCaretRange, $normalizeCaret, $getChildCaret, $getCaretInDirection, $isParagraphNode, $isLineBreakNode, $createParagraphNode, $isElementNode, $isRootOrShadowRoot, $isRootNode, $createNodeSelection, $isDecoratorNode, $isTextNode, $getSiblingCaret, $rewindSiblingCaret, $splitAtPointCaretNext, $isChildCaret, $isTextPointCaret, $isExtendableTextPointCaret, $isSiblingCaret, $getCommonAncestor, $findMatchingParent, TextNode, createCommand, defineExtension, COMMAND_PRIORITY_EDITOR, $getEditor, $getNodeByKey, HISTORY_MERGE_TAG, SKIP_SCROLL_INTO_VIEW_TAG, $cloneWithProperties, $getNearestRootOrShadowRoot, $createRangeSelection, $setSelection, createState, COMMAND_PRIORITY_NORMAL, $getState, $setState, $hasUpdateTag, PASTE_TAG, FORMAT_TEXT_COMMAND, UNDO_COMMAND, REDO_COMMAND, KEY_ARROW_RIGHT_COMMAND, KEY_TAB_COMMAND, OUTDENT_CONTENT_COMMAND, INDENT_CONTENT_COMMAND, $isNodeSelection, KEY_ARROW_LEFT_COMMAND, KEY_ARROW_UP_COMMAND, KEY_ARROW_DOWN_COMMAND, DELETE_CHARACTER_COMMAND, SELECTION_CHANGE_COMMAND, CLICK_COMMAND, isDOMNode, $getNearestNodeFromDOMNode, $addUpdateTag, ElementNode, $splitNode, $getChildCaretAtIndex, $createLineBreakNode, SELECTION_INSERT_CLIPBOARD_NODES_COMMAND, PASTE_COMMAND, $onUpdate, ParagraphNode, RootNode, COMMAND_PRIORITY_HIGH, DRAGSTART_COMMAND, DROP_COMMAND, INSERT_PARAGRAPH_COMMAND, mergeRegister as mergeRegister$1, CLEAR_HISTORY_COMMAND, KEY_ENTER_COMMAND, COMMAND_PRIORITY_CRITICAL, KEY_SPACE_COMMAND, INPUT_COMMAND, KEY_BACKSPACE_COMMAND, KEY_DOWN_COMMAND } from 'lexical';
5
5
  import { LinkNode, $createAutoLinkNode, $toggleLink, $createLinkNode, $isLinkNode, AutoLinkNode } from '@lexical/link';
6
6
  import { buildEditorFromExtensions } from '@lexical/extension';
7
7
  import { ListNode, ListItemNode, $getListDepth, INSERT_UNORDERED_LIST_COMMAND, INSERT_ORDERED_LIST_COMMAND, $isListItemNode, $isListNode, registerList } from '@lexical/list';
8
- import { $getNearestNodeOfType, $wrapNodeInElement, $lastToFirstIterator, $descendantsMatching, mergeRegister, $insertFirst, $unwrapAndFilterDescendants, $firstToLastIterator, $getNearestBlockElementAncestorOrThrow, IS_APPLE } from '@lexical/utils';
8
+ import { $getNearestNodeOfType, $wrapNodeInElement, $lastToFirstIterator, $descendantsMatching, mergeRegister, $insertNodeToNearestRoot, $insertFirst, $unwrapAndFilterDescendants, $firstToLastIterator, $getNearestBlockElementAncestorOrThrow, IS_APPLE } from '@lexical/utils';
9
9
  import { registerPlainText } from '@lexical/plain-text';
10
10
  import { RichTextExtension, $isQuoteNode, $isHeadingNode, $createHeadingNode, $createQuoteNode, QuoteNode, HeadingNode, registerRichText } from '@lexical/rich-text';
11
11
  import { $generateNodesFromDOM, $generateHtmlFromNodes } from '@lexical/html';
@@ -1449,7 +1449,7 @@ function filterMatchPosition(text, potentialMatch) {
1449
1449
 
1450
1450
  if (!normalizedMatch) return 0
1451
1451
 
1452
- const match = normalizedText.match(new RegExp(`(?:^|\\b)${escapeForRegExp(normalizedMatch)}`));
1452
+ const match = normalizedText.match(new RegExp(`(?<![\\p{L}\\p{N}])${escapeForRegExp(normalizedMatch)}`, "u"));
1453
1453
  return match ? match.index : -1
1454
1454
  }
1455
1455
 
@@ -1650,6 +1650,39 @@ function $isListItemStructurallyEmpty(listItem) {
1650
1650
  return true
1651
1651
  }
1652
1652
 
1653
+ // Returns the document text up to `offset` inside `targetNode`. Non-inline
1654
+ // element siblings are joined with `\n\n`, matching Lexical's own
1655
+ // ElementNode.getTextContent behavior.
1656
+ function $textBeforeOffset(targetNode, offset) {
1657
+ const parts = [];
1658
+ let done = false;
1659
+
1660
+ function visit(node) {
1661
+ if (done) return
1662
+ if (node === targetNode) {
1663
+ parts.push(node.getTextContent().slice(0, offset));
1664
+ done = true;
1665
+ return
1666
+ }
1667
+ if ($isElementNode(node)) {
1668
+ const children = node.getChildren();
1669
+ for (let i = 0; i < children.length; i++) {
1670
+ visit(children[i]);
1671
+ if (done) return
1672
+ const child = children[i];
1673
+ if ($isElementNode(child) && !child.isInline() && i < children.length - 1) {
1674
+ parts.push("\n\n");
1675
+ }
1676
+ }
1677
+ } else {
1678
+ parts.push(node.getTextContent());
1679
+ }
1680
+ }
1681
+
1682
+ visit($getRoot());
1683
+ return parts.join("")
1684
+ }
1685
+
1653
1686
  function isAttachmentSpacerTextNode(node, previousNode, index, childCount) {
1654
1687
  return $isTextNode(node)
1655
1688
  && node.getTextContent() === " "
@@ -1935,6 +1968,11 @@ function safeCloneEditorState(editorState) {
1935
1968
  return clone
1936
1969
  }
1937
1970
 
1971
+ const INITIAL_PREVIEW_POLL_DELAY_MS = 3000;
1972
+ const MAX_PREVIEW_POLL_DELAY_MS = 120000;
1973
+ const MAX_PREVIEW_POLL_ATTEMPTS = 20;
1974
+
1975
+
1938
1976
  class ActionTextAttachmentNode extends DecoratorNode {
1939
1977
  static getType() {
1940
1978
  return "action_text_attachment"
@@ -2009,7 +2047,7 @@ class ActionTextAttachmentNode extends DecoratorNode {
2009
2047
  return Lexxy.global.get("attachmentTagName")
2010
2048
  }
2011
2049
 
2012
- constructor({ tagName, sgid, src, previewSrc, previewable, pendingPreview, altText, caption, contentType, fileName, fileSize, width, height, uploadError }, key) {
2050
+ constructor({ tagName, sgid, src, previewSrc, previewable, previewStatusUrl, pendingPreview, altText, caption, contentType, fileName, fileSize, width, height, uploadError }, key) {
2013
2051
  super(key);
2014
2052
 
2015
2053
  this.tagName = tagName || ActionTextAttachmentNode.TAG_NAME;
@@ -2017,6 +2055,7 @@ class ActionTextAttachmentNode extends DecoratorNode {
2017
2055
  this.src = src;
2018
2056
  this.previewSrc = previewSrc;
2019
2057
  this.previewable = parseBoolean(previewable);
2058
+ this.previewStatusUrl = previewStatusUrl;
2020
2059
  this.pendingPreview = pendingPreview;
2021
2060
  this.altText = altText || "";
2022
2061
  this.caption = caption || "";
@@ -2095,6 +2134,8 @@ class ActionTextAttachmentNode extends DecoratorNode {
2095
2134
  sgid: this.sgid,
2096
2135
  src: this.src,
2097
2136
  previewable: this.previewable,
2137
+ previewStatusUrl: this.previewStatusUrl,
2138
+ pendingPreview: this.pendingPreview,
2098
2139
  altText: this.altText,
2099
2140
  caption: this.caption,
2100
2141
  contentType: this.contentType,
@@ -2213,41 +2254,68 @@ class ActionTextAttachmentNode extends DecoratorNode {
2213
2254
  });
2214
2255
  }
2215
2256
 
2257
+ // While the file-icon is shown, watch for the preview to become ready.
2258
+ // With a status URL, poll it (2xx = processing, anything else = ready).
2259
+ // Without one, preload the preview URL once and swap on load.
2216
2260
  #pollForPreview(figure) {
2261
+ if (this.previewStatusUrl) {
2262
+ this.#waitForPreviewByPollingStatus(figure);
2263
+ } else {
2264
+ this.#waitForPreviewByPreloadingImage(figure);
2265
+ }
2266
+ }
2267
+
2268
+ #waitForPreviewByPollingStatus(figure) {
2217
2269
  let attempt = 0;
2218
- const maxAttempts = 10;
2219
2270
 
2220
- const tryLoad = () => {
2271
+ const tryStatus = async () => {
2221
2272
  if (!this.editor.read(() => this.isAttached())) return
2222
2273
 
2223
- const img = new Image();
2224
- const cacheBustedSrc = `${this.src}${this.src.includes("?") ? "&" : "?"}_=${Date.now()}`;
2274
+ try {
2275
+ // redirect: "manual" prevents fetch from transparently following a
2276
+ // 3xx response — without it, a status endpoint that redirected to,
2277
+ // say, the preview URL would resolve to a 200 and look like
2278
+ // "still processing." The contract is "any non-2xx means done."
2279
+ const response = await fetch(this.previewStatusUrl, { credentials: "include", redirect: "manual" });
2225
2280
 
2226
- img.onload = () => {
2227
2281
  if (!this.editor.read(() => this.isAttached())) return
2228
2282
 
2229
- // The placeholder is a file-type icon SVG (86×100). A real thumbnail
2230
- // generated from PDF/video content is significantly larger.
2231
- if (img.naturalWidth > 150 && img.naturalHeight > 150) {
2232
- this.#swapToPreviewDOM(figure, cacheBustedSrc);
2233
- } else {
2283
+ if (response.ok) {
2234
2284
  retry();
2285
+ } else {
2286
+ this.#swapToPreviewDOM(figure, this.src);
2235
2287
  }
2236
- };
2237
- img.onerror = () => retry();
2238
- img.src = cacheBustedSrc;
2288
+ } catch {
2289
+ retry();
2290
+ }
2239
2291
  };
2240
2292
 
2241
2293
  const retry = () => {
2242
2294
  attempt++;
2243
- if (attempt < maxAttempts && this.editor.read(() => this.isAttached())) {
2244
- const delay = Math.min(2000 * Math.pow(1.5, attempt), 15000);
2245
- setTimeout(tryLoad, delay);
2295
+ if (attempt < MAX_PREVIEW_POLL_ATTEMPTS && this.editor.read(() => this.isAttached())) {
2296
+ const delay = Math.min(2000 * Math.pow(1.5, attempt), MAX_PREVIEW_POLL_DELAY_MS);
2297
+ setTimeout(tryStatus, delay);
2246
2298
  }
2247
2299
  };
2248
2300
 
2249
2301
  // Give the server time to start processing before the first attempt
2250
- setTimeout(tryLoad, 3000);
2302
+ setTimeout(tryStatus, INITIAL_PREVIEW_POLL_DELAY_MS);
2303
+ }
2304
+
2305
+ #waitForPreviewByPreloadingImage(figure) {
2306
+ const img = new Image();
2307
+ img.onload = () => {
2308
+ if (!this.editor.read(() => this.isAttached())) return
2309
+ this.#swapToPreviewDOM(figure, this.src);
2310
+ };
2311
+ img.onerror = () => {
2312
+ // Clear pendingPreview so undo/redo or any JSON round-trip doesn't
2313
+ // re-enter the pending flow and issue another fetch. The file icon
2314
+ // stays as the stable fallback.
2315
+ if (!this.editor.read(() => this.isAttached())) return
2316
+ this.patchAndRewriteHistory({ pendingPreview: false });
2317
+ };
2318
+ img.src = this.src;
2251
2319
  }
2252
2320
 
2253
2321
  #swapToPreviewDOM(figure, previewSrc) {
@@ -3592,8 +3660,7 @@ class CommandDispatcher {
3592
3660
  }
3593
3661
 
3594
3662
  dispatchInsertHorizontalDivider() {
3595
- this.contents.insertAtCursorEnsuringLineBelow(new HorizontalDividerNode());
3596
- this.editor.focus();
3663
+ $insertNodeToNearestRoot(new HorizontalDividerNode);
3597
3664
  }
3598
3665
 
3599
3666
  dispatchSetFormatHeadingLarge() {
@@ -4826,7 +4893,7 @@ class ImageGalleryNode extends ElementNode {
4826
4893
  replaceWithSingularChild() {
4827
4894
  if (this.#hasSingularChild) {
4828
4895
  const child = this.getFirstChild();
4829
- return this.replace(child)
4896
+ return this.replace($makeSafeForRoot(child))
4830
4897
  }
4831
4898
  }
4832
4899
 
@@ -5254,6 +5321,7 @@ class AttachmentNodeConversion {
5254
5321
  fileName: blob.filename,
5255
5322
  fileSize: blob.byte_size,
5256
5323
  previewable: blob.previewable,
5324
+ previewStatusUrl: blob.preview_status_url
5257
5325
  }
5258
5326
  }
5259
5327
 
@@ -5373,14 +5441,13 @@ class Contents {
5373
5441
  }
5374
5442
 
5375
5443
  insertDOM(doc, { tag } = {}) {
5376
- this.#unwrapPlaceholderAnchors(doc);
5377
-
5378
5444
  this.editor.update(() => {
5379
- if ($hasUpdateTag(PASTE_TAG)) this.#stripTableCellColorStyles(doc);
5445
+ if ($hasUpdateTag(PASTE_TAG)) this.#formatPastedDOM(doc);
5380
5446
 
5381
5447
  const nodes = this.editorElement.$generateNodesFromDOM(doc);
5382
- if (!this.#insertUploadNodes(nodes)) {
5383
- this.insertAtCursor(...nodes);
5448
+
5449
+ if (!$hasUpdateTag(PASTE_TAG) || !this.#dispatchPastedNodesCommand(nodes)) {
5450
+ this.#insertUploadNodes(nodes) || this.insertAtCursor(...nodes);
5384
5451
  }
5385
5452
  }, { tag });
5386
5453
  }
@@ -5700,6 +5767,17 @@ class Contents {
5700
5767
  });
5701
5768
  }
5702
5769
 
5770
+ #formatPastedDOM(doc) {
5771
+ this.#unwrapPlaceholderAnchors(doc);
5772
+ this.#stripTableCellColorStyles(doc);
5773
+ }
5774
+
5775
+ #dispatchPastedNodesCommand(nodes) {
5776
+ return this.editor.dispatchCommand(SELECTION_INSERT_CLIPBOARD_NODES_COMMAND, {
5777
+ nodes, selection: $getSelection()
5778
+ })
5779
+ }
5780
+
5703
5781
  #insertNodeIfRoot(node) {
5704
5782
  const selection = $getSelection();
5705
5783
  if (!$isRangeSelection(selection)) return false
@@ -5994,10 +6072,10 @@ class Clipboard {
5994
6072
 
5995
6073
  #handleParsedClipboardNodes({ nodes, selection }) {
5996
6074
  const url = $bareUrlFromSingleLink(nodes);
5997
- if (!url) return false
5998
-
5999
- this.#insertSingleLinkAt(selection, url);
6000
- return true
6075
+ if (url && $isRangeSelection(selection)) {
6076
+ this.#insertSingleLinkAt(selection, url);
6077
+ return true
6078
+ }
6001
6079
  }
6002
6080
 
6003
6081
  #isPlainTextOrURLPasted(clipboardData) {
@@ -6078,10 +6156,7 @@ class Clipboard {
6078
6156
  const linkNode = $createLinkNode(url).append($createTextNode(url));
6079
6157
  selection.insertNodes([ linkNode ]);
6080
6158
 
6081
- // Defer the lexxy:insert-link event until after the active update commits;
6082
- // listeners may run editor mutations of their own.
6083
- const nodeKey = linkNode.getKey();
6084
- Promise.resolve().then(() => this.#dispatchLinkInsertEvent(nodeKey, { url }));
6159
+ $onUpdate(() => this.#dispatchLinkInsertEvent(linkNode.getKey(), { url }));
6085
6160
  }
6086
6161
 
6087
6162
  #dispatchLinkInsertEvent(nodeKey, payload) {
@@ -7537,9 +7612,10 @@ class LexicalEditorElement extends HTMLElement {
7537
7612
  static observedAttributes = [ "connected", "required" ]
7538
7613
 
7539
7614
  #initialValue = ""
7615
+ #previousInternalFormValue = null
7616
+
7540
7617
  #initializeEventDispatched = false
7541
7618
  #editorInitializedDispatched = false
7542
- #valueLoaded = false
7543
7619
  #listeners = new ListenerBin()
7544
7620
  #disposables = []
7545
7621
  #historyState = { undo: false, redo: false }
@@ -7591,12 +7667,11 @@ class LexicalEditorElement extends HTMLElement {
7591
7667
  disconnectedCallback() {
7592
7668
  this.#initializeEventDispatched = false;
7593
7669
  this.#editorInitializedDispatched = false;
7594
- if (this.#valueLoaded) {
7595
- this.valueBeforeDisconnect = this.value;
7596
- } else {
7597
- this.valueBeforeDisconnect = null;
7598
- }
7599
- this.#valueLoaded = false;
7670
+
7671
+ this.#previousInternalFormValue = null;
7672
+ this.valueBeforeDisconnect = this.value;
7673
+
7674
+ this.#clearCachedValues();
7600
7675
  this.#reset(); // Prevent hangs with Safari when morphing
7601
7676
  }
7602
7677
 
@@ -7621,13 +7696,9 @@ class LexicalEditorElement extends HTMLElement {
7621
7696
  }
7622
7697
 
7623
7698
  toString() {
7624
- if (this.cachedStringValue == null) {
7625
- this.editor?.getEditorState().read(() => {
7626
- this.cachedStringValue = $getReadableTextContent($getRoot());
7627
- });
7628
- }
7629
-
7630
- return this.cachedStringValue
7699
+ return this.cachedStringValue ??= this.editor?.read(() => {
7700
+ return $getReadableTextContent($getRoot())
7701
+ })
7631
7702
  }
7632
7703
 
7633
7704
  get form() {
@@ -7711,8 +7782,8 @@ class LexicalEditorElement extends HTMLElement {
7711
7782
  return dispatch(this, "lexxy:file-accept", { file }, true)
7712
7783
  }
7713
7784
 
7714
- $generateNodesFromDOM(doc) {
7715
- let nodes = $generateNodesFromDOM(this.editor, doc);
7785
+ $generateNodesFromDOM(doc, { editor = this.editor } = {}) {
7786
+ let nodes = $generateNodesFromDOM(editor, doc);
7716
7787
  if ($hasUpdateTag(PASTE_TAG)) nodes = $convertInlineImageDataURIs(nodes, this);
7717
7788
  return filterDisallowedAttachmentNodes(nodes, this)
7718
7789
  }
@@ -7754,7 +7825,6 @@ class LexicalEditorElement extends HTMLElement {
7754
7825
 
7755
7826
  if (!this.editor) return
7756
7827
 
7757
- this.#editorInitializedDispatched = true;
7758
7828
  this.#dispatchEditorInitialized();
7759
7829
  this.#dispatchAttributesChange();
7760
7830
  }
@@ -7799,17 +7869,12 @@ class LexicalEditorElement extends HTMLElement {
7799
7869
  }
7800
7870
 
7801
7871
  get value() {
7802
- if (!this.cachedValue) {
7803
- this.editor?.getEditorState().read(() => {
7804
- this.cachedValue = sanitize($generateHtmlFromNodes(this.editor, null));
7805
- });
7806
- }
7807
-
7808
- return this.cachedValue
7872
+ return this.cachedValue ??= this.editor?.read(() => {
7873
+ return sanitize($generateHtmlFromNodes(this.editor, null))
7874
+ }) ?? null
7809
7875
  }
7810
7876
 
7811
7877
  set value(html) {
7812
- this.#valueLoaded = true;
7813
7878
  const editorHasFocus = this.#isContentFocused;
7814
7879
 
7815
7880
  this.editor.update(() => {
@@ -7821,11 +7886,8 @@ class LexicalEditorElement extends HTMLElement {
7821
7886
  $addUpdateTag(SKIP_DOM_SELECTION_TAG);
7822
7887
  }
7823
7888
 
7824
- $getRoot()
7825
- .clear()
7826
- .selectEnd()
7827
- .insertNodes(this.#parseHtmlIntoLexicalNodes(html));
7828
7889
 
7890
+ this.#setEditorHtml(html);
7829
7891
  this.#toggleEmptyStatus();
7830
7892
  }, { discrete: true });
7831
7893
  }
@@ -7838,9 +7900,9 @@ class LexicalEditorElement extends HTMLElement {
7838
7900
  return this.#historyState.redo
7839
7901
  }
7840
7902
 
7841
- #parseHtmlIntoLexicalNodes(html) {
7903
+ #parseHtmlIntoLexicalNodes(html, { editor = this.editor } = {}) {
7842
7904
  if (!html) html = "<p></p>";
7843
- const nodes = this.$generateNodesFromDOM(parseHtml(`${html}`));
7905
+ const nodes = this.$generateNodesFromDOM(parseHtml(`${html}`), { editor });
7844
7906
 
7845
7907
  return nodes
7846
7908
  .filter(this.#isNotWhitespaceOnlyNode)
@@ -7876,7 +7938,6 @@ class LexicalEditorElement extends HTMLElement {
7876
7938
  this.#attachDebugHooks();
7877
7939
  this.#attachToolbar();
7878
7940
  this.#configureSanitizer();
7879
- this.#loadInitialValue();
7880
7941
  this.#resetBeforeTurboCaches();
7881
7942
  }
7882
7943
 
@@ -7901,7 +7962,8 @@ class LexicalEditorElement extends HTMLElement {
7901
7962
  nodes: this.#lexicalNodes,
7902
7963
  html: {
7903
7964
  export: new Map([ [ TextNode, exportTextNodeDOM ], [ CodeHighlightNode, exportTextNodeDOM ] ])
7904
- }
7965
+ },
7966
+ $initialEditorState: (editor) => this.#loadInitialValue(editor)
7905
7967
  },
7906
7968
  ...this.extensions.lexicalExtensions
7907
7969
  );
@@ -7968,28 +8030,30 @@ class LexicalEditorElement extends HTMLElement {
7968
8030
  return Array.from(this.attributes).filter(attribute => attribute.name.startsWith("aria-"))
7969
8031
  }
7970
8032
 
7971
- set #internalFormValue(html) {
7972
- const changed = this.#internalFormValue !== undefined && this.#internalFormValue !== this.value;
8033
+ #setInternalFormValue(html) {
8034
+ const changed = this.#previousInternalFormValue !== null && html !== this.#previousInternalFormValue;
7973
8035
 
7974
8036
  this.internals.setFormValue(html);
7975
- this._internalFormValue = html;
8037
+ this.#previousInternalFormValue = html;
7976
8038
 
7977
8039
  if (changed) {
7978
8040
  dispatch(this, "lexxy:change");
7979
8041
  }
7980
8042
  }
7981
8043
 
7982
- get #internalFormValue() {
7983
- return this._internalFormValue
8044
+ #loadInitialValue(editor) {
8045
+ const initialHtml = this.valueBeforeDisconnect || this.getAttribute("value") || "<p><br></p>";
8046
+
8047
+ this.#initialValue = initialHtml;
8048
+ this.#setInternalFormValue(initialHtml);
8049
+ this.#setEditorHtml(initialHtml, { editor });
7984
8050
  }
7985
8051
 
7986
- #loadInitialValue() {
7987
- if (!this.#valueLoaded) {
7988
- const initialHtml = this.valueBeforeDisconnect || this.getAttribute("value") || "<p><br></p>";
7989
- this.editor.update(() => {
7990
- this.value = this.#initialValue = initialHtml;
7991
- }, { tag: HISTORY_MERGE_TAG });
7992
- }
8052
+ #setEditorHtml(html, { editor = this.editor } = { }) {
8053
+ $getRoot()
8054
+ .clear()
8055
+ .selectEnd()
8056
+ .insertNodes(this.#parseHtmlIntoLexicalNodes(html, { editor }));
7993
8057
  }
7994
8058
 
7995
8059
  #resetBeforeTurboCaches() {
@@ -8007,7 +8071,7 @@ class LexicalEditorElement extends HTMLElement {
8007
8071
  #synchronizeWithChanges() {
8008
8072
  this.#listeners.track(this.editor.registerUpdateListener(({ editorState }) => {
8009
8073
  this.#clearCachedValues();
8010
- this.#internalFormValue = this.value;
8074
+ this.#setInternalFormValue(this.value);
8011
8075
  this.#toggleEmptyStatus();
8012
8076
  this.#requestValidityRefresh();
8013
8077
  this.#dispatchAttributesChange();
@@ -8265,6 +8329,8 @@ class LexicalEditorElement extends HTMLElement {
8265
8329
  #dispatchEditorInitialized() {
8266
8330
  if (!this.adapter) return
8267
8331
 
8332
+ this.#editorInitializedDispatched = true;
8333
+
8268
8334
  this.adapter.dispatchEditorInitialized({
8269
8335
  highlightColors: this.#resolvedHighlightColors,
8270
8336
  headingFormats: this.#supportedHeadingFormats
@@ -8279,7 +8345,6 @@ class LexicalEditorElement extends HTMLElement {
8279
8345
  }
8280
8346
 
8281
8347
  if (!this.#editorInitializedDispatched) {
8282
- this.#editorInitializedDispatched = true;
8283
8348
  this.#dispatchEditorInitialized();
8284
8349
  }
8285
8350
  }
@@ -8553,6 +8618,9 @@ class RemoteFilterSource extends BaseSource {
8553
8618
  const NOTHING_FOUND_DEFAULT_MESSAGE = "Nothing found";
8554
8619
  const FILTER_DEBOUNCE_INTERVAL = 50;
8555
8620
 
8621
+ // Start of line, or after a space or newline.
8622
+ const DEFAULT_ONLY_AT_PATTERN = "^|[ \\n]";
8623
+
8556
8624
  class LexicalPromptElement extends HTMLElement {
8557
8625
  #globalListeners = new ListenerBin()
8558
8626
  #popoverListeners = new ListenerBin()
@@ -8598,6 +8666,14 @@ class LexicalPromptElement extends HTMLElement {
8598
8666
  return this.hasAttribute("supports-space-in-searches")
8599
8667
  }
8600
8668
 
8669
+ get onlyAt() {
8670
+ return this.getAttribute("only-at")
8671
+ }
8672
+
8673
+ get verticalDirection() {
8674
+ return this.getAttribute("vertical-direction")
8675
+ }
8676
+
8601
8677
  get open() {
8602
8678
  return this.popoverElement?.classList?.contains("lexxy-prompt-menu--visible")
8603
8679
  }
@@ -8641,14 +8717,10 @@ class LexicalPromptElement extends HTMLElement {
8641
8717
  if (offset >= triggerLength) {
8642
8718
  const textBeforeCursor = fullText.slice(offset - triggerLength, offset);
8643
8719
 
8644
- // Check if trigger is at the start of the text node (new line case) or preceded by space or newline
8645
8720
  if (textBeforeCursor === this.trigger) {
8646
- const isAtStart = offset === triggerLength;
8647
-
8648
- const charBeforeTrigger = offset > triggerLength ? fullText[offset - triggerLength - 1] : null;
8649
- const isPrecededBySpaceOrNewline = charBeforeTrigger === " " || charBeforeTrigger === "\n";
8721
+ const textBeforeTrigger = $textBeforeOffset(node, offset - triggerLength);
8650
8722
 
8651
- if (isAtStart || isPrecededBySpaceOrNewline) {
8723
+ if (this.#onlyAtRegExp.test(textBeforeTrigger)) {
8652
8724
  this.#popoverListeners.dispose();
8653
8725
  this.#showPopover();
8654
8726
  }
@@ -8659,7 +8731,15 @@ class LexicalPromptElement extends HTMLElement {
8659
8731
  }));
8660
8732
  }
8661
8733
 
8734
+ get #onlyAtRegExp() {
8735
+ return new RegExp(`(?:${this.onlyAt ?? DEFAULT_ONLY_AT_PATTERN})$`)
8736
+ }
8737
+
8662
8738
  get #promptContentTypePermitted() {
8739
+ // `insert-editable-text` prompts never create attachments, so the
8740
+ // editor's attachment support and content-type allowlist don't apply.
8741
+ if (this.hasAttribute("insert-editable-text")) return true
8742
+
8663
8743
  const el = this.#editorElement;
8664
8744
  if (!el.supportsAttachments) {
8665
8745
  return false
@@ -8822,11 +8902,15 @@ class LexicalPromptElement extends HTMLElement {
8822
8902
 
8823
8903
  const popoverRect = this.popoverElement.getBoundingClientRect();
8824
8904
 
8825
- if (popoverRect.right > window.innerWidth) {
8905
+ if (popoverRect.right > editorRect.right) {
8826
8906
  this.popoverElement.toggleAttribute("data-clipped-at-right", true);
8827
8907
  }
8828
8908
 
8829
- if (popoverRect.bottom > window.innerHeight) {
8909
+ const forceTop = this.verticalDirection === "top";
8910
+ const forceBottom = this.verticalDirection === "bottom";
8911
+ const overflowsWindow = popoverRect.bottom > window.innerHeight;
8912
+
8913
+ if (!forceBottom && (forceTop || overflowsWindow)) {
8830
8914
  this.#setPopoverOffsetY(contentRect.height - y + fontSize);
8831
8915
  this.popoverElement.toggleAttribute("data-clipped-at-bottom", true);
8832
8916
  }
@@ -28,8 +28,8 @@ import 'prismjs/components/prism-kotlin';
28
28
  window.Prism ||= {};
29
29
  window.Prism.manual = true;
30
30
 
31
- function highlightCode() {
32
- const elements = document.querySelectorAll("pre[data-language]");
31
+ function highlightCode(root = document) {
32
+ const elements = root.querySelectorAll("pre[data-language]:not([data-highlighted])");
33
33
 
34
34
  elements.forEach(preElement => {
35
35
  highlightElement(preElement);
@@ -37,6 +37,8 @@ function highlightCode() {
37
37
  }
38
38
 
39
39
  function highlightElement(preElement) {
40
+ if (preElement.dataset.highlighted === "true") return
41
+
40
42
  const language = preElement.getAttribute("data-language");
41
43
  let code = preElement.innerHTML.replace(/<br\s*\/?>/gi, "\n");
42
44
 
@@ -55,6 +57,8 @@ function highlightElement(preElement) {
55
57
  if (highlights.length > 0) {
56
58
  applyHighlightRanges(preElement, highlights);
57
59
  }
60
+
61
+ preElement.dataset.highlighted = "true";
58
62
  }
59
63
 
60
64
  // Walk the DOM tree inside a <pre> element and build a list of
@@ -160,4 +164,4 @@ function collectTextNodes(root) {
160
164
  return nodes
161
165
  }
162
166
 
163
- export { highlightCode };
167
+ export { highlightCode, highlightElement };
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@37signals/lexxy",
3
- "version": "0.9.15-beta.next-0",
3
+ "version": "0.9.16",
4
4
  "description": "Lexxy - A modern rich text editor for Rails.",
5
5
  "module": "dist/lexxy.esm.js",
6
6
  "type": "module",