From 1abb29d18f61318a733ab3baeb23637970900700 Mon Sep 17 00:00:00 2001 From: Jeff Emmett Date: Thu, 12 Mar 2026 12:11:22 -0700 Subject: [PATCH] feat(canvas): drag-to-select, Ctrl+A select all, bulk delete warning - Swap interaction model: click+drag is now marquee selection (was pan), hold-and-drag or space/middle-click is pan - Ctrl+A / Cmd+A selects all visible shapes on the canvas - Deleting more than 5 elements shows a confirmation dialog requiring the user to click DELETE to proceed Co-Authored-By: Claude Opus 4.6 --- website/canvas.html | 109 +++++++++++++++++++++++++++++--------------- 1 file changed, 72 insertions(+), 37 deletions(-) diff --git a/website/canvas.html b/website/canvas.html index deeacda..3b2a950 100644 --- a/website/canvas.html +++ b/website/canvas.html @@ -3844,6 +3844,17 @@ return; } + if ((e.ctrlKey || e.metaKey) && e.key.toLowerCase() === "a") { + e.preventDefault(); + for (const el of canvasContent.children) { + if (el.id && typeof el.x === "number" && !el.forgotten) { + selectedShapeIds.add(el.id); + } + } + updateSelectionVisuals(); + return; + } + if (e.ctrlKey || e.metaKey || e.altKey) return; const key = e.key.toLowerCase(); @@ -5733,28 +5744,58 @@ }); // ── Delete selected shapes (forget, not hard-delete) ── + function doDeleteSelected() { + const did = getLocalDID(); + for (const id of selectedShapeIds) { + const el = document.getElementById(id); + const state = sync.getShapeVisualState(id); + if (state === 'forgotten') { + sync.hardDeleteShape(id); + if (el) el.remove(); + } else { + sync.forgetShape(id, did); + if (el) el.forgotten = true; + } + } + deselectAll(); + } + document.addEventListener("keydown", (e) => { if ((e.key === "Delete" || e.key === "Backspace") && !e.target.closest("input, textarea, [contenteditable]") && selectedShapeIds.size > 0) { - const did = getLocalDID(); - for (const id of selectedShapeIds) { - const el = document.getElementById(id); - const state = sync.getShapeVisualState(id); - if (state === 'forgotten') { - // Already forgotten — hard delete - sync.hardDeleteShape(id); - if (el) el.remove(); - } else { - // Present — forget (fade) - sync.forgetShape(id, did); - if (el) el.forgotten = true; - } + if (selectedShapeIds.size > 5) { + showBulkDeleteConfirm(selectedShapeIds.size); + } else { + doDeleteSelected(); } - deselectAll(); } }); + // ── Bulk delete confirmation dialog ── + function showBulkDeleteConfirm(count) { + const overlay = document.createElement("div"); + overlay.style.cssText = "position:fixed;inset:0;background:rgba(0,0,0,0.6);z-index:10000;display:flex;align-items:center;justify-content:center"; + const dialog = document.createElement("div"); + dialog.style.cssText = "background:var(--rs-bg-surface,#1e1b4b);border:1px solid var(--rs-border,#334155);border-radius:12px;padding:1.5rem 2rem;max-width:380px;text-align:center;color:var(--rs-text-primary,#e2e8f0);font-family:system-ui,sans-serif"; + dialog.innerHTML = ` +

Delete ${count} elements?

+

You are about to delete a large number of elements. This action cannot be easily undone.

+
+ + +
`; + overlay.appendChild(dialog); + document.body.appendChild(overlay); + + overlay.addEventListener("click", (e) => { if (e.target === overlay) { overlay.remove(); } }); + dialog.querySelector("#bulk-delete-cancel").addEventListener("click", () => overlay.remove()); + dialog.querySelector("#bulk-delete-confirm").addEventListener("click", () => { + overlay.remove(); + doDeleteSelected(); + }); + } + // ── Canvas pointer interaction: pan-first + hold-to-select ── let isPanning = false; let panPointerId = null; @@ -5764,11 +5805,11 @@ let selectStartX = 0, selectStartY = 0; const selectRect = document.getElementById("select-rect"); - // Pan-first interaction state + // Select-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 + const HOLD_DELAY = 250; // ms to hold before switching to pan + const SELECT_THRESHOLD = 4; // px movement to confirm select intent canvas.addEventListener("pointerdown", (e) => { if (isTouchPanning) return; // two-finger gesture owns the canvas @@ -5828,17 +5869,12 @@ selectStartY = e.clientY; canvas.setPointerCapture(e.pointerId); - // After HOLD_DELAY ms without significant movement → switch to marquee select + // After HOLD_DELAY ms without significant movement → switch to pan 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"; + interactionMode = "pan"; + isPanning = true; + canvas.style.cursor = "grabbing"; }, HOLD_DELAY); }); @@ -5857,22 +5893,21 @@ return; } - // Pending → if moved beyond threshold, commit to pan + // Pending → if moved beyond threshold, commit to marquee select if (interactionMode === "pending") { const dx = e.clientX - selectStartX; const dy = e.clientY - selectStartY; - if (Math.abs(dx) > PAN_THRESHOLD || Math.abs(dy) > PAN_THRESHOLD) { + if (Math.abs(dx) > SELECT_THRESHOLD || Math.abs(dy) > SELECT_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(); + 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"; } return; }