feat(canvas): add clipboard interop for tldraw/Excalidraw/JSON paste + Ctrl+C copy
CI/CD / deploy (push) Waiting to run
Details
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:
parent
87bafc9d74
commit
52e2f77383
|
|
@ -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() {
|
||||
|
|
|
|||
Loading…
Reference in New Issue