@37signals/lexxy 0.9.19-alpha.3 → 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.
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, $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';
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, $createRangeSelectionFromDom, 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, $isListNode, $isListItemNode, INSERT_UNORDERED_LIST_COMMAND, INSERT_ORDERED_LIST_COMMAND, $createListNode, $createListItemNode, registerList } from '@lexical/list';
@@ -1384,7 +1384,8 @@ class CustomActionTextAttachmentNode extends DecoratorNode {
1384
1384
  }
1385
1385
 
1386
1386
  createDOM() {
1387
- const figure = createElement(this.tagName, { "content-type": this.contentType, "data-lexxy-decorator": true });
1387
+ const figure = createElement(this.tagName, { "content-type": this.contentType, "data-lexxy-decorator": true, draggable: true });
1388
+ figure.dataset.lexicalNodeKey = this.__key;
1388
1389
 
1389
1390
  figure.insertAdjacentHTML("beforeend", sanitize(this.innerHtml));
1390
1391
 
@@ -1437,6 +1438,10 @@ class CustomActionTextAttachmentNode extends DecoratorNode {
1437
1438
  }
1438
1439
  }
1439
1440
 
1441
+ function $isCustomActionTextAttachmentNode(node) {
1442
+ return node instanceof CustomActionTextAttachmentNode
1443
+ }
1444
+
1440
1445
  function dasherize(value) {
1441
1446
  return value.replace(/([A-Z])/g, (_, char) => `-${char.toLowerCase()}`)
1442
1447
  }
@@ -3928,7 +3933,7 @@ class CommandDispatcher {
3928
3933
  }
3929
3934
 
3930
3935
  #isInternalDrag(event) {
3931
- return event.dataTransfer?.types.includes("application/x-lexxy-node-key")
3936
+ return event.dataTransfer?.types.some((type) => type.startsWith("application/x-lexxy-"))
3932
3937
  }
3933
3938
 
3934
3939
  #handleTabKey(event) {
@@ -3982,14 +3987,14 @@ class Selection {
3982
3987
  }
3983
3988
 
