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() {