Lightweight SVG editor by mizchi/luna, like Excalidraw
Dependencies
git clone <repository-url>
cd moonlight
pnpm install# Start development server
pnpm dev
# Type checking
moon check
# Format code
moon fmt
# Build for production
pnpm build| Tool | Shortcut | Description |
|---|---|---|
| Select | V | Select and move elements |
| Free draw | P | Draw freehand, fitted to a path |
| Rectangle | 1 | Add a rectangle |
| Circle | 2 | Add a circle |
| Ellipse | 3 | Add an ellipse |
| Line | 4 | Add a line |
| Arrow | 5 | Add an arrow |
| Text | 6 | Add a text element |
| Shortcut | Action |
|---|---|
| Ctrl+Z / Cmd+Z | Undo |
| Ctrl+Shift+Z / Ctrl+Y | Redo |
| Delete / Backspace | Delete selected element |
| Ctrl+D / Cmd+D | Duplicate selected element |
| Ctrl+C / Ctrl+V | Copy / paste elements |
| Ctrl+A | Select all |
| Escape | Deselect / cancel / close the fullscreen modal |
| Shortcut | Action |
|---|---|
| Arrow Keys | Move selected element (5px, 10px with grid snap) |
| Shift + Arrow Keys | Resize selected element |
| Arrow Keys (nothing selected) | Pan the canvas |
| Shortcut | Action |
|---|---|
| 1 … 6 | Rect / Circle / Ellipse / Line / Arrow / Text |
| V | Select mode |
| P | Toggle free draw |
| ? | Open the help page |
| Shortcut | Action |
|---|---|
| Ctrl+S | Download the drawing as SVG |
| Ctrl+Shift+C | Copy the SVG text to the clipboard |
| Ctrl+Shift+V | Import SVG from the clipboard |
<script type="module" src="./main.ts"></script><script type="module" src="./embed.ts"></script>
<div id="editor-container"></div>
<script>
const editor = MoonlightEditor.create(
document.getElementById('editor-container'),
{
width: 800,
height: 600,
theme: 'light', // or 'dark'
readonly: false
}
);
// API
const svg = editor.exportSvg();
editor.importSvg(svgString);
editor.clear();
editor.hasFocus();
</script><script type="module" src="./webcomponent.ts"></script>
<moonlight-editor
width="800"
height="600"
theme="light">
</moonlight-editor>
<script>
const editor = document.querySelector('moonlight-editor');
// API (same as embed mode)
const svg = editor.exportSvg();
editor.importSvg(svgString);
editor.clear();
editor.hasFocus();
</script>| Attribute | Type | Default | Description |
|---|---|---|---|
| width | number | 400 | Canvas width in pixels |
| height | number | 300 | Canvas height in pixels |
| theme | string | "light" | Theme: "light" or "dark" |
| readonly | boolean | false | Disable editing |
<moonlight-editor width="800" height="600"></moonlight-editor>
<script src="https://moonlight.mizchi.workers.dev/moonlight-editor.component.js" async></script><moonlight-editor width="800" height="500">
<template>
<svg viewBox="0 0 800 500">
<rect x="100" y="100" width="120" height="80" fill="#4CAF50" stroke="#2E7D32" stroke-width="2"/>
<circle cx="400" cy="200" r="50" fill="#2196F3" stroke="#1565C0" stroke-width="2"/>
<text x="300" y="350" font-size="24" fill="#333">Hello Moonlight!</text>
</svg>
</template>
</moonlight-editor>
<script src="https://moonlight.mizchi.workers.dev/moonlight-editor.component.js" async></script><button onclick="alert(editor.exportSvg())">Export SVG</button>
<button onclick="editor.clear()">Clear</button>
<moonlight-editor id="editor" width="800" height="600" theme="dark"></moonlight-editor>
<script src="https://moonlight.mizchi.workers.dev/moonlight-editor.component.js" async></script>
<script>
const editor = document.getElementById('editor');
// editor.exportSvg() - Get SVG string
// editor.importSvg(svg) - Load SVG
// editor.clear() - Clear canvas
// editor.hasFocus() - Check focus state
</script>| Attribute | Default | Description |
|---|---|---|
| width | 400 | Canvas width (px) |
| height | 300 | Canvas height (px) |
| theme | "light" | "light" or "dark" |
| readonly | - | Add to disable editing |
pnpm build:all # Build component
pnpm deploy # Deploy to Workersimport { createScene } from '@mizchi/moonlight/headless';
const scene = createScene({ width: 640, height: 420 });
scene.apply(`
rect box 80 80 120 80
circle dot 400 200 40
line link 200 120 360 200
join link start box right
join link end dot left
`);
scene.toSvg(); // Moonlight SVG
scene.describe(); // "the start of line link is attached to the right anchor of rect box"
scene.violations(); // connections that no longer hold — empty means consistentecho 'rect a 10 10 80 60' | moonlight render -o out.svg
moonlight describe out.svg # what is in the drawing, in words
echo 'circle b 200 100 40' | moonlight apply out.svg
moonlight png out.svg -o out.pnginterface EditorHandle {
// Export current canvas as SVG string
exportSvg(): string;
// Import SVG string to canvas.
// Returns false when nothing could be imported (the string did not parse, or
// it held no shape the editor understands); the current drawing is kept.
importSvg(svg: string): boolean;
// Clear all elements
clear(): void;
// Check if editor has focus
hasFocus(): boolean;
// Register change callback
onChange(callback: () => void): void;
}src/
├── main.mbt # Editor assembly (library; the entry point lives in entries/app)
├── ui.mbt # UI components (sidebar, toolbar)
├── render.mbt # SVG rendering
├── model/ # Pure calculation — no signals, no DOM
│ ├── types.mbt # Data models (Element, Style, etc.)
│ ├── command.mbt # Undo/Redo commands
│ └── element_ops.mbt # Pure element operations
├── interaction/ # Semantic interaction model — what a gesture means
│ ├── types.mbt # Scene, Target, Input, Gesture, Session
│ ├── joints.mbt # Joints between line endpoints and shape anchors
│ ├── resize.mbt # Resize geometry per shape × handle
│ ├── session.mbt # The gesture state machine (a pure reducer)
│ └── dsl.mbt # Scenario DSL shared by unit and visual tests
├── core/
│ └── scene.mbt # Editor state (signals) over the pure layers
├── lib/ # Shared library code
├── entries/
│ ├── app/ # Full editor entry point
│ └── model-js/ # The interaction model, exported to JS for tests
├── embed/ # Embed mode entry point
├── webcomponent/ # Web Component entry point
└── preview/ # Preview modejust test-unit # MoonBit unit tests, including the interaction model
just test # Playwright end-to-end suite
just visual-model # Semantic prediction vs. a real mouse gesture
just vlm-integrity # vlmkit reference-free gate over 3 viewports (needs `just dev`)
just vlm-snapshot # vlmkit visual snapshots (needs `just dev`)
just vrt # Screenshot regression against stored baselines| Reviewer | Needs |
|---|---|
| anthropic / openrouter / gemini | ANTHROPIC_API_KEY / OPENROUTER_API_KEY / GEMINI_API_KEY |
| claude-cli | the claude CLI on PATH, already signed in — no key |
Install
Download zipLightweight SVG editor by mizchi/luna, like Excalidraw
Dependencies