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
2 changes: 2 additions & 0 deletions .github/workflows/ci.yml
Original file line number Diff line number Diff line change
Expand Up @@ -64,6 +64,8 @@ jobs:

- run: bun run test:combine

- run: bun run test:watch

docker-build:
runs-on: ubuntu-latest
needs: [typecheck, lint, unit-tests]
Expand Down
27 changes: 27 additions & 0 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -170,6 +170,33 @@ excalirender combine a.excalidraw b.excalidraw --dark --gap 60 # Dark mode, 6

See [docs/COMBINE.md](docs/COMBINE.md) for implementation details.

### Watch Command

Watch `.excalidraw` file(s) and preview in the browser with live reload. The number of input files determines the mode: 1 file for export, 2 files for diff.

```bash
excalirender watch diagram.excalidraw # Export mode
excalirender watch old.excalidraw new.excalidraw # Diff mode
excalirender watch diagram.excalidraw --dark --scale 2 --port 8080 # With options
excalirender watch old.excalidraw new.excalidraw --hide-unchanged # Diff options
```

| Flag | Description | Default |
|------|-------------|---------|
| `-p, --port <number>` | HTTP server port | `3333` |
| `-s, --scale <number>` | Export scale factor | `1` |
| `-d, --dark` | Enable dark mode | `false` |
| `--transparent` | Transparent background | `false` |
| `-b, --background <color>` | Background color | From file |
| `-f, --frame <name>` | Export specific frame (export mode only) | - |
| `--no-open` | Don't auto-open browser | `false` |
| `--hide-unchanged` | Don't render unchanged elements (diff mode) | `false` |
| `--no-tags` | Don't render status tags (diff mode) | - |

Editing and saving the `.excalidraw` file auto-refreshes the browser preview. Parse errors are logged without crashing — the last good render is preserved. Press Ctrl+C to stop the server.

See [docs/WATCH.md](docs/WATCH.md) for architecture and implementation details.

## How It Works

The rendering pipeline reads `.excalidraw` JSON files and draws elements to a server-side canvas using the same libraries Excalidraw uses:
Expand Down
165 changes: 165 additions & 0 deletions docs/WATCH.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,165 @@
# Watch Command

Live browser preview for `.excalidraw` files with auto-refresh on file changes.

## Overview

`excalirender watch` starts a local HTTP server that renders `.excalidraw` files to PNG and serves them in a browser. When the source file is saved, the preview refreshes automatically via Server-Sent Events (SSE).

**Mode detection** is based on file count:
- 1 file argument: **export mode** — renders single file as PNG
- 2 file arguments: **diff mode** — renders visual diff between files

No new dependencies — uses `Bun.serve()` for HTTP, `fs.watch()` for file changes, and native `ReadableStream` for SSE.

## Architecture

```
┌──────────────┐ fs.watch() ┌─────────────────┐
│ .excalidraw │ ───────────────> │ Watch Server │
│ file(s) │ file change │ (Bun.serve) │
└──────────────┘ └────────┬────────┘
┌──────────────┼──────────────┐
│ │ │
GET / GET /image GET /events
HTML page PNG buffer SSE stream
│ │ │
└──────────────┼──────────────┘
┌──────▼──────┐
│ Browser │
│ <img> tag │
└─────────────┘
```

### HTTP Routes

| Route | Content-Type | Description |
|-------|-------------|-------------|
| `GET /` | `text/html` | HTML page with `<img>` and SSE listener |
| `GET /image` | `image/png` | Current rendered PNG buffer |
| `GET /events` | `text/event-stream` | SSE stream, pushes `data: reload\n\n` on changes |

### SSE Live Reload

The server maintains a `Set<ReadableStreamDefaultController>` of connected SSE clients. When a file change triggers a re-render, `notifyClients()` enqueues `"data: reload\n\n"` to all controllers. The browser's `EventSource` listener updates the `<img>` src with a cache-busting timestamp.

```typescript
const sseClients = new Set<ReadableStreamDefaultController>();

function notifyClients() {
const data = new TextEncoder().encode("data: reload\n\n");
for (const client of sseClients) {
try { client.enqueue(data); }
catch { sseClients.delete(client); }
}
}
```

### File Watching

