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} + 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}