editor: only drop a task item into a task list & fix crash

This commit is contained in:
Ammar Ahmed
2026-07-29 11:36:30 +05:00
parent efaffc8729
commit 1070831b63
3 changed files with 59 additions and 20 deletions

View File

@@ -79,6 +79,7 @@ function dropGapPlugin() {
} }
function createGap({ height, indent }: DropGap) { function createGap({ height, indent }: DropGap) {
// a task list is made of list items, so the gap is one too
const element = document.createElement("li"); const element = document.createElement("li");
element.className = DROP_GAP_CLASS; element.className = DROP_GAP_CLASS;
element.contentEditable = "false"; element.contentEditable = "false";
@@ -182,8 +183,13 @@ export function startItemDrag(
const top = e.clientY + state.offsetY; const top = e.clientY + state.offsetY;
state.preview.style.transform = `translate3d(0, ${top}px, 0)`; state.preview.style.transform = `translate3d(0, ${top}px, 0)`;
// undefined means "leave the gap as it is"; null clears it, so a drop
// outside any task list has no target and is cancelled
const target = findGap(view, state, e.clientX, top); const target = findGap(view, state, e.clientX, top);
if (target) setGap(view, (state.gap = target)); if (target !== undefined) {
state.gap = target ?? undefined;
setGap(view, target);
}
fitPreview(view, state); fitPreview(view, state);
autoScroll(state, e.clientY); autoScroll(state, e.clientY);
}; };
@@ -259,18 +265,29 @@ function createPreview(view: EditorView, item: HTMLElement, box: DOMRect) {
/** /**
* Where the item would land: the sibling top edge nearest to the top edge * Where the item would land: the sibling top edge nearest to the top edge
* of the item being dragged. The gap counts as one of those edges, which * of the item being dragged, within the task list under the pointer. The
* is what keeps it in place while the item is over it — moving it would * gap counts as one of those edges, which is what keeps it in place while
* move everything below it, putting a different edge under the item, and * the item is over it — moving it would move everything below it, putting a
* it would flicker between the two. * different edge under the item, and it would flicker between the two.
*
* Returns `undefined` to leave the gap where it is (the pointer is over the
* item itself, or off the document for a frame), and `null` to clear it —
* a task item only drops into a task list, so anywhere else is cancelled.
*/ */
function findGap(view: EditorView, drag: Drag, x: number, top: number) { function findGap(
view: EditorView,
drag: Drag,
x: number,
top: number
): DropGap | null | undefined {
const element = document.elementFromPoint( const element = document.elementFromPoint(
Math.max(x, view.dom.getBoundingClientRect().left + 1), Math.max(x, view.dom.getBoundingClientRect().left + 1),
top top
); );
const list = element?.closest("ul"); if (!element || !view.dom.contains(element)) return undefined;
if (!list || !view.dom.contains(list)) return drag.gap ?? null;
const list = element.closest<HTMLElement>("ul.tasklist-content-wrapper");
if (!list || !view.dom.contains(list)) return null;
let closest: number | null = null; let closest: number | null = null;
let distance = Infinity; let distance = Infinity;
@@ -295,8 +312,9 @@ function findGap(view: EditorView, drag: Drag, x: number, top: number) {
if (child === children.at(-1)) consider(box.bottom, pos.after); if (child === children.at(-1)) consider(box.bottom, pos.after);
} }
if (closest === null || (closest > drag.pos && closest < drag.end)) if (closest === null) return null;
return drag.gap ?? null; // dropping the item into itself is a no-op: leave the gap alone
if (closest > drag.pos && closest < drag.end) return undefined;
const nest = x - drag.startX > NEST_THRESHOLD && canNest(view, closest, drag); const nest = x - drag.startX > NEST_THRESHOLD && canNest(view, closest, drag);
return { pos: closest, height: drag.height, indent: nest ? NEST_INDENT : 0 }; return { pos: closest, height: drag.height, indent: nest ? NEST_INDENT : 0 };
@@ -340,18 +358,34 @@ function canNest(view: EditorView, pos: number, drag: Drag) {
/** moves the item at `from` to `to`, returning where it ended up */ /** moves the item at `from` to `to`, returning where it ended up */
function moveItem(view: EditorView, from: number, to: number) { function moveItem(view: EditorView, from: number, to: number) {
const item = view.state.doc.nodeAt(from); const { state } = view;
const item = state.doc.nodeAt(from);
if (!item) return null; if (!item) return null;
const tr = view.state.tr.deleteRange(from, from + item.nodeSize); // NOTE: `deleteRange`, not `delete`: taking the only child out of a
const at = tr.mapping.map(to); // nested list leaves the list empty, and an empty list is not valid
// content, so it would be filled with a blank item. This takes the list
// itself away instead.
const tr = state.tr.deleteRange(from, from + item.nodeSize);
const at = Math.min(tr.mapping.map(to), tr.doc.content.size);
const deleted = tr.doc; const deleted = tr.doc;
tr.replaceRangeWith(at, at, item); // the target is always a task list, but guard anyway: dropping the item
if (tr.doc.eq(deleted)) return null; // where it does not fit would put it somewhere unexpected
if (tr.doc.eq(view.state.doc)) return at; const $at = tr.doc.resolve(at);
if (!$at.parent.canReplaceWith($at.index(), $at.index(), item.type))
return null;
tr.replaceRangeWith(at, at, item);
// nowhere it fits, or already exactly there
if (tr.doc.eq(deleted)) return null;
if (tr.doc.eq(state.doc)) return at;
// select the item, but only if it really landed where we think it did:
// NodeSelection throws if there is no node right after `at`
const node = tr.doc.resolve(at).nodeAfter;
if (node?.type === item.type) tr.setSelection(NodeSelection.create(tr.doc, at)); // prettier-ignore
tr.setSelection(NodeSelection.create(tr.doc, at));
view.dispatch(tr.setMeta("uiEvent", "drop")); view.dispatch(tr.setMeta("uiEvent", "drop"));
return at; return at;
} }

View File

@@ -74,7 +74,7 @@ export function TaskItemComponent(
style={{ style={{
flexDirection: "row", flexDirection: "row",
alignItems: "center", alignItems: "center",
maxWidth: "95%", maxWidth: "100%",
flexGrow: 1 flexGrow: 1
}} }}
> >
@@ -159,7 +159,9 @@ export function TaskItemComponent(
sx={{ sx={{
bg: "background", bg: "background",
opacity: 0, opacity: 0,
alignSelf: "flex-start", position: "absolute",
insetInlineEnd: 0,
top: 0,
marginTop: "calc((1lh - 14px) / 2)" marginTop: "calc((1lh - 14px) / 2)"
}} }}
> >

View File

@@ -245,7 +245,10 @@ export function TaskListComponent(
paddingInlineStart: 0, paddingInlineStart: 0,
marginBlockStart: isNested ? 10 : 0, marginBlockStart: isNested ? 10 : 0,
marginBlockEnd: 0, marginBlockEnd: 0,
marginLeft: isNested ? (editor.isEditable ? -35 : -10) : 0, // NOTE: inline-start, not left: the indent has to be on the
// side the text flows from, so it stays on the right in RTL
// instead of showing up on the wrong side
marginInlineStart: isNested ? (editor.isEditable ? -35 : -10) : 0,
padding: 0 padding: 0
}, },
li: { li: {