3984
3989
  get cursorPosition() {
3985
- let position = { x: 0, y: 0 };
3990
+ let position = null;
3986
3991
 
3987
3992
  this.editor.getEditorState().read(() => {
3988
3993
  const range = this.#getValidSelectionRange();
3989
3994
  if (!range) return
3990
3995
 
3991
3996
  const rect = this.#getReliableRectFromRange(range);
3992
- if (!rect) return
3997
+ if (this.#isRectUnreliable(rect)) return
3993
3998
 
3994
3999
  position = this.#calculateCursorPosition(rect, range);
3995
4000
  });
@@ -5559,7 +5564,7 @@ class ShadowRootNodeInserter extends BaseNodeInserter {
5559
5564
 
5560
5565
  class NodeSelectionNodeInserter extends BaseNodeInserter {
5561
5566
  static handles(selection) {
5562
- return $isNodeSelection(selection)
5567
+ return $isNodeSelection(selection) && selection.getNodes().length > 0
5563
5568
  }
5564
5569
 
5565
5570
  insertNodes(nodes) {
@@ -5757,8 +5762,15 @@ class Contents {
5757
5762
  }, { tag });
5758
5763
  }
5759
5764
 
5765
+ insertText(text, { tag } = {}) {
5766
+ this.editor.update(() => {
5767
+ const paragraph = $createParagraphNode().append($createTextNode(text));
5768
+ this.insertAtCursor(paragraph);
5769
+ }, { tag });
5770
+ }
5771
+
5760
5772
  insertAtCursor(...nodes) {
5761
- const selection = $getSelection() ?? $getRoot().selectEnd();
5773
+ const selection = this.#insertableSelection();
5762
5774
  const inserter = BaseNodeInserter.for(selection);
5763
5775
 
5764
5776
  inserter.insertNodes(nodes);
@@ -5952,14 +5964,13 @@ class Contents {
5952
5964
  replaceTextBackUntil(stringToReplace, replacementNodes) {
5953
5965
  replacementNodes = Array.isArray(replacementNodes) ? replacementNodes : [ replacementNodes ];
5954
5966
 
5955
- const selection = $getSelection();
5956
5967
  const { anchorNode, offset } = this.#getTextAnchorData();
5957
5968
  if (!anchorNode) return
5958
5969
 
5959
5970
  const lastIndex = this.#findReplacementStart(anchorNode, offset, stringToReplace);
5960
5971
  if (lastIndex === -1) return
5961
5972
 
5962
- this.#performTextReplacement(anchorNode, selection, lastIndex, stringToReplace, replacementNodes);
5973
+ this.#performTextReplacement(anchorNode, lastIndex, stringToReplace, replacementNodes);
5963
5974
  }
5964
5975
 
5965
5976
  uploadFiles(files, { selectLast } = {}) {
@@ -6099,6 +6110,15 @@ class Contents {
6099
6110
  });
6100
6111
  }
6101
6112
 
6113
+ #insertableSelection() {
6114
+ const selection = $getSelection();
6115
+ if ($isNodeSelection(selection) && selection.getNodes().length === 0) {
6116
+ return $getRoot().selectEnd()
6117
+ }
6118
+
6119
+ return selection ?? $getRoot().selectEnd()
6120
+ }
6121
+
6102
6122
  #formatPastedDOM(doc) {
6103
6123
  new PastedContentFormatter(doc).format();
6104
6124
  }
@@ -6282,13 +6302,13 @@ class Contents {
6282
6302
  }
6283
6303
  }
6284
6304
 
6285
- #performTextReplacement(anchorNode, selection, startIndex, stringToReplace, replacementNodes) {
6305
+ #performTextReplacement(anchorNode, startIndex, stringToReplace, replacementNodes) {
6286
6306
  const fullText = anchorNode.getTextContent();
6287
6307
  const textBeforeString = fullText.slice(0, startIndex);
6288
6308
  const textAfterString = fullText.slice(startIndex + stringToReplace.length);
6289
6309
 
6290
- const textNodeBefore = this.#cloneTextNodeFormatting(anchorNode, selection, textBeforeString);
6291
- const textNodeAfter = this.#cloneTextNodeFormatting(anchorNode, selection, textAfterString || " ");
6310
+ const textNodeBefore = this.#cloneTextNodeFormatting(anchorNode, textBeforeString);
6311
+ const textNodeAfter = this.#cloneTextNodeFormatting(anchorNode, textAfterString || " ");
6292
6312
 
6293
6313
  anchorNode.replace(textNodeBefore);
6294
6314
 
@@ -6300,18 +6320,12 @@ class Contents {
6300
6320
  textNodeAfter.select(cursorOffset, cursorOffset);
6301
6321
  }
6302
6322
 
6303
- #cloneTextNodeFormatting(anchorNode, selection, text) {
6304
- const parent = anchorNode.getParent();
6305
- const fallbackFormat = parent?.getTextFormat?.() || 0;
6306
- const fallbackStyle = parent?.getTextStyle?.() || "";
6307
- const format = $isRangeSelection(selection) && selection.format ? selection.format : (anchorNode.getFormat() || fallbackFormat);
6308
- const style = $isRangeSelection(selection) && selection.style ? selection.style : (anchorNode.getStyle() || fallbackStyle);
6309
-
6323
+ #cloneTextNodeFormatting(anchorNode, text) {
6310
6324
  return $createTextNode(text)
6311
- .setFormat(format)
6325
+ .setFormat(anchorNode.getFormat())
6312
6326
  .setDetail(anchorNode.getDetail())
6313
6327
  .setMode(anchorNode.getMode())
6314
- .setStyle(style)
6328
+ .setStyle(anchorNode.getStyle())
6315
6329
  }
6316
6330
 
6317
6331
  #insertReplacementNodes(startNode, replacementNodes) {
