Skip to content
Open
109 changes: 109 additions & 0 deletions lifeplayer67.CanvasView/CHANGELOG.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,109 @@
# Canvas View Changelog

## [0.9.5] - 2026-09-25 (unreleased)

- Markdown in cards is rendered by showdown (the converter NotePlan's own helpers
use): full lists, GFM tables, tasklists, emoji, hashtags/mentions/highlights;
local images embed as data URIs. Identical styling for text and note cards.
- Notes open beside the canvas (split view by default; setting for new window
or main editor).
- Editor polish: caret lands where you double-clicked and is scrolled into view;
card bodies scroll only when the card is selected (Obsidian behaviour).

## [0.8.0] - 2026-09-25 (unreleased)

- New command: `/new canvas` — creates an empty `.canvas` file (in the Canvas
folder setting, or `Canvases/` by default; a folder prefix in the name works)
and opens it. Opening an existing name just opens it.
- All UI strings are in English for the community release.

## [0.7.0] - 2026-09-25 (unreleased)

- Copy & paste: ⌘C puts the selection on the system clipboard as a JSON Canvas
snippet, ⌘V pastes it at the view center with fresh ids — works across canvases
(and from any app that puts JSON Canvas on the clipboard).
- File cards refresh their note content when the canvas window regains focus, so
edits made in the NotePlan editor show up without reopening (throttled; never
interrupts an open card editor).
- F zooms to the selection; with nothing selected it fits the whole canvas.

## [0.6.4] - 2026-09-25 (unreleased)

Obsidian-style group interaction:

- An unselected group's interior behaves like the background: rubber-band its
children, double-click to create a card inside; the group activates only by
its frame (~14 px, zoom-independent) or its label.
- Once selected, the group drags from anywhere inside its bounds.
- Starting any background action (click or rubber-band) drops the old selection
immediately; a rubber band that fully encloses a group selects the group too.

## [0.6.0] - 2026-09-24 (unreleased)

Obsidian parity push:

- Drag-to-add: grab a palette button and drop it where the card should appear
(clicking still creates at the center).
- Note picker with autocomplete: adding a note card (or double-clicking a file
card's header) opens a search over all NotePlan notes — arrows + Enter to pick,
free text is kept as a raw path.
- Images: file cards pointing at png/jpg/gif/webp/svg render the image.
- Link cards embed a live website preview (iframe; sites that forbid embedding
via X-Frame-Options show blank — see README limitations).
- The canvas now opens in a split pane inside the main NotePlan window by default
(setting: "Open canvas in the main window").

## [0.5.0] - 2026-09-24 (unreleased)

- Groups fixed: the edges SVG layer sat above groups and swallowed their clicks —
now clicks pass through, so groups select, drag, resize and rename normally.
- Group creation: ⌘G wraps the current selection in a group; the palette also has
a "+ група" button.
- New-card palette (top-left): create text, note (file) and group cards; the right
editor opens immediately after creation. (No separate "link" button — text cards
autolink URLs; the link node type is still read/rendered for Obsidian compatibility.)
- Obsidian-style selection: plain drag on the background draws a rubber-band
selection (Shift+drag pans; scroll/trackpad pans as before). The selection toolbar
gained a ⊞ button that wraps the selected cards in a group.
- Markdown: `- [ ]` / `- [x]` checkboxes render in cards.

## [0.4.1] - 2026-09-24 (unreleased)

- File cards render the linked note's CONTENT (like Obsidian) and edit it inline:
double-click the body to write — saved straight into the NotePlan note; if the note
doesn't exist yet, it is created on first save. Double-click the header to change
which file the card points to. ⌘+click opens the note.
- URL/path editors are now full-card inline inputs instead of a floating box.

## [0.3.0] - 2026-09-21 (unreleased)

Editing parity push (Obsidian-like):

- Create text cards: double-click empty canvas.
- Draw edges: hover a card, drag from a side anchor dot onto another card.
- Selection: click, shift+click multi-select, shift+drag marquee; Esc clears.
- Delete (⌫), duplicate (⌘D), undo/redo (⌘Z / ⇧⌘Z).
- Resize: 8 handles on a single selected card.
- Colors: floating toolbar (6 presets + none) or keys 1–6 / 0; works for edges too.
- Edge labels: double-click an edge to add/edit; group labels editable the same way.
- Dragging a group moves everything fully inside it.
- Architecture: state now lives in the window (requiredFiles/canvasClient.js); the
plugin persists whole documents via 'saveCanvas' (debounced 400 ms, validated
before writing). Unknown fields from other tools survive.

## [0.2.0] - 2026-09-20 (unreleased)

- Editing: drag nodes to move them, double-click a text node to edit its markdown
(Cmd+Enter or click away to save, Esc to cancel); changes are written back to the
same `.canvas` file, staying compatible with Obsidian (unknown fields preserved).
- Note links (file nodes, `[[wikilinks]]`) now open via the np.Shared comms bridge —
`noteplan://` links are blocked inside the HTML window, so clicks are relayed to the
plugin, which calls `Editor.openNoteByTitle`. A message explains when the note
doesn't exist in NotePlan yet.
- Edges are drawn client-side and follow nodes live while dragging.

## [0.1.0] - 2026-09-20 (unreleased)

- Initial version: `/open canvas` command — read-only viewer for JSON Canvas (`.canvas`)
files with pan & zoom, all four node types (text, file, link, group), edges with labels
and arrows, preset + hex colors, clickable note links.
72 changes: 72 additions & 0 deletions lifeplayer67.CanvasView/README.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,72 @@
# Canvas View plugin

View [JSON Canvas](https://jsoncanvas.org) (`.canvas`) files — the open format used by
Obsidian Canvas — right inside NotePlan.

## Commands

- `/new canvas` — creates an empty `.canvas` file (in the *Canvas folder* setting, or
`Canvases/` by default) and opens it.
- `/open canvas` — pick any indexed `.canvas` file from a list and open it in the
canvas editor (opens as a split pane inside the main window by default).
- Scroll / trackpad to pan, ⌘+scroll (or ctrl+scroll) to zoom, drag the background to pan,
double-click the background to re-fit.
- Drag a node to move it; double-click a text node to edit its markdown
(⌘+Enter or click away to save, Esc to cancel). Changes are saved back to the
same `.canvas` file and stay compatible with Obsidian.
- File and link cards: plain click selects, ⌘+click opens (the note in NotePlan / the
URL in your browser), double-click edits the file path or URL. `[[wikilinks]]` inside
text cards open on plain click. Notes are matched by title and must exist in NotePlan.

## Editor cheat-sheet

| Action | How |
|---|---|
| Create a card | double-click the background, or drag/click a palette button (`+ card`, `+ note`, `+ group`) |
| Connect cards | hover a card, drag from a side anchor dot onto another card |
| Select | click; ⇧+click adds; drag on the background rubber-bands (a band fully enclosing a group selects the group) |
| Move | drag a card; a selected group drags from anywhere inside; an unselected group only by its frame or label |
| Edit | double-click: text card → markdown, note card body → the note itself, note card header → note picker, group frame/label → rename, edge → label |
| Open | ↗ button or ⌘+click (note in NotePlan / URL in browser); links inside text open on plain click |
| Colors | toolbar swatches or keys 1–6, 0 clears |
| Group / duplicate / delete | ⌘G / ⌘D / ⌫ |
| Copy & paste | ⌘C / ⌘V (JSON Canvas snippet via the system clipboard — works across canvases) |
| Undo / redo | ⌘Z / ⇧⌘Z |
| Navigate | scroll pans, ⌘+scroll zooms, ⇧+drag pans, F zooms to selection, double-click background re-fits |

## Settings

- **Canvas folder** — absolute path to the folder holding your `.canvas` files (e.g. your
Obsidian vault). Relative paths given to the command are resolved against it.

## Roadmap

- v0.1: read-only viewer
- v0.2: editing — drag nodes, edit text, save back to the same `.canvas` file (this version)
- Creating/deleting nodes and edges; group dragging with children
- Importer command: convert a canvas into a structured NotePlan note

## Platform limitations (API wishlist)

Feature parity with Obsidian Canvas is limited by a few NotePlan plugin-API gaps.
Native support (or new APIs) for these would close the remaining distance:

1. **No directory listing API.** `DataStore.loadData` reads a known path, but plugins
cannot enumerate folder contents — so an autocomplete over media/attachment files
(like Obsidian's "drag to add media") is not implementable. A `DataStore.listFiles(folder)`
would solve it.
2. **Website previews are best-effort.** Link cards embed sites via `<iframe>`, and
WKWebView honours `X-Frame-Options`/CSP — many sites render blank. Obsidian bypasses
this with per-card native webviews; an equivalent would need NotePlan-side support.
3. **No custom file-type editors.** A plugin cannot register itself as the editor for
`.canvas` files, so canvases can't open from the sidebar like notes — the closest
integration is the HTML split view this plugin uses. First-class JSON Canvas support
in NotePlan (an open format: https://jsoncanvas.org) would be the ideal endgame.
4. **No note-change events.** Note contents shown inside file cards are read when the
canvas opens; edits made in the NotePlan editor while the canvas is open aren't
pushed to the window (workaround: reopen the canvas).

## Support

Please report issues in the [NotePlan/plugins repository](https://github.com/NotePlan/plugins/issues)
or ping @lifeplayer67 in the NotePlan Discord.
115 changes: 115 additions & 0 deletions lifeplayer67.CanvasView/__tests__/canvasRenderer.test.js
Original file line number Diff line number Diff line change
@@ -0,0 +1,115 @@
/* global describe, test, expect */
// @flow
import { renderCanvasHTML, inlineJSON } from '../src/canvasRenderer'

const client = require('../requiredFiles/canvasClient.js')

const sampleCanvas = {
nodes: [
{ id: 'a', type: 'text', text: '# Hello **bold** [[My Note]]', x: 0, y: 0, width: 250, height: 120, color: '4' },
{ id: 'g', type: 'group', label: 'Group A', x: -50, y: -50, width: 800, height: 500 },
],
edges: [{ id: 'e1', fromNode: 'a', toNode: 'g', label: 'links to' }],
}

describe('canvasRenderer shell', () => {
test('embeds canvas data, path, toolbar and client script', () => {
const html = renderCanvasHTML(sampleCanvas, 'test', '../../../Notes/Canvases/x.canvas')
expect(html).toContain('window.__canvas = ')
expect(html).toContain('"links to"')
expect(html).toContain('window.__canvasPath = "../../../Notes/Canvases/x.canvas"')
expect(html).toContain('src="./canvasClient.js"')
expect(html).toContain('id="toolbar"')
expect(html).toContain('data-color="6"')
})

test('embedded JSON cannot break out of the script tag', () => {
const evil = { nodes: [{ id: 'x', type: 'text', text: '</script><script>alert(1)</script>', x: 0, y: 0, width: 10, height: 10 }] }
expect(renderCanvasHTML(evil, 't', 'p')).not.toContain('</script><script>alert(1)')
expect(inlineJSON('</script>')).not.toContain('</script>')
})
})

describe('canvasClient pure helpers', () => {
test('resolveColor maps presets and passes hex through', () => {
expect(client.resolveColor('1')).toEqual('#e93147')
expect(client.resolveColor('#123456')).toEqual('#123456')
expect(client.resolveColor(undefined, '#fff')).toEqual('#fff')
})

test('renderMarkdown handles heading, bold, wikilink and escapes HTML', () => {
const html = client.renderMarkdown('# Hi\n\n**yo** [[Some/Path/Note.md|alias]]\n<script>x</script>')
expect(html).toMatch(/<h1[^>]*>Hi<\/h1>/)
// <b>/<emph> matching NotePlan theme CSS selectors 'p b' / 'p emph'
expect(html).toContain('<b>yo</b>')
expect(html).toContain('data-note-title="Note"')
expect(html).toContain('>alias</a>')
expect(html).not.toContain('<script>x')
})

test('renderMarkdown renders tables', () => {
const html = client.renderMarkdown('| Col A | Col B |\n|---|---|\n| a1 | b1 |\n| a2 | b2 |')
expect(html).toContain('<table>')
expect(html).toMatch(/<th[^>]*>Col A<\/th>/)
expect(html).toContain('b2</td>')
})

test('renderMarkdown renders images: external as-is, relative against assetBase', () => {
const html = client.renderMarkdown('![pic](https://x.y/i.png)\n\n![](%D0%9F_attachments/IMG.jpg)\n\n![[embed.png]]', '../../Notes/Sub/')
expect(html).toContain('src="https://x.y/i.png"')
expect(html).toContain('src="../../Notes/Sub/%D0%9F_attachments/IMG.jpg"') // pre-encoded: not encoded twice
expect(html).toContain('src="../../Notes/Sub/embed.png"')
})

test('renderMarkdown renders checkboxes as GFM tasklists', () => {
const html = client.renderMarkdown('- [ ] todo\n- [x] done')
expect(html).toContain('type="checkbox"')
expect(html).toContain('checked')
})

test('autoSides picks facing sides', () => {
const a = { x: 0, y: 0, width: 100, height: 100 }
const right = { x: 500, y: 0, width: 100, height: 100 }
const below = { x: 0, y: 500, width: 100, height: 100 }
expect(client.autoSides(a, right)).toEqual(['right', 'left'])
expect(client.autoSides(a, below)).toEqual(['bottom', 'top'])
})

test('nearestSide finds the closest node side to a point', () => {
const n = { x: 0, y: 0, width: 100, height: 100 }
expect(client.nearestSide(n, 105, 50)).toEqual('right')
expect(client.nearestSide(n, 50, -5)).toEqual('top')
})

test('nodeInsideGroup detects full containment', () => {
const g = { id: 'g', x: 0, y: 0, width: 500, height: 500 }
expect(client.nodeInsideGroup({ id: 'a', x: 10, y: 10, width: 100, height: 100 }, g)).toBe(true)
expect(client.nodeInsideGroup({ id: 'b', x: 450, y: 10, width: 100, height: 100 }, g)).toBe(false)
expect(client.nodeInsideGroup(g, g)).toBe(false)
})

test('filterNotes ranks prefix > title > path matches and caps at 8', () => {
const idx = [
{ t: 'Заплановане', f: 'Work/Заплановане.md' },
{ t: 'Плани', f: 'Notes/Плани.md' },
{ t: 'Інше', f: 'Архів/плани-старі.md' },
...Array.from({ length: 10 }, (_, i) => ({ t: `Нотатка ${i}`, f: `n${i}.md` })),
]
const res = client.filterNotes(idx, 'план')
expect(res[0].t).toEqual('Плани')
expect(res[1].t).toEqual('Заплановане')
expect(res[2].t).toEqual('Інше')
expect(client.filterNotes(idx, '').length).toBeLessThanOrEqual(8)
})

test('isGroupBorderHit: frame activates, interior does not', () => {
const g = { x: 0, y: 0, width: 500, height: 400 }
expect(client.isGroupBorderHit(g, 5, 200, 14)).toBe(true) // left frame
expect(client.isGroupBorderHit(g, 250, 395, 14)).toBe(true) // bottom frame
expect(client.isGroupBorderHit(g, 250, 200, 14)).toBe(false) // deep interior
})

test('genId makes 16-char hex ids like Obsidian', () => {
expect(client.genId()).toMatch(/^[0-9a-f]{16}$/)
})
})
80 changes: 80 additions & 0 deletions lifeplayer67.CanvasView/plugin.json
Original file line number Diff line number Diff line change
@@ -0,0 +1,80 @@
{
"noteplan.minAppVersion": "3.9.0",
"macOS.minVersion": "10.13.0",
"plugin.id": "lifeplayer67.CanvasView",
"plugin.name": "Canvas View",
"plugin.version": "0.9.5",
"plugin.lastUpdateInfo": "Full markdown rendering via showdown (tables, lists, images), notes open beside the canvas, caret-at-click editing, Obsidian-style card scrolling.",
"plugin.description": "Edit Obsidian Canvas / JSON Canvas (.canvas) files inside NotePlan: create and connect cards, move, resize, color, label edges, undo/redo — fully compatible with Obsidian.",
"plugin.icon": "fa-diagram-project",
"plugin.iconColor": "orange-500",
"plugin.iconStyle": "solid",
"plugin.author": "lifeplayer67",
"plugin.dependencies": [],
"plugin.script": "script.js",
"plugin.requiredFiles": ["canvasClient.js", "showdown.min.js"],
"plugin.url": "https://github.com/NotePlan/plugins/blob/main/lifeplayer67.CanvasView/README.md",
"plugin.changelog": "https://github.com/NotePlan/plugins/blob/main/lifeplayer67.CanvasView/CHANGELOG.md",
"plugin.commands": [
{
"name": "open canvas",
"description": "Open a JSON Canvas (.canvas) file in a viewer window",
"jsFunction": "openCanvas",
"alias": ["canvas", "jsoncanvas", "obsidian canvas"],
"arguments": ["path to the .canvas file (absolute, or relative to the canvas folder setting)"]
},
{
"name": "new canvas",
"description": "Create a new empty JSON Canvas file and open it",
"jsFunction": "newCanvas",
"alias": ["create canvas"],
"arguments": ["name for the new .canvas file (a folder prefix is allowed, e.g. 'Projects/Ideas')"]
},
{
"name": "onMessageFromHTMLView",
"description": "Canvas View: callback to receive messages from the HTML window",
"jsFunction": "onMessageFromHTMLView",
"hidden": true
}
],
"plugin.settings": [
{
"type": "heading",
"title": "Canvas View settings"
},
{
"key": "canvasFolder",
"title": "Canvas folder",
"description": "Subfolder of your NotePlan Notes folder that holds your .canvas files (e.g. 'Canvases'). Relative paths given to the 'open canvas' command are resolved against it; absolute paths (starting with /) are used as-is.",
"type": "string",
"default": "",
"required": false
},
{
"key": "openInSplitView",
"title": "Open canvas in the main window (split view)",
"description": "When on, the canvas opens as a split pane inside the main NotePlan window (like a note). When off, it opens in a separate floating window.",
"type": "bool",
"default": true,
"required": false
},
{
"key": "openNoteIn",
"title": "Open notes from cards in",
"description": "Where a note opens when you ⌘+click a card or press its ↗ button. 'Split view' keeps the canvas on screen.",
"type": "string",
"choices": ["Split view", "New window", "Main editor"],
"default": "Split view",
"required": false
},
{
"key": "_logLevel",
"title": "Log level",
"description": "Set how much logging output will be displayed when executing Canvas View commands in NotePlan Plugin Console.",
"type": "string",
"choices": ["DEBUG", "INFO", "WARN", "ERROR", "none"],
"default": "INFO",
"required": true
}
]
}
Loading