From 3d35eaff20e8aa2b28f0d8c464bd0bfeaac13ca6 Mon Sep 17 00:00:00 2001 From: lucaronin Date: Mon, 23 Feb 2026 20:44:30 +0100 Subject: [PATCH] feat: color wikilinks by destination note type Wikilinks in the editor now show the accent color of the target note's type (Project=red, Person=yellow, Topic=green, etc.) instead of always being blue. Broken links (target not found) show muted text with a dashed underline. - Extract wikilink color resolution to src/utils/wikilinkColors.ts - Use getTypeColor from typeColors.ts (single source of truth) - Support alias and pipe-syntax matching in findEntryByTarget - Add wikilink--broken CSS class for non-existent targets - Add 17 unit tests covering all color resolution cases - Update mock data with wikilinks to various note types + broken link Co-Authored-By: Claude Opus 4.6 --- src/components/Editor.css | 15 +++- src/components/Editor.tsx | 34 ++------ src/mock-tauri.ts | 4 + src/utils/wikilinkColors.test.ts | 131 +++++++++++++++++++++++++++++++ src/utils/wikilinkColors.ts | 37 +++++++++ 5 files changed, 190 insertions(+), 31 deletions(-) create mode 100644 src/utils/wikilinkColors.test.ts create mode 100644 src/utils/wikilinkColors.ts diff --git a/src/components/Editor.css b/src/components/Editor.css index 284866a5..e19add14 100644 --- a/src/components/Editor.css +++ b/src/components/Editor.css @@ -1,6 +1,5 @@ -/* Wikilink inline content */ +/* Wikilink inline content — color is set via inline style per note type */ .wikilink { - color: var(--accent-blue, #2196f3); cursor: pointer; text-decoration: underline; text-decoration-style: dotted; @@ -10,8 +9,18 @@ } .wikilink:hover { - background: var(--accent-blue-bg, rgba(33, 150, 243, 0.1)); text-decoration-style: solid; + opacity: 0.85; +} + +/* Broken wikilink: dashed underline + lower opacity */ +.wikilink--broken { + text-decoration-style: dashed; + opacity: 0.7; +} + +.wikilink--broken:hover { + opacity: 0.55; } /* BlockNote container */ diff --git a/src/components/Editor.tsx b/src/components/Editor.tsx index 2c07ee47..afb4e0dd 100644 --- a/src/components/Editor.tsx +++ b/src/components/Editor.tsx @@ -15,6 +15,7 @@ import { TabBar } from './TabBar' import { BreadcrumbBar } from './BreadcrumbBar' import { useEditorTheme } from '../hooks/useTheme' import { splitFrontmatter, preProcessWikilinks, injectWikilinks, restoreWikilinksInBlocks, countWords } from '../utils/wikilinks' +import { resolveWikilinkColor as resolveColor } from '../utils/wikilinkColors' import './Editor.css' import './EditorTheme.css' @@ -63,31 +64,8 @@ interface EditorProps { // Module-level cache so the WikiLink renderer (defined outside React) can access entries const _wikilinkEntriesRef: { current: VaultEntry[] } = { current: [] } -const TYPE_COLOR_MAP: Record = { - red: 'var(--accent-red)', - orange: 'var(--accent-orange)', - yellow: 'var(--accent-yellow)', - green: 'var(--accent-green)', - blue: 'var(--accent-blue)', - purple: 'var(--accent-purple)', -} - -function findEntryByTarget(entries: VaultEntry[], target: string): VaultEntry | undefined { - return entries.find(e => e.title === target || e.filename.replace(/\.md$/, '') === target) -} - -function lookupColorForEntry(entries: VaultEntry[], entry: VaultEntry): string | undefined { - if (entry.isA === 'Type' && entry.color) return TYPE_COLOR_MAP[entry.color] - if (!entry.isA) return undefined - const typeEntry = entries.find(e => e.isA === 'Type' && e.title === entry.isA) - return typeEntry?.color ? TYPE_COLOR_MAP[typeEntry.color] : undefined -} - -function resolveWikilinkColor(target: string): string | undefined { - const entries = _wikilinkEntriesRef.current - if (!entries.length) return undefined - const entry = findEntryByTarget(entries, target) - return entry ? lookupColorForEntry(entries, entry) : undefined +function resolveWikilinkColor(target: string) { + return resolveColor(_wikilinkEntriesRef.current, target) } const WikiLink = createReactInlineContentSpec( @@ -101,12 +79,12 @@ const WikiLink = createReactInlineContentSpec( { render: (props) => { const target = props.inlineContent.props.target - const color = resolveWikilinkColor(target) + const { color, isBroken } = resolveWikilinkColor(target) return ( {target} diff --git a/src/mock-tauri.ts b/src/mock-tauri.ts index 4bcf3481..e7ad58fd 100644 --- a/src/mock-tauri.ts +++ b/src/mock-tauri.ts @@ -122,6 +122,10 @@ status: Active See [[Stock Screener — EMA200 Wick Bounce]] for the experiment approach. Contact [[Matteo Cellini]] for sponsorship data. Link to [[Grow Newsletter]] responsibility. +Check [[Software Development]] for tech notes. +See [[Laputa App Design Session]] event recap. +Read [[Write Weekly Essays]] procedure. +Also see [[Non-Existent Note]] which is a broken link. ## Paragraphs & Spacing This is a normal paragraph with enough text to test line wrapping and spacing between elements. The paragraph should have comfortable line height and spacing from the heading above. diff --git a/src/utils/wikilinkColors.test.ts b/src/utils/wikilinkColors.test.ts new file mode 100644 index 00000000..7896e244 --- /dev/null +++ b/src/utils/wikilinkColors.test.ts @@ -0,0 +1,131 @@ +import { describe, it, expect } from 'vitest' +import type { VaultEntry } from '../types' +import { findEntryByTarget, lookupColorForEntry, resolveWikilinkColor } from './wikilinkColors' + +function makeEntry(overrides: Partial): VaultEntry { + return { + path: '/vault/note.md', + filename: 'note.md', + title: 'Note', + isA: null, + aliases: [], + belongsTo: [], + relatedTo: [], + status: null, + owner: null, + cadence: null, + archived: false, + trashed: false, + trashedAt: null, + modifiedAt: null, + createdAt: null, + fileSize: 100, + snippet: '', + relationships: {}, + icon: null, + color: null, + order: null, + ...overrides, + } +} + +const typeProject = makeEntry({ path: '/vault/type/project.md', filename: 'project.md', title: 'Project', isA: 'Type', color: 'red' }) +const typePerson = makeEntry({ path: '/vault/type/person.md', filename: 'person.md', title: 'Person', isA: 'Type', color: 'yellow' }) +const typeEvent = makeEntry({ path: '/vault/type/event.md', filename: 'event.md', title: 'Event', isA: 'Type', color: 'yellow' }) +const typeTopic = makeEntry({ path: '/vault/type/topic.md', filename: 'topic.md', title: 'Topic', isA: 'Type', color: 'green' }) +const typeRecipe = makeEntry({ path: '/vault/type/recipe.md', filename: 'recipe.md', title: 'Recipe', isA: 'Type', color: 'orange', icon: 'cooking-pot' }) + +const projectEntry = makeEntry({ path: '/vault/project/app.md', filename: 'app.md', title: 'Build App', isA: 'Project' }) +const personEntry = makeEntry({ path: '/vault/person/alice.md', filename: 'alice.md', title: 'Alice', isA: 'Person', aliases: ['Alice Smith'] }) +const eventEntry = makeEntry({ path: '/vault/event/kickoff.md', filename: 'kickoff.md', title: 'Kickoff Meeting', isA: 'Event' }) +const topicEntry = makeEntry({ path: '/vault/topic/dev.md', filename: 'dev.md', title: 'Software Development', isA: 'Topic' }) +const recipeEntry = makeEntry({ path: '/vault/recipe/pasta.md', filename: 'pasta.md', title: 'Pasta Carbonara', isA: 'Recipe' }) +const untypedEntry = makeEntry({ path: '/vault/note/random.md', filename: 'random.md', title: 'Random Thought' }) + +const allEntries = [typeProject, typePerson, typeEvent, typeTopic, typeRecipe, projectEntry, personEntry, eventEntry, topicEntry, recipeEntry, untypedEntry] + +describe('findEntryByTarget', () => { + it('matches by title', () => { + expect(findEntryByTarget(allEntries, 'Alice')).toBe(personEntry) + }) + + it('matches by filename stem', () => { + expect(findEntryByTarget(allEntries, 'pasta')).toBe(recipeEntry) + }) + + it('matches by alias', () => { + expect(findEntryByTarget(allEntries, 'Alice Smith')).toBe(personEntry) + }) + + it('handles pipe syntax (display name)', () => { + expect(findEntryByTarget(allEntries, 'Alice|Alice S.')).toBe(personEntry) + }) + + it('returns undefined for non-existent target', () => { + expect(findEntryByTarget(allEntries, 'Non Existent')).toBeUndefined() + }) +}) + +describe('lookupColorForEntry', () => { + it('returns Project color (red) for a Project entry', () => { + expect(lookupColorForEntry(allEntries, projectEntry)).toBe('var(--accent-red)') + }) + + it('returns Person color (yellow) for a Person entry', () => { + expect(lookupColorForEntry(allEntries, personEntry)).toBe('var(--accent-yellow)') + }) + + it('returns Event color (yellow) for an Event entry', () => { + expect(lookupColorForEntry(allEntries, eventEntry)).toBe('var(--accent-yellow)') + }) + + it('returns Topic color (green) for a Topic entry', () => { + expect(lookupColorForEntry(allEntries, topicEntry)).toBe('var(--accent-green)') + }) + + it('returns custom color (orange) for a Recipe entry via Type color', () => { + expect(lookupColorForEntry(allEntries, recipeEntry)).toBe('var(--accent-orange)') + }) + + it('returns default blue for an entry with no type', () => { + expect(lookupColorForEntry(allEntries, untypedEntry)).toBe('var(--accent-blue)') + }) +}) + +describe('resolveWikilinkColor', () => { + it('returns type color for a known typed note', () => { + const result = resolveWikilinkColor(allEntries, 'Build App') + expect(result.isBroken).toBe(false) + expect(result.color).toBe('var(--accent-red)') + }) + + it('returns broken link for a non-existent target', () => { + const result = resolveWikilinkColor(allEntries, 'Does Not Exist') + expect(result.isBroken).toBe(true) + expect(result.color).toBe('var(--text-muted)') + }) + + it('returns default color for an untyped note', () => { + const result = resolveWikilinkColor(allEntries, 'Random Thought') + expect(result.isBroken).toBe(false) + expect(result.color).toBe('var(--accent-blue)') + }) + + it('returns default color when entries list is empty', () => { + const result = resolveWikilinkColor([], 'Anything') + expect(result.isBroken).toBe(false) + expect(result.color).toBe('var(--accent-blue)') + }) + + it('resolves alias-based wikilink target', () => { + const result = resolveWikilinkColor(allEntries, 'Alice Smith') + expect(result.isBroken).toBe(false) + expect(result.color).toBe('var(--accent-yellow)') + }) + + it('resolves pipe-syntax wikilink target', () => { + const result = resolveWikilinkColor(allEntries, 'Alice|Alice S.') + expect(result.isBroken).toBe(false) + expect(result.color).toBe('var(--accent-yellow)') + }) +}) diff --git a/src/utils/wikilinkColors.ts b/src/utils/wikilinkColors.ts new file mode 100644 index 00000000..a2823fd2 --- /dev/null +++ b/src/utils/wikilinkColors.ts @@ -0,0 +1,37 @@ +/** + * Wikilink color resolution: maps wikilink targets to their note-type accent color. + * Used by the WikiLink inline content renderer in the editor. + */ +import type { VaultEntry } from '../types' +import { getTypeColor } from './typeColors' + +/** Broken-link color: muted text to signal the target note doesn't exist */ +const BROKEN_LINK_COLOR = 'var(--text-muted)' + +/** Find a vault entry matching a wikilink target string */ +export function findEntryByTarget(entries: VaultEntry[], target: string): VaultEntry | undefined { + // Handle pipe syntax: [[path|display name]] → use path part for matching + const key = target.includes('|') ? target.split('|')[0] : target + return entries.find(e => + e.title === key || + e.filename.replace(/\.md$/, '') === key || + e.aliases.includes(key), + ) +} + +/** Resolve the accent color for a given entry based on its type */ +export function lookupColorForEntry(entries: VaultEntry[], entry: VaultEntry): string { + if (!entry.isA) return getTypeColor(null) + const typeEntry = entries.find(e => e.isA === 'Type' && e.title === entry.isA) + return getTypeColor(entry.isA, typeEntry?.color) +} + +export interface WikilinkColorResult { color: string; isBroken: boolean } + +/** Resolve the display color for a wikilink target */ +export function resolveWikilinkColor(entries: VaultEntry[], target: string): WikilinkColorResult { + if (!entries.length) return { color: getTypeColor(null), isBroken: false } + const entry = findEntryByTarget(entries, target) + if (!entry) return { color: BROKEN_LINK_COLOR, isBroken: true } + return { color: lookupColorForEntry(entries, entry), isBroken: false } +}