From 658eb966d654f6609aa90fb74ca456caa17c6d41 Mon Sep 17 00:00:00 2001 From: Jeff Emmett Date: Sun, 1 Mar 2026 17:51:34 -0800 Subject: [PATCH] fix: push overlapping siblings instead of displacing the dragged shape The overlap resolver now moves siblings in the drag direction rather than snapping the dragged shape away from them. Supports chain-pushing (A pushes B into C) with a recursion depth of 3. Co-Authored-By: Claude Opus 4.6 --- lib/folk-shape.ts | 60 ++++++++++++++++++++++++++++++----------------- 1 file changed, 39 insertions(+), 21 deletions(-) diff --git a/lib/folk-shape.ts b/lib/folk-shape.ts index 99ed6cc..d095c18 100644 --- a/lib/folk-shape.ts +++ b/lib/folk-shape.ts @@ -725,19 +725,29 @@ export class FolkShape extends FolkElement { } /** - * After moving, push this shape away from any overlapping siblings. - * Uses minimum penetration depth — picks the smallest displacement - * among all four directions to resolve each overlap. + * After moving, push overlapping siblings out of the way. + * The dragged shape stays where the cursor placed it; + * siblings are displaced in the drag direction. */ - #resolveOverlaps(_dx: number, _dy: number) { + #resolveOverlaps(dx: number, dy: number) { const parent = this.parentElement; if (!parent) return; + this.#pushSiblings(parent, dx, dy, new Set([this]), 3); + } + + /** + * For each sibling overlapping `pusher`, move the sibling out of the way. + * Recurses so chain-pushing works (A pushes B into C → C also moves). + */ + #pushSiblings(parent: Element, dx: number, dy: number, excluded: Set, depth: number) { + if (depth <= 0) return; const gap = FolkShape.GAP; const me = { x: this.x, y: this.y, w: this.width, h: this.height }; for (const sibling of parent.children) { - if (sibling === this || !(sibling instanceof FolkShape)) continue; + if (!(sibling instanceof FolkShape)) continue; + if (excluded.has(sibling)) continue; if (sibling.tagName.toLowerCase() === "folk-arrow") continue; const other = { x: sibling.x, y: sibling.y, w: sibling.width, h: sibling.height }; @@ -745,32 +755,40 @@ export class FolkShape extends FolkElement { // Check overlap (with gap buffer) const overlapX = me.x < other.x + other.w + gap && me.x + me.w + gap > other.x; const overlapY = me.y < other.y + other.h + gap && me.y + me.h + gap > other.y; - if (!overlapX || !overlapY) continue; - // Distance to clear on each side (4 possible escape directions) - const clearRight = (other.x + other.w + gap) - me.x; - const clearLeft = other.x - (me.x + me.w + gap); - const clearDown = (other.y + other.h + gap) - me.y; - const clearUp = other.y - (me.y + me.h + gap); + // How far to push the sibling in each direction to clear the overlap + const pushRight = (me.x + me.w + gap) - other.x; // sibling moves +x + const pushLeft = (me.x) - (other.x + other.w + gap); // sibling moves -x + const pushDown = (me.y + me.h + gap) - other.y; // sibling moves +y + const pushUp = (me.y) - (other.y + other.h + gap); // sibling moves -y - // Pick the direction with smallest absolute displacement const candidates = [ - { axis: "x" as const, d: clearRight }, - { axis: "x" as const, d: clearLeft }, - { axis: "y" as const, d: clearDown }, - { axis: "y" as const, d: clearUp }, + { axis: "x" as const, d: pushRight }, + { axis: "x" as const, d: pushLeft }, + { axis: "y" as const, d: pushDown }, + { axis: "y" as const, d: pushUp }, ]; - const best = candidates.reduce((a, b) => Math.abs(a.d) < Math.abs(b.d) ? a : b); + // Prefer directions aligned with drag movement, break ties by smallest displacement + const best = candidates.reduce((a, b) => { + const aAligned = (a.axis === "x" ? a.d * dx : a.d * dy) > 0; + const bAligned = (b.axis === "x" ? b.d * dx : b.d * dy) > 0; + if (aAligned !== bAligned) return aAligned ? a : b; + return Math.abs(a.d) < Math.abs(b.d) ? a : b; + }); + + // Push the sibling (public setters trigger requestUpdate + transform events) if (best.axis === "x") { - this.#rect.x += best.d; + sibling.x += best.d; } else { - this.#rect.y += best.d; + sibling.y += best.d; } - me.x = this.#rect.x; - me.y = this.#rect.y; + // Recurse: the pushed sibling may now overlap others + const nextExcluded = new Set(excluded); + nextExcluded.add(sibling); + sibling.#pushSiblings(parent, dx, dy, nextExcluded, depth - 1); } }