marquee box-select on canvas with the Select tool

Click-and-drag on an empty area of the canvas (with the V tool
active) now draws a marching-ants rectangle. On release, every
layer whose bounding box intersects the rectangle is selected;
Shift / Ctrl / Cmd held during the drag adds to the existing
selection instead of replacing it.

Plain clicks (no drag past the 4 px deadzone) still clear the
selection, preserving the previous "click empty area to deselect"
behaviour. Other tools own their own canvas drags (Pen creates
anchors, Hand pans, Shape drags out a primitive, Direct Select
marquees over anchors), so this only kicks in for V.

Shift+click on a layer to add or remove it from the current
selection was already wired — confirmed working through the
existing additive path in startLayerDrag.
This commit is contained in:
thePR0M3TH3AN committed 2026-06-02 09:38:52 -04:00
1 parent eb9aec19d8
commit cd54d8357f
1 file changed
+129 -4
+129 -4
View File
@@ -2517,6 +2517,25 @@
.toolOptions .buttonRow { margin:6px 0 0; }
.toolOptHint { font-size:.7rem; color:var(--muted); margin:4px 0 0; line-height:1.35; }
.penOverlay { position:absolute; inset:0; z-index:998; pointer-events:none; }
/* Marching-ants marquee box drawn while drag-selecting on the
canvas. CSS animation shifts four 1px dashed borders so the
outline reads as moving — same effect as Photoshop / Figma. */
.marqueeSelectBox {
position: absolute;
pointer-events: none;
z-index: 10000;
background:
linear-gradient(90deg, rgba(15,23,42,0.95) 50%, rgba(255,255,255,0.95) 50%) 0 0/8px 1px repeat-x,
linear-gradient(90deg, rgba(15,23,42,0.95) 50%, rgba(255,255,255,0.95) 50%) 0 100%/8px 1px repeat-x,
linear-gradient(0deg, rgba(15,23,42,0.95) 50%, rgba(255,255,255,0.95) 50%) 0 0/1px 8px repeat-y,
linear-gradient(0deg, rgba(15,23,42,0.95) 50%, rgba(255,255,255,0.95) 50%) 100% 0/1px 8px repeat-y;
animation: marqueeAntsMarch 0.6s linear infinite;
}
@keyframes marqueeAntsMarch {
to {
background-position: 8px 0, -8px 100%, 0 -8px, 100% 8px;
}
}
body.tool-drawing .penOverlay { pointer-events:auto; cursor:crosshair; }
/* Direct Selection captures segment + marquee drags too, but
individual anchor / handle dots still set their own cursors. */
@@ -18733,6 +18752,110 @@
setLayerSelection([]);
}
// Marquee drag-select on an empty canvas region. Used by the
// Select (V) tool only — other tools own their canvas drags
// (pen creates anchors, hand pans, shape drags out a box,
// direct-select marquees over anchors). A short click without
// movement keeps the legacy "click empty area to deselect"
// behavior; a real drag draws a marching-ants rectangle and on
// release selects every layer whose bbox intersects it.
// Shift / Ctrl / Cmd held adds to the existing selection
// instead of replacing it.
function attachCanvasMarquee(host, isDesignerLayer) {
if (!host) return;
const MARQUEE_THRESHOLD = 4; // px before a click becomes a drag
host.addEventListener('pointerdown', (event) => {
if (event.button !== 0) return; // left button only
// Bail if the user is on a tool that owns canvas drags.
const tool = (typeof effectiveEditorTool === 'function') ? effectiveEditorTool() : 'select';
if (tool !== 'select') return;
// freeLayer pointerdowns stopPropagation, so reaching here
// means the click missed every layer. But also bail if the
// event originated inside a layer overlay (e.g. selection box).
if (event.target.closest('.freeLayer')) return;
if (event.target.closest('.layerSelectionBox')) return;
const hostRect = host.getBoundingClientRect();
const unitW = isDesignerLayer ? discTemplate.discD : sheetW();
const unitH = isDesignerLayer ? discTemplate.discD : sheetH();
const additive = !!(event.shiftKey || event.ctrlKey || event.metaKey);
const startScreenX = event.clientX, startScreenY = event.clientY;
const startBaseIds = additive ? new Set(selectedLayerIds()) : new Set();
let box = null;
let dragging = false;
try { host.setPointerCapture(event.pointerId); } catch {}
const screenToInch = (sx, sy) => ({
x: (sx - hostRect.left) / hostRect.width * unitW,
y: (sy - hostRect.top) / hostRect.height * unitH,
});
const screenToHostPct = (sx, sy) => ({
x: (sx - hostRect.left) / hostRect.width * 100,
y: (sy - hostRect.top) / hostRect.height * 100,
});
const move = (e) => {
if (!dragging) {
const dx = e.clientX - startScreenX;
const dy = e.clientY - startScreenY;
if (dx * dx + dy * dy < MARQUEE_THRESHOLD * MARQUEE_THRESHOLD) return;
dragging = true;
box = document.createElement('div');
box.className = 'marqueeSelectBox';
host.appendChild(box);
}
const a = screenToHostPct(startScreenX, startScreenY);
const b = screenToHostPct(e.clientX, e.clientY);
const x0 = Math.min(a.x, b.x), x1 = Math.max(a.x, b.x);
const y0 = Math.min(a.y, b.y), y1 = Math.max(a.y, b.y);
box.style.left = `${x0}%`;
box.style.top = `${y0}%`;
box.style.width = `${x1 - x0}%`;
box.style.height = `${y1 - y0}%`;
};
const up = (e) => {
host.removeEventListener('pointermove', move);
host.removeEventListener('pointerup', up);
host.removeEventListener('pointercancel', up);
if (box && box.parentNode) box.parentNode.removeChild(box);
if (!dragging) {
// Plain click on empty canvas → legacy behaviour: deselect.
if (!additive) { clearLayerSelection(); render(); }
return;
}
// Compute marquee rect in inch space + find intersecting layers.
const a = screenToInch(startScreenX, startScreenY);
const b = screenToInch(e.clientX, e.clientY);
const r = {
x: Math.min(a.x, b.x), y: Math.min(a.y, b.y),
w: Math.abs(b.x - a.x), h: Math.abs(b.y - a.y),
};
const hits = state.layers.filter(l => {
if (!l || l.locked || l.hidden || l._generatedDiscLayer) return false;
const onDesigner = l.target === 'designer-disc';
if (onDesigner !== isDesignerLayer) return false;
if (!isDesignerLayer && typeof isLayerForCurrentMode === 'function'
&& !isLayerForCurrentMode(l)) return false;
const lx = Number(l.x) || 0, ly = Number(l.y) || 0;
const lw = Number(l.w) || 0, lh = Number(l.h) || 0;
// AABB intersection — fast and matches Illustrator's
// "loose" marquee behaviour (any touch selects).
return lx < r.x + r.w && lx + lw > r.x && ly < r.y + r.h && ly + lh > r.y;
});
const idsAfter = additive
? Array.from(new Set([...startBaseIds, ...hits.map(l => l.id)]))
: hits.map(l => l.id);
setLayerSelection(idsAfter, idsAfter[idsAfter.length - 1] || '');
render();
};
host.addEventListener('pointermove', move);
host.addEventListener('pointerup', up);
host.addEventListener('pointercancel', up);
});
}
let _layerPanelSelectionAnchor = null;
let _layerPanelDrag = null;
@@ -29964,10 +30087,12 @@
const addTextLayerBtn = $('addTextLayerBtn');
if (addTextLayerBtn) addTextLayerBtn.addEventListener('click', () => addTextLayer());
$('sheet').addEventListener('click', () => {
clearLayerSelection();
render();
});
// Empty-canvas pointerdown: marquee drag-select with the
// Select tool, plain click clears the selection. Layers
// stopPropagation on their own pointerdowns so this only fires
// when the click misses every layer.
attachCanvasMarquee($('sheet'), false);
attachCanvasMarquee($('designerDiscWrap'), true);
// Clicks in the gutter between the sheet edge and the previewShell
// edge also deselect — the existing #sheet handler misses those.
// Layer .freeLayer clicks stopPropagation so they don't bubble up