dragging logic

This commit is contained in:
Salihu
2026-08-07 22:47:18 +01:00
parent 1e2184c021
commit 166dcdbb05
6 changed files with 159 additions and 9 deletions
@@ -39,7 +39,7 @@ export default function TabsView(props: NodeViewProps) {
const previous = document.activeElement as HTMLElement | null; const previous = document.activeElement as HTMLElement | null;
const input = event.currentTarget; const input = event.currentTarget;
if (!previous.contains(input)) { if (!previous?.contains(input)) {
allowFocusRef.current = true; allowFocusRef.current = true;
return; return;
} }
@@ -88,7 +88,7 @@ export default function TabsView(props: NodeViewProps) {
return ( return (
<NodeViewWrapper data-type='tabs'> <NodeViewWrapper data-type='tabs'>
<Tabs value={String(activeTab)}> <Tabs value={String(activeTab)}>
<Tabs.List> <Tabs.List style={{ marginBottom: 10 }}>
{tabs.map(({ label, id }, index) => ( {tabs.map(({ label, id }, index) => (
<Tabs.Tab <Tabs.Tab
key={id} key={id}
@@ -5,8 +5,9 @@ import {
PluginKey, PluginKey,
TextSelection, TextSelection,
} from "@tiptap/pm/state"; } from "@tiptap/pm/state";
import { Fragment, Slice, Node } from "@tiptap/pm/model"; import { Fragment, Slice } from "@tiptap/pm/model";
import { EditorView } from "@tiptap/pm/view"; import type { Node, ResolvedPos } from "@tiptap/pm/model";
import type { EditorView } from "@tiptap/pm/view";
export interface GlobalDragHandleOptions { export interface GlobalDragHandleOptions {
/** /**
@@ -150,9 +151,126 @@ function isCustomNodeDOM(
return false; return false;
} }
function getDirectTarget($pos: ResolvedPos, ancestorDepth: number) {
const ancestor = $pos.node(ancestorDepth);
if (ancestor.childCount === 0) return null;
if ($pos.depth > 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) { function calcNodePos(pos: number, view: EditorView) {
const $pos = view.state.doc.resolve(pos); 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; return pos;
} }
@@ -160,6 +278,8 @@ export function DragHandlePlugin(
options: GlobalDragHandleOptions & { pluginKey: string }, options: GlobalDragHandleOptions & { pluginKey: string },
) { ) {
let listType = ""; let listType = "";
let dragSource: DragSource | null = null;
function handleDragStart(event: DragEvent, view: EditorView) { function handleDragStart(event: DragEvent, view: EditorView) {
view.focus(); view.focus();
@@ -176,6 +296,8 @@ export function DragHandlePlugin(
if (!(node instanceof Element)) return; if (!(node instanceof Element)) return;
dragSource = dragSourceFromDOM(view, node);
let draggedNodePos = nodePosAtDOM(node, view, options); let draggedNodePos = nodePosAtDOM(node, view, options);
if (draggedNodePos == null || draggedNodePos < 0) return; if (draggedNodePos == null || draggedNodePos < 0) return;
draggedNodePos = calcNodePos(draggedNodePos, view); draggedNodePos = calcNodePos(draggedNodePos, view);
@@ -401,6 +523,7 @@ export function DragHandlePlugin(
); );
const notDragging = node?.closest(".not-draggable"); const notDragging = node?.closest(".not-draggable");
const notDraggingMatch = node?.matches(".not-draggable-match")
const excludedTagList = options.excludedTags const excludedTagList = options.excludedTags
.concat(["ol", "ul"]) .concat(["ol", "ul"])
.join(", "); .join(", ");
@@ -408,7 +531,8 @@ export function DragHandlePlugin(
if ( if (
!(node instanceof Element) || !(node instanceof Element) ||
node.matches(excludedTagList) || node.matches(excludedTagList) ||
notDragging notDragging ||
notDraggingMatch
) { ) {
hideDragHandle(); hideDragHandle();
return; return;
@@ -496,6 +620,22 @@ export function DragHandlePlugin(
const isDroppedInsideList = const isDroppedInsideList =
resolvedPos.parent.type.name === "listItem"; 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 <ol> tag otherwise ol list items will be transformed into ul list item when dropped // If the selected node is a list item and is not dropped inside a list, we need to wrap it inside <ol> tag otherwise ol list items will be transformed into ul list item when dropped
if ( if (
view.state.selection instanceof NodeSelection && view.state.selection instanceof NodeSelection &&
@@ -513,6 +653,7 @@ export function DragHandlePlugin(
}, },
dragend: (view) => { dragend: (view) => {
view.dom.classList.remove("dragging"); view.dom.classList.remove("dragging");
dragSource = null;
}, },
}, },
}, },
@@ -526,6 +667,7 @@ const GlobalDragHandle = Extension.create({
return { return {
dragHandleWidth: 20, dragHandleWidth: 20,
scrollThreshold: 100, scrollThreshold: 100,
dragHandleSelector: undefined,
excludedTags: [], excludedTags: [],
customNodes: [], customNodes: [],
atomNodes: [], atomNodes: [],
@@ -239,7 +239,7 @@ export const mainExtensions = [
Typography, Typography,
TrailingNode, TrailingNode,
GlobalDragHandle.configure({ GlobalDragHandle.configure({
customNodes: ["transclusionSource", "transclusionReference"], customNodes: ["transclusionSource", "transclusionReference", "tabPanel"],
atomNodes: ["base"], atomNodes: ["base"],
}), }),
TextStyle, TextStyle,
@@ -4,4 +4,12 @@
outline: none; outline: none;
} }
} }
[data-type="tabLabel"] {
display: none;
}
[data-type="tabPanel"] {
outline: none;
}
} }
@@ -30,8 +30,8 @@ export const TabLabel = Node.create<TabLabelOptions>({
mergeAttributes( mergeAttributes(
{ {
"data-type": this.name, "data-type": this.name,
hidden: "hidden",
"aria-hidden": "true", "aria-hidden": "true",
class: "not-draggable-match"
}, },
this.options.HTMLAttributes, this.options.HTMLAttributes,
HTMLAttributes, HTMLAttributes,
@@ -27,7 +27,7 @@ export const TabPanel = Node.create<TabPanelOptions>({
return [ return [
"div", "div",
mergeAttributes( mergeAttributes(
{ "data-type": this.name, role: "tabpanel" }, { "data-type": this.name, role: "tabpanel", class: "not-draggable-match" },
this.options.HTMLAttributes, this.options.HTMLAttributes,
HTMLAttributes, HTMLAttributes,
), ),