diff --git a/lib/folk-shape.ts b/lib/folk-shape.ts index 69bacb1b..7ee228f4 100644 --- a/lib/folk-shape.ts +++ b/lib/folk-shape.ts @@ -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"]), diff --git a/website/canvas.html b/website/canvas.html index cd129fe0..b380afff 100644 --- a/website/canvas.html +++ b/website/canvas.html @@ -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