@@ -6529,14 +6543,31 @@ class Clipboard {
6529
6543
  #pasteMarkdown(text) {
6530
6544
  const html = marked(text, { breaks: true });
6531
6545
  const doc = parseHtml(html);
6532
- const detail = Object.freeze({
6533
- markdown: text,
6534
- document: doc,
6535
- addBlockSpacing: () => addBlockSpacing(doc)
6536
- });
6537
6546
 
6538
- dispatch(this.editorElement, "lexxy:insert-markdown", detail);
6539
- this.contents.insertDOM(doc, { tag: PASTE_TAG });
6547
+ if (this.#isPlainTextWithoutMarkdown(doc)) {
6548
+ this.contents.insertText(text, { tag: PASTE_TAG });
6549
+ } else {
6550
+ const detail = Object.freeze({
6551
+ markdown: text,
6552
+ document: doc,
6553
+ addBlockSpacing: () => addBlockSpacing(doc)
6554
+ });
6555
+
6556
+ dispatch(this.editorElement, "lexxy:insert-markdown", detail);
6557
+ this.contents.insertDOM(doc, { tag: PASTE_TAG });
6558
+ }
6559
+ }
6560
+
6561
+ // Markdown conversion collapses runs of whitespace and unescapes backslashes,
6562
+ // silently corrupting plain text such as Windows/UNC file paths. When the text
6563
+ // carries no Markdown structure, paste it verbatim instead.
6564
+ #isPlainTextWithoutMarkdown(doc) {
6565
+ const elements = Array.from(doc.body.children);
6566
+ if (elements.length !== 1) return false
6567
+
6568
+ const paragraph = elements[0];
6569
+ return paragraph.nodeName === "P"
6570
+ && Array.from(paragraph.childNodes).every((node) => node.nodeType === Node.TEXT_NODE)
6540
6571
  }
6541
6572
 
6542
6573
  #pasteRichText(clipboardData) {
@@ -7144,7 +7175,7 @@ class TablesExtension extends LexxyExtension {
7144
7175
  }
7145
7176
  }
7146
7177
 
7147
- const MIME_TYPE = "application/x-lexxy-node-key";
7178
+ const MIME_TYPE$1 = "application/x-lexxy-node-key";
7148
7179
 
