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; outline: none;
} }
:host(:hover), :host(:hover) {
:host(:state(highlighted)) {
outline: none; outline: none;
} }
:host(:state(highlighted)) {
outline: 2px solid #3b82f6;
outline-offset: 2px;
}
:host(:state(move)), :host(:state(move)),
:host(:state(rotate)), :host(:state(rotate)),
:host(:state(resize-top-left)), :host(:state(resize-top-left)),

View File

@ -442,6 +442,16 @@
overflow: visible; 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 */ /* Touch-friendly resize handles */
@media (pointer: coarse) { @media (pointer: coarse) {
folk-shape::part(resize-top-left), folk-shape::part(resize-top-left),
@ -840,6 +850,7 @@
</div> </div>
<div id="canvas"><div id="canvas-content"></div></div> <div id="canvas"><div id="canvas-content"></div></div>
<div id="select-rect"></div>
<script type="module"> <script type="module">
import { import {
@ -1168,7 +1179,35 @@
const storedUsername = localStorage.getItem("rspace-username") || `User ${peerId.slice(0, 4)}`; const storedUsername = localStorage.getItem("rspace-username") || `User ${peerId.slice(0, 4)}`;
const presence = new PresenceManager(canvas, peerId, storedUsername); 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; let selectedShapeId = null;
// Throttle cursor updates (send at most every 50ms) // Throttle cursor updates (send at most every 50ms)
@ -1539,10 +1578,21 @@
} }
}); });
// Track selection for MI bridge // Track selection for MI bridge — supports Shift/Ctrl+click multi-select
shape.addEventListener("pointerdown", () => { 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; selectedShapeId = shape.id;
__miCanvasBridge.setSelection([shape.id]); updateSelectionVisuals();
}); });
// Close button // Close button
@ -2542,11 +2592,43 @@
updateCanvasTransform(); updateCanvasTransform();
}, { passive: false }); }, { 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 isPanning = false;
let panPointerId = null; let panPointerId = null;
let panStartX = 0; let panStartX = 0;
let panStartY = 0; let panStartY = 0;
let isSelecting = false;
let selectStartX = 0, selectStartY = 0;
const selectRect = document.getElementById("select-rect");
canvas.addEventListener("pointerdown", (e) => { canvas.addEventListener("pointerdown", (e) => {
if (e.target !== canvas && e.target !== canvasContent) return; if (e.target !== canvas && e.target !== canvasContent) return;
@ -2569,23 +2651,41 @@
return; return;
} }
// Clicking canvas background clears MI selection and exits any editing shape // Whiteboard tool active → don't select or pan
selectedShapeId = null; if (wbTool) return;
__miCanvasBridge.setSelection([]);
// Exit edit mode on any currently-editing shape // Middle-click or Space held → PAN
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 (e.button === 1 || spaceHeld) {
if (el.exitEditMode) el.exitEditMode();
});
isPanning = true; isPanning = true;
panPointerId = e.pointerId; panPointerId = e.pointerId;
panStartX = e.clientX; panStartX = e.clientX;
panStartY = e.clientY; panStartY = e.clientY;
canvas.setPointerCapture(e.pointerId); canvas.setPointerCapture(e.pointerId);
canvas.style.cursor = "grabbing"; canvas.style.cursor = "grabbing";
return;
}
// Left-click on background → start marquee selection
deselectAll();
selectedShapeId = null;
// 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();
});
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.addEventListener("pointermove", (e) => { canvas.addEventListener("pointermove", (e) => {
if (!isPanning || e.pointerId !== panPointerId) return; if (isPanning && e.pointerId === panPointerId) {
const dx = e.clientX - panStartX; const dx = e.clientX - panStartX;
const dy = e.clientY - panStartY; const dy = e.clientY - panStartY;
panX += dx; panX += dx;
@ -2593,20 +2693,63 @@
panStartX = e.clientX; panStartX = e.clientX;
panStartY = e.clientY; panStartY = e.clientY;
updateCanvasTransform(); 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) => { canvas.addEventListener("pointerup", (e) => {
if (e.pointerId !== panPointerId) return; if (isPanning && e.pointerId === panPointerId) {
isPanning = false; isPanning = false;
panPointerId = null; panPointerId = null;
canvas.style.cursor = ""; 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) => { canvas.addEventListener("pointercancel", (e) => {
if (e.pointerId !== panPointerId) return; if (isPanning && e.pointerId === panPointerId) {
isPanning = false; isPanning = false;
panPointerId = null; panPointerId = null;
canvas.style.cursor = ""; canvas.style.cursor = "";
}
if (isSelecting) {
isSelecting = false;
selectRect.style.display = "none";
}
}); });
// Double-click on empty canvas background → quick-draw (pencil) mode // Double-click on empty canvas background → quick-draw (pencil) mode