From 803c84d239aa9c70368fb61fbc48c1fd90cd0c5c Mon Sep 17 00:00:00 2001 From: Vladyslav Pohorielov Date: Thu, 24 Sep 2026 21:19:13 +0300 Subject: [PATCH 1/8] lifeplayer67.CanvasView 0.4.4: edit Obsidian Canvas (.canvas) files inside NotePlan MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit New plugin: opens JSON Canvas files in an HTML window with full editing — create/move/resize/color cards, draw edges, labels, multi-select, undo/redo. File cards render the linked note's content and edit it inline (creating the note on first save); styling follows the user's NotePlan theme. --- lifeplayer67.CanvasView/CHANGELOG.md | 42 + lifeplayer67.CanvasView/README.md | 33 + .../__tests__/canvasRenderer.test.js | 74 ++ lifeplayer67.CanvasView/plugin.json | 56 ++ .../requiredFiles/canvasClient.js | 856 ++++++++++++++++++ lifeplayer67.CanvasView/src/NPCanvasView.js | 172 ++++ lifeplayer67.CanvasView/src/canvasRenderer.js | 132 +++ lifeplayer67.CanvasView/src/index.js | 29 + 8 files changed, 1394 insertions(+) create mode 100644 lifeplayer67.CanvasView/CHANGELOG.md create mode 100644 lifeplayer67.CanvasView/README.md create mode 100644 lifeplayer67.CanvasView/__tests__/canvasRenderer.test.js create mode 100644 lifeplayer67.CanvasView/plugin.json create mode 100644 lifeplayer67.CanvasView/requiredFiles/canvasClient.js create mode 100644 lifeplayer67.CanvasView/src/NPCanvasView.js create mode 100644 lifeplayer67.CanvasView/src/canvasRenderer.js create mode 100644 lifeplayer67.CanvasView/src/index.js diff --git a/lifeplayer67.CanvasView/CHANGELOG.md b/lifeplayer67.CanvasView/CHANGELOG.md new file mode 100644 index 000000000..9894c242b --- /dev/null +++ b/lifeplayer67.CanvasView/CHANGELOG.md @@ -0,0 +1,42 @@ +# Canvas View Changelog + +## [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. diff --git a/lifeplayer67.CanvasView/README.md b/lifeplayer67.CanvasView/README.md new file mode 100644 index 000000000..0a6581e72 --- /dev/null +++ b/lifeplayer67.CanvasView/README.md @@ -0,0 +1,33 @@ +# Canvas View plugin + +View [JSON Canvas](https://jsoncanvas.org) (`.canvas`) files — the open format used by +Obsidian Canvas — right inside NotePlan. + +## Commands + +- `/open canvas` — opens a `.canvas` file in an editor window with pan & zoom. + - 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. + +## 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 + +## Support + +Please report issues in the [NotePlan/plugins repository](https://github.com/NotePlan/plugins/issues) +or ping @lifeplayer67 in the NotePlan Discord. diff --git a/lifeplayer67.CanvasView/__tests__/canvasRenderer.test.js b/lifeplayer67.CanvasView/__tests__/canvasRenderer.test.js new file mode 100644 index 000000000..732ae64b1 --- /dev/null +++ b/lifeplayer67.CanvasView/__tests__/canvasRenderer.test.js @@ -0,0 +1,74 @@ +/* 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: '', x: 0, y: 0, width: 10, height: 10 }] } + expect(renderCanvasHTML(evil, 't', 'p')).not.toContain('')).not.toContain('') + }) +}) + +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**yo** [[Some/Path/Note.md|alias]]\n') + expect(html).toContain('

Hi

') + // / inside

, matching NotePlan theme CSS selectors 'p b' / 'p emph' + expect(html).toContain('

yo') + expect(html).toContain('data-note-title="Note"') + expect(html).toContain('>alias') + expect(html).not.toContain(' + + +` + +/** + * Open a .canvas (JSON Canvas) file in a viewer/editor window. + * @param {string?} pathArg path to the file; prompted for if not given + */ +export async function openCanvas(pathArg?: string): Promise { + try { + const settings = getSettings() + let path = pathArg ?? '' + if (path === '') { + const answer = await CommandBar.textPrompt( + 'Canvas View', + settings.canvasFolder !== '' ? `Path to .canvas file (relative to Notes/${settings.canvasFolder})` : 'Path to .canvas file (relative to your Notes folder)', + '', + ) + if (answer === false || answer == null || String(answer).trim() === '') return + path = String(answer) + } + const fullPath = resolveCanvasPath(path, settings.canvasFolder) + logDebug(pluginJson, `openCanvas: loading '${fullPath}'`) + + let canvas: ?TCanvasData + try { + canvas = loadCanvas(fullPath) + } catch (parseError) { + await showMessage(`'${fullPath}' is not valid JSON Canvas: ${parseError.message}`, 'OK', 'Canvas View') + return + } + if (canvas == null) { + await showMessage(`Could not read '${fullPath}'. Check the path and the 'Canvas folder' plugin setting.`, 'OK', 'Canvas View') + return + } + const nodeCount = canvas.nodes?.length ?? 0 + const edgeCount = canvas.edges?.length ?? 0 + logDebug(pluginJson, `openCanvas: parsed ${nodeCount} nodes, ${edgeCount} edges`) + + // Resolve file-node contents so the window can render and edit them inline + const fileContents: { [string]: Object } = {} + for (const node of canvas.nodes ?? []) { + if (node.type !== 'file') continue + const noteTitle = String(node.file ?? '').split('/').pop().replace(/\.[^.]+$/, '') + const matches = DataStore.projectNoteByTitle(noteTitle) ?? [] + fileContents[node.id] = matches.length > 0 ? { found: true, title: noteTitle, content: matches[0].content ?? '' } : { found: false, title: noteTitle } + } + + const title = fullPath.split('/').pop().replace(/\.canvas$/i, '') + const body = COMMS_BRIDGE_HTML + renderCanvasHTML(canvas, `${title} — ${nodeCount} nodes, ${edgeCount} edges`, fullPath, fileContents) + + await showHTMLV2(body, { + windowTitle: `Canvas: ${title}`, + customId: WINDOW_CUSTOM_ID, + savedFilename: '../../lifeplayer67.CanvasView/canvas-view.html', + width: 1100, + height: 750, + shouldFocus: true, + // no generalCSSIn: let showHTMLV2 inject CSS generated from the user's NotePlan + // theme — it defines --bg-main-color/--fg-main-color/etc., which our CSS consumes + }) + } catch (error) { + logError(pluginJson, `openCanvas: ${error.message}`) + clo(error, 'openCanvas error') + } +} + +/** + * Receive messages sent from the HTML window via the np.Shared comms bridge. + * data can arrive as a JSON string when invoked through x-callback (for testing). + */ +export async function onMessageFromHTMLView(actionType: string, data: any): Promise { + try { + // Tolerate the one-array calling convention too: onMessageFromHTMLView(['action', {...}]) + if (Array.isArray(actionType)) { + // $FlowIgnore[incompatible-type] + ;[actionType, data] = actionType + } + const payload = typeof data === 'string' ? JSON.parse(data) : data ?? {} + logDebug(pluginJson, `onMessageFromHTMLView: '${actionType}'`) + switch (actionType) { + case 'saveCanvas': { + const jsonStr = String(payload.json) + JSON.parse(jsonStr) // validate before touching the file — never write a broken canvas + const ok = DataStore.saveData(jsonStr, payload.path, true) + if (!ok) logError(pluginJson, `saveCanvas: saveData failed for '${payload.path}'`) + break + } + case 'saveNote': { + const noteTitle = String(payload.title ?? '') + const content = String(payload.content) + const matches = DataStore.projectNoteByTitle(noteTitle) ?? [] + if (matches.length > 0) { + matches[0].content = content + logDebug(pluginJson, `saveNote: updated '${noteTitle}' (${content.length} chars)`) + } else { + // First write into a card whose note doesn't exist yet: create it + const withTitle = content.startsWith('# ') ? content : `# ${noteTitle}\n${content}` + const filename = DataStore.newNoteWithContent(withTitle, '') + logDebug(pluginJson, `saveNote: created '${filename}' for title '${noteTitle}'`) + } + break + } + case 'openNote': { + const title = String(payload.title ?? '') + const note = await Editor.openNoteByTitle(title) + if (note == null) { + await showMessage(`No note titled '${title}' found in NotePlan. Import it first, then the link will work.`, 'OK', 'Canvas View') + } + break + } + default: + logError(pluginJson, `onMessageFromHTMLView: unknown actionType '${actionType}'`) + } + } catch (error) { + logError(pluginJson, `onMessageFromHTMLView: ${error.message}`) + } +} diff --git a/lifeplayer67.CanvasView/src/canvasRenderer.js b/lifeplayer67.CanvasView/src/canvasRenderer.js new file mode 100644 index 000000000..5649440b6 --- /dev/null +++ b/lifeplayer67.CanvasView/src/canvasRenderer.js @@ -0,0 +1,132 @@ +// @flow +// ----------------------------------------------------------------------------- +// Server-side shell for the canvas window. All rendering and interaction live +// in requiredFiles/canvasClient.js (loaded via +

+
+
${SWATCHES}
×
🗑
+
${escapeHtml(title)}
+
+ +` +} + +const CSS = ` +html, body { margin: 0; padding: 0; overflow: hidden; height: 100%; } +#viewport { position: absolute; inset: 0; overflow: hidden; cursor: grab; + background: var(--bg-main-color, #1e1e1e); + background-image: radial-gradient(circle, rgba(128,128,128,0.25) 1px, transparent 1px); + background-size: 24px 24px; } +#viewport.panning { cursor: grabbing; } +#viewport.linking { cursor: crosshair; } +#world { position: absolute; transform-origin: 0 0; } +.node { position: absolute; box-sizing: border-box; border: 2px solid; border-radius: 8px; + padding: 10px 12px; overflow: visible; font-family: -apple-system, sans-serif; font-size: 14px; + background: var(--bg-alt-color, #262626); color: var(--fg-main-color, #d4d4d4); } +.node > .content, .node > a, .node > span { display: block; overflow: hidden; width: 100%; height: 100%; } +.node.group { border-style: dashed; border-radius: 12px; z-index: 0; padding: 0; background-clip: padding-box; } +.node.text, .node.file, .node.link { z-index: 2; cursor: default; } +.node.selected { outline: 2px solid #4da3ff; outline-offset: 2px; } +.node.dragging { opacity: 0.85; } +.group-label { position: absolute; top: -24px; left: 8px; font-weight: 600; font-size: 13px; } +.node a { color: #4da3ff; text-decoration: none; cursor: pointer; -webkit-user-drag: none; } +.node code { background: rgba(128,128,128,0.25); border-radius: 3px; padding: 0 3px; font-size: 0.9em; } +.node h1, .node h2, .node h3, .node h4 { margin: 0 0 6px 0; background: transparent; } +.node p { margin: 0; } +.node .blank { height: 0.7em; } +.node textarea { position: absolute; inset: 0; width: 100%; height: 100%; box-sizing: border-box; + border: none; outline: none; resize: none; padding: 10px 12px; font: inherit; + background: var(--bg-alt-color, #262626); color: inherit; border-radius: 6px; z-index: 5; } +.file-link { display: flex !important; align-items: center; gap: 6px; font-weight: 500; } +.file-head { display: flex; align-items: center; gap: 6px; font-weight: 600; font-size: 13px; + padding-bottom: 5px; margin-bottom: 6px; border-bottom: 1px solid rgba(128,128,128,0.3); } +.fh-name { flex: 1; overflow: hidden; text-overflow: ellipsis; white-space: nowrap; } +.open-btn { cursor: pointer; opacity: 0.55; font-size: 15px; padding: 0 2px; } +.open-btn:hover { opacity: 1; } +.file-content { font-size: 12px; } +.file-missing { opacity: 0.65; font-style: italic; font-size: 12px; } +.card-input { position: absolute; inset: 0; width: 100%; height: 100%; box-sizing: border-box; + border: none; outline: 2px solid #4da3ff; border-radius: 6px; padding: 10px 12px; font: inherit; + background: var(--bg-alt-color, #262626); color: inherit; z-index: 6; } +.anchor { position: absolute; width: 12px; height: 12px; border-radius: 50%; background: #4da3ff; + opacity: 0; transition: opacity 0.12s; z-index: 6; cursor: crosshair; } +.node:hover .anchor { opacity: 0.9; } +.anchor[data-side="top"] { top: -7px; left: calc(50% - 6px); } +.anchor[data-side="bottom"] { bottom: -7px; left: calc(50% - 6px); } +.anchor[data-side="left"] { left: -7px; top: calc(50% - 6px); } +.anchor[data-side="right"] { right: -7px; top: calc(50% - 6px); } +.handle { position: absolute; width: 10px; height: 10px; background: #fff; border: 1.5px solid #4da3ff; + border-radius: 2px; z-index: 7; } +.handle-nw { top: -6px; left: -6px; cursor: nwse-resize; } +.handle-n { top: -6px; left: calc(50% - 5px); cursor: ns-resize; } +.handle-ne { top: -6px; right: -6px; cursor: nesw-resize; } +.handle-e { right: -6px; top: calc(50% - 5px); cursor: ew-resize; } +.handle-se { bottom: -6px; right: -6px; cursor: nwse-resize; } +.handle-s { bottom: -6px; left: calc(50% - 5px); cursor: ns-resize; } +.handle-sw { bottom: -6px; left: -6px; cursor: nesw-resize; } +.handle-w { left: -6px; top: calc(50% - 5px); cursor: ew-resize; } +#edges { position: absolute; z-index: 1; overflow: visible; } +.edge-line { fill: none; stroke-width: 2; pointer-events: none; } +.edge-line.selected { stroke-width: 4; filter: drop-shadow(0 0 3px #4da3ff); } +.edge-hit { fill: none; stroke: transparent; stroke-width: 14; pointer-events: stroke; cursor: pointer; } +.edge-temp { fill: none; stroke: #4da3ff; stroke-width: 2; stroke-dasharray: 6 4; pointer-events: none; } +.edge-label { fill: var(--fg-main-color, #d4d4d4); font-size: 12px; font-family: -apple-system, sans-serif; cursor: pointer; } +.label-input { position: absolute; z-index: 8; width: 160px; padding: 3px 6px; font-size: 12px; + font-family: -apple-system, sans-serif; border: 1px solid #4da3ff; border-radius: 4px; + background: var(--bg-alt-color, #262626); color: var(--fg-main-color, #d4d4d4); } +.node .label-input { top: -28px; left: 4px; } +#marquee { position: absolute; border: 1px solid #4da3ff; background: rgba(77,163,255,0.12); z-index: 20; pointer-events: none; } +#toolbar { position: fixed; top: 10px; left: 50%; transform: translateX(-50%); z-index: 30; + display: none; gap: 8px; align-items: center; padding: 8px 12px; border-radius: 10px; + background: rgba(30,30,30,0.9); border: 1px solid rgba(128,128,128,0.35); } +#toolbar .swatch { width: 18px; height: 18px; border-radius: 50%; cursor: pointer; + border: 1.5px solid rgba(255,255,255,0.4); text-align: center; line-height: 15px; + color: #ccc; font-family: -apple-system, sans-serif; font-size: 13px; } +#toolbar .swatch[data-color="1"] { background: #e93147; } +#toolbar .swatch[data-color="2"] { background: #ec7500; } +#toolbar .swatch[data-color="3"] { background: #e0ac00; } +#toolbar .swatch[data-color="4"] { background: #08b94e; } +#toolbar .swatch[data-color="5"] { background: #00bfbc; } +#toolbar .swatch[data-color="6"] { background: #7852ee; } +#toolbar .tb-delete { cursor: pointer; font-size: 15px; margin-left: 4px; } +#hud { position: fixed; bottom: 10px; left: 12px; z-index: 10; font-family: -apple-system, sans-serif; + font-size: 12px; color: rgba(128,128,128,0.9); user-select: none; } +#zoom-level { margin-left: 10px; } +@media (prefers-color-scheme: light) { + #viewport { background: var(--bg-main-color, #fafafa); } + .node { background: var(--bg-alt-color, #ffffff); color: var(--fg-main-color, #222); } + .node textarea { background: var(--bg-alt-color, #ffffff); } + #toolbar { background: rgba(250,250,250,0.95); } + .label-input { background: #fff; color: #222; } +} +` diff --git a/lifeplayer67.CanvasView/src/index.js b/lifeplayer67.CanvasView/src/index.js new file mode 100644 index 000000000..7f9a16641 --- /dev/null +++ b/lifeplayer67.CanvasView/src/index.js @@ -0,0 +1,29 @@ +// @flow +// ----------------------------------------------------------------------------- +// Canvas View plugin for NotePlan +// Entry point: exports the plugin's commands and lifecycle hooks. +// ----------------------------------------------------------------------------- + +export { openCanvas, onMessageFromHTMLView } from './NPCanvasView' + +import pluginJson from '../plugin.json' +import { updateSettingData, pluginUpdated } from '@helpers/NPConfiguration' +import { logDebug, logError } from '@helpers/dev' + +export function init(): void { + // Placeholder for future startup work (e.g. checking for plugin updates) +} + +export function onSettingsUpdated(): void { + // Nothing to do yet; settings are read fresh on every command run +} + +export async function onUpdateOrInstall(): Promise { + try { + logDebug(pluginJson, 'onUpdateOrInstall running') + const updateResult = await updateSettingData(pluginJson) + await pluginUpdated(pluginJson, { code: updateResult, message: 'Settings updated' }) + } catch (error) { + logError(pluginJson, `onUpdateOrInstall: ${error.message}`) + } +} From d9eb0f938a9750aef155ef4fcc84ad6ac78af259 Mon Sep 17 00:00:00 2001 From: Vladyslav Pohorielov Date: Thu, 24 Sep 2026 21:45:21 +0300 Subject: [PATCH 2/8] lifeplayer67.CanvasView 0.5.0: working groups, rubber-band selection, new-card palette - Fix groups being unclickable: the edges SVG layer sits above groups and was swallowing their mouse events; it is now pointer-events: none (edge hit paths re-enable them), so groups select, drag, resize and rename normally. - Group creation: Cmd+G wraps the selection in a group; the selection toolbar gained a group button; the palette can create an empty group. - Obsidian-style selection: plain drag on the background draws a rubber-band selection (Shift+drag pans; scroll/trackpad pans as before). - New-card palette (text / note / group) that opens the matching editor right after creating the card. - Render markdown checkboxes in cards. --- lifeplayer67.CanvasView/CHANGELOG.md | 14 +++ .../__tests__/canvasRenderer.test.js | 6 ++ lifeplayer67.CanvasView/plugin.json | 4 +- .../requiredFiles/canvasClient.js | 89 ++++++++++++++++--- lifeplayer67.CanvasView/src/canvasRenderer.js | 23 ++++- 5 files changed, 118 insertions(+), 18 deletions(-) diff --git a/lifeplayer67.CanvasView/CHANGELOG.md b/lifeplayer67.CanvasView/CHANGELOG.md index 9894c242b..252d1323d 100644 --- a/lifeplayer67.CanvasView/CHANGELOG.md +++ b/lifeplayer67.CanvasView/CHANGELOG.md @@ -1,5 +1,19 @@ # Canvas View Changelog +## [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: diff --git a/lifeplayer67.CanvasView/__tests__/canvasRenderer.test.js b/lifeplayer67.CanvasView/__tests__/canvasRenderer.test.js index 732ae64b1..d14f6113d 100644 --- a/lifeplayer67.CanvasView/__tests__/canvasRenderer.test.js +++ b/lifeplayer67.CanvasView/__tests__/canvasRenderer.test.js @@ -47,6 +47,12 @@ describe('canvasClient pure helpers', () => { expect(html).not.toContain('
-
${SWATCHES}
×
🗑
+
${SWATCHES}
×
⊞
🗑
+
+ + + +
${escapeHtml(title)}
@@ -43,7 +48,7 @@ window.__fileContents = ${inlineJSON(fileContents)} const CSS = ` html, body { margin: 0; padding: 0; overflow: hidden; height: 100%; } -#viewport { position: absolute; inset: 0; overflow: hidden; cursor: grab; +#viewport { position: absolute; inset: 0; overflow: hidden; cursor: default; background: var(--bg-main-color, #1e1e1e); background-image: radial-gradient(circle, rgba(128,128,128,0.25) 1px, transparent 1px); background-size: 24px 24px; } @@ -95,7 +100,9 @@ html, body { margin: 0; padding: 0; overflow: hidden; height: 100%; } .handle-s { bottom: -6px; left: calc(50% - 5px); cursor: ns-resize; } .handle-sw { bottom: -6px; left: -6px; cursor: nesw-resize; } .handle-w { left: -6px; top: calc(50% - 5px); cursor: ew-resize; } -#edges { position: absolute; z-index: 1; overflow: visible; } +/* pointer-events none is load-bearing: the svg spans the whole canvas ABOVE groups + (z 1 vs 0), so without it the transparent svg box eats every click on a group */ +#edges { position: absolute; z-index: 1; overflow: visible; pointer-events: none; } .edge-line { fill: none; stroke-width: 2; pointer-events: none; } .edge-line.selected { stroke-width: 4; filter: drop-shadow(0 0 3px #4da3ff); } .edge-hit { fill: none; stroke: transparent; stroke-width: 14; pointer-events: stroke; cursor: pointer; } @@ -118,7 +125,16 @@ html, body { margin: 0; padding: 0; overflow: hidden; height: 100%; } #toolbar .swatch[data-color="4"] { background: #08b94e; } #toolbar .swatch[data-color="5"] { background: #00bfbc; } #toolbar .swatch[data-color="6"] { background: #7852ee; } +#toolbar .tb-group { cursor: pointer; font-size: 17px; margin-left: 6px; color: var(--fg-main-color, #d4d4d4); } +#toolbar .tb-group:hover, #toolbar .tb-delete:hover { transform: scale(1.15); } #toolbar .tb-delete { cursor: pointer; font-size: 15px; margin-left: 4px; } +#palette { position: fixed; top: 10px; left: 10px; z-index: 30; display: flex; flex-direction: column; + gap: 4px; padding: 6px; border-radius: 10px; background: rgba(30,30,30,0.9); + border: 1px solid rgba(128,128,128,0.35); } +#palette button { border: none; border-radius: 6px; padding: 4px 10px; font-size: 12px; text-align: left; + font-family: -apple-system, sans-serif; cursor: pointer; background: transparent; + color: var(--fg-main-color, #d4d4d4); } +#palette button:hover { background: rgba(128,128,128,0.25); } #hud { position: fixed; bottom: 10px; left: 12px; z-index: 10; font-family: -apple-system, sans-serif; font-size: 12px; color: rgba(128,128,128,0.9); user-select: none; } #zoom-level { margin-left: 10px; } @@ -127,6 +143,7 @@ html, body { margin: 0; padding: 0; overflow: hidden; height: 100%; } .node { background: var(--bg-alt-color, #ffffff); color: var(--fg-main-color, #222); } .node textarea { background: var(--bg-alt-color, #ffffff); } #toolbar { background: rgba(250,250,250,0.95); } + #palette { background: rgba(250,250,250,0.95); } .label-input { background: #fff; color: #222; } } ` From 7c97de450ab634fc8f27a7ab3b73ff782302ee07 Mon Sep 17 00:00:00 2001 From: Vladyslav Pohorielov Date: Fri, 25 Sep 2026 18:06:01 +0300 Subject: [PATCH 3/8] lifeplayer67.CanvasView 0.6.1: groups, rubber-band select, drag-to-add, note picker, media, split view Groups (0.5.0): - Fix groups being unclickable: the edges SVG layer sat above groups and swallowed their mouse events; now pointer-events: none with edge hit paths re-enabling them, so groups select, drag, resize and rename normally. - Cmd+G wraps the selection in a group; toolbar gained a group button. - Obsidian-style rubber-band selection on background drag (Shift+drag pans). Obsidian parity (0.6.x): - Drag-to-add palette: drop a text/note/group button where the card should go. - Note picker with autocomplete over all NotePlan notes for file cards. - Images (png/jpg/gif/webp/svg) render inside file cards via data URIs. - Link cards embed website previews (iframe; X-Frame-Options limits apply). - Canvas opens in a split pane inside the main window (setting to revert); one window/sidebar entry per canvas. - /open canvas with no argument lists all indexed .canvas files to pick from. - loadCanvas auto-repairs fold-ellipsis corruption written when a .canvas is opened and folded in the NotePlan text editor; fit() waits for a restored split pane to gain size before laying out. - README documents platform limitations / API wishlist for upstream. --- lifeplayer67.CanvasView/CHANGELOG.md | 15 ++ lifeplayer67.CanvasView/README.md | 20 +++ .../__tests__/canvasRenderer.test.js | 14 ++ lifeplayer67.CanvasView/plugin.json | 12 +- .../requiredFiles/canvasClient.js | 146 ++++++++++++++++-- lifeplayer67.CanvasView/src/NPCanvasView.js | 106 +++++++++++-- lifeplayer67.CanvasView/src/canvasRenderer.js | 28 +++- 7 files changed, 306 insertions(+), 35 deletions(-) diff --git a/lifeplayer67.CanvasView/CHANGELOG.md b/lifeplayer67.CanvasView/CHANGELOG.md index 252d1323d..a23b3117d 100644 --- a/lifeplayer67.CanvasView/CHANGELOG.md +++ b/lifeplayer67.CanvasView/CHANGELOG.md @@ -1,5 +1,20 @@ # Canvas View Changelog +## [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 — diff --git a/lifeplayer67.CanvasView/README.md b/lifeplayer67.CanvasView/README.md index 0a6581e72..271cb2b0f 100644 --- a/lifeplayer67.CanvasView/README.md +++ b/lifeplayer67.CanvasView/README.md @@ -27,6 +27,26 @@ Obsidian Canvas — right inside NotePlan. - 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 `' : '' + inner = '
🔗 ' + escapeHtml(url) + '↗
' + frame } return '
' + inner + anchors + '
' } @@ -270,6 +297,8 @@ } function fit() { + // a restored split pane can report 0×0 for a few frames — wait until it has a size + if (!viewport.clientWidth || !viewport.clientHeight) { setTimeout(fit, 120); return } var b = drawEdges() var w = b[2] - b[0], h = b[3] - b[1] scale = Math.min(viewport.clientWidth / w, viewport.clientHeight / h, 1.5) @@ -642,11 +671,69 @@ var el = world.querySelector('.node[data-id="' + CSS.escape(n.id) + '"]') if (!el) return if (kind === 'text') editTextNode(el) - else if (kind === 'file') editCardValue(el, 'file') + else if (kind === 'file') openNotePicker(el) else if (kind === 'link') editLinkCard(el) else if (kind === 'group') editGroupLabel(el) } + /** Autocomplete picker over the NotePlan note index; free text = raw file path */ + function openNotePicker(el) { + if (el.querySelector('.picker-input')) return + var n = nodeById[el.dataset.id] + var input = document.createElement('input') + input.className = 'card-input picker-input' + input.value = n.file || '' + input.placeholder = 'Назва нотатки або шлях…' + var list = document.createElement('div') + list.className = 'picker' + el.appendChild(input) + el.appendChild(list) + setTimeout(function () { input.focus(); input.select() }, 0) + var items = [], active = 0 + function refresh() { + items = filterNotes(noteIndex, input.value) + active = 0 + list.innerHTML = items + .map(function (it, i) { + return '
' + escapeHtml(it.t) + '' + escapeHtml(it.f) + '
' + }) + .join('') + } + function highlight() { + list.querySelectorAll('.picker-row').forEach(function (row, i) { row.classList.toggle('active', i === active) }) + } + function close() { input.remove(); list.remove() } + function commit(filename) { + var v = filename != null ? filename : input.value.trim() + close() + if (v === '' || v === (n.file || '')) { renderScene(); return } + pushUndo() + n.file = v + delete fileContents[n.id] + renderScene() + persist() + var t = v.split('/').pop().replace(/\.[^.]+$/, '') + if (!/\.(png|jpe?g|gif|webp|svg)$/i.test(v)) toPlugin('getNoteContent', { id: n.id, title: t }) + } + input.addEventListener('input', refresh) + input.addEventListener('keydown', function (ev) { + if (ev.key === 'ArrowDown') { active = Math.min(active + 1, items.length - 1); highlight(); ev.preventDefault() } + else if (ev.key === 'ArrowUp') { active = Math.max(active - 1, 0); highlight(); ev.preventDefault() } + else if (ev.key === 'Enter') { commit(items[active] ? items[active].f : null) } + else if (ev.key === 'Escape') { close(); renderScene() } + ev.stopPropagation() + }) + input.addEventListener('blur', function () { + // slight delay so a click on a picker row lands before we close + setTimeout(function () { if (document.body.contains(input)) { close(); renderScene() } }, 150) + }) + list.addEventListener('mousedown', function (ev) { + var row = ev.target.closest('.picker-row') + if (row) { ev.preventDefault(); commit(items[Number(row.dataset.i)].f) } + }) + refresh() + } + function createTextNode(wx, wy) { createNode('text', wx, wy) } @@ -687,7 +774,24 @@ var px = 0, py = 0, moved = false var marqueeEl = null, marqueeStart = null + var placeKind = null, ghostEl = null + viewport.addEventListener('mousedown', function (e) { + // Obsidian-style drag-to-add: grab a palette button and drop it on the canvas + var paletteBtn = e.target.closest('#palette button') + if (paletteBtn) { + mode = 'place' + placeKind = paletteBtn.dataset.new + px = e.clientX; py = e.clientY; moved = false + ghostEl = document.createElement('div') + ghostEl.id = 'ghost' + ghostEl.textContent = paletteBtn.textContent + ghostEl.style.left = e.clientX + 'px' + ghostEl.style.top = e.clientY + 'px' + document.body.appendChild(ghostEl) + e.preventDefault() + return + } if (e.target.closest('textarea, input, #toolbar, #palette')) return // inline links inside text content keep native click behavior and never start a drag; // card-level links (file/link nodes) fall through so the card can be selected/dragged @@ -706,7 +810,7 @@ if (tEl) { editTextNode(tEl); return } var fEl = e.target.closest('.node.file') if (fEl) { - if (e.target.closest('.file-head')) editCardValue(fEl, 'file') + if (e.target.closest('.file-head')) openNotePicker(fEl) else editNoteContent(fEl) return } @@ -812,6 +916,9 @@ doResize(e) } else if (mode === 'link') { updateEdgeDraft(e) + } else if (mode === 'place') { + ghostEl.style.left = e.clientX + 'px' + ghostEl.style.top = e.clientY + 'px' } else if (mode === 'marquee') { var x1 = Math.min(marqueeStart.x, e.clientX), y1 = Math.min(marqueeStart.y, e.clientY) var x2 = Math.max(marqueeStart.x, e.clientX), y2 = Math.max(marqueeStart.y, e.clientY) @@ -830,6 +937,20 @@ }, true) window.addEventListener('mouseup', function (e) { + if (mode === 'place') { + if (ghostEl) ghostEl.remove() + ghostEl = null + if (moved && !e.target.closest('#palette')) { + var wp = toWorld(e.clientX, e.clientY) + createNode(placeKind, wp.x, wp.y) + } else if (!moved) { + var cc = toWorld(viewport.clientWidth / 2, viewport.clientHeight / 2) + createNode(placeKind, cc.x, cc.y) + } + placeKind = null + mode = null + return + } if (mode === 'drag' && moved) persist() if (mode === 'link' && edgeDraft) finishEdge(e) if (mode === 'marquee') { @@ -861,6 +982,11 @@ document.addEventListener('click', function (e) { var link = e.target.closest('a') if (link && link.closest('.node')) { + // explicit open buttons (↗ on link cards) open on plain click + if (link.classList.contains('ext-open')) { + if (moved) e.preventDefault() + return + } var noteTitle = link.dataset.noteTitle var isInline = !!link.closest('.content') if (isInline) { @@ -873,12 +999,6 @@ if (noteTitle) { e.preventDefault(); toPlugin('openNote', { title: noteTitle }) } return // external URL cards keep the default ⌘+click open (target=_blank) } - var paletteBtn = e.target.closest('#palette button') - if (paletteBtn) { - var c = toWorld(viewport.clientWidth / 2, viewport.clientHeight / 2) - createNode(paletteBtn.dataset.new, c.x, c.y) - return - } var openBtn = e.target.closest('.open-btn') if (openBtn && !moved) { toPlugin('openNote', { title: openBtn.closest('.node').dataset.noteTitle }) @@ -913,7 +1033,7 @@ window.addEventListener('resize', fit) // ---------- boot ---------- - console.log('canvasClient v0.5.0 booted: ' + canvas.nodes.length + ' nodes, ' + canvas.edges.length + ' edges') + console.log('canvasClient v0.6.1 booted: ' + canvas.nodes.length + ' nodes, ' + canvas.edges.length + ' edges') renderScene() fit() })() diff --git a/lifeplayer67.CanvasView/src/NPCanvasView.js b/lifeplayer67.CanvasView/src/NPCanvasView.js index 93e6ed599..15508b4ae 100644 --- a/lifeplayer67.CanvasView/src/NPCanvasView.js +++ b/lifeplayer67.CanvasView/src/NPCanvasView.js @@ -5,20 +5,45 @@ import pluginJson from '../plugin.json' import { renderCanvasHTML, type TCanvasData } from './canvasRenderer' -import { showHTMLV2 } from '@helpers/HTMLView' +import { showHTMLV2, sendToHTMLWindow } from '@helpers/HTMLView' import { logDebug, logError, clo } from '@helpers/dev' import { showMessage } from '@helpers/userInput' const PLUGIN_ID = pluginJson['plugin.id'] -const WINDOW_CUSTOM_ID = `${PLUGIN_ID}.main` + +/** One window/sidebar entry per canvas, so two canvases never fight over one pane */ +function windowIdFor(fullPath: string): string { + return `${PLUGIN_ID}.${fullPath.split('/').pop() ?? 'main'}` +} type TSettings = { canvasFolder: string, + openInSplitView: boolean, } function getSettings(): TSettings { const settings = DataStore.settings ?? {} - return { canvasFolder: (settings.canvasFolder ?? '').trim() } + return { + canvasFolder: (settings.canvasFolder ?? '').trim(), + openInSplitView: settings.openInSplitView !== false, + } +} + +const IMAGE_EXT_RE = /\.(png|jpe?g|gif|webp|svg)$/i +const IMAGE_MIME: { [string]: string } = { png: 'image/png', jpg: 'image/jpeg', jpeg: 'image/jpeg', gif: 'image/gif', webp: 'image/webp', svg: 'image/svg+xml' } + +/** Try to load an image referenced by a file node as a data URI (base64, ≤ ~2.5 MB) */ +function loadImageDataURI(filePath: string): ?string { + const candidates = [`../../../Notes/${filePath}`] + if (filePath.startsWith('Notes/')) candidates.push(`../../../${filePath}`) + for (const p of candidates) { + const b64 = DataStore.loadData(p, false) + if (b64 != null && b64 !== '' && b64.length < 3_500_000) { + const ext = (filePath.split('.').pop() ?? '').toLowerCase() + return `data:${IMAGE_MIME[ext] ?? 'image/png'};base64,${b64}` + } + } + return null } /** @@ -40,7 +65,18 @@ function resolveCanvasPath(input: string, canvasFolder: string): string { function loadCanvas(fullPath: string): ?TCanvasData { const raw = DataStore.loadData(fullPath, true) if (raw == null || raw === '') return null - return JSON.parse(raw) + try { + return JSON.parse(raw) + } catch (parseError) { + // Seen in the wild: stray '…' (U+2026) after '[' / '{' and unicode whitespace + // between tokens (source unclear — possibly a sync/text-layer round-trip). + // Strip garbage that sits OUTSIDE strings and retry before giving up. + const cleaned = raw.replace(/([[{,]\s*)…\s*/g, '$1').replace(/[\u2028\u2029\u00A0\u200B\uFEFF]/g, ' ') + const parsed = JSON.parse(cleaned) // still broken → throws to the caller's message + logError(pluginJson, `loadCanvas: auto-repaired invalid JSON in '${fullPath}' (was: ${parseError.message})`) + DataStore.saveData(cleaned, fullPath, true) + return parsed + } } // HTML pieces for the two-way comms bridge (np.Shared must be installed). @@ -64,16 +100,26 @@ export async function openCanvas(pathArg?: string): Promise { try { const settings = getSettings() let path = pathArg ?? '' + let fullPath = '' if (path === '') { - const answer = await CommandBar.textPrompt( - 'Canvas View', - settings.canvasFolder !== '' ? `Path to .canvas file (relative to Notes/${settings.canvasFolder})` : 'Path to .canvas file (relative to your Notes folder)', - '', - ) - if (answer === false || answer == null || String(answer).trim() === '') return - path = String(answer) + // NotePlan indexes .canvas files as notes, so we can offer a proper chooser + const canvasNotes = DataStore.projectNotes.filter((n) => n.filename.toLowerCase().endsWith('.canvas')) + if (canvasNotes.length > 0) { + const options = canvasNotes.map((n) => n.filename) + const res = await CommandBar.showOptions(options, 'Який canvas відкрити?') + if (res == null) return + fullPath = `../../../Notes/${options[res.index]}` + } else { + const answer = await CommandBar.textPrompt( + 'Canvas View', + settings.canvasFolder !== '' ? `Path to .canvas file (relative to Notes/${settings.canvasFolder})` : 'Path to .canvas file (relative to your Notes folder)', + '', + ) + if (answer === false || answer == null || String(answer).trim() === '') return + path = String(answer) + } } - const fullPath = resolveCanvasPath(path, settings.canvasFolder) + if (fullPath === '') fullPath = resolveCanvasPath(path, settings.canvasFolder) logDebug(pluginJson, `openCanvas: loading '${fullPath}'`) let canvas: ?TCanvasData @@ -91,25 +137,41 @@ export async function openCanvas(pathArg?: string): Promise { const edgeCount = canvas.edges?.length ?? 0 logDebug(pluginJson, `openCanvas: parsed ${nodeCount} nodes, ${edgeCount} edges`) - // Resolve file-node contents so the window can render and edit them inline + // Resolve file-node contents so the window can render and edit them inline; + // image files become data URIs so they display right in the card const fileContents: { [string]: Object } = {} for (const node of canvas.nodes ?? []) { if (node.type !== 'file') continue - const noteTitle = String(node.file ?? '').split('/').pop().replace(/\.[^.]+$/, '') + const filePath = String(node.file ?? '') + if (IMAGE_EXT_RE.test(filePath)) { + const dataURI = loadImageDataURI(filePath) + fileContents[node.id] = dataURI != null ? { found: true, media: dataURI } : { found: false, title: filePath.split('/').pop() } + continue + } + const noteTitle = filePath.split('/').pop().replace(/\.[^.]+$/, '') const matches = DataStore.projectNoteByTitle(noteTitle) ?? [] fileContents[node.id] = matches.length > 0 ? { found: true, title: noteTitle, content: matches[0].content ?? '' } : { found: false, title: noteTitle } } + // Note index for the autocomplete picker in the window + const noteIndex = DataStore.projectNotes + .filter((n) => (n.title ?? '') !== '') + .map((n) => ({ t: n.title ?? '', f: n.filename })) + const title = fullPath.split('/').pop().replace(/\.canvas$/i, '') - const body = COMMS_BRIDGE_HTML + renderCanvasHTML(canvas, `${title} — ${nodeCount} nodes, ${edgeCount} edges`, fullPath, fileContents) + const body = COMMS_BRIDGE_HTML + renderCanvasHTML(canvas, `${title} — ${nodeCount} nodes, ${edgeCount} edges`, fullPath, fileContents, noteIndex) await showHTMLV2(body, { windowTitle: `Canvas: ${title}`, - customId: WINDOW_CUSTOM_ID, + customId: windowIdFor(fullPath), savedFilename: '../../lifeplayer67.CanvasView/canvas-view.html', width: 1100, height: 750, shouldFocus: true, + showInMainWindow: settings.openInSplitView, + splitView: settings.openInSplitView, + icon: 'diagram-project', + iconColor: 'orange-500', // no generalCSSIn: let showHTMLV2 inject CSS generated from the user's NotePlan // theme — it defines --bg-main-color/--fg-main-color/etc., which our CSS consumes }) @@ -155,6 +217,18 @@ export async function onMessageFromHTMLView(actionType: string, data: any): Prom } break } + case 'getNoteContent': { + // The picker attached a card to a different note: send its content back + const noteTitle = String(payload.title ?? '') + const matches = DataStore.projectNoteByTitle(noteTitle) ?? [] + await sendToHTMLWindow(windowIdFor(String(payload.path ?? '')), 'NOTE_CONTENT', { + id: payload.id, + found: matches.length > 0, + title: noteTitle, + content: matches.length > 0 ? matches[0].content ?? '' : '', + }) + break + } case 'openNote': { const title = String(payload.title ?? '') const note = await Editor.openNoteByTitle(title) diff --git a/lifeplayer67.CanvasView/src/canvasRenderer.js b/lifeplayer67.CanvasView/src/canvasRenderer.js index afde001c6..e57f3a141 100644 --- a/lifeplayer67.CanvasView/src/canvasRenderer.js +++ b/lifeplayer67.CanvasView/src/canvasRenderer.js @@ -24,21 +24,28 @@ const SWATCHES = ['1', '2', '3', '4', '5', '6'] .map((c) => `
`) .join('') -export function renderCanvasHTML(canvas: TCanvasData, title: string, canvasPath: string, fileContents: { [string]: Object } = {}): string { +export function renderCanvasHTML( + canvas: TCanvasData, + title: string, + canvasPath: string, + fileContents: { [string]: Object } = {}, + noteIndex: Array<{ t: string, f: string }> = [], +): string { return `
${SWATCHES}
×
⊞
🗑
- - - + + +
${escapeHtml(title)}
@@ -83,6 +90,19 @@ html, body { margin: 0; padding: 0; overflow: hidden; height: 100%; } .card-input { position: absolute; inset: 0; width: 100%; height: 100%; box-sizing: border-box; border: none; outline: 2px solid #4da3ff; border-radius: 6px; padding: 10px 12px; font: inherit; background: var(--bg-alt-color, #262626); color: inherit; z-index: 6; } +.picker-input { inset: auto 0 auto 0; top: 0; height: 40px; } +.picker { position: absolute; top: 42px; left: 0; right: 0; z-index: 40; border-radius: 8px; + background: var(--bg-alt-color, #262626); border: 1px solid rgba(128,128,128,0.4); + box-shadow: 0 8px 24px rgba(0,0,0,0.4); overflow: hidden; } +.picker-row { padding: 6px 10px; font-size: 13px; cursor: pointer; display: flex; gap: 8px; align-items: baseline; } +.picker-row .p-path { font-size: 11px; opacity: 0.55; overflow: hidden; text-overflow: ellipsis; white-space: nowrap; } +.picker-row.active, .picker-row:hover { background: rgba(77,163,255,0.25); } +.card-img { max-width: 100%; max-height: calc(100% - 30px); object-fit: contain; display: block; } +.web-frame { width: 100%; height: calc(100% - 30px); border: none; border-radius: 0 0 6px 6px; + pointer-events: none; background: #fff; } +#ghost { position: fixed; z-index: 50; pointer-events: none; padding: 8px 14px; border-radius: 8px; + border: 2px dashed #4da3ff; background: rgba(77,163,255,0.15); font-family: -apple-system, sans-serif; + font-size: 13px; color: var(--fg-main-color, #d4d4d4); transform: translate(-50%, -50%); } .anchor { position: absolute; width: 12px; height: 12px; border-radius: 50%; background: #4da3ff; opacity: 0; transition: opacity 0.12s; z-index: 6; cursor: crosshair; } .node:hover .anchor { opacity: 0.9; } From 16a5d8385e4b75aa2fc1218bdd4c321c28dbbc5d Mon Sep 17 00:00:00 2001 From: Vladyslav Pohorielov Date: Fri, 25 Sep 2026 18:22:01 +0300 Subject: [PATCH 4/8] lifeplayer67.CanvasView 0.6.4: Obsidian-style group interaction - An unselected group's interior behaves like the background: rubber-band its children (enables nested groups), double-click creates a card inside; the group activates only by its frame (~14px, zoom-independent) or label. - A selected group drags from anywhere inside its bounds. - Starting any background action drops the old selection immediately; a rubber band that fully encloses a group selects the group as well (containment, not intersection, so bands drawn inside a group don't grab it). --- lifeplayer67.CanvasView/CHANGELOG.md | 11 ++++ .../__tests__/canvasRenderer.test.js | 7 +++ lifeplayer67.CanvasView/plugin.json | 2 +- .../requiredFiles/canvasClient.js | 51 ++++++++++++++++--- 4 files changed, 63 insertions(+), 8 deletions(-) diff --git a/lifeplayer67.CanvasView/CHANGELOG.md b/lifeplayer67.CanvasView/CHANGELOG.md index a23b3117d..75a75410f 100644 --- a/lifeplayer67.CanvasView/CHANGELOG.md +++ b/lifeplayer67.CanvasView/CHANGELOG.md @@ -1,5 +1,16 @@ # Canvas View Changelog +## [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: diff --git a/lifeplayer67.CanvasView/__tests__/canvasRenderer.test.js b/lifeplayer67.CanvasView/__tests__/canvasRenderer.test.js index 5afacd24e..9c34de236 100644 --- a/lifeplayer67.CanvasView/__tests__/canvasRenderer.test.js +++ b/lifeplayer67.CanvasView/__tests__/canvasRenderer.test.js @@ -88,6 +88,13 @@ describe('canvasClient pure helpers', () => { 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}$/) }) diff --git a/lifeplayer67.CanvasView/plugin.json b/lifeplayer67.CanvasView/plugin.json index fa26cbe36..ae3aff143 100644 --- a/lifeplayer67.CanvasView/plugin.json +++ b/lifeplayer67.CanvasView/plugin.json @@ -3,7 +3,7 @@ "macOS.minVersion": "10.13.0", "plugin.id": "lifeplayer67.CanvasView", "plugin.name": "Canvas View", - "plugin.version": "0.6.0", + "plugin.version": "0.6.4", "plugin.lastUpdateInfo": "Obsidian parity push: drag-to-add palette, note autocomplete picker, images render in file cards, website previews in link cards, opens in split view inside the main window.", "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", diff --git a/lifeplayer67.CanvasView/requiredFiles/canvasClient.js b/lifeplayer67.CanvasView/requiredFiles/canvasClient.js index 7a4ba55fe..cfb33cb1f 100644 --- a/lifeplayer67.CanvasView/requiredFiles/canvasClient.js +++ b/lifeplayer67.CanvasView/requiredFiles/canvasClient.js @@ -92,6 +92,12 @@ return n.id !== g.id && n.x >= g.x && n.y >= g.y && n.x + n.width <= g.x + g.width && n.y + n.height <= g.y + g.height } + /** Groups activate only by their frame: is the point within `margin` of the border? */ + function isGroupBorderHit(g, wx, wy, margin) { + var interior = wx > g.x + margin && wx < g.x + g.width - margin && wy > g.y + margin && wy < g.y + g.height - margin + return !interior + } + /** Filter the note index for the autocomplete picker: title-prefix > title > path matches */ function filterNotes(index, q) { var query = String(q || '').toLowerCase().trim() @@ -113,6 +119,7 @@ autoSides: autoSides, nearestSide: nearestSide, nodeInsideGroup: nodeInsideGroup, + isGroupBorderHit: isGroupBorderHit, genId: genId, filterNotes: filterNotes, } @@ -774,7 +781,7 @@ var px = 0, py = 0, moved = false var marqueeEl = null, marqueeStart = null - var placeKind = null, ghostEl = null + var placeKind = null, ghostEl = null, interiorGroupClick = false viewport.addEventListener('mousedown', function (e) { // Obsidian-style drag-to-add: grab a palette button and drop it on the canvas @@ -817,8 +824,16 @@ var lEl = e.target.closest('.node.link') if (lEl) { editLinkCard(lEl); return } var gEl = e.target.closest('.node.group') - if (gEl) { editGroupLabel(gEl); return } - var eEl = e.target.closest('.edge-hit, .edge-line, .edge-label') + if (gEl) { + // frame/label double-click renames the group; its interior acts like the + // background — a new text card appears right there, inside the group + var gdp = toWorld(e.clientX, e.clientY) + var gdn = nodeById[gEl.dataset.id] + if (e.target.closest('.group-label') || (gdn && isGroupBorderHit(gdn, gdp.x, gdp.y, 14 / scale))) editGroupLabel(gEl) + else createTextNode(gdp.x, gdp.y) + return + } + var eEl = e.target.closest('.edge-hit, .edge-label, .edge-line') if (eEl) { editEdgeLabel(eEl.dataset.edgeId); return } if (!e.target.closest('.node')) { var wpt = toWorld(e.clientX, e.clientY) @@ -843,6 +858,18 @@ return } var nodeEl = e.target.closest('.node') + // An UNSELECTED group's empty interior behaves like the background (so you can + // rubber-band its children); it activates only by its frame or label. Once the + // group IS selected, dragging anywhere inside its bounds moves it. + interiorGroupClick = false + if (nodeEl && nodeEl.classList.contains('group') && !e.target.closest('.group-label') && !selectedNodes.has(nodeEl.dataset.id)) { + var gp = toWorld(e.clientX, e.clientY) + var gn = nodeById[nodeEl.dataset.id] + if (gn && !isGroupBorderHit(gn, gp.x, gp.y, 14 / scale)) { + nodeEl = null + interiorGroupClick = true + } + } if (nodeEl) { var n = nodeById[nodeEl.dataset.id] if (!e.shiftKey && !selectedNodes.has(n.id)) { @@ -885,6 +912,11 @@ viewport.classList.add('panning') } else { mode = 'marquee' + // starting a rubber-band (or a plain background click) drops the old selection right away + if (selectedNodes.size || selectedEdges.size) { + clearSelection() + updateSelectionUI() + } marqueeStart = { x: e.clientX, y: e.clientY } marqueeEl = document.createElement('div') marqueeEl.id = 'marquee' @@ -958,11 +990,16 @@ var r = marqueeEl.getBoundingClientRect() var w1 = toWorld(r.left, r.top), w2 = toWorld(r.right, r.bottom) canvas.nodes.forEach(function (n) { - if (n.type === 'group') return // rubber-band selects cards, not surrounding groups + if (n.type === 'group') { + // a group joins the selection only when the rubber band encloses it fully — + // intersection would grab the surrounding group on every band drawn inside it + if (n.x >= w1.x && n.y >= w1.y && n.x + n.width <= w2.x && n.y + n.height <= w2.y) selectedNodes.add(n.id) + return + } if (n.x < w2.x && n.x + n.width > w1.x && n.y < w2.y && n.y + n.height > w1.y) selectedNodes.add(n.id) }) - } else if (!e.target.closest('.node, a, #toolbar, #palette, .edge-hit, .edge-line')) { - clearSelection() // plain click on the background deselects + } else if (interiorGroupClick || !e.target.closest('.node, a, #toolbar, #palette, .edge-hit, .edge-line')) { + clearSelection() // plain click on the background (or a group's interior) deselects } marqueeEl.remove() marqueeEl = null @@ -1033,7 +1070,7 @@ window.addEventListener('resize', fit) // ---------- boot ---------- - console.log('canvasClient v0.6.1 booted: ' + canvas.nodes.length + ' nodes, ' + canvas.edges.length + ' edges') + console.log('canvasClient v0.6.4 booted: ' + canvas.nodes.length + ' nodes, ' + canvas.edges.length + ' edges') renderScene() fit() })() From c52d4dfd49f7625e6902f4d1410ea56c528cb13c Mon Sep 17 00:00:00 2001 From: Vladyslav Pohorielov Date: Fri, 25 Sep 2026 18:40:25 +0300 Subject: [PATCH 5/8] lifeplayer67.CanvasView 0.7.2: copy/paste, live note refresh, zoom to selection MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - Cmd+C puts the selection (cards + edges between them) on the system clipboard as a JSON Canvas snippet; Cmd+V pastes at the view center with fresh ids — works across canvases and with snippets copied from Obsidian. - File cards re-pull their note content when the pointer enters the canvas pane (window focus is unreliable in the embedded split view), throttled to 2s and never interrupting an open editor or an in-flight drag. - File card bodies scroll (wheel over a card scrolls it instead of panning), so long notes are readable without resizing the card. - F zooms to the selection; with nothing selected it fits the whole canvas. --- lifeplayer67.CanvasView/CHANGELOG.md | 10 ++ lifeplayer67.CanvasView/plugin.json | 2 +- .../requiredFiles/canvasClient.js | 104 +++++++++++++++++- lifeplayer67.CanvasView/src/NPCanvasView.js | 21 ++++ lifeplayer67.CanvasView/src/canvasRenderer.js | 2 +- 5 files changed, 135 insertions(+), 4 deletions(-) diff --git a/lifeplayer67.CanvasView/CHANGELOG.md b/lifeplayer67.CanvasView/CHANGELOG.md index 75a75410f..c0f283e3a 100644 --- a/lifeplayer67.CanvasView/CHANGELOG.md +++ b/lifeplayer67.CanvasView/CHANGELOG.md @@ -1,5 +1,15 @@ # Canvas View Changelog +## [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: diff --git a/lifeplayer67.CanvasView/plugin.json b/lifeplayer67.CanvasView/plugin.json index ae3aff143..a3d61fe41 100644 --- a/lifeplayer67.CanvasView/plugin.json +++ b/lifeplayer67.CanvasView/plugin.json @@ -3,7 +3,7 @@ "macOS.minVersion": "10.13.0", "plugin.id": "lifeplayer67.CanvasView", "plugin.name": "Canvas View", - "plugin.version": "0.6.4", + "plugin.version": "0.7.0", "plugin.lastUpdateInfo": "Obsidian parity push: drag-to-add palette, note autocomplete picker, images render in file cards, website previews in link cards, opens in split view inside the main window.", "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", diff --git a/lifeplayer67.CanvasView/requiredFiles/canvasClient.js b/lifeplayer67.CanvasView/requiredFiles/canvasClient.js index cfb33cb1f..5a8e79e21 100644 --- a/lifeplayer67.CanvasView/requiredFiles/canvasClient.js +++ b/lifeplayer67.CanvasView/requiredFiles/canvasClient.js @@ -142,7 +142,11 @@ window.__onPluginMessage = function (type, data) { if (type === 'NOTE_CONTENT') { fileContents[data.id] = { found: data.found, title: data.title, content: data.content || '' } - renderScene() + // don't nuke an open editor or an in-flight drag; flush on mouseup instead + if (mode || document.querySelector('.node textarea, .card-input, .label-input')) pendingRender = true + else renderScene() + } else if (type === 'CLIPBOARD') { + pasteSnippet(String(data.text || '')) } } @@ -319,6 +323,11 @@ } viewport.addEventListener('wheel', function (e) { + // let a scrollable card body scroll natively instead of panning the canvas + if (!e.ctrlKey && !e.metaKey) { + var scrollable = e.target.closest('.file-content') + if (scrollable && scrollable.scrollHeight > scrollable.clientHeight) return + } e.preventDefault() if (e.ctrlKey || e.metaKey) { var factor = Math.exp(-e.deltaY * 0.01) @@ -380,6 +389,66 @@ persist() } + /** ⌘C: put the selection on the system clipboard as a JSON Canvas snippet */ + function copySelection() { + if (!selectedNodes.size) return + var nodes = canvas.nodes.filter(function (n) { return selectedNodes.has(n.id) }) + var edges = canvas.edges.filter(function (e) { + return (selectedNodes.has(e.fromNode) && selectedNodes.has(e.toNode)) || selectedEdges.has(e.id) + }) + toPlugin('setClipboard', { text: JSON.stringify({ nodes: nodes, edges: edges }, null, '\t') }) + } + + /** ⌘V (after the plugin sends the clipboard back): paste a snippet at the view center */ + function pasteSnippet(text) { + var snippet + try { snippet = JSON.parse(text) } catch (e) { return } + if (!snippet || !Array.isArray(snippet.nodes) || !snippet.nodes.length) return + pushUndo() + var xs = snippet.nodes.map(function (n) { return n.x }), ys = snippet.nodes.map(function (n) { return n.y }) + var x2 = snippet.nodes.map(function (n) { return n.x + n.width }), y2 = snippet.nodes.map(function (n) { return n.y + n.height }) + var cx = (Math.min.apply(null, xs) + Math.max.apply(null, x2)) / 2 + var cy = (Math.min.apply(null, ys) + Math.max.apply(null, y2)) / 2 + var target = toWorld(viewport.clientWidth / 2, viewport.clientHeight / 2) + var dx = Math.round(target.x - cx), dy = Math.round(target.y - cy) + var idMap = {} + clearSelection() + snippet.nodes.forEach(function (n) { + var c = JSON.parse(JSON.stringify(n)) + idMap[n.id] = c.id = genId() + c.x += dx + c.y += dy + canvas.nodes.push(c) + selectedNodes.add(c.id) + }) + ;(snippet.edges || []).forEach(function (e) { + if (!idMap[e.fromNode] || !idMap[e.toNode]) return + var c = JSON.parse(JSON.stringify(e)) + c.id = genId() + c.fromNode = idMap[e.fromNode] + c.toNode = idMap[e.toNode] + canvas.edges.push(c) + }) + renderScene() + persist() + } + + /** F: zoom to the selection (or fit everything when nothing is selected) */ + function fitSelection() { + if (!selectedNodes.size) { fit(); return } + var sel = canvas.nodes.filter(function (n) { return selectedNodes.has(n.id) }) + var pad = 80 + var minX = Math.min.apply(null, sel.map(function (n) { return n.x })) - pad + var minY = Math.min.apply(null, sel.map(function (n) { return n.y })) - pad + var maxX = Math.max.apply(null, sel.map(function (n) { return n.x + n.width })) + pad + var maxY = Math.max.apply(null, sel.map(function (n) { return n.y + n.height })) + pad + var w = maxX - minX, h = maxY - minY + scale = Math.min(viewport.clientWidth / w, viewport.clientHeight / h, 2) + tx = (viewport.clientWidth - w * scale) / 2 - minX * scale + ty = (viewport.clientHeight - h * scale) / 2 - minY * scale + apply() + } + function duplicateSelection() { if (!selectedNodes.size) return pushUndo() @@ -1012,6 +1081,10 @@ dragUndoPushed = false viewport.classList.remove('panning') mode = null + if (pendingRender && !document.querySelector('.node textarea, .card-input, .label-input')) { + pendingRender = false + renderScene() + } }) // ---------- clicks: note links & toolbar ---------- @@ -1061,16 +1134,43 @@ if (meta && (e.key === 'Z' || (e.key === 'z' && e.shiftKey))) { e.preventDefault(); redo(); return } if (meta && e.key === 'd') { e.preventDefault(); duplicateSelection(); return } if (meta && e.key === 'g') { e.preventDefault(); groupSelection(); return } + if (meta && e.key === 'c') { e.preventDefault(); copySelection(); return } + if (meta && e.key === 'v') { e.preventDefault(); toPlugin('getClipboard', {}); return } + if (e.key === 'f' && !meta) { fitSelection(); return } if (e.key === 'Backspace' || e.key === 'Delete') { e.preventDefault(); deleteSelection(); return } if (e.key === 'Escape') { clearSelection(); updateSelectionUI(); return } if (/^[1-6]$/.test(e.key)) { applyColor(e.key); return } if (e.key === '0') applyColor(null) }) + // Re-pull note contents when the user comes back to the canvas, so file cards + // don't go stale after edits in the NotePlan editor. In the embedded split view + // the window 'focus' event is unreliable, so the pointer ENTERING the pane is + // the primary trigger (that's exactly "I clicked over to the canvas"). + var lastRefresh = 0 + var pendingRender = false + function refreshNoteCards() { + var now = Date.now() + if (now - lastRefresh < 2000) return + if (document.querySelector('.node textarea, .card-input, .label-input')) return + lastRefresh = now + var items = [] + canvas.nodes.forEach(function (n) { + var fc = fileContents[n.id] + if (n.type === 'file' && fc && fc.found && !fc.media) items.push({ id: n.id, title: fc.title }) + }) + if (items.length) { + console.log('canvasClient: refreshing ' + items.length + ' note card(s)') + toPlugin('refreshNotes', { items: items }) + } + } + viewport.addEventListener('mouseenter', refreshNoteCards) + window.addEventListener('focus', refreshNoteCards) + window.addEventListener('resize', fit) // ---------- boot ---------- - console.log('canvasClient v0.6.4 booted: ' + canvas.nodes.length + ' nodes, ' + canvas.edges.length + ' edges') + console.log('canvasClient v0.7.2 booted: ' + canvas.nodes.length + ' nodes, ' + canvas.edges.length + ' edges') renderScene() fit() })() diff --git a/lifeplayer67.CanvasView/src/NPCanvasView.js b/lifeplayer67.CanvasView/src/NPCanvasView.js index 15508b4ae..211ae8449 100644 --- a/lifeplayer67.CanvasView/src/NPCanvasView.js +++ b/lifeplayer67.CanvasView/src/NPCanvasView.js @@ -229,6 +229,27 @@ export async function onMessageFromHTMLView(actionType: string, data: any): Prom }) break } + case 'setClipboard': { + Clipboard.string = String(payload.text ?? '') + break + } + case 'getClipboard': { + await sendToHTMLWindow(windowIdFor(String(payload.path ?? '')), 'CLIPBOARD', { text: Clipboard.string ?? '' }) + break + } + case 'refreshNotes': { + const windowId = windowIdFor(String(payload.path ?? '')) + for (const item of payload.items ?? []) { + const matches = DataStore.projectNoteByTitle(String(item.title)) ?? [] + await sendToHTMLWindow(windowId, 'NOTE_CONTENT', { + id: item.id, + found: matches.length > 0, + title: String(item.title), + content: matches.length > 0 ? matches[0].content ?? '' : '', + }) + } + break + } case 'openNote': { const title = String(payload.title ?? '') const note = await Editor.openNoteByTitle(title) diff --git a/lifeplayer67.CanvasView/src/canvasRenderer.js b/lifeplayer67.CanvasView/src/canvasRenderer.js index e57f3a141..ff7ee2771 100644 --- a/lifeplayer67.CanvasView/src/canvasRenderer.js +++ b/lifeplayer67.CanvasView/src/canvasRenderer.js @@ -85,7 +85,7 @@ html, body { margin: 0; padding: 0; overflow: hidden; height: 100%; } .fh-name { flex: 1; overflow: hidden; text-overflow: ellipsis; white-space: nowrap; } .open-btn { cursor: pointer; opacity: 0.55; font-size: 15px; padding: 0 2px; } .open-btn:hover { opacity: 1; } -.file-content { font-size: 12px; } +.file-content { font-size: 12px; overflow-y: auto !important; max-height: calc(100% - 34px); } .file-missing { opacity: 0.65; font-style: italic; font-size: 12px; } .card-input { position: absolute; inset: 0; width: 100%; height: 100%; box-sizing: border-box; border: none; outline: 2px solid #4da3ff; border-radius: 6px; padding: 10px 12px; font: inherit; From f050d353dce9894e2643e339aa2f29f79938258c Mon Sep 17 00:00:00 2001 From: Vladyslav Pohorielov Date: Fri, 25 Sep 2026 18:53:07 +0300 Subject: [PATCH 6/8] lifeplayer67.CanvasView 0.8.0: /new canvas command, English UI, README polish - New command '/new canvas': creates an empty .canvas (Canvas folder setting or Canvases/ by default, folder prefix in the name supported) and opens it; openCanvas refactored so both commands share showCanvasWindow(). - All UI strings in English for the community release; README gains an editor cheat-sheet and updated command docs. - Flow errors fixed (nullable split().pop(), no param reassignment). --- lifeplayer67.CanvasView/CHANGELOG.md | 7 ++ lifeplayer67.CanvasView/README.md | 21 +++++- lifeplayer67.CanvasView/plugin.json | 11 ++- .../requiredFiles/canvasClient.js | 10 +-- lifeplayer67.CanvasView/src/NPCanvasView.js | 74 +++++++++++++++---- lifeplayer67.CanvasView/src/canvasRenderer.js | 8 +- lifeplayer67.CanvasView/src/index.js | 2 +- 7 files changed, 106 insertions(+), 27 deletions(-) diff --git a/lifeplayer67.CanvasView/CHANGELOG.md b/lifeplayer67.CanvasView/CHANGELOG.md index c0f283e3a..b358f68e9 100644 --- a/lifeplayer67.CanvasView/CHANGELOG.md +++ b/lifeplayer67.CanvasView/CHANGELOG.md @@ -1,5 +1,12 @@ # Canvas View Changelog +## [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 diff --git a/lifeplayer67.CanvasView/README.md b/lifeplayer67.CanvasView/README.md index 271cb2b0f..60f535e8e 100644 --- a/lifeplayer67.CanvasView/README.md +++ b/lifeplayer67.CanvasView/README.md @@ -5,7 +5,10 @@ Obsidian Canvas — right inside NotePlan. ## Commands -- `/open canvas` — opens a `.canvas` file in an editor window with pan & zoom. +- `/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 @@ -15,6 +18,22 @@ Obsidian Canvas — right inside NotePlan. 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 diff --git a/lifeplayer67.CanvasView/plugin.json b/lifeplayer67.CanvasView/plugin.json index a3d61fe41..577c23884 100644 --- a/lifeplayer67.CanvasView/plugin.json +++ b/lifeplayer67.CanvasView/plugin.json @@ -3,8 +3,8 @@ "macOS.minVersion": "10.13.0", "plugin.id": "lifeplayer67.CanvasView", "plugin.name": "Canvas View", - "plugin.version": "0.7.0", - "plugin.lastUpdateInfo": "Obsidian parity push: drag-to-add palette, note autocomplete picker, images render in file cards, website previews in link cards, opens in split view inside the main window.", + "plugin.version": "0.8.0", + "plugin.lastUpdateInfo": "New '/new canvas' command. Copy/paste snippets, live note refresh, scrollable file cards, zoom to selection, Obsidian-style groups.", "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", @@ -23,6 +23,13 @@ "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", diff --git a/lifeplayer67.CanvasView/requiredFiles/canvasClient.js b/lifeplayer67.CanvasView/requiredFiles/canvasClient.js index 5a8e79e21..72270efef 100644 --- a/lifeplayer67.CanvasView/requiredFiles/canvasClient.js +++ b/lifeplayer67.CanvasView/requiredFiles/canvasClient.js @@ -233,12 +233,12 @@ var body if (fc && fc.media) body = '' else if (fc && fc.found) body = '
' + renderMarkdown(fc.content || '') + '
' - else body = '
Нотатки ще немає в NotePlan.
Подвійний клік — написати (нотатка створиться).
Подвійний клік по заголовку — вибрати іншу.
' - inner = '
📄 ' + escapeHtml(base) + '↗
' + body + else body = '
No such note in NotePlan yet.
Double-click to write it (the note gets created).
Double-click the header to pick another.
' + inner = '
📄 ' + escapeHtml(base) + '↗
' + body } else if (n.type === 'link') { var url = String(n.url || '') var frame = /^https?:\/\//.test(url) ? '' : '' - inner = '
🔗 ' + escapeHtml(url) + '↗
' + frame + inner = '
🔗 ' + escapeHtml(url) + '↗
' + frame } return '
' + inner + anchors + '
' } @@ -759,7 +759,7 @@ var input = document.createElement('input') input.className = 'card-input picker-input' input.value = n.file || '' - input.placeholder = 'Назва нотатки або шлях…' + input.placeholder = 'Note title or path…' var list = document.createElement('div') list.className = 'picker' el.appendChild(input) @@ -1170,7 +1170,7 @@ window.addEventListener('resize', fit) // ---------- boot ---------- - console.log('canvasClient v0.7.2 booted: ' + canvas.nodes.length + ' nodes, ' + canvas.edges.length + ' edges') + console.log('canvasClient v0.8.0 booted: ' + canvas.nodes.length + ' nodes, ' + canvas.edges.length + ' edges') renderScene() fit() })() diff --git a/lifeplayer67.CanvasView/src/NPCanvasView.js b/lifeplayer67.CanvasView/src/NPCanvasView.js index 211ae8449..3e092ca8f 100644 --- a/lifeplayer67.CanvasView/src/NPCanvasView.js +++ b/lifeplayer67.CanvasView/src/NPCanvasView.js @@ -106,7 +106,7 @@ export async function openCanvas(pathArg?: string): Promise { const canvasNotes = DataStore.projectNotes.filter((n) => n.filename.toLowerCase().endsWith('.canvas')) if (canvasNotes.length > 0) { const options = canvasNotes.map((n) => n.filename) - const res = await CommandBar.showOptions(options, 'Який canvas відкрити?') + const res = await CommandBar.showOptions(options, 'Open which canvas?') if (res == null) return fullPath = `../../../Notes/${options[res.index]}` } else { @@ -120,7 +120,51 @@ export async function openCanvas(pathArg?: string): Promise { } } if (fullPath === '') fullPath = resolveCanvasPath(path, settings.canvasFolder) - logDebug(pluginJson, `openCanvas: loading '${fullPath}'`) + await showCanvasWindow(fullPath, settings) + } catch (error) { + logError(pluginJson, `openCanvas: ${error.message}`) + clo(error, 'openCanvas error') + } +} + +/** + * Create a new empty .canvas file and open it. + * @param {string?} nameArg name for the canvas, optionally with a folder ('Ideas' or 'Projects/Ideas') + */ +export async function newCanvas(nameArg?: string): Promise { + try { + const settings = getSettings() + let name = (nameArg ?? '').trim() + if (name === '') { + const answer = await CommandBar.textPrompt('Canvas View', "Name for the new canvas (a folder is fine too, e.g. 'Projects/Ideas')", '') + if (answer === false || answer == null || String(answer).trim() === '') return + name = String(answer).trim() + } + const withExt = /\.canvas$/i.test(name) ? name : `${name}.canvas` + const defaultFolder = settings.canvasFolder !== '' ? settings.canvasFolder.replace(/^\/+|\/+$/g, '') : 'Canvases' + const relPath = withExt.includes('/') ? withExt : `${defaultFolder}/${withExt}` + const fullPath = `../../../Notes/${relPath}` + const existing = DataStore.loadData(fullPath, true) + if (existing == null || existing === '') { + const ok = DataStore.saveData(JSON.stringify({ nodes: [], edges: [] }, null, '\t'), fullPath, true) + if (!ok) { + await showMessage(`Could not create '${relPath}'.`, 'OK', 'Canvas View') + return + } + logDebug(pluginJson, `newCanvas: created '${relPath}'`) + } else { + logDebug(pluginJson, `newCanvas: '${relPath}' already exists — opening it`) + } + await showCanvasWindow(fullPath, settings) + } catch (error) { + logError(pluginJson, `newCanvas: ${error.message}`) + } +} + +/** Load a canvas by its resolved path and open the editor window/pane for it */ +async function showCanvasWindow(fullPath: string, settings: TSettings): Promise { + try { + logDebug(pluginJson, `showCanvasWindow: loading '${fullPath}'`) let canvas: ?TCanvasData try { @@ -145,10 +189,10 @@ export async function openCanvas(pathArg?: string): Promise { const filePath = String(node.file ?? '') if (IMAGE_EXT_RE.test(filePath)) { const dataURI = loadImageDataURI(filePath) - fileContents[node.id] = dataURI != null ? { found: true, media: dataURI } : { found: false, title: filePath.split('/').pop() } + fileContents[node.id] = dataURI != null ? { found: true, media: dataURI } : { found: false, title: filePath.split('/').pop() ?? '' } continue } - const noteTitle = filePath.split('/').pop().replace(/\.[^.]+$/, '') + const noteTitle = (filePath.split('/').pop() ?? '').replace(/\.[^.]+$/, '') const matches = DataStore.projectNoteByTitle(noteTitle) ?? [] fileContents[node.id] = matches.length > 0 ? { found: true, title: noteTitle, content: matches[0].content ?? '' } : { found: false, title: noteTitle } } @@ -158,7 +202,7 @@ export async function openCanvas(pathArg?: string): Promise { .filter((n) => (n.title ?? '') !== '') .map((n) => ({ t: n.title ?? '', f: n.filename })) - const title = fullPath.split('/').pop().replace(/\.canvas$/i, '') + const title = (fullPath.split('/').pop() ?? '').replace(/\.canvas$/i, '') const body = COMMS_BRIDGE_HTML + renderCanvasHTML(canvas, `${title} — ${nodeCount} nodes, ${edgeCount} edges`, fullPath, fileContents, noteIndex) await showHTMLV2(body, { @@ -176,8 +220,8 @@ export async function openCanvas(pathArg?: string): Promise { // theme — it defines --bg-main-color/--fg-main-color/etc., which our CSS consumes }) } catch (error) { - logError(pluginJson, `openCanvas: ${error.message}`) - clo(error, 'openCanvas error') + logError(pluginJson, `showCanvasWindow: ${error.message}`) + clo(error, 'showCanvasWindow error') } } @@ -188,13 +232,15 @@ export async function openCanvas(pathArg?: string): Promise { export async function onMessageFromHTMLView(actionType: string, data: any): Promise { try { // Tolerate the one-array calling convention too: onMessageFromHTMLView(['action', {...}]) - if (Array.isArray(actionType)) { - // $FlowIgnore[incompatible-type] - ;[actionType, data] = actionType + let action = actionType + let rawData = data + if (Array.isArray(action)) { + rawData = action[1] + action = String(action[0]) } - const payload = typeof data === 'string' ? JSON.parse(data) : data ?? {} - logDebug(pluginJson, `onMessageFromHTMLView: '${actionType}'`) - switch (actionType) { + const payload = typeof rawData === 'string' ? JSON.parse(rawData) : rawData ?? {} + logDebug(pluginJson, `onMessageFromHTMLView: '${action}'`) + switch (action) { case 'saveCanvas': { const jsonStr = String(payload.json) JSON.parse(jsonStr) // validate before touching the file — never write a broken canvas @@ -259,7 +305,7 @@ export async function onMessageFromHTMLView(actionType: string, data: any): Prom break } default: - logError(pluginJson, `onMessageFromHTMLView: unknown actionType '${actionType}'`) + logError(pluginJson, `onMessageFromHTMLView: unknown actionType '${action}'`) } } catch (error) { logError(pluginJson, `onMessageFromHTMLView: ${error.message}`) diff --git a/lifeplayer67.CanvasView/src/canvasRenderer.js b/lifeplayer67.CanvasView/src/canvasRenderer.js index ff7ee2771..3060a22b0 100644 --- a/lifeplayer67.CanvasView/src/canvasRenderer.js +++ b/lifeplayer67.CanvasView/src/canvasRenderer.js @@ -41,11 +41,11 @@ window.__noteIndex = ${inlineJSON(noteIndex)}
-
${SWATCHES}
×
⊞
🗑
+
${SWATCHES}
×
⊞
🗑
- - - + + +
${escapeHtml(title)}
diff --git a/lifeplayer67.CanvasView/src/index.js b/lifeplayer67.CanvasView/src/index.js index 7f9a16641..f2104c019 100644 --- a/lifeplayer67.CanvasView/src/index.js +++ b/lifeplayer67.CanvasView/src/index.js @@ -4,7 +4,7 @@ // Entry point: exports the plugin's commands and lifecycle hooks. // ----------------------------------------------------------------------------- -export { openCanvas, onMessageFromHTMLView } from './NPCanvasView' +export { openCanvas, newCanvas, onMessageFromHTMLView } from './NPCanvasView' import pluginJson from '../plugin.json' import { updateSettingData, pluginUpdated } from '@helpers/NPConfiguration' From 6c82f8d8dc3878f8e02529752470a063a83eb0bc Mon Sep 17 00:00:00 2001 From: Vladyslav Pohorielov Date: Fri, 25 Sep 2026 22:12:08 +0300 Subject: [PATCH 7/8] lifeplayer67.CanvasView 0.9.5: real markdown rendering, notes open beside the canvas, editor polish MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - Note cards render through the repo's shared converter (getNoteContentAsHTML / showdown): full lists, GFM tables, tasklists, emoji, NotePlan hashtags, mentions and highlights; local images embed as data URIs (the webview cannot read files outside the plugin folder). Text cards use the same showdown build shipped as a requiredFile, with a thin post-pass for wikilinks, target=_blank and relative image paths — the hand-rolled renderer is gone. - Both card types map strong/em to the theme's 'p b' / 'p emph' selectors and share one content stylesheet, so bold/italic colors, bullets and tables look identical everywhere. - /new canvas command creates an empty canvas and opens it; English UI strings. - Notes open BESIDE the canvas (split view by default; setting offers new window or main editor) instead of replacing the pane. - Editor polish: double-click places the caret at the clicked spot (rendered point mapped back to the markdown source) and scrolls it into view; wheel over an editor scrolls the editor; card bodies scroll only when the card is selected (Obsidian behaviour), otherwise the canvas pans. - Fold-ellipsis auto-repair on load; Flow clean; 13 Jest tests. --- .../__tests__/canvasRenderer.test.js | 28 +++- lifeplayer67.CanvasView/plugin.json | 11 +- .../requiredFiles/canvasClient.js | 133 +++++++++++++----- .../requiredFiles/showdown.min.js | 3 + lifeplayer67.CanvasView/src/NPCanvasView.js | 32 ++++- lifeplayer67.CanvasView/src/canvasRenderer.js | 11 ++ 6 files changed, 171 insertions(+), 47 deletions(-) create mode 100644 lifeplayer67.CanvasView/requiredFiles/showdown.min.js diff --git a/lifeplayer67.CanvasView/__tests__/canvasRenderer.test.js b/lifeplayer67.CanvasView/__tests__/canvasRenderer.test.js index 9c34de236..487ab941a 100644 --- a/lifeplayer67.CanvasView/__tests__/canvasRenderer.test.js +++ b/lifeplayer67.CanvasView/__tests__/canvasRenderer.test.js @@ -38,19 +38,33 @@ describe('canvasClient pure helpers', () => { }) test('renderMarkdown handles heading, bold, wikilink and escapes HTML', () => { - const html = client.renderMarkdown('# Hi\n**yo** [[Some/Path/Note.md|alias]]\n') - expect(html).toContain('

Hi

') - // / inside

, matching NotePlan theme CSS selectors 'p b' / 'p emph' - expect(html).toContain('

yo') + const html = client.renderMarkdown('# Hi\n\n**yo** [[Some/Path/Note.md|alias]]\n') + expect(html).toMatch(/]*>Hi<\/h1>/) + // / matching NotePlan theme CSS selectors 'p b' / 'p emph' + expect(html).toContain('yo') expect(html).toContain('data-note-title="Note"') expect(html).toContain('>alias') expect(html).not.toContain(' ` } @@ -98,6 +99,16 @@ html, body { margin: 0; padding: 0; overflow: hidden; height: 100%; } .picker-row .p-path { font-size: 11px; opacity: 0.55; overflow: hidden; text-overflow: ellipsis; white-space: nowrap; } .picker-row.active, .picker-row:hover { background: rgba(77,163,255,0.25); } .card-img { max-width: 100%; max-height: calc(100% - 30px); object-fit: contain; display: block; } +/* shared content styling so text cards and note cards look identical */ +.node .content img { max-width: 100%; border-radius: 4px; margin: 4px 0; } +.node .content table { border-collapse: collapse; margin: 6px 0; font-size: 0.95em; } +.node .content th, .node .content td { border: 1px solid rgba(128,128,128,0.4); padding: 3px 8px; text-align: left; } +.node .content th { font-weight: 600; background: rgba(128,128,128,0.12); } +.node .content ul, .node .content ol { margin: 2px 0 2px 4px; padding-left: 16px; } +.node .content ul { list-style: disc; } +.node .content li { margin: 1px 0; } +.node .content p { margin: 0; } +.node .content blockquote { margin: 4px 0; padding-left: 8px; border-left: 3px solid rgba(128,128,128,0.5); opacity: 0.9; } .web-frame { width: 100%; height: calc(100% - 30px); border: none; border-radius: 0 0 6px 6px; pointer-events: none; background: #fff; } #ghost { position: fixed; z-index: 50; pointer-events: none; padding: 8px 14px; border-radius: 8px; From 3c533fc872839f5a0c8b31d43a30fa0c10e58ec7 Mon Sep 17 00:00:00 2001 From: Vladyslav Pohorielov Date: Fri, 25 Sep 2026 22:13:45 +0300 Subject: [PATCH 8/8] lifeplayer67.CanvasView: align plugin.json version and changelog at 0.9.5 --- lifeplayer67.CanvasView/CHANGELOG.md | 10 ++++++++++ lifeplayer67.CanvasView/plugin.json | 4 ++-- 2 files changed, 12 insertions(+), 2 deletions(-) diff --git a/lifeplayer67.CanvasView/CHANGELOG.md b/lifeplayer67.CanvasView/CHANGELOG.md index b358f68e9..3eba7b00a 100644 --- a/lifeplayer67.CanvasView/CHANGELOG.md +++ b/lifeplayer67.CanvasView/CHANGELOG.md @@ -1,5 +1,15 @@ # 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 diff --git a/lifeplayer67.CanvasView/plugin.json b/lifeplayer67.CanvasView/plugin.json index 764f53892..c4ea84b79 100644 --- a/lifeplayer67.CanvasView/plugin.json +++ b/lifeplayer67.CanvasView/plugin.json @@ -3,8 +3,8 @@ "macOS.minVersion": "10.13.0", "plugin.id": "lifeplayer67.CanvasView", "plugin.name": "Canvas View", - "plugin.version": "0.8.0", - "plugin.lastUpdateInfo": "New '/new canvas' command. Copy/paste snippets, live note refresh, scrollable file cards, zoom to selection, Obsidian-style groups.", + "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",