From 1cbee3e4d149f8967c596e4b6e67bbe82fdd869d Mon Sep 17 00:00:00 2001 From: Jeff Emmett Date: Tue, 24 Mar 2026 17:42:50 -0700 Subject: [PATCH] fix(canvas): absolute drag positioning + remove Move Here drop ghosts Replace movementX/Y delta accumulation with absolute mouse-to-shape offset tracking for drift-free drag. Remove drop suggestion overlay system entirely. Co-Authored-By: Claude Opus 4.6 --- lib/folk-shape.ts | 22 ++++++++++++ website/canvas.html | 82 +++------------------------------------------ 2 files changed, 27 insertions(+), 77 deletions(-) diff --git a/lib/folk-shape.ts b/lib/folk-shape.ts index 6fab2b0..53cd4db 100644 --- a/lib/folk-shape.ts +++ b/lib/folk-shape.ts @@ -234,6 +234,7 @@ export class FolkShape extends FolkElement { #startAngle = 0; #lastTouchPos: Point | null = null; #isTouchDragging = false; + #dragOffset: Point | null = null; get x() { return this.#rect.x; @@ -606,6 +607,15 @@ export class FolkShape extends FolkElement { this.#startAngle = Vector.angleFromOrigin(mousePos, parentRotateOrigin) - this.#rect.rotation; } + // Capture offset from mouse to shape origin for absolute drag tracking + if (!handle) { + const mouseInParent = this.#screenToParent(event.clientX, event.clientY); + this.#dragOffset = { + x: mouseInParent.x - this.#rect.x, + y: mouseInParent.y - this.#rect.y, + }; + } + target.addEventListener("pointermove", this); target.addEventListener("lostpointercapture", this); target.setPointerCapture(event.pointerId); @@ -616,6 +626,7 @@ export class FolkShape extends FolkElement { if (event.type === "lostpointercapture") { this.#internals.states.delete(handle || "move"); + this.#dragOffset = null; target.removeEventListener("pointermove", this); target.removeEventListener("lostpointercapture", this); this.#updateCursors(); @@ -638,6 +649,17 @@ export class FolkShape extends FolkElement { }; } else if (event.type === "pointermove") { if (!target) return; + + // For shape body drag, use absolute positioning (no drift) + if ((target === this || isDragHandle) && !handle && this.#dragOffset) { + const mouseInParent = this.#screenToParent(event.clientX, event.clientY); + this.x = mouseInParent.x - this.#dragOffset.x; + this.y = mouseInParent.y - this.#dragOffset.y; + event.preventDefault(); + return; + } + + // For resize/rotate handles, use delta-based movement const zoom = (window.visualViewport?.scale ?? 1) * this.#getParentScale(); moveDelta = { x: event.movementX / zoom, diff --git a/website/canvas.html b/website/canvas.html index 189c088..a8de718 100644 --- a/website/canvas.html +++ b/website/canvas.html @@ -4644,8 +4644,8 @@ Use real coordinates, YYYY-MM-DD dates, ISO currency codes. Ask clarifying quest let activeDragShape = null; let unsnapX = 0, unsnapY = 0; let snapCorrecting = false; - let dropGhostEl = null; - let dropGhostTimeout = null; + + function getSnapTargets(excludeEl) { return [...canvasContent.children] @@ -4768,8 +4768,9 @@ Use real coordinates, YYYY-MM-DD dates, ISO currency codes. Ask clarifying quest // Only snap during moves, not resize/rotate if (dx === 0 && dy === 0) return; - unsnapX += dx; - unsnapY += dy; + // Use the shape's current position as the raw (unsnapped) target + unsnapX = cur.x; + unsnapY = cur.y; const targets = getSnapTargets(shape); const { snapX, snapY, guides } = computeSnaps( @@ -4781,10 +4782,8 @@ Use real coordinates, YYYY-MM-DD dates, ISO currency codes. Ask clarifying quest // Apply snap correction if position differs from where drag placed it if (Math.abs(finalX - cur.x) > 0.1 || Math.abs(finalY - cur.y) > 0.1) { - // Modify the emitted rect so CSS shows snap position this frame cur.x = finalX; cur.y = finalY; - // Update internal state (queues a correction update we'll skip) snapCorrecting = true; shape.x = finalX; shape.y = finalY; @@ -4797,75 +4796,7 @@ Use real coordinates, YYYY-MM-DD dates, ISO currency codes. Ask clarifying quest } }, { capture: true }); - // Drop suggestion helpers - function clearDropGhost() { - if (dropGhostEl) { - dropGhostEl.remove(); - dropGhostEl = null; - } - if (dropGhostTimeout) { - clearTimeout(dropGhostTimeout); - dropGhostTimeout = null; - } - } - function showDropGhost(shape, gx, gy) { - clearDropGhost(); - const ghost = document.createElement("div"); - ghost.style.cssText = ` - position: absolute; - left: ${gx}px; top: ${gy}px; - width: ${shape.width}px; height: ${shape.height}px; - border: 2px dashed ${SNAP_COLOR}; - border-radius: 8px; - pointer-events: auto; - cursor: pointer; - z-index: 4; - display: flex; - align-items: center; - justify-content: center; - font: 12px system-ui; - color: ${SNAP_COLOR}; - background: rgba(20, 184, 166, 0.05); - transition: opacity 0.3s; - `; - ghost.textContent = "Move here?"; - ghost.addEventListener("click", () => { - shape.x = gx; - shape.y = gy; - clearDropGhost(); - }); - canvasContent.appendChild(ghost); - dropGhostEl = ghost; - - // Auto-fade after 3 seconds - dropGhostTimeout = setTimeout(() => { - if (dropGhostEl === ghost) { - ghost.style.opacity = "0"; - setTimeout(() => { if (dropGhostEl === ghost) clearDropGhost(); }, 300); - } - }, 3000); - - // Dismiss on any other canvas click - const dismissHandler = (ev) => { - if (ev.target !== ghost) { - clearDropGhost(); - canvas.removeEventListener("pointerdown", dismissHandler, { capture: true }); - } - }; - canvas.addEventListener("pointerdown", dismissHandler, { capture: true }); - } - - function checkDropSuggestion(shape) { - const shapeRect = { x: shape.x, y: shape.y, width: shape.width, height: shape.height }; - const existing = getExistingShapeRects(shape); - const overlaps = existing.some(e => rectsOverlap(shapeRect, e, 0)); - if (!overlaps) return; - - const center = { x: shape.x + shape.width / 2, y: shape.y + shape.height / 2 }; - const pos = findFreePosition(shape.width, shape.height, center.x, center.y, shape); - showDropGhost(shape, pos.x, pos.y); - } // ── Helpers for converting SVG drawings into folk-shape elements ── @@ -7392,14 +7323,11 @@ Use real coordinates, YYYY-MM-DD dates, ISO currency codes. Ask clarifying quest unsnapX = shape.x; unsnapY = shape.y; snapCorrecting = false; - clearDropGhost(); } function onShapeMoveEnd() { if (!activeDragShape) return; - const shape = activeDragShape; activeDragShape = null; clearSnapGuides(); - checkDropSuggestion(shape); } rwPrev.addEventListener("click", () => {