Bump to 1.0.38: fix self-linking tiles and clarify canvas vs. grid

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).
This commit is contained in:
Daniel Anderson 2026-07-22 16:09:01 +10:00
parent b755ea2490
commit 6cf1d550bb
12 changed files with 241 additions and 34 deletions

View file

@ -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

View file

@ -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",

4
package-lock.json generated
View file

@ -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",

View file

@ -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": {

View file

@ -44,7 +44,11 @@ export class TemplatePickerModal extends FuzzySuggestModal<TemplateChoice> {
// ── 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(

View file

@ -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();
}

View file

@ -91,6 +91,16 @@ export class VisualNotesView extends FileView {
// ── Public navigation API (called by GridRenderer) ───────────
async navigateToBoard(targetPath: string): Promise<void> {
// 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 {

View file

@ -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;

View file

@ -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) => {

View file

@ -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) };
}

71
test/tile-modal.test.ts Normal file
View file

@ -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' });
});
});

View file

@ -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"
}