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:
parent
e7687dfe74
commit
ac33a25dae
|
|
@ -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 {
|
||||
|
|
|
|||
Loading…
Reference in New Issue