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,89 @@ export class EditorState { : null, ); + readonly #displayedHoveredNodeId: string | null = $derived.by(() => { + const deepId = this.#deepHoveredNodeId; + if (!deepId) return null; + if (!this.#modifierHeld) return 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; + } + 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 @@ + + +