@37signals/lexxy 0.8.2-beta → 0.8.5-beta

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
@@ -10,7 +10,7 @@ import 'prismjs/components/prism-json';
10
10
  import 'prismjs/components/prism-diff';
11
11
  import DOMPurify from 'dompurify';
12
12
  import { getStyleObjectFromCSS, getCSSFromStyleObject, $isAtNodeEnd, $getSelectionStyleValueForProperty, $patchStyleText } from '@lexical/selection';
13
- import { SKIP_DOM_SELECTION_TAG, $getSelection, $isRangeSelection, $getNodeByKey, $isTextNode, $createRangeSelection, $setSelection, DecoratorNode, $createNodeSelection, HISTORY_MERGE_TAG, SKIP_SCROLL_INTO_VIEW_TAG, $createParagraphNode, TextNode, createCommand, createState, defineExtension, COMMAND_PRIORITY_NORMAL, $getState, $setState, $hasUpdateTag, PASTE_TAG, FORMAT_TEXT_COMMAND, $createTextNode, $isRootOrShadowRoot, UNDO_COMMAND, REDO_COMMAND, PASTE_COMMAND, COMMAND_PRIORITY_LOW, KEY_ARROW_RIGHT_COMMAND, KEY_TAB_COMMAND, OUTDENT_CONTENT_COMMAND, INDENT_CONTENT_COMMAND, $getEditor, $getNearestRootOrShadowRoot, $isNodeSelection, $getRoot, $isLineBreakNode, $isElementNode, KEY_ARROW_LEFT_COMMAND, KEY_ARROW_UP_COMMAND, KEY_ARROW_DOWN_COMMAND, DELETE_CHARACTER_COMMAND, SELECTION_CHANGE_COMMAND, CLICK_COMMAND, isDOMNode, $getNearestNodeFromDOMNode, $isDecoratorNode, KEY_ENTER_COMMAND, COMMAND_PRIORITY_HIGH, $isParagraphNode, ElementNode, $splitNode, $createLineBreakNode, $isRootNode, ParagraphNode, RootNode, CLEAR_HISTORY_COMMAND, $addUpdateTag, COMMAND_PRIORITY_CRITICAL, KEY_SPACE_COMMAND, KEY_BACKSPACE_COMMAND, KEY_DOWN_COMMAND } from 'lexical';
13
+ import { SKIP_DOM_SELECTION_TAG, $getSelection, $isRangeSelection, $getNodeByKey, $isTextNode, $createRangeSelection, $setSelection, DecoratorNode, $createNodeSelection, HISTORY_MERGE_TAG, SKIP_SCROLL_INTO_VIEW_TAG, $createParagraphNode, TextNode, createCommand, createState, defineExtension, COMMAND_PRIORITY_NORMAL, $getState, $setState, $hasUpdateTag, PASTE_TAG, FORMAT_TEXT_COMMAND, $createTextNode, $isRootOrShadowRoot, UNDO_COMMAND, REDO_COMMAND, PASTE_COMMAND, COMMAND_PRIORITY_LOW, KEY_ARROW_RIGHT_COMMAND, KEY_TAB_COMMAND, OUTDENT_CONTENT_COMMAND, INDENT_CONTENT_COMMAND, $getEditor, $getNearestRootOrShadowRoot, $isNodeSelection, $getRoot, $isLineBreakNode, $isElementNode, KEY_ARROW_LEFT_COMMAND, KEY_ARROW_UP_COMMAND, KEY_ARROW_DOWN_COMMAND, DELETE_CHARACTER_COMMAND, SELECTION_CHANGE_COMMAND, CLICK_COMMAND, isDOMNode, $getNearestNodeFromDOMNode, $isDecoratorNode, KEY_ENTER_COMMAND, COMMAND_PRIORITY_HIGH, $isParagraphNode, ElementNode, $splitNode, $createLineBreakNode, $isRootNode, ParagraphNode, RootNode, DRAGSTART_COMMAND, DROP_COMMAND, CLEAR_HISTORY_COMMAND, $addUpdateTag, COMMAND_PRIORITY_CRITICAL, KEY_SPACE_COMMAND, KEY_BACKSPACE_COMMAND, KEY_DOWN_COMMAND } from 'lexical';
14
14
  import { buildEditorFromExtensions } from '@lexical/extension';
15
15
  import { ListNode, INSERT_UNORDERED_LIST_COMMAND, INSERT_ORDERED_LIST_COMMAND, ListItemNode, $getListDepth, $isListItemNode, $isListNode, $createListNode, registerList } from '@lexical/list';
16
16
  import { $createAutoLinkNode, $toggleLink, LinkNode, $createLinkNode, AutoLinkNode, $isLinkNode } from '@lexical/link';
@@ -632,7 +632,7 @@ class LexicalToolbarElement extends HTMLElement {
632
632
  }
633
633
 
634
634
  get #buttons() {
635
- return Array.from(this.querySelectorAll(":scope > button"))
635
+ return Array.from(this.querySelectorAll(":scope > button:not([data-prevent-overflow='true'])"))
636
636
  }
637
637
 
638
638
  get #focusableItems() {
