New files:
commands/canvas.md — slash command with full trigger table
skills/canvas/SKILL.md — complete skill: open, new, add image/text/pdf/note,
zone, list, from-banana operations
skills/canvas/references/canvas-spec.md — Obsidian canvas JSON spec:
coordinate system, all node types, color table,
image sizing by aspect ratio, auto-positioning
pseudocode, common mistakes, full example
Auto-positioning algorithm (bbox math):
- Finds rightmost node in target zone
- Places next node at rightmost_x + 40, same row y
- If overflow: wraps to new row (max_y_in_zone + 20)
- Falls back to below all content if no zone found
Banana integration:
- /canvas from banana: checks .recent-images.txt first, then
filesystem find -newer 10min, presents list for confirmation
- After any /banana run: suggests /canvas from banana
Updated:
skills/wiki/SKILL.md — added /canvas to routing table
skills/wiki/references/plugins.md — added Calendar + Thino with
pre-installed note + manual install fallback
README.md — /canvas in commands table + file structure
6.8 KiB
6.8 KiB
Obsidian Canvas JSON Specification
Canvas files are JSON with two top-level keys: nodes (array) and edges (array).
Obsidian reads and writes them as UTF-8 JSON files with .canvas extension.
Coordinate System
x increases →
┌─────────────────────────────────
│ (-920, -2400) (0, -2400)
│
y │ (-920, 0) (0, 0) ← origin
↓ │
│ (-920, 540) (500, 540)
- Origin (0, 0) is the center of the canvas viewport.
- x increases rightward. Negative x = left of center.
- y increases downward. Negative y = above center.
- Node
xandyare the top-left corner of the node, not the center. - Obsidian pans to fit all nodes on first open.
Node Types
Text node
Renders markdown content as a styled card.
{
"id": "text-title-4821",
"type": "text",
"text": "# Heading\n\nParagraph with **bold** and `code`.",
"x": -400,
"y": -300,
"width": 400,
"height": 120,
"color": "6"
}
text: markdown string. Use\nfor newlines.- Minimum readable size: width ≥ 200, height ≥ 60.
coloris optional. Omit for default (no color).
File node
Renders an image, PDF, markdown note, or other vault file inline.
{
"id": "img-cover-7823",
"type": "file",
"file": "_attachments/images/skool-hub/logo-pro.png",
"x": -900,
"y": -100,
"width": 420,
"height": 236
}
file: vault-relative path (not absolute, not~/).- Supported:
.png.jpg.webp.gif.pdf.md.canvas - For
.mdfiles: renders as a preview card. - For
.pdffiles: renders the first page as preview. - No
colorfield for file nodes — color is ignored.
Group node (Zone)
A labeled rectangular region. Does not clip or contain nodes — it's a visual guide. Nodes placed "inside" a group are just positioned within its bounding box.
{
"id": "zone-branding-3391",
"type": "group",
"label": "Brand Identity",
"x": -920,
"y": -880,
"width": 1060,
"height": 290,
"color": "6"
}
label: shown at the top of the group box.color: colors the group border and label.- Groups do not affect auto-layout — they are purely visual containers.
Link node
Renders a web URL as an embedded preview card.
{
"id": "link-karpathy-2233",
"type": "link",
"url": "https://github.com/karpathy",
"x": 200,
"y": -300,
"width": 400,
"height": 120
}
url: must be a validhttps://URL.- Obsidian fetches the Open Graph preview (title, description, thumbnail).
Edges
Connections between nodes. Usually empty for mood boards.
{
"id": "e-hub-cidx",
"fromNode": "hub",
"fromSide": "right",
"toNode": "c-idx",
"toSide": "left",
"toEnd": "arrow",
"label": "concepts",
"color": "5"
}
fromSide/toSide:"top""bottom""left""right"toEnd:"arrow"(directed) or"none"(undirected line)labelandcolorare optional.
Color Reference
| Code | Color | Hex (approx) | Use case |
|---|---|---|---|
"1" |
Red / Tomato | #e03e3e | Warnings, archive |
"2" |
Orange | #d09035 | Active work |
"3" |
Yellow / Gold | #d0a023 | WIP, notes |
"4" |
Green / Teal | #448361 | Content, sources |
"5" |
Blue / Cyan | #3ea7d3 | Navigation, info |
"6" |
Purple / Violet | #9063d2 | Title, identity |
Omit color entirely for the default (no border color, transparent label).
Image Sizing Guidelines
Calculate from actual image dimensions using PIL or identify:
python3 -c "from PIL import Image; img=Image.open('path.png'); print(img.width, img.height)"
# or
identify -format '%w %h' path.png
| Aspect ratio | Condition | Canvas width | Canvas height |
|---|---|---|---|
| 16:9 (wide) | ratio 1.6–2.0 | 420 | 236 |
| 2:1 (ultra wide) | ratio > 2.0 | 440 | 220 |
| 4:3 | ratio 1.2–1.6 | 380 | 285 |
| 1:1 (square) | ratio 0.9–1.1 | 280 | 280 |
| 3:4 | ratio 0.6–0.9 | 240 | 320 |
| 9:16 (portrait) | ratio < 0.6 | 200 | 356 |
| any | 400 | 520 | |
| Unknown | fallback | 320 | 240 |
Auto-Positioning Pseudocode
function place_node(canvas, zone_label, new_w, new_h):
zone = find group node where label == zone_label
padding = 20
if zone not found:
max_y = max(n.y + n.height for n in canvas.nodes) + 60
return (-400, max_y)
# Nodes visually inside zone
inside = [n for n in canvas.nodes
if n.type != 'group'
and zone.x <= n.x < zone.x + zone.width
and zone.y <= n.y < zone.y + zone.height]
if inside is empty:
return (zone.x + padding, zone.y + padding)
# Rightmost point in zone
rightmost = max(n.x + n.width for n in inside)
next_x = rightmost + 40
if next_x + new_w > zone.x + zone.width - padding:
# Overflow → new row
bottom_of_row = max(n.y + n.height for n in inside)
return (zone.x + padding, bottom_of_row + padding)
# Same row
row_y = min(n.y for n in inside) # align to top of existing row
return (next_x, row_y)
Full Example: Two-Zone Canvas
{
"nodes": [
{
"id": "title-0001",
"type": "text",
"text": "# Brand Reference\n\n**AI Marketing Hub** visual assets",
"x": -920, "y": -2440, "width": 560, "height": 180, "color": "6"
},
{
"id": "zone-logos",
"type": "group",
"label": "Logos & Icons",
"x": -920, "y": -2200, "width": 1800, "height": 320, "color": "6"
},
{
"id": "img-logo-pro",
"type": "file",
"file": "_attachments/images/skool-hub/logo-pro.png",
"x": -900, "y": -2180, "width": 420, "height": 236
},
{
"id": "img-icon-free",
"type": "file",
"file": "_attachments/images/skool-hub/icon-free-v1.png",
"x": -440, "y": -2180, "width": 280, "height": 280
},
{
"id": "zone-covers",
"type": "group",
"label": "Skill Covers",
"x": -920, "y": -1820, "width": 1800, "height": 340, "color": "3"
},
{
"id": "img-seo",
"type": "file",
"file": "_attachments/images/skool-hub/claude-seo-cover.png",
"x": -900, "y": -1800, "width": 420, "height": 236
}
],
"edges": []
}
Common Mistakes
- Wrong path format: use
_attachments/images/file.pngnot/home/user/...or~/... - ID collision: always read existing IDs before generating a new one
- Negative y confusion:
y: -2400is ABOVEy: -1000(more negative = higher up) - Group does not clip: placing a node "inside" a group is just positioning it within the group's bounding box — there is no parent-child relationship in the JSON
- Missing height on text nodes: Obsidian will render the text but may clip it if height is too small. Use height ≥ content-lines × 24.