lexxy 0.9.19.alpha.2 → 0.9.19

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.
@@ -6301,6 +6301,14 @@ class ListenerBin {
6301
6301
  }
6302
6302
  }
6303
6303
 
6304
+ function handlingDefault(handler) {
6305
+ return event => {
6306
+ const handled = handler(event);
6307
+ if (handled) event.preventDefault();
6308
+ return handled
6309
+ }
6310
+ }
6311
+
6304
6312
  function createElement(name, properties, content = "") {
6305
6313
  const element = document.createElement(name);
6306
6314
  for (const [ key, value ] of Object.entries(properties || {})) {
@@ -7642,7 +7650,8 @@ class CustomActionTextAttachmentNode extends Ji {
7642
7650
  }
7643
7651
 
7644
7652
  createDOM() {
7645
- const figure = createElement(this.tagName, { "content-type": this.contentType, "data-lexxy-decorator": true });
7653
+ const figure = createElement(this.tagName, { "content-type": this.contentType, "data-lexxy-decorator": true, draggable: true });
7654
+ figure.dataset.lexicalNodeKey = this.__key;
7646
7655
 
7647
7656
  figure.insertAdjacentHTML("beforeend", sanitize(this.innerHtml));
7648
7657
 
@@ -7695,6 +7704,10 @@ class CustomActionTextAttachmentNode extends Ji {
7695
7704
  }
7696
7705
  }
7697
7706
 
7707
+ function $isCustomActionTextAttachmentNode(node) {
7708
+ return node instanceof CustomActionTextAttachmentNode
7709
+ }
7710
+
7698
7711
  function dasherize(value) {
7699
7712
  return value.replace(/([A-Z])/g, (_, char) => `-${char.toLowerCase()}`)
7700
7713
  }
@@ -9189,6 +9202,30 @@ var theme = {
9189
9202
  }
9190
9203
  };
9191
9204
 
9205
+ class UploadRequests {
9206
+ #requestsByKey = new Map()
9207
+
9208
+ track(key, request) {
9209
+ this.#requestsByKey.set(key, request);
9210
+ }
9211
+
9212
+ forget(key) {
9213
+ this.#requestsByKey.delete(key);
9214
+ }
9215
+
9216
+ abort(key) {
9217
+ const request = this.#requestsByKey.get(key);
9218
+ if (request) {
9219
+ this.#requestsByKey.delete(key);
9220
+ request.abort();
9221
+ }
9222
+ }
9223
+
9224
+ clear() {
9225
+ this.#requestsByKey.clear();
9226
+ }
9227
+ }
9228
+
9192
9229
  /**
9193
9230
  * Copyright (c) Meta Platforms, Inc. and affiliates.
9194
9231
  *
@@ -10222,7 +10259,7 @@ class CommandDispatcher {
10222
10259
  }
10223
10260
 
10224
10261
  #isInternalDrag(event) {
10225
- return event.dataTransfer?.types.includes("application/x-lexxy-node-key")
10262
+ return event.dataTransfer?.types.some((type) => type.startsWith("application/x-lexxy-"))
10226
10263
  }
10227
10264
 
10228
10265
  #handleTabKey(event) {
@@ -10276,14 +10313,14 @@ class Selection {
10276
10313
  }
10277
10314
 
10278
10315
  get cursorPosition() {
10279
- let position = { x: 0, y: 0 };
10316
+ let position = null;
10280
10317
 
10281
10318
  this.editor.getEditorState().read(() => {
10282
10319
  const range = this.#getValidSelectionRange();
10283
10320
  if (!range) return
10284
10321
 
10285
10322
  const rect = this.#getReliableRectFromRange(range);
10286
- if (!rect) return
10323
+ if (this.#isRectUnreliable(rect)) return
10287
10324
 
10288
10325
  position = this.#calculateCursorPosition(rect, range);
10289
10326
  });
@@ -10574,10 +10611,10 @@ class Selection {
10574
10611
 
10575
10612
  #processSelectionChangeCommands() {
10576
10613
  this.#listeners.track(
10577
- this.editor.registerCommand(Ne$1, this.#selectPreviousNode.bind(this), io),
10578
- this.editor.registerCommand(ke$2, this.#selectNextNode.bind(this), io),
10579
- this.editor.registerCommand(we$1, this.#selectPreviousTopLevelNode.bind(this), io),
10580
- this.editor.registerCommand(Ee$3, this.#selectNextTopLevelNode.bind(this), io),
10614
+ this.editor.registerCommand(Ne$1, handlingDefault(this.#selectPreviousNode.bind(this)), io),
10615
+ this.editor.registerCommand(ke$2, handlingDefault(this.#selectNextNode.bind(this)), io),
10616
+ this.editor.registerCommand(we$1, handlingDefault(this.#selectPreviousTopLevelNode.bind(this)), io),
10617
+ this.editor.registerCommand(Ee$3, handlingDefault(this.#selectNextTopLevelNode.bind(this)), io),
10581
10618
 
10582
10619
  this.editor.registerCommand(fe$4, this.#selectDecoratorNodeBeforeDeletion.bind(this), io),
10583
10620
 
@@ -10676,49 +10713,58 @@ class Selection {
10676
10713
  }
10677
10714
  }
10678
10715
 
10679
- async #selectPreviousNode(event) {
10680
- if (event?.shiftKey) return false
10716
+ #selectPreviousNode(event) {
10717
+ if (event.shiftKey) {
10718
+ return this.#withCurrentNodeSelectionNode((currentNode) => {
10719
+ const selection = this.#rangeSelectDecorator(currentNode, "forward");
10681
10720
 
10682
- if (this.hasNodeSelection) {
10683
- return await this.#withCurrentNode((currentNode) => currentNode.selectPrevious())
10721
+ // Can't rely on native pass-through with Playwright on firefox
10722
+ selection.modify("extend", true, "character");
10723
+ return true
10724
+ })
10684
10725
  } else {
10685
- return this.#selectInLexical(this.nodeBeforeCursor)
10726
+ return this.#withCurrentNodeSelectionNode((currentNode) => currentNode.selectPrevious())
10727
+ || this.#selectInLexical(this.nodeBeforeCursor)
10686
10728
  }
10687
10729
  }
10688
10730
 
10689
- async #selectNextNode(event) {
10690
- if (event?.shiftKey) return false
10731
+ #selectNextNode(event) {
10732
+ if (event.shiftKey) {
10733
+ return this.#withCurrentNodeSelectionNode((currentNode) => {
10734
+ const selection = this.#rangeSelectDecorator(currentNode, "forward");
10691
10735
 
10692
- if (this.hasNodeSelection) {
10693
- return await this.#withCurrentNode((currentNode) => currentNode.selectNext(0, 0))
10736
+ // Can't rely on native pass-through with Playwright on firefox
10737
+ selection.modify("extend", false, "character");
10738
+ return true
10739
+ })
10694
10740
  } else {
10695
- return this.#selectInLexical(this.nodeAfterCursor)
10741
+ return this.#withCurrentNodeSelectionNode((currentNode) => currentNode.selectNext(0, 0))
10742
+ || this.#selectInLexical(this.nodeAfterCursor)
10696
10743
  }
10697
10744
  }
10698
10745
 
10699
- async #selectPreviousTopLevelNode() {
10700
- if (this.hasNodeSelection) {
10701
- return await this.#withCurrentNode((currentNode) => currentNode.getTopLevelElement().selectPrevious())
10702
- } else {
10703
- return this.#selectInLexical(this.topLevelNodeBeforeCursor)
10704
- }
10746
+ #selectPreviousTopLevelNode() {
10747
+ return this.#withCurrentNodeSelectionNode((currentNode) => currentNode.getTopLevelElement().selectPrevious())
10748
+ || this.#selectInLexical(this.topLevelNodeBeforeCursor)
10749
+ }
10750
+
10751
+ #selectNextTopLevelNode() {
10752
+ return this.#withCurrentNodeSelectionNode((currentNode) => currentNode.getTopLevelElement().selectNext(0, 0))
10753
+ || this.#selectInLexical(this.topLevelNodeAfterCursor)
10705
10754
  }
10706
10755
 
10707
- async #selectNextTopLevelNode() {
10756
+ #withCurrentNodeSelectionNode(fn) {
10708
10757
  if (this.hasNodeSelection) {
10709
- return await this.#withCurrentNode((currentNode) => currentNode.getTopLevelElement().selectNext(0, 0))
10710
- } else {
10711
- return this.#selectInLexical(this.topLevelNodeAfterCursor)
10758
+ return fn(Qr().getNodes()[0])
10712
10759
  }
10713
10760
  }
10714
10761
 
10715
- async #withCurrentNode(fn) {
10716
- await nextFrame();
10717
- if (this.hasNodeSelection) {
10718
- this.editor.update(() => {
10719
- fn(Qr().getNodes()[0]);
10720
- this.editor.focus();
10721
- });
10762
+ #rangeSelectDecorator(node, direction = "forward") {
10763
+ if (ji(node)) {
10764
+ const [ anchorOffset, focusOffset ] = direction === "forward" ? [ 0, 1 ] : [ 1, 0 ];
10765
+ const indexAtNode = node.getIndexWithinParent();
10766
+
10767
+ return node.getParent().select(indexAtNode + anchorOffset, indexAtNode + focusOffset)
10722
10768
  }
10723
10769
  }
10724
10770
 
@@ -11340,9 +11386,9 @@ function $findOrCreateGalleryForImage(node) {
11340
11386
  class Uploader {
11341
11387
  #files
11342
11388
 
11343
- static for(editorElement, files) {
11389
+ static for(editorElement, files, options = {}) {
11344
11390
  const UploaderKlass = GalleryUploader.handle(editorElement, files) ? GalleryUploader : Uploader;
11345
- return new UploaderKlass(editorElement, files)
11391
+ return new UploaderKlass(editorElement, files, options)
11346
11392
  }
11347
11393
 
11348
11394
  constructor(editorElement, files, options = {}) {
@@ -11364,7 +11410,13 @@ class Uploader {
11364
11410
  }
11365
11411
 
11366
11412
  $createUploadNodes() {
11367
- this.nodes = this.files.map(file => this.contents.$createUploadNode(file));
11413
+ this.nodes = this.files.map(file => this.#createUploadNode(file));
11414
+ }
11415
+
11416
+ #createUploadNode(file) {
11417
+ return this.options.pending
11418
+ ? this.contents.$createPendingUploadNode(file)
11419
+ : this.contents.$createUploadNode(file)
11368
11420
  }
11369
11421
 
11370
11422
  $insertUploadNodes() {
@@ -11622,6 +11674,8 @@ class ActionTextAttachmentUploadNode extends ActionTextAttachmentNode {
11622
11674
  this.#dispatchEvent("lexxy:upload-start", { file: this.file });
11623
11675
 
11624
11676
  upload.create((error, blob) => {
11677
+ this.#forgetUploadRequest();
11678
+
11625
11679
  if (error) {
11626
11680
  this.#dispatchEvent("lexxy:upload-end", { file: this.file, error });
11627
11681
  this.#handleUploadError(error);
@@ -11644,12 +11698,26 @@ class ActionTextAttachmentUploadNode extends ActionTextAttachmentNode {
11644
11698
  directUploadWillStoreFileWithXHR: (request) => {
11645
11699
  if (shouldAuthenticateUploads) request.withCredentials = true;
11646
11700
 
11701
+ this.#rememberUploadRequest(request);
11702
+
11647
11703
  const uploadProgressHandler = (event) => this.#handleUploadProgress(event, request);
11648
11704
  request.upload.addEventListener("progress", uploadProgressHandler);
11649
11705
  }
11650
11706
  }
11651
11707
  }
11652
11708
 
11709
+ #forgetUploadRequest() {
11710
+ this.#editorElement.uploadRequests.forget(this.getKey());
11711
+ }
11712
+
11713
+ #rememberUploadRequest(request) {
11714
+ this.#editorElement.uploadRequests.track(this.getKey(), request);
11715
+ }
11716
+
11717
+ get #editorElement() {
11718
+ return this.editor.getRootElement()?.closest("lexxy-editor")
11719
+ }
11720
+
11653
11721
  #setUploadStarted() {
11654
11722
  this.#setProgress(1);
11655
11723
  }
@@ -11822,7 +11890,7 @@ class ShadowRootNodeInserter extends BaseNodeInserter {
11822
11890
 
11823
11891
  class NodeSelectionNodeInserter extends BaseNodeInserter {
11824
11892
  static handles(selection) {
11825
- return Ir(selection)
11893
+ return Ir(selection) && selection.getNodes().length > 0
11826
11894
  }
11827
11895
 
11828
11896
  insertNodes(nodes) {
@@ -12020,18 +12088,20 @@ class Contents {
12020
12088
  }, { tag });
12021
12089
  }
12022
12090
 
12091
+ insertText(text, { tag } = {}) {
12092
+ this.editor.update(() => {
12093
+ const paragraph = eo().append(kr(text));
12094
+ this.insertAtCursor(paragraph);
12095
+ }, { tag });
12096
+ }
12097
+
12023
12098
  insertAtCursor(...nodes) {
12024
- const selection = Qr() ?? Zo().selectEnd();
12099
+ const selection = this.#insertableSelection();
12025
12100
  const inserter = BaseNodeInserter.for(selection);
12026
12101
 
12027
12102
  inserter.insertNodes(nodes);
12028
12103
  }
12029
12104
 
12030
- insertAtCursorEnsuringLineBelow(node) {
12031
- this.insertAtCursor(node);
12032
- this.#insertLineBelowIfLastNode(node);
12033
- }
12034
-
12035
12105
  applyParagraphFormat() {
12036
12106
  const selection = Qr();
12037
12107
  if (!Fr(selection)) return
@@ -12173,17 +12243,27 @@ class Contents {
12173
12243
  const fullText = anchorNode.getTextContent();
12174
12244
  const offset = anchor.offset;
12175
12245
 
12176
- const textBeforeCursor = fullText.slice(0, offset);
12177
-
12178
- const lastIndex = textBeforeCursor.lastIndexOf(string);
12246
+ const lastIndex = fullText.slice(0, offset).lastIndexOf(string);
12179
12247
  if (lastIndex !== -1) {
12180
- result = textBeforeCursor.slice(lastIndex + string.length);
12248
+ result = fullText.slice(lastIndex + string.length, this.#endOffsetAt(fullText, offset));
12181
12249
  }
12182
12250
  });
12183
12251
 
12184
12252
  return result
12185
12253
  }
12186
12254
 
12255
+ // The query runs from the trigger up to the next whitespace, even when the
12256
+ // cursor sits inside an existing word — inserting "@" before "Jack" must
12257
+ // filter by "Jack" rather than treating the prompt as empty.
12258
+ #endOffsetAt(fullText, cursorOffset) {
12259
+ const whitespaceOffset = fullText.slice(cursorOffset).search(/\s/);
12260
+ if (whitespaceOffset === -1) {
12261
+ return fullText.length
12262
+ } else {
12263
+ return cursorOffset + whitespaceOffset
12264
+ }
12265
+ }
12266
+
12187
12267
  containsTextBackUntil(string) {
12188
12268
  let result = false;
12189
12269
 
@@ -12210,14 +12290,13 @@ class Contents {
12210
12290
  replaceTextBackUntil(stringToReplace, replacementNodes) {
12211
12291
  replacementNodes = Array.isArray(replacementNodes) ? replacementNodes : [ replacementNodes ];
12212
12292
 
12213
- const selection = Qr();
12214
12293
  const { anchorNode, offset } = this.#getTextAnchorData();
12215
12294
  if (!anchorNode) return
12216
12295
 
12217
- const lastIndex = this.#findLastIndexBeforeCursor(anchorNode, offset, stringToReplace);
12296
+ const lastIndex = this.#findReplacementStart(anchorNode, offset, stringToReplace);
12218
12297
  if (lastIndex === -1) return
12219
12298
 
12220
- this.#performTextReplacement(anchorNode, selection, offset, lastIndex, replacementNodes);
12299
+ this.#performTextReplacement(anchorNode, lastIndex, stringToReplace, replacementNodes);
12221
12300
  }
12222
12301
 
12223
12302
  uploadFiles(files, { selectLast } = {}) {
@@ -12250,24 +12329,46 @@ class Contents {
12250
12329
  })
12251
12330
  }
12252
12331
 
12332
+ $createPendingUploadNode(file) {
12333
+ return $createActionTextAttachmentUploadNode({
12334
+ file,
12335
+ uploadUrl: null,
12336
+ blobUrlTemplate: this.editorElement.blobUrlTemplate,
12337
+ contentType: file.type,
12338
+ })
12339
+ }
12340
+
12253
12341
  insertPendingAttachment(file) {
12254
12342
  if (!this.editorElement.supportsAttachments) return null
12255
12343
 
12256
12344
  let nodeKey = null;
12257
12345
  this.editor.update(() => {
12258
- const uploadNode = new ActionTextAttachmentUploadNode({
12259
- file,
12260
- uploadUrl: null,
12261
- blobUrlTemplate: this.editorElement.blobUrlTemplate,
12262
- editor: this.editor
12263
- });
12346
+ const uploadNode = this.$createPendingUploadNode(file);
12264
12347
  this.insertAtCursor(uploadNode);
12265
12348
  nodeKey = uploadNode.getKey();
12266
- }, { tag: Jn });
12349
+ });
12267
12350
 
12268
- if (!nodeKey) return null
12351
+ return nodeKey ? this.#pendingAttachmentHandle(nodeKey) : null
12352
+ }
12269
12353
 
12354
+ insertPendingAttachments(files) {
12355
+ const fileList = Array.from(files);
12356
+ if (!this.editorElement.supportsAttachments || fileList.length === 0) return []
12357
+
12358
+ let nodeKeys = [];
12359
+ this.editor.update(() => {
12360
+ const uploader = Uploader.for(this.editorElement, fileList, { pending: true });
12361
+ uploader.$uploadFiles();
12362
+ nodeKeys = (uploader.nodes ?? []).map(node => node.getKey());
12363
+ });
12364
+
12365
+ return nodeKeys.map(nodeKey => this.#pendingAttachmentHandle(nodeKey))
12366
+ }
12367
+
12368
+ #pendingAttachmentHandle(initialNodeKey) {
12270
12369
  const editor = this.editor;
12370
+ let nodeKey = initialNodeKey;
12371
+
12271
12372
  return {
12272
12373
  setAttributes(blob) {
12273
12374
  editor.update(() => {
@@ -12335,6 +12436,15 @@ class Contents {
12335
12436
  });
12336
12437
  }
12337
12438
 
12439
+ #insertableSelection() {
12440
+ const selection = Qr();
12441
+ if (Ir(selection) && selection.getNodes().length === 0) {
12442
+ return Zo().selectEnd()
12443
+ }
12444
+
12445
+ return selection ?? Zo().selectEnd()
12446
+ }
12447
+
12338
12448
  #formatPastedDOM(doc) {
12339
12449
  new PastedContentFormatter(doc).format();
12340
12450
  }
@@ -12472,17 +12582,6 @@ class Contents {
12472
12582
  }
12473
12583
  }
12474
12584
 
12475
- #insertLineBelowIfLastNode(node) {
12476
- this.editor.update(() => {
12477
- const nextSibling = node.getNextSibling();
12478
- if (!nextSibling) {
12479
- const newParagraph = eo();
12480
- node.insertAfter(newParagraph);
12481
- newParagraph.selectStart();
12482
- }
12483
- });
12484
- }
12485
-
12486
12585
  #unwrap(node) {
12487
12586
  const children = node.getChildren();
12488
12587
 
@@ -12515,19 +12614,27 @@ class Contents {
12515
12614
  return { anchorNode, offset: anchor.offset }
12516
12615
  }
12517
12616
 
12518
- #findLastIndexBeforeCursor(anchorNode, offset, stringToReplace) {
12617
+ // The replaced span can straddle the cursor (e.g. "@Jack" when "@" was just
12618
+ // inserted before "Jack"), so we anchor on the trigger before the cursor and
12619
+ // verify the whole string matches there rather than searching text up to it.
12620
+ #findReplacementStart(anchorNode, offset, stringToReplace) {
12519
12621
  const fullText = anchorNode.getTextContent();
12520
- const textBeforeCursor = fullText.slice(0, offset);
12521
- return textBeforeCursor.lastIndexOf(stringToReplace)
12622
+ const triggerIndex = fullText.slice(0, offset).lastIndexOf(stringToReplace[0]);
12623
+
12624
+ if (triggerIndex !== -1 && fullText.startsWith(stringToReplace, triggerIndex)) {
12625
+ return triggerIndex
12626
+ } else {
12627
+ return -1
12628
+ }
12522
12629
  }
12523
12630
 
12524
- #performTextReplacement(anchorNode, selection, offset, lastIndex, replacementNodes) {
12631
+ #performTextReplacement(anchorNode, startIndex, stringToReplace, replacementNodes) {
12525
12632
  const fullText = anchorNode.getTextContent();
12526
- const textBeforeString = fullText.slice(0, lastIndex);
12527
- const textAfterCursor = fullText.slice(offset);
12633
+ const textBeforeString = fullText.slice(0, startIndex);
12634
+ const textAfterString = fullText.slice(startIndex + stringToReplace.length);
12528
12635
 
12529
- const textNodeBefore = this.#cloneTextNodeFormatting(anchorNode, selection, textBeforeString);
12530
- const textNodeAfter = this.#cloneTextNodeFormatting(anchorNode, selection, textAfterCursor || " ");
12636
+ const textNodeBefore = this.#cloneTextNodeFormatting(anchorNode, textBeforeString);
12637
+ const textNodeAfter = this.#cloneTextNodeFormatting(anchorNode, textAfterString || " ");
12531
12638
 
12532
12639
  anchorNode.replace(textNodeBefore);
12533
12640
 
@@ -12535,22 +12642,16 @@ class Contents {
12535
12642
  lastInsertedNode.insertAfter(textNodeAfter);
12536
12643
 
12537
12644
  this.#appendLineBreakIfNeeded(textNodeAfter.getParentOrThrow());
12538
- const cursorOffset = textAfterCursor ? 0 : 1;
12645
+ const cursorOffset = textAfterString ? 0 : 1;
12539
12646
  textNodeAfter.select(cursorOffset, cursorOffset);
12540
12647
  }
12541
12648
 
12542
- #cloneTextNodeFormatting(anchorNode, selection, text) {
12543
- const parent = anchorNode.getParent();
12544
- const fallbackFormat = parent?.getTextFormat?.() || 0;
12545
- const fallbackStyle = parent?.getTextStyle?.() || "";
12546
- const format = Fr(selection) && selection.format ? selection.format : (anchorNode.getFormat() || fallbackFormat);
12547
- const style = Fr(selection) && selection.style ? selection.style : (anchorNode.getStyle() || fallbackStyle);
12548
-
12649
+ #cloneTextNodeFormatting(anchorNode, text) {
12549
12650
  return kr(text)
12550
- .setFormat(format)
12651
+ .setFormat(anchorNode.getFormat())
12551
12652
  .setDetail(anchorNode.getDetail())
12552
12653
  .setMode(anchorNode.getMode())
12553
- .setStyle(style)
12654
+ .setStyle(anchorNode.getStyle())
12554
12655
  }
12555
12656
 
12556
12657
  #insertReplacementNodes(startNode, replacementNodes) {
@@ -12840,14 +12941,31 @@ class Clipboard {
12840
12941
  #pasteMarkdown(text) {
12841
12942
  const html = k(text, { breaks: true });
12842
12943
  const doc = parseHtml(html);
12843
- const detail = Object.freeze({
12844
- markdown: text,
12845
- document: doc,
12846
- addBlockSpacing: () => addBlockSpacing(doc)
12847
- });
12848
12944
 
12849
- dispatch(this.editorElement, "lexxy:insert-markdown", detail);
12850
- this.contents.insertDOM(doc, { tag: jn });
12945
+ if (this.#isPlainTextWithoutMarkdown(doc)) {
12946
+ this.contents.insertText(text, { tag: jn });
12947
+ } else {
12948
+ const detail = Object.freeze({
12949
+ markdown: text,
12950
+ document: doc,
12951
+ addBlockSpacing: () => addBlockSpacing(doc)
12952
+ });
12953
+
12954
+ dispatch(this.editorElement, "lexxy:insert-markdown", detail);
12955
+ this.contents.insertDOM(doc, { tag: jn });
12956
+ }
12957
+ }
12958
+
12959
+ // Markdown conversion collapses runs of whitespace and unescapes backslashes,
12960
+ // silently corrupting plain text such as Windows/UNC file paths. When the text
12961
+ // carries no Markdown structure, paste it verbatim instead.
12962
+ #isPlainTextWithoutMarkdown(doc) {
12963
+ const elements = Array.from(doc.body.children);
12964
+ if (elements.length !== 1) return false
12965
+
12966
+ const paragraph = elements[0];
12967
+ return paragraph.nodeName === "P"
12968
+ && Array.from(paragraph.childNodes).every((node) => node.nodeType === Node.TEXT_NODE)
12851
12969
  }
12852
12970
 
12853
12971
  #pasteRichText(clipboardData) {
@@ -13455,7 +13573,7 @@ class TablesExtension extends LexxyExtension {
13455
13573
  }
13456
13574
  }
13457
13575
 
13458
- const MIME_TYPE = "application/x-lexxy-node-key";
13576
+ const MIME_TYPE$1 = "application/x-lexxy-node-key";
13459
13577
 
13460
13578
  class AttachmentDragAndDrop {
13461
13579
  #editor
@@ -13502,7 +13620,7 @@ class AttachmentDragAndDrop {
13502
13620
  if (!figure) return false
13503
13621
 
13504
13622
  this.#draggedNodeKey = figure.dataset.lexicalNodeKey;
13505
- event.dataTransfer.setData(MIME_TYPE, this.#draggedNodeKey);
13623
+ event.dataTransfer.setData(MIME_TYPE$1, this.#draggedNodeKey);
13506
13624
  event.dataTransfer.effectAllowed = "move";
13507
13625
 
13508
13626
  // Add dragging class after a tick so it doesn't affect the drag image
@@ -13851,11 +13969,12 @@ class AttachmentsExtension extends LexxyExtension {
13851
13969
 
13852
13970
  #handleUploadMutations(mutations) {
13853
13971
  const previousUploadsCount = this.#uploadsCount;
13854
- for (const [ , mutation ] of mutations) {
13972
+ for (const [ key, mutation ] of mutations) {
13855
13973
  if (mutation === "created") {
13856
13974
  this.#uploadsCount++;
13857
13975
  } else if (mutation === "destroyed") {
13858
13976
  this.#uploadsCount--;
13977
+ this.editorElement.uploadRequests.abort(key);
13859
13978
  }
13860
13979
  }
13861
13980
 
@@ -14281,6 +14400,268 @@ class PreventLexicalTripleClickExtension extends LexxyExtension {
14281
14400
  }
14282
14401
  }
14283
14402
 
14403
+ function caretRect(node, offset) {
14404
+ const range = document.createRange();
14405
+ range.setStart(node, offset);
14406
+ range.collapse(true);
14407
+
14408
+ const rect = range.getBoundingClientRect();
14409
+ if (rect.height > 0) {
14410
+ return rect
14411
+ } else {
14412
+ return null
14413
+ }
14414
+ }
14415
+
14416
+ function caretFromPoint(clientX, clientY) {
14417
+ if (document.caretPositionFromPoint) {
14418
+ const position = document.caretPositionFromPoint(clientX, clientY);
14419
+ if (position) return { node: position.offsetNode, offset: position.offset }
14420
+ } else if (document.caretRangeFromPoint) {
14421
+ const range = document.caretRangeFromPoint(clientX, clientY);
14422
+ if (range) return { node: range.startContainer, offset: range.startOffset }
14423
+ }
14424
+
14425
+ return null
14426
+ }
14427
+
14428
+ const MIME_TYPE = "application/x-lexxy-custom-attachment-key";
14429
+
14430
+ // Custom inline attachments reorder by dropping at a text caret, unlike block
14431
+ // attachments which insert between blocks or into galleries.
14432
+ class CustomAttachmentDragAndDrop {
14433
+ #editor
14434
+ #draggedNodeKey = null
14435
+ #draggingRafId = null
14436
+ #dragOverRafId = null
14437
+ #dropIndicator = null
14438
+ #listeners = new ListenerBin()
14439
+
14440
+ constructor(editor) {
14441
+ this.#editor = editor;
14442
+
14443
+ // Register at HIGH priority to intercept before the base @lexical/rich-text
14444
+ // handlers, which consume drag events. The block-attachment handler also
14445
+ // registers here but bails for inline custom attachments, so we get our turn.
14446
+ this.#listeners.track(
14447
+ editor.registerCommand(Be$2, (event) => this.#handleDragStart(event), so),
14448
+ editor.registerCommand(ze$2, (event) => this.#handleDrop(event), so)
14449
+ );
14450
+
14451
+ this.#listeners.track(editor.registerRootListener((root, prevRoot) => {
14452
+ if (prevRoot) {
14453
+ prevRoot.removeEventListener("dragover", this.#onDragOver);
14454
+ prevRoot.removeEventListener("dragend", this.#onDragEnd);
14455
+ }
14456
+ if (root) {
14457
+ root.addEventListener("dragover", this.#onDragOver);
14458
+ root.addEventListener("dragend", this.#onDragEnd);
14459
+ }
14460
+ }));
14461
+ }
14462
+
14463
+ destroy() {
14464
+ this.#cleanup();
14465
+ this.#dropIndicator?.remove();
14466
+ this.#dropIndicator = null;
14467
+ this.#listeners.dispose();
14468
+ }
14469
+
14470
+ #handleDragStart(event) {
14471
+ const attachment = this.#customAttachmentElementFrom(event.target);
14472
+ if (!attachment) return false
14473
+
14474
+ this.#draggedNodeKey = attachment.dataset.lexicalNodeKey;
14475
+ event.dataTransfer.setData(MIME_TYPE, this.#draggedNodeKey);
14476
+ event.dataTransfer.effectAllowed = "move";
14477
+
14478
+ this.#draggingRafId = requestAnimationFrame(() => {
14479
+ this.#draggingRafId = null;
14480
+ attachment.classList.add("lexxy-dragging");
14481
+ });
14482
+
14483
+ return true
14484
+ }
14485
+
14486
+ #onDragOver = (event) => {
14487
+ if (!this.#draggedNodeKey) return
14488
+
14489
+ event.preventDefault();
14490
+ event.dataTransfer.dropEffect = "move";
14491
+
14492
+ if (!this.#dragOverRafId) {
14493
+ this.#dragOverRafId = requestAnimationFrame(() => {
14494
+ this.#dragOverRafId = null;
14495
+ this.#updateDropIndicator(event);
14496
+ });
14497
+ }
14498
+ }
14499
+
14500
+ #onDragEnd = () => {
14501
+ this.#cleanup();
14502
+ }
14503
+
14504
+ #handleDrop(event) {
14505
+ if (!this.#draggedNodeKey) return false
14506
+
14507
+ event.preventDefault();
14508
+
14509
+ const dropPoint = this.#resolveDropPoint(event);
14510
+ const draggedKey = this.#draggedNodeKey;
14511
+ this.#cleanup();
14512
+
14513
+ if (dropPoint) {
14514
+ this.#moveAttachment(draggedKey, dropPoint);
14515
+ }
14516
+
14517
+ return true
14518
+ }
14519
+
14520
+ #resolveDropPoint(event) {
14521
+ const rootElement = this.#editor.getRootElement();
14522
+ if (!rootElement) return null
14523
+
14524
+ const caret = caretFromPoint(event.clientX, event.clientY);
14525
+ if (!caret || !rootElement.contains(caret.node)) return null
14526
+
14527
+ // A caret on the root itself points between blocks. Mentions behave like text:
14528
+ // they only drop onto an existing line, so snap to the nearest one.
14529
+ if (caret.node === rootElement) {
14530
+ return this.#nearestLineCaret(rootElement, event.clientY)
14531
+ } else {
14532
+ return caret
14533
+ }
14534
+ }
14535
+
14536
+ #nearestLineCaret(rootElement, clientY) {
14537
+ let nearestLine = null;
14538
+ let nearestDistance = Infinity;
14539
+
14540
+ for (const line of rootElement.children) {
14541
+ const rect = line.getBoundingClientRect();
14542
+ const distance = Math.min(Math.abs(clientY - rect.top), Math.abs(clientY - rect.bottom));
14543
+ if (distance < nearestDistance) {
14544
+ nearestDistance = distance;
14545
+ nearestLine = line;
14546
+ }
14547
+ }
14548
+
14549
+ if (!nearestLine) return null
14550
+
14551
+ const rect = nearestLine.getBoundingClientRect();
14552
+ if (clientY < rect.top) {
14553
+ return { node: nearestLine, offset: 0 }
14554
+ } else {
14555
+ return { node: nearestLine, offset: nearestLine.childNodes.length }
14556
+ }
14557
+ }
14558
+
14559
+ #moveAttachment(draggedKey, dropPoint) {
14560
+ this.#editor.update(() => {
14561
+ const draggedNode = Yo(draggedKey);
14562
+ if (!$isCustomActionTextAttachmentNode(draggedNode)) return
14563
+
14564
+ const selection = Gr({
14565
+ anchorNode: dropPoint.node,
14566
+ anchorOffset: dropPoint.offset,
14567
+ focusNode: dropPoint.node,
14568
+ focusOffset: dropPoint.offset
14569
+ }, this.#editor);
14570
+ if (!selection) return
14571
+
14572
+ es(selection);
14573
+
14574
+ draggedNode.remove();
14575
+ selection.insertNodes([ draggedNode ]);
14576
+ });
14577
+ }
14578
+
14579
+ #updateDropIndicator(event) {
14580
+ this.#hideCaret();
14581
+
14582
+ const dropPoint = this.#resolveDropPoint(event);
14583
+ if (dropPoint) this.#showCaret(this.#caretRectFor(dropPoint));
14584
+ }
14585
+
14586
+ #caretRectFor({ node, offset }) {
14587
+ const rect = caretRect(node, offset);
14588
+ if (rect) return rect
14589
+
14590
+ // A blank line has no text to measure, so fall back to the line's own box.
14591
+ const line = node.nodeType === Node.TEXT_NODE ? node.parentElement : node;
14592
+ if (!line) return null
14593
+
14594
+ const lineRect = line.getBoundingClientRect();
14595
+ return { left: lineRect.left, top: lineRect.top, height: lineRect.height }
14596
+ }
14597
+
14598
+ #showCaret(rect) {
14599
+ if (!rect) return
14600
+
14601
+ const caret = this.#ensureCaretIndicator();
14602
+ caret.style.blockSize = `${rect.height}px`;
14603
+ caret.style.insetInlineStart = `${rect.left}px`;
14604
+ caret.style.insetBlockStart = `${rect.top}px`;
14605
+ }
14606
+
14607
+ #ensureCaretIndicator() {
14608
+ this.#dropIndicator ||= createElement("div", { className: "lexxy-drop-caret" });
14609
+
14610
+ this.#editorElement().appendChild(this.#dropIndicator);
14611
+ this.#dropIndicator.style.display = "block";
14612
+ return this.#dropIndicator
14613
+ }
14614
+
14615
+ #editorElement() {
14616
+ return this.#editor.getRootElement().closest("lexxy-editor")
14617
+ }
14618
+
14619
+ #hideCaret() {
14620
+ if (this.#dropIndicator) this.#dropIndicator.style.display = "none";
14621
+ }
14622
+
14623
+ #customAttachmentElementFrom(target) {
14624
+ return target?.closest?.("[data-lexxy-decorator][data-lexical-node-key]")
14625
+ }
14626
+
14627
+ #cleanup() {
14628
+ if (this.#draggedNodeKey) {
14629
+ const rootElement = this.#editor.getRootElement();
14630
+ const attachment = rootElement?.querySelector(`[data-lexical-node-key="${this.#draggedNodeKey}"]`);
14631
+ attachment?.classList.remove("lexxy-dragging");
14632
+ }
14633
+
14634
+ this.#hideCaret();
14635
+ this.#draggedNodeKey = null;
14636
+
14637
+ if (this.#draggingRafId) {
14638
+ cancelAnimationFrame(this.#draggingRafId);
14639
+ this.#draggingRafId = null;
14640
+ }
14641
+
14642
+ if (this.#dragOverRafId) {
14643
+ cancelAnimationFrame(this.#dragOverRafId);
14644
+ this.#dragOverRafId = null;
14645
+ }
14646
+ }
14647
+ }
14648
+
14649
+ class CustomAttachmentDragAndDropExtension extends LexxyExtension {
14650
+ get enabled() {
14651
+ return this.editorElement.supportsRichText
14652
+ }
14653
+
14654
+ get lexicalExtension() {
14655
+ return dc({
14656
+ name: "lexxy/custom-attachment-drag-and-drop",
14657
+ register: (editor) => {
14658
+ const dragAndDrop = new CustomAttachmentDragAndDrop(editor);
14659
+ return () => dragAndDrop.destroy()
14660
+ }
14661
+ })
14662
+ }
14663
+ }
14664
+
14284
14665
  class LexicalEditorElement extends HTMLElement {
14285
14666
  static formAssociated = true
14286
14667
  static debug = false
@@ -14299,6 +14680,7 @@ class LexicalEditorElement extends HTMLElement {
14299
14680
 
14300
14681
  #validity = new Map()
14301
14682
  #validationTextArea = document.createElement("textarea")
14683
+ #uploadRequests
14302
14684
 
14303
14685
  constructor() {
14304
14686
  super();
@@ -14306,6 +14688,10 @@ class LexicalEditorElement extends HTMLElement {
14306
14688
  this.internals.role = "presentation";
14307
14689
  }
14308
14690
 
14691
+ get uploadRequests() {
14692
+ return this.#uploadRequests
14693
+ }
14694
+
14309
14695
  connectedCallback() {
14310
14696
  this.id ||= generateDomId("lexxy-editor");
14311
14697
  this.config = new EditorConfiguration(this);
@@ -14326,6 +14712,7 @@ class LexicalEditorElement extends HTMLElement {
14326
14712
  this.#disposables.push(this.clipboard);
14327
14713
 
14328
14714
  this.adapter = new BrowserAdapter();
14715
+ this.#uploadRequests = new UploadRequests();
14329
14716
 
14330
14717
  const commandDispatcher = CommandDispatcher.configureFor(this);
14331
14718
  this.#disposables.push(commandDispatcher);
@@ -14431,7 +14818,8 @@ class LexicalEditorElement extends HTMLElement {
14431
14818
  AttachmentsExtension,
14432
14819
  FormatEscapeExtension,
14433
14820
  LinkOpenerExtension,
14434
- PreventLexicalTripleClickExtension
14821
+ PreventLexicalTripleClickExtension,
14822
+ CustomAttachmentDragAndDropExtension
14435
14823
  ]
14436
14824
  }
14437
14825
 
@@ -15099,6 +15487,7 @@ class LexicalEditorElement extends HTMLElement {
15099
15487
  #reset() {
15100
15488
  this.#dispose();
15101
15489
  this.#resetValidity();
15490
+ this.#uploadRequests?.clear();
15102
15491
  this.editorContentElement?.remove();
15103
15492
  this.editorContentElement = null;
15104
15493
 
@@ -15465,24 +15854,35 @@ class LexicalPromptElement extends HTMLElement {
15465
15854
  const { node, offset } = this.#selection.selectedNodeWithOffset();
15466
15855
  if (!node) return
15467
15856
 
15468
- if (Tr(node) && offset > 0) {
15469
- const fullText = node.getTextContent();
15470
- const textBeforeCursor = fullText.slice(0, offset);
15471
- const lastTriggerIndex = textBeforeCursor.lastIndexOf(this.trigger);
15472
- const triggerEndIndex = lastTriggerIndex + this.trigger.length - 1;
15473
-
15474
- // If trigger is not found, or cursor is at or before the trigger end position, hide popover
15475
- if (lastTriggerIndex === -1 || offset <= triggerEndIndex) {
15476
- this.#hidePopover();
15857
+ if (this.#cursorIsTypingSearchTerm(node, offset)) {
15858
+ if (!this.popoverElement.hasAttribute("data-anchored")) {
15859
+ this.#positionPopover();
15477
15860
  }
15478
15861
  } else {
15479
- // Cursor is not in a text node or at offset 0, hide popover
15480
15862
  this.#hidePopover();
15481
15863
  }
15482
15864
  });
15483
15865
  }));
15484
15866
  }
15485
15867
 
15868
+ // The popover should stay open only while the cursor sits at the end of the
15869
+ // trigger and its search term. When the cursor moves away — before the
15870
+ // trigger, or past the token into later text — the text between the trigger
15871
+ // and the cursor breaks that run and we dismiss. A newline always breaks the
15872
+ // run; a space breaks it only for triggers that don't support spaces in
15873
+ // searches, since those that do (e.g. `person:`) expect multi-word terms.
15874
+ #cursorIsTypingSearchTerm(node, offset) {
15875
+ if (!Tr(node) || offset === 0) return false
15876
+
15877
+ const textBeforeCursor = node.getTextContent().slice(0, offset);
15878
+ const lastTriggerIndex = textBeforeCursor.lastIndexOf(this.trigger);
15879
+ if (lastTriggerIndex === -1) return false
15880
+
15881
+ const searchTerm = textBeforeCursor.slice(lastTriggerIndex + this.trigger.length);
15882
+ const breakPattern = this.supportsSpaceInSearches ? /\n/ : /[ \n]/;
15883
+ return !breakPattern.test(searchTerm)
15884
+ }
15885
+
15486
15886
  get #editor() {
15487
15887
  return this.#editorElement.editor
15488
15888
  }
@@ -15588,8 +15988,16 @@ class LexicalPromptElement extends HTMLElement {
15588
15988
  }
15589
15989
  }
15590
15990
 
15991
+ // Right after a Turbo history restore the editor reconnects before the DOM selection
15992
+ // is re-established, so the cursor geometry is momentarily unavailable. Anchoring then
15993
+ // would pin the menu to the editor's left edge for the rest of the open cycle, so we
15994
+ // skip it and let a later reposition anchor it once the selection is ready. The menu
15995
+ // stays hidden until anchored (see the `[data-anchored]` rule in the stylesheet).
15591
15996
  #positionPopover() {
15592
- const { x, y, fontSize } = this.#selection.cursorPosition;
15997
+ const cursorPosition = this.#selection.cursorPosition;
15998
+ if (!cursorPosition) return
15999
+
16000
+ const { x, y, fontSize } = cursorPosition;
15593
16001
  const editorRect = this.#editorElement.getBoundingClientRect();
15594
16002
  const contentRect = this.#editorContentElement.getBoundingClientRect();
15595
16003
  const verticalOffset = contentRect.top - editorRect.top;
@@ -17028,5 +17436,5 @@ const configure = Lexxy.configure;
17028
17436
  // Pushing elements definition to after the current call stack to allow global configuration to take place first
17029
17437
  setTimeout(defineElements, 0);
17030
17438
 
17031
- export { $createActionTextAttachmentNode, $createActionTextAttachmentUploadNode, $isActionTextAttachmentNode, ActionTextAttachmentNode, ActionTextAttachmentUploadNode, CustomActionTextAttachmentNode, LexxyExtension as Extension, HorizontalDividerNode, NativeAdapter, configure, highlightCode, highlightElement };
17439
+ export { $createActionTextAttachmentNode, $createActionTextAttachmentUploadNode, $isActionTextAttachmentNode, $isCustomActionTextAttachmentNode, ActionTextAttachmentNode, ActionTextAttachmentUploadNode, CustomActionTextAttachmentNode, LexxyExtension as Extension, HorizontalDividerNode, NativeAdapter, configure, highlightCode, highlightElement };
17032
17440
  //# sourceMappingURL=lexxy.js.map