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 <noreply@anthropic.com>
This commit is contained in:
Jeff Emmett 2026-03-12 12:11:22 -07:00
parent 357e0bb4c0
commit 1abb29d18f
1 changed files with 72 additions and 37 deletions

View File

@ -3844,6 +3844,17 @@
return; 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; if (e.ctrlKey || e.metaKey || e.altKey) return;
const key = e.key.toLowerCase(); const key = e.key.toLowerCase();
@ -5733,28 +5744,58 @@
}); });
// ── Delete selected shapes (forget, not hard-delete) ── // ── 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) => { document.addEventListener("keydown", (e) => {
if ((e.key === "Delete" || e.key === "Backspace") && if ((e.key === "Delete" || e.key === "Backspace") &&
!e.target.closest("input, textarea, [contenteditable]") && !e.target.closest("input, textarea, [contenteditable]") &&
selectedShapeIds.size > 0) { selectedShapeIds.size > 0) {
const did = getLocalDID(); if (selectedShapeIds.size > 5) {
for (const id of selectedShapeIds) { showBulkDeleteConfirm(selectedShapeIds.size);
const el = document.getElementById(id); } else {
const state = sync.getShapeVisualState(id); doDeleteSelected();
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;
}
} }
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 = `
<h3 style="margin:0 0 0.5rem;font-size:1.125rem">Delete ${count} elements?</h3>
<p style="color:var(--rs-text-secondary,#94a3b8);font-size:0.875rem;margin:0 0 1.25rem;line-height:1.5">You are about to delete a large number of elements. This action cannot be easily undone.</p>
<div style="display:flex;gap:0.75rem;justify-content:center">
<button id="bulk-delete-cancel" style="padding:0.5rem 1.25rem;border-radius:8px;border:1px solid var(--rs-border,#334155);background:transparent;color:var(--rs-text-primary,#e2e8f0);cursor:pointer;font-size:0.875rem">Cancel</button>
<button id="bulk-delete-confirm" style="padding:0.5rem 1.25rem;border-radius:8px;border:1px solid #dc2626;background:#dc2626;color:#fff;cursor:pointer;font-size:0.875rem;font-weight:600">DELETE</button>
</div>`;
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 ── // ── Canvas pointer interaction: pan-first + hold-to-select ──
let isPanning = false; let isPanning = false;
let panPointerId = null; let panPointerId = null;
@ -5764,11 +5805,11 @@
let selectStartX = 0, selectStartY = 0; let selectStartX = 0, selectStartY = 0;
const selectRect = document.getElementById("select-rect"); const selectRect = document.getElementById("select-rect");
// Pan-first interaction state // Select-first interaction state
let interactionMode = "none"; // "none" | "pending" | "pan" | "select" let interactionMode = "none"; // "none" | "pending" | "pan" | "select"
let holdTimer = null; let holdTimer = null;
const HOLD_DELAY = 250; // ms to hold before switching to marquee const HOLD_DELAY = 250; // ms to hold before switching to pan
const PAN_THRESHOLD = 4; // px movement to confirm pan intent const SELECT_THRESHOLD = 4; // px movement to confirm select intent
canvas.addEventListener("pointerdown", (e) => { canvas.addEventListener("pointerdown", (e) => {
if (isTouchPanning) return; // two-finger gesture owns the canvas if (isTouchPanning) return; // two-finger gesture owns the canvas
@ -5828,17 +5869,12 @@
selectStartY = e.clientY; selectStartY = e.clientY;
canvas.setPointerCapture(e.pointerId); 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(() => { holdTimer = setTimeout(() => {
if (interactionMode !== "pending") return; if (interactionMode !== "pending") return;
interactionMode = "select"; interactionMode = "pan";
isSelecting = true; isPanning = true;
selectRect.style.display = "block"; canvas.style.cursor = "grabbing";
selectRect.style.left = selectStartX + "px";
selectRect.style.top = selectStartY + "px";
selectRect.style.width = "0";
selectRect.style.height = "0";
canvas.style.cursor = "crosshair";
}, HOLD_DELAY); }, HOLD_DELAY);
}); });
@ -5857,22 +5893,21 @@
return; return;
} }
// Pending → if moved beyond threshold, commit to pan // Pending → if moved beyond threshold, commit to marquee select
if (interactionMode === "pending") { if (interactionMode === "pending") {
const dx = e.clientX - selectStartX; const dx = e.clientX - selectStartX;
const dy = e.clientY - selectStartY; 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); clearTimeout(holdTimer);
holdTimer = null; holdTimer = null;
interactionMode = "pan"; interactionMode = "select";
isPanning = true; isSelecting = true;
canvas.style.cursor = "grabbing"; selectRect.style.display = "block";
// Apply the initial movement selectRect.style.left = selectStartX + "px";
panX += dx; selectRect.style.top = selectStartY + "px";
panY += dy; selectRect.style.width = "0";
panStartX = e.clientX; selectRect.style.height = "0";
panStartY = e.clientY; canvas.style.cursor = "crosshair";
updateCanvasTransform();
} }
return; return;
} }