feat(canvas): add clipboard interop for tldraw/Excalidraw/JSON paste + Ctrl+C copy
CI/CD / deploy (push) Waiting to run Details

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 <noreply@anthropic.com>
This commit is contained in:
Jeff Emmett 2026-04-02 15:08:55 -07:00
parent 87bafc9d74
commit 52e2f77383
1 changed files with 280 additions and 1 deletions

View File

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