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;
|
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;
|
||||||
}
|
}
|
||||||
|
|
|
||||||
Loading…
Reference in New Issue