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,