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:
@@ -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', () => {
|
||||
|
||||
@@ -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>
|
||||
)
|
||||
},
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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,
|
||||
|
||||
Reference in New Issue
Block a user