feat: show resize handles on selected shapes + pan-first canvas navigation
Resize handles now appear when a shape is highlighted/focused, not just during active drag. Canvas left-click+drag now pans by default; hold 250ms then drag for marquee selection. Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
This commit is contained in:
parent
2bbe50991d
commit
7616fe0757
|
|
@ -172,6 +172,8 @@ const styles = css`
|
|||
translate: -100% 0%;
|
||||
}
|
||||
|
||||
:host(:state(highlighted)) :is([part^="resize"], [part^="rotation"]),
|
||||
:host(:focus) :is([part^="resize"], [part^="rotation"]),
|
||||
:host(:state(move)) :is([part^="resize"], [part^="rotation"]),
|
||||
:host(:state(resize-top-left)) :is([part^="resize"], [part^="rotation"]),
|
||||
:host(:state(resize-top-right)) :is([part^="resize"], [part^="rotation"]),
|
||||
|
|
|
|||
|
|
@ -2921,7 +2921,7 @@
|
|||
}
|
||||
});
|
||||
|
||||
// ── Canvas pointer interaction: marquee selection + pan ──
|
||||
// ── Canvas pointer interaction: pan-first + hold-to-select ──
|
||||
let isPanning = false;
|
||||
let panPointerId = null;
|
||||
let panStartX = 0;
|
||||
|
|
@ -2930,6 +2930,12 @@
|
|||
let selectStartX = 0, selectStartY = 0;
|
||||
const selectRect = document.getElementById("select-rect");
|
||||
|
||||
// Pan-first interaction state
|
||||
let interactionMode = "none"; // "none" | "pending" | "pan" | "select"
|
||||
let holdTimer = null;
|
||||
const HOLD_DELAY = 250; // ms to hold before switching to marquee
|
||||
const PAN_THRESHOLD = 4; // px movement to confirm pan intent
|
||||
|
||||
canvas.addEventListener("pointerdown", (e) => {
|
||||
if (e.target !== canvas && e.target !== canvasContent) return;
|
||||
if (connectMode) return;
|
||||
|
|
@ -2954,9 +2960,10 @@
|
|||
// Whiteboard tool active → don't select or pan
|
||||
if (wbTool) return;
|
||||
|
||||
// Middle-click or Space held → PAN
|
||||
// Middle-click or Space held → immediate PAN (no hold delay)
|
||||
if (e.button === 1 || spaceHeld) {
|
||||
isPanning = true;
|
||||
interactionMode = "pan";
|
||||
panPointerId = e.pointerId;
|
||||
panStartX = e.clientX;
|
||||
panStartY = e.clientY;
|
||||
|
|
@ -2965,27 +2972,41 @@
|
|||
return;
|
||||
}
|
||||
|
||||
// Left-click on background → start marquee selection
|
||||
// Left-click on background → start in "pending" mode
|
||||
// Deselect and exit edit modes immediately
|
||||
deselectAll();
|
||||
selectedShapeId = null;
|
||||
// Exit edit mode on any currently-editing shape
|
||||
canvasContent.querySelectorAll("folk-shape, folk-markdown, folk-wrapper, folk-slide, folk-chat, folk-obs-note, folk-rapp, folk-embed, folk-drawfast, folk-prompt, folk-workflow-block").forEach(el => {
|
||||
if (el.exitEditMode) el.exitEditMode();
|
||||
});
|
||||
|
||||
isSelecting = true;
|
||||
interactionMode = "pending";
|
||||
panPointerId = e.pointerId;
|
||||
panStartX = e.clientX;
|
||||
panStartY = e.clientY;
|
||||
selectStartX = e.clientX;
|
||||
selectStartY = e.clientY;
|
||||
selectRect.style.display = "block";
|
||||
selectRect.style.left = e.clientX + "px";
|
||||
selectRect.style.top = e.clientY + "px";
|
||||
selectRect.style.width = "0";
|
||||
selectRect.style.height = "0";
|
||||
canvas.setPointerCapture(e.pointerId);
|
||||
|
||||
// After HOLD_DELAY ms without significant movement → switch to marquee select
|
||||
holdTimer = setTimeout(() => {
|
||||
if (interactionMode !== "pending") return;
|
||||
interactionMode = "select";
|
||||
isSelecting = true;
|
||||
selectRect.style.display = "block";
|
||||
selectRect.style.left = selectStartX + "px";
|
||||
selectRect.style.top = selectStartY + "px";
|
||||
selectRect.style.width = "0";
|
||||
selectRect.style.height = "0";
|
||||
canvas.style.cursor = "crosshair";
|
||||
}, HOLD_DELAY);
|
||||
});
|
||||
|
||||
canvas.addEventListener("pointermove", (e) => {
|
||||
if (isPanning && e.pointerId === panPointerId) {
|
||||
if (e.pointerId !== panPointerId) return;
|
||||
|
||||
// Active pan
|
||||
if (interactionMode === "pan" || isPanning) {
|
||||
const dx = e.clientX - panStartX;
|
||||
const dy = e.clientY - panStartY;
|
||||
panX += dx;
|
||||
|
|
@ -2995,61 +3016,100 @@
|
|||
updateCanvasTransform();
|
||||
return;
|
||||
}
|
||||
if (!isSelecting) return;
|
||||
|
||||
const x = Math.min(selectStartX, e.clientX);
|
||||
const y = Math.min(selectStartY, e.clientY);
|
||||
const w = Math.abs(e.clientX - selectStartX);
|
||||
const h = Math.abs(e.clientY - selectStartY);
|
||||
selectRect.style.left = x + "px";
|
||||
selectRect.style.top = y + "px";
|
||||
selectRect.style.width = w + "px";
|
||||
selectRect.style.height = h + "px";
|
||||
// Pending → if moved beyond threshold, commit to pan
|
||||
if (interactionMode === "pending") {
|
||||
const dx = e.clientX - selectStartX;
|
||||
const dy = e.clientY - selectStartY;
|
||||
if (Math.abs(dx) > PAN_THRESHOLD || Math.abs(dy) > PAN_THRESHOLD) {
|
||||
clearTimeout(holdTimer);
|
||||
holdTimer = null;
|
||||
interactionMode = "pan";
|
||||
isPanning = true;
|
||||
canvas.style.cursor = "grabbing";
|
||||
// Apply the initial movement
|
||||
panX += dx;
|
||||
panY += dy;
|
||||
panStartX = e.clientX;
|
||||
panStartY = e.clientY;
|
||||
updateCanvasTransform();
|
||||
}
|
||||
return;
|
||||
}
|
||||
|
||||
// Active marquee selection
|
||||
if (interactionMode === "select" && isSelecting) {
|
||||
const x = Math.min(selectStartX, e.clientX);
|
||||
const y = Math.min(selectStartY, e.clientY);
|
||||
const w = Math.abs(e.clientX - selectStartX);
|
||||
const h = Math.abs(e.clientY - selectStartY);
|
||||
selectRect.style.left = x + "px";
|
||||
selectRect.style.top = y + "px";
|
||||
selectRect.style.width = w + "px";
|
||||
selectRect.style.height = h + "px";
|
||||
}
|
||||
});
|
||||
|
||||
canvas.addEventListener("pointerup", (e) => {
|
||||
if (isPanning && e.pointerId === panPointerId) {
|
||||
if (e.pointerId !== panPointerId) return;
|
||||
|
||||
const mode = interactionMode;
|
||||
clearTimeout(holdTimer);
|
||||
holdTimer = null;
|
||||
|
||||
if (mode === "pan" || isPanning) {
|
||||
isPanning = false;
|
||||
panPointerId = null;
|
||||
interactionMode = "none";
|
||||
canvas.style.cursor = spaceHeld ? "grab" : "";
|
||||
return;
|
||||
}
|
||||
if (!isSelecting) return;
|
||||
isSelecting = false;
|
||||
selectRect.style.display = "none";
|
||||
|
||||
// Convert screen rect to find shapes inside
|
||||
const selRect = {
|
||||
left: Math.min(selectStartX, e.clientX),
|
||||
top: Math.min(selectStartY, e.clientY),
|
||||
right: Math.max(selectStartX, e.clientX),
|
||||
bottom: Math.max(selectStartY, e.clientY),
|
||||
};
|
||||
if (mode === "select" && isSelecting) {
|
||||
isSelecting = false;
|
||||
interactionMode = "none";
|
||||
panPointerId = null;
|
||||
selectRect.style.display = "none";
|
||||
canvas.style.cursor = "";
|
||||
|
||||
// If tiny drag (< 4px), treat as a click → deselect all (already done)
|
||||
if (selRect.right - selRect.left < 4 && selRect.bottom - selRect.top < 4) return;
|
||||
const selRect = {
|
||||
left: Math.min(selectStartX, e.clientX),
|
||||
top: Math.min(selectStartY, e.clientY),
|
||||
right: Math.max(selectStartX, e.clientX),
|
||||
bottom: Math.max(selectStartY, e.clientY),
|
||||
};
|
||||
|
||||
// Hit-test shapes against screen coordinates
|
||||
for (const el of canvasContent.children) {
|
||||
if (!el.id || typeof el.x !== "number") continue;
|
||||
const shapeScreenRect = el.getBoundingClientRect();
|
||||
if (rectsOverlapScreen(selRect, shapeScreenRect)) {
|
||||
selectedShapeIds.add(el.id);
|
||||
// If tiny drag (< 4px), treat as a click → deselect all (already done)
|
||||
if (selRect.right - selRect.left < 4 && selRect.bottom - selRect.top < 4) return;
|
||||
|
||||
// Hit-test shapes against screen coordinates
|
||||
for (const el of canvasContent.children) {
|
||||
if (!el.id || typeof el.x !== "number") continue;
|
||||
const shapeScreenRect = el.getBoundingClientRect();
|
||||
if (rectsOverlapScreen(selRect, shapeScreenRect)) {
|
||||
selectedShapeIds.add(el.id);
|
||||
}
|
||||
}
|
||||
updateSelectionVisuals();
|
||||
return;
|
||||
}
|
||||
updateSelectionVisuals();
|
||||
|
||||
// "pending" mode with no significant movement → just a click (deselect already done)
|
||||
interactionMode = "none";
|
||||
panPointerId = null;
|
||||
canvas.style.cursor = "";
|
||||
});
|
||||
|
||||
canvas.addEventListener("pointercancel", (e) => {
|
||||
if (isPanning && e.pointerId === panPointerId) {
|
||||
isPanning = false;
|
||||
panPointerId = null;
|
||||
canvas.style.cursor = "";
|
||||
}
|
||||
if (isSelecting) {
|
||||
isSelecting = false;
|
||||
selectRect.style.display = "none";
|
||||
}
|
||||
if (e.pointerId !== panPointerId) return;
|
||||
clearTimeout(holdTimer);
|
||||
holdTimer = null;
|
||||
isPanning = false;
|
||||
isSelecting = false;
|
||||
interactionMode = "none";
|
||||
panPointerId = null;
|
||||
selectRect.style.display = "none";
|
||||
canvas.style.cursor = "";
|
||||
});
|
||||
|
||||
// Double-click on empty canvas background → quick-draw (pencil) mode
|
||||
|
|
|
|||
Loading…
Reference in New Issue