From 45d032cb094d9f8623fa6632e23bdf0dbf0a485f Mon Sep 17 00:00:00 2001 From: xarmian Date: Mon, 8 Jun 2026 20:20:46 -0400 Subject: [PATCH] fix(web): graph node click/dbl-click + Open as real link (TASK-1788) (#725) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit * fix(web): graph node click/dbl-click + Open as real link (TASK-1788) Three interaction fixes for the per-item graph (TASK-1787 follow-up): - Click-to-select and double-click-to-zoom didn't fire. Root cause: the viewport called setPointerCapture on pointerdown, which suppressed the SVG nodes' click/dblclick events. Now drag/capture engages only after the pointer moves past a 4px threshold, so a plain press still produces a node click; a gesture that became a pan suppresses the trailing click via a flag. - Open actions are now real links (controls "Open ↗" and the detail panel "Open item ↗"), so cmd/ctrl-click opens the item in a new tab; a plain click is a normal SvelteKit navigation that closes the drawer via the ?graph URL effect. Replaced the onOpenItem callback prop with an itemHref builder. Parent: PLAN-1780. * fix(web): robust pan/click suppression per Codex review (round 1) - Clear suppressClick when the drag gesture ends (deferred one tick so the trailing click is still suppressed) instead of waiting for the next node click. Fixes a pan ending on empty canvas leaving the flag set and swallowing the next intentional click. - Guard onPointerMove on e.buttons: if the primary button isn't held (press ended off-viewport before a drag engaged, so no pointerup was seen), abort instead of starting a ghost pan when the pointer returns. Parent: PLAN-1780. --- web/src/lib/components/graph/ItemGraph.svelte | 89 +++++++++++++------ .../[collection]/[slug]/+page.svelte | 12 +-- 2 files changed, 71 insertions(+), 30 deletions(-) diff --git a/web/src/lib/components/graph/ItemGraph.svelte b/web/src/lib/components/graph/ItemGraph.svelte index 8c85db95..35c0f828 100644 --- a/web/src/lib/components/graph/ItemGraph.svelte +++ b/web/src/lib/components/graph/ItemGraph.svelte @@ -22,14 +22,15 @@ workspace, focusRef, depth: initialDepth = 2, - onOpenItem + itemHref }: { workspace: string; focusRef: string; depth?: number; - /** Called to open an item — collection is provided so callers can build - * a /{user}/{ws}/{collection}/{ref} URL without a lookup. */ - onOpenItem?: (ref: string, collection?: string) => void; + /** Builds the href for an item — opens are rendered as real links so + * cmd/ctrl-click opens the item in a new tab. Collection is passed so the + * caller can build /{user}/{ws}/{collection}/{ref} without a lookup. */ + itemHref: (ref: string, collection?: string) => string; } = $props(); // ── Fixed layout geometry ──────────────────────────────────────────────────── @@ -415,7 +416,15 @@ const MIN_SCALE = 0.25; const MAX_SCALE = 2.5; + // Drag is engaged only AFTER the pointer moves past a small threshold — a + // plain press (a click/double-click on a node) must not capture the pointer, + // because pointer capture on pointerdown suppresses the node's click/dblclick + // events (which drive selection + zoom). + const DRAG_THRESHOLD = 4; + let maybeDrag = false; let dragging = false; + let suppressClick = false; + let capturedPointerId: number | null = null; let dragStartX = 0; let dragStartY = 0; let dragOriginTx = 0; @@ -442,25 +451,59 @@ // (legend toggles, detail-card actions, error retry) — they sit inside the // viewport, so without this a click on them would also begin a drag. if ((e.target as Element).closest?.('.legend, .detail-card, .state-overlay')) return; - dragging = true; + maybeDrag = true; + dragging = false; + suppressClick = false; dragStartX = e.clientX; dragStartY = e.clientY; dragOriginTx = tx; dragOriginTy = ty; - (e.currentTarget as Element).setPointerCapture(e.pointerId); + // NOTE: no setPointerCapture here — capturing on pointerdown would swallow + // the node's click/dblclick. We capture only once a real drag starts. } function onPointerMove(e: PointerEvent) { - if (!dragging) return; - tx = dragOriginTx + (e.clientX - dragStartX); - ty = dragOriginTy + (e.clientY - dragStartY); + if (!maybeDrag) return; + // If the primary button is no longer held, the press ended off-viewport + // before a drag engaged (no capture yet, so we never got pointerup) — abort + // rather than start a ghost pan with no button down. + if ((e.buttons & 1) === 0) { + maybeDrag = false; + return; + } + const dx = e.clientX - dragStartX; + const dy = e.clientY - dragStartY; + if (!dragging) { + if (Math.hypot(dx, dy) < DRAG_THRESHOLD) return; + dragging = true; + suppressClick = true; // this gesture is a pan, not a click + capturedPointerId = e.pointerId; + try { + (e.currentTarget as Element).setPointerCapture(e.pointerId); + } catch { + // capture unsupported/failed — panning still works while over the viewport + } + } + tx = dragOriginTx + dx; + ty = dragOriginTy + dy; } function onPointerUp(e: PointerEvent) { - if (!dragging) return; - dragging = false; - try { - (e.currentTarget as Element).releasePointerCapture(e.pointerId); - } catch { - // pointer may already be released — ignore. + maybeDrag = false; + if (dragging) { + dragging = false; + if (capturedPointerId !== null) { + try { + (e.currentTarget as Element).releasePointerCapture(capturedPointerId); + } catch { + // pointer may already be released — ignore. + } + capturedPointerId = null; + } + // Suppress the click this pan produces, then clear on the next tick so a + // later genuine click (even one ending on empty canvas) isn't affected. + suppressClick = true; + setTimeout(() => { + suppressClick = false; + }, 0); } } @@ -516,6 +559,7 @@ // opening the item are explicit actions in that panel (so a stray click can't // navigate away). Double click zooms to the node. function onNodeClick(ref: string) { + if (suppressClick) return; // this click concluded a pan gesture — ignore it selectedRef = ref; } @@ -524,18 +568,10 @@ zoomToNode(ref); } - function openItem(ref: string) { - onOpenItem?.(ref, collectionFor(ref)); - } - function focusHere(ref: string) { currentFocus = ref; // re-root the neighborhood on this node } - function openFocused() { - openItem(currentFocus); - } - function backToOrigin() { currentFocus = focusRef; } @@ -646,7 +682,7 @@
- +
Open ↗ @@ -796,7 +832,7 @@ {/if}
- + Open item ↗ {#if sel.ref !== currentFocus} {/if} @@ -896,6 +932,7 @@ color: var(--text-primary); } .open-btn { + display: inline-block; padding: var(--space-1) var(--space-3); font-size: 0.8em; font-weight: 600; @@ -904,6 +941,8 @@ border: none; border-radius: var(--radius); cursor: pointer; + text-decoration: none; + text-align: center; } .open-btn:hover { filter: brightness(1.08); diff --git a/web/src/routes/[username]/[workspace]/[collection]/[slug]/+page.svelte b/web/src/routes/[username]/[workspace]/[collection]/[slug]/+page.svelte index 1cbdd4d2..6cedee3a 100644 --- a/web/src/routes/[username]/[workspace]/[collection]/[slug]/+page.svelte +++ b/web/src/routes/[username]/[workspace]/[collection]/[slug]/+page.svelte @@ -193,10 +193,12 @@ graphLoadError = false; void ensureGraphComp(); } - function openItemFromGraph(ref: string, collection?: string) { - // Navigate to the item without ?graph; the URL-watching effect closes the - // drawer once the new route (no param) takes effect. - goto(`/${username}/${wsSlug}/${collection ?? collSlug}/${ref}`); + // Build an item URL for the graph's open links. Rendered as real in + // ItemGraph so cmd/ctrl-click opens in a new tab; a plain click is a normal + // SvelteKit navigation, and the new route (no ?graph) closes the drawer via + // the URL-watching effect. + function graphItemHref(ref: string, collection?: string): string { + return `/${username}/${wsSlug}/${collection ?? collSlug}/${ref}`; } // ESC closes the graph drawer (only while open — no global listener otherwise). $effect(() => { @@ -3036,7 +3038,7 @@
{:else if ItemGraphComp} {@const Graph = ItemGraphComp} - + {:else}
Loading graph…
{/if}