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:
parent
357e0bb4c0
commit
1abb29d18f
|
|
@ -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 = `
|
||||
<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 ──
|
||||
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;
|
||||
}
|
||||
|
|
|
|||
Loading…
Reference in New Issue