Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
34 changes: 34 additions & 0 deletions src/components/CanvasArea.jsx
Original file line number Diff line number Diff line change
@@ -1,6 +1,7 @@
import { COLORS, FONTS, Z_INDEX } from "../styles/theme";
import { DEFAULT_SCREEN_WIDTH, DEFAULT_SCREEN_HEIGHT } from "../constants";
import { copyScreenForFigma, copyScreensForFigma, copyScreensForFigmaEditable, downloadScreenSvg } from "../utils/copyToFigma";
import { copyScreensAsImage } from "../utils/copyAsImage";
import { ScreenNode } from "./ScreenNode";
import { ConnectionLines } from "./ConnectionLines";
import { ConditionalPrompt } from "./ConditionalPrompt";
Expand Down Expand Up @@ -409,6 +410,39 @@ export function CanvasArea({
<div style={{ height: 1, background: COLORS.border, margin: "4px 0" }} />
</>
)}
<button
onClick={async () => {
setGroupContextMenu(null);
try {
const count = await copyScreensAsImage(copyTargetScreens);
if (count && showToast) {
showToast(count > 1
? `${count} screens copied as image`
: "Screen copied as image");
} else if (!count && showToast) {
showToast("No image content to copy");
}
} catch (e) {
if (showToast) showToast(`Copy failed: ${e.message}`);
}
}}
style={{
display: "block",
width: "100%",
padding: "6px 14px",
background: "none",
border: "none",
color: COLORS.text,
fontFamily: FONTS.mono,
fontSize: 12,
textAlign: "left",
cursor: "pointer",
}}
>
{copyTargetIds.length > 1
? `Copy ${copyTargetIds.length} Screens as Image`
: "Copy as Image"}
</button>
{hasFigmaExport && (
<>
<button
Expand Down
113 changes: 113 additions & 0 deletions src/utils/copyAsImage.js
Original file line number Diff line number Diff line change
@@ -0,0 +1,113 @@
import { wireframeToPng } from "./wireframeRenderer";
import { DEFAULT_SCREEN_WIDTH } from "../constants";

function canvasToBlob(canvas) {
return new Promise((resolve) => canvas.toBlob(resolve, "image/png"));
}

function loadImage(src) {
return new Promise((resolve, reject) => {
const img = new Image();
img.onload = () => resolve(img);
img.onerror = reject;
img.src = src;
});
}

/**
* Converts a screen to a drawable Image element, handling all content types.
* Returns null if the screen has no visual content.
*/
async function screenToImage(screen) {
if (screen.imageData) {
return loadImage(screen.imageData);
}
if (screen.svgContent) {
const encoded = btoa(unescape(encodeURIComponent(screen.svgContent)));
return loadImage(`data:image/svg+xml;base64,${encoded}`);
}
if (screen.wireframe) {
const dataUrl = await wireframeToPng(screen.wireframe);
if (!dataUrl) return null;
return loadImage(dataUrl);
}
return null;
}

/**
* Copies one or more screens as a PNG image to the system clipboard.
* For multiple screens, composes them into a single image preserving
* their relative canvas positions.
*
* @param {Array} screens - Array of screen objects
* @returns {Promise<number|false>} Count of screens copied, or false if nothing to copy
*/
export async function copyScreensAsImage(screens) {
if (!screens.length) return false;

// Load all screen images in parallel
const entries = (
await Promise.all(
screens.map(async (s) => {
const img = await screenToImage(s);
return img ? { screen: s, img } : null;
}),
)
).filter(Boolean);

if (!entries.length) return false;

let canvas;

if (entries.length === 1) {
const { img } = entries[0];
canvas = document.createElement("canvas");
canvas.width = img.naturalWidth;
canvas.height = img.naturalHeight;
const ctx = canvas.getContext("2d");
ctx.drawImage(img, 0, 0);
} else {
// Determine scale: ratio of natural image pixels to canvas-display width
const first = entries[0];
const displayW = first.screen.width || DEFAULT_SCREEN_WIDTH;
const scale = first.img.naturalWidth / displayW;

// Bounding box in canvas coordinates
let minX = Infinity, minY = Infinity, maxX = -Infinity, maxY = -Infinity;
for (const { screen, img } of entries) {
const w = screen.width || DEFAULT_SCREEN_WIDTH;
const h = img.naturalHeight / (img.naturalWidth / w);
minX = Math.min(minX, screen.x);
minY = Math.min(minY, screen.y);
maxX = Math.max(maxX, screen.x + w);
maxY = Math.max(maxY, screen.y + h);
}

const totalW = Math.round((maxX - minX) * scale);
const totalH = Math.round((maxY - minY) * scale);

// Safety cap for very large compositions (16384 is common canvas limit)
const maxDim = 16384;
const capScale = Math.min(1, maxDim / Math.max(totalW, totalH));

canvas = document.createElement("canvas");
canvas.width = Math.round(totalW * capScale);
canvas.height = Math.round(totalH * capScale);
const ctx = canvas.getContext("2d");

for (const { screen, img } of entries) {
const dx = (screen.x - minX) * scale * capScale;
const dy = (screen.y - minY) * scale * capScale;
const dw = img.naturalWidth * capScale;
const dh = img.naturalHeight * capScale;
ctx.drawImage(img, dx, dy, dw, dh);
}
}

const blob = await canvasToBlob(canvas);
await navigator.clipboard.write([
new ClipboardItem({ "image/png": blob }),
]);

return entries.length;
}
Loading