mirror of
https://github.com/docmost/docmost.git
synced 2026-08-24 06:42:17 +10:00
dragging logic
This commit is contained in:
@@ -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,
|
||||||
),
|
),
|
||||||
|
|||||||
Reference in New Issue
Block a user