Uses `node:fs` `watch()` with a 200ms debounce to handle rapid editor saves (editors often write to temp file then rename):

```typescript
const DEBOUNCE_MS = 200;
let lastRender = 0;

for (const filePath of inputPaths) {
watch(filePath, async () => {
if (Date.now() - lastRender < DEBOUNCE_MS) return;
lastRender = Date.now();
// Re-render and notify SSE clients
});
}
```

## Rendering Pipeline

### Export Mode

Reuses the standard export pipeline:

1. `prepareExport(inputPath, options)` — reads file, sorts elements, computes bounds
2. `renderElementsToCanvas(elements, renderOptions)` — draws to canvas
3. `canvas.toBuffer("image/png")` — produces PNG buffer

### Diff Mode

Reuses the diff algorithm with inline tag rendering:

1. `computeDiff(oldPath, newPath)` — computes added/removed/modified/unchanged
2. Style unchanged elements with `applyUnchangedStyle()`
3. `renderElementsToCanvas(allElements, renderOptions)` — draws to canvas with `afterRender` callback for diff tags
4. `canvas.toBuffer("image/png")` — produces PNG buffer

Diff tags are rendered inline via `renderDiffTag()` which draws colored labels (added/removed/modified) below each changed element.

## HTML Preview Page

The served HTML page has:
- Dark background (`#1a1a1a`) for comfortable viewing
- Checkerboard pattern behind the image (visible with `--transparent`)
- File name header with last render timestamp
- `EventSource` listening on `/events` for live reload

## Error Recovery

Parse errors during re-render are caught and logged to the terminal. The last successfully rendered PNG is preserved — the browser continues showing the previous valid render. When the file is fixed and saved again, the preview updates normally.

```
[12:34:56] Rendered in 120ms
[12:35:02] Error: Failed to parse diagram.excalidraw — keeping last render
[12:35:10] Rendered in 95ms
```

## Key Design Decisions

1. **SSE over WebSocket**: One-way server-to-browser push is all that's needed. SSE is simpler and sufficient.
2. **PNG only**: The preview always renders PNG (not SVG/PDF) for consistent browser display and fast rendering.
3. **Dynamic import**: `watch.ts` is loaded via `await import("./watch.js")` so non-watch commands don't pay the import cost.
4. **Browser open**: Uses `Bun.spawn(["xdg-open", url])` with `unref()` so the child process doesn't block the server. Failures are silently ignored.
5. **Mode from file count**: Instead of a `--diff` flag, the mode is auto-detected from the number of arguments (1 = export, 2 = diff).

## File Structure

| File | Role |
|------|------|
| `src/watch.ts` | Watch server: rendering, HTTP, SSE, file watcher |
| `src/cli.ts` | `WatchCLIArgs` interface, `buildWatchArgs()`, watch subcommand |
| `src/index.ts` | Watch routing with validation |

### Key Functions

- `startWatchServer(config)` — entry point, validates files, initial render, starts server + watchers
- `renderExportToBuffer(inputPath, options)` — renders single file to PNG buffer
- `renderDiffToBuffer(oldPath, newPath, options)` — renders visual diff to PNG buffer
- `renderDiffTag(ctx, element, status, offsetX, offsetY)` — draws colored status tag below element
- `buildHtmlPage(title)` — returns HTML string for the preview page

### WatchConfig Interface

```typescript
interface WatchConfig {
inputPaths: string[];
mode: "export" | "diff";
port: number;
open: boolean;
exportOptions: ExportOptions;
diffOptions: DiffOptions;
}
```

## Options Reference

