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:
parent
3faf44865e
commit
1d8fc2b23b
|
|
@ -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)),
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
Loading…
Reference in New Issue