@37signals/lexxy 0.9.18 → 0.9.19-alpha.autocapitalize

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,7 +1,7 @@
1
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, $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';
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, $normalizeSelection__EXPERIMENTAL, 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';
@@ -4031,9 +4031,12 @@ class Selection {
4031
4031
  }
4032
4032
 
4033
4033
  get isOnPreviewableImage() {
4034
- const selection = $getSelection();
4035
- const firstNode = selection?.getNodes().at(0);
4036
- return $isActionTextAttachmentNode(firstNode) && firstNode.isPreviewableImage
4034
+ return this.previewableImageNode != null
4035
+ }
4036
+
4037
+ get previewableImageNode() {
4038
+ const firstNode = $getSelection()?.getNodes().at(0);
4039
+ return $isActionTextAttachmentNode(firstNode) && firstNode.isPreviewableImage ? firstNode : null
4037
4040
  }
4038
4041
 
4039
4042
  get isAtNodeStart() {
@@ -5008,7 +5011,10 @@ class GalleryUploader extends Uploader {
5008
5011
 
5009
5012
  #findOrCreateGallery() {
5010
5013
  if (this.selection.isOnPreviewableImage) {
5011
- this.#gallery = $findOrCreateGalleryForImage(this.#selectedNode);
5014
+ // Resolve from the previewable image itself (the selection's first node), not from
5015
+ // #selectedNode (the anchor) — those differ when the selection runs from an image
5016
+ // into following text, and the anchor text node can't join a gallery (returns null).
5017
+ this.#gallery = $findOrCreateGalleryForImage(this.selection.previewableImageNode);
5012
5018
  } else if (this.#selectionIsAfterGalleryEdge) {
5013
5019
  this.#gallery = $findOrCreateGalleryForImage(this.selection.nodeBeforeCursor);
5014
5020
  } else {
@@ -5345,7 +5351,8 @@ class NodeInserter {
5345
5351
  const INSERTERS = [
5346
5352
  CodeNodeInserter,
5347
5353
  ShadowRootNodeInserter,
5348
- NodeSelectionNodeInserter
5354
+ NodeSelectionNodeInserter,
5355
+ BlockContainerNodeInserter
5349
5356
  ];
5350
5357
  const Inserter = INSERTERS.find(inserter => inserter.handles(selection));
5351
5358
  return Inserter ? new Inserter(selection) : selection
@@ -5371,14 +5378,40 @@ class CodeNodeInserter extends NodeInserter {
5371
5378
 
5372
5379
  const caret = $getChildCaretAtIndex(codeNode, insertionIndex + 1, "previous");
5373
5380
 
5381
+ // Nodes that are already in the document come from the format-toggle path (existing
5382
+ // content converted into this code block). Brand-new nodes (dropped/pasted content)
5383
+ // were never attached.
5384
+ const existingNodes = new Set(nodes.filter(node => node.isAttached()));
5385
+ const trailingNodes = [];
5386
+
5374
5387
  for (const node of nodes) {
5375
- if (!node.isAttached()) continue
5376
- if (caret.getNodeAtCaret() && $isElementNode(node)) { caret.insert($createLineBreakNode()); }
5388
+ if (existingNodes.has(node)) {
5389
+ if (!node.isAttached()) continue // already pulled in when a converted ancestor was removed
5390
+ } else if (!this.#canJoinCodeBlock(node)) {
5391
+ trailingNodes.push(node); // e.g. a dropped attachment, which a code block can't hold
5392
+ continue
5393
+ }
5377
5394
 
5395
+ if (caret.getNodeAtCaret() && $isElementNode(node)) { caret.insert($createLineBreakNode()); }
5378
5396
  caret.insert(this.#convertNodeToCodeChild(node));
5379
5397
  }
5380
5398
 
5381
- caret.getNodeAtCaret().selectEnd();
5399
+ const lastTrailingNode = this.#insertAfterCodeBlock(codeNode, trailingNodes);
5400
+ const nodeToSelect = lastTrailingNode ?? caret.getNodeAtCaret();
5401
+ nodeToSelect?.selectEnd();
5402
+ }
5403
+
5404
+ #canJoinCodeBlock(node) {
5405
+ return $isTextNode(node) || $isLineBreakNode(node)
5406
+ }
5407
+
5408
+ #insertAfterCodeBlock(codeNode, nodes) {
5409
+ let previousNode = codeNode;
5410
+ for (const node of nodes) {
5411
+ previousNode.insertAfter(node);
5412
+ previousNode = node;
5413
+ }
5414
+ return nodes.at(-1)
5382
5415
  }
5383
5416
 
5384
5417
  #convertNodeToCodeChild(node) {
@@ -5394,7 +5427,7 @@ class CodeNodeInserter extends NodeInserter {
5394
5427
 
5395
5428
  class ShadowRootNodeInserter extends NodeInserter {
5396
5429
  static handles(selection) {
5397
- return $isShadowRoot(selection?.anchor.getNode())
5430
+ return $isShadowRoot(selection?.anchor?.getNode())
5398
5431
  }
5399
5432
 
5400
5433
  insertNodes(nodes) {
@@ -5423,6 +5456,31 @@ class NodeSelectionNodeInserter extends NodeInserter {
5423
5456
  }
5424
5457
  }
5425
5458
 
5459
+ // Lexical's RangeSelection.insertNodes requires every selection point to have a block
5460
+ // ancestor with inline children. An element point on a container of block nodes — e.g.
5461
+ // a quote holding paragraphs — has none, so Lexical throws invariant #211 or #212.
5462
+ // Descend such points to a leaf position before inserting.
5463
+ class BlockContainerNodeInserter extends NodeInserter {
5464
+ static handles(selection) {
5465
+ return $isRangeSelection(selection) &&
5466
+ [ selection.anchor, selection.focus ].some($isPointOnBlockContainer)
5467
+ }
5468
+
5469
+ insertNodes(nodes) {
5470
+ $normalizeSelection__EXPERIMENTAL(this.selection);
5471
+ this.selection.insertNodes(nodes);
5472
+ }
5473
+ }
5474
+
5475
+ function $isPointOnBlockContainer(point) {
5476
+ if (point.type === "element") {
5477
+ const firstChild = point.getNode().getFirstChild();
5478
+ return ($isElementNode(firstChild) || $isDecoratorNode(firstChild)) && !firstChild.isInline()
5479
+ } else {
5480
+ return false
5481
+ }
5482
+ }
5483
+
5426
5484
  class Contents {
5427
5485
  constructor(editorElement) {
5428
5486
  this.editorElement = editorElement;
@@ -5519,7 +5577,7 @@ class Contents {
5519
5577
  blockElements.forEach(node => this.#unwrapCodeBlock(node));
5520
5578
  } else {
5521
5579
  $expandSelectionToLineBreaksAndSplitAtEdges(selection);
5522
- const elements = this.#blockLevelElementsInSelection(selection);
5580
+ const elements = this.#outermostElements(this.#blockLevelElementsInSelection(selection));
5523
5581
  if (elements.length === 0) return
5524
5582
 
5525
5583
  const codeNode = $createCodeNode("plain");
@@ -5655,6 +5713,8 @@ class Contents {
5655
5713
  }
5656
5714
 
5657
5715
  uploadFiles(files, { selectLast } = {}) {
5716
+ if (!this.editorElement) return // Disposed (e.g. on turbo:before-cache); a late drop can still land here
5717
+
5658
5718
  if (!this.editorElement.supportsAttachments) {
5659
5719
  console.warn("This editor does not supports attachments (it's configured with [attachments=false])");
5660
5720
  return
@@ -5845,6 +5905,18 @@ class Contents {
5845
5905
  return Array.from(elements)
5846
5906
  }
5847
5907
 
5908
+ // Selections spanning nested structures (a quote and its inner paragraphs,
5909
+ // nested list items) yield both an element and its ancestor. Converting the
5910
+ // ancestor detaches its whole subtree — including a node freshly inserted
5911
+ // inside it — which can leave the selection on removed nodes (Lexical
5912
+ // invariant #19). The outermost elements already cover their descendants'
5913
+ // text content, so keep only those.
5914
+ #outermostElements(elements) {
5915
+ return elements.filter((element) => {
5916
+ return elements.every((other) => other === element || !element.getParents().includes(other))
5917
+ })
5918
+ }
5919
+
5848
5920
  #insertUploadNodes(nodes) {
5849
5921
  if (nodes.every($isActionTextAttachmentNode)) {
5850
5922
  const uploader = Uploader.for(this.editorElement, []);
@@ -7425,7 +7497,7 @@ class FormatEscapeExtension extends LexxyExtension {
7425
7497
  }
7426
7498
 
7427
7499
  get allowedElements() {
7428
- return [ { tag: "li", attributes: [ "value" ] } ]
7500
+ return [ { tag: "ol", attributes: [ "start" ] }, { tag: "li", attributes: [ "value" ] } ]
7429
7501
  }
7430
7502
 
7431
7503
  get lexicalExtension() {
@@ -7609,7 +7681,7 @@ class LexicalEditorElement extends HTMLElement {
7609
7681
  static debug = false
7610
7682
  static commands = [ "bold", "italic", "strikethrough" ]
7611
7683
 
7612
- static observedAttributes = [ "connected", "required" ]
7684
+ static observedAttributes = [ "autocapitalize", "connected", "required" ]
7613
7685
 
7614
7686
  #initialValue = ""
7615
7687
  #previousInternalFormValue = null
@@ -7676,8 +7748,15 @@ class LexicalEditorElement extends HTMLElement {
7676
7748
  }
7677
7749
 
7678
7750
  attributeChangedCallback(name, oldValue, newValue) {
7679
- if (name === "connected") this.connectedChangedCallback(oldValue, newValue);
7680
- if (name === "required") this.requiredChangedCallback(oldValue, newValue);
7751
+ if (typeof this[`${name}ChangedCallback`] === "function") {
7752
+ this[`${name}ChangedCallback`](oldValue, newValue);
7753
+ }
7754
+ }
7755
+
7756
+ autocapitalizeChangedCallback() {
7757
+ if (this.editorContentElement) {
7758
+ this.#transferAttributeToContentEditable(this.editorContentElement, "autocapitalize");
7759
+ }
7681
7760
  }
7682
7761
 
7683
7762
  connectedChangedCallback(oldValue, newValue) {
@@ -8009,25 +8088,33 @@ class LexicalEditorElement extends HTMLElement {
8009
8088
 
8010
8089
  #createEditorContentElement() {
8011
8090
  const editorContentElement = createElement("div", {
8091
+ id: `${this.id}-content`,
8012
8092
  classList: "lexxy-editor__content",
8013
8093
  contenteditable: true,
8014
- autocapitalize: "none",
8015
8094
  role: "textbox",
8016
8095
  "aria-multiline": true,
8017
8096
  "aria-label": this.#labelText,
8018
8097
  placeholder: this.getAttribute("placeholder")
8019
8098
  });
8020
- editorContentElement.id = `${this.id}-content`;
8099
+
8021
8100
  this.#ariaAttributes.forEach(attribute => editorContentElement.setAttribute(attribute.name, attribute.value));
8022
8101
 
8023
- if (this.getAttribute("tabindex")) {
8024
- editorContentElement.setAttribute("tabindex", this.getAttribute("tabindex"));
8025
- this.removeAttribute("tabindex");
8102
+ this.#transferAttributeToContentEditable(editorContentElement, "autocapitalize");
8103
+ this.#transferAttributeToContentEditable(editorContentElement, "tabindex", { defaultValue: 0, removeSource: true });
8104
+
8105
+ return editorContentElement
8106
+ }
8107
+
8108
+ #transferAttributeToContentEditable(element, qualifiedName, { defaultValue = null, removeSource = false } = {}) {
8109
+ if (this.hasAttribute(qualifiedName)) {
8110
+ element.setAttribute(qualifiedName, this.getAttribute(qualifiedName));
8111
+ } else if (defaultValue !== null) {
8112
+ element.setAttribute(qualifiedName, defaultValue);
8026
8113
  } else {
8027
- editorContentElement.setAttribute("tabindex", 0);
8114
+ element.removeAttribute(qualifiedName);
8028
8115
  }
8029
8116
 
8030
- return editorContentElement
8117
+ if (removeSource) this.removeAttribute(qualifiedName);
8031
8118
  }
8032
8119
 
8033
8120
  get #labelText() {
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@37signals/lexxy",
3
- "version": "0.9.18",
3
+ "version": "0.9.19-alpha.autocapitalize",
4
4
  "description": "Lexxy - A modern rich text editor for Rails.",
5
5
  "module": "dist/lexxy.esm.js",
6
6
  "type": "module",