Skip to content
Open
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
32 changes: 32 additions & 0 deletions api/download/[id].ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,32 @@
import { createVercelPngStore } from "../../src/png-store.js";

const store = createVercelPngStore();

export async function GET(request: Request) {
const url = new URL(request.url);
const id = url.pathname.split("/").pop();
if (!id || !/^[a-zA-Z0-9]+$/.test(id)) {
return new Response(JSON.stringify({ error: "Invalid ID" }), {
status: 400,
headers: { "Content-Type": "application/json" },
});
}

const entry = await store.load(id);
if (!entry) {
return new Response(JSON.stringify({ error: "Not found or expired" }), {
status: 404,
headers: { "Content-Type": "application/json" },
});
}

await store.delete(id);

return new Response(entry.data, {
headers: {
"Content-Type": "image/png",
"Content-Disposition": `attachment; filename="${entry.filename}"`,
"Cache-Control": "no-store",
},
});
}
10 changes: 9 additions & 1 deletion api/mcp.ts
Original file line number Diff line number Diff line change
@@ -1,14 +1,22 @@
import { createMcpHandler } from "mcp-handler";
import path from "node:path";
import { createVercelStore } from "../src/checkpoint-store.js";
import { createVercelPngStore } from "../src/png-store.js";
import { registerTools } from "../src/server.js";

const store = createVercelStore();
const pngStore = createVercelPngStore();

const baseUrl = process.env.VERCEL_URL
? `https://${process.env.VERCEL_URL}`
: process.env.VERCEL_PROJECT_PRODUCTION_URL
? `https://${process.env.VERCEL_PROJECT_PRODUCTION_URL}`
: "http://localhost:3001";

const mcpHandler = createMcpHandler(
(server) => {
const distDir = path.join(process.cwd(), "dist");
registerTools(server, distDir, store);
registerTools(server, distDir, store, { pngStore, baseUrl });
},
{ serverInfo: { name: "Excalidraw", version: "1.0.0" } },
{ basePath: "", maxDuration: 60, sessionIdGenerator: undefined },
Expand Down
2 changes: 2 additions & 0 deletions src/global.css
Original file line number Diff line number Diff line change
Expand Up @@ -104,11 +104,13 @@ body {
right: 8px;
z-index: 100;
opacity: 0;
pointer-events: none;
transition: opacity 0.2s ease;
}

.main:hover .toolbar {
opacity: 1;
pointer-events: auto;
}

/* Fullscreen button */
Expand Down
26 changes: 24 additions & 2 deletions src/main.ts
Original file line number Diff line number Diff line change
Expand Up @@ -11,6 +11,7 @@ import { StreamableHTTPServerTransport } from "@modelcontextprotocol/sdk/server/
import cors from "cors";
import type { Request, Response } from "express";
import { FileCheckpointStore } from "./checkpoint-store.js";
import { MemoryPngStore } from "./png-store.js";
import { createServer } from "./server.js";

/**
Expand All @@ -20,12 +21,27 @@ import { createServer } from "./server.js";
*/
export async function startStreamableHTTPServer(
createServer: () => McpServer,
pngStore: MemoryPngStore,
): Promise<void> {
const port = parseInt(process.env.PORT ?? "3001", 10);

const app = createMcpExpressApp({ host: "0.0.0.0" });
app.use(cors());

// PNG download endpoint — serves stored PNGs as file downloads
app.get("/download/:id", async (req: Request, res: Response) => {
const id = req.params.id as string;
const entry = await pngStore.load(id);
if (!entry) {
res.status(404).json({ error: "Not found or expired" });
return;
}
await pngStore.delete(id);
res.setHeader("Content-Type", "image/png");
res.setHeader("Content-Disposition", `attachment; filename="${entry.filename}"`);
res.send(entry.data);
});

app.all("/mcp", async (req: Request, res: Response) => {
const server = createServer();
const transport = new StreamableHTTPServerTransport({
Expand Down Expand Up @@ -82,11 +98,17 @@ export async function startStdioServer(

async function main() {
const store = new FileCheckpointStore();
const factory = () => createServer(store);
const pngStore = new MemoryPngStore();

if (process.argv.includes("--stdio")) {
// stdio mode: no HTTP server, save_png falls back to ~/Downloads
const factory = () => createServer(store);
await startStdioServer(factory);
} else {
await startStreamableHTTPServer(factory);
const port = parseInt(process.env.PORT ?? "3001", 10);
const baseUrl = `http://localhost:${port}`;
const factory = () => createServer(store, { pngStore, baseUrl });
await startStreamableHTTPServer(factory, pngStore);
}
}

Expand Down
180 changes: 168 additions & 12 deletions src/mcp-app.tsx
Original file line number Diff line number Diff line change
@@ -1,6 +1,6 @@
import { useApp } from "@modelcontextprotocol/ext-apps/react";
import type { App } from "@modelcontextprotocol/ext-apps";
import { Excalidraw, exportToSvg, convertToExcalidrawElements, restore, CaptureUpdateAction, FONT_FAMILY, serializeAsJSON, MainMenu } from "@excalidraw/excalidraw";
import { Excalidraw, exportToSvg, exportToBlob, convertToExcalidrawElements, restore, CaptureUpdateAction, FONT_FAMILY, serializeAsJSON, MainMenu } from "@excalidraw/excalidraw";
import morphdom from "morphdom";
import { useCallback, useEffect, useRef, useState } from "react";
import { initPencilAudio, playStroke } from "./pencil-audio";
Expand Down Expand Up @@ -122,6 +122,85 @@ const ExternalLinkIcon = () => (
</svg>
);

const DownloadIcon = () => (
<svg width="14" height="14" viewBox="0 0 16 16" fill="none" stroke="currentColor" strokeWidth="1.25" strokeLinecap="round" strokeLinejoin="round">
<path d="M2.667 10v2.667c0 .368.298.666.666.666h9.334a.667.667 0 0 0 .666-.666V10" />
<path d="M8 2.667V10.667" />
<path d="M5.333 8L8 10.667L10.667 8" />
</svg>
);

const CopyIcon = () => (
<svg width="14" height="14" viewBox="0 0 16 16" fill="none" stroke="currentColor" strokeWidth="1.25" strokeLinecap="round" strokeLinejoin="round">
<rect x="5.333" y="5.333" width="8" height="8" rx="1" />
<path d="M2.667 10.667H2a.667.667 0 0 1-.667-.667V2A.667.667 0 0 1 2 1.333h8a.667.667 0 0 1 .667.667v.667" />
</svg>
);

const CheckIcon = () => (
<svg width="14" height="14" viewBox="0 0 16 16" fill="none" stroke="currentColor" strokeWidth="1.5" strokeLinecap="round" strokeLinejoin="round">
<path d="M3.333 8.667L6 11.333L12.667 4.667" />
</svg>
);

async function copyPngToClipboard(elements: any[]): Promise<boolean> {
if (!elements?.length) return false;
// Pass the blob promise directly to ClipboardItem to retain user-activation
// context on Safari (awaiting exportToBlob first would expire it)
const blobPromise = exportToBlob({
elements: elements as any,
appState: { exportBackground: true } as any,
files: null,
});
await navigator.clipboard.write([new ClipboardItem({ "image/png": blobPromise })]);
return true;
}

async function downloadAsPng(elements: any[], app?: App): Promise<{ path?: string } | null> {
if (!elements?.length || !app) return null;
try {
const blob = await exportToBlob({
elements: elements as any,
appState: { exportBackground: true } as any,
files: null,
});

// Convert blob to base64 for upload to server
const reader = new FileReader();
const base64 = await new Promise<string>((resolve, reject) => {
reader.onload = () => {
const result = reader.result as string;
resolve(result.replace(/^data:[^;]+;base64,/, ""));
};
reader.onerror = reject;
reader.readAsDataURL(blob);
});

// Upload to server — returns either a download URL (HTTP mode) or a file path (stdio mode)
const result = await app.callServerTool({
name: "upload_png",
arguments: { data: base64 },
});

if (result.isError) {
app.sendLog({ level: "error", logger: "Excalidraw", data: `upload_png failed: ${JSON.stringify(result.content)}` });
return null;
}

const response = JSON.parse((result.content[0] as any).text);
if (response.url) {
// HTTP mode: open download URL in browser
await app.openLink({ url: response.url });
return {};
}
// stdio mode: file was saved to ~/Downloads
return { path: response.path };
} catch (err) {
if (app) app.sendLog({ level: "error", logger: "Excalidraw", data: `PNG export failed: ${err}` });
return null;
}
}

async function shareToExcalidraw(data: {elements: any[], appState: any, files: any}, app: App) {
try {
if (!data.elements?.length) return;
Expand All @@ -147,6 +226,75 @@ async function shareToExcalidraw(data: {elements: any[], appState: any, files: a
}
}

function PngButton({ getElements, app }: { getElements: () => any[]; app: App }) {
const [state, setState] = useState<"idle" | "saving" | "saved">("idle");
const [savedPath, setSavedPath] = useState<string | null>(null);

const handleClick = async () => {
setState("saving");
setSavedPath(null);
try {
const result = await downloadAsPng(getElements(), app);
if (result) {
setState("saved");
setSavedPath(result.path ?? null);
setTimeout(() => { setState("idle"); setSavedPath(null); }, 3000);
} else {
setState("idle");
}
} catch {
setState("idle");
}
};

const label = state === "saving" ? "Saving…"
: state === "saved" ? (savedPath ? `Saved to Downloads` : "Saved!")
: "PNG";

return (
<button
className="app-button"
style={{ display: "flex", alignItems: "center", gap: 5, width: "auto", padding: "0 10px" }}
onClick={handleClick}
disabled={state === "saving"}
title={savedPath ?? "Save PNG to Downloads"}
>
<DownloadIcon />
<span style={{ fontSize: "0.75rem", fontWeight: 400 }}>{label}</span>
</button>
);
}

function CopyPngButton({ getElements }: { getElements: () => any[] }) {
const [state, setState] = useState<"idle" | "copying" | "copied">("idle");

const handleClick = async () => {
setState("copying");
try {
await copyPngToClipboard(getElements());
setState("copied");
setTimeout(() => setState("idle"), 2000);
} catch {
setState("idle");
}
};

return (
<button
className="app-button"
style={{ display: "flex", alignItems: "center", gap: 5, width: "auto", padding: "0 10px" }}
onClick={handleClick}
disabled={state === "copying"}
title="Copy PNG to clipboard"
>
{state === "copied" ? <CheckIcon /> : <CopyIcon />}
<span style={{ fontSize: "0.75rem", fontWeight: 400 }}>
{state === "copying" ? "Copying…" : state === "copied" ? "Copied!" : "Copy PNG"}
</span>
</button>
);
}

function ShareButton({ onConfirm }: { onConfirm: () => Promise<void> }) {
const [state, setState] = useState<"idle" | "confirm" | "uploading">("idle");

Expand Down Expand Up @@ -842,6 +990,10 @@ export function ExcalidrawAppCore({ app }: { app: App }) {
}}
/>

<CopyPngButton getElements={() => elements} />

<PngButton getElements={() => elements} app={app} />

<button
className="app-button"
onClick={toggleFullscreen}
Expand All @@ -867,17 +1019,21 @@ export function ExcalidrawAppCore({ app }: { app: App }) {
theme="light"
onChange={(els) => onEditorChange(app, els)}
renderTopRightUI={() => (
<ShareButton
onConfirm={async () => {
if (excalidrawApi) {
const elements = excalidrawApi.getSceneElements();
const appState = excalidrawApi.getAppState();
const files = excalidrawApi.getFiles();

await shareToExcalidraw({ elements, appState, files }, app);
}
}}
/>
<>
<ShareButton
onConfirm={async () => {
if (excalidrawApi) {
const elements = excalidrawApi.getSceneElements();
const appState = excalidrawApi.getAppState();
const files = excalidrawApi.getFiles();

await shareToExcalidraw({ elements, appState, files }, app);
}
}}
/>
<CopyPngButton getElements={() => excalidrawApi?.getSceneElements() ?? []} />
<PngButton getElements={() => excalidrawApi?.getSceneElements() ?? []} app={app} />
</>
)}
>
<MainMenu>
Expand Down
Loading