@@ -702,7 +702,7 @@ class LexicalToolbarElement extends HTMLElement {
702
702
  ${ToolbarIcons.ol}
703
703
  </button>
704
704
 
705
- <button class="lexxy-editor__toolbar-button" type="button" name="upload" data-command="uploadAttachments" title="Upload file">
705
+ <button class="lexxy-editor__toolbar-button" type="button" name="upload" data-command="uploadAttachments" data-prevent-overflow="true" title="Upload file">
706
706
  ${ToolbarIcons.attachment}
707
707
  </button>
708
708
 
@@ -713,9 +713,9 @@ class LexicalToolbarElement extends HTMLElement {
713
713
  <button class="lexxy-editor__toolbar-button" type="button" name="divider" data-command="insertHorizontalDivider" title="Insert a divider">
714
714
  ${ToolbarIcons.hr}
715
715
  </button>
716
-
716
+
717
717
  <div class="lexxy-editor__toolbar-spacer" role="separator"></div>
718
-
718
+
719
719
  <button class="lexxy-editor__toolbar-button" type="button" name="undo" data-command="undo" title="Undo">
720
720
  ${ToolbarIcons.undo}
721
721
  </button>
@@ -1675,6 +1675,8 @@ class CommandDispatcher {
1675
1675
  }
1676
1676
 
1677
1677
  #handleDragEnter(event) {
1678
+ if (this.#isInternalDrag(event)) return
1679
+
1678
1680
  this.dragCounter++;
1679
1681
  if (this.dragCounter === 1) {
1680
1682
  this.#saveSelectionBeforeDrag();
@@ -1683,6 +1685,8 @@ class CommandDispatcher {
1683
1685
  }
1684
1686
 
1685
1687
  #handleDragLeave(event) {
1688
+ if (this.#isInternalDrag(event)) return
1689
+
1686
1690
  this.dragCounter--;
1687
1691
  if (this.dragCounter === 0) {
1688
1692
  this.#selectionBeforeDrag = null;
@@ -1691,10 +1695,14 @@ class CommandDispatcher {
1691
1695
  }
1692
1696
 
1693
1697
  #handleDragOver(event) {
1698
+ if (this.#isInternalDrag(event)) return
1699
+
1694
1700
  event.preventDefault();
1695
1701
  }
1696
1702
 
1697
1703
  #handleDrop(event) {
1704
+ if (this.#isInternalDrag(event)) return
1705
+
1698
1706
  event.preventDefault();
1699
1707
 
1700
1708
  this.dragCounter = 0;
@@ -1728,6 +1736,10 @@ class CommandDispatcher {
1728
1736
  this.#selectionBeforeDrag = null;
1729
1737
  }
1730
1738
 
1739
+ #isInternalDrag(event) {
1740
+ return event.dataTransfer?.types.includes("application/x-lexxy-node-key")
1741
+ }
1742
+
1731
1743
  #handleTabKey(event) {
1732
1744
  if (this.selection.isInsideList) {
1733
1745
  return this.#handleTabForList(event)
@@ -1976,6 +1988,8 @@ class ActionTextAttachmentNode extends DecoratorNode {
1976
1988
 
1977
1989
  createAttachmentFigure() {
1978
1990
  const figure = createAttachmentFigure(this.contentType, this.isPreviewableAttachment, this.fileName);
1991
+ figure.draggable = true;
1992
+ figure.dataset.lexicalNodeKey = this.__key;
1979
1993
 
1980
1994
  const deleteButton = createElement("lexxy-node-delete-button");
1981
1995
  figure.appendChild(deleteButton);
@@ -1993,7 +2007,9 @@ class ActionTextAttachmentNode extends DecoratorNode {
1993
2007
 
1994
2008
  #createDOMForImage(options = {}) {
1995
2009
  const img = createElement("img", { src: this.src, draggable: false, alt: this.altText, ...this.#imageDimensions, ...options });
1996
- return img
2010
+ const container = createElement("div", { className: "attachment__container" });
2011
+ container.appendChild(img);
2012
+ return container
1997
2013
  }
1998
2014
 
1999
2015
  get #imageDimensions() {
@@ -2286,8 +2302,12 @@ class Selection {
2286
2302
  if (!anchorNode) return null
2287
2303
 
2288
2304
  if ($isTextNode(anchorNode)) {
2289
- if (offset < anchorNode.getTextContentSize()) return null
2290
- return this.#getNextNodeFromTextEnd(anchorNode)
2305
+ if (offset === anchorNode.getTextContentSize()) return this.#getNextNodeFromTextEnd(anchorNode)
2306
+ if (this.#isCursorOnLastVisualLineOfBlock(anchorNode)) {
2307
+ const topLevelElement = anchorNode.getTopLevelElement();
2308
+ return topLevelElement ? topLevelElement.getNextSibling() : null
2309
+ }
2310
+ return null
2291
2311
  }
2292
2312
 
2293
2313
  if ($isElementNode(anchorNode)) {
@@ -2317,8 +2337,12 @@ class Selection {
2317
2337
  if (!anchorNode) return null
2318
2338
 
2319
2339
  if ($isTextNode(anchorNode)) {
2320
- if (offset > 0) return null
2321
- return this.#getPreviousNodeFromTextStart(anchorNode)
2340
+ if (offset === 0) return this.#getPreviousNodeFromTextStart(anchorNode)
2341
+ if (this.#isCursorOnFirstVisualLineOfBlock(anchorNode)) {
2342
+ const topLevelElement = anchorNode.getTopLevelElement();
2343
+ return topLevelElement ? topLevelElement.getPreviousSibling() : null
2344
+ }
2345
+ return null
2322
2346
  }
2323
2347
 
2324
2348
  if ($isElementNode(anchorNode)) {
@@ -2542,7 +2566,7 @@ class Selection {
2542
2566
  const node = backwards ? this.nodeBeforeCursor : this.nodeAfterCursor;
2543
2567
  if (!$isDecoratorNode(node)) return false
2544
2568
 
2545
- if (this.#collapseListItemToParagraph()) return true
2569
+ if (this.#collapseListItemToParagraph(node)) return true
2546
2570
 
2547
2571
  this.#removeEmptyElementAnchorNode();
2548
2572
 
@@ -2553,12 +2577,16 @@ class Selection {
2553
2577
  // When the cursor is inside a list item, collapse the list item into a
2554
2578
  // paragraph instead of selecting the decorator. This lets the user
2555
2579
  // delete a list that immediately follows an attachment without the
2556
- // attachment becoming selected.
2557
- #collapseListItemToParagraph() {
2580
+ // attachment becoming selected. Only applies when the decorator is
2581
+ // outside the list item (e.g. a block attachment before the list),
2582
+ // not when it's an inline mention inside the list item.
2583
+ #collapseListItemToParagraph(decoratorNode) {
2558
2584
  const anchorNode = $getSelection()?.anchor?.getNode();
2559
2585
  const listItem = anchorNode && $getNearestNodeOfType(anchorNode, ListItemNode);
2560
2586
  if (!listItem) return false
2561
2587
 
2588
+ if (listItem.isParentOf(decoratorNode)) return false
2589
+
2562
2590
  const listNode = $getNearestNodeOfType(listItem, ListNode);
2563
2591
  if (!listNode) return false
2564
2592
 
@@ -2743,6 +2771,63 @@ class Selection {
2743
2771
  }
2744
2772
  return current ? current.getPreviousSibling() : null
2745
2773
  }
2774
+
2775
+ #isCursorOnFirstVisualLineOfBlock(anchorNode) {
2776
+ return this.#isCursorOnEdgeLineOfBlock(anchorNode, "first")
2777
+ }
2778
+
2779
+ #isCursorOnLastVisualLineOfBlock(anchorNode) {
2780
+ return this.#isCursorOnEdgeLineOfBlock(anchorNode, "last")
2781
+ }
2782
+
2783
+ // Check whether the cursor sits on the first or last visual line of its
2784
+ // top-level block by comparing the Y position of the cursor with the Y
2785
+ // position of the block's start (first line) or end (last line).
2786
+ #isCursorOnEdgeLineOfBlock(anchorNode, edge) {
2787
+ const topLevelElement = anchorNode.getTopLevelElement();
2788
+ if (!topLevelElement) return false
2789
+
2790
+ const domElement = this.editor.getElementByKey(topLevelElement.getKey());
2791
+ if (!domElement) return false
2792
+
2793
+ const nativeSelection = window.getSelection();
2794
+ if (!nativeSelection?.rangeCount) return false
2795
+
2796
+ const cursorRect = this.#getReliableRectFromRange(nativeSelection.getRangeAt(0));
2797
+ if (!cursorRect || this.#isRectUnreliable(cursorRect)) return false
2798
+
2799
+ const edgeRect = this.#getEdgeCharRect(domElement, edge);
2800
+ if (!edgeRect || this.#isRectUnreliable(edgeRect)) return false
2801
+
2802
+ const tolerance = edgeRect.height > 0 ? edgeRect.height * 0.5 : 5;
2803
+ return Math.abs(cursorRect.top - edgeRect.top) < tolerance
2804
+ }
2805
+
2806
+ // Get a reliable bounding rect for the first or last character in a DOM
2807
+ // element by creating a non-collapsed range around it.
2808
+ #getEdgeCharRect(element, edge) {
2809
+ const walker = document.createTreeWalker(element, 4 /* NodeFilter.SHOW_TEXT */);
2810
+ let textNode;
2811
+
2812
+ if (edge === "first") {
2813
+ textNode = walker.nextNode();
2814
+ } else {
2815
+ while (walker.nextNode()) textNode = walker.currentNode;
2816
+ }
2817
+
2818
+ if (!textNode || textNode.length === 0) return null
2819
+
2820
+ const range = document.createRange();
2821
+ if (edge === "first") {
2822
+ range.setStart(textNode, 0);
2823
+ range.setEnd(textNode, 1);
2824
+ } else {
2825
+ range.setStart(textNode, textNode.length - 1);
2826
+ range.setEnd(textNode, textNode.length);
2827
+ }
2828
+
2829
+ return range.getBoundingClientRect()
2830
+ }
2746
2831
  }
2747
2832
 
2748
2833
  function sanitize(html) {
@@ -3636,14 +3721,12 @@ class ImageGalleryNode extends ElementNode {
3636
3721
  static importDOM() {
3637
3722
  return {
3638
3723
  div: (element) => {
3639
- const containsAttachment = element.querySelector(`:scope > :is(${this.#attachmentTags.join()})`);
3640
- if (!containsAttachment) return null
3724
+ if (!this.#isGalleryElement(element)) return null
3641
3725
 
3642
3726
  return {
3643
3727
  conversion: () => {
3644
3728
  return {
3645
- node: $createImageGalleryNode(),
3646
- after: children => children
3729
+ node: $createImageGalleryNode()
3647
3730
  }
3648
3731
  },
3649
3732
  priority: 2
@@ -3660,6 +3743,13 @@ class ImageGalleryNode extends ElementNode {
3660
3743
  return $isActionTextAttachmentNode(node) && node.isPreviewableImage
3661
3744
  }
3662
3745
 
3746
+ static #isGalleryElement(element) {
3747
+ const attachmentChildren = element.querySelectorAll(`:scope > :is(${this.#attachmentTags.join()})`);
3748
+ return element.textContent.trim() === ""
3749
+ && attachmentChildren.length > 0
3750
+ && element.children.length === attachmentChildren.length
3751
+ }
3752
+
3663
3753
  static get #attachmentTags() {
3664
3754
  return Object.keys(ActionTextAttachmentNode.importDOM())
3665
3755
  }
@@ -5355,6 +5445,365 @@ class TablesExtension extends LexxyExtension {
5355
5445
  }
5356
5446
  }
5357
5447
 
5448
+ const MIME_TYPE = "application/x-lexxy-node-key";
5449
+
5450
+ class AttachmentDragAndDrop {
5451
+ #editor
5452
+ #draggedNodeKey = null
5453
+ #rafId = null
5454
+ #draggingRafId = null
5455
+ #cleanupFns = []
5456
+
5457
+ constructor(editor) {
5458
+ this.#editor = editor;
5459
+
5460
+ // Register Lexical commands at HIGH priority to intercept before the
5461
+ // base @lexical/rich-text handlers (which return true and consume the events).
5462
+ this.#cleanupFns.push(
5463
+ editor.registerCommand(DRAGSTART_COMMAND, (event) => this.#handleDragStart(event), COMMAND_PRIORITY_HIGH),
5464
+ editor.registerCommand(DROP_COMMAND, (event) => this.#handleDrop(event), COMMAND_PRIORITY_HIGH),
5465
+ );
5466
+
5467
+ // Use a root listener to register DOM-level dragover/dragend handlers
5468
+ // (these events need throttled rAF handling that works better as DOM listeners).
5469
+ const unregister = editor.registerRootListener((root, prevRoot) => {
5470
+ if (prevRoot) {
5471
+ prevRoot.removeEventListener("dragover", this.#onDragOver);
5472
+ prevRoot.removeEventListener("dragend", this.#onDragEnd);
5473
+ }
5474
+ if (root) {
5475
+ root.addEventListener("dragover", this.#onDragOver);
5476
+ root.addEventListener("dragend", this.#onDragEnd);
5477
+ }
5478
+ });
5479
+ this.#cleanupFns.push(unregister);
5480
+ }
5481
+
5482
+ destroy() {
5483
+ this.#cleanup();
5484
+ for (const fn of this.#cleanupFns) fn();
5485
+ this.#cleanupFns = [];
5486
+ }
5487
+
5488
+ // -- Event handlers --------------------------------------------------------
5489
+
5490
+ #handleDragStart(event) {
5491
+ if (event.target.closest("textarea")) return false
5492
+
5493
+ const figure = event.target.closest("figure.attachment[data-lexical-node-key]");
5494
+ if (!figure) return false
5495
+
5496
+ this.#draggedNodeKey = figure.dataset.lexicalNodeKey;
5497
+ event.dataTransfer.setData(MIME_TYPE, this.#draggedNodeKey);
5498
+ event.dataTransfer.effectAllowed = "move";
5499
+
5500
+ // Add dragging class after a tick so it doesn't affect the drag image
5501
+ this.#draggingRafId = requestAnimationFrame(() => {
5502
+ this.#draggingRafId = null;
5503
+ figure.classList.add("lexxy-dragging");
5504
+ });
5505
+
5506
+ return true
5507
+ }
5508
+
5509
+ #onDragOver = (event) => {
5510
+ if (!this.#draggedNodeKey) return
5511
+
5512
+ event.preventDefault();
5513
+ event.dataTransfer.dropEffect = "move";
5514
+
5515
+ if (!this.#rafId) {
5516
+ this.#rafId = requestAnimationFrame(() => {
5517
+ this.#rafId = null;
5518
+ this.#updateDropTarget(event);
5519
+ });
5520
+ }
5521
+ }
5522
+
5523
+ #handleDrop(event) {
5524
+ if (!this.#draggedNodeKey) return false
5525
+
5526
+ event.preventDefault();
5527
+
5528
+ const target = this.#resolveDropTarget(event);
5529
+ const draggedKey = this.#draggedNodeKey;
5530
+ this.#cleanup();
5531
+
5532
+ if (target) {
5533
+ this.#performDrop(draggedKey, target);
5534
+ }
5535
+ return true
5536
+ }
5537
+
5538
+ #onDragEnd = () => {
5539
+ this.#cleanup();
5540
+ }
5541
+
5542
+ // -- Drop target resolution -----------------------------------------------
5543
+
5544
+ #updateDropTarget(event) {
5545
+ this.#clearDropIndicators();
5546
+
5547
+ const target = this.#resolveDropTarget(event);
5548
+ if (!target) return
5549
+
5550
+ if (target.type === "gallery" || target.type === "gallery-reorder") {
5551
+ target.element.classList.add(`lexxy-drop-target--gallery-${target.position}`);
5552
+ } else if (target.type === "list-item") {
5553
+ target.element.classList.add(`lexxy-drop-target--list-${target.position}`);
5554
+ } else {
5555
+ target.element.classList.add(`lexxy-drop-target--block-${target.position}`);
5556
+ }
5557
+ }
5558
+
5559
+ #resolveDropTarget(event) {
5560
+ const element = document.elementFromPoint(event.clientX, event.clientY);
5561
+ if (!element) return null
5562
+
5563
+ const rootElement = this.#editor.getRootElement();
5564
+ if (!rootElement || !rootElement.contains(element)) return null
5565
+
5566
+ // Check if hovering over a previewable image (for gallery merge or reorder)
5567
+ const targetFigure = element.closest("figure.attachment--preview[data-lexical-node-key]");
5568
+ if (targetFigure && targetFigure.dataset.lexicalNodeKey !== this.#draggedNodeKey) {
5569
+ const targetGallery = targetFigure.closest(".attachment-gallery");
5570
+ if (targetGallery) {
5571
+ // If the dragged image is in the same gallery, this is a reorder
5572
+ const draggedFigure = rootElement.querySelector(`[data-lexical-node-key="${this.#draggedNodeKey}"]`);
5573
+ if (draggedFigure && targetGallery.contains(draggedFigure)) {
5574
+ const position = this.#computeHorizontalPosition(targetFigure, event.clientX);
5575
+ return { type: "gallery-reorder", element: targetFigure, nodeKey: targetFigure.dataset.lexicalNodeKey, position }
5576
+ }
5577
+ }
5578
+ const position = this.#computeHorizontalPosition(targetFigure, event.clientX);
5579
+ return { type: "gallery", element: targetFigure, nodeKey: targetFigure.dataset.lexicalNodeKey, position }
5580
+ }
5581
+
5582
+ // Hovering over the dragged image itself inside a gallery — treat as no-op
5583
+ // to prevent fallthrough to the block handler, which would eject it from the gallery.
5584
+ if (targetFigure && targetFigure.closest(".attachment-gallery")) return null
5585
+
5586
+ // Check if hovering over a gallery's empty space (for reorder within gallery)
5587
+ const targetGallery = element.closest(".attachment-gallery");
5588
+ if (targetGallery) {
5589
+ let galleryFigure = element.closest("figure.attachment[data-lexical-node-key]");
5590
+ if (!galleryFigure) {
5591
+ galleryFigure = this.#findNearestFigureInGallery(targetGallery, event.clientX);
5592
+ }
5593
+ if (galleryFigure && galleryFigure.dataset.lexicalNodeKey !== this.#draggedNodeKey) {
5594
+ const position = this.#computeHorizontalPosition(galleryFigure, event.clientX);
5595
+ return { type: "gallery-reorder", element: galleryFigure, nodeKey: galleryFigure.dataset.lexicalNodeKey, position }
5596
+ }
5597
+ // Nearest figure is the dragged image — no-op to avoid block handler fallthrough
5598
+ if (galleryFigure) return null
5599
+ }
5600
+
5601
+ // Check if hovering over a list item (for list splitting)
5602
+ const listItem = element.closest("li");
5603
+ if (listItem && rootElement.contains(listItem)) {
5604
+ const position = this.#computeVerticalPosition(listItem, event.clientY);
5605
+ return { type: "list-item", element: listItem, position }
5606
+ }
5607
+
5608
+ // Otherwise, find nearest block-level element for between-block insertion.
5609
+ // Normalize so each gap has exactly one indicator: prefer "after" on the
5610
+ // previous sibling, falling back to "before" only for the first block.
5611
+ const block = this.#findNearestBlock(element, rootElement, event.clientY);
5612
+ if (!block) return null
5613
+
5614
+ const position = this.#computeVerticalPosition(block, event.clientY);
5615
+ if (position === "before" && block.previousElementSibling) {
5616
+ return { type: "block", element: block.previousElementSibling, position: "after" }
5617
+ }
5618
+ return { type: "block", element: block, position }
5619
+ }
5620
+
5621
+ #findNearestBlock(element, rootElement, clientY) {
5622
+ let current = element;
5623
+ while (current && current !== rootElement) {
5624
+ if (current.parentElement === rootElement) return current
5625
+ current = current.parentElement;
5626
+ }
5627
+
5628
+ // elementFromPoint landed on the root itself (e.g. a margin gap between
5629
+ // blocks). Fall back to the nearest child by vertical distance.
5630
+ let nearest = null;
5631
+ let minDistance = Infinity;
5632
+ for (const child of rootElement.children) {
5633
+ const rect = child.getBoundingClientRect();
5634
+ const distance = Math.min(Math.abs(clientY - rect.top), Math.abs(clientY - rect.bottom));
5635
+ if (distance < minDistance) {
5636
+ minDistance = distance;
5637
+ nearest = child;
5638
+ }
5639
+ }
5640
+ return nearest
5641
+ }
5642
+
5643
+ #computeVerticalPosition(element, clientY) {
5644
+ const rect = element.getBoundingClientRect();
5645
+ return clientY < rect.top + rect.height / 2 ? "before" : "after"
5646
+ }
5647
+
5648
+ #computeHorizontalPosition(element, clientX) {
5649
+ const rect = element.getBoundingClientRect();
5650
+ return clientX < rect.left + rect.width / 2 ? "before" : "after"
5651
+ }
5652
+
5653
+ #findNearestFigureInGallery(gallery, clientX) {
5654
+ const figures = gallery.querySelectorAll("figure.attachment[data-lexical-node-key]");
5655
+ let nearest = null;
5656
+ let minDistance = Infinity;
5657
+ for (const figure of figures) {
5658
+ const rect = figure.getBoundingClientRect();
5659
+ const center = rect.left + rect.width / 2;
5660
+ const distance = Math.abs(clientX - center);
5661
+ if (distance < minDistance) {
5662
+ minDistance = distance;
5663
+ nearest = figure;
5664
+ }
5665
+ }
5666
+ return nearest
5667
+ }
5668
+
5669
+ // -- Drop indicator --------------------------------------------------------
5670
+
5671
+ static #DROP_CLASSES = [
5672
+ "lexxy-drop-target--gallery-before", "lexxy-drop-target--gallery-after",
5673
+ "lexxy-drop-target--list-before", "lexxy-drop-target--list-after",
5674
+ "lexxy-drop-target--block-before", "lexxy-drop-target--block-after",
5675
+ ]
5676
+
5677
+ #clearDropIndicators() {
5678
+ const rootElement = this.#editor.getRootElement();
5679
+ if (!rootElement) return
5680
+
5681
+ for (const el of rootElement.querySelectorAll("[class*='lexxy-drop-target--']")) {
5682
+ el.classList.remove(...AttachmentDragAndDrop.#DROP_CLASSES);
5683
+ }
5684
+ }
5685
+
5686
+ // -- Node operations -------------------------------------------------------
5687
+
5688
+ #performDrop(draggedKey, target) {
5689
+ const draggedNode = $getNodeByKey(draggedKey);
5690
+ if (!draggedNode || !$isActionTextAttachmentNode(draggedNode)) return
5691
+
5692
+ if (target.type === "gallery") {
5693
+ this.#dropOntoImage(draggedNode, target.nodeKey, target.position);
5694
+ } else if (target.type === "gallery-reorder") {
5695
+ this.#reorderInGallery(draggedNode, target.nodeKey, target.position);
5696
+ } else if (target.type === "list-item") {
5697
+ this.#dropIntoList(draggedNode, target);
5698
+ } else {
5699
+ this.#dropBetweenBlocks(draggedNode, target);
5700
+ }
5701
+
5702
+ // Clear selection to prevent a second history entry. Lexical dispatches
5703
+ // SELECTION_CHANGE_COMMAND during commit for non-range selections, which
5704
+ // creates a separate update. Null selection avoids that dispatch entirely
5705
+ // and also prevents Firefox's follow-up selectionchange from dirtying nodes.
5706
+ $setSelection(null);
5707
+ }
5708
+
5709
+ #dropOntoImage(draggedNode, targetKey, position) {
5710
+ const targetNode = $getNodeByKey(targetKey);
5711
+ if (!targetNode || !$isActionTextAttachmentNode(targetNode)) return
5712
+ if (draggedNode.is(targetNode)) return
5713
+
5714
+ draggedNode.remove();
5715
+
5716
+ const gallery = $findOrCreateGalleryForImage(targetNode);
5717
+ if (gallery) {
5718
+ if (position === "before") {
5719
+ targetNode.insertBefore(draggedNode);
5720
+ } else {
5721
+ targetNode.insertAfter(draggedNode);
5722
+ }
5723
+ }
5724
+ }
5725
+
5726
+ #reorderInGallery(draggedNode, targetKey, position) {
5727
+ const targetNode = $getNodeByKey(targetKey);
5728
+ if (!targetNode || draggedNode.is(targetNode)) return
5729
+
5730
+ draggedNode.remove();
5731
+
5732
+ if (position === "before") {
5733
+ targetNode.insertBefore(draggedNode);
5734
+ } else {
5735
+ targetNode.insertAfter(draggedNode);
5736
+ }
5737
+ }
5738
+
5739
+ #dropIntoList(draggedNode, target) {
5740
+ const listItemNode = $getNearestNodeFromDOMNode(target.element);
5741
+ if (!listItemNode || !$isListItemNode(listItemNode)) return
5742
+
5743
+ const listNode = listItemNode.getParent();
5744
+ if (!listNode || !$isListNode(listNode)) return
5745
+
5746
+ const children = listNode.getChildren();
5747
+ const index = children.indexOf(listItemNode);
5748
+ if (index === -1) return
5749
+
5750
+ const splitIndex = target.position === "before" ? index : index + 1;
5751
+
5752
+ draggedNode.remove();
5753
+
5754
+ if (splitIndex === 0) {
5755
+ listNode.insertBefore(draggedNode);
5756
+ } else if (splitIndex >= children.length) {
5757
+ listNode.insertAfter(draggedNode);
5758
+ } else {
5759
+ const [ , listAfter ] = $splitNode(listNode, splitIndex);
5760
+ listAfter.insertBefore(draggedNode);
5761
+ }
5762
+ }
5763
+
5764
+ #dropBetweenBlocks(draggedNode, target) {
5765
+ const targetNode = $getNearestNodeFromDOMNode(target.element);
5766
+ if (!targetNode) return
5767
+
5768
+ const topLevelTarget = targetNode.getTopLevelElement?.() || targetNode;
5769
+ if (draggedNode.is(topLevelTarget)) return
5770
+
5771
+ draggedNode.remove();
5772
+
5773
+ if (target.position === "before") {
5774
+ topLevelTarget.insertBefore(draggedNode);
5775
+ } else {
5776
+ topLevelTarget.insertAfter(draggedNode);
5777
+ }
5778
+ }
5779
+
5780
+ // -- Lifecycle helpers -----------------------------------------------------
5781
+
5782
+ #cleanup() {
5783
+ this.#clearDropIndicators();
5784
+
5785
+ if (this.#draggedNodeKey) {
5786
+ const rootElement = this.#editor.getRootElement();
5787
+ if (rootElement) {
5788
+ const figure = rootElement.querySelector(`[data-lexical-node-key="${this.#draggedNodeKey}"]`);
5789
+ figure?.classList.remove("lexxy-dragging");
5790
+ }
5791
+ }
5792
+
5793
+ this.#draggedNodeKey = null;
5794
+
5795
+ if (this.#rafId) {
5796
+ cancelAnimationFrame(this.#rafId);
5797
+ this.#rafId = null;
5798
+ }
5799
+
5800
+ if (this.#draggingRafId) {
5801
+ cancelAnimationFrame(this.#draggingRafId);
5802
+ this.#draggingRafId = null;
5803
+ }
5804
+ }
5805
+ }
5806
+
5358
5807
  class AttachmentsExtension extends LexxyExtension {
5359
5808
  get enabled() {
5360
5809
  return this.editorElement.supportsAttachments
@@ -5369,14 +5818,37 @@ class AttachmentsExtension extends LexxyExtension {
5369
5818
  ImageGalleryNode
5370
5819
  ],
5371
5820
  register(editor) {
5821
+ const dragAndDrop = new AttachmentDragAndDrop(editor);
5822
+
5372
5823
  return mergeRegister(
5373
- editor.registerCommand(DELETE_CHARACTER_COMMAND, $collapseIntoGallery, COMMAND_PRIORITY_NORMAL)
5824
+ editor.registerNodeTransform(ActionTextAttachmentNode, $extractAttachmentFromParagraph),
5825
+ editor.registerCommand(DELETE_CHARACTER_COMMAND, $collapseIntoGallery, COMMAND_PRIORITY_NORMAL),
5826
+ () => dragAndDrop.destroy()
5374
5827
  )
5375
5828
  }
5376
5829
  })
5377
5830
  }
5378
5831
  }
5379
5832
 
5833
+ // Decorator nodes can be wrapped in a Paragraph Node by Lexical when contained in a <div>
5834
+ // We remove them, splitting the node as needed
5835
+ function $extractAttachmentFromParagraph(attachmentNode) {
5836
+ const parentNode = attachmentNode.getParent();
5837
+ if (!$isParagraphNode(parentNode)) return
5838
+
5839
+ if (parentNode.getChildrenSize() === 1) {
5840
+ parentNode.replace(attachmentNode);
5841
+ } else {
5842
+ const index = attachmentNode.getIndexWithinParent();
5843
+ const [ topParagraph, bottomParagraph ] = $splitNode(parentNode, index);
5844
+ topParagraph.insertAfter(attachmentNode);
5845
+
5846
+ for (const p of [ topParagraph, bottomParagraph ]) {
5847
+ if (p.isEmpty()) p.remove();
5848
+ }
5849
+ }
5850
+ }
5851
+
5380
5852
  function $collapseIntoGallery(backwards) {
5381
5853
  const anchor = $getSelection()?.anchor;
5382
5854
  if (!anchor) return false
@@ -5623,7 +6095,6 @@ class LexicalEditorElement extends HTMLElement {
5623
6095
  return nodes
5624
6096
  .filter(this.#isNotWhitespaceOnlyNode)
5625
6097
  .map(this.#wrapTextNode)
5626
- .map(this.#unwrapDecoratorNode)
5627
6098
  }
5628
6099
 
5629
6100
  // Whitespace-only text nodes (e.g. "\n" between block elements like <div>) and stray line break
@@ -5645,18 +6116,6 @@ class LexicalEditorElement extends HTMLElement {
5645
6116
  return paragraph
5646
6117
  }
5647
6118
 
5648
- // Custom decorator block elements such as action-text-attachments get wrapped into <p> automatically by Lexical.
5649
- // We unwrap those.
5650
- #unwrapDecoratorNode(node) {
5651
- if ($isParagraphNode(node) && node.getChildrenSize() === 1) {
5652
- const child = node.getFirstChild();
5653
- if ($isDecoratorNode(child) && !child.isInline()) {
5654
- return child
5655
- }
5656
- }
5657
- return node
5658
- }
5659
-
5660
6119
  #initialize() {
5661
6120
  this.#synchronizeWithChanges();
5662
6121
  this.#registerComponents();
@@ -6968,21 +7427,16 @@ class NodeDeleteButton extends HTMLElement {
6968
7427
  this.editor = this.editorElement.editor;
6969
7428
  this.classList.add("lexxy-floating-controls");
6970
7429
 
6971
- if (!this.deleteButton) {
7430
+ if (!this.querySelector(".lexxy-node-delete")) {
6972
7431
  this.#attachDeleteButton();
6973
7432
  }
6974
7433
  }
6975
7434
 
6976
7435
  disconnectedCallback() {
6977
- if (this.deleteButton && this.handleDeleteClick) {
6978
- this.deleteButton.removeEventListener("click", this.handleDeleteClick);
6979
- }
6980
-
6981
- this.handleDeleteClick = null;
6982
- this.deleteButton = null;
6983
7436
  this.editor = null;
6984
7437
  this.editorElement = null;
6985
7438
  }
7439
+
6986
7440
  #attachDeleteButton() {
6987
7441
  const container = createElement("div", { className: "lexxy-floating-controls__group" });
6988
7442
 
@@ -419,16 +419,12 @@
419
419
  text-align: center;
420
420
 
421
421
  .attachment {
422
- display: inline-block;
422
+ display: inline-flex;
423
+ flex-direction: column;
424
+ gap: 0;
423
425
  inline-size: calc(33.333% - 0.8ch);
424
426
  vertical-align: top;
425
427
 
426
- img {
427
- margin: auto;
428
- max-block-size: 50rem;
429
- object-fit: contain;
430
- }
431
-
432
428
  .attachment__caption {
433
429
  padding-block-end: 1ch;
434
430
  }
@@ -451,10 +447,12 @@
451
447
  --lexxy-attachment-image-size: 1em;
452
448
  --lexxy-attachment-text-color: currentColor;
453
449
 
450
+ align-items: center;
454
451
  background: var(--lexxy-attachment-bg-color);
455
452
  border-radius: var(--lexxy-radius);
456
453
  color: var(--lexxy-attachment-text-color);
457
- display: inline;
454
+ display: inline-flex;
455
+ gap: 0.25ch;
458
456
  margin: 0;
459
457
  padding: 0;
460
458
  position: relative;
@@ -464,8 +462,6 @@
464
462
  block-size: var(--lexxy-attachment-image-size);
465
463
  border-radius: 50%;
466
464
  inline-size: var(--lexxy-attachment-image-size);
467
- margin-inline-end: 0.25ch;
468
- vertical-align: middle;
469
465
  }
470
466
  }
471
467
 
@@ -186,6 +186,8 @@
186
186
  }
187
187
 
188
188
  .attachment {
189
+ background-color: var(--lexxy-color-canvas);
190
+
189
191
  progress {
190
192
  max-inline-size: 10ch;
191
193
  margin: auto;
@@ -205,6 +207,93 @@
205
207
  inset-inline-start: unset;
206
208
  }
207
209
  }
210
+
211
+ &.attachment--error {
212
+ background: color-mix(var(--lexxy-color-red) 10%, transparent);
213
+ padding: 2ch;
214
+
215
+ &:before {
216
+ align-items: center;
217
+ aspect-ratio: 1;
218
+ background: var(--lexxy-color-red);
219
+ block-size: 1.5lh;
220
+ border-radius: 50%;
221
+ color: white;
222
+ content: "!";
223
+ display: flex;
224
+ justify-content: center;
225
+ margin: auto;
226
+ }
227
+
228
+ > div {
229
+ flex: 1;
230
+ font-size: 0.85em;
231
+ padding: 1ch;
232
+ text-align: start;
233
+ }
234
+ }
235
+ }
236
+
237
+ .attachment[draggable] {
238
+ cursor: grab;
239
+ }
240
+
241
+ .attachment.lexxy-dragging {
242
+ opacity: 0.4;
243
+ }
244
+
245
+ [class*="lexxy-drop-target--"] {
246
+ position: relative;
247
+ }
248
+
249
+ /* Horizontal line indicator for block and list drops */
250
+ .lexxy-drop-target--block-before::before,
251
+ .lexxy-drop-target--block-after::after,
252
+ .lexxy-drop-target--list-before::before,
253
+ .lexxy-drop-target--list-after::after {
254
+ background-color: var(--lexxy-focus-ring-color);
255
+ block-size: 3px;
256
+ border-radius: 1px;
257
+ content: "";
258
+ inset-inline: 0;
259
+ pointer-events: none;
260
+ position: absolute;
261
+ transform: translate(0, 0.5ch);
262
+ }
263
+
264
+ .lexxy-drop-target--block-before::before,
265
+ .lexxy-drop-target--list-before::before {
266
+ transform: translate(0, -0.5ch);
267
+ }
268
+
269
+ .lexxy-drop-target--block-before::before,
270
+ .lexxy-drop-target--list-before::before {
271
+ inset-block-start: -2px;
272
+ }
273
+
274
+ .lexxy-drop-target--block-after::after,
275
+ .lexxy-drop-target--list-after::after {
276
+ inset-block-end: -2px;
277
+ }
278
+
279
+ /* Vertical line indicator for gallery merge and reorder */
280
+ .lexxy-drop-target--gallery-before::before,
281
+ .lexxy-drop-target--gallery-after::after {
282
+ background-color: var(--lexxy-focus-ring-color);
283
+ border-radius: 1px;
284
+ content: "";
285
+ inset-block: 0;
286
+ inline-size: 3px;
287
+ pointer-events: none;
288
+ position: absolute;
289
+ }
290
+
291
+ .lexxy-drop-target--gallery-before::before {
292
+ inset-inline-start: -4px;
293
+ }
294
+
295
+ .lexxy-drop-target--gallery-after::after {
296
+ inset-inline-end: -4px;
208
297
  }
209
298
 
210
299
  .attachment:hover:not(.node--selected) {
@@ -225,27 +314,35 @@
225
314
  /* ------------------------------------------------------------------------ */
226
315
 
227
316
  .attachment-gallery {
317
+ --lexxy-attachment-gallery-columns: 3;
228
318
  --lexxy-attachment-gallery-gap: 0.4ch;
229
319
  --lexxy-focus-ring-offset: -6px;
230
320
 
231
- display: block;
232
321
  padding: 0;
233
322
 
234
323
  .attachment {
324
+ background: transparent;
325
+ box-sizing: border-box;
235
326
  margin: var(--lexxy-attachment-gallery-gap);
236
327
  padding: 0;
237
328
  padding-block-end: var(--lexxy-attachment-gap);
238
329
  vertical-align: top;
239
330
 
240
- &.attachment--error {
241
- padding: 2ch;
242
- }
243
-
244
331
  img {
245
332
  box-sizing: border-box;
246
333
  padding: 1ch;
247
334
  padding-block-end: 0;
248
335
  }
336
+
337
+
338
+ &.attachment--error {
339
+ background: color-mix(var(--lexxy-color-red) 10%, transparent);
340
+ padding: 2ch;
341
+
342
+ > div {
343
+ text-align: center;
344
+ }
345
+ }
249
346
  }
250
347
  }
251
348
 
@@ -905,7 +1002,6 @@ action-text-attachment[content-type^="application/vnd.actiontext"] {
905
1002
  }
906
1003
 
907
1004
  lexxy-node-delete-button {
908
- display: none;
909
1005
  inset-inline-start: 0;
910
1006
  line-height: 1lh;
911
1007
 
@@ -920,8 +1016,4 @@ action-text-attachment[content-type^="application/vnd.actiontext"] {
920
1016
  }
921
1017
  }
922
1018
  }
923
-
924
- &.node--selected lexxy-node-delete-button {
925
- display: block;
926
- }
927
1019
  }
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@37signals/lexxy",
3
- "version": "0.8.2-beta",
3
+ "version": "0.8.5-beta",
4
4
  "description": "Lexxy - A modern rich text editor for Rails.",
5
5
  "module": "dist/lexxy.esm.js",
6
6
  "type": "module",