From 2f43b4bfebe6d1e2fe9a70ec79cd835faf2b591e Mon Sep 17 00:00:00 2001 From: lucaronin Date: Wed, 18 Mar 2026 18:30:50 +0100 Subject: [PATCH] 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) --- src/components/InspectorPanels.test.tsx | 53 +++++++++++++++++++++ src/components/editorSchema.tsx | 23 ++++++--- src/components/inspector/BacklinksPanel.tsx | 2 + src/components/inspector/LinkButton.tsx | 4 +- src/components/inspector/shared.ts | 3 ++ 5 files changed, 77 insertions(+), 8 deletions(-) diff --git a/src/components/InspectorPanels.test.tsx b/src/components/InspectorPanels.test.tsx index 55036414..4cf5ceb2 100644 --- a/src/components/InspectorPanels.test.tsx +++ b/src/components/InspectorPanels.test.tsx @@ -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( + + ) + expect(screen.getByText('🚀')).toBeInTheDocument() + expect(screen.getByText('Rocket Note')).toBeInTheDocument() + }) + + it('does not show emoji when entry has no icon', () => { + render( + + ) + 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() + 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() + 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', () => { diff --git a/src/components/editorSchema.tsx b/src/components/editorSchema.tsx index 77d8bcf0..bc01e8c6 100644 --- a/src/components/editorSchema.tsx +++ b/src/components/editorSchema.tsx @@ -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 ( - {displayText} + {emoji && {emoji}{' '}} + {text} ) }, diff --git a/src/components/inspector/BacklinksPanel.tsx b/src/components/inspector/BacklinksPanel.tsx index 147b6818..cdb397d0 100644 --- a/src/components/inspector/BacklinksPanel.tsx +++ b/src/components/inspector/BacklinksPanel.tsx @@ -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 && } + {entry.icon && isEmoji(entry.icon) && {entry.icon}} {entry.title} diff --git a/src/components/inspector/LinkButton.tsx b/src/components/inspector/LinkButton.tsx index 6ccc7df4..95311f0a 100644 --- a/src/components/inspector/LinkButton.tsx +++ b/src/components/inspector/LinkButton.tsx @@ -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 > {isTrashed && } + {emoji && {emoji}} {label} diff --git a/src/components/inspector/shared.ts b/src/components/inspector/shared.ts index a83f07e6..ace1ac9e 100644 --- a/src/components/inspector/shared.ts +++ b/src/components/inspector/shared.ts @@ -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,