feat: show note emoji icon in wikilinks, relationships, and backlinks

Display the note's frontmatter emoji before its title in the editor
wikilink renderer, relationship LinkButtons, and backlinks panel for
consistent emoji visibility across the app.

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
This commit is contained in:
lucaronin
2026-03-18 18:30:50 +01:00
parent a1600d5dee
commit 2f43b4bfeb
5 changed files with 77 additions and 8 deletions

View File

@@ -242,6 +242,36 @@ describe('DynamicRelationshipsPanel', () => {
expect(screen.getByText('My Cool Project')).toBeInTheDocument()
})
it('shows emoji icon before label when entry has an emoji', () => {
const emojiEntry = makeEntry({
path: '/vault/note/rocket.md', filename: 'rocket.md', title: 'Rocket Note', isA: 'Note', icon: '🚀',
})
render(
<DynamicRelationshipsPanel
typeEntryMap={{}}
frontmatter={{ 'Belongs to': ['[[Rocket Note]]'] }}
entries={[emojiEntry]}
onNavigate={onNavigate}
/>
)
expect(screen.getByText('🚀')).toBeInTheDocument()
expect(screen.getByText('Rocket Note')).toBeInTheDocument()
})
it('does not show emoji when entry has no icon', () => {
render(
<DynamicRelationshipsPanel
typeEntryMap={{}}
frontmatter={{ 'Belongs to': ['[[project/my-project]]'] }}
entries={entries}
onNavigate={onNavigate}
/>
)
const link = screen.getByText('My Project')
const container = link.closest('.group\\/link')
expect(container?.textContent).not.toMatch(/^[\p{Emoji_Presentation}]/u)
})
describe('relation editing', () => {
const onUpdateProperty = vi.fn()
const onDeleteProperty = vi.fn()
@@ -649,6 +679,29 @@ describe('BacklinksPanel', () => {
fireEvent.click(screen.getByTestId('backlinks-toggle'))
expect(screen.queryByText('Note A')).not.toBeInTheDocument()
})
it('shows emoji icon before backlink title when entry has an emoji', () => {
const backlinks = [{
entry: makeEntry({ title: 'Starred Note', icon: '⭐' }),
context: null,
}]
render(<BacklinksPanel typeEntryMap={{}} backlinks={backlinks} onNavigate={onNavigate} />)
fireEvent.click(screen.getByTestId('backlinks-toggle'))
expect(screen.getByText('⭐')).toBeInTheDocument()
expect(screen.getByText('Starred Note')).toBeInTheDocument()
})
it('does not show emoji when backlink entry has no icon', () => {
const backlinks = [{ entry: makeEntry({ title: 'Plain Note' }), context: null }]
render(<BacklinksPanel typeEntryMap={{}} backlinks={backlinks} onNavigate={onNavigate} />)
fireEvent.click(screen.getByTestId('backlinks-toggle'))
expect(screen.getByText('Plain Note')).toBeInTheDocument()
const btn = screen.getByText('Plain Note').closest('button')
const spans = btn?.querySelectorAll('span.shrink-0')
// No emoji span should exist (only possible shrink-0 spans are trash icon, not emoji)
const emojiSpans = Array.from(spans ?? []).filter(s => /[\p{Emoji_Presentation}]/u.test(s.textContent ?? ''))
expect(emojiSpans).toHaveLength(0)
})
})
describe('ReferencedByPanel', () => {

View File

@@ -4,6 +4,7 @@ import { createReactInlineContentSpec } from '@blocknote/react'
import { resolveWikilinkColor as resolveColor } from '../utils/wikilinkColors'
import { resolveEntry } from '../utils/wikilink'
import type { VaultEntry } from '../types'
import { isEmoji } from '../utils/emoji'
// Module-level cache so the WikiLink renderer (defined outside React) can access entries
export const _wikilinkEntriesRef: { current: VaultEntry[] } = { current: [] }
@@ -12,15 +13,22 @@ function resolveWikilinkColor(target: string) {
return resolveColor(_wikilinkEntriesRef.current, target)
}
/** Resolve the display text for a wikilink target.
/** Resolve the display text and optional emoji for a wikilink target.
* Priority: pipe display text → entry title → humanised path stem */
function resolveDisplayText(target: string): string {
function resolveDisplayInfo(target: string): { text: string; emoji: string | null } {
const pipeIdx = target.indexOf('|')
if (pipeIdx !== -1) return target.slice(pipeIdx + 1)
if (pipeIdx !== -1) {
const entry = resolveEntry(_wikilinkEntriesRef.current, target.slice(0, pipeIdx))
const emoji = entry?.icon && isEmoji(entry.icon) ? entry.icon : null
return { text: target.slice(pipeIdx + 1), emoji }
}
const entry = resolveEntry(_wikilinkEntriesRef.current, target)
if (entry) return entry.title
if (entry) {
const emoji = entry.icon && isEmoji(entry.icon) ? entry.icon : null
return { text: entry.title, emoji }
}
const last = target.split('/').pop() ?? target
return last.replace(/-/g, ' ').replace(/\b\w/g, c => c.toUpperCase())
return { text: last.replace(/-/g, ' ').replace(/\b\w/g, c => c.toUpperCase()), emoji: null }
}
export const WikiLink = createReactInlineContentSpec(
@@ -35,14 +43,15 @@ export const WikiLink = createReactInlineContentSpec(
render: (props) => {
const target = props.inlineContent.props.target
const { color, isBroken } = resolveWikilinkColor(target)
const displayText = resolveDisplayText(target)
const { text, emoji } = resolveDisplayInfo(target)
return (
<span
className={`wikilink${isBroken ? ' wikilink--broken' : ''}`}
data-target={target}
style={{ color }}
>
{displayText}
{emoji && <span className="wikilink-emoji">{emoji}{' '}</span>}
{text}
</span>
)
},

View File

@@ -2,6 +2,7 @@ import { useState } from 'react'
import type { VaultEntry } from '../../types'
import { CaretRight, Trash } from '@phosphor-icons/react'
import { getTypeColor } from '../../utils/typeColors'
import { isEmoji } from '../../utils/emoji'
import { entryStatusTitle } from './shared'
import { StatusSuffix } from './LinkButton'
@@ -29,6 +30,7 @@ function BacklinkEntry({ entry, context, typeEntryMap, onNavigate }: {
style={{ color: isDimmed ? 'var(--muted-foreground)' : getTypeColor(entry.isA, te?.color) }}
>
{entry.trashed && <Trash size={12} className="shrink-0" />}
{entry.icon && isEmoji(entry.icon) && <span className="shrink-0">{entry.icon}</span>}
{entry.title}
<StatusSuffix isArchived={entry.archived} isTrashed={entry.trashed} />
</span>

View File

@@ -7,8 +7,9 @@ export function StatusSuffix({ isArchived, isTrashed }: { isArchived: boolean; i
return null
}
export function LinkButton({ label, typeColor, bgColor, isArchived, isTrashed, onClick, onRemove, title, TypeIcon }: {
export function LinkButton({ label, emoji, typeColor, bgColor, isArchived, isTrashed, onClick, onRemove, title, TypeIcon }: {
label: string
emoji?: string | null
typeColor: string
bgColor?: string
isArchived: boolean
@@ -33,6 +34,7 @@ export function LinkButton({ label, typeColor, bgColor, isArchived, isTrashed, o
>
<span className="flex items-center gap-1 flex-1 truncate">
{isTrashed && <Trash size={12} className="shrink-0" />}
{emoji && <span className="shrink-0">{emoji}</span>}
{label}
<StatusSuffix isArchived={isArchived} isTrashed={isTrashed} />
</span>

View File

@@ -3,6 +3,7 @@ import type { VaultEntry } from '../../types'
import { getTypeColor, getTypeLightColor } from '../../utils/typeColors'
import { getTypeIcon } from '../NoteItem'
import { findEntryByTarget } from '../../utils/wikilinkColors'
import { isEmoji } from '../../utils/emoji'
export function isWikilink(value: string): boolean {
return /^\[\[.*\]\]$/.test(value)
@@ -30,8 +31,10 @@ export function resolveRefProps(ref: string, entries: VaultEntry[], typeEntryMap
const resolved = resolveRef(ref, entries)
const refType = resolved?.isA ?? null
const te = typeEntryMap[refType ?? '']
const icon = resolved?.icon
return {
label: wikilinkDisplay(ref),
emoji: icon && isEmoji(icon) ? icon : null,
typeColor: getTypeColor(refType, te?.color),
bgColor: getTypeLightColor(refType, te?.color),
isArchived: resolved?.archived ?? false,