feat: make whiteboard drawings selectable, moveable, resizable, deletable

Convert wb-svg drawings from raw SVG overlay elements into folk-shape
web components, giving them full interactivity (select, drag, resize,
rotate, delete, multi-select, keyboard nudge, context menu).

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
This commit is contained in:
Jeff Emmett 2026-03-04 11:56:12 -08:00
parent e7687dfe74
commit ac33a25dae
1 changed files with 118 additions and 30 deletions

View File

@ -1387,6 +1387,7 @@
}
}
folk-shape,
folk-markdown,
folk-wrapper,
folk-arrow,
@ -1428,6 +1429,15 @@
position: absolute;
}
/* Whiteboard drawings rendered as folk-shapes with inline SVG */
folk-shape[data-wb-drawing] > svg {
width: 100%;
height: 100%;
display: block;
overflow: visible;
pointer-events: none;
}
.connect-mode :is(folk-markdown, folk-wrapper, folk-slide, folk-chat,
folk-google-item, folk-piano, folk-embed, folk-calendar, folk-map,
folk-image-gen, folk-video-gen, folk-prompt, folk-zine-gen, folk-transcription,
@ -2907,10 +2917,6 @@
if (document.getElementById(data.id)) {
return;
}
// Check if wb-svg already exists in the overlay
if (data.type === "wb-svg" && wbOverlay.querySelector(`[data-wb-id="${data.id}"]`)) {
return;
}
try {
isProcessingRemote = true;
@ -3201,18 +3207,23 @@
if (data.maxItems) shape.maxItems = data.maxItems;
if (data.refreshInterval) shape.refreshInterval = data.refreshInterval;
break;
case "wb-svg":
// Whiteboard SVG drawing — recreate in SVG overlay, not as a folk-shape
if (data.svgMarkup) {
const temp = document.createElementNS("http://www.w3.org/2000/svg", "g");
temp.innerHTML = data.svgMarkup;
const svgEl = temp.firstElementChild;
if (svgEl) {
svgEl.setAttribute("data-wb-id", data.id);
wbOverlay.appendChild(svgEl);
}
case "wb-svg": {
// Whiteboard SVG drawing — render as a folk-shape with inline SVG
if (!data.svgMarkup) return null;
let vb = data.svgViewBox;
// Old format: x/y/width/height are 0 — compute bounds from SVG
if (!data.width || !data.height || !vb) {
const bounds = computeWbBounds(data.svgMarkup);
if (!bounds) return null;
data.x = bounds.x;
data.y = bounds.y;
data.width = bounds.width;
data.height = bounds.height;
vb = bounds.viewBox;
}
return null; // Not a folk-shape element
shape = createWbShapeElement(data.svgMarkup, vb);
break;
}
case "folk-markdown":
default:
shape = document.createElement("folk-markdown");
@ -3917,12 +3928,57 @@
const wbColor = "#1e293b";
const wbStrokeWidth = 3;
// SVG overlay for whiteboard drawing
// SVG overlay for whiteboard drawing (used during active drawing only)
const wbOverlay = document.createElementNS("http://www.w3.org/2000/svg", "svg");
wbOverlay.id = "wb-overlay";
wbOverlay.style.cssText = "position:absolute;top:0;left:0;width:100%;height:100%;pointer-events:none;z-index:5;overflow:visible;";
canvasContent.appendChild(wbOverlay);
// ── Helpers for converting SVG drawings into folk-shape elements ──
// Compute bounding box of SVG markup by temporarily rendering it
function computeWbBounds(svgMarkup, padding) {
if (padding === undefined) padding = wbStrokeWidth + 2;
const temp = document.createElementNS("http://www.w3.org/2000/svg", "g");
temp.innerHTML = svgMarkup;
const el = temp.firstElementChild;
if (!el) return null;
wbOverlay.appendChild(el);
const bbox = el.getBBox();
el.remove();
if (bbox.width < 1 && bbox.height < 1) return null;
return {
x: bbox.x - padding,
y: bbox.y - padding,
width: bbox.width + padding * 2,
height: bbox.height + padding * 2,
viewBox: `${bbox.x - padding} ${bbox.y - padding} ${bbox.width + padding * 2} ${bbox.height + padding * 2}`,
};
}
// Create a folk-shape element wrapping SVG drawing content
function createWbShapeElement(svgMarkup, viewBox) {
const shape = document.createElement("folk-shape");
shape.dataset.wbDrawing = "true";
const svg = document.createElementNS("http://www.w3.org/2000/svg", "svg");
svg.setAttribute("viewBox", viewBox);
svg.setAttribute("preserveAspectRatio", "none");
svg.innerHTML = svgMarkup;
shape.appendChild(svg);
// Monkey-patch toJSON so CommunitySync persists svgMarkup
shape.toJSON = function() {
return {
type: "wb-svg",
svgMarkup: svgMarkup,
svgViewBox: viewBox,
};
};
return shape;
}
function setWbTool(tool) {
wbTool = wbTool === tool ? null : tool;
@ -3932,9 +3988,15 @@
canvas.style.cursor = "";
}
// Disable shape interaction when whiteboard tool is active
canvasContent.style.pointerEvents = wbTool ? "none" : "";
wbOverlay.style.pointerEvents = wbTool ? "all" : "none";
// Disable shape interaction when drawing tools are active.
// Eraser keeps canvasContent interactive so it can delete wb-drawing shapes.
if (wbTool && wbTool !== "eraser") {
canvasContent.style.pointerEvents = "none";
wbOverlay.style.pointerEvents = "all";
} else {
canvasContent.style.pointerEvents = "";
wbOverlay.style.pointerEvents = wbTool === "eraser" ? "all" : "none";
}
syncBottomToolbar();
}
@ -4237,18 +4299,27 @@
if (!wbDrawing) return;
wbDrawing = false;
// Persist the completed SVG element to Automerge
// Convert the completed SVG drawing into a folk-shape
if (wbPreviewEl) {
const wbId = `wb-${Date.now()}-${++shapeCounter}`;
wbPreviewEl.setAttribute("data-wb-id", wbId);
const svgMarkup = wbPreviewEl.outerHTML;
const bounds = computeWbBounds(svgMarkup);
sync.addShapeData({
type: "wb-svg",
id: wbId,
svgMarkup,
x: 0, y: 0, width: 0, height: 0, rotation: 0,
});
// Remove the temporary preview from the overlay
wbPreviewEl.remove();
if (bounds) {
const wbId = `wb-${Date.now()}-${++shapeCounter}`;
const shape = createWbShapeElement(svgMarkup, bounds.viewBox);
shape.id = wbId;
shape.x = bounds.x;
shape.y = bounds.y;
shape.width = bounds.width;
shape.height = bounds.height;
setupShapeEventListeners(shape);
canvasContent.appendChild(shape);
sync.registerShape(shape);
}
}
wbPreviewEl = null;
@ -4260,8 +4331,7 @@
}
});
// Eraser click fallback — deletion is handled in pointerdown above,
// but catch any clicks that slip through (e.g. keyboard-triggered)
// Eraser click fallback for old SVG overlay elements
wbOverlay.addEventListener("click", (e) => {
if (wbTool !== "eraser") return;
const hit = e.target;
@ -4272,6 +4342,24 @@
}
});
// Eraser for wb-drawing folk-shapes — capturing phase intercepts
// before normal shape interaction (drag/select) kicks in
canvasContent.addEventListener("pointerdown", (e) => {
if (wbTool !== "eraser") return;
const target = e.target.closest?.("[data-wb-drawing]");
if (!target) return;
e.stopPropagation();
e.preventDefault();
const state = sync.getShapeVisualState(target.id);
if (state === "forgotten") {
sync.hardDeleteShape(target.id);
target.remove();
} else {
sync.forgetShape(target.id, getLocalDID());
target.forgotten = true;
}
}, true);
// ── Helper: get local user DID ──
function getLocalDID() {
try {