Embeddable canvas drawing editor for Go web apps — Excalidraw-style tools, pure vanilla JS, zero frontend dependencies.
Designed to be embedded in go-wiki, taskai, blogs, and any Go web application. Authors draw on an edit page, readers get a pan/zoom-only viewport. Embeds are resizable and support fullscreen.
- Tools — Select, Rectangle, Ellipse, Line, Arrow, Pencil, Text
- Editor — Full toolbar, color picker, stroke width, font size, undo/redo (Ctrl+Z/Y), keyboard shortcuts, auto-save after 2s idle
- Viewer — Read-only canvas: pan (drag / touch), zoom (scroll / pinch), no edit UI
- Fullscreen — Toggle via button or F11, works in both editor and viewer modes
- Resizable embed — Drag edges/corner to resize the embedded viewport in any host page
- New canvas API — Create drawings programmatically via JSON endpoint or JS widget
- Dot-grid background — scales with zoom
- Storage interface — swap in any backend; default is atomic JSON files on disk
- Embedded assets — CSS, JS, templates via Go's
embed.FS— no npm, no build step - Zero external Go dependencies — stdlib only
go get github.com/anchoo2kewl/go-drawpackage main
import (
"log"
"net/http"
godraw "github.com/anchoo2kewl/go-draw"
)
func main() {
d, err := godraw.New(
godraw.WithBasePath("/draw"), // default
)
if err != nil {
log.Fatal(err)
}
http.Handle("/draw/", d.Handler()) // trailing slash required
log.Fatal(http.ListenAndServe(":8080", nil))
}Visit http://localhost:8080/draw/ to create a drawing, share its ID.
// Read-only viewer
snippet := draw.ViewerSnippet("my-drawing-id", "100%", "520px")
// Full editor
snippet := draw.EditorSnippet("my-drawing-id", "100%", "600px")
// Pass template.HTML(snippet) to your template.The embed widget wraps the iframe in a container with drag-to-resize handles on all edges.
// In your Go handler:
snippet := draw.EmbedSnippet("my-drawing-id", "100%", "520px", "view") // or "edit"
// Returns HTML with the iframe + embed.js scriptOr use the widget directly in HTML:
<div class="godraw-embed"
data-src="/draw/my-drawing-id"
data-width="100%"
data-height="520px">
</div>
<script src="/draw/static/embed.js"></script>The embed container can be resized by:
- Dragging the bottom-right corner handle (resize both dimensions)
- Dragging the right edge (resize width)
- Dragging the bottom edge (resize height)
Include embed.js and use the GoDraw global:
<script src="/draw/static/embed.js"></script>
<script>
// Embed into a container element
const el = document.getElementById("my-drawing-container");
GoDraw.embed(el, {
src: "/draw/my-drawing-id/edit",
width: "100%",
height: "600px",
basePath: "/draw"
});
// Create a new canvas programmatically
const data = await GoDraw.newCanvas({ basePath: "/draw" });
// data = { id: "abc123", edit_url: "/draw/abc123/edit", view_url: "/draw/abc123" }
</script>The embed widget fires custom events on the container element:
container.addEventListener("godraw:ready", e => {
console.log("Canvas ready:", e.detail.id, e.detail.mode);
});
container.addEventListener("godraw:new-canvas", e => {
console.log("New canvas created:", e.detail.id);
});
container.addEventListener("godraw:fullscreen", e => {
console.log("Fullscreen:", e.detail.active);
});Fullscreen is available in both editor and viewer modes:
- Click the fullscreen button (bottom-right corner)
- Press F11
- Works inside iframes (requires
allowfullscreenattribute, added automatically by all snippet methods)
| Method | Path | Description |
|---|---|---|
GET |
/draw/ |
List all drawings |
GET |
/draw/new |
Create drawing, redirect to edit |
GET |
/draw/{id} |
Read-only viewer |
GET |
/draw/{id}/edit |
Full editor |
GET |
/draw/{id}/data |
Raw scene JSON |
POST |
/draw/{id}/save |
Persist scene JSON |
POST |
/draw/{id}/delete |
Delete drawing |
POST |
/draw/api/new |
Create drawing (with optional title & scene), return JSON |
GET |
/draw/api/list |
List all drawings as JSON |
POST |
/draw/api/{id}/rename |
Rename a drawing |
POST |
/draw/api/{id}/delete |
Delete a drawing (JSON response) |
Create an empty drawing:
POST /draw/api/new
→ { "id": "abc123xyz", "edit_url": "/draw/abc123xyz/edit", "view_url": "/draw/abc123xyz" }
Create a drawing with title and pre-built scene:
POST /draw/api/new
Content-Type: application/json
{
"title": "Architecture Diagram",
"scene": {
"version": 1,
"elements": [
{
"id": "r1", "type": "rect",
"x": 100, "y": 50, "w": 200, "h": 80,
"strokeColor": "#1e1e2e", "fillColor": "#dbeafe",
"opacity": 100, "strokeWidth": 2,
"text": "My Service", "fontSize": 14
}
]
}
}
→ { "id": "abc123xyz", "edit_url": "/draw/abc123xyz/edit", "view_url": "/draw/abc123xyz" }
POST /draw/{id}/save
Content-Type: application/json
{ "title": "New Title", "scene": { "version": 1, "elements": [...] } }
→ { "ok": true, "id": "abc123xyz" }
GET /draw/{id}/data
→ { "id": "abc123xyz", "title": "My Drawing", "scene": { "version": 1, "elements": [...] } }
POST /draw/api/{id}/rename
Content-Type: application/json
{ "title": "New Name" }
→ { "ok": true }
godraw.New(
godraw.WithBasePath("/draw"), // URL prefix (default: "/draw")
godraw.WithStore(myStore), // custom storage backend
godraw.WithMaxSceneBytes(4 << 20), // max save payload (default: 2 MB)
)Implement store.Store:
type Store interface {
Get(id string) (*Drawing, error)
Save(d *Drawing) error
List() ([]*Drawing, error)
Delete(id string) error
}Then pass it via godraw.WithStore(myStore).
| Key | Action |
|---|---|
V |
Select tool |
R |
Rectangle |
E |
Ellipse |
L |
Line |
A |
Arrow |
P |
Pencil |
T |
Text |
Del / Backspace |
Delete selected |
Ctrl+Z |
Undo |
Ctrl+Y |
Redo |
Ctrl+S |
Save |
Esc |
Deselect / cancel text |
Space + drag |
Pan (in any tool) |
F11 |
Toggle fullscreen |
| Scroll | Zoom |
Drawings are stored as JSON. The scene field is opaque to the Go layer — it is produced and consumed by the frontend (canvas.js). When creating drawings programmatically via the API, you must follow this format exactly.
{
"version": 1,
"elements": [ ... ]
}Every element has these fields:
| Field | Type | Description |
|---|---|---|
id |
string | Unique identifier (any string, must be unique within the scene) |
type |
string | Element type: rect, ellipse, line, arrow, pencil, text, image |
strokeColor |
string | Stroke/border color as hex (e.g. "#1e1e2e") |
fillColor |
string | Fill color as hex, or "" / "transparent" for none |
opacity |
number | 0–100 (not 0–1). 100 = fully opaque, 50 = half transparent |
strokeWidth |
number | Stroke width in pixels (1–4) |
strokeStyle |
string | "solid" (default), "dashed", or "dotted" |
roughness |
number | Continuous 0..2. 0 = clean, 1 = hand-drawn (default), 2 = sketchy double-stroke |
roundness |
string | "sharp" (default) or "round" (rounded corners for rects) |
angle |
number | Rotation in radians (0 = no rotation) |
Rectangle (rect) and Ellipse (ellipse):
{ "id": "r1", "type": "rect", "x": 100, "y": 50, "w": 200, "h": 80, ... }x,y— top-left corner positionw,h— width and heighttext(optional) — centered label inside the shapefontSize(optional) — label font size in pixels (default: 16)
Arrow (arrow) and Line (line):
{ "id": "a1", "type": "arrow", "x": 100, "y": 50, "x2": 100, "y2": 150, ... }x,y— start pointx2,y2— end point- Do not use
w,hfor arrows/lines — they use endpoint coordinates
Text (text):
{ "id": "t1", "type": "text", "x": 100, "y": 50, "w": 200, "h": 20, "text": "Hello", "fontSize": 16, ... }x,y— top-left positionw,h— bounding box (used for selection/hit testing)text— the text contentfontSize— font size in pixels
Pencil (pencil):
{ "id": "p1", "type": "pencil", "pts": [{"x": 10, "y": 20}, {"x": 15, "y": 25}, ...], ... }pts— array of{x, y}points
Image (image):
{ "id": "i1", "type": "image", "x": 100, "y": 50, "w": 300, "h": 200, "src": "/draw/uploads/abc.png", ... }x,y— top-left positionw,h— display dimensionssrc— image URL
{
"version": 1,
"elements": [
{
"id": "box1", "type": "rect",
"x": 100, "y": 50, "w": 200, "h": 80,
"strokeColor": "#2563eb", "fillColor": "#dbeafe",
"opacity": 100, "strokeWidth": 2, "angle": 0,
"text": "Service A", "fontSize": 14
},
{
"id": "arr1", "type": "arrow",
"x": 200, "y": 130, "x2": 200, "y2": 200,
"strokeColor": "#2563eb", "fillColor": "",
"opacity": 100, "strokeWidth": 2, "angle": 0
},
{
"id": "box2", "type": "rect",
"x": 100, "y": 200, "w": 200, "h": 80,
"strokeColor": "#059669", "fillColor": "#ecfdf5",
"opacity": 100, "strokeWidth": 2, "angle": 0,
"text": "Service B", "fontSize": 14
},
{
"id": "label1", "type": "text",
"x": 320, "y": 80, "w": 100, "h": 18,
"strokeColor": "#6b7280", "fillColor": "",
"opacity": 70, "strokeWidth": 1, "angle": 0,
"text": "Annotation", "fontSize": 12
}
]
}go-draw/
├── draw.go Top-level Draw struct, New(), ViewerSnippet(), EmbedSnippet()
├── options.go Functional options
├── handler.go HTTP routes and handlers (including API endpoints)
├── embed.go //go:embed + canvas template
├── store/
│ └── store.go Store interface + FileStore implementation
├── static/
│ ├── canvas.js Canvas engine (editor + viewer + fullscreen)
│ └── embed.js Host-page embed widget (resize + events + JS API)
└── _examples/
└── standalone/
└── main.go Runnable demo server
MIT