7149
7180
  class AttachmentDragAndDrop {
7150
7181
  #editor
@@ -7191,7 +7222,7 @@ class AttachmentDragAndDrop {
7191
7222
  if (!figure) return false
7192
7223
 
7193
7224
  this.#draggedNodeKey = figure.dataset.lexicalNodeKey;
7194
- event.dataTransfer.setData(MIME_TYPE, this.#draggedNodeKey);
7225
+ event.dataTransfer.setData(MIME_TYPE$1, this.#draggedNodeKey);
7195
7226
  event.dataTransfer.effectAllowed = "move";
7196
7227
 
7197
7228
  // Add dragging class after a tick so it doesn't affect the drag image
@@ -7971,6 +8002,268 @@ class PreventLexicalTripleClickExtension extends LexxyExtension {
7971
8002
  }
7972
8003
  }
7973
8004
 
8005
+ function caretRect(node, offset) {
8006
+ const range = document.createRange();
8007
+ range.setStart(node, offset);
8008
+ range.collapse(true);
8009
+
8010
+ const rect = range.getBoundingClientRect();
8011
+ if (rect.height > 0) {
8012
+ return rect
8013
+ } else {
8014
+ return null
8015
+ }
8016
+ }
8017
+
8018
+ function caretFromPoint(clientX, clientY) {
8019
+ if (document.caretPositionFromPoint) {
8020
+ const position = document.caretPositionFromPoint(clientX, clientY);
8021
+ if (position) return { node: position.offsetNode, offset: position.offset }
8022
+ } else if (document.caretRangeFromPoint) {
8023
+ const range = document.caretRangeFromPoint(clientX, clientY);
8024
+ if (range) return { node: range.startContainer, offset: range.startOffset }
8025
+ }
8026
+
8027
+ return null
8028
+ }
8029
+
8030
+ const MIME_TYPE = "application/x-lexxy-custom-attachment-key";
8031
+
8032
+ // Custom inline attachments reorder by dropping at a text caret, unlike block
8033
+ // attachments which insert between blocks or into galleries.
8034
+ class CustomAttachmentDragAndDrop {
8035
+ #editor
8036
+ #draggedNodeKey = null
8037
+ #draggingRafId = null
8038
+ #dragOverRafId = null
8039
+ #dropIndicator = null
8040
+ #listeners = new ListenerBin()
8041
+
8042
+ constructor(editor) {
8043
+ this.#editor = editor;
8044
+
8045
+ // Register at HIGH priority to intercept before the base @lexical/rich-text
8046
+ // handlers, which consume drag events. The block-attachment handler also
8047
+ // registers here but bails for inline custom attachments, so we get our turn.
8048
+ this.#listeners.track(
8049
+ editor.registerCommand(DRAGSTART_COMMAND, (event) => this.#handleDragStart(event), COMMAND_PRIORITY_HIGH),
8050
+ editor.registerCommand(DROP_COMMAND, (event) => this.#handleDrop(event), COMMAND_PRIORITY_HIGH)
8051
+ );
8052
+
8053
+ this.#listeners.track(editor.registerRootListener((root, prevRoot) => {
8054
+ if (prevRoot) {
8055
+ prevRoot.removeEventListener("dragover", this.#onDragOver);
8056
+ prevRoot.removeEventListener("dragend", this.#onDragEnd);
8057
+ }
8058
+ if (root) {
8059
+ root.addEventListener("dragover", this.#onDragOver);
8060
+ root.addEventListener("dragend", this.#onDragEnd);
8061
+ }
8062
+ }));
8063
+ }
8064
+
8065
+ destroy() {
8066
+ this.#cleanup();
8067
+ this.#dropIndicator?.remove();
8068
+ this.#dropIndicator = null;
8069
+ this.#listeners.dispose();
8070
+ }
8071
+
8072
+ #handleDragStart(event) {
8073
+ const attachment = this.#customAttachmentElementFrom(event.target);
8074
+ if (!attachment) return false
8075
+
8076
+ this.#draggedNodeKey = attachment.dataset.lexicalNodeKey;
8077
+ event.dataTransfer.setData(MIME_TYPE, this.#draggedNodeKey);
8078
+ event.dataTransfer.effectAllowed = "move";
8079
+
8080
+ this.#draggingRafId = requestAnimationFrame(() => {
8081
+ this.#draggingRafId = null;
8082
+ attachment.classList.add("lexxy-dragging");
8083
+ });
8084
+
8085
+ return true
8086
+ }
8087
+
8088
+ #onDragOver = (event) => {
8089
+ if (!this.#draggedNodeKey) return
8090
+
8091
+ event.preventDefault();
8092
+ event.dataTransfer.dropEffect = "move";
8093
+
8094
+ if (!this.#dragOverRafId) {
8095
+ this.#dragOverRafId = requestAnimationFrame(() => {
8096
+ this.#dragOverRafId = null;
8097
+ this.#updateDropIndicator(event);
8098
+ });
8099
+ }
8100
+ }
8101
+
8102
+ #onDragEnd = () => {
8103
+ this.#cleanup();
8104
+ }
8105
+
8106
+ #handleDrop(event) {
8107
+ if (!this.#draggedNodeKey) return false
8108
+
8109
+ event.preventDefault();
8110
+
8111
+ const dropPoint = this.#resolveDropPoint(event);
8112
+ const draggedKey = this.#draggedNodeKey;
8113
+ this.#cleanup();
8114
+
8115
+ if (dropPoint) {
8116
+ this.#moveAttachment(draggedKey, dropPoint);
8117
+ }
8118
+
8119
+ return true
8120
+ }
8121
+
8122
+ #resolveDropPoint(event) {
8123
+ const rootElement = this.#editor.getRootElement();
8124
+ if (!rootElement) return null
8125
+
8126
+ const caret = caretFromPoint(event.clientX, event.clientY);
8127
+ if (!caret || !rootElement.contains(caret.node)) return null
8128
+
8129
+ // A caret on the root itself points between blocks. Mentions behave like text:
8130
+ // they only drop onto an existing line, so snap to the nearest one.
8131
+ if (caret.node === rootElement) {
8132
+ return this.#nearestLineCaret(rootElement, event.clientY)
8133
+ } else {
8134
+ return caret
8135
+ }
8136
+ }
8137
+
8138
+ #nearestLineCaret(rootElement, clientY) {
8139
+ let nearestLine = null;
8140
+ let nearestDistance = Infinity;
8141
+
8142
+ for (const line of rootElement.children) {
8143
+ const rect = line.getBoundingClientRect();
8144
+ const distance = Math.min(Math.abs(clientY - rect.top), Math.abs(clientY - rect.bottom));
8145
+ if (distance < nearestDistance) {
8146
+ nearestDistance = distance;
8147
+ nearestLine = line;
8148
+ }
8149
+ }
8150
+
8151
+ if (!nearestLine) return null
8152
+
8153
+ const rect = nearestLine.getBoundingClientRect();
8154
+ if (clientY < rect.top) {
8155
+ return { node: nearestLine, offset: 0 }
8156
+ } else {
8157
+ return { node: nearestLine, offset: nearestLine.childNodes.length }
8158
+ }
8159
+ }
8160
+
8161
+ #moveAttachment(draggedKey, dropPoint) {
8162
+ this.#editor.update(() => {
8163
+ const draggedNode = $getNodeByKey(draggedKey);
8164
+ if (!$isCustomActionTextAttachmentNode(draggedNode)) return
8165
+
8166
+ const selection = $createRangeSelectionFromDom({
8167
+ anchorNode: dropPoint.node,
8168
+ anchorOffset: dropPoint.offset,
8169
+ focusNode: dropPoint.node,
8170
+ focusOffset: dropPoint.offset
8171
+ }, this.#editor);
8172
+ if (!selection) return
8173
+
8174
+ $setSelection(selection);
8175
+
8176
+ draggedNode.remove();
8177
+ selection.insertNodes([ draggedNode ]);
8178
+ });
8179
+ }
8180
+
8181
+ #updateDropIndicator(event) {
8182
+ this.#hideCaret();
8183
+
8184
+ const dropPoint = this.#resolveDropPoint(event);
8185
+ if (dropPoint) this.#showCaret(this.#caretRectFor(dropPoint));
8186
+ }
8187
+
8188
+ #caretRectFor({ node, offset }) {
8189
+ const rect = caretRect(node, offset);
8190
+ if (rect) return rect
8191
+
8192
+ // A blank line has no text to measure, so fall back to the line's own box.
8193
+ const line = node.nodeType === Node.TEXT_NODE ? node.parentElement : node;
8194
+ if (!line) return null
8195
+
8196
+ const lineRect = line.getBoundingClientRect();
8197
+ return { left: lineRect.left, top: lineRect.top, height: lineRect.height }
8198
+ }
8199
+
8200
+ #showCaret(rect) {
8201
+ if (!rect) return
8202
+
8203
+ const caret = this.#ensureCaretIndicator();
8204
+ caret.style.blockSize = `${rect.height}px`;
8205
+ caret.style.insetInlineStart = `${rect.left}px`;
8206
+ caret.style.insetBlockStart = `${rect.top}px`;
8207
+ }
8208
+
8209
+ #ensureCaretIndicator() {
8210
+ this.#dropIndicator ||= createElement("div", { className: "lexxy-drop-caret" });
8211
+
8212
+ this.#editorElement().appendChild(this.#dropIndicator);
8213
+ this.#dropIndicator.style.display = "block";
8214
+ return this.#dropIndicator
8215
+ }
8216
+
8217
+ #editorElement() {
8218
+ return this.#editor.getRootElement().closest("lexxy-editor")
8219
+ }
8220
+
8221
+ #hideCaret() {
8222
+ if (this.#dropIndicator) this.#dropIndicator.style.display = "none";
8223
+ }
8224
+
8225
+ #customAttachmentElementFrom(target) {
8226
+ return target?.closest?.("[data-lexxy-decorator][data-lexical-node-key]")
8227
+ }
8228
+
8229
+ #cleanup() {
8230
+ if (this.#draggedNodeKey) {
8231
+ const rootElement = this.#editor.getRootElement();
8232
+ const attachment = rootElement?.querySelector(`[data-lexical-node-key="${this.#draggedNodeKey}"]`);
8233
+ attachment?.classList.remove("lexxy-dragging");
8234
+ }
8235
+
8236
+ this.#hideCaret();
8237
+ this.#draggedNodeKey = null;
8238
+
8239
+ if (this.#draggingRafId) {
8240
+ cancelAnimationFrame(this.#draggingRafId);
8241
+ this.#draggingRafId = null;
8242
+ }
8243
+
8244
+ if (this.#dragOverRafId) {
8245
+ cancelAnimationFrame(this.#dragOverRafId);
8246
+ this.#dragOverRafId = null;
8247
+ }
8248
+ }
8249
+ }
8250
+
8251
+ class CustomAttachmentDragAndDropExtension extends LexxyExtension {
8252
+ get enabled() {
8253
+ return this.editorElement.supportsRichText
8254
+ }
8255
+
8256
+ get lexicalExtension() {
8257
+ return defineExtension({
8258
+ name: "lexxy/custom-attachment-drag-and-drop",
8259
+ register: (editor) => {
8260
+ const dragAndDrop = new CustomAttachmentDragAndDrop(editor);
8261
+ return () => dragAndDrop.destroy()
8262
+ }
8263
+ })
8264
+ }
8265
+ }
8266
+
7974
8267
  class LexicalEditorElement extends HTMLElement {
7975
8268
  static formAssociated = true
7976
8269
  static debug = false
@@ -8127,7 +8420,8 @@ class LexicalEditorElement extends HTMLElement {
8127
8420
  AttachmentsExtension,
8128
8421
  FormatEscapeExtension,
8129
8422
  LinkOpenerExtension,
8130
- PreventLexicalTripleClickExtension
8423
+ PreventLexicalTripleClickExtension,
8424
+ CustomAttachmentDragAndDropExtension
8131
8425
  ]
8132
8426
  }