| Flag | Description | Default |
|------|-------------|---------|
| `-p, --port <number>` | HTTP server port | `3333` |
| `-s, --scale <number>` | Export scale factor | `1` |
| `-d, --dark` | Enable dark mode | `false` |
| `--transparent` | Transparent background | `false` |
| `-b, --background <color>` | Background color | From file |
| `-f, --frame <name>` | Export specific frame (export mode only) | - |
| `--no-open` | Don't auto-open browser | `false` |
| `--hide-unchanged` | Don't render unchanged elements (diff mode) | `false` |
| `--no-tags` | Don't render status tags (diff mode) | - |
3 changes: 2 additions & 1 deletion package.json
Original file line number Diff line number Diff line change
Expand Up @@ -23,7 +23,8 @@
"test:diff": "bun run tests/diff/run.ts",
"test:stdin-stdout": "bun run tests/stdin-stdout/run.ts",
"test:info": "bun run tests/info/run.ts",
"test:combine": "bun run tests/combine/run.ts"
"test:combine": "bun run tests/combine/run.ts",
"test:watch": "bun run tests/watch/run.ts"
},
"keywords": ["excalidraw", "cli", "png", "export"],
"author": "",
Expand Down
73 changes: 72 additions & 1 deletion src/cli.ts
Original file line number Diff line number Diff line change
Expand Up @@ -35,11 +35,22 @@ export interface CombineCLIArgs {
options: CombineOptions;
}

export interface WatchCLIArgs {
command: "watch";
inputPaths: string[];
mode: "export" | "diff";
port: number;
open: boolean;
exportOptions: ExportOptions;
diffOptions: DiffOptions;
}

export type CLIArgs =
| ExportCLIArgs
| DiffCLIArgs
| InfoCLIArgs
| CombineCLIArgs;
| CombineCLIArgs
| WatchCLIArgs;

/**
* Generate default output filename for diff command.
Expand Down Expand Up @@ -153,6 +164,43 @@ function buildCombineArgs(
};
}

function buildWatchArgs(
files: string[],
opts: Record<string, unknown>,
): WatchCLIArgs {
const mode = files.length === 2 ? "diff" : "export";
const scale = Number.parseFloat(opts.scale as string) || 1;
const darkMode = (opts.dark as boolean) || false;
const transparent = (opts.transparent as boolean) || false;

return {
command: "watch",
inputPaths: files,
mode,
port: Number.isNaN(Number.parseInt(opts.port as string, 10))
? 3333
: Number.parseInt(opts.port as string, 10),
open: opts.open !== false,
exportOptions: {
outputPath: "",
scale,
background: transparent
? "transparent"
: (opts.background as string) || null,
darkMode,
frameId: (opts.frame as string) || undefined,
},
diffOptions: {
outputPath: "",
scale,
hideUnchanged: (opts.hideUnchanged as boolean) || false,
showTags: opts.tags !== false,
darkMode,
transparent,
},
};
}

export function parseArgs(): CLIArgs {
let result: CLIArgs | null = null;

Expand Down Expand Up @@ -249,6 +297,29 @@ export function parseArgs(): CLIArgs {
result = buildCombineArgs(files, opts);
});

program
.command("watch")
.description(
"Watch .excalidraw file(s) and preview in browser with live reload",
)
.argument("<files...>", "Input file(s): 1 file = export, 2 files = diff")
.option("-p, --port <number>", "HTTP server port", "3333")
.option("-s, --scale <number>", "Export scale factor", "1")
.option("-d, --dark", "Enable dark mode", false)
.option("--transparent", "Transparent background", false)
.option("-b, --background <color>", "Background color")
.option("-f, --frame <name>", "Export specific frame (export mode)")
.option("--no-open", "Don't auto-open browser")
.option(
"--hide-unchanged",
"Don't render unchanged elements (diff mode)",
false,
)
.option("--no-tags", "Don't render status tags (diff mode)")
.action((files: string[], opts: Record<string, unknown>) => {
result = buildWatchArgs(files, opts);
});

program.parse();

if (!result) {
Expand Down
21 changes: 20 additions & 1 deletion src/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -141,7 +141,26 @@ async function main() {
try {
const args = parseArgs();

if (args.command === "info") {
if (args.command === "watch") {
const { inputPaths } = args;

// Validate file count
if (inputPaths.length === 0 || inputPaths.length > 2) {
console.error(
"Error: Watch mode supports 1 file (export) or 2 files (diff)",
);
process.exit(1);
}

// Validate no stdin
if (inputPaths.some((p) => p === "-")) {
console.error("Error: Watch mode doesn't support stdin (-)");
process.exit(1);
}

const { startWatchServer } = await import("./watch.js");
await startWatchServer(args);
} else if (args.command === "info") {
const { inputPath, json } = args;
const content = inputPath === "-" ? readStdin() : undefined;
runInfo(inputPath, { json }, content);
Expand Down
Loading