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:
parent
e70b40df9a
commit
1cbee3e4d1
|
|
@ -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,
|
||||||
|
|
|
||||||
|
|
@ -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", () => {
|
||||||
|
|
|
||||||
Loading…
Reference in New Issue