From 4a3eb15f727f73d32400b21ceb2d67d69af28e06 Mon Sep 17 00:00:00 2001 From: Alice Dang Date: Mon, 30 Mar 2026 00:39:15 +0100 Subject: [PATCH 1/2] Add smart alignment guides, snap system, and gap distance reader - Screen dragging with edge/center/equal-size snap alignment guides - Live distance ruler measurements during drag along active guides - Option/Alt + hover gap distance reader between selected and hovered frames - Parent inset mode showing 4-side distances when hovering parent container - Fix selection overlay lag during screen drag by tracking selectedNode in measurement effect Co-Authored-By: Claude Opus 4.6 (1M context) --- .../[docId]/[pageId]/alignment-guides.ts | 226 ++++++++++++++++++ .../[docId]/[pageId]/alignment-overlay.svelte | 109 +++++++++ .../src/routes/[docId]/[pageId]/canvas.svelte | 213 ++++++++++++++++- .../[docId]/[pageId]/editor-state.svelte.ts | 5 +- .../[docId]/[pageId]/gap-measurement.ts | 118 +++++++++ .../[docId]/[pageId]/gap-overlay.svelte | 111 +++++++++ 6 files changed, 774 insertions(+), 8 deletions(-) create mode 100644 apps/web/src/routes/[docId]/[pageId]/alignment-guides.ts create mode 100644 apps/web/src/routes/[docId]/[pageId]/alignment-overlay.svelte create mode 100644 apps/web/src/routes/[docId]/[pageId]/gap-measurement.ts create mode 100644 apps/web/src/routes/[docId]/[pageId]/gap-overlay.svelte diff --git a/apps/web/src/routes/[docId]/[pageId]/alignment-guides.ts b/apps/web/src/routes/[docId]/[pageId]/alignment-guides.ts new file mode 100644 index 0000000..6c51eb0 --- /dev/null +++ b/apps/web/src/routes/[docId]/[pageId]/alignment-guides.ts @@ -0,0 +1,226 @@ +export type ScreenRect = { + id: string; + x: number; + y: number; + width: number; + height: number; +}; + +export type Guide = { + /** 'x' = vertical line at this x-position; 'y' = horizontal line at this y-position */ + axis: "x" | "y"; + position: number; + start: number; + end: number; +}; + +export type DistanceIndicator = { + /** 'x' = horizontal measurement; 'y' = vertical measurement */ + axis: "x" | "y"; + from: number; + to: number; + /** Position on the perpendicular axis (where the label sits) */ + position: number; +}; + +export type AlignResult = { + snappedX: number; + snappedY: number; + guides: Guide[]; + distances: DistanceIndicator[]; +}; + +const SNAP_THRESHOLD = 5; +const GUIDE_PADDING = 20; + +function xEdges(r: ScreenRect): number[] { + return [r.x, r.x + r.width / 2, r.x + r.width]; +} + +function yEdges(r: ScreenRect): number[] { + return [r.y, r.y + r.height / 2, r.y + r.height]; +} + +export function computeAlignment( + dragged: ScreenRect, + others: ScreenRect[], + threshold = SNAP_THRESHOLD, +): AlignResult { + if (others.length === 0) { + return { + snappedX: dragged.x, + snappedY: dragged.y, + guides: [], + distances: [], + }; + } + + // --- Find best X snap (produces vertical guides) --- + let bestXOffset = 0; + let bestXDist = Infinity; + + const dxEdges = xEdges(dragged); + for (const other of others) { + for (const de of dxEdges) { + for (const oe of xEdges(other)) { + const d = Math.abs(de - oe); + if (d < bestXDist) { + bestXDist = d; + bestXOffset = oe - de; + } + } + } + } + + // --- Find best Y snap (produces horizontal guides) --- + let bestYOffset = 0; + let bestYDist = Infinity; + + const dyEdges = yEdges(dragged); + for (const other of others) { + for (const de of dyEdges) { + for (const oe of yEdges(other)) { + const d = Math.abs(de - oe); + if (d < bestYDist) { + bestYDist = d; + bestYOffset = oe - de; + } + } + } + } + + const snapX = bestXDist <= threshold; + const snapY = bestYDist <= threshold; + + const snappedX = snapX ? dragged.x + bestXOffset : dragged.x; + const snappedY = snapY ? dragged.y + bestYOffset : dragged.y; + + const snapped: ScreenRect = { ...dragged, x: snappedX, y: snappedY }; + const guides: Guide[] = []; + const distances: DistanceIndicator[] = []; + + // After snapping, find ALL edges that now align (not just the one that + // triggered the snap). This surfaces center guides and equal-size guides + // automatically — e.g. if two screens share the same height and their tops + // are snapped, the bottom edges also match and get their own guide. + + if (snapX) { + const snappedXEdges = xEdges(snapped); + const seen = new Set(); + for (const de of snappedXEdges) { + for (const o of others) { + if (xEdges(o).some((oe) => Math.abs(de - oe) < 1) && !seen.has(de)) { + seen.add(de); + let minY = snapped.y; + let maxY = snapped.y + snapped.height; + for (const o2 of others) { + if (xEdges(o2).some((e) => Math.abs(e - de) < 1)) { + minY = Math.min(minY, o2.y); + maxY = Math.max(maxY, o2.y + o2.height); + } + } + guides.push({ + axis: "x", + position: de, + start: minY - GUIDE_PADDING, + end: maxY + GUIDE_PADDING, + }); + } + } + } + addDistances(snapped, others, "y", distances); + } + + if (snapY) { + const snappedYEdges = yEdges(snapped); + const seen = new Set(); + for (const de of snappedYEdges) { + for (const o of others) { + if (yEdges(o).some((oe) => Math.abs(de - oe) < 1) && !seen.has(de)) { + seen.add(de); + let minX = snapped.x; + let maxX = snapped.x + snapped.width; + for (const o2 of others) { + if (yEdges(o2).some((e) => Math.abs(e - de) < 1)) { + minX = Math.min(minX, o2.x); + maxX = Math.max(maxX, o2.x + o2.width); + } + } + guides.push({ + axis: "y", + position: de, + start: minX - GUIDE_PADDING, + end: maxX + GUIDE_PADDING, + }); + } + } + } + addDistances(snapped, others, "x", distances); + } + + return { snappedX, snappedY, guides, distances }; +} + +function addDistances( + snapped: ScreenRect, + others: ScreenRect[], + measureAxis: "x" | "y", + out: DistanceIndicator[], +) { + if (measureAxis === "x") { + const sL = snapped.x; + const sR = snapped.x + snapped.width; + const sMidY = snapped.y + snapped.height / 2; + + let closestLeftEdge: number | null = null; + let closestRightEdge: number | null = null; + + for (const o of others) { + if (o.y + o.height <= snapped.y || o.y >= snapped.y + snapped.height) + {continue;} + const oR = o.x + o.width; + const oL = o.x; + if (oR <= sL && (closestLeftEdge === null || oR > closestLeftEdge)) + {closestLeftEdge = oR;} + if (oL >= sR && (closestRightEdge === null || oL < closestRightEdge)) + {closestRightEdge = oL;} + } + + if (closestLeftEdge !== null && sL - closestLeftEdge > 0) { + out.push({ axis: "x", from: closestLeftEdge, to: sL, position: sMidY }); + } + if (closestRightEdge !== null && closestRightEdge - sR > 0) { + out.push({ axis: "x", from: sR, to: closestRightEdge, position: sMidY }); + } + } else { + const sT = snapped.y; + const sB = snapped.y + snapped.height; + const sMidX = snapped.x + snapped.width / 2; + + let closestAboveEdge: number | null = null; + let closestBelowEdge: number | null = null; + + for (const o of others) { + if (o.x + o.width <= snapped.x || o.x >= snapped.x + snapped.width) + {continue;} + const oB = o.y + o.height; + const oT = o.y; + if (oB <= sT && (closestAboveEdge === null || oB > closestAboveEdge)) + {closestAboveEdge = oB;} + if (oT >= sB && (closestBelowEdge === null || oT < closestBelowEdge)) + {closestBelowEdge = oT;} + } + + if (closestAboveEdge !== null && sT - closestAboveEdge > 0) { + out.push({ axis: "y", from: closestAboveEdge, to: sT, position: sMidX }); + } + if (closestBelowEdge !== null && closestBelowEdge - sB > 0) { + out.push({ + axis: "y", + from: sB, + to: closestBelowEdge, + position: sMidX, + }); + } + } +} diff --git a/apps/web/src/routes/[docId]/[pageId]/alignment-overlay.svelte b/apps/web/src/routes/[docId]/[pageId]/alignment-overlay.svelte new file mode 100644 index 0000000..60d98b9 --- /dev/null +++ b/apps/web/src/routes/[docId]/[pageId]/alignment-overlay.svelte @@ -0,0 +1,109 @@ + + + + + {#each result.guides as guide, i (i)} + {#if guide.axis === "x"} + + {:else} + + {/if} + {/each} + + + {#each result.distances as dist, i (i)} + {@const gap = Math.round(dist.to - dist.from)} + {#if dist.axis === "x"} + {@const x1 = sx(dist.from)} + {@const x2 = sx(dist.to)} + {@const y = sy(dist.position)} + {@const midX = (x1 + x2) / 2} + {@const w = labelWidth(gap)} + + + + + + {gap} + {:else} + {@const y1 = sy(dist.from)} + {@const y2 = sy(dist.to)} + {@const x = sx(dist.position)} + {@const midY = (y1 + y2) / 2} + {@const w = labelWidth(gap)} + + + + + + {gap} + {/if} + {/each} + diff --git a/apps/web/src/routes/[docId]/[pageId]/canvas.svelte b/apps/web/src/routes/[docId]/[pageId]/canvas.svelte index f993e08..12b8fad 100644 --- a/apps/web/src/routes/[docId]/[pageId]/canvas.svelte +++ b/apps/web/src/routes/[docId]/[pageId]/canvas.svelte @@ -4,10 +4,19 @@ nodeStyle, paragraphStyle, runStyle, + type ScreenNode, screenStyle, } from "@dashedhq/core"; - import { getEditorState } from "./editor-state.svelte"; + import { + type AlignResult, + computeAlignment, + type ScreenRect, + } from "./alignment-guides"; + import AlignmentOverlay from "./alignment-overlay.svelte"; + import { type CanvasMeasurements, getEditorState } from "./editor-state.svelte"; + import { computeGap, computeInsets, type DocRect, type GapLine } from "./gap-measurement"; + import GapOverlay from "./gap-overlay.svelte"; import SelectionOverlay from "./selection-overlay.svelte"; import TextEditor from "./text-editor.svelte"; @@ -18,6 +27,158 @@ let canvasEl = $state(); + // --- Screen drag state --- + let screenDrag = $state<{ + screenId: string; + rawX: number; + rawY: number; + started: boolean; + pointerId: number; + } | null>(null); + let alignResult = $state(null); + let screenDragJustEnded = false; + + // --- Gap measurement state (Option/Alt + hover) --- + let altHeld = $state(false); + let gapLines = $state([]); + + function toDocRect( + rect: DOMRect, + cm: CanvasMeasurements, + ): DocRect { + return { + x: (rect.x - cm.boundingRect.x - cm.clientLeft - editor.panX) / editor.zoom, + y: (rect.y - cm.boundingRect.y - cm.clientTop - editor.panY) / editor.zoom, + width: rect.width / editor.zoom, + height: rect.height / editor.zoom, + }; + } + + function handleGapMeasure(e: PointerEvent) { + if (!editor.selectedNodeId || !canvasEl || !editor.canvasMeasurements) { + gapLines = []; + return; + } + + const el = document.elementFromPoint(e.clientX, e.clientY); + if (!el) { + gapLines = []; + return; + } + + const targetEl = (el as HTMLElement).closest( + "[data-id]", + ) as HTMLElement | null; + if (!targetEl) { + gapLines = []; + return; + } + + const targetId = targetEl.dataset.id!; + + // Ignore hovering the selected node itself + if (targetId === editor.selectedNodeId) { + gapLines = []; + return; + } + + const selectedEl = canvasEl.querySelector( + `[data-id="${editor.selectedNodeId}"]`, + ) as HTMLElement | null; + if (!selectedEl) { + gapLines = []; + return; + } + + const cm = editor.canvasMeasurements; + const selectedRect = toDocRect(selectedEl.getBoundingClientRect(), cm); + const targetRect = toDocRect(targetEl.getBoundingClientRect(), cm); + + // If target is the parent of the selected node → show insets + const parent = editor.findParent(editor.selectedNodeId); + if (parent && parent.id === targetId) { + gapLines = computeInsets(selectedRect, targetRect); + } else { + gapLines = computeGap(selectedRect, targetRect); + } + } + + function getOtherScreenRects(excludeId: string): ScreenRect[] { + return editor.topLevelNodes + .filter( + (n): n is ScreenNode => n.type === "screen" && n.id !== excludeId, + ) + .map((n) => ({ id: n.id, x: n.x, y: n.y, width: n.width, height: n.height })); + } + + function handleScreenPointerDown(e: PointerEvent, node: ScreenNode) { + if (spaceHeld) {return;} + e.preventDefault(); + e.stopPropagation(); + editor.selectNode(node.id); + screenDrag = { + screenId: node.id, + rawX: node.x, + rawY: node.y, + started: false, + pointerId: e.pointerId, + }; + canvasEl!.setPointerCapture(e.pointerId); + } + + function handleScreenDragMove(e: PointerEvent) { + if (!screenDrag) {return;} + + const dx = e.movementX / editor.zoom; + const dy = e.movementY / editor.zoom; + screenDrag.rawX += dx; + screenDrag.rawY += dy; + + if (!screenDrag.started) { + const screen = editor.getScreen(screenDrag.screenId); + const dist = Math.hypot( + screenDrag.rawX - screen.x, + screenDrag.rawY - screen.y, + ); + if (dist < 2) {return;} + screenDrag.started = true; + editor.beginPatch(); + } + + const screen = editor.getScreen(screenDrag.screenId); + const others = getOtherScreenRects(screenDrag.screenId); + const result = computeAlignment( + { + id: screen.id, + x: screenDrag.rawX, + y: screenDrag.rawY, + width: screen.width, + height: screen.height, + }, + others, + ); + + alignResult = result; + editor.updateScreen(screenDrag.screenId, (s) => { + s.x = Math.round(result.snappedX); + s.y = Math.round(result.snappedY); + }); + } + + function handleScreenDragEnd() { + if (!screenDrag) {return;} + if (screenDrag.started) { + editor.commitPatch(); + screenDragJustEnded = true; + requestAnimationFrame(() => { + screenDragJustEnded = false; + }); + } + canvasEl?.releasePointerCapture(screenDrag.pointerId); + screenDrag = null; + alignResult = null; + } + $effect(() => { if (!canvasEl) { return; @@ -119,6 +280,9 @@ e.preventDefault(); spaceHeld = true; } + if (e.key === "Alt" && !e.repeat) { + altHeld = true; + } if ((e.metaKey || e.ctrlKey) && e.code === "KeyZ") { e.preventDefault(); if (e.shiftKey) { @@ -153,6 +317,10 @@ spaceHeld = false; dragging = false; } + if (e.key === "Alt") { + altHeld = false; + gapLines = []; + } }} /> @@ -164,7 +332,7 @@ class="flex-1 bg-neutral-950 border border-neutral-700 rounded-xl overflow-hidden relative {spaceHeld ? (dragging ? 'cursor-grabbing' : 'cursor-grab') : ''}" onclick={() => { - if (dragging) { + if (dragging || screenDragJustEnded) { return; } editor.clearNodeSelection(); @@ -179,12 +347,25 @@ e.currentTarget.setPointerCapture(e.pointerId); }} onpointermove={(e) => { - if (!dragging) { + if (screenDrag) { + handleScreenDragMove(e); return; } - editor.pan(e.movementX, e.movementY); + if (dragging) { + editor.pan(e.movementX, e.movementY); + return; + } + if (altHeld && editor.selectedNodeId) { + handleGapMeasure(e); + } else if (gapLines.length > 0) { + gapLines = []; + } }} onpointerup={(e) => { + if (screenDrag) { + handleScreenDragEnd(); + return; + } if (!dragging) { return; } @@ -197,19 +378,31 @@ > {#each editor.topLevelNodes as node (node.id)} {#if node.type === "screen"} +
{ + e.stopPropagation(); + if (!screenDrag) {editor.selectNode(node.id);} + }} + onpointerdown={(e) => handleScreenPointerDown(e, node)} > {node.name}
{ e.stopPropagation(); - editor.selectNode(node.id); + if (!screenDrag) {editor.selectNode(node.id);} + }} + onpointerdown={(e) => { + if (e.target === e.currentTarget) { + handleScreenPointerDown(e, node); + } }} > {#each editor.resolveChildren(node.children) as child (child.id)} @@ -220,6 +413,14 @@ {/each}
+ {#if alignResult && screenDrag?.started} + + {/if} + + {#if gapLines.length > 0} + + {/if} + {#if editor.selectedNode && editor.nodeMeasurements && editor.canvasMeasurements} hL && sL < hR; + const overlapY = sB > hT && sT < hB; + + const lines: GapLine[] = []; + + // Horizontal gap + let hFrom: number | undefined; + let hTo: number | undefined; + if (sR <= hL) { + hFrom = sR; + hTo = hL; + } else if (hR <= sL) { + hFrom = hR; + hTo = sL; + } + + if (hFrom !== undefined && hTo !== undefined && hTo > hFrom) { + const posY = overlapY + ? (Math.max(sT, hT) + Math.min(sB, hB)) / 2 + : (sT + sB) / 2; + lines.push({ axis: "x", from: hFrom, to: hTo, position: posY }); + } + + // Vertical gap + let vFrom: number | undefined; + let vTo: number | undefined; + if (sB <= hT) { + vFrom = sB; + vTo = hT; + } else if (hB <= sT) { + vFrom = hB; + vTo = sT; + } + + if (vFrom !== undefined && vTo !== undefined && vTo > vFrom) { + const posX = overlapX + ? (Math.max(sL, hL) + Math.min(sR, hR)) / 2 + : (sL + sR) / 2; + lines.push({ axis: "y", from: vFrom, to: vTo, position: posX }); + } + + return lines; +} + +/** + * Compute inset distances from a child rect to all four sides of its parent. + * Only includes sides where the gap is positive. + */ +export function computeInsets(child: DocRect, parent: DocRect): GapLine[] { + const lines: GapLine[] = []; + const midX = child.x + child.width / 2; + const midY = child.y + child.height / 2; + + const left = child.x - parent.x; + if (left > 0) { + lines.push({ axis: "x", from: parent.x, to: child.x, position: midY }); + } + + const right = parent.x + parent.width - (child.x + child.width); + if (right > 0) { + lines.push({ + axis: "x", + from: child.x + child.width, + to: parent.x + parent.width, + position: midY, + }); + } + + const top = child.y - parent.y; + if (top > 0) { + lines.push({ axis: "y", from: parent.y, to: child.y, position: midX }); + } + + const bottom = parent.y + parent.height - (child.y + child.height); + if (bottom > 0) { + lines.push({ + axis: "y", + from: child.y + child.height, + to: parent.y + parent.height, + position: midX, + }); + } + + return lines; +} diff --git a/apps/web/src/routes/[docId]/[pageId]/gap-overlay.svelte b/apps/web/src/routes/[docId]/[pageId]/gap-overlay.svelte new file mode 100644 index 0000000..fc75754 --- /dev/null +++ b/apps/web/src/routes/[docId]/[pageId]/gap-overlay.svelte @@ -0,0 +1,111 @@ + + + + {#each lines as line, i (i)} + {@const gap = Math.round(line.to - line.from)} + {#if line.axis === "x"} + {@const x1 = sx(line.from)} + {@const x2 = sx(line.to)} + {@const y = sy(line.position)} + {@const midX = (x1 + x2) / 2} + {@const w = labelWidth(gap)} + + + + + + {gap} + {:else} + {@const y1 = sy(line.from)} + {@const y2 = sy(line.to)} + {@const x = sx(line.position)} + {@const midY = (y1 + y2) / 2} + {@const w = labelWidth(gap)} + + + + + + {gap} + {/if} + {/each} + From 342d0b61eeff72101fa5960c0c3db7ec69727a9f Mon Sep 17 00:00:00 2001 From: Alice Dang Date: Mon, 30 Mar 2026 01:15:16 +0100 Subject: [PATCH 2/2] Add resize alignment guides for frames inside stack parents When resizing a frame inside a stack/auto-layout parent, the dragged edge snaps to sibling frame edges with guide lines. Tracks raw (unsnapped) dimensions separately to prevent jitter, queries sibling DOM rects each frame for accurate positions in reflowing layouts. Co-Authored-By: Claude Opus 4.6 (1M context) --- .../[docId]/[pageId]/selection-overlay.svelte | 203 ++++++++++++++++-- 1 file changed, 191 insertions(+), 12 deletions(-) diff --git a/apps/web/src/routes/[docId]/[pageId]/selection-overlay.svelte b/apps/web/src/routes/[docId]/[pageId]/selection-overlay.svelte index 96381ad..e272072 100644 --- a/apps/web/src/routes/[docId]/[pageId]/selection-overlay.svelte +++ b/apps/web/src/routes/[docId]/[pageId]/selection-overlay.svelte @@ -3,6 +3,7 @@ import { clampMin } from "$lib/utils"; + import type { Guide } from "./alignment-guides"; import { type CanvasMeasurements, getEditorState, @@ -18,6 +19,82 @@ let { node, nodeMeasurements, canvasMeasurements }: Props = $props(); const editor = getEditorState(); + // -- Resize snap helpers -- + + const SNAP_THRESHOLD = 5; + const GUIDE_PADDING = 20; + + type Rect = { x: number; y: number; width: number; height: number }; + + function domToDoc(r: DOMRect): Rect { + return { + x: + (r.x - + canvasMeasurements.boundingRect.x - + canvasMeasurements.clientLeft - + editor.panX) / + editor.zoom, + y: + (r.y - + canvasMeasurements.boundingRect.y - + canvasMeasurements.clientTop - + editor.panY) / + editor.zoom, + width: r.width / editor.zoom, + height: r.height / editor.zoom, + }; + } + + function findSnapEdge( + proposed: number, + edges: number[], + ): number | null { + let best: number | null = null; + let bestDist = SNAP_THRESHOLD + 1; + for (const edge of edges) { + const d = Math.abs(proposed - edge); + if (d <= SNAP_THRESHOLD && d < bestDist) { + best = edge; + bestDist = d; + } + } + return best; + } + + function makeGuide( + axis: "x" | "y", + position: number, + nr: Rect, + sibRects: Rect[], + ): Guide { + if (axis === "x") { + let minY = nr.y; + let maxY = nr.y + nr.height; + for (const r of sibRects) { + if ( + Math.abs(r.x - position) < 1 || + Math.abs(r.x + r.width - position) < 1 + ) { + minY = Math.min(minY, r.y); + maxY = Math.max(maxY, r.y + r.height); + } + } + return { axis, position, start: minY - GUIDE_PADDING, end: maxY + GUIDE_PADDING }; + } + let minX = nr.x; + let maxX = nr.x + nr.width; + for (const r of sibRects) { + if ( + Math.abs(r.y - position) < 1 || + Math.abs(r.y + r.height - position) < 1 + ) { + minX = Math.min(minX, r.x); + maxX = Math.max(maxX, r.x + r.width); + } + } + return { axis, position, start: minX - GUIDE_PADDING, end: maxX + GUIDE_PADDING }; + } + const overlay = $derived.by(() => ({ x: nodeMeasurements.boundingRect.x - canvasMeasurements.boundingRect.x - canvasMeasurements.clientLeft, y: nodeMeasurements.boundingRect.y - canvasMeasurements.boundingRect.y - canvasMeasurements.clientTop, @@ -38,6 +115,10 @@ | "bottom-right"; let resizeHandle = $state(null); + let resizeSiblingIds: string[] = []; + let rawWidth = 0; + let rawHeight = 0; + let resizeGuides = $state([]); function resizesRight(h: ResizeHandle): boolean { return h === "right" || h === "top-right" || h === "bottom-right"; @@ -82,6 +163,16 @@ }); } + // Capture raw dimensions and siblings for snap + rawWidth = nodeMeasurements.offsetWidth; + rawHeight = nodeMeasurements.offsetHeight; + const parent = editor.findParent(node.id); + if (parent && (parent.type === "frame" || parent.type === "screen")) { + resizeSiblingIds = parent.children.filter((id) => id !== node.id); + } else { + resizeSiblingIds = []; + } + resizeHandle = handle; editor.beginPatch(); (e.currentTarget as HTMLElement).setPointerCapture(e.pointerId); @@ -94,22 +185,80 @@ const dx = e.movementX / editor.zoom; const dy = e.movementY / editor.zoom; - const { width, height } = node.dimensions; - const size: { width?: Size; height?: Size } = {}; - if (resizesRight(resizeHandle) && width.type === "fixed") { - size.width = { type: "fixed", value: clampMin(width.value + dx, 1) }; - } - if (resizesLeft(resizeHandle) && width.type === "fixed") { - size.width = { type: "fixed", value: clampMin(width.value - dx, 1) }; + // Track raw (unsnapped) dimensions + if (resizesRight(resizeHandle)) {rawWidth += dx;} + if (resizesLeft(resizeHandle)) {rawWidth -= dx;} + if (resizesBottom(resizeHandle)) {rawHeight += dy;} + if (resizesTop(resizeHandle)) {rawHeight -= dy;} + + let snappedWidth = rawWidth; + let snappedHeight = rawHeight; + const newGuides: Guide[] = []; + + // Snap against sibling edges + if (resizeSiblingIds.length > 0) { + const nodeEl = document.querySelector( + `[data-id="${node.id}"]`, + ) as HTMLElement | null; + if (nodeEl) { + const nr = domToDoc(nodeEl.getBoundingClientRect()); + const sibRects: Rect[] = []; + const sibXEdges: number[] = []; + const sibYEdges: number[] = []; + + for (const id of resizeSiblingIds) { + const el = document.querySelector( + `[data-id="${id}"]`, + ) as HTMLElement | null; + if (!el) {continue;} + const r = domToDoc(el.getBoundingClientRect()); + sibRects.push(r); + sibXEdges.push(r.x, r.x + r.width); + sibYEdges.push(r.y, r.y + r.height); + } + + if (resizesRight(resizeHandle)) { + const snap = findSnapEdge(nr.x + rawWidth, sibXEdges); + if (snap) { + snappedWidth = snap - nr.x; + newGuides.push(makeGuide("x", snap, nr, sibRects)); + } + } else if (resizesLeft(resizeHandle)) { + const anchorRight = nr.x + nr.width; + const snap = findSnapEdge(anchorRight - rawWidth, sibXEdges); + if (snap) { + snappedWidth = anchorRight - snap; + newGuides.push(makeGuide("x", snap, nr, sibRects)); + } + } + + if (resizesBottom(resizeHandle)) { + const snap = findSnapEdge(nr.y + rawHeight, sibYEdges); + if (snap) { + snappedHeight = snap - nr.y; + newGuides.push(makeGuide("y", snap, nr, sibRects)); + } + } else if (resizesTop(resizeHandle)) { + const anchorBottom = nr.y + nr.height; + const snap = findSnapEdge(anchorBottom - rawHeight, sibYEdges); + if (snap) { + snappedHeight = anchorBottom - snap; + newGuides.push(makeGuide("y", snap, nr, sibRects)); + } + } + } } - if (resizesBottom(resizeHandle) && height.type === "fixed") { - size.height = { type: "fixed", value: clampMin(height.value + dy, 1) }; + + resizeGuides = newGuides; + + const size: { width?: Size; height?: Size } = {}; + if (resizesX(resizeHandle)) { + size.width = { type: "fixed", value: clampMin(snappedWidth, 1) }; } - if (resizesTop(resizeHandle) && height.type === "fixed") { - size.height = { type: "fixed", value: clampMin(height.value - dy, 1) }; + if (resizesY(resizeHandle)) { + size.height = { type: "fixed", value: clampMin(snappedHeight, 1) }; } - editor.resizeNode(node.id, size); } @@ -118,6 +267,8 @@ return; } resizeHandle = null; + resizeGuides = []; + resizeSiblingIds = []; editor.commitPatch(); (e.currentTarget as HTMLElement).releasePointerCapture(e.pointerId); } @@ -230,3 +381,31 @@ > {/each} {/if} + +{#if resizeGuides.length > 0} + + {#each resizeGuides as guide, i (i)} + {#if guide.axis === "x"} + + {:else} + + {/if} + {/each} + +{/if}