From 52e2f7738377c64b82694b0a4ede39e6d785520e Mon Sep 17 00:00:00 2001 From: Jeff Emmett Date: Thu, 2 Apr 2026 15:08:55 -0700 Subject: [PATCH] feat(canvas): add clipboard interop for tldraw/Excalidraw/JSON paste + Ctrl+C copy Enable pasting structured shape data from tldraw, Excalidraw, and generic JSON into the rSpace canvas, with automatic type conversion and viewport centering. Ctrl+C on selected shapes writes rSpace JSON to clipboard for round-trip paste or external consumption. Co-Authored-By: Claude Opus 4.6 --- website/canvas.html | 281 +++++++++++++++++++++++++++++++++++++++++++- 1 file changed, 280 insertions(+), 1 deletion(-) diff --git a/website/canvas.html b/website/canvas.html index bf684db..065d345 100644 --- a/website/canvas.html +++ b/website/canvas.html @@ -4402,6 +4402,196 @@ Use real coordinates, YYYY-MM-DD dates, ISO currency codes. Ask clarifying quest } }); + // ── Canvas clipboard interop helpers ── + + function rand4() { return Math.random().toString(36).slice(2, 6); } + + /** Extract plain text from tldraw's TLRichText (ProseMirror-like doc) */ + function extractTldrawText(richText) { + if (!richText) return ""; + if (typeof richText === "string") return richText; + if (richText.content) { + return richText.content + .map(node => node.content?.map(n => n.text || "").join("") || "") + .join("\n"); + } + return ""; + } + + /** Convert tldraw shapes → rSpace ShapeData[] */ + function convertTldrawShapes(tldrawShapes) { + const out = []; + for (let i = 0; i < tldrawShapes.length; i++) { + const s = tldrawShapes[i]; + const id = `paste-${Date.now()}-${i}-${rand4()}`; + const base = { + id, + x: s.x || 0, + y: s.y || 0, + rotation: s.rotation || 0, + }; + + switch (s.type) { + case "geo": { + const w = s.props?.w || 300; + const h = s.props?.h || 200; + const text = extractTldrawText(s.props?.richText) || s.props?.text || ""; + const geo = s.props?.geo || "rectangle"; + out.push({ ...base, type: "folk-markdown", width: w, height: h, + content: text || `# ${geo.charAt(0).toUpperCase() + geo.slice(1)}\n\nImported from tldraw` }); + break; + } + case "text": { + const text = extractTldrawText(s.props?.richText) || s.props?.text || "Text"; + out.push({ ...base, type: "folk-markdown", width: s.props?.w || 250, height: 100, + content: text }); + break; + } + case "note": { + const text = extractTldrawText(s.props?.richText) || s.props?.text || ""; + out.push({ ...base, type: "folk-markdown", width: 200, height: 200, + content: text || "# Note\n\nImported from tldraw" }); + break; + } + case "arrow": { + out.push({ ...base, type: "folk-arrow", width: 0, height: 0, + color: "#374151" }); + break; + } + case "image": { + out.push({ ...base, type: "folk-image", width: s.props?.w || 400, height: s.props?.h || 300, + src: s.props?.url || s.props?.src || "" }); + break; + } + case "bookmark": { + out.push({ ...base, type: "folk-bookmark", width: 320, height: 200, + url: s.props?.url || "" }); + break; + } + case "embed": { + out.push({ ...base, type: "folk-embed", width: s.props?.w || 480, height: s.props?.h || 360, + src: s.props?.url || "" }); + break; + } + case "frame": { + out.push({ ...base, type: "folk-wrapper", width: s.props?.w || 320, height: s.props?.h || 240, + title: s.props?.name || "Frame" }); + break; + } + default: { + // draw, line, highlight, etc. → placeholder note + out.push({ ...base, type: "folk-markdown", width: 250, height: 100, + content: `# Imported ${s.type}\n\nDrawing element from tldraw` }); + break; + } + } + } + return out; + } + + /** Convert Excalidraw elements → rSpace ShapeData[] */ + function convertExcalidrawElements(elements) { + const out = []; + for (let i = 0; i < elements.length; i++) { + const el = elements[i]; + const id = `paste-${Date.now()}-${i}-${rand4()}`; + const base = { + id, + x: el.x || 0, + y: el.y || 0, + rotation: el.angle || 0, + }; + const w = el.width || 300; + const h = el.height || 200; + + switch (el.type) { + case "rectangle": + case "ellipse": + case "diamond": { + const text = el.text || el.originalText || ""; + out.push({ ...base, type: "folk-markdown", width: w, height: h, + content: text || `# ${el.type.charAt(0).toUpperCase() + el.type.slice(1)}\n\nImported from Excalidraw` }); + break; + } + case "text": { + out.push({ ...base, type: "folk-markdown", width: Math.max(w, 150), height: Math.max(h, 60), + content: el.text || el.originalText || "Text" }); + break; + } + case "arrow": + case "line": { + out.push({ ...base, type: "folk-arrow", width: 0, height: 0, + color: "#374151" }); + break; + } + case "image": { + out.push({ ...base, type: "folk-image", width: w, height: h, src: "" }); + break; + } + case "frame": { + out.push({ ...base, type: "folk-wrapper", width: w, height: h, + title: el.name || "Frame" }); + break; + } + default: { + out.push({ ...base, type: "folk-markdown", width: Math.max(w, 200), height: Math.max(h, 80), + content: `# Imported ${el.type}\n\nElement from Excalidraw` }); + break; + } + } + } + return out; + } + + /** Offset shapes so they're centered on viewport center */ + function centerShapesOnViewport(shapes) { + if (shapes.length === 0) return shapes; + let minX = Infinity, minY = Infinity, maxX = -Infinity, maxY = -Infinity; + for (const s of shapes) { + minX = Math.min(minX, s.x); + minY = Math.min(minY, s.y); + maxX = Math.max(maxX, s.x + (s.width || 0)); + maxY = Math.max(maxY, s.y + (s.height || 0)); + } + const center = getViewportCenter(); + const dx = center.x - (minX + maxX) / 2; + const dy = center.y - (minY + maxY) / 2; + for (const s of shapes) { + s.x += dx; + s.y += dy; + } + return shapes; + } + + /** Materialize an array of ShapeData into canvas elements */ + function materializeShapes(shapes) { + let count = 0; + for (const data of shapes) { + // Skip arrows that have no connected endpoints (disconnected from tldraw/excalidraw) + if (data.type === "folk-arrow" && !data.sourceId && !data.targetId) continue; + const shape = newShapeElement(data); + if (!shape) continue; + shape.x = data.x; + shape.y = data.y; + shape.width = data.width || shape.width || 300; + shape.height = data.height || shape.height || 200; + if (typeof data.rotation === "number") shape.rotation = data.rotation; + // Apply type-specific properties + if (data.content !== undefined && shape.content !== undefined) shape.content = data.content; + if (data.src !== undefined && shape.src !== undefined) shape.src = data.src; + if (data.url !== undefined && shape.url !== undefined) shape.url = data.url; + if (data.title !== undefined && shape.title !== undefined) shape.title = data.title; + if (data.color !== undefined && shape.color !== undefined) shape.color = data.color; + if (data.sourceId) shape.sourceId = data.sourceId; + if (data.targetId) shape.targetId = data.targetId; + setupShapeEventListeners(shape); + canvasContent.appendChild(shape); + sync.registerShape(shape); + count++; + } + return count; + } + // Paste handler — only when not focused on an input/textarea/contenteditable document.addEventListener("paste", (e) => { const el = document.activeElement; @@ -4419,8 +4609,59 @@ Use real coordinates, YYYY-MM-DD dates, ISO currency codes. Ask clarifying quest } } - // 2. Check for text + // 2. Check for structured canvas JSON (tldraw, Excalidraw, rSpace round-trip) const text = (e.clipboardData?.getData("text/plain") || "").trim(); + if (text && (text.startsWith("{") || text.startsWith("["))) { + try { + const obj = JSON.parse(text); + let converted = null; + let source = "clipboard"; + + if (obj.type === "rspace/clipboard" && Array.isArray(obj.shapes)) { + // rSpace round-trip — re-ID shapes to avoid duplicates + converted = obj.shapes.map((s, i) => ({ + ...s, id: `paste-${Date.now()}-${i}-${rand4()}` + })); + source = "rSpace"; + } else if (obj.type === "excalidraw/clipboard" && Array.isArray(obj.elements)) { + converted = convertExcalidrawElements(obj.elements); + source = "Excalidraw"; + } else if (obj.type === "application/tldraw" && Array.isArray(obj.shapes)) { + converted = convertTldrawShapes(obj.shapes); + source = "tldraw"; + } else if (Array.isArray(obj.shapes) && obj.shapes[0]?.props && obj.shapes[0]?.x !== undefined) { + // tldraw-style without explicit type marker + converted = convertTldrawShapes(obj.shapes); + source = "tldraw"; + } else if (Array.isArray(obj) && obj.length > 0 && obj[0].type && obj[0].x !== undefined) { + // Generic JSON array of shape-like objects + converted = obj.map((s, i) => ({ + id: `paste-${Date.now()}-${i}-${rand4()}`, + type: "folk-markdown", + x: s.x || 0, + y: s.y || 0, + width: s.width || 300, + height: s.height || 200, + rotation: s.rotation || 0, + content: s.content || s.text || `# ${s.type || "Shape"}\n\nImported from JSON`, + })); + source = "JSON"; + } + + if (converted && converted.length > 0) { + e.preventDefault(); + centerShapesOnViewport(converted); + const count = materializeShapes(converted); + if (count > 0) { + showToast(`Pasted ${count} shape${count > 1 ? "s" : ""} from ${source}`); + } + return; + } + } catch (_) { + // Not valid JSON — fall through to existing handlers + } + } + if (!text) return; // 3. URL detection @@ -6793,6 +7034,44 @@ Use real coordinates, YYYY-MM-DD dates, ISO currency codes. Ask clarifying quest } }); + // ── Copy selected shapes to clipboard (Ctrl+C) ── + document.addEventListener("copy", (e) => { + if (isInTextInput(e)) return; + if (selectedShapeIds.size === 0) return; + e.preventDefault(); + + const shapes = []; + const idSet = new Set(selectedShapeIds); + + // Collect selected shapes + for (const id of selectedShapeIds) { + const data = sync.doc?.shapes?.[id]; + if (data) shapes.push(JSON.parse(JSON.stringify(data))); + } + + // Also include arrows where both endpoints are in the selection + if (sync.doc?.shapes) { + for (const [id, s] of Object.entries(sync.doc.shapes)) { + if (idSet.has(id)) continue; + if (s.type === "folk-arrow" && s.sourceId && s.targetId + && idSet.has(s.sourceId) && idSet.has(s.targetId)) { + shapes.push(JSON.parse(JSON.stringify(s))); + } + } + } + + if (shapes.length === 0) return; + + const clipboard = { + type: "rspace/clipboard", + version: 1, + shapes + }; + + e.clipboardData.setData("text/plain", JSON.stringify(clipboard)); + showToast(`Copied ${shapes.length} shape${shapes.length > 1 ? "s" : ""}`); + }); + // ── Bulk delete confirmation dialog ── let bulkDeleteOverlay = null; function dismissBulkDelete() {