From 166dcdbb05f411c34f80899c2d418a1cf1e873c6 Mon Sep 17 00:00:00 2001 From: Salihu Date: Fri, 7 Aug 2026 22:47:18 +0100 Subject: [PATCH] dragging logic --- .../editor/components/tabs/tabs-view.tsx | 4 +- .../features/editor/extensions/drag-handle.ts | 150 +++++++++++++++++- .../features/editor/extensions/extensions.ts | 2 +- .../src/features/editor/styles/tabs.css | 8 + packages/editor-ext/src/lib/tabs/tab-label.ts | 2 +- packages/editor-ext/src/lib/tabs/tab-panel.ts | 2 +- 6 files changed, 159 insertions(+), 9 deletions(-) diff --git a/apps/client/src/features/editor/components/tabs/tabs-view.tsx b/apps/client/src/features/editor/components/tabs/tabs-view.tsx index 6b193521d..0c0cf1b23 100644 --- a/apps/client/src/features/editor/components/tabs/tabs-view.tsx +++ b/apps/client/src/features/editor/components/tabs/tabs-view.tsx @@ -39,7 +39,7 @@ export default function TabsView(props: NodeViewProps) { const previous = document.activeElement as HTMLElement | null; const input = event.currentTarget; - if (!previous.contains(input)) { + if (!previous?.contains(input)) { allowFocusRef.current = true; return; } @@ -88,7 +88,7 @@ export default function TabsView(props: NodeViewProps) { return ( - + {tabs.map(({ label, id }, index) => ( ancestorDepth) { + const childDepth = ancestorDepth + 1; + return { + pos: $pos.before(childDepth), + node: $pos.node(childDepth), + }; + } + + const index = $pos.index(ancestorDepth); + const childIndex = Math.min(index, ancestor.childCount - 1); + if (childIndex < 0) return null; + + return { + pos: $pos.posAtIndex(childIndex, ancestorDepth), + node: ancestor.child(childIndex), + }; +} + +type DragSource = { from: number; to: number; node: Node }; + +function dragSourceFromDOM(view: EditorView, dom: Element): DragSource | null { + let pos: number; + try { + pos = view.posAtDOM(dom, 0); + } catch { + return null; + } + + const $pos = view.state.doc.resolve(pos); + if ($pos.depth === 0) return null; + + const from = $pos.before($pos.depth); + const node = $pos.node($pos.depth); + return { from, to: from + node.nodeSize, node }; +} + +function resolveMoveSource( + view: EditorView, + hint: DragSource | null, +): DragSource | null { + const { state } = view; + + if (hint) { + const current = state.doc.nodeAt(hint.from); + if (current?.eq(hint.node)) return hint; + } + + if (state.selection instanceof NodeSelection) { + return { + from: state.selection.from, + to: state.selection.from + state.selection.node.nodeSize, + node: state.selection.node, + }; + } + + return null; +} + +function moveNodeWithinTabPanel( + view: EditorView, + dropPos: number, + source: DragSource, +): boolean { + const { state } = view; + const { from: sourceFrom, to: sourceTo, node: sourceNode } = source; + + const $drop = state.doc.resolve(dropPos); + const panelDepth = view.state.doc.resolve(dropPos).depth + if (panelDepth < 0) return false; + + if (dropPos > sourceFrom && dropPos < sourceTo) return false; + + let insertPos: number; + const target = getDirectTarget($drop, panelDepth); + if (!target) { + insertPos = $drop.start(panelDepth); + } else { + const targetStart = target.pos; + const targetEnd = targetStart + target.node.nodeSize; + const midpoint = (targetStart + targetEnd) / 2; + insertPos = dropPos >= midpoint ? targetEnd : targetStart; + } + + // Already in place. + if (insertPos === sourceFrom || insertPos === sourceTo) return false; + + const tr = state.tr; + tr.delete(sourceFrom, sourceTo); + + const insertAt = tr.mapping.map(insertPos, -1); + const $insert = tr.doc.resolve(insertAt); + const index = $insert.index(); + + if (!$insert.parent.canReplace(index, index, Fragment.from(sourceNode))) { + return false; + } + + tr.insert(insertAt, sourceNode); + + const $placed = tr.doc.resolve( + Math.min(insertAt, tr.doc.content.size), + ); + tr.setSelection(NodeSelection.near($placed)); + tr.scrollIntoView(); + + view.dispatch(tr); + return true; +} + +const NON_PROMOTABLE_PARENTS = new Set(["tabs", "tab", "tabPanel"]); + function calcNodePos(pos: number, view: EditorView) { const $pos = view.state.doc.resolve(pos); - if ($pos.depth > 1) return $pos.before($pos.depth); + if ($pos.depth > 1 && !NON_PROMOTABLE_PARENTS.has($pos.node($pos.depth).type.name)) + return $pos.before($pos.depth); return pos; } @@ -160,6 +278,8 @@ export function DragHandlePlugin( options: GlobalDragHandleOptions & { pluginKey: string }, ) { let listType = ""; + let dragSource: DragSource | null = null; + function handleDragStart(event: DragEvent, view: EditorView) { view.focus(); @@ -176,6 +296,8 @@ export function DragHandlePlugin( if (!(node instanceof Element)) return; + dragSource = dragSourceFromDOM(view, node); + let draggedNodePos = nodePosAtDOM(node, view, options); if (draggedNodePos == null || draggedNodePos < 0) return; draggedNodePos = calcNodePos(draggedNodePos, view); @@ -401,6 +523,7 @@ export function DragHandlePlugin( ); const notDragging = node?.closest(".not-draggable"); + const notDraggingMatch = node?.matches(".not-draggable-match") const excludedTagList = options.excludedTags .concat(["ol", "ul"]) .join(", "); @@ -408,7 +531,8 @@ export function DragHandlePlugin( if ( !(node instanceof Element) || node.matches(excludedTagList) || - notDragging + notDragging || + notDraggingMatch ) { hideDragHandle(); return; @@ -496,6 +620,22 @@ export function DragHandlePlugin( const isDroppedInsideList = resolvedPos.parent.type.name === "listItem"; + const isDroppedInsideTabPanel = + resolvedPos.parent.type.name === "tabPanel"; + + if (isDroppedInsideTabPanel) { + const source = resolveMoveSource(view, dragSource); + const moved = source + ? moveNodeWithinTabPanel(view, dropPos.pos, source) + : false; + + // even when not moved swallow the drop so ProseMirror doesn't insert a copy. + event.preventDefault(); + view.dragging = null; + dragSource = null; + return moved; + } + // If the selected node is a list item and is not dropped inside a list, we need to wrap it inside
    tag otherwise ol list items will be transformed into ul list item when dropped if ( view.state.selection instanceof NodeSelection && @@ -513,6 +653,7 @@ export function DragHandlePlugin( }, dragend: (view) => { view.dom.classList.remove("dragging"); + dragSource = null; }, }, }, @@ -526,6 +667,7 @@ const GlobalDragHandle = Extension.create({ return { dragHandleWidth: 20, scrollThreshold: 100, + dragHandleSelector: undefined, excludedTags: [], customNodes: [], atomNodes: [], diff --git a/apps/client/src/features/editor/extensions/extensions.ts b/apps/client/src/features/editor/extensions/extensions.ts index f31316534..1fad5e5db 100644 --- a/apps/client/src/features/editor/extensions/extensions.ts +++ b/apps/client/src/features/editor/extensions/extensions.ts @@ -239,7 +239,7 @@ export const mainExtensions = [ Typography, TrailingNode, GlobalDragHandle.configure({ - customNodes: ["transclusionSource", "transclusionReference"], + customNodes: ["transclusionSource", "transclusionReference", "tabPanel"], atomNodes: ["base"], }), TextStyle, diff --git a/apps/client/src/features/editor/styles/tabs.css b/apps/client/src/features/editor/styles/tabs.css index 4fe4d8ba5..fe8786aa3 100644 --- a/apps/client/src/features/editor/styles/tabs.css +++ b/apps/client/src/features/editor/styles/tabs.css @@ -4,4 +4,12 @@ outline: none; } } + + [data-type="tabLabel"] { + display: none; + } + + [data-type="tabPanel"] { + outline: none; + } } diff --git a/packages/editor-ext/src/lib/tabs/tab-label.ts b/packages/editor-ext/src/lib/tabs/tab-label.ts index a5cc240c3..dbbcb641c 100644 --- a/packages/editor-ext/src/lib/tabs/tab-label.ts +++ b/packages/editor-ext/src/lib/tabs/tab-label.ts @@ -30,8 +30,8 @@ export const TabLabel = Node.create({ mergeAttributes( { "data-type": this.name, - hidden: "hidden", "aria-hidden": "true", + class: "not-draggable-match" }, this.options.HTMLAttributes, HTMLAttributes, diff --git a/packages/editor-ext/src/lib/tabs/tab-panel.ts b/packages/editor-ext/src/lib/tabs/tab-panel.ts index acd082beb..bc1b9948b 100644 --- a/packages/editor-ext/src/lib/tabs/tab-panel.ts +++ b/packages/editor-ext/src/lib/tabs/tab-panel.ts @@ -27,7 +27,7 @@ export const TabPanel = Node.create({ return [ "div", mergeAttributes( - { "data-type": this.name, role: "tabpanel" }, + { "data-type": this.name, role: "tabpanel", class: "not-draggable-match" }, this.options.HTMLAttributes, HTMLAttributes, ),