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:
Jeff Emmett 2026-03-01 10:13:22 -08:00
parent 2bbe50991d
commit 7616fe0757
2 changed files with 111 additions and 49 deletions

View File

@ -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"]),

View File

@ -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