Skip to content

Commit e805c75

Browse files
committed
feat(desktop): add canvas context tab
Signed-off-by: framethespace <68256458+framethespace@users.noreply.github.com>
1 parent 9938fa4 commit e805c75

21 files changed

Lines changed: 3394 additions & 175 deletions

README.md

Lines changed: 21 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -45,6 +45,27 @@
4545

4646
---
4747

48+
## Recent Desktop Additions
49+
50+
The items in this section are the latest workflow upgrades added on top of the
51+
core Open CoDesign experience, so they stay separate from the baseline repo
52+
feature list below.
53+
54+
- **Pinned `Canvas` tab with full Excalidraw UI** — sketch wireframes, widgets, motion notes, and layout ideas directly in the app before the first generation
55+
- **Canvas-to-model context export** — the canvas is packaged into prompt context automatically as a summary plus SVG exports, with frame-aware exports when the scene is too large
56+
- **Imported canvas images are sent separately too** — reference images dropped into the canvas also show up as standalone chat attachments for clearer model context
57+
- **Visible send confirmation** — the composer and chat now show when canvas context was actually included, so you can confirm it at a glance
58+
- **Canvas autosave and save-on-submit** — rough sketches are persisted per design, with an extra flush when you send a prompt
59+
- **Smart follow-up reuse** — canvas context is only resent on later turns when the canvas changed since the last successful generation
60+
61+
<p align="center">
62+
<img src="./website/Excalidraw-canvas.png" alt="Open CoDesign canvas tab with an Excalidraw wireframe and imported UI references" width="1000" />
63+
</p>
64+
65+
For implementation details, see [`apps/desktop/CANVAS_CONTEXT.md`](./apps/desktop/CANVAS_CONTEXT.md).
66+
67+
---
68+
4869
## What it is
4970

5071
Turn a prompt into a polished prototype, slide deck, or marketing asset, locally, with the model you already use.

apps/desktop/CANVAS_CONTEXT.md

Lines changed: 40 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,40 @@
1+
# Canvas Context
2+
3+
The desktop app now includes a pinned `Canvas` tab beside `Files`. It embeds a
4+
full Excalidraw surface that can be used before the first generation, so users
5+
can sketch wireframes, widgets, animation notes, and layout ideas before asking
6+
the model to build or edit the UI.
7+
8+
## How It Works
9+
10+
- The renderer mounts Excalidraw in `CanvasSketchView.tsx`.
11+
- Canvas state is stored per design through `canvas:v1:*` IPC handlers in
12+
`src/main/canvas-ipc.ts`.
13+
- Scene JSON is persisted under the app user-data directory, alongside a small
14+
list of imported local files.
15+
- Imported images are also surfaced as regular chat attachments so the model
16+
receives both the scene context and the original image files.
17+
18+
## Prompt Context Export
19+
20+
Before generation, the store converts the current scene into prompt attachments:
21+
22+
- `canvas-summary.md` with a compact summary of visible elements and labels
23+
- one `canvas.svg` export for the whole scene, or
24+
- up to four frame-specific SVG exports when Excalidraw frames are present
25+
26+
These artifacts are written to a temp directory and attached automatically when
27+
the canvas contains visible content.
28+
29+
## Current Limitation
30+
31+
The current generation pipeline is still text-first. In practice that means the
32+
model receives SVG and markdown artifacts derived from the Excalidraw scene,
33+
plus any imported source images, rather than true bitmap-vision analysis of the
34+
canvas itself.
35+
36+
## Testing Note
37+
38+
Vitest uses a local Excalidraw shim so renderer tests stay deterministic and do
39+
not depend on the full browser/runtime behavior of the published Excalidraw
40+
bundle.

apps/desktop/package.json

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -18,6 +18,7 @@
1818
"test": "vitest run --passWithNoTests"
1919
},
2020
"dependencies": {
21+
"@excalidraw/excalidraw": "^0.18.1",
2122
"@open-codesign/artifacts": "workspace:*",
2223
"@open-codesign/core": "workspace:*",
2324
"@open-codesign/exporters": "workspace:*",
Lines changed: 143 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,143 @@
1+
import { mkdir, readFile, writeFile } from 'node:fs/promises';
2+
import { basename, join } from 'node:path';
3+
import { LocalInputFile } from '@open-codesign/shared';
4+
import { app, ipcMain } from './electron-runtime';
5+
6+
interface CanvasStatePayload {
7+
sceneJson: string | null;
8+
importedFiles: Array<{
9+
path: string;
10+
name: string;
11+
size: number;
12+
}>;
13+
}
14+
15+
function canvasStateDir(designId: string): string {
16+
return join(app.getPath('userData'), 'canvas-state', designId);
17+
}
18+
19+
function canvasScenePath(designId: string): string {
20+
return join(canvasStateDir(designId), 'scene.excalidraw.json');
21+
}
22+
23+
function canvasImportsPath(designId: string): string {
24+
return join(canvasStateDir(designId), 'imports.json');
25+
}
26+
27+
function canvasExportDir(designId: string): string {
28+
return join(app.getPath('temp'), 'open-codesign-canvas-context', designId);
29+
}
30+
31+
async function readTextIfPresent(path: string): Promise<string | null> {
32+
try {
33+
return await readFile(path, 'utf8');
34+
} catch (error) {
35+
const code = (error as { code?: unknown })?.code;
36+
if (code === 'ENOENT') return null;
37+
throw error;
38+
}
39+
}
40+
41+
function requireSchemaV1(raw: unknown, channel: string): Record<string, unknown> {
42+
if (typeof raw !== 'object' || raw === null) {
43+
throw new Error(`${channel} expects an object payload`);
44+
}
45+
const record = raw as Record<string, unknown>;
46+
if (record['schemaVersion'] !== 1) {
47+
throw new Error(`${channel} requires schemaVersion: 1`);
48+
}
49+
return record;
50+
}
51+
52+
function requireDesignId(record: Record<string, unknown>, channel: string): string {
53+
const designId = record['designId'];
54+
if (typeof designId !== 'string' || designId.trim().length === 0) {
55+
throw new Error(`${channel} requires a non-empty designId`);
56+
}
57+
return designId;
58+
}
59+
60+
function parseImportedFiles(raw: unknown): CanvasStatePayload['importedFiles'] {
61+
if (!Array.isArray(raw)) return [];
62+
return raw
63+
.map((entry) => LocalInputFile.parse(entry))
64+
.map((file) => ({ path: file.path, name: file.name, size: file.size }));
65+
}
66+
67+
function sanitizeFileName(name: string): string {
68+
const clean = basename(name).replace(/[^\w.\-]+/g, '-');
69+
return clean.length > 0 ? clean : 'canvas-context.txt';
70+
}
71+
72+
export function registerCanvasIpc(): void {
73+
ipcMain.handle('canvas:v1:load-state', async (_event: unknown, raw: unknown) => {
74+
const record = requireSchemaV1(raw, 'canvas:v1:load-state');
75+
const designId = requireDesignId(record, 'canvas:v1:load-state');
76+
const [sceneJson, importsJson] = await Promise.all([
77+
readTextIfPresent(canvasScenePath(designId)),
78+
readTextIfPresent(canvasImportsPath(designId)),
79+
]);
80+
81+
let importedFiles: CanvasStatePayload['importedFiles'] = [];
82+
if (importsJson) {
83+
try {
84+
importedFiles = parseImportedFiles(JSON.parse(importsJson));
85+
} catch {
86+
importedFiles = [];
87+
}
88+
}
89+
90+
return {
91+
sceneJson,
92+
importedFiles,
93+
} satisfies CanvasStatePayload;
94+
});
95+
96+
ipcMain.handle('canvas:v1:save-state', async (_event: unknown, raw: unknown) => {
97+
const record = requireSchemaV1(raw, 'canvas:v1:save-state');
98+
const designId = requireDesignId(record, 'canvas:v1:save-state');
99+
const sceneJson = record['sceneJson'];
100+
if (sceneJson !== null && typeof sceneJson !== 'string') {
101+
throw new Error('canvas:v1:save-state requires sceneJson to be a string or null');
102+
}
103+
104+
const importedFiles = parseImportedFiles(record['importedFiles']);
105+
await mkdir(canvasStateDir(designId), { recursive: true });
106+
await Promise.all([
107+
writeFile(canvasScenePath(designId), sceneJson ?? '', 'utf8'),
108+
writeFile(canvasImportsPath(designId), JSON.stringify(importedFiles, null, 2), 'utf8'),
109+
]);
110+
return { ok: true as const };
111+
});
112+
113+
ipcMain.handle('canvas:v1:write-context-files', async (_event: unknown, raw: unknown) => {
114+
const record = requireSchemaV1(raw, 'canvas:v1:write-context-files');
115+
const designId = requireDesignId(record, 'canvas:v1:write-context-files');
116+
const files = record['files'];
117+
if (!Array.isArray(files)) {
118+
throw new Error('canvas:v1:write-context-files requires files[]');
119+
}
120+
await mkdir(canvasExportDir(designId), { recursive: true });
121+
const stamp = Date.now().toString(36);
122+
const written = await Promise.all(
123+
files.map(async (entry, index) => {
124+
if (typeof entry !== 'object' || entry === null) {
125+
throw new Error('canvas:v1:write-context-files received an invalid file entry');
126+
}
127+
const file = entry as Record<string, unknown>;
128+
const name = sanitizeFileName(
129+
typeof file['name'] === 'string' ? file['name'] : `canvas-context-${index + 1}.txt`,
130+
);
131+
const content = typeof file['content'] === 'string' ? file['content'] : '';
132+
const path = join(canvasExportDir(designId), `${stamp}-${index + 1}-${name}`);
133+
await writeFile(path, content, 'utf8');
134+
return LocalInputFile.parse({
135+
path,
136+
name,
137+
size: Buffer.byteLength(content, 'utf8'),
138+
});
139+
}),
140+
);
141+
return written;
142+
});
143+
}
Lines changed: 136 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,136 @@
1+
import '@excalidraw/excalidraw/index.css';
2+
import { Excalidraw, serializeAsJSON } from '@excalidraw/excalidraw';
3+
import type { ExcalidrawElement } from '@excalidraw/excalidraw/element/types';
4+
import type { LocalInputFile } from '@open-codesign/shared';
5+
import type { ComponentProps } from 'react';
6+
import { useEffect, useMemo, useRef } from 'react';
7+
import { useCodesignStore } from '../store';
8+
9+
type AppState = Parameters<typeof serializeAsJSON>[1];
10+
type BinaryFiles = Parameters<typeof serializeAsJSON>[2];
11+
type ExcalidrawImperativeAPI = Parameters<
12+
NonNullable<ComponentProps<typeof Excalidraw>['excalidrawAPI']>
13+
>[0];
14+
15+
function extractLocalInputFile(file: File): LocalInputFile | null {
16+
const path = (file as File & { path?: string }).path;
17+
if (typeof path !== 'string' || path.length === 0) return null;
18+
return {
19+
path,
20+
name: file.name,
21+
size: file.size,
22+
};
23+
}
24+
25+
export function CanvasSketchView() {
26+
const currentDesignId = useCodesignStore((s) => s.currentDesignId);
27+
const canvasSceneLoaded = useCodesignStore((s) => s.canvasSceneLoaded);
28+
const canvasSeed = useCodesignStore((s) => s.canvasSeed);
29+
const ensureCurrentDesign = useCodesignStore((s) => s.ensureCurrentDesign);
30+
const loadCanvasStateForCurrentDesign = useCodesignStore((s) => s.loadCanvasStateForCurrentDesign);
31+
32+
const apiRef = useRef<ExcalidrawImperativeAPI | null>(null);
33+
const saveTimerRef = useRef<number | null>(null);
34+
35+
useEffect(() => {
36+
if (!window.codesign?.snapshots) return;
37+
if (!currentDesignId) {
38+
void ensureCurrentDesign();
39+
return;
40+
}
41+
if (!canvasSceneLoaded) {
42+
void loadCanvasStateForCurrentDesign();
43+
}
44+
}, [currentDesignId, canvasSceneLoaded, ensureCurrentDesign, loadCanvasStateForCurrentDesign]);
45+
46+
useEffect(() => {
47+
const flushCanvasState = () => {
48+
const api = apiRef.current;
49+
if (!api) return;
50+
const sceneJson = serializeAsJSON(
51+
api.getSceneElementsIncludingDeleted(),
52+
api.getAppState(),
53+
api.getFiles(),
54+
'local',
55+
);
56+
void useCodesignStore.getState().persistCanvasState(sceneJson);
57+
};
58+
59+
const handleBeforeUnload = () => {
60+
flushCanvasState();
61+
};
62+
63+
window.addEventListener('beforeunload', handleBeforeUnload);
64+
return () => {
65+
if (saveTimerRef.current !== null) {
66+
window.clearTimeout(saveTimerRef.current);
67+
}
68+
window.removeEventListener('beforeunload', handleBeforeUnload);
69+
flushCanvasState();
70+
};
71+
}, []);
72+
73+
const initialData = useMemo(() => {
74+
const canvasScene = useCodesignStore.getState().canvasScene;
75+
if (!canvasScene) return null;
76+
return {
77+
elements: canvasScene.elements,
78+
appState: canvasScene.appState,
79+
files: canvasScene.files,
80+
};
81+
}, [canvasSeed, currentDesignId]);
82+
83+
if (!currentDesignId || !canvasSceneLoaded) {
84+
return (
85+
<div className="h-full flex items-center justify-center bg-[var(--color-background)] text-[var(--text-sm)] text-[var(--color-text-muted)]">
86+
Loading canvas...
87+
</div>
88+
);
89+
}
90+
91+
return (
92+
<div className="h-full w-full bg-[var(--color-background)]">
93+
<Excalidraw
94+
key={`${currentDesignId}:${canvasSeed}`}
95+
initialData={initialData}
96+
excalidrawAPI={(api) => {
97+
apiRef.current = api;
98+
}}
99+
generateIdForFile={async (file) => {
100+
const localFile = extractLocalInputFile(file);
101+
if (localFile) {
102+
useCodesignStore.getState().addCanvasImportedFile(localFile);
103+
}
104+
return [
105+
file.name,
106+
file.size,
107+
file.lastModified,
108+
Math.random().toString(36).slice(2, 8),
109+
].join('-');
110+
}}
111+
onChange={(
112+
elements: readonly ExcalidrawElement[],
113+
appState: AppState,
114+
files: BinaryFiles,
115+
) => {
116+
useCodesignStore.getState().updateCanvasScene({
117+
elements,
118+
appState,
119+
files,
120+
});
121+
if (saveTimerRef.current !== null) {
122+
window.clearTimeout(saveTimerRef.current);
123+
}
124+
saveTimerRef.current = window.setTimeout(() => {
125+
const api = apiRef.current;
126+
const latestElements = api?.getSceneElementsIncludingDeleted() ?? elements;
127+
const latestAppState = api?.getAppState() ?? appState;
128+
const latestFiles = api?.getFiles() ?? files;
129+
const sceneJson = serializeAsJSON(latestElements, latestAppState, latestFiles, 'local');
130+
void useCodesignStore.getState().persistCanvasState(sceneJson);
131+
}, 350);
132+
}}
133+
/>
134+
</div>
135+
);
136+
}

0 commit comments

Comments
 (0)