editor: drop placeholder on first item on task list should not hide when moving the dragged item above it or above the task list header. it should safely drop as the first list item.

- Improve drag/drop reliability be increasing the hit slop area for starting the drag.

- Ensure drag survives between rerenders
- Add a solid background to dragged item so it doesn't conflict with items underneath it
This commit is contained in:
Ammar Ahmed
2026-07-29 19:11:39 +05:00
parent 1070831b63
commit 79086108a5
2 changed files with 61 additions and 12 deletions

View File

@@ -133,7 +133,10 @@ export function startItemDrag(
if (!editor.isEditable || event.button !== 0 || !item) return; if (!editor.isEditable || event.button !== 0 || !item) return;
event.stopPropagation(); event.stopPropagation();
if (event.pointerType === "mouse") event.preventDefault(); // the handle has no tap action of its own, so cancelling the default is
// safe — and on touch it is what stops the WebView from starting a text
// selection instead of the drag
if (event.cancelable) event.preventDefault();
const { view } = editor; const { view } = editor;
let drag: Drag | undefined; let drag: Drag | undefined;
@@ -185,7 +188,7 @@ export function startItemDrag(
// undefined means "leave the gap as it is"; null clears it, so a drop // undefined means "leave the gap as it is"; null clears it, so a drop
// outside any task list has no target and is cancelled // 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, e.clientY, top);
if (target !== undefined) { if (target !== undefined) {
state.gap = target ?? undefined; state.gap = target ?? undefined;
setGap(view, target); setGap(view, target);
@@ -205,9 +208,12 @@ export function startItemDrag(
const cleanup = () => { const cleanup = () => {
clearTimeout(hold); clearTimeout(hold);
handle.removeEventListener("pointermove", move); // NOTE: on `window`, not the handle. The handle is re-rendered whenever
handle.removeEventListener("pointerup", end); // the gap moves (a decoration change re-renders the node views), and
handle.removeEventListener("pointercancel", cleanup); // listeners on the old element would be lost — the drag would freeze.
window.removeEventListener("pointermove", move);
window.removeEventListener("pointerup", end);
window.removeEventListener("pointercancel", cleanup);
if (!drag) return; if (!drag) return;
cancelAnimationFrame(drag.frame ?? 0); cancelAnimationFrame(drag.frame ?? 0);
drag.preview.remove(); drag.preview.remove();
@@ -217,14 +223,26 @@ export function startItemDrag(
drag = undefined; drag = undefined;
}; };
handle.setPointerCapture?.(event.pointerId); window.addEventListener("pointermove", move, { passive: false });
handle.addEventListener("pointermove", move, { passive: false }); window.addEventListener("pointerup", end);
handle.addEventListener("pointerup", end); window.addEventListener("pointercancel", cleanup);
handle.addEventListener("pointercancel", cleanup);
if (event.pointerType !== "mouse") if (event.pointerType !== "mouse")
hold = setTimeout(start, HOLD_DELAY) as unknown as number; hold = setTimeout(start, HOLD_DELAY) as unknown as number;
} }
/** the nearest background colour that is not see-through */
function opaqueBackground(element: HTMLElement) {
for (
let node: HTMLElement | null = element;
node;
node = node.parentElement
) {
const bg = getComputedStyle(node).backgroundColor;
if (bg && bg !== "transparent" && !bg.startsWith("rgba(0, 0, 0, 0")) return bg; // prettier-ignore
}
return "var(--background, #fff)";
}
/** /**
* A copy of the item that follows the pointer, with its nested items left * A copy of the item that follows the pointer, with its nested items left
* out so that tall items stay easy to place. * out so that tall items stay easy to place.
@@ -237,6 +255,12 @@ function createPreview(view: EditorView, item: HTMLElement, box: DOMRect) {
preview.style.width = `${box.width}px`; preview.style.width = `${box.width}px`;
preview.style.font = style.font; preview.style.font = style.font;
preview.style.color = style.color; preview.style.color = style.color;
// the preview lives on `document.body`, where the editor's theme
// variables are not defined, so the CSS `var(--background)` resolves to
// transparent — a WebKit issue in particular. Read a concrete colour
// while the item is still in the editor, or the checkboxes below show
// through it.
preview.style.backgroundColor = opaqueBackground(item);
const list = (item.parentElement ?? document.createElement("ul")).cloneNode( const list = (item.parentElement ?? document.createElement("ul")).cloneNode(
false false
@@ -273,20 +297,33 @@ function createPreview(view: EditorView, item: HTMLElement, box: DOMRect) {
* Returns `undefined` to leave the gap where it is (the pointer is over the * 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 — * 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. * a task item only drops into a task list, so anywhere else is cancelled.
*
* The list is found under the pointer (`pointerY`), but the slot within it
* from the item's own top edge (`top`). The item's top rises above the list
* before the pointer does, so hit testing with the pointer is what lets the
* item reach the very first slot.
*/ */
function findGap( function findGap(
view: EditorView, view: EditorView,
drag: Drag, drag: Drag,
x: number, x: number,
pointerY: number,
top: number top: number
): DropGap | null | undefined { ): 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 pointerY
); );
if (!element || !view.dom.contains(element)) return undefined; if (!element || !view.dom.contains(element)) return undefined;
const list = element.closest<HTMLElement>("ul.tasklist-content-wrapper"); // the list under the pointer, or — when the pointer is on a list's header
// (the tools bar sits above the first item, outside the `ul`) — that
// list, so the item can still be dropped into its first slot
const list =
element.closest<HTMLElement>("ul.tasklist-content-wrapper") ||
element
.closest(".taskList-view-content-wrap")
?.querySelector<HTMLElement>("ul.tasklist-content-wrapper");
if (!list || !view.dom.contains(list)) return null; if (!list || !view.dom.contains(list)) return null;
let closest: number | null = null; let closest: number | null = null;

View File

@@ -97,8 +97,20 @@ export function TaskItemComponent(
// selection or the long press callout // selection or the long press callout
touchAction: "none", touchAction: "none",
userSelect: "none", userSelect: "none",
WebkitUserSelect: "none",
WebkitTouchCallout: "none", WebkitTouchCallout: "none",
svg: { pointerEvents: "none" } svg: { pointerEvents: "none" },
// hit slop: an invisible box larger than the icon, so a finger
// landing near the handle still starts the drag instead of the
// browser selecting the text next to it.
position: "relative",
"::before": {
content: '""',
position: "absolute",
insetBlock: "-12px",
insetInlineStart: "-16px",
insetInlineEnd: "-2px"
}
}} }}
size={isMobile ? "2.46ch" : "2.22ch"} size={isMobile ? "2.46ch" : "2.22ch"}
/> />