8133
8427
 
@@ -9163,7 +9457,9 @@ class LexicalPromptElement extends HTMLElement {
9163
9457
  if (!node) return
9164
9458
 
9165
9459
  if (this.#cursorIsTypingSearchTerm(node, offset)) {
9166
- return
9460
+ if (!this.popoverElement.hasAttribute("data-anchored")) {
9461
+ this.#positionPopover();
9462
+ }
9167
9463
  } else {
9168
9464
  this.#hidePopover();
9169
9465
  }
@@ -9294,8 +9590,16 @@ class LexicalPromptElement extends HTMLElement {
9294
9590
  }
9295
9591
  }
9296
9592
 
9593
+ // Right after a Turbo history restore the editor reconnects before the DOM selection
9594
+ // is re-established, so the cursor geometry is momentarily unavailable. Anchoring then
9595
+ // would pin the menu to the editor's left edge for the rest of the open cycle, so we
9596
+ // skip it and let a later reposition anchor it once the selection is ready. The menu
9597
+ // stays hidden until anchored (see the `[data-anchored]` rule in the stylesheet).
9297
9598
  #positionPopover() {
9298
- const { x, y, fontSize } = this.#selection.cursorPosition;
9599
+ const cursorPosition = this.#selection.cursorPosition;
9600
+ if (!cursorPosition) return
9601
+
9602
+ const { x, y, fontSize } = cursorPosition;
9299
9603
  const editorRect = this.#editorElement.getBoundingClientRect();
9300
9604
  const contentRect = this.#editorContentElement.getBoundingClientRect();
9301
9605
  const verticalOffset = contentRect.top - editorRect.top;
@@ -10598,4 +10902,4 @@ const configure = Lexxy.configure;
10598
10902
  // Pushing elements definition to after the current call stack to allow global configuration to take place first
10599
10903
  setTimeout(defineElements, 0);
10600
10904
 
10601
- export { $createActionTextAttachmentNode, $createActionTextAttachmentUploadNode, $isActionTextAttachmentNode, ActionTextAttachmentNode, ActionTextAttachmentUploadNode, CustomActionTextAttachmentNode, LexxyExtension as Extension, HorizontalDividerNode, NativeAdapter, configure };
10905
+ export { $createActionTextAttachmentNode, $createActionTextAttachmentUploadNode, $isActionTextAttachmentNode, $isCustomActionTextAttachmentNode, ActionTextAttachmentNode, ActionTextAttachmentUploadNode, CustomActionTextAttachmentNode, LexxyExtension as Extension, HorizontalDividerNode, NativeAdapter, configure };
@@ -249,6 +249,22 @@
249
249
  opacity: 0.4;
250
250
  }
251
251
 
252
+ [data-lexxy-decorator][draggable] {
253
+ cursor: grab;
254
+ }
255
+
256
+ [data-lexxy-decorator].lexxy-dragging {
257
+ opacity: 0.4;
258
+ }
259
+
260
+ .lexxy-drop-caret {
261
+ background-color: var(--lexxy-focus-ring-color);
262
+ border-radius: 1px;
263
+ inline-size: 2px;
264
+ pointer-events: none;
265
+ position: fixed;
266
+ }
267
+
252
268
  [class*="lexxy-drop-target--"] {
253
269
  position: relative;
254
270
  }
@@ -1063,7 +1079,7 @@
1063
1079
  }
1064
1080
  }
1065
1081
 
1066
- :where(.lexxy-prompt-menu--visible) {
1082
+ :where(.lexxy-prompt-menu--visible[data-anchored]) {
1067
1083
  visibility: initial;
1068
1084
  }
1069
1085
 
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@37signals/lexxy",
3
- "version": "0.9.19-alpha.3",
3
+ "version": "0.9.19",
4
4
  "description": "Lexxy - A modern rich text editor for Rails.",
5
5
  "module": "dist/lexxy.esm.js",
6
6
  "type": "module",