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 <noreply@anthropic.com>
This commit is contained in:
Jeff Emmett 2026-03-24 17:42:50 -07:00
parent e70b40df9a
commit 1cbee3e4d1
2 changed files with 27 additions and 77 deletions

View File

@ -234,6 +234,7 @@ export class FolkShape extends FolkElement {
#startAngle = 0; #startAngle = 0;
#lastTouchPos: Point | null = null; #lastTouchPos: Point | null = null;
#isTouchDragging = false; #isTouchDragging = false;
#dragOffset: Point | null = null;
get x() { get x() {
return this.#rect.x; return this.#rect.x;
@ -606,6 +607,15 @@ export class FolkShape extends FolkElement {
this.#startAngle = Vector.angleFromOrigin(mousePos, parentRotateOrigin) - this.#rect.rotation; 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("pointermove", this);
target.addEventListener("lostpointercapture", this); target.addEventListener("lostpointercapture", this);
target.setPointerCapture(event.pointerId); target.setPointerCapture(event.pointerId);
@ -616,6 +626,7 @@ export class FolkShape extends FolkElement {
if (event.type === "lostpointercapture") { if (event.type === "lostpointercapture") {
this.#internals.states.delete(handle || "move"); this.#internals.states.delete(handle || "move");
this.#dragOffset = null;
target.removeEventListener("pointermove", this); target.removeEventListener("pointermove", this);
target.removeEventListener("lostpointercapture", this); target.removeEventListener("lostpointercapture", this);
this.#updateCursors(); this.#updateCursors();
@ -638,6 +649,17 @@ export class FolkShape extends FolkElement {
}; };
} else if (event.type === "pointermove") { } else if (event.type === "pointermove") {
if (!target) return; 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(); const zoom = (window.visualViewport?.scale ?? 1) * this.#getParentScale();
moveDelta = { moveDelta = {
x: event.movementX / zoom, x: event.movementX / zoom,

View File

@ -4644,8 +4644,8 @@ Use real coordinates, YYYY-MM-DD dates, ISO currency codes. Ask clarifying quest
let activeDragShape = null; let activeDragShape = null;
let unsnapX = 0, unsnapY = 0; let unsnapX = 0, unsnapY = 0;
let snapCorrecting = false; let snapCorrecting = false;
let dropGhostEl = null;
let dropGhostTimeout = null;
function getSnapTargets(excludeEl) { function getSnapTargets(excludeEl) {
return [...canvasContent.children] 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 // Only snap during moves, not resize/rotate
if (dx === 0 && dy === 0) return; if (dx === 0 && dy === 0) return;
unsnapX += dx; // Use the shape's current position as the raw (unsnapped) target
unsnapY += dy; unsnapX = cur.x;
unsnapY = cur.y;
const targets = getSnapTargets(shape); const targets = getSnapTargets(shape);
const { snapX, snapY, guides } = computeSnaps( 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 // 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) { 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.x = finalX;
cur.y = finalY; cur.y = finalY;
// Update internal state (queues a correction update we'll skip)
snapCorrecting = true; snapCorrecting = true;
shape.x = finalX; shape.x = finalX;
shape.y = finalY; shape.y = finalY;
@ -4797,75 +4796,7 @@ Use real coordinates, YYYY-MM-DD dates, ISO currency codes. Ask clarifying quest
} }
}, { capture: true }); }, { 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 ── // ── 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; unsnapX = shape.x;
unsnapY = shape.y; unsnapY = shape.y;
snapCorrecting = false; snapCorrecting = false;
clearDropGhost();
} }
function onShapeMoveEnd() { function onShapeMoveEnd() {
if (!activeDragShape) return; if (!activeDragShape) return;
const shape = activeDragShape;
activeDragShape = null; activeDragShape = null;
clearSnapGuides(); clearSnapGuides();
checkDropSuggestion(shape);
} }
rwPrev.addEventListener("click", () => { rwPrev.addEventListener("click", () => {