Merge pull request #26 from refactoringhq/task/wikilink-colorati
feat: color wikilinks by destination note type
This commit is contained in:
165
design/wikilink-colorati.pen
Normal file
165
design/wikilink-colorati.pen
Normal file
@@ -0,0 +1,165 @@
|
||||
{
|
||||
"children": [
|
||||
{
|
||||
"type": "frame",
|
||||
"id": "wlc01",
|
||||
"x": 0,
|
||||
"y": 0,
|
||||
"name": "Wikilink Colors — Editor View (Light)",
|
||||
"theme": { "Mode": "Light" },
|
||||
"clip": true,
|
||||
"width": 760,
|
||||
"height": 440,
|
||||
"fill": "$--background",
|
||||
"layout": "vertical",
|
||||
"padding": [32, 40],
|
||||
"gap": 12,
|
||||
"children": [
|
||||
{
|
||||
"type": "text",
|
||||
"id": "wlc02",
|
||||
"content": "Build Laputa App",
|
||||
"fontSize": 28,
|
||||
"fontWeight": 700,
|
||||
"fill": "$--foreground"
|
||||
},
|
||||
{
|
||||
"type": "text",
|
||||
"id": "wlc03",
|
||||
"content": "Wiki-Links",
|
||||
"fontSize": 20,
|
||||
"fontWeight": 600,
|
||||
"fill": "$--foreground"
|
||||
},
|
||||
{
|
||||
"type": "frame",
|
||||
"id": "wlc10",
|
||||
"layout": "horizontal",
|
||||
"gap": 0,
|
||||
"width": "fill_container",
|
||||
"height": "hug_contents",
|
||||
"children": [
|
||||
{ "type": "text", "id": "wlc10a", "content": "See ", "fontSize": 15, "fill": "$--foreground" },
|
||||
{ "type": "text", "id": "wlc10b", "content": "Stock Screener — EMA200 Wick Bounce", "fontSize": 15, "fill": "$--accent-red", "textDecoration": "underline" },
|
||||
{ "type": "text", "id": "wlc10c", "content": " for the experiment approach.", "fontSize": 15, "fill": "$--foreground" }
|
||||
]
|
||||
},
|
||||
{
|
||||
"type": "frame",
|
||||
"id": "wlc11",
|
||||
"layout": "horizontal",
|
||||
"gap": 0,
|
||||
"width": "fill_container",
|
||||
"height": "hug_contents",
|
||||
"children": [
|
||||
{ "type": "text", "id": "wlc11a", "content": "Contact ", "fontSize": 15, "fill": "$--foreground" },
|
||||
{ "type": "text", "id": "wlc11b", "content": "Matteo Cellini", "fontSize": 15, "fill": "$--accent-yellow", "textDecoration": "underline" },
|
||||
{ "type": "text", "id": "wlc11c", "content": " for sponsorship data.", "fontSize": 15, "fill": "$--foreground" }
|
||||
]
|
||||
},
|
||||
{
|
||||
"type": "frame",
|
||||
"id": "wlc12",
|
||||
"layout": "horizontal",
|
||||
"gap": 0,
|
||||
"width": "fill_container",
|
||||
"height": "hug_contents",
|
||||
"children": [
|
||||
{ "type": "text", "id": "wlc12a", "content": "Link to ", "fontSize": 15, "fill": "$--foreground" },
|
||||
{ "type": "text", "id": "wlc12b", "content": "Grow Newsletter", "fontSize": 15, "fill": "$--accent-purple", "textDecoration": "underline" },
|
||||
{ "type": "text", "id": "wlc12c", "content": " responsibility.", "fontSize": 15, "fill": "$--foreground" }
|
||||
]
|
||||
},
|
||||
{
|
||||
"type": "frame",
|
||||
"id": "wlc13",
|
||||
"layout": "horizontal",
|
||||
"gap": 0,
|
||||
"width": "fill_container",
|
||||
"height": "hug_contents",
|
||||
"children": [
|
||||
{ "type": "text", "id": "wlc13a", "content": "Check ", "fontSize": 15, "fill": "$--foreground" },
|
||||
{ "type": "text", "id": "wlc13b", "content": "Software Development", "fontSize": 15, "fill": "$--accent-green", "textDecoration": "underline" },
|
||||
{ "type": "text", "id": "wlc13c", "content": " for tech notes.", "fontSize": 15, "fill": "$--foreground" }
|
||||
]
|
||||
},
|
||||
{
|
||||
"type": "frame",
|
||||
"id": "wlc14",
|
||||
"layout": "horizontal",
|
||||
"gap": 0,
|
||||
"width": "fill_container",
|
||||
"height": "hug_contents",
|
||||
"children": [
|
||||
{ "type": "text", "id": "wlc14a", "content": "See ", "fontSize": 15, "fill": "$--foreground" },
|
||||
{ "type": "text", "id": "wlc14b", "content": "Laputa App Design Session", "fontSize": 15, "fill": "$--accent-yellow", "textDecoration": "underline" },
|
||||
{ "type": "text", "id": "wlc14c", "content": " event recap.", "fontSize": 15, "fill": "$--foreground" }
|
||||
]
|
||||
},
|
||||
{
|
||||
"type": "frame",
|
||||
"id": "wlc15",
|
||||
"name": "Legend",
|
||||
"layout": "horizontal",
|
||||
"gap": 16,
|
||||
"width": "fill_container",
|
||||
"height": "hug_contents",
|
||||
"padding": [12, 0, 0, 0],
|
||||
"children": [
|
||||
{ "type": "text", "id": "wlc15a", "content": "Experiment", "fontSize": 12, "fill": "$--accent-red" },
|
||||
{ "type": "text", "id": "wlc15b", "content": "Person / Event", "fontSize": 12, "fill": "$--accent-yellow" },
|
||||
{ "type": "text", "id": "wlc15c", "content": "Responsibility", "fontSize": 12, "fill": "$--accent-purple" },
|
||||
{ "type": "text", "id": "wlc15d", "content": "Topic", "fontSize": 12, "fill": "$--accent-green" }
|
||||
]
|
||||
}
|
||||
]
|
||||
},
|
||||
{
|
||||
"type": "frame",
|
||||
"id": "wlc20",
|
||||
"x": 800,
|
||||
"y": 0,
|
||||
"name": "Wikilink Colors — Broken Link State",
|
||||
"theme": { "Mode": "Light" },
|
||||
"clip": true,
|
||||
"width": 500,
|
||||
"height": 200,
|
||||
"fill": "$--background",
|
||||
"layout": "vertical",
|
||||
"padding": [32, 40],
|
||||
"gap": 12,
|
||||
"children": [
|
||||
{
|
||||
"type": "text",
|
||||
"id": "wlc21",
|
||||
"content": "Broken Wikilink",
|
||||
"fontSize": 20,
|
||||
"fontWeight": 600,
|
||||
"fill": "$--foreground"
|
||||
},
|
||||
{
|
||||
"type": "frame",
|
||||
"id": "wlc22",
|
||||
"layout": "horizontal",
|
||||
"gap": 0,
|
||||
"width": "fill_container",
|
||||
"height": "hug_contents",
|
||||
"children": [
|
||||
{ "type": "text", "id": "wlc22a", "content": "Also see ", "fontSize": 15, "fill": "$--foreground" },
|
||||
{ "type": "text", "id": "wlc22b", "content": "Non-Existent Note", "fontSize": 15, "fill": "$--muted-foreground", "textDecoration": "underline", "opacity": 0.7 },
|
||||
{ "type": "text", "id": "wlc22c", "content": " which is a broken link.", "fontSize": 15, "fill": "$--foreground" }
|
||||
]
|
||||
},
|
||||
{
|
||||
"type": "text",
|
||||
"id": "wlc23",
|
||||
"content": "Broken links show muted color + dashed underline + reduced opacity",
|
||||
"fontSize": 12,
|
||||
"fill": "$--muted-foreground",
|
||||
"fontStyle": "italic"
|
||||
}
|
||||
]
|
||||
}
|
||||
],
|
||||
"variables": {}
|
||||
}
|
||||
@@ -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 */
|
||||
|
||||
@@ -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<string, string> = {
|
||||
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 (
|
||||
<span
|
||||
className="wikilink"
|
||||
className={`wikilink${isBroken ? ' wikilink--broken' : ''}`}
|
||||
data-target={target}
|
||||
style={color ? { color, textDecorationColor: color } : undefined}
|
||||
style={{ color, textDecorationColor: color }}
|
||||
>
|
||||
{target}
|
||||
</span>
|
||||
|
||||
@@ -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.
|
||||
|
||||
131
src/utils/wikilinkColors.test.ts
Normal file
131
src/utils/wikilinkColors.test.ts
Normal file
@@ -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>): 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)')
|
||||
})
|
||||
})
|
||||
37
src/utils/wikilinkColors.ts
Normal file
37
src/utils/wikilinkColors.ts
Normal file
@@ -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 }
|
||||
}
|
||||
Reference in New Issue
Block a user