@37signals/lexxy 0.9.19-alpha.1 → 0.9.19-alpha.3

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.
Files changed (2) hide show
  1. package/dist/lexxy.esm.js +504 -173
  2. package/package.json +4 -3
package/dist/lexxy.esm.js CHANGED
@@ -1,13 +1,13 @@
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, $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';
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, $normalizeSelection__EXPERIMENTAL, $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, INSERT_LINE_BREAK_COMMAND, COMMAND_PRIORITY_HIGH, INSERT_PARAGRAPH_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, DRAGSTART_COMMAND, DROP_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
- import { ListNode, ListItemNode, $getListDepth, INSERT_UNORDERED_LIST_COMMAND, INSERT_ORDERED_LIST_COMMAND, $isListItemNode, $isListNode, registerList } from '@lexical/list';
7
+ import { ListNode, ListItemNode, $getListDepth, $isListNode, $isListItemNode, INSERT_UNORDERED_LIST_COMMAND, INSERT_ORDERED_LIST_COMMAND, $createListNode, $createListItemNode, registerList } from '@lexical/list';
8
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
- import { RichTextExtension, $isQuoteNode, $isHeadingNode, $createHeadingNode, $createQuoteNode, QuoteNode, HeadingNode, registerRichText } from '@lexical/rich-text';
10
+ import { RichTextExtension, $isQuoteNode, QuoteNode, $isHeadingNode, $createHeadingNode, $createQuoteNode, HeadingNode, registerRichText } from '@lexical/rich-text';
11
11
  import { $generateNodesFromDOM, $generateHtmlFromNodes } from '@lexical/html';
12
12
  import { HistoryExtension } from '@lexical/history';
13
13
  import { $isCodeNode, CodeHighlightNode, CodeNode, $createCodeNode, $isCodeHighlightNode, $createCodeHighlightNode, normalizeCodeLang, registerCodeHighlighting, CODE_LANGUAGE_FRIENDLY_NAME_MAP } from '@lexical/code';
@@ -125,6 +125,14 @@ class ListenerBin {
125
125
  }
126
126
  }
127
127
 
128
+ function handlingDefault(handler) {
129
+ return event => {
130
+ const handled = handler(event);
131
+ if (handled) event.preventDefault();
132
+ return handled
133
+ }
134
+ }
135
+
128
136
  function createElement(name, properties, content = "") {
129
137
  const element = document.createElement(name);
130
138
  for (const [ key, value ] of Object.entries(properties || {})) {
@@ -1534,8 +1542,12 @@ function $isSafeForRoot(node) {
1534
1542
  function $makeSafeForRoot(node) {
1535
1543
  if ($isSafeForRoot(node)) {
1536
1544
  return node
1537
- } else {
1545
+ } else if (node.getParent()) {
1538
1546
  return $wrapNodeInElement(node, () => node.createParentElementNode())
1547
+ } else {
1548
+ // Detached nodes (e.g. clipboard nodes being inserted) can't be `replace`d in place,
1549
+ // so append them into a fresh required parent instead.
1550
+ return node.createParentElementNode().append(node)
1539
1551
  }
1540
1552
  }
1541
1553
 
@@ -1723,7 +1735,7 @@ function $splitSelectedParagraphsAtInnerLineBreaks(selection) {
1723
1735
  }
1724
1736
  }
1725
1737
 
1726
- function $expandSelectionToLineBreaksAndSplitAtEdges(selection) {
1738
+ function $expandSelectionToLineBreaksAndSplitAtEdges(selection, fallbackAncestor = (node) => node.getTopLevelElement()) {
1727
1739
  $ensureForwardRangeSelection(selection);
1728
1740
 
1729
1741
  const focusCaret = $caretFromPoint(selection.focus, "next");
@@ -1743,8 +1755,8 @@ function $expandSelectionToLineBreaksAndSplitAtEdges(selection) {
1743
1755
  const focusOuter = focusBrCaret && $splitAroundLineBreak(focusBrCaret);
1744
1756
  const anchorOuter = anchorBrCaret && $splitAroundLineBreak(anchorBrCaret);
1745
1757
 
1746
- const innerStart = anchorOuter?.getNextSibling() ?? selection.anchor.getNode().getTopLevelElement();
1747
- const innerEnd = focusOuter?.getPreviousSibling() ?? selection.focus.getNode().getTopLevelElement();
1758
+ const innerStart = anchorOuter?.getNextSibling() ?? fallbackAncestor(selection.anchor.getNode());
1759
+ const innerEnd = focusOuter?.getPreviousSibling() ?? fallbackAncestor(selection.focus.getNode());
1748
1760
  if (!innerStart || !innerEnd) return
1749
1761
 
1750
1762
  $setSelectionFromCaretRange($getCaretRange(
@@ -1850,6 +1862,49 @@ function $splitAroundLineBreak(lineBreakCaret) {
1850
1862
  return outer
1851
1863
  }
1852
1864
 
1865
+ // Lexical's RangeSelection.insertNodes/insertLineBreak require every selection point to have a
1866
+ // block ancestor with inline children. An element point on a container of block nodes — e.g. a
1867
+ // quote holding paragraphs — has none, so Lexical throws invariant #211 or #212. This detects
1868
+ // such a point so callers can descend it to a leaf before inserting.
1869
+ function $isPointOnBlockContainer(point) {
1870
+ if (point.type !== "element") return false
1871
+
1872
+ const firstChild = point.getNode().getFirstChild();
1873
+ return ($isElementNode(firstChild) || $isDecoratorNode(firstChild)) && !firstChild.isInline()
1874
+ }
1875
+
1876
+ function $hasPointOnBlockContainer(selection) {
1877
+ return $isRangeSelection(selection) &&
1878
+ [ selection.anchor, selection.focus ].some($isPointOnBlockContainer)
1879
+ }
1880
+
1881
+ // Descend any block-container element point in the selection to a leaf position, so a subsequent
1882
+ // Lexical insert (insertNodes, insertLineBreak, INSERT_PARAGRAPH) doesn't throw invariant #211/#212.
1883
+ function $normalizeBlockContainerSelection(selection = $getSelection()) {
1884
+ if (!$hasPointOnBlockContainer(selection)) return false
1885
+
1886
+ $normalizeSelection__EXPERIMENTAL(selection);
1887
+ return true
1888
+ }
1889
+
1890
+ function $consecutiveSiblingGroups(blocks) {
1891
+ const ordered = [ ...blocks ].sort((a, b) => a.getIndexWithinParent() - b.getIndexWithinParent());
1892
+ const groups = [];
1893
+
1894
+ for (const block of ordered) {
1895
+ const lastGroup = groups.at(-1);
1896
+ const previous = lastGroup?.at(-1);
1897
+
1898
+ if (previous && previous.getParent().is(block.getParent()) && previous.getNextSibling()?.is(block)) {
1899
+ lastGroup.push(block);
1900
+ } else {
1901
+ groups.push([ block ]);
1902
+ }
1903
+ }
1904
+
1905
+ return groups
1906
+ }
1907
+
1853
1908
  // Payload: Record<nodeKey, { patch?, replace? }>
1854
1909
  // - patch: plain object, shallow-merged into the existing node's properties
1855
1910
  // - replace: a LexicalNode instance that replaces the node
@@ -2826,6 +2881,30 @@ var theme = {
2826
2881
  }
2827
2882
  };
2828
2883
 
2884
+ class UploadRequests {
2885
+ #requestsByKey = new Map()
2886
+
2887
+ track(key, request) {
2888
+ this.#requestsByKey.set(key, request);
2889
+ }
2890
+
2891
+ forget(key) {
2892
+ this.#requestsByKey.delete(key);
2893
+ }
2894
+
2895
+ abort(key) {
2896
+ const request = this.#requestsByKey.get(key);
2897
+ if (request) {
2898
+ this.#requestsByKey.delete(key);
2899
+ request.abort();
2900
+ }
2901
+ }
2902
+
2903
+ clear() {
2904
+ this.#requestsByKey.clear();
2905
+ }
2906
+ }
2907
+
2829
2908
  // Shared, strictly-contained element used to attach ephemeral nodes when we
2830
2909
  // need to read computed styles (e.g. canonicalizing style values, resolving
2831
2910
  // CSS custom properties). The container is created once and attached to
@@ -3741,6 +3820,16 @@ class CommandDispatcher {
3741
3820
  #registerKeyboardCommands() {
3742
3821
  this.#registerCommandHandler(KEY_ARROW_RIGHT_COMMAND, COMMAND_PRIORITY_NORMAL, this.#handleArrowRightKey.bind(this));
3743
3822
  this.#registerCommandHandler(KEY_TAB_COMMAND, COMMAND_PRIORITY_NORMAL, this.#handleTabKey.bind(this));
3823
+
3824
+ // Run before Lexical's built-in insert handlers to descend an element point on a
3825
+ // block container to a leaf, avoiding error #211 on Enter / Shift+Enter in a quote.
3826
+ this.#registerCommandHandler(INSERT_LINE_BREAK_COMMAND, COMMAND_PRIORITY_HIGH, this.#normalizeBlockContainerSelection.bind(this));
3827
+ this.#registerCommandHandler(INSERT_PARAGRAPH_COMMAND, COMMAND_PRIORITY_HIGH, this.#normalizeBlockContainerSelection.bind(this));
3828
+ }
3829
+
3830
+ #normalizeBlockContainerSelection() {
3831
+ $normalizeBlockContainerSelection();
3832
+ return false
3744
3833
  }
3745
3834
 
3746
3835
  #handleArrowRightKey(event) {
@@ -4013,6 +4102,10 @@ class Selection {
4013
4102
  return this.nearestNodeOfType(ListItemNode)
4014
4103
  }
4015
4104
 
4105
+ get isInsideBlockQuote() {
4106
+ return this.nearestNodeOfType(QuoteNode)
4107
+ }
4108
+
4016
4109
  get isIndentedList() {
4017
4110
  const closestListNode = this.nearestNodeOfType(ListNode);
4018
4111
  return closestListNode && ($getListDepth(closestListNode) > 1)
@@ -4187,10 +4280,10 @@ class Selection {
4187
4280
 
4188
4281
  #processSelectionChangeCommands() {
4189
4282
  this.#listeners.track(
4190
- this.editor.registerCommand(KEY_ARROW_LEFT_COMMAND, this.#selectPreviousNode.bind(this), COMMAND_PRIORITY_LOW),
4191
- this.editor.registerCommand(KEY_ARROW_RIGHT_COMMAND, this.#selectNextNode.bind(this), COMMAND_PRIORITY_LOW),
4192
- this.editor.registerCommand(KEY_ARROW_UP_COMMAND, this.#selectPreviousTopLevelNode.bind(this), COMMAND_PRIORITY_LOW),
4193
- this.editor.registerCommand(KEY_ARROW_DOWN_COMMAND, this.#selectNextTopLevelNode.bind(this), COMMAND_PRIORITY_LOW),
4283
+ this.editor.registerCommand(KEY_ARROW_LEFT_COMMAND, handlingDefault(this.#selectPreviousNode.bind(this)), COMMAND_PRIORITY_LOW),
4284
+ this.editor.registerCommand(KEY_ARROW_RIGHT_COMMAND, handlingDefault(this.#selectNextNode.bind(this)), COMMAND_PRIORITY_LOW),
4285
+ this.editor.registerCommand(KEY_ARROW_UP_COMMAND, handlingDefault(this.#selectPreviousTopLevelNode.bind(this)), COMMAND_PRIORITY_LOW),
4286
+ this.editor.registerCommand(KEY_ARROW_DOWN_COMMAND, handlingDefault(this.#selectNextTopLevelNode.bind(this)), COMMAND_PRIORITY_LOW),
4194
4287
 
4195
4288
  this.editor.registerCommand(DELETE_CHARACTER_COMMAND, this.#selectDecoratorNodeBeforeDeletion.bind(this), COMMAND_PRIORITY_LOW),
4196
4289
 
@@ -4289,49 +4382,58 @@ class Selection {
4289
4382
  }
4290
4383
  }
4291
4384
 
4292
- async #selectPreviousNode(event) {
4293
- if (event?.shiftKey) return false
4385
+ #selectPreviousNode(event) {
4386
+ if (event.shiftKey) {
4387
+ return this.#withCurrentNodeSelectionNode((currentNode) => {
4388
+ const selection = this.#rangeSelectDecorator(currentNode, "forward");
4294
4389
 
4295
- if (this.hasNodeSelection) {
4296
- return await this.#withCurrentNode((currentNode) => currentNode.selectPrevious())
4390
+ // Can't rely on native pass-through with Playwright on firefox
4391
+ selection.modify("extend", true, "character");
4392
+ return true
4393
+ })
4297
4394
  } else {
4298
- return this.#selectInLexical(this.nodeBeforeCursor)
4395
+ return this.#withCurrentNodeSelectionNode((currentNode) => currentNode.selectPrevious())
4396
+ || this.#selectInLexical(this.nodeBeforeCursor)
4299
4397
  }
4300
4398
  }
4301
4399
 
4302
- async #selectNextNode(event) {
4303
- if (event?.shiftKey) return false
4400
+ #selectNextNode(event) {
4401
+ if (event.shiftKey) {
4402
+ return this.#withCurrentNodeSelectionNode((currentNode) => {
4403
+ const selection = this.#rangeSelectDecorator(currentNode, "forward");
4304
4404
 
4305
- if (this.hasNodeSelection) {
4306
- return await this.#withCurrentNode((currentNode) => currentNode.selectNext(0, 0))
4405
+ // Can't rely on native pass-through with Playwright on firefox
4406
+ selection.modify("extend", false, "character");
4407
+ return true
4408
+ })
4307
4409
  } else {
4308
- return this.#selectInLexical(this.nodeAfterCursor)
4410
+ return this.#withCurrentNodeSelectionNode((currentNode) => currentNode.selectNext(0, 0))
4411
+ || this.#selectInLexical(this.nodeAfterCursor)
4309
4412
  }
4310
4413
  }
4311
4414
 
4312
- async #selectPreviousTopLevelNode() {
4313
- if (this.hasNodeSelection) {
4314
- return await this.#withCurrentNode((currentNode) => currentNode.getTopLevelElement().selectPrevious())
4315
- } else {
4316
- return this.#selectInLexical(this.topLevelNodeBeforeCursor)
4317
- }
4415
+ #selectPreviousTopLevelNode() {
4416
+ return this.#withCurrentNodeSelectionNode((currentNode) => currentNode.getTopLevelElement().selectPrevious())
4417
+ || this.#selectInLexical(this.topLevelNodeBeforeCursor)
4418
+ }
4419
+
4420
+ #selectNextTopLevelNode() {
4421
+ return this.#withCurrentNodeSelectionNode((currentNode) => currentNode.getTopLevelElement().selectNext(0, 0))
4422
+ || this.#selectInLexical(this.topLevelNodeAfterCursor)
4318
4423
  }
4319
4424
 
4320
- async #selectNextTopLevelNode() {
4425
+ #withCurrentNodeSelectionNode(fn) {
4321
4426
  if (this.hasNodeSelection) {
4322
- return await this.#withCurrentNode((currentNode) => currentNode.getTopLevelElement().selectNext(0, 0))
4323
- } else {
4324
- return this.#selectInLexical(this.topLevelNodeAfterCursor)
4427
+ return fn($getSelection().getNodes()[0])
4325
4428
  }
4326
4429
  }
4327
4430
 
4328
- async #withCurrentNode(fn) {
4329
- await nextFrame();
4330
- if (this.hasNodeSelection) {
4331
- this.editor.update(() => {
4332
- fn($getSelection().getNodes()[0]);
4333
- this.editor.focus();
4334
- });
4431
+ #rangeSelectDecorator(node, direction = "forward") {
4432
+ if ($isDecoratorNode(node)) {
4433
+ const [ anchorOffset, focusOffset ] = direction === "forward" ? [ 0, 1 ] : [ 1, 0 ];
4434
+ const indexAtNode = node.getIndexWithinParent();
4435
+
4436
+ return node.getParent().select(indexAtNode + anchorOffset, indexAtNode + focusOffset)
4335
4437
  }
4336
4438
  }
4337
4439
 
@@ -4440,6 +4542,9 @@ class Selection {
4440
4542
  // - First item (no previous sibling): convert to a paragraph above the
4441
4543
  // list, matching the standard "unwrap list formatting" behavior that
4442
4544
  // users expect from pressing backspace at the start of a list item.
4545
+ // Inside a blockquote we instead just remove the empty item and move
4546
+ // the cursor into the next one — stranding a paragraph there would
4547
+ // leave the blank line the user is trying to close.
4443
4548
  //
4444
4549
  // When the empty item is the last/only one in the list, we return false
4445
4550
  // and let Lexical's default (convert to paragraph) provide the standard
@@ -4458,19 +4563,22 @@ class Selection {
4458
4563
  if (!nextSibling) return false
4459
4564
 
4460
4565
  const previousSibling = listItem.getPreviousSibling();
4566
+ const listNode = $getNearestNodeOfType(listItem, ListNode);
4567
+ if (!listNode) return false
4568
+
4461
4569
  if (previousSibling) {
4462
4570
  previousSibling.selectEnd();
4463
4571
  listItem.remove();
4464
- return true
4572
+ } else if ($isQuoteNode(listNode.getParent())) {
4573
+ nextSibling.selectStart();
4574
+ listItem.remove();
4575
+ } else {
4576
+ const paragraph = $createParagraphNode();
4577
+ listNode.insertBefore(paragraph);
4578
+ listItem.remove();
4579
+ paragraph.selectStart();
4465
4580
  }
4466
4581
 
4467
- const listNode = $getNearestNodeOfType(listItem, ListNode);
4468
- if (!listNode) return false
4469
-
4470
- const paragraph = $createParagraphNode();
4471
- listNode.insertBefore(paragraph);
4472
- listItem.remove();
4473
- paragraph.selectStart();
4474
4582
  return true
4475
4583
  }
4476
4584
 
@@ -4947,9 +5055,9 @@ function $findOrCreateGalleryForImage(node) {
4947
5055
  class Uploader {
4948
5056
  #files
4949
5057
 
4950
- static for(editorElement, files) {
5058
+ static for(editorElement, files, options = {}) {
4951
5059
  const UploaderKlass = GalleryUploader.handle(editorElement, files) ? GalleryUploader : Uploader;
4952
- return new UploaderKlass(editorElement, files)
5060
+ return new UploaderKlass(editorElement, files, options)
4953
5061
  }
4954
5062
 
4955
5063
  constructor(editorElement, files, options = {}) {
@@ -4971,7 +5079,13 @@ class Uploader {
4971
5079
  }
4972
5080
 
4973
5081
  $createUploadNodes() {
4974
- this.nodes = this.files.map(file => this.contents.$createUploadNode(file));
5082
+ this.nodes = this.files.map(file => this.#createUploadNode(file));
5083
+ }
5084
+
5085
+ #createUploadNode(file) {
5086
+ return this.options.pending
5087
+ ? this.contents.$createPendingUploadNode(file)
5088
+ : this.contents.$createUploadNode(file)
4975
5089
  }
4976
5090
 
4977
5091
  $insertUploadNodes() {
@@ -5229,6 +5343,8 @@ class ActionTextAttachmentUploadNode extends ActionTextAttachmentNode {
5229
5343
  this.#dispatchEvent("lexxy:upload-start", { file: this.file });
5230
5344
 
5231
5345
  upload.create((error, blob) => {
5346
+ this.#forgetUploadRequest();
5347
+
5232
5348
  if (error) {
5233
5349
  this.#dispatchEvent("lexxy:upload-end", { file: this.file, error });
5234
5350
  this.#handleUploadError(error);
@@ -5251,12 +5367,26 @@ class ActionTextAttachmentUploadNode extends ActionTextAttachmentNode {
5251
5367
  directUploadWillStoreFileWithXHR: (request) => {
5252
5368
  if (shouldAuthenticateUploads) request.withCredentials = true;
5253
5369
 
5370
+ this.#rememberUploadRequest(request);
5371
+
5254
5372
  const uploadProgressHandler = (event) => this.#handleUploadProgress(event, request);
5255
5373
  request.upload.addEventListener("progress", uploadProgressHandler);
5256
5374
  }
5257
5375
  }
5258
5376
  }
5259
5377
 
5378
+ #forgetUploadRequest() {
5379
+ this.#editorElement.uploadRequests.forget(this.getKey());
5380
+ }
5381
+
5382
+ #rememberUploadRequest(request) {
5383
+ this.#editorElement.uploadRequests.track(this.getKey(), request);
5384
+ }
5385
+
5386
+ get #editorElement() {
5387
+ return this.editor.getRootElement()?.closest("lexxy-editor")
5388
+ }
5389
+
5260
5390
  #setUploadStarted() {
5261
5391
  this.#setProgress(1);
5262
5392
  }
@@ -5346,24 +5476,13 @@ function $createActionTextAttachmentUploadNode(...args) {
5346
5476
  return new ActionTextAttachmentUploadNode(...args)
5347
5477
  }
5348
5478
 
5349
- class NodeInserter {
5350
- static for(selection) {
5351
- const INSERTERS = [
5352
- CodeNodeInserter,
5353
- ShadowRootNodeInserter,
5354
- NodeSelectionNodeInserter,
5355
- BlockContainerNodeInserter
5356
- ];
5357
- const Inserter = INSERTERS.find(inserter => inserter.handles(selection));
5358
- return Inserter ? new Inserter(selection) : selection
5359
- }
5360
-
5479
+ class BaseNodeInserter {
5361
5480
  constructor(selection) {
5362
5481
  this.selection = selection;
5363
5482
  }
5364
5483
  }
5365
5484
 
5366
- class CodeNodeInserter extends NodeInserter {
5485
+ class CodeNodeInserter extends BaseNodeInserter {
5367
5486
  static handles(selection) {
5368
5487
  return $getNearestNodeOfType(selection.anchor?.getNode(), CodeNode)
5369
5488
  }
@@ -5422,10 +5541,9 @@ class CodeNodeInserter extends NodeInserter {
5422
5541
  return $createTextNode(node.getTextContent())
5423
5542
  }
5424
5543
  }
5425
-
5426
5544
  }
5427
5545
 
5428
- class ShadowRootNodeInserter extends NodeInserter {
5546
+ class ShadowRootNodeInserter extends BaseNodeInserter {
5429
5547
  static handles(selection) {
5430
5548
  return $isShadowRoot(selection?.anchor?.getNode())
5431
5549
  }
@@ -5439,20 +5557,96 @@ class ShadowRootNodeInserter extends NodeInserter {
5439
5557
  }
5440
5558
  }
5441
5559
 
5442
- class NodeSelectionNodeInserter extends NodeInserter {
5560
+ class NodeSelectionNodeInserter extends BaseNodeInserter {
5443
5561
  static handles(selection) {
5444
5562
  return $isNodeSelection(selection)
5445
5563
  }
5446
5564
 
5447
5565
  insertNodes(nodes) {
5448
- const selectedNodes = this.selection.getNodes();
5449
-
5450
5566
  // Overrides Lexical's default behavior of _removing_ the currently selected nodes
5451
5567
  // https://github.com/facebook/lexical/blob/v0.38.2/packages/lexical/src/LexicalSelection.ts#L412
5452
- let lastNode = selectedNodes.at(-1);
5568
+ let lastNode = this.selection.getNodes().at(-1);
5569
+
5570
+ for (const node of nodes) {
5571
+ // Inserting after a top-level node would make this one a root child. Inline nodes
5572
+ // can't live there (Lexical error #99), so wrap them in their required parent first.
5573
+ const nodeToInsert = this.#insertsIntoRoot(lastNode) ? $makeSafeForRoot(node) : node;
5574
+ lastNode = lastNode.insertAfter(nodeToInsert);
5575
+ }
5576
+ }
5577
+
5578
+ #insertsIntoRoot(node) {
5579
+ return node.is(node.getTopLevelElement())
5580
+ }
5581
+ }
5582
+
5583
+ // A list item can only hold inline content, so a block node (such as an image
5584
+ // attachment) dropped into one corrupts the list: Lexical lifts it into the
5585
+ // wrong list item and orphans an empty bullet. Block nodes belong at the top
5586
+ // level instead, splitting the list around the cursor. Inline content keeps
5587
+ // Lexical's default behavior and stays within the list item.
5588
+ class ListItemNodeInserter extends BaseNodeInserter {
5589
+ static handles(selection) {
5590
+ return $isRangeSelection(selection) &&
5591
+ $getNearestNodeOfType(selection.anchor.getNode(), ListItemNode)
5592
+ }
5593
+
5594
+ insertNodes(nodes) {
5595
+ if (nodes.some(node => this.#isBlockDecorator(node))) {
5596
+ this.#insertAroundList(nodes);
5597
+ } else {
5598
+ this.selection.insertNodes(nodes);
5599
+ }
5600
+ }
5601
+
5602
+ #insertAroundList(nodes) {
5603
+ if (!this.selection.isCollapsed()) { this.selection.removeText(); }
5604
+
5605
+ // Break out of any nesting to the outermost list. Splitting an inner list
5606
+ // would leave the block stranded inside an ancestor list item, which is the
5607
+ // very corruption we are avoiding. The block must land at the list's level.
5608
+ const anchorNode = this.selection.anchor.getNode();
5609
+ const outerList = this.#outermostList(anchorNode);
5610
+ const topItem = this.#topLevelItemFor(anchorNode, outerList);
5611
+
5612
+ // A blank top-level bullet is just the insertion point (e.g. the user pressed
5613
+ // Enter to leave the list); break out of it entirely. A bullet with content —
5614
+ // including one wrapping a nested list — splits so its content stays in the list.
5615
+ const splitAfterItem = $isBlankNode(topItem) ? topItem.getPreviousSibling() : topItem;
5616
+ const splitIndex = splitAfterItem ? splitAfterItem.getIndexWithinParent() + 1 : 0;
5617
+ const [ listBefore, listAfter ] = $splitNode(outerList, splitIndex);
5618
+ if ($isBlankNode(topItem)) { topItem.remove(); }
5619
+
5620
+ let anchor = listBefore ?? listAfter;
5453
5621
  for (const node of nodes) {
5454
- lastNode = lastNode.insertAfter(node);
5622
+ anchor.insertAfter(node);
5623
+ anchor = node;
5455
5624
  }
5625
+
5626
+ this.#removeEmptyList(listBefore);
5627
+ this.#removeEmptyList(listAfter);
5628
+ nodes.at(-1).selectNext();
5629
+ }
5630
+
5631
+ #outermostList(node) {
5632
+ return [ node, ...node.getParents() ].reverse().find($isListNode)
5633
+ }
5634
+
5635
+ #topLevelItemFor(node, outerList) {
5636
+ return [ node, ...node.getParents() ].find(ancestor =>
5637
+ $isListItemNode(ancestor) && ancestor.getParent()?.is(outerList)
5638
+ )
5639
+ }
5640
+
5641
+ #removeEmptyList(list) {
5642
+ if ($isListNode(list) && list.isEmpty()) list.remove();
5643
+ }
5644
+
5645
+ // Only block decorator nodes (image/file attachments) are intercepted. A list
5646
+ // item cannot hold them, so they must break out. Pasted element blocks
5647
+ // (paragraphs, quotes) keep Lexical's own list-escape semantics.
5648
+ #isBlockDecorator(node) {
5649
+ return $isDecoratorNode(node) && !node.isInline()
5456
5650
  }
5457
5651
  }
5458
5652
 
@@ -5460,10 +5654,9 @@ class NodeSelectionNodeInserter extends NodeInserter {
5460
5654
  // ancestor with inline children. An element point on a container of block nodes — e.g.
5461
5655
  // a quote holding paragraphs — has none, so Lexical throws invariant #211 or #212.
5462
5656
  // Descend such points to a leaf position before inserting.
5463
- class BlockContainerNodeInserter extends NodeInserter {
5657
+ class BlockContainerNodeInserter extends BaseNodeInserter {
5464
5658
  static handles(selection) {
5465
- return $isRangeSelection(selection) &&
5466
- [ selection.anchor, selection.focus ].some($isPointOnBlockContainer)
5659
+ return $hasPointOnBlockContainer(selection)
5467
5660
  }
5468
5661
 
5469
5662
  insertNodes(nodes) {
@@ -5472,12 +5665,66 @@ class BlockContainerNodeInserter extends NodeInserter {
5472
5665
  }
5473
5666
  }
5474
5667
 
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
5668
+ const INSERTERS = [
5669
+ CodeNodeInserter,
5670
+ ShadowRootNodeInserter,
5671
+ NodeSelectionNodeInserter,
5672
+ ListItemNodeInserter,
5673
+ BlockContainerNodeInserter
5674
+ ];
5675
+
5676
+ // Defined here rather than on the base class so the base can stay free of any
5677
+ // dependency on its subclasses (they import the base), avoiding an import cycle.
5678
+ BaseNodeInserter.for = (selection) => {
5679
+ const inserterClass = INSERTERS.find(inserter => inserter.handles(selection));
5680
+ return inserterClass ? new inserterClass(selection) : selection
5681
+ };
5682
+
5683
+ class PastedContentFormatter {
5684
+ constructor(doc) {
5685
+ this.doc = doc;
5686
+ }
5687
+
5688
+ format() {
5689
+ this.#unwrapPlaceholderAnchors();
5690
+ this.#stripTableCellColorStyles();
5691
+ this.#stripStrayListChildren();
5692
+ return this.doc
5693
+ }
5694
+
5695
+ // Anchors with non-meaningful hrefs (e.g. "#", "") appear in content copied
5696
+ // from rendered views where mentions and interactive elements are wrapped in
5697
+ // <a href="#"> tags. Unwrap them so their text content pastes as plain text
5698
+ // and real links are preserved.
5699
+ #unwrapPlaceholderAnchors() {
5700
+ for (const anchor of this.doc.querySelectorAll("a")) {
5701
+ const href = anchor.getAttribute("href") || "";
5702
+ if (href === "" || href === "#") {
5703
+ anchor.replaceWith(...anchor.childNodes);
5704
+ }
5705
+ }
5706
+ }
5707
+
5708
+ // Table cells copied from a page inherit the source theme's inline color
5709
+ // styles (e.g. dark-mode backgrounds). Strip them so pasted tables adopt
5710
+ // the current theme instead of carrying stale colors.
5711
+ #stripTableCellColorStyles() {
5712
+ for (const cell of this.doc.querySelectorAll("td, th")) {
5713
+ cell.style.removeProperty("background-color");
5714
+ cell.style.removeProperty("background");
5715
+ cell.style.removeProperty("color");
5716
+ }
5717
+ }
5718
+
5719
+ // Only <li> is a valid child of a list; drop stray <br>/whitespace so the
5720
+ // import doesn't wrap them into an empty leading item.
5721
+ #stripStrayListChildren() {
5722
+ for (const list of this.doc.querySelectorAll("ol, ul")) {
5723
+ for (const child of Array.from(list.childNodes)) {
5724
+ if (child.nodeType === Node.ELEMENT_NODE && child.tagName === "LI") continue
5725
+ list.removeChild(child);
5726
+ }
5727
+ }
5481
5728
  }
5482
5729
  }
5483
5730
 
@@ -5512,21 +5759,16 @@ class Contents {
5512
5759
 
5513
5760
  insertAtCursor(...nodes) {
5514
5761
  const selection = $getSelection() ?? $getRoot().selectEnd();
5515
- const inserter = NodeInserter.for(selection);
5762
+ const inserter = BaseNodeInserter.for(selection);
5516
5763
 
5517
5764
  inserter.insertNodes(nodes);
5518
5765
  }
5519
5766
 
5520
- insertAtCursorEnsuringLineBelow(node) {
5521
- this.insertAtCursor(node);
5522
- this.#insertLineBelowIfLastNode(node);
5523
- }
5524
-
5525
5767
  applyParagraphFormat() {
5526
5768
  const selection = $getSelection();
5527
5769
  if (!$isRangeSelection(selection)) return
5528
5770
 
5529
- $expandSelectionToLineBreaksAndSplitAtEdges(selection);
5771
+ $expandSelectionToLineBreaksAndSplitAtEdges(selection, (node) => $getNearestBlockElementAncestorOrThrow(node));
5530
5772
  $setBlocksType(selection, () => $createParagraphNode());
5531
5773
  }
5532
5774
 
@@ -5539,13 +5781,11 @@ class Contents {
5539
5781
  }
5540
5782
 
5541
5783
  applyUnorderedListFormat() {
5542
- this.#splitParagraphsAtLineBreaksUnlessInsideList();
5543
- this.editor.dispatchCommand(INSERT_UNORDERED_LIST_COMMAND, undefined);
5784
+ this.#applyListFormat("bullet", INSERT_UNORDERED_LIST_COMMAND);
5544
5785
  }
5545
5786
 
5546
5787
  applyOrderedListFormat() {
5547
- this.#splitParagraphsAtLineBreaksUnlessInsideList();
5548
- this.editor.dispatchCommand(INSERT_ORDERED_LIST_COMMAND, undefined);
5788
+ this.#applyListFormat("number", INSERT_ORDERED_LIST_COMMAND);
5549
5789
  }
5550
5790
 
5551
5791
  clearFormatting() {
@@ -5633,7 +5873,7 @@ class Contents {
5633
5873
 
5634
5874
  const selection = $getSelection();
5635
5875
  if ($isRangeSelection(selection)) {
5636
- selection.insertNodes([ linkNode ]);
5876
+ BaseNodeInserter.for(selection).insertNodes([ linkNode ]);
5637
5877
  linkNodeKey = linkNode.getKey();
5638
5878
  }
5639
5879
  });
@@ -5665,17 +5905,27 @@ class Contents {
5665
5905
  const fullText = anchorNode.getTextContent();
5666
5906
  const offset = anchor.offset;
5667
5907
 
5668
- const textBeforeCursor = fullText.slice(0, offset);
5669
-
5670
- const lastIndex = textBeforeCursor.lastIndexOf(string);
5908
+ const lastIndex = fullText.slice(0, offset).lastIndexOf(string);
5671
5909
  if (lastIndex !== -1) {
5672
- result = textBeforeCursor.slice(lastIndex + string.length);
5910
+ result = fullText.slice(lastIndex + string.length, this.#endOffsetAt(fullText, offset));
5673
5911
  }
5674
5912
  });
5675
5913
 
5676
5914
  return result
5677
5915
  }
5678
5916
 
5917
+ // The query runs from the trigger up to the next whitespace, even when the
5918
+ // cursor sits inside an existing word — inserting "@" before "Jack" must
5919
+ // filter by "Jack" rather than treating the prompt as empty.
5920
+ #endOffsetAt(fullText, cursorOffset) {
5921
+ const whitespaceOffset = fullText.slice(cursorOffset).search(/\s/);
5922
+ if (whitespaceOffset === -1) {
5923
+ return fullText.length
5924
+ } else {
5925
+ return cursorOffset + whitespaceOffset
5926
+ }
5927
+ }
5928
+
5679
5929
  containsTextBackUntil(string) {
5680
5930
  let result = false;
5681
5931
 
@@ -5706,10 +5956,10 @@ class Contents {
5706
5956
  const { anchorNode, offset } = this.#getTextAnchorData();
5707
5957
  if (!anchorNode) return
5708
5958
 
5709
- const lastIndex = this.#findLastIndexBeforeCursor(anchorNode, offset, stringToReplace);
5959
+ const lastIndex = this.#findReplacementStart(anchorNode, offset, stringToReplace);
5710
5960
  if (lastIndex === -1) return
5711
5961
 
5712
- this.#performTextReplacement(anchorNode, selection, offset, lastIndex, replacementNodes);
5962
+ this.#performTextReplacement(anchorNode, selection, lastIndex, stringToReplace, replacementNodes);
5713
5963
  }
5714
5964
 
5715
5965
  uploadFiles(files, { selectLast } = {}) {
@@ -5742,24 +5992,46 @@ class Contents {
5742
5992
  })
5743
5993
  }
5744
5994
 
5995
+ $createPendingUploadNode(file) {
5996
+ return $createActionTextAttachmentUploadNode({
5997
+ file,
5998
+ uploadUrl: null,
5999
+ blobUrlTemplate: this.editorElement.blobUrlTemplate,
6000
+ contentType: file.type,
6001
+ })
6002
+ }
6003
+
5745
6004
  insertPendingAttachment(file) {
5746
6005
  if (!this.editorElement.supportsAttachments) return null
5747
6006
 
5748
6007
  let nodeKey = null;
5749
6008
  this.editor.update(() => {
5750
- const uploadNode = new ActionTextAttachmentUploadNode({
5751
- file,
5752
- uploadUrl: null,
5753
- blobUrlTemplate: this.editorElement.blobUrlTemplate,
5754
- editor: this.editor
5755
- });
6009
+ const uploadNode = this.$createPendingUploadNode(file);
5756
6010
  this.insertAtCursor(uploadNode);
5757
6011
  nodeKey = uploadNode.getKey();
5758
- }, { tag: HISTORY_MERGE_TAG });
6012
+ });
6013
+
6014
+ return nodeKey ? this.#pendingAttachmentHandle(nodeKey) : null
6015
+ }
6016
+
6017
+ insertPendingAttachments(files) {
6018
+ const fileList = Array.from(files);
6019
+ if (!this.editorElement.supportsAttachments || fileList.length === 0) return []
6020
+
6021
+ let nodeKeys = [];
6022
+ this.editor.update(() => {
6023
+ const uploader = Uploader.for(this.editorElement, fileList, { pending: true });
6024
+ uploader.$uploadFiles();
6025
+ nodeKeys = (uploader.nodes ?? []).map(node => node.getKey());
6026
+ });
5759
6027
 
5760
- if (!nodeKey) return null
6028
+ return nodeKeys.map(nodeKey => this.#pendingAttachmentHandle(nodeKey))
6029
+ }
5761
6030
 
6031
+ #pendingAttachmentHandle(initialNodeKey) {
5762
6032
  const editor = this.editor;
6033
+ let nodeKey = initialNodeKey;
6034
+
5763
6035
  return {
5764
6036
  setAttributes(blob) {
5765
6037
  editor.update(() => {
@@ -5828,8 +6100,7 @@ class Contents {
5828
6100
  }
5829
6101
 
5830
6102
  #formatPastedDOM(doc) {
5831
- this.#unwrapPlaceholderAnchors(doc);
5832
- this.#stripTableCellColorStyles(doc);
6103
+ new PastedContentFormatter(doc).format();
5833
6104
  }
5834
6105
 
5835
6106
  #dispatchPastedNodesCommand(nodes) {
@@ -5877,6 +6148,45 @@ class Contents {
5877
6148
  codeNode.remove();
5878
6149
  }
5879
6150
 
6151
+ #applyListFormat(listType, command) {
6152
+ if (this.selection.isInsideBlockQuote) {
6153
+ this.#insertListInsideQuote(listType);
6154
+ } else {
6155
+ this.#splitParagraphsAtLineBreaksUnlessInsideList();
6156
+ this.editor.dispatchCommand(command);
6157
+ }
6158
+ }
6159
+
6160
+ #insertListInsideQuote(listType) {
6161
+ for (const group of $consecutiveSiblingGroups(this.#quotedBlocksInSelection())) {
6162
+ this.#wrapBlocksInList(group, listType);
6163
+ }
6164
+ }
6165
+
6166
+ #quotedBlocksInSelection() {
6167
+ const selection = $getSelection();
6168
+ if (!$isRangeSelection(selection)) return []
6169
+
6170
+ const blocks = this.#outermostElements(this.#blockLevelElementsInSelection(selection));
6171
+ return blocks.filter((block) => $isQuoteNode(block.getParent()))
6172
+ }
6173
+
6174
+ #wrapBlocksInList(blocks, listType) {
6175
+ const list = $createListNode(listType);
6176
+ blocks[0].insertBefore(list);
6177
+
6178
+ for (const block of blocks) {
6179
+ const listItem = $createListItemNode();
6180
+ if ($isListNode(block)) {
6181
+ listItem.append(...block.getChildren().flatMap((item) => item.getChildren()));
6182
+ } else {
6183
+ listItem.append(...block.getChildren());
6184
+ }
6185
+ list.append(listItem);
6186
+ block.remove();
6187
+ }
6188
+ }
6189
+
5880
6190
  #splitParagraphsAtLineBreaksUnlessInsideList() {
5881
6191
  if (this.selection.isInsideList) return
5882
6192
 
@@ -5926,17 +6236,6 @@ class Contents {
5926
6236
  }
5927
6237
  }
5928
6238
 
5929
- #insertLineBelowIfLastNode(node) {
5930
- this.editor.update(() => {
5931
- const nextSibling = node.getNextSibling();
5932
- if (!nextSibling) {
5933
- const newParagraph = $createParagraphNode();
5934
- node.insertAfter(newParagraph);
5935
- newParagraph.selectStart();
5936
- }
5937
- });
5938
- }
5939
-
5940
6239
  #unwrap(node) {
5941
6240
  const children = node.getChildren();
5942
6241
 
@@ -5957,30 +6256,6 @@ class Contents {
5957
6256
  node.remove();
5958
6257
  }
5959
6258
 
5960
- // Anchors with non-meaningful hrefs (e.g. "#", "") appear in content copied
5961
- // from rendered views where mentions and interactive elements are wrapped in
5962
- // <a href="#"> tags. Unwrap them so their text content pastes as plain text
5963
- // and real links are preserved.
5964
- #unwrapPlaceholderAnchors(doc) {
5965
- for (const anchor of doc.querySelectorAll("a")) {
5966
- const href = anchor.getAttribute("href") || "";
5967
- if (href === "" || href === "#") {
5968
- anchor.replaceWith(...anchor.childNodes);
5969
- }
5970
- }
5971
- }
5972
-
5973
- // Table cells copied from a page inherit the source theme's inline color
5974
- // styles (e.g. dark-mode backgrounds). Strip them so pasted tables adopt
5975
- // the current theme instead of carrying stale colors.
5976
- #stripTableCellColorStyles(doc) {
5977
- for (const cell of doc.querySelectorAll("td, th")) {
5978
- cell.style.removeProperty("background-color");
5979
- cell.style.removeProperty("background");
5980
- cell.style.removeProperty("color");
5981
- }
5982
- }
5983
-
5984
6259
  #getTextAnchorData() {
5985
6260
  const selection = $getSelection();
5986
6261
  if (!selection || !selection.isCollapsed()) return { anchorNode: null, offset: 0 }
@@ -5993,19 +6268,27 @@ class Contents {
5993
6268
  return { anchorNode, offset: anchor.offset }
5994
6269
  }
5995
6270
 
5996
- #findLastIndexBeforeCursor(anchorNode, offset, stringToReplace) {
6271
+ // The replaced span can straddle the cursor (e.g. "@Jack" when "@" was just
6272
+ // inserted before "Jack"), so we anchor on the trigger before the cursor and
6273
+ // verify the whole string matches there rather than searching text up to it.
6274
+ #findReplacementStart(anchorNode, offset, stringToReplace) {
5997
6275
  const fullText = anchorNode.getTextContent();
5998
- const textBeforeCursor = fullText.slice(0, offset);
5999
- return textBeforeCursor.lastIndexOf(stringToReplace)
6276
+ const triggerIndex = fullText.slice(0, offset).lastIndexOf(stringToReplace[0]);
6277
+
6278
+ if (triggerIndex !== -1 && fullText.startsWith(stringToReplace, triggerIndex)) {
6279
+ return triggerIndex
6280
+ } else {
6281
+ return -1
6282
+ }
6000
6283
  }
6001
6284
 
6002
- #performTextReplacement(anchorNode, selection, offset, lastIndex, replacementNodes) {
6285
+ #performTextReplacement(anchorNode, selection, startIndex, stringToReplace, replacementNodes) {
6003
6286
  const fullText = anchorNode.getTextContent();
6004
- const textBeforeString = fullText.slice(0, lastIndex);
6005
- const textAfterCursor = fullText.slice(offset);
6287
+ const textBeforeString = fullText.slice(0, startIndex);
6288
+ const textAfterString = fullText.slice(startIndex + stringToReplace.length);
6006
6289
 
6007
6290
  const textNodeBefore = this.#cloneTextNodeFormatting(anchorNode, selection, textBeforeString);
6008
- const textNodeAfter = this.#cloneTextNodeFormatting(anchorNode, selection, textAfterCursor || " ");
6291
+ const textNodeAfter = this.#cloneTextNodeFormatting(anchorNode, selection, textAfterString || " ");
6009
6292
 
6010
6293
  anchorNode.replace(textNodeBefore);
6011
6294
 
@@ -6013,7 +6296,7 @@ class Contents {
6013
6296
  lastInsertedNode.insertAfter(textNodeAfter);
6014
6297
 
6015
6298
  this.#appendLineBreakIfNeeded(textNodeAfter.getParentOrThrow());
6016
- const cursorOffset = textAfterCursor ? 0 : 1;
6299
+ const cursorOffset = textAfterString ? 0 : 1;
6017
6300
  textNodeAfter.select(cursorOffset, cursorOffset);
6018
6301
  }
6019
6302
 
@@ -6226,7 +6509,7 @@ class Clipboard {
6226
6509
  }
6227
6510
 
6228
6511
  const linkNode = $createLinkNode(url).append($createTextNode(url));
6229
- selection.insertNodes([ linkNode ]);
6512
+ BaseNodeInserter.for(selection).insertNodes([ linkNode ]);
6230
6513
 
6231
6514
  $onUpdate(() => this.#dispatchLinkInsertEvent(linkNode.getKey(), { url }));
6232
6515
  }
@@ -7257,11 +7540,12 @@ class AttachmentsExtension extends LexxyExtension {
7257
7540
 
7258
7541
  #handleUploadMutations(mutations) {
7259
7542
  const previousUploadsCount = this.#uploadsCount;
7260
- for (const [ , mutation ] of mutations) {
7543
+ for (const [ key, mutation ] of mutations) {
7261
7544
  if (mutation === "created") {
7262
7545
  this.#uploadsCount++;
7263
7546
  } else if (mutation === "destroyed") {
7264
7547
  this.#uploadsCount--;
7548
+ this.editorElement.uploadRequests.abort(key);
7265
7549
  }
7266
7550
  }
7267
7551
 
@@ -7692,7 +7976,7 @@ class LexicalEditorElement extends HTMLElement {
7692
7976
  static debug = false
7693
7977
  static commands = [ "bold", "italic", "strikethrough" ]
7694
7978
 
7695
- static observedAttributes = [ "connected", "required" ]
7979
+ static observedAttributes = [ "autocapitalize", "connected", "required" ]
7696
7980
 
7697
7981
  #initialValue = ""
7698
7982
  #previousInternalFormValue = null
@@ -7705,6 +7989,7 @@ class LexicalEditorElement extends HTMLElement {
7705
7989
 
7706
7990
  #validity = new Map()
7707
7991
  #validationTextArea = document.createElement("textarea")
7992
+ #uploadRequests
7708
7993
 
7709
7994
  constructor() {
7710
7995
  super();
@@ -7712,6 +7997,10 @@ class LexicalEditorElement extends HTMLElement {
7712
7997
  this.internals.role = "presentation";
7713
7998
  }
7714
7999
 
8000
+ get uploadRequests() {
8001
+ return this.#uploadRequests
8002
+ }
8003
+
7715
8004
  connectedCallback() {
7716
8005
  this.id ||= generateDomId("lexxy-editor");
7717
8006
  this.config = new EditorConfiguration(this);
@@ -7732,6 +8021,7 @@ class LexicalEditorElement extends HTMLElement {
7732
8021
  this.#disposables.push(this.clipboard);
7733
8022
 
7734
8023
  this.adapter = new BrowserAdapter();
8024
+ this.#uploadRequests = new UploadRequests();
7735
8025
 
7736
8026
  const commandDispatcher = CommandDispatcher.configureFor(this);
7737
8027
  this.#disposables.push(commandDispatcher);
@@ -7759,8 +8049,15 @@ class LexicalEditorElement extends HTMLElement {
7759
8049
  }
7760
8050
 
7761
8051
  attributeChangedCallback(name, oldValue, newValue) {
7762
- if (name === "connected") this.connectedChangedCallback(oldValue, newValue);
7763
- if (name === "required") this.requiredChangedCallback(oldValue, newValue);
8052
+ if (typeof this[`${name}ChangedCallback`] === "function") {
8053
+ this[`${name}ChangedCallback`](oldValue, newValue);
8054
+ }
8055
+ }
8056
+
8057
+ autocapitalizeChangedCallback() {
8058
+ if (this.editorContentElement) {
8059
+ this.#transferAttributeToContentEditable(this.editorContentElement, "autocapitalize");
8060
+ }
7764
8061
  }
7765
8062
 
7766
8063
  connectedChangedCallback(oldValue, newValue) {
@@ -8092,25 +8389,33 @@ class LexicalEditorElement extends HTMLElement {
8092
8389
 
8093
8390
  #createEditorContentElement() {
8094
8391
  const editorContentElement = createElement("div", {
8392
+ id: `${this.id}-content`,
8095
8393
  classList: "lexxy-editor__content",
8096
8394
  contenteditable: true,
8097
- autocapitalize: "none",
8098
8395
  role: "textbox",
8099
8396
  "aria-multiline": true,
8100
8397
  "aria-label": this.#labelText,
8101
8398
  placeholder: this.getAttribute("placeholder")
8102
8399
  });
8103
- editorContentElement.id = `${this.id}-content`;
8400
+
8104
8401
  this.#ariaAttributes.forEach(attribute => editorContentElement.setAttribute(attribute.name, attribute.value));
8105
8402
 
8106
- if (this.getAttribute("tabindex")) {
8107
- editorContentElement.setAttribute("tabindex", this.getAttribute("tabindex"));
8108
- this.removeAttribute("tabindex");
8403
+ this.#transferAttributeToContentEditable(editorContentElement, "autocapitalize");
8404
+ this.#transferAttributeToContentEditable(editorContentElement, "tabindex", { defaultValue: 0, removeSource: true });
8405
+
8406
+ return editorContentElement
8407
+ }
8408
+
8409
+ #transferAttributeToContentEditable(element, qualifiedName, { defaultValue = null, removeSource = false } = {}) {
8410
+ if (this.hasAttribute(qualifiedName)) {
8411
+ element.setAttribute(qualifiedName, this.getAttribute(qualifiedName));
8412
+ } else if (defaultValue !== null) {
8413
+ element.setAttribute(qualifiedName, defaultValue);
8109
8414
  } else {
8110
- editorContentElement.setAttribute("tabindex", 0);
8415
+ element.removeAttribute(qualifiedName);
8111
8416
  }
8112
8417
 
8113
- return editorContentElement
8418
+ if (removeSource) this.removeAttribute(qualifiedName);
8114
8419
  }
8115
8420
 
8116
8421
  get #labelText() {
@@ -8490,6 +8795,7 @@ class LexicalEditorElement extends HTMLElement {
8490
8795
  #reset() {
8491
8796
  this.#dispose();
8492
8797
  this.#resetValidity();
8798
+ this.#uploadRequests?.clear();
8493
8799
  this.editorContentElement?.remove();
8494
8800
  this.editorContentElement = null;
8495
8801
 
@@ -8727,6 +9033,7 @@ class LexicalPromptElement extends HTMLElement {
8727
9033
  this.source = this.#createSource();
8728
9034
 
8729
9035
  this.#addTriggerListener();
9036
+ this.#removePopoverBeforeTurboCaches();
8730
9037
  this.toggleAttribute("connected", true);
8731
9038
  }
8732
9039
 
@@ -8734,7 +9041,7 @@ class LexicalPromptElement extends HTMLElement {
8734
9041
  this.#popoverListeners.dispose();
8735
9042
  this.#globalListeners.dispose();
8736
9043
  this.source = null;
8737
- this.popoverElement = null;
9044
+ this.#removePopover();
8738
9045
  }
8739
9046
 
8740
9047
 
@@ -8855,24 +9162,33 @@ class LexicalPromptElement extends HTMLElement {
8855
9162
  const { node, offset } = this.#selection.selectedNodeWithOffset();
8856
9163
  if (!node) return
8857
9164
 
8858
- if ($isTextNode(node) && offset > 0) {
8859
- const fullText = node.getTextContent();
8860
- const textBeforeCursor = fullText.slice(0, offset);
8861
- const lastTriggerIndex = textBeforeCursor.lastIndexOf(this.trigger);
8862
- const triggerEndIndex = lastTriggerIndex + this.trigger.length - 1;
8863
-
8864
- // If trigger is not found, or cursor is at or before the trigger end position, hide popover
8865
- if (lastTriggerIndex === -1 || offset <= triggerEndIndex) {
8866
- this.#hidePopover();
8867
- }
9165
+ if (this.#cursorIsTypingSearchTerm(node, offset)) {
9166
+ return
8868
9167
  } else {
8869
- // Cursor is not in a text node or at offset 0, hide popover
8870
9168
  this.#hidePopover();
8871
9169
  }
8872
9170
  });
8873
9171
  }));
8874
9172
  }
8875
9173
 
9174
+ // The popover should stay open only while the cursor sits at the end of the
9175
+ // trigger and its search term. When the cursor moves away — before the
9176
+ // trigger, or past the token into later text — the text between the trigger
9177
+ // and the cursor breaks that run and we dismiss. A newline always breaks the
9178
+ // run; a space breaks it only for triggers that don't support spaces in
9179
+ // searches, since those that do (e.g. `person:`) expect multi-word terms.
9180
+ #cursorIsTypingSearchTerm(node, offset) {
9181
+ if (!$isTextNode(node) || offset === 0) return false
9182
+
9183
+ const textBeforeCursor = node.getTextContent().slice(0, offset);
9184
+ const lastTriggerIndex = textBeforeCursor.lastIndexOf(this.trigger);
9185
+ if (lastTriggerIndex === -1) return false
9186
+
9187
+ const searchTerm = textBeforeCursor.slice(lastTriggerIndex + this.trigger.length);
9188
+ const breakPattern = this.supportsSpaceInSearches ? /\n/ : /[ \n]/;
9189
+ return !breakPattern.test(searchTerm)
9190
+ }
9191
+
8876
9192
  get #editor() {
8877
9193
  return this.#editorElement.editor
8878
9194
  }
@@ -9030,6 +9346,21 @@ class LexicalPromptElement extends HTMLElement {
9030
9346
  this.#addTriggerListener();
9031
9347
  }
9032
9348
 
9349
+ // The popover is appended to the <lexxy-editor> subtree, so Turbo serializes it
9350
+ // into the page cache. Removing it before caching prevents an orphaned, unmanaged
9351
+ // popover from being restored on history back/forward.
9352
+ #removePopoverBeforeTurboCaches() {
9353
+ this.#globalListeners.track(
9354
+ registerEventListener(document, "turbo:before-cache", () => this.#removePopover())
9355
+ );
9356
+ }
9357
+
9358
+ #removePopover() {
9359
+ this.#popoverListeners.dispose();
9360
+ this.popoverElement?.remove();
9361
+ this.popoverElement = null;
9362
+ }
9363
+
9033
9364
  #filterOptions = async () => {
9034
9365
  if (this.initialPrompt) {
9035
9366
  this.initialPrompt = false;
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@37signals/lexxy",
3
- "version": "0.9.19-alpha.1",
3
+ "version": "0.9.19-alpha.3",
4
4
  "description": "Lexxy - A modern rich text editor for Rails.",
5
5
  "module": "dist/lexxy.esm.js",
6
6
  "type": "module",
@@ -28,8 +28,8 @@
28
28
  "@rollup/plugin-node-resolve": "^16.0.1",
29
29
  "@rollup/plugin-terser": "^0.4.4",
30
30
  "eslint": "^10.3.0",
31
- "globals": "^17.6.0",
32
31
  "eslint-plugin-compat": "^7.0.2",
32
+ "globals": "^17.6.0",
33
33
  "jsdom": "^27.3.0",
34
34
  "rollup": "^4.44.1",
35
35
  "rollup-plugin-copy": "^3.5.0",
@@ -52,7 +52,8 @@
52
52
  "test:browser:headed": "npx playwright test --config test/browser/playwright.config.js --headed",
53
53
  "test:browser:debug": "npx playwright test --config test/browser/playwright.config.js --debug",
54
54
  "prerelease": "yarn build:npm",
55
- "release": "yarn build:npm && yarn publish"
55
+ "release": "yarn build:npm && yarn publish",
56
+ "release:alpha": "yarn build:npm && yarn publish --tag alpha"
56
57
  },
57
58
  "dependencies": {
58
59
  "@lexical/clipboard": "^0.44.0",