From 6cf1d550bbba7f0f2152a281f80493e7bc695dce Mon Sep 17 00:00:00 2001 From: Daniel Anderson Date: Wed, 22 Jul 2026 16:09:01 +1000 Subject: [PATCH] Bump to 1.0.38: fix self-linking tiles and clarify canvas vs. grid MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit A tile could target the very board it lives on, producing a "dead end" tile that appeared to do nothing when clicked — reported as "cannot get into my canvas" after the user's grid board's target picker offered (and they picked) itself, likely the only entry in a fresh vault. TileModal's board/canvas pickers now exclude the current board, saving a self-link is refused, and clicking an already-saved self-linked tile explains the problem instead of silently doing nothing (view.ts's navigateToBoard). Also relabels the New Board dialog ("Canvas"/"Tile grid" instead of "Freeform"/"Grid", canvas now the default — the grid default was part of how the user ended up here) and adds a small layout badge to every board's header, since grid and canvas boards are otherwise visually identical (same extension, same icon). --- CHANGELOG.md | 9 +++++ manifest.json | 2 +- package-lock.json | 4 +-- package.json | 2 +- src/create-board-modal.ts | 22 +++++++----- src/tile-modal.ts | 60 +++++++++++++++++++++++---------- src/view.ts | 23 +++++++++++-- styles.css | 17 ++++++++++ test/fake-vault.ts | 4 +++ test/obsidian-stub.ts | 58 ++++++++++++++++++++++++++++++++ test/tile-modal.test.ts | 71 +++++++++++++++++++++++++++++++++++++++ versions.json | 3 +- 12 files changed, 241 insertions(+), 34 deletions(-) create mode 100644 test/tile-modal.test.ts diff --git a/CHANGELOG.md b/CHANGELOG.md index 1b2ed7c..bd491b9 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -2,6 +2,15 @@ All notable user-facing changes to Visual Notes. +## 1.0.38 + +### Fixed +- A tile could link to the board it lives on, creating a "dead end" tile that appeared to do nothing when clicked — most likely to happen in a fresh vault, where the current board was often the only entry in the target picker. The picker now excludes the current board, saving a self-linked tile is refused with an explanation, and clicking an already-saved self-linked tile (from before this fix) now explains the problem instead of silently doing nothing. + +### Changed +- The New Board dialog now labels the two layouts "Canvas" and "Tile grid" (previously "Freeform"/"Grid") with clearer descriptions, and defaults to Canvas — the grid default was contributing to users ending up with a tile-launcher page when they expected a canvas. +- Every open board now shows a small "Canvas" or "Tile grid" badge next to its name in the header, since the two look identical otherwise (same file extension, same icon). + ## 1.0.37 ### Changed diff --git a/manifest.json b/manifest.json index c8aa21e..64f2bb5 100644 --- a/manifest.json +++ b/manifest.json @@ -1,7 +1,7 @@ { "id": "visual-notes", "name": "Visual Notes", - "version": "1.0.37", + "version": "1.0.38", "minAppVersion": "1.7.2", "description": "A visual workspace built on the Canvas format: nestable freeform boards, icon tile grids, kanban boards, sticky notes, checklists, columns, drawing with pen and highlighter, labels, reactions, and more.", "author": "Daniel Anderson", diff --git a/package-lock.json b/package-lock.json index ea7b0f7..3549860 100644 --- a/package-lock.json +++ b/package-lock.json @@ -1,12 +1,12 @@ { "name": "visual-notes", - "version": "1.0.37", + "version": "1.0.38", "lockfileVersion": 3, "requires": true, "packages": { "": { "name": "visual-notes", - "version": "1.0.37", + "version": "1.0.38", "license": "MIT", "dependencies": { "html-to-image": "^1.11.13", diff --git a/package.json b/package.json index 10d7b59..7d73812 100644 --- a/package.json +++ b/package.json @@ -1,6 +1,6 @@ { "name": "visual-notes", - "version": "1.0.37", + "version": "1.0.38", "description": "Visual Notes — a visual workspace for Obsidian", "main": "main.js", "scripts": { diff --git a/src/create-board-modal.ts b/src/create-board-modal.ts index dc382c9..55b857a 100644 --- a/src/create-board-modal.ts +++ b/src/create-board-modal.ts @@ -44,7 +44,11 @@ export class TemplatePickerModal extends FuzzySuggestModal { // ── Create board modal ──────────────────────────────────────── export class CreateBoardModal extends Modal { - private layout: 'grid' | 'freeform' = 'grid'; + // Canvas is the default: it's the layout most users mean by "a Visual + // Notes board". Defaulting to grid led users who wanted a canvas to + // create a tile-launcher grid without realizing (bug report: "cannot get + // into their canvas"). + private layout: 'grid' | 'freeform' = 'freeform'; private boardName = 'New Visual Notes board'; private targetFolder: TFolder | null = null; private onCreated: (file: TFile) => void; @@ -75,16 +79,16 @@ export class CreateBoardModal extends Modal { const layoutRow = contentEl.createDiv('visual-notes-layout-row'); for (const opt of [ { - value: 'grid' as const, - label: 'Grid', - icon: '⊞', - desc: 'Ordered tiles in a responsive grid — clean and fast.', + value: 'freeform' as const, + label: 'Canvas', + icon: '✦', + desc: 'An infinite canvas with free-placed notes, images, drawings and connections — the full Visual Notes experience.', }, { - value: 'freeform' as const, - label: 'Freeform', - icon: '✦', - desc: 'Infinite canvas with free-placed cards, pan and zoom.', + value: 'grid' as const, + label: 'Tile grid', + icon: '⊞', + desc: 'A launcher page of tiles that link to your canvases, notes and folders — like a home screen, not a canvas itself.', }, ]) { const card = layoutRow.createDiv( diff --git a/src/tile-modal.ts b/src/tile-modal.ts index 81855bf..6b35553 100644 --- a/src/tile-modal.ts +++ b/src/tile-modal.ts @@ -323,7 +323,7 @@ export class TileModal extends Modal { dd .addOption('note', 'Note') .addOption('folder', 'Folder') - .addOption('board', 'Nested board') + .addOption('board', 'Visual Notes board (canvas or grid)') .setValue(this.targetKind) .onChange(v => { this.targetKind = v as TargetKind; @@ -429,15 +429,16 @@ export class TileModal extends Modal { } else { // Board target: pick an existing .canvas board file or create a new // nested one. - const boardPaths = this.app.vault - .getAllLoadedFiles() - .filter((f): f is TFile => f instanceof TFile && f.extension === 'canvas') - .map(f => f.path) - .sort(); + const boardPaths = this.getBoardPaths(); const pathSetting = new Setting(contentEl) .setName('Target board') - .setDesc('Choose an existing board or create a new nested one'); + .setDesc(boardPaths.length > 0 + ? 'Choose an existing board or create a new nested one' + // The board this tile lives on is deliberately not offered (a tile + // linking to its own board would have nowhere to go) — in a fresh + // vault that can leave nothing to browse, which needs saying. + : 'No other boards exist yet — create a new nested one below'); const pathDisplay = pathSetting.controlEl.createSpan('visual-notes-modal-path-display' + (this.targetPath ? '' : ' is-empty')); pathDisplay.setText(this.targetPath || 'None selected'); @@ -482,23 +483,46 @@ export class TileModal extends Modal { .addEventListener('click', () => this.close()); const saveBtn = btnRow.createEl('button', { text: 'Save', cls: 'mod-cta visual-notes-modal-save' }); - saveBtn.addEventListener('click', () => { - if (!this.tile.label?.trim()) { new Notice('Please enter a label.'); return; } - if (!this.targetPath) { new Notice('Please select a target.'); return; } + saveBtn.addEventListener('click', () => { this.trySave(); }); + } - const saved: TileCard = { - ...(this.tile as TileCard), - target: { kind: this.targetKind, path: this.targetPath }, - }; - this.onSave(saved); - this.close(); - }); + // Validation + save, extracted from the button handler so the self-link + // guard is directly testable. Returns whether the tile was saved. + private trySave(): boolean { + if (!this.tile.label?.trim()) { new Notice('Please enter a label.'); return false; } + if (!this.targetPath) { new Notice('Please select a target.'); return false; } + // The picker lists already exclude the current board, but an edited + // pre-existing tile (or one created before this guard) can still carry + // a self-link — refuse to save it rather than recreate the "tile that + // goes nowhere" confusion. + if (this.currentFile && this.targetPath === this.currentFile.path) { + new Notice('A tile can\'t link to the board it\'s on — pick a different target.'); + return false; + } + + const saved: TileCard = { + ...(this.tile as TileCard), + target: { kind: this.targetKind, path: this.targetPath }, + }; + this.onSave(saved); + this.close(); + return true; + } + + // A tile linking to the board it lives on can never go anywhere, so the + // current board is excluded from every board/canvas picker list. + private getBoardPaths(): string[] { + return this.app.vault + .getAllLoadedFiles() + .filter((f): f is TFile => f instanceof TFile && f.extension === 'canvas' && f.path !== this.currentFile?.path) + .map(f => f.path) + .sort(); } private getPathsForKind(kind: 'folder' | 'canvas' | 'note'): string[] { const all = this.app.vault.getAllLoadedFiles(); if (kind === 'folder') return all.filter(f => f instanceof TFolder).map(f => f.path).sort(); - if (kind === 'canvas') return all.filter((f): f is TFile => f instanceof TFile && f.extension === 'canvas').map(f => f.path).sort(); + if (kind === 'canvas') return this.getBoardPaths(); return all.filter((f): f is TFile => f instanceof TFile && f.extension === 'md').map(f => f.path).sort(); } diff --git a/src/view.ts b/src/view.ts index 0252ee8..2b9ee42 100644 --- a/src/view.ts +++ b/src/view.ts @@ -91,6 +91,16 @@ export class VisualNotesView extends FileView { // ── Public navigation API (called by GridRenderer) ─────────── async navigateToBoard(targetPath: string): Promise { + // A tile pointing at the very board it lives on used to "navigate" to + // itself: the same board re-rendered, nothing visibly changed, and a + // bogus history entry piled up — reported as "cannot get into my + // canvas". Creating such a tile is now blocked in TileModal, but boards + // saved before that (or edited by hand) can still carry one — explain + // instead of silently doing nothing. + if (this.file && targetPath === this.file.path) { + new Notice('This tile links to the board it\'s on, so it has nowhere to go. Right-click the tile and choose Edit to point it at a different board.', 8000); + return; + } const targetFile = this.app.vault.getAbstractFileByPath(targetPath); if (!(targetFile instanceof TFile)) { new Notice(`Board file not found: ${targetPath}`); @@ -121,7 +131,7 @@ export class VisualNotesView extends FileView { container.empty(); container.addClass('visual-notes-container'); - this.renderHeader(container, file); + this.renderHeader(container, file, board.layout); const content = container.createDiv('visual-notes-content'); @@ -157,7 +167,7 @@ export class VisualNotesView extends FileView { this.renderer.render(); } - private renderHeader(container: HTMLElement, file: TFile): void { + private renderHeader(container: HTMLElement, file: TFile, layout: VisualNotesFile['layout']): void { const header = container.createDiv('visual-notes-view-header'); // Back button (visible when we have history) @@ -203,6 +213,15 @@ export class VisualNotesView extends FileView { }); breadcrumb.createSpan({ text: file.basename, cls: 'visual-notes-breadcrumb-current' }); } + + // Board-type badge — grid and canvas boards are otherwise visually + // identical in the file explorer and tab bar (same .canvas extension, + // same icon), which let users mix the two up ("expected a canvas, got a + // grid"). Name the layout right in the header. + breadcrumb.createSpan({ + text: layout === 'freeform' ? 'Canvas' : 'Tile grid', + cls: 'visual-notes-layout-badge', + }); } private renderEmpty(): void { diff --git a/styles.css b/styles.css index 3859be6..0364e17 100644 --- a/styles.css +++ b/styles.css @@ -151,6 +151,23 @@ body.theme-dark { padding: 2px 4px; } +/* Board-type badge next to the board name — grid and canvas boards look + identical everywhere else (same .canvas extension, icon and tab), so the + header names which one you're in. */ +.visual-notes-layout-badge { + font-size: 10px; + font-weight: 600; + text-transform: uppercase; + letter-spacing: 0.06em; + padding: 2px 6px; + border-radius: 4px; + background: var(--background-modifier-border); + color: var(--text-muted); + margin-left: 6px; + flex-shrink: 0; + user-select: none; +} + /* ── Grid ───────────────────────────────────────────────────── */ .visual-notes-grid { display: grid; diff --git a/test/fake-vault.ts b/test/fake-vault.ts index 0420909..25580e3 100644 --- a/test/fake-vault.ts +++ b/test/fake-vault.ts @@ -80,6 +80,10 @@ export class FakeVault { return file; }, createFolder: async (path: string) => { this.folders.add(path); }, + getAllLoadedFiles: () => [ + ...Array.from(this.entries.values()).map(e => e.file), + ...Array.from(this.folders).map(p => makeFolder(p)), + ], }; const fileManager = { renameFile: async (file: FakeFile, newPath: string) => { diff --git a/test/obsidian-stub.ts b/test/obsidian-stub.ts index ca0d40b..bae648b 100644 --- a/test/obsidian-stub.ts +++ b/test/obsidian-stub.ts @@ -29,6 +29,64 @@ export class Notice { export function setIcon(_el: HTMLElement, _iconId: string): void {} +export function getIconIds(): string[] { return []; } + +// Just enough of Obsidian's Setting for modules that build settings UIs +// (tile-modal.ts) to load and render without crashing — chainable methods, +// real child elements, no visual fidelity. +export class Setting { + settingEl: HTMLElement; + controlEl: HTMLElement; + descEl: HTMLElement; + constructor(containerEl: HTMLElement) { + this.settingEl = containerEl.createDiv('setting-item'); + this.descEl = this.settingEl.createDiv('setting-item-description'); + this.controlEl = this.settingEl.createDiv('setting-item-control'); + } + setName(_n: string): this { return this; } + setDesc(d: string): this { this.descEl.setText(d); return this; } + setHeading(): this { return this; } + addText(cb: (t: any) => void): this { + const inputEl = this.controlEl.createEl('input'); + const t = { + inputEl, + setPlaceholder: () => t, setValue: (v: string) => { inputEl.value = v; return t; }, + onChange: (fn: (v: string) => void) => { inputEl.addEventListener('input', () => fn(inputEl.value)); return t; }, + }; + cb(t); + return this; + } + addButton(cb: (b: any) => void): this { + const buttonEl = this.controlEl.createEl('button'); + const b = { + buttonEl, + setButtonText: (txt: string) => { buttonEl.setText(txt); return b; }, + setCta: () => b, setWarning: () => b, + onClick: (fn: () => void) => { buttonEl.addEventListener('click', fn); return b; }, + }; + cb(b); + return this; + } + addDropdown(cb: (d: any) => void): this { + const selectEl = this.controlEl.createEl('select'); + const d = { + selectEl, + addOption: (value: string, text: string) => { + const o = selectEl.createEl('option'); o.value = value; o.setText(text); return d; + }, + setValue: (v: string) => { selectEl.value = v; return d; }, + onChange: (fn: (v: string) => void) => { selectEl.addEventListener('change', () => fn(selectEl.value)); return d; }, + }; + cb(d); + return this; + } + addToggle(cb: (t: any) => void): this { + const t = { setValue: () => t, onChange: () => t }; + cb(t); + return this; + } +} + export async function requestUrl(_opts: unknown): Promise<{ status: number; text: string; json: unknown; arrayBuffer: ArrayBuffer }> { return { status: 200, text: '', json: {}, arrayBuffer: new ArrayBuffer(0) }; } diff --git a/test/tile-modal.test.ts b/test/tile-modal.test.ts new file mode 100644 index 0000000..1305db3 --- /dev/null +++ b/test/tile-modal.test.ts @@ -0,0 +1,71 @@ +// @vitest-environment jsdom +// +// Regression tests for the self-linking tile bug: a tile on a grid board +// could target the very board it lives on (the picker offered the current +// board — in a fresh vault it was often the ONLY option), producing a tile +// that "went nowhere" when clicked. See also navigateToBoard's guard in +// view.ts for tiles saved before these checks existed. +import { describe, it, expect, vi } from 'vitest'; +import { TileModal } from '../src/tile-modal'; +import { fakeApp } from './fake-app'; +import { FakeVault } from './fake-vault'; +import type { TFile } from 'obsidian'; +import type { TileCard } from '../src/file-types'; + +function setup(boardPaths: string[], currentPath: string) { + const vault = new FakeVault(); + for (const p of boardPaths) vault.putText(p, '{}'); + const app = fakeApp(vault); + const currentFile = app.vault.getAbstractFileByPath(currentPath) as TFile; + expect(currentFile).toBeTruthy(); + return { app, currentFile }; +} + +describe('TileModal: self-link prevention (bug: tile pointing at its own board)', () => { + it('the board picker list excludes the board the tile lives on', () => { + const { app, currentFile } = setup(['Home.canvas', 'Projects.canvas', 'Ideas.canvas'], 'Home.canvas'); + const modal = new TileModal(app, null, () => {}, currentFile); + expect((modal as any).getBoardPaths()).toEqual(['Ideas.canvas', 'Projects.canvas']); + }); + + it('the canvas-file picker list excludes the current board too', () => { + const { app, currentFile } = setup(['Home.canvas', 'Other.canvas'], 'Home.canvas'); + const modal = new TileModal(app, null, () => {}, currentFile); + expect((modal as any).getPathsForKind('canvas')).toEqual(['Other.canvas']); + }); + + it('in a fresh vault (only the current board exists) the picker list is empty rather than offering a self-link', () => { + // This is the exact reported scenario: the vault's only .canvas file is + // the board being edited, so the old list contained exactly one entry — + // the board itself — making the broken pick look like the intended one. + const { app, currentFile } = setup(['Home.canvas'], 'Home.canvas'); + const modal = new TileModal(app, null, () => {}, currentFile); + expect((modal as any).getBoardPaths()).toEqual([]); + }); + + it('refuses to save a tile whose target is the board it lives on', () => { + const { app, currentFile } = setup(['Home.canvas', 'Other.canvas'], 'Home.canvas'); + const onSave = vi.fn(); + const modal = new TileModal(app, null, onSave, currentFile); + (modal as any).tile.label = 'My tile'; + (modal as any).targetKind = 'board'; + (modal as any).targetPath = 'Home.canvas'; + + expect((modal as any).trySave()).toBe(false); + expect(onSave).not.toHaveBeenCalled(); + }); + + it('saves normally when the target is a different board', () => { + const { app, currentFile } = setup(['Home.canvas', 'Other.canvas'], 'Home.canvas'); + const onSave = vi.fn(); + const modal = new TileModal(app, null, onSave, currentFile); + (modal as any).tile.label = 'My tile'; + (modal as any).targetKind = 'board'; + (modal as any).targetPath = 'Other.canvas'; + + expect((modal as any).trySave()).toBe(true); + expect(onSave).toHaveBeenCalledTimes(1); + const saved = onSave.mock.calls[0][0] as TileCard; + expect(saved.target).toEqual({ kind: 'board', path: 'Other.canvas' }); + }); +}); diff --git a/versions.json b/versions.json index 4024227..a4146eb 100644 --- a/versions.json +++ b/versions.json @@ -36,5 +36,6 @@ "1.0.34": "1.7.2", "1.0.35": "1.7.2", "1.0.36": "1.7.2", - "1.0.37": "1.7.2" + "1.0.37": "1.7.2", + "1.0.38": "1.7.2" }