feat: default selector tool with marquee multi-select, space+drag pan

Replace single-click-to-pan with selector as default tool. Left-click-drag
on canvas background draws a blue marquee rectangle to select multiple shapes.
Shift/Ctrl+click toggles additive selection. Panning now via Space+drag,
middle-click, or wheel/trackpad (unchanged). Delete/Backspace removes all
selected shapes. folk-shape highlighted state shows blue selection outline.

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
This commit is contained in:
Jeff Emmett 2026-02-27 16:35:31 -08:00
parent 3faf44865e
commit 1d8fc2b23b
2 changed files with 177 additions and 30 deletions

View File

@ -97,11 +97,15 @@ const styles = css`
outline: none;
}
:host(:hover),
:host(:state(highlighted)) {
:host(:hover) {
outline: none;
}
:host(:state(highlighted)) {
outline: 2px solid #3b82f6;
outline-offset: 2px;
}
:host(:state(move)),
:host(:state(rotate)),
:host(:state(resize-top-left)),

View File

@ -442,6 +442,16 @@
overflow: visible;
}
#select-rect {
position: fixed;
border: 1.5px solid #3b82f6;
background: rgba(59, 130, 246, 0.08);
border-radius: 2px;
pointer-events: none;
z-index: 9998;
display: none;
}
/* Touch-friendly resize handles */
@media (pointer: coarse) {
folk-shape::part(resize-top-left),
@ -840,6 +850,7 @@
</div>
<div id="canvas"><div id="canvas-content"></div></div>
<div id="select-rect"></div>
<script type="module">
import {
@ -1168,7 +1179,35 @@
const storedUsername = localStorage.getItem("rspace-username") || `User ${peerId.slice(0, 4)}`;
const presence = new PresenceManager(canvas, peerId, storedUsername);
// Track selected shape for presence sharing
// Track selected shapes for presence sharing and multi-select
let selectedShapeIds = new Set();
function selectShape(id, additive = false) {
if (!additive) selectedShapeIds.clear();
selectedShapeIds.add(id);
updateSelectionVisuals();
}
function deselectAll() {
selectedShapeIds.clear();
updateSelectionVisuals();
}
function updateSelectionVisuals() {
for (const el of canvasContent.children) {
if (el.highlighted !== undefined) {
el.highlighted = selectedShapeIds.has(el.id);
}
}
__miCanvasBridge.setSelection([...selectedShapeIds]);
}
function rectsOverlapScreen(sel, r) {
return !(sel.left > r.right || sel.right < r.left ||
sel.top > r.bottom || sel.bottom < r.top);
}
// Compat alias for presence (uses first selected)
let selectedShapeId = null;
// Throttle cursor updates (send at most every 50ms)
@ -1539,10 +1578,21 @@
}
});
// Track selection for MI bridge
shape.addEventListener("pointerdown", () => {
// Track selection for MI bridge — supports Shift/Ctrl+click multi-select
shape.addEventListener("pointerdown", (e) => {
if (e.shiftKey || e.metaKey || e.ctrlKey) {
// Additive toggle
if (selectedShapeIds.has(shape.id)) {
selectedShapeIds.delete(shape.id);
} else {
selectedShapeIds.add(shape.id);
}
} else if (!selectedShapeIds.has(shape.id)) {
selectedShapeIds.clear();
selectedShapeIds.add(shape.id);
}
selectedShapeId = shape.id;
__miCanvasBridge.setSelection([shape.id]);
updateSelectionVisuals();
});
// Close button
@ -2542,11 +2592,43 @@
updateCanvasTransform();
}, { passive: false });
// Single-finger canvas pan (pointer events on empty background)
// ── Space key tracking for space+drag pan ──
let spaceHeld = false;
document.addEventListener("keydown", (e) => {
if (e.code === "Space" && !e.target.closest("input, textarea, [contenteditable]")) {
e.preventDefault();
spaceHeld = true;
canvas.style.cursor = "grab";
}
});
document.addEventListener("keyup", (e) => {
if (e.code === "Space") {
spaceHeld = false;
if (!isPanning) canvas.style.cursor = "";
}
});
// ── Delete selected shapes ──
document.addEventListener("keydown", (e) => {
if ((e.key === "Delete" || e.key === "Backspace") &&
!e.target.closest("input, textarea, [contenteditable]") &&
selectedShapeIds.size > 0) {
for (const id of selectedShapeIds) {
sync.deleteShape(id);
document.getElementById(id)?.remove();
}
deselectAll();
}
});
// ── Canvas pointer interaction: marquee selection + pan ──
let isPanning = false;
let panPointerId = null;
let panStartX = 0;
let panStartY = 0;
let isSelecting = false;
let selectStartX = 0, selectStartY = 0;
const selectRect = document.getElementById("select-rect");
canvas.addEventListener("pointerdown", (e) => {
if (e.target !== canvas && e.target !== canvasContent) return;
@ -2569,44 +2651,105 @@
return;
}
// Clicking canvas background clears MI selection and exits any editing shape
// Whiteboard tool active → don't select or pan
if (wbTool) return;
// Middle-click or Space held → PAN
if (e.button === 1 || spaceHeld) {
isPanning = true;
panPointerId = e.pointerId;
panStartX = e.clientX;
panStartY = e.clientY;
canvas.setPointerCapture(e.pointerId);
canvas.style.cursor = "grabbing";
return;
}
// Left-click on background → start marquee selection
deselectAll();
selectedShapeId = null;
__miCanvasBridge.setSelection([]);
// Exit edit mode on any currently-editing shape
canvasContent.querySelectorAll("folk-shape, folk-markdown, folk-wrapper, folk-slide, folk-chat, folk-obs-note, folk-rapp, folk-embed, folk-drawfast, folk-prompt, folk-workflow-block").forEach(el => {
if (el.exitEditMode) el.exitEditMode();
});
isPanning = true;
panPointerId = e.pointerId;
panStartX = e.clientX;
panStartY = e.clientY;
isSelecting = true;
selectStartX = e.clientX;
selectStartY = e.clientY;
selectRect.style.display = "block";
selectRect.style.left = e.clientX + "px";
selectRect.style.top = e.clientY + "px";
selectRect.style.width = "0";
selectRect.style.height = "0";
canvas.setPointerCapture(e.pointerId);
canvas.style.cursor = "grabbing";
});
canvas.addEventListener("pointermove", (e) => {
if (!isPanning || e.pointerId !== panPointerId) return;
const dx = e.clientX - panStartX;
const dy = e.clientY - panStartY;
panX += dx;
panY += dy;
panStartX = e.clientX;
panStartY = e.clientY;
updateCanvasTransform();
if (isPanning && e.pointerId === panPointerId) {
const dx = e.clientX - panStartX;
const dy = e.clientY - panStartY;
panX += dx;
panY += dy;
panStartX = e.clientX;
panStartY = e.clientY;
updateCanvasTransform();
return;
}
if (!isSelecting) return;
const x = Math.min(selectStartX, e.clientX);
const y = Math.min(selectStartY, e.clientY);
const w = Math.abs(e.clientX - selectStartX);
const h = Math.abs(e.clientY - selectStartY);
selectRect.style.left = x + "px";
selectRect.style.top = y + "px";
selectRect.style.width = w + "px";
selectRect.style.height = h + "px";
});
canvas.addEventListener("pointerup", (e) => {
if (e.pointerId !== panPointerId) return;
isPanning = false;
panPointerId = null;
canvas.style.cursor = "";
if (isPanning && e.pointerId === panPointerId) {
isPanning = false;
panPointerId = null;
canvas.style.cursor = spaceHeld ? "grab" : "";
return;
}
if (!isSelecting) return;
isSelecting = false;
selectRect.style.display = "none";
// Convert screen rect to find shapes inside
const selRect = {
left: Math.min(selectStartX, e.clientX),
top: Math.min(selectStartY, e.clientY),
right: Math.max(selectStartX, e.clientX),
bottom: Math.max(selectStartY, e.clientY),
};
// If tiny drag (< 4px), treat as a click deselect all (already done)
if (selRect.right - selRect.left < 4 && selRect.bottom - selRect.top < 4) return;
// Hit-test shapes against screen coordinates
for (const el of canvasContent.children) {
if (!el.id || typeof el.x !== "number") continue;
const shapeScreenRect = el.getBoundingClientRect();
if (rectsOverlapScreen(selRect, shapeScreenRect)) {
selectedShapeIds.add(el.id);
}
}
updateSelectionVisuals();
});
canvas.addEventListener("pointercancel", (e) => {
if (e.pointerId !== panPointerId) return;
isPanning = false;
panPointerId = null;
canvas.style.cursor = "";
if (isPanning && e.pointerId === panPointerId) {
isPanning = false;
panPointerId = null;
canvas.style.cursor = "";
}
if (isSelecting) {
isSelecting = false;
selectRect.style.display = "none";
}
});
// Double-click on empty canvas background → quick-draw (pencil) mode