From 8673cab7b8cd365bfe555825a910b11d30830124 Mon Sep 17 00:00:00 2001 From: Alice Dang Date: Mon, 30 Mar 2026 14:42:58 +0100 Subject: [PATCH 1/2] Add hover focus ring and modifier-based parent targeting for canvas frames Adds a visible outline ring when hovering frames and text nodes on the canvas, with Cmd/Ctrl modifier support to promote the hover target to the nearest parent frame. Clicking while the modifier is held selects the promoted parent. Co-Authored-By: Claude Opus 4.6 (1M context) --- .../src/routes/[docId]/[pageId]/canvas.svelte | 51 ++++++++++- .../[docId]/[pageId]/editor-state.svelte.ts | 86 +++++++++++++++++++ .../routes/[docId]/[pageId]/hover-ring.svelte | 28 ++++++ 3 files changed, 162 insertions(+), 3 deletions(-) create mode 100644 apps/web/src/routes/[docId]/[pageId]/hover-ring.svelte diff --git a/apps/web/src/routes/[docId]/[pageId]/canvas.svelte b/apps/web/src/routes/[docId]/[pageId]/canvas.svelte index f993e08..df24e25 100644 --- a/apps/web/src/routes/[docId]/[pageId]/canvas.svelte +++ b/apps/web/src/routes/[docId]/[pageId]/canvas.svelte @@ -8,6 +8,7 @@ } from "@dashedhq/core"; import { getEditorState } from "./editor-state.svelte"; + import HoverRing from "./hover-ring.svelte"; import SelectionOverlay from "./selection-overlay.svelte"; import TextEditor from "./text-editor.svelte"; @@ -61,6 +62,20 @@ } return editor.registerSelectedNodeEl(el); }); + + $effect(() => { + const id = editor.hoveredNodeId; + if (!id || !canvasEl) { + return; + } + const el = canvasEl.querySelector( + `[data-id="${id}"]`, + ) as HTMLElement | null; + if (!el) { + return; + } + return editor.registerHoveredNodeEl(el); + }); {#snippet renderNode(node: Node)} @@ -71,7 +86,11 @@ data-id={node.id} onclick={(e) => { e.stopPropagation(); - editor.selectNode(node.id); + if ((e.metaKey || e.ctrlKey) && editor.hoveredNodeId) { + editor.selectNode(editor.hoveredNodeId); + } else { + editor.selectNode(node.id); + } }} ondblclick={(e) => { e.stopPropagation(); @@ -105,6 +124,9 @@ { + if (e.key === "Meta" || e.key === "Control") { + editor.setModifierHeld(true); + } if (!canvasEl) { return; } @@ -149,11 +171,15 @@ } }} onkeyup={(e) => { + if (e.key === "Meta" || e.key === "Control") { + editor.setModifierHeld(false); + } if (e.code === "Space") { spaceHeld = false; dragging = false; } }} + onblur={() => editor.setModifierHeld(false)} /> @@ -169,6 +195,7 @@ } editor.clearNodeSelection(); }} + onpointerleave={() => editor.clearHover()} onpointerdown={(e) => { if (!spaceHeld) { return; @@ -179,10 +206,21 @@ e.currentTarget.setPointerCapture(e.pointerId); }} onpointermove={(e) => { - if (!dragging) { + if (dragging) { + editor.pan(e.movementX, e.movementY); return; } - editor.pan(e.movementX, e.movementY); + if (spaceHeld) return; + let el = e.target as HTMLElement | null; + while (el && el !== e.currentTarget) { + const id = el.dataset?.id; + if (id && editor.isHoverableNode(id)) { + editor.hoverNode(id); + return; + } + el = el.parentElement; + } + editor.clearHover(); }} onpointerup={(e) => { if (!dragging) { @@ -220,6 +258,13 @@ {/each} + {#if editor.hoveredNodeId && editor.hoveredNodeId !== editor.selectedNodeId && editor.hoveredNodeMeasurements && editor.canvasMeasurements && !spaceHeld} + + {/if} + {#if editor.selectedNode && editor.nodeMeasurements && editor.canvasMeasurements} ; #children: string[]; #selection = $state(null); + #deepHoveredNodeId = $state(null); + #modifierHeld = $state(false); + #hoveredNodeEl: HTMLElement | null = null; + #hoveredNodeMeasurements = $state(null); #canvasMeasurements = $state(null); #nodeEl: HTMLElement | null = null; #nodeMeasurements = $state(null); @@ -124,6 +128,7 @@ export class EditorState { this.#zoom; /* eslint-enable @typescript-eslint/no-unused-expressions */ this.#measureNode(); + this.#measureHoveredNode(); }); } @@ -156,6 +161,87 @@ export class EditorState { : null, ); + readonly #displayedHoveredNodeId: string | null = $derived.by(() => { + const deepId = this.#deepHoveredNodeId; + if (!deepId) return null; + if (!this.#modifierHeld) return deepId; + + const parent = this.#findParent(deepId); + if (parent && parent.type === "frame" && parent.visible) { + return parent.id; + } + return deepId; + }); + + get hoveredNodeId(): string | null { + return this.#displayedHoveredNodeId; + } + + readonly hoveredNode: Node | null = $derived( + this.#displayedHoveredNodeId + ? this.#getNodeOrNull(this.#displayedHoveredNodeId) + : null, + ); + + get hoveredNodeMeasurements(): NodeMeasurements | null { + return this.#hoveredNodeMeasurements; + } + + isHoverableNode(id: string): boolean { + const node = this.#nodes.get(id); + return ( + node !== undefined && + (node.type === "frame" || node.type === "text") && + node.visible + ); + } + + hoverNode(id: string) { + if (this.#deepHoveredNodeId === id) { + return; + } + this.#deepHoveredNodeId = id; + } + + clearHover() { + if (!this.#deepHoveredNodeId) { + return; + } + this.#deepHoveredNodeId = null; + } + + setModifierHeld(held: boolean) { + this.#modifierHeld = held; + } + + #measureHoveredNode() { + if (this.#hoveredNodeEl) { + this.#hoveredNodeMeasurements = { + boundingRect: this.#hoveredNodeEl.getBoundingClientRect(), + offsetWidth: this.#hoveredNodeEl.offsetWidth, + offsetHeight: this.#hoveredNodeEl.offsetHeight, + }; + } + } + + registerHoveredNodeEl(el: HTMLElement): () => void { + this.#hoveredNodeEl = el; + this.#measureHoveredNode(); + + const observer = new ResizeObserver(() => this.#measureHoveredNode()); + observer.observe(el); + + return () => { + observer.disconnect(); + this.#hoveredNodeEl = null; + this.#hoveredNodeMeasurements = null; + }; + } + + #getNodeOrNull(id: string): Node | null { + return this.#nodes.get(id) ?? null; + } + #getNodeOrDie(id: string, fn: string) { const node = this.#nodes.get(id); if (!node) { diff --git a/apps/web/src/routes/[docId]/[pageId]/hover-ring.svelte b/apps/web/src/routes/[docId]/[pageId]/hover-ring.svelte new file mode 100644 index 0000000..24eec20 --- /dev/null +++ b/apps/web/src/routes/[docId]/[pageId]/hover-ring.svelte @@ -0,0 +1,28 @@ + + +
From 72ec96a19dfcffbec80646f4a2fc686ed3fc18c9 Mon Sep 17 00:00:00 2001 From: Alice Dang Date: Mon, 30 Mar 2026 23:09:32 +0100 Subject: [PATCH 2/2] Selection-aware modifier hover: promote from selected frame to parent When Cmd/Ctrl is held and a frame is selected, the hover ring now promotes from the selected frame to its parent rather than from the deepest hovered frame. This allows reliable upward targeting in deeply nested structures where children share identical bounds. Co-Authored-By: Claude Opus 4.6 (1M context) --- apps/web/src/routes/[docId]/[pageId]/editor-state.svelte.ts | 4 +++- 1 file changed, 3 insertions(+), 1 deletion(-) 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 be7b9d1..1eeaa68 100644 --- a/apps/web/src/routes/[docId]/[pageId]/editor-state.svelte.ts +++ b/apps/web/src/routes/[docId]/[pageId]/editor-state.svelte.ts @@ -166,7 +166,9 @@ export class EditorState { if (!deepId) return null; if (!this.#modifierHeld) return deepId; - const parent = this.#findParent(deepId); + // When a frame is selected, promote from the selected frame instead + const referenceId = this.#selection?.nodeId ?? deepId; + const parent = this.#findParent(referenceId); if (parent && parent.type === "frame" && parent.visible) { return parent.id; }