From 04d53473c43f6a06b0966fb6cb0eebadd7cacb5d Mon Sep 17 00:00:00 2001 From: Alice Dang Date: Tue, 31 Mar 2026 22:56:32 +0100 Subject: [PATCH] Replace drag surfaces with drag handle, fix overlay measurement and hover interactions - Add #nodeVersion counter to EditorState so overlay measurements update when sibling/parent nodes change (fixes focus ring lag after reorder) - Replace large drag surface overlays with a small 16x16 drag handle icon at top-center, eliminating pointer event conflicts with child frames - Use document.elementsFromPoint for robust hover detection through overlays - Extract hover logic into updateHover() called from both pointermove and keydown/keyup, so Cmd+hover instantly shows parent focus ring - Cmd+hover promotes to selected node's parent when cursor is inside it Co-Authored-By: Claude Opus 4.6 (1M context) --- .../src/routes/[docId]/[pageId]/canvas.svelte | 69 +++++++++++----- .../[docId]/[pageId]/editor-state.svelte.ts | 6 ++ .../[docId]/[pageId]/selection-overlay.svelte | 82 +++++++++++++------ 3 files changed, 112 insertions(+), 45 deletions(-) diff --git a/apps/web/src/routes/[docId]/[pageId]/canvas.svelte b/apps/web/src/routes/[docId]/[pageId]/canvas.svelte index 28b0f19..9b9ed3f 100644 --- a/apps/web/src/routes/[docId]/[pageId]/canvas.svelte +++ b/apps/web/src/routes/[docId]/[pageId]/canvas.svelte @@ -17,6 +17,50 @@ let spaceHeld = $state(false); let dragging = $state(false); let modifierHeld = $state(false); + let lastPointerX = 0; + let lastPointerY = 0; + + function findNodeAtPoint(clientX: number, clientY: number): string | null { + for (const el of document.elementsFromPoint(clientX, clientY)) { + if (el instanceof HTMLElement && el.dataset.id) { + return el.dataset.id; + } + } + return null; + } + + function updateHover(clientX: number, clientY: number) { + if (spaceHeld) { + return; + } + const id = findNodeAtPoint(clientX, clientY); + if (id) { + if (modifierHeld) { + // If found node is the selected node or inside it, + // hover the selected node's parent instead + let targetId = id; + if (editor.selectedNodeId) { + let checkId: string | null = id; + while (checkId) { + if (checkId === editor.selectedNodeId) { + targetId = editor.selectedNodeId; + break; + } + const p = editor.findParent(checkId); + checkId = p?.id ?? null; + } + } + const parent = editor.findParent(targetId); + if (parent && parent.type !== "root") { + editor.hoverNode(parent.id); + return; + } + } + editor.hoverNode(id); + return; + } + editor.clearHover(); + } let canvasEl = $state(); @@ -100,6 +144,7 @@ onkeydown={(e) => { if (e.key === "Meta" || e.key === "Control") { modifierHeld = true; + updateHover(lastPointerX, lastPointerY); } if (!canvasEl) { return; @@ -147,6 +192,7 @@ onkeyup={(e) => { if (e.key === "Meta" || e.key === "Control") { modifierHeld = false; + updateHover(lastPointerX, lastPointerY); } if (e.code === "Space") { spaceHeld = false; @@ -184,26 +230,9 @@ editor.pan(e.movementX, e.movementY); return; } - if (spaceHeld) { - return; - } - let el = e.target as HTMLElement | null; - while (el && el !== e.currentTarget) { - const id = el.dataset.id; - if (id) { - if (modifierHeld) { - const parent = editor.findParent(id); - if (parent && parent.type !== "root") { - editor.hoverNode(parent.id); - return; - } - } - editor.hoverNode(id); - return; - } - el = el.parentElement; - } - editor.clearHover(); + lastPointerX = e.clientX; + lastPointerY = e.clientY; + updateHover(e.clientX, e.clientY); }} onpointerup={(e) => { if (!dragging) { diff --git a/apps/web/src/routes/[docId]/[pageId]/editor-state.svelte.ts b/apps/web/src/routes/[docId]/[pageId]/editor-state.svelte.ts index 7d3c838..da45cda 100644 --- a/apps/web/src/routes/[docId]/[pageId]/editor-state.svelte.ts +++ b/apps/web/src/routes/[docId]/[pageId]/editor-state.svelte.ts @@ -178,6 +178,7 @@ export class EditorState { #currentPatch: Patch | null = null; #undoStack = $state([]); #redoStack = $state([]); + #nodeVersion = $state(0); constructor(document: Document, page: Page) { this.#document = $state(document); @@ -197,6 +198,7 @@ export class EditorState { this.#panY; this.#zoom; this.selectedNode; + this.#nodeVersion; /* eslint-enable @typescript-eslint/no-unused-expressions */ this.#measureSelectedNode(); }); @@ -220,6 +222,7 @@ export class EditorState { this.#panY; this.#zoom; this.#hoveredNodeId; + this.#nodeVersion; /* eslint-enable @typescript-eslint/no-unused-expressions */ this.#measureHoveredNode(); }); @@ -320,6 +323,7 @@ export class EditorState { patch.updated[id] = { old, next }; this.#nodes.set(id, next); + this.#nodeVersion++; if (!this.#currentPatch) { this.#undoStack.push(patch); @@ -354,6 +358,7 @@ export class EditorState { for (const [id, { old }] of Object.entries(patch.updated)) { this.#nodes.set(id, old); } + this.#nodeVersion++; this.#redoStack.push(patch); } @@ -371,6 +376,7 @@ export class EditorState { for (const [id, { next }] of Object.entries(patch.updated)) { this.#nodes.set(id, next); } + this.#nodeVersion++; this.#undoStack.push(patch); } diff --git a/apps/web/src/routes/[docId]/[pageId]/selection-overlay.svelte b/apps/web/src/routes/[docId]/[pageId]/selection-overlay.svelte index fca133e..a3ad743 100644 --- a/apps/web/src/routes/[docId]/[pageId]/selection-overlay.svelte +++ b/apps/web/src/routes/[docId]/[pageId]/selection-overlay.svelte @@ -124,6 +124,8 @@ node.type === "text", ); + const isDraggable = $derived(isAbsolute || isAuto); + // -- Auto drag (reorder siblings) -- type SiblingRect = { id: string; midX: number; midY: number }; @@ -300,6 +302,8 @@ (e.currentTarget as HTMLElement).releasePointerCapture(e.pointerId); } + // -- Absolute drag -- + function handleDragStart(e: PointerEvent) { if (!isAbsolute || node.type !== "frame") { return; @@ -341,6 +345,34 @@ (e.currentTarget as HTMLElement).releasePointerCapture(e.pointerId); } + // -- Drag handle (unified for auto + absolute) -- + + const DRAG_HANDLE_SIZE = 16; + + function handleDragHandleDown(e: PointerEvent) { + if (isAuto) { + startAutoDrag(e); + } else if (isAbsolute) { + handleDragStart(e); + } + } + + function handleDragHandleMove(e: PointerEvent) { + if (autoDragging) { + handleAutoDragMove(e); + } else if (dragging) { + handleDragMove(e); + } + } + + function handleDragHandleUp(e: PointerEvent) { + if (autoDragging) { + handleAutoDragEnd(e); + } else if (dragging) { + handleDragEnd(e); + } + } + const HANDLE_SIZE = 8; const EDGE_THICKNESS = 6; @@ -370,19 +402,34 @@ style="left: {overlay.x}px; top: {overlay.y}px; width: {overlay.width}px; height: {overlay.height}px" > -{#if isAuto} - +{#if isDraggable} +
e.stopPropagation()} - onpointerdown={startAutoDrag} - onpointermove={handleAutoDragMove} - onpointerup={handleAutoDragEnd} - >
+ onpointerdown={handleDragHandleDown} + onpointermove={handleDragHandleMove} + onpointerup={handleDragHandleUp} + > + + + + + + + {/if} {#if dropIndicator} @@ -393,21 +440,6 @@ > {/if} -{#if isAbsolute} - - -
e.stopPropagation()} - onpointerdown={handleDragStart} - onpointermove={handleDragMove} - onpointerup={handleDragEnd} - >
-{/if} - {#if node.type === "frame" || node.type === "text"}