mirror of
https://github.com/danderson1988/visual-notes.git
synced 2026-07-22 08:30:17 +00:00
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:
parent
b755ea2490
commit
6cf1d550bb
12 changed files with 241 additions and 34 deletions
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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
4
package-lock.json
generated
|
|
@ -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",
|
||||
|
|
|
|||
|
|
@ -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": {
|
||||
|
|
|
|||
|
|
@ -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(
|
||||
|
|
|
|||
|
|
@ -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();
|
||||
}
|
||||
|
||||
|
|
|
|||
23
src/view.ts
23
src/view.ts
|
|
@ -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 {
|
||||
|
|
|
|||
17
styles.css
17
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;
|
||||
|
|
|
|||
|
|
@ -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) => {
|
||||
|
|
|
|||
|
|
@ -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
71
test/tile-modal.test.ts
Normal 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' });
|
||||
});
|
||||
});
|
||||
|
|
@ -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"
|
||||
}
|
||||
|
|
|
|||
Loading…
Reference in a new issue