feat: add note icon property support

This commit is contained in:
lucaronin
2026-04-07 21:09:06 +02:00
parent 2eb8e02061
commit 771409d30b
34 changed files with 358 additions and 198 deletions

View File

@@ -17,6 +17,7 @@ These frontmatter field names have special meaning in Laputa's UI:
| `title:` | Human-readable title (synced with filename) | Breadcrumb, sidebar. Filename = `slugify(title).md` |
| `type:` | Entity type (Project, Person, Quarter…) | Type chip in note list + sidebar grouping |
| `status:` | Lifecycle stage (active, done, blocked…) | Colored chip in note list + editor header |
| `icon:` | Per-note icon (emoji, Phosphor name, or HTTP/HTTPS image URL) | Rendered on note title surfaces; editable from the Properties panel |
| `url:` | External link | Clickable link chip in editor header |
| `date:` | Single date | Formatted date badge |
| `start_date:` + `end_date:` | Duration/timespan | Date range badge |
@@ -167,7 +168,7 @@ Each entity type can have a corresponding **type document** in the `type/` folde
| Property | Type | Description |
|----------|------|-------------|
| `icon` | string | Phosphor icon name (kebab-case, e.g., "cooking-pot") |
| `icon` | string | Type icon as a Phosphor name (kebab-case, e.g., "cooking-pot") |
| `color` | string | Accent color: red, purple, blue, green, yellow, orange |
| `order` | number | Sidebar display order (lower = higher priority) |
| `sidebar_label` | string | Custom label overriding auto-pluralization |

View File

@@ -175,7 +175,7 @@ flowchart TD
- **Sidebar** (150-400px, resizable): Top-level filters (All Notes, Changes, Pulse) and collapsible type-based section groups. Each type can have a custom icon, color, sort, and visibility set via its type document in `type/`.
- **Note List / Pulse View** (200-500px, resizable): When a section group or filter is selected, shows filtered notes with snippets, modified dates, and status indicators. When Pulse filter is active, shows `PulseView` — a chronological git activity feed grouped by day.
- **Editor** (flex, fills remaining space): Single note open at a time (no tabs — see ADR-0003). Breadcrumb bar with word count, BlockNote rich text editor with wikilink support. Can toggle to diff view (modified files) or raw CodeMirror view. Decomposed into `Editor` (orchestrator), `EditorContent`, `EditorRightPanel`, `SingleEditorView`, with hooks `useDiffMode`, `useEditorFocus`, `useEditorSave`, `useRawMode`. Navigation history (Cmd+[/]) replaces tabs.
- **Inspector / AI Agent** (200-500px or 40px collapsed): Toggles between Inspector (frontmatter, relationships, instances, backlinks, git history) and AI Agent panel (Claude CLI subprocess with tool execution). The Sparkle icon in the breadcrumb bar toggles between them. When viewing a Type note, the Inspector shows an **Instances** section listing all notes of that type (sorted by modified_at desc, capped at 50).
- **Inspector / AI Agent** (200-500px or 40px collapsed): Toggles between Inspector (frontmatter, relationships, instances, backlinks, git history) and AI Agent panel (Claude CLI subprocess with tool execution). The Sparkle icon in the breadcrumb bar toggles between them. Per-note `icon` is a suggested Inspector property and the command palette's "Set Note Icon" action opens that field directly. When viewing a Type note, the Inspector shows an **Instances** section listing all notes of that type (sorted by modified_at desc, capped at 50).
Panels are separated by `ResizeHandle` components that support drag-to-resize.

View File

@@ -27,7 +27,6 @@ import { useCommitFlow } from './hooks/useCommitFlow'
import { useViewMode } from './hooks/useViewMode'
import { useEntryActions } from './hooks/useEntryActions'
import { useAppCommands } from './hooks/useAppCommands'
import { isEmoji } from './utils/emoji'
import { generateCommitMessage } from './utils/commitMessage'
import { useDialogs } from './hooks/useDialogs'
import { useVaultSwitcher } from './hooks/useVaultSwitcher'
@@ -60,8 +59,10 @@ import { isNoteWindow, getNoteWindowParams } from './utils/windowMode'
import { GitRequiredModal } from './components/GitRequiredModal'
import { RenameDetectedBanner, type DetectedRename } from './components/RenameDetectedBanner'
import { openNoteListPropertiesPicker } from './components/note-list/noteListPropertiesEvents'
import { focusNoteIconPropertyEditor } from './components/noteIconPropertyEvents'
import { trackEvent } from './lib/telemetry'
import { extractDeletedContentFromDiff } from './components/note-list/noteListUtils'
import { hasNoteIconValue } from './utils/noteIcon'
import './App.css'
// Type declarations for mock content storage and test overrides
@@ -86,6 +87,7 @@ function App() {
setNoteListFilter('open')
}, [])
const layout = useLayoutPanels(noteWindowParams ? { initialInspectorCollapsed: true } : undefined)
const { setInspectorCollapsed } = layout
const visibleNotesRef = useRef<VaultEntry[]>([])
const [toastMessage, setToastMessage] = useState<string | null>(null)
const dialogs = useDialogs()
@@ -276,17 +278,18 @@ function App() {
await notes.handleUpdateFrontmatter(path, 'title', filename)
}, [notes])
const handleSetNoteIcon = useCallback(async (path: string, emoji: string) => {
await notes.handleUpdateFrontmatter(path, 'icon', emoji)
}, [notes])
const handleRemoveNoteIcon = useCallback(async (path: string) => {
await notes.handleDeleteProperty(path, 'icon')
}, [notes])
const handleSetNoteIconCommand = useCallback(() => {
window.dispatchEvent(new CustomEvent('laputa:open-icon-picker'))
}, [])
setInspectorCollapsed(false)
window.requestAnimationFrame(() => {
window.requestAnimationFrame(() => {
focusNoteIconPropertyEditor()
})
})
}, [setInspectorCollapsed])
const handleCustomizeInboxColumns = useCallback(() => {
openNoteListPropertiesPicker('inbox')
@@ -541,7 +544,7 @@ function App() {
onRemoveNoteIcon: handleRemoveNoteIconCommand,
activeNoteHasIcon: (() => {
const ae = vault.entries.find(e => e.path === notes.activeTabPath)
return !!(ae?.icon && isEmoji(ae.icon))
return hasNoteIconValue(ae?.icon)
})(),
noteListFilter,
onSetNoteListFilter: setNoteListFilter,
@@ -682,8 +685,6 @@ function App() {
onFileCreated={vaultBridge.handleAgentFileCreated}
onFileModified={vaultBridge.handleAgentFileModified}
onVaultChanged={vaultBridge.handleAgentVaultChanged}
onSetNoteIcon={activeDeletedFile ? undefined : handleSetNoteIcon}
onRemoveNoteIcon={activeDeletedFile ? undefined : handleRemoveNoteIcon}
isConflicted={conflictFlow.isConflicted}
onKeepMine={conflictFlow.handleKeepMine}
onKeepTheirs={conflictFlow.handleKeepTheirs}

View File

@@ -107,17 +107,16 @@ describe('BreadcrumbBar — title in breadcrumb (always rendered, CSS-toggled)',
expect(screen.getByText('test')).toBeInTheDocument()
})
it('does not render emoji icon in breadcrumb (icon removed from breadcrumb title)', () => {
it('renders emoji note icons in the breadcrumb title', () => {
const entryWithEmoji = { ...baseEntry, icon: '🚀' }
render(<BreadcrumbBar entry={entryWithEmoji} {...defaultProps} />)
// BreadcrumbTitle now only shows type label and filename stem — no icon
expect(screen.queryByText('🚀')).not.toBeInTheDocument()
expect(screen.getByTestId('breadcrumb-note-icon')).toHaveTextContent('🚀')
})
it('does not show icon when entry has a non-emoji icon', () => {
it('renders Phosphor note icons in the breadcrumb title', () => {
const entryWithPhosphor = { ...baseEntry, icon: 'cooking-pot' }
render(<BreadcrumbBar entry={entryWithPhosphor} {...defaultProps} />)
expect(screen.queryByText('cooking-pot')).not.toBeInTheDocument()
expect(screen.getByTestId('breadcrumb-note-icon').tagName.toLowerCase()).toBe('svg')
})
it('falls back to "Note" when isA is null', () => {

View File

@@ -15,6 +15,7 @@ import {
Star,
CheckCircle,
} from '@phosphor-icons/react'
import { NoteTitleIcon } from './NoteTitleIcon'
interface BreadcrumbBarProps {
entry: VaultEntry
@@ -184,7 +185,10 @@ function BreadcrumbTitle({ entry }: { entry: VaultEntry }) {
<div className="flex items-center gap-1.5 min-w-0 text-sm text-muted-foreground">
<span className="shrink-0">{typeLabel}</span>
<span className="shrink-0 text-border"></span>
<span className="truncate font-medium text-foreground">{filenameStem}</span>
<span className="flex min-w-0 items-center gap-1 truncate font-medium text-foreground">
<NoteTitleIcon icon={entry.icon} size={15} testId="breadcrumb-note-icon" />
<span className="truncate">{filenameStem}</span>
</span>
</div>
)
}

View File

@@ -550,7 +550,7 @@ describe('DynamicPropertiesPanel', () => {
})
describe('suggested property slots', () => {
it('shows Status/Date/URL slots when no properties exist and onAddProperty provided', () => {
it('shows Status/Date/URL/Icon slots when no properties exist and onAddProperty provided', () => {
render(
<DynamicPropertiesPanel
entry={makeEntry()}
@@ -560,10 +560,11 @@ describe('DynamicPropertiesPanel', () => {
/>
)
const slots = screen.getAllByTestId('suggested-property')
expect(slots.length).toBe(3)
expect(slots.length).toBe(4)
expect(screen.getByText('Status')).toBeInTheDocument()
expect(screen.getByText('Date')).toBeInTheDocument()
expect(screen.getByText('URL')).toBeInTheDocument()
expect(screen.getByText('Icon')).toBeInTheDocument()
})
it('hides Status slot when Status property already exists', () => {
@@ -577,7 +578,7 @@ describe('DynamicPropertiesPanel', () => {
/>
)
const slots = screen.getAllByTestId('suggested-property')
expect(slots.length).toBe(2)
expect(slots.length).toBe(3)
expect(screen.queryAllByText('Status').some(el => el.closest('[data-testid="suggested-property"]'))).toBe(false)
})
@@ -586,7 +587,7 @@ describe('DynamicPropertiesPanel', () => {
<DynamicPropertiesPanel
entry={makeEntry()}
content=""
frontmatter={{ Status: 'Active', Date: '2024-01-01', URL: 'https://example.com' }}
frontmatter={{ Status: 'Active', Date: '2024-01-01', URL: 'https://example.com', icon: 'star' }}
onAddProperty={onAddProperty}
onUpdateProperty={onUpdateProperty}
/>
@@ -647,7 +648,7 @@ describe('DynamicPropertiesPanel', () => {
// The suggested slot for Status should be gone
const remainingSlots = screen.getAllByTestId('suggested-property')
expect(remainingSlots.length).toBe(2) // Date and URL remain
expect(remainingSlots.length).toBe(3) // Date, URL, and Icon remain
// Status dropdown is portaled to body — check for it there
const dropdown = document.querySelector('[data-testid="status-dropdown-popover"]')
expect(dropdown).toBeInTheDocument()
@@ -886,7 +887,7 @@ describe('DynamicPropertiesPanel', () => {
})
describe('system property filtering', () => {
it('hides archived, archived_at, icon from properties panel', () => {
it('hides archived and archived_at but keeps icon visible in properties panel', () => {
render(
<DynamicPropertiesPanel
entry={makeEntry()}
@@ -897,12 +898,13 @@ describe('DynamicPropertiesPanel', () => {
)
expect(screen.queryByText('Archived')).not.toBeInTheDocument()
expect(screen.queryByText('Archived at')).not.toBeInTheDocument()
expect(screen.queryByText('Icon')).not.toBeInTheDocument()
expect(screen.getByText('Icon')).toBeInTheDocument()
expect(screen.getByText('📝')).toBeInTheDocument()
// Custom property still visible
expect(screen.getByText('Cadence')).toBeInTheDocument()
})
it('filters system properties case-insensitively', () => {
it('keeps icon visible even when cased differently', () => {
render(
<DynamicPropertiesPanel
entry={makeEntry()}
@@ -912,7 +914,8 @@ describe('DynamicPropertiesPanel', () => {
/>
)
expect(screen.queryByText('Archived')).not.toBeInTheDocument()
expect(screen.queryByText('Icon')).not.toBeInTheDocument()
expect(screen.getByText('Icon')).toBeInTheDocument()
expect(screen.getByText('🎯')).toBeInTheDocument()
expect(screen.getByText('Cadence')).toBeInTheDocument()
})
@@ -930,6 +933,23 @@ describe('DynamicPropertiesPanel', () => {
})
})
describe('icon property rendering', () => {
it('keeps icon values in plain text mode even when they are urls', () => {
render(
<DynamicPropertiesPanel
entry={makeEntry()}
content=""
frontmatter={{ icon: 'https://example.com/favicon.png' }}
onUpdateProperty={onUpdateProperty}
/>
)
expect(screen.getByText('Icon')).toBeInTheDocument()
expect(screen.getByText('https://example.com/favicon.png')).toBeInTheDocument()
expect(screen.queryByTestId('url-link')).not.toBeInTheDocument()
})
})
describe('display mode override', () => {
beforeEach(() => {
resetVaultConfigStore()

View File

@@ -1,4 +1,4 @@
import { useMemo, useCallback } from 'react'
import { useMemo, useCallback, useEffect } from 'react'
import type { VaultEntry } from '../types'
import type { FrontmatterValue } from './Inspector'
import type { ParsedFrontmatter } from '../utils/frontmatter'
@@ -8,6 +8,7 @@ import { SmartPropertyValueCell, DisplayModeSelector } from './PropertyValueCell
import { TypeSelector } from './TypeSelector'
import { AddPropertyForm } from './AddPropertyForm'
import type { PropertyDisplayMode } from '../utils/propertyTypes'
import { FOCUS_NOTE_ICON_PROPERTY_EVENT } from './noteIconPropertyEvents'
function toSentenceCase(key: string): string {
const spaced = key.replace(/[_-]/g, ' ')
@@ -66,7 +67,12 @@ function AddPropertyButton({ onClick, disabled }: { onClick: () => void; disable
)
}
const SUGGESTED_PROPERTIES = ['Status', 'Date', 'URL'] as const
const SUGGESTED_PROPERTIES = [
{ key: 'Status', label: 'Status' },
{ key: 'Date', label: 'Date' },
{ key: 'URL', label: 'URL' },
{ key: 'icon', label: 'Icon' },
] as const
function SuggestedPropertySlot({ label, onAdd }: { label: string; onAdd: () => void }) {
return (
@@ -110,7 +116,7 @@ export function DynamicPropertiesPanel({
}, [propertyEntries, frontmatter])
const missingSuggested = onAddProperty
? SUGGESTED_PROPERTIES.filter(p => !existingKeys.has(p.toLowerCase()))
? SUGGESTED_PROPERTIES.filter(p => !existingKeys.has(p.key.toLowerCase()))
: []
const handleSuggestedAdd = useCallback((key: string) => {
@@ -120,6 +126,20 @@ export function DynamicPropertiesPanel({
onAddProperty(key, '')
}, [onAddProperty, setEditingKey])
useEffect(() => {
const handleFocusNoteIcon = () => {
const existingIconKey = propertyEntries.find(([key]) => key.toLowerCase() === 'icon')?.[0]
if (!existingIconKey && !onAddProperty) return
if (!existingIconKey) onAddProperty('icon', '')
setEditingKey(existingIconKey ?? 'icon')
}
window.addEventListener(FOCUS_NOTE_ICON_PROPERTY_EVENT, handleFocusNoteIcon)
return () => window.removeEventListener(FOCUS_NOTE_ICON_PROPERTY_EVENT, handleFocusNoteIcon)
}, [onAddProperty, propertyEntries, setEditingKey])
return (
<div className="flex flex-col gap-3">
<div className="flex flex-col gap-1.5">
@@ -136,8 +156,8 @@ export function DynamicPropertiesPanel({
onDisplayModeChange={handleDisplayModeChange}
/>
))}
{missingSuggested.map(key => (
<SuggestedPropertySlot key={key} label={key} onAdd={() => handleSuggestedAdd(key)} />
{missingSuggested.map(({ key, label }) => (
<SuggestedPropertySlot key={key} label={label} onAdd={() => handleSuggestedAdd(key)} />
))}
</div>
{showAddDialog

View File

@@ -68,10 +68,6 @@ interface EditorProps {
onFileCreated?: (relativePath: string) => void
onFileModified?: (relativePath: string) => void
onVaultChanged?: () => void
/** Called when user sets an emoji icon on a note. */
onSetNoteIcon?: (path: string, emoji: string) => void
/** Called when user removes an emoji icon from a note. */
onRemoveNoteIcon?: (path: string) => void
/** Whether the active note has a merge conflict. */
isConflicted?: boolean
/** Resolve conflict by keeping the local version. */
@@ -209,7 +205,6 @@ export const Editor = memo(function Editor(props: EditorProps) {
onToggleFavorite, onToggleOrganized, onDeleteNote, onArchiveNote, onUnarchiveNote,
onContentChange, onSave, onTitleSync,
onFileCreated, onFileModified, onVaultChanged,
onSetNoteIcon, onRemoveNoteIcon,
isConflicted, onKeepMine, onKeepTheirs,
} = props
@@ -260,8 +255,6 @@ export const Editor = memo(function Editor(props: EditorProps) {
vaultPath={vaultPath}
rawLatestContentRef={rawLatestContentRef}
onTitleChange={onTitleSync}
onSetNoteIcon={onSetNoteIcon}
onRemoveNoteIcon={onRemoveNoteIcon}
isConflicted={isConflicted}
onKeepMine={onKeepMine}
onKeepTheirs={onKeepTheirs}

View File

@@ -1,5 +1,5 @@
import type React from 'react'
import { useCallback, useRef, useEffect } from 'react'
import { useRef, useEffect } from 'react'
import type { VaultEntry, NoteStatus } from '../types'
import type { useCreateBlockNote } from '@blocknote/react'
import { DiffView } from './DiffView'
@@ -11,8 +11,8 @@ import { ConflictNoteBanner } from './ConflictNoteBanner'
import { RawEditorView } from './RawEditorView'
import { countWords } from '../utils/wikilinks'
import { SingleEditorView } from './SingleEditorView'
import { isEmoji } from '../utils/emoji'
import { useEditorTheme } from '../hooks/useTheme'
import { resolveNoteIcon } from '../utils/noteIcon'
interface Tab {
entry: VaultEntry
@@ -50,10 +50,6 @@ interface EditorContentProps {
rawLatestContentRef?: React.MutableRefObject<string | null>
/** Called when the user edits the dedicated title field. */
onTitleChange?: (path: string, newTitle: string) => void
/** Called when user sets or changes an emoji icon via the picker. */
onSetNoteIcon?: (path: string, emoji: string) => void
/** Called when user removes an emoji icon. */
onRemoveNoteIcon?: (path: string) => void
/** Whether the active note has a merge conflict. */
isConflicted?: boolean
/** Resolve conflict by keeping the local version. */
@@ -160,7 +156,6 @@ export function EditorContent({
activeStatus,
onNavigateWikilink, onEditorChange, vaultPath,
rawLatestContentRef, onTitleChange,
onSetNoteIcon, onRemoveNoteIcon,
isConflicted, onKeepMine, onKeepTheirs,
...breadcrumbProps
}: EditorContentProps) {
@@ -174,7 +169,7 @@ export function EditorContent({
const effectiveRawMode = rawMode || isNonMarkdownText
const showEditor = !diffMode && !effectiveRawMode
const entryIcon = activeTab?.entry.icon ?? null
const emojiIcon = entryIcon && isEmoji(entryIcon) ? entryIcon : null
const hasDisplayIcon = resolveNoteIcon(entryIcon).kind !== 'none'
const isUntitledDraft = !!activeTab
&& activeTab.entry.filename.startsWith('untitled-')
&& (activeStatus === 'new' || activeStatus === 'unsaved' || activeStatus === 'pendingSave')
@@ -204,14 +199,6 @@ export function EditorContent({
return () => { observer.disconnect(); bar.removeAttribute('data-title-hidden') }
}, [activeTab?.entry.path, showEditor])
const handleSetIcon = useCallback((emoji: string) => {
if (activeTab) onSetNoteIcon?.(activeTab.entry.path, emoji)
}, [activeTab, onSetNoteIcon])
const handleRemoveIcon = useCallback(() => {
if (activeTab) onRemoveNoteIcon?.(activeTab.entry.path)
}, [activeTab, onRemoveNoteIcon])
if (!activeTab) {
return <div className="flex flex-1 flex-col min-w-0 min-h-0" />
}
@@ -242,14 +229,14 @@ export function EditorContent({
<div ref={titleSectionRef} className="title-section" data-title-ui-visible={showTitleSection || undefined}>
{showTitleSection && (
<>
{!emojiIcon && (
{!hasDisplayIcon && (
<div className="title-section__add-icon">
<NoteIcon icon={null} editable onSetIcon={handleSetIcon} onRemoveIcon={handleRemoveIcon} />
<NoteIcon icon={null} editable />
</div>
)}
<div className={`title-section__row${emojiIcon ? '' : ' title-section__row--no-icon'}`}>
{emojiIcon && (
<NoteIcon icon={emojiIcon} editable onSetIcon={handleSetIcon} onRemoveIcon={handleRemoveIcon} />
<div className={`title-section__row${hasDisplayIcon ? '' : ' title-section__row--no-icon'}`}>
{hasDisplayIcon && (
<NoteIcon icon={entryIcon} editable />
)}
<TitleField
title={activeTab.entry.title}

View File

@@ -2,6 +2,7 @@ import { useState, useRef, useCallback, useMemo, useEffect, type ComponentType,
import type { VaultEntry } from '../types'
import { getTypeColor, getTypeLightColor } from '../utils/typeColors'
import { getTypeIcon } from './NoteItem'
import { NoteTitleIcon } from './NoteTitleIcon'
import './WikilinkSuggestionMenu.css'
const MIN_QUERY_LENGTH = 2
@@ -21,6 +22,7 @@ interface NoteAutocompleteProps {
interface MatchedEntry {
title: string
noteIcon: string | null
noteType?: string
typeColor?: string
typeLightColor?: string
@@ -40,6 +42,7 @@ function matchEntries(entries: VaultEntry[], typeEntryMap: Record<string, VaultE
const noteType = isA || undefined
return {
title: e.title,
noteIcon: e.icon,
noteType,
typeColor: noteType ? getTypeColor(isA, te?.color) : undefined,
typeLightColor: noteType ? getTypeLightColor(isA, te?.color) : undefined,
@@ -142,6 +145,7 @@ export function NoteAutocomplete({ entries, typeEntryMap, value, onChange, onSel
>
<span className="wikilink-menu__title" style={{ display: 'flex', alignItems: 'center', gap: 6 }}>
{item.TypeIcon && <item.TypeIcon width={14} height={14} style={{ color: item.typeColor, flexShrink: 0 }} />}
<NoteTitleIcon icon={item.noteIcon} size={14} />
{item.title}
</span>
{item.noteType && (

View File

@@ -1,78 +1,72 @@
import { describe, it, expect, vi } from 'vitest'
import { render, screen, fireEvent, act } from '@testing-library/react'
import { NoteIcon } from './NoteIcon'
import { FOCUS_NOTE_ICON_PROPERTY_EVENT } from './noteIconPropertyEvents'
describe('NoteIcon', () => {
it('shows add button when no icon is set', () => {
render(<NoteIcon icon={null} onSetIcon={() => {}} onRemoveIcon={() => {}} />)
render(<NoteIcon icon={null} />)
expect(screen.getByTestId('note-icon-add')).toBeInTheDocument()
expect(screen.queryByTestId('note-icon-display')).not.toBeInTheDocument()
})
it('displays the emoji when icon is set', () => {
render(<NoteIcon icon="🎯" onSetIcon={() => {}} onRemoveIcon={() => {}} />)
render(<NoteIcon icon="🎯" />)
expect(screen.getByTestId('note-icon-display')).toHaveTextContent('🎯')
expect(screen.queryByTestId('note-icon-add')).not.toBeInTheDocument()
})
it('does not display Phosphor icon names as emoji', () => {
render(<NoteIcon icon="rocket" onSetIcon={() => {}} onRemoveIcon={() => {}} />)
// Phosphor icon name is not an emoji → shows add button
expect(screen.getByTestId('note-icon-add')).toBeInTheDocument()
it('displays Phosphor icons when icon is set', () => {
render(<NoteIcon icon="rocket" />)
expect(screen.getByTestId('note-icon-display').querySelector('svg')).toBeInTheDocument()
})
it('opens emoji picker when add button is clicked', () => {
render(<NoteIcon icon={null} onSetIcon={() => {}} onRemoveIcon={() => {}} />)
it('displays image icons when icon is set to a url', () => {
render(<NoteIcon icon="https://example.com/favicon.png" />)
expect(screen.getByTestId('note-icon-display').querySelector('img')).toBeInTheDocument()
})
it('focuses the icon property when add button is clicked', () => {
const handler = vi.fn()
window.addEventListener(FOCUS_NOTE_ICON_PROPERTY_EVENT, handler)
render(<NoteIcon icon={null} />)
fireEvent.click(screen.getByTestId('note-icon-add'))
expect(screen.getByTestId('emoji-picker')).toBeInTheDocument()
expect(handler).toHaveBeenCalledOnce()
window.removeEventListener(FOCUS_NOTE_ICON_PROPERTY_EVENT, handler)
})
it('shows change/remove menu when existing icon is clicked', () => {
render(<NoteIcon icon="🔥" onSetIcon={() => {}} onRemoveIcon={() => {}} />)
it('focuses the icon property when existing icon is clicked', () => {
const handler = vi.fn()
window.addEventListener(FOCUS_NOTE_ICON_PROPERTY_EVENT, handler)
render(<NoteIcon icon="🔥" />)
fireEvent.click(screen.getByTestId('note-icon-display'))
expect(screen.getByTestId('note-icon-menu')).toBeInTheDocument()
expect(screen.getByTestId('note-icon-change')).toBeInTheDocument()
expect(screen.getByTestId('note-icon-remove')).toBeInTheDocument()
})
it('calls onRemoveIcon when Remove is clicked', () => {
const onRemove = vi.fn()
render(<NoteIcon icon="🔥" onSetIcon={() => {}} onRemoveIcon={onRemove} />)
fireEvent.click(screen.getByTestId('note-icon-display'))
fireEvent.click(screen.getByTestId('note-icon-remove'))
expect(onRemove).toHaveBeenCalledOnce()
})
it('opens picker when Change is clicked from menu', () => {
render(<NoteIcon icon="🔥" onSetIcon={() => {}} onRemoveIcon={() => {}} />)
fireEvent.click(screen.getByTestId('note-icon-display'))
fireEvent.click(screen.getByTestId('note-icon-change'))
expect(screen.getByTestId('emoji-picker')).toBeInTheDocument()
})
it('calls onSetIcon when emoji is selected from picker', () => {
const onSet = vi.fn()
render(<NoteIcon icon={null} onSetIcon={onSet} onRemoveIcon={() => {}} />)
fireEvent.click(screen.getByTestId('note-icon-add'))
const emojiButtons = screen.getAllByTestId('emoji-option')
fireEvent.click(emojiButtons[0])
expect(onSet).toHaveBeenCalledOnce()
expect(handler).toHaveBeenCalledOnce()
window.removeEventListener(FOCUS_NOTE_ICON_PROPERTY_EVENT, handler)
})
it('hides add button when not editable', () => {
render(<NoteIcon icon={null} editable={false} onSetIcon={() => {}} onRemoveIcon={() => {}} />)
render(<NoteIcon icon={null} editable={false} />)
expect(screen.queryByTestId('note-icon-add')).not.toBeInTheDocument()
})
it('disables icon click when not editable', () => {
render(<NoteIcon icon="🎯" editable={false} onSetIcon={() => {}} onRemoveIcon={() => {}} />)
render(<NoteIcon icon="🎯" editable={false} />)
const display = screen.getByTestId('note-icon-display')
expect(display).toBeDisabled()
})
it('opens picker on laputa:open-icon-picker event', () => {
render(<NoteIcon icon={null} onSetIcon={() => {}} onRemoveIcon={() => {}} />)
it('maps the legacy picker event to the icon property focus event', () => {
const handler = vi.fn()
window.addEventListener(FOCUS_NOTE_ICON_PROPERTY_EVENT, handler)
render(<NoteIcon icon={null} />)
act(() => { window.dispatchEvent(new CustomEvent('laputa:open-icon-picker')) })
expect(screen.getByTestId('emoji-picker')).toBeInTheDocument()
expect(handler).toHaveBeenCalledOnce()
window.removeEventListener(FOCUS_NOTE_ICON_PROPERTY_EVENT, handler)
})
})

View File

@@ -1,70 +1,46 @@
import { useState, useCallback, useEffect } from 'react'
import { EmojiPicker } from './EmojiPicker'
import { isEmoji } from '../utils/emoji'
import { useCallback, useEffect } from 'react'
import { NoteTitleIcon } from './NoteTitleIcon'
import { focusNoteIconPropertyEditor } from './noteIconPropertyEvents'
import { resolveNoteIcon } from '../utils/noteIcon'
interface NoteIconProps {
icon: string | null
editable?: boolean
onSetIcon: (emoji: string) => void
onRemoveIcon: () => void
}
export function NoteIcon({ icon, editable = true, onSetIcon, onRemoveIcon }: NoteIconProps) {
const [pickerOpen, setPickerOpen] = useState(false)
const [showRemove, setShowRemove] = useState(false)
const hasIcon = icon && isEmoji(icon)
export function NoteIcon({ icon, editable = true }: NoteIconProps) {
const hasIcon = resolveNoteIcon(icon).kind !== 'none'
// Listen for command palette "Set Note Icon" event
useEffect(() => {
if (!editable) return
const handler = () => setPickerOpen(true)
const handler = () => focusNoteIconPropertyEditor()
window.addEventListener('laputa:open-icon-picker', handler)
return () => window.removeEventListener('laputa:open-icon-picker', handler)
}, [editable])
const openPicker = useCallback(() => {
const handleActivate = useCallback(() => {
if (!editable) return
if (hasIcon) {
setShowRemove(prev => !prev)
} else {
setPickerOpen(true)
}
}, [editable, hasIcon])
const handleSelect = useCallback((emoji: string) => {
onSetIcon(emoji)
setPickerOpen(false)
setShowRemove(false)
}, [onSetIcon])
const handleRemove = useCallback(() => {
onRemoveIcon()
setShowRemove(false)
}, [onRemoveIcon])
const handleChangePicker = useCallback(() => {
setShowRemove(false)
setPickerOpen(true)
}, [])
focusNoteIconPropertyEditor()
}, [editable])
return (
<div className="note-icon-area" data-testid="note-icon-area" style={{ position: 'relative' }}>
{hasIcon ? (
<button
className="note-icon-button note-icon-button--active"
onClick={openPicker}
onClick={handleActivate}
data-testid="note-icon-display"
title={editable ? 'Change or remove icon' : undefined}
title={editable ? 'Edit icon' : undefined}
disabled={!editable}
>
{icon}
<NoteTitleIcon icon={icon} size={26} />
</button>
) : (
editable && (
<button
className="note-icon-button note-icon-button--add"
onClick={openPicker}
onClick={handleActivate}
data-testid="note-icon-add"
title="Add icon"
>
@@ -73,30 +49,6 @@ export function NoteIcon({ icon, editable = true, onSetIcon, onRemoveIcon }: Not
</button>
)
)}
{showRemove && hasIcon && (
<div className="note-icon-menu" data-testid="note-icon-menu">
<button
className="note-icon-menu__item"
onClick={handleChangePicker}
data-testid="note-icon-change"
>
Change icon
</button>
<button
className="note-icon-menu__item note-icon-menu__item--danger"
onClick={handleRemove}
data-testid="note-icon-remove"
>
Remove icon
</button>
</div>
)}
{pickerOpen && (
<EmojiPicker
onSelect={handleSelect}
onClose={() => setPickerOpen(false)}
/>
)}
</div>
)
}

View File

@@ -131,3 +131,35 @@ describe('NoteItem property chips', () => {
expect(screen.queryByTestId('property-chips')).not.toBeInTheDocument()
})
})
describe('NoteItem note icons', () => {
it('renders a Phosphor note icon in the note row', () => {
render(
<NoteItem
entry={makeEntry({ icon: 'star' })}
isSelected={false}
noteStatus="clean"
typeEntryMap={{ Movie: makeTypeEntry() }}
onClickNote={noop}
/>
)
expect(screen.getByTestId('note-title-icon').tagName.toLowerCase()).toBe('svg')
})
it('renders an image note icon in the note row', () => {
render(
<NoteItem
entry={makeEntry({ icon: 'https://example.com/favicon.png' })}
isSelected={false}
noteStatus="clean"
typeEntryMap={{ Movie: makeTypeEntry() }}
onClickNote={noop}
/>
)
const icon = screen.getByTestId('note-title-icon')
expect(icon.tagName.toLowerCase()).toBe('img')
expect(icon).toHaveAttribute('src', 'https://example.com/favicon.png')
})
})

View File

@@ -9,8 +9,8 @@ import {
import { getTypeColor, getTypeLightColor } from '../utils/typeColors'
import { resolveIcon } from '../utils/iconRegistry'
import { relativeDate, getDisplayDate } from '../utils/noteListHelpers'
import { isEmoji } from '../utils/emoji'
import { wikilinkDisplay } from '../utils/wikilink'
import { NoteTitleIcon } from './NoteTitleIcon'
const TYPE_ICON_MAP: Record<string, ComponentType<SVGAttributes<SVGSVGElement>>> = {
Project: Wrench,
@@ -224,7 +224,7 @@ export function NoteItem({ entry, isSelected, isMultiSelected = false, isHighlig
<div className="pr-5">
<div className={cn("truncate text-[13px]", isBinary ? "text-muted-foreground" : "text-foreground", isSelected && !isBinary ? "font-semibold" : "font-medium")}>
{noteStatus !== 'clean' && !isBinary && <StatusDot noteStatus={noteStatus} />}
{entry.icon && isEmoji(entry.icon) && <span className="mr-1">{entry.icon}</span>}
<NoteTitleIcon icon={entry.icon} size={15} className="mr-1" testId="note-title-icon" />
{entry.title}
{!isBinary && <StateBadge archived={entry.archived} />}
</div>

View File

@@ -1,9 +1,11 @@
import { useRef, useEffect, type ComponentType, type SVGAttributes } from 'react'
import { cn } from '@/lib/utils'
import { Badge } from '@/components/ui/badge'
import { NoteTitleIcon } from './NoteTitleIcon'
export interface NoteSearchResultItem {
title: string
noteIcon?: string | null
noteType?: string
typeColor?: string
typeLightColor?: string
@@ -68,6 +70,7 @@ export function NoteSearchList<T extends NoteSearchResultItem>({
style={item.typeColor ? { color: item.typeColor } : undefined}
/>
)}
<NoteTitleIcon icon={item.noteIcon} size={14} testId="note-search-item-icon" />
<span className="truncate">{item.title}</span>
</span>
{item.noteType && (

View File

@@ -0,0 +1,33 @@
import { render, screen } from '@testing-library/react'
import { describe, it, expect } from 'vitest'
import { NoteTitleIcon } from './NoteTitleIcon'
describe('NoteTitleIcon', () => {
it('renders a single emoji icon as text', () => {
render(<NoteTitleIcon icon="🚀" testId="note-title-icon" />)
expect(screen.getByTestId('note-title-icon')).toHaveTextContent('🚀')
})
it('renders a Phosphor icon when the name is recognized', () => {
render(<NoteTitleIcon icon="cooking pot" testId="note-title-icon" />)
const icon = screen.getByTestId('note-title-icon')
expect(icon.tagName.toLowerCase()).toBe('svg')
})
it('renders an image when the icon is an http url', () => {
render(<NoteTitleIcon icon="https://example.com/favicon.png" testId="note-title-icon" />)
const icon = screen.getByTestId('note-title-icon')
expect(icon.tagName.toLowerCase()).toBe('img')
expect(icon).toHaveAttribute('src', 'https://example.com/favicon.png')
})
it('renders nothing for an unrecognized icon value', () => {
const { container } = render(<NoteTitleIcon icon="definitely-not-a-real-icon" testId="note-title-icon" />)
expect(screen.queryByTestId('note-title-icon')).not.toBeInTheDocument()
expect(container).toBeEmptyDOMElement()
})
})

View File

@@ -0,0 +1,54 @@
import { cn } from '@/lib/utils'
import { resolveNoteIcon } from '../utils/noteIcon'
interface NoteTitleIconProps {
icon: string | null | undefined
size?: number
className?: string
color?: string
testId?: string
}
export function NoteTitleIcon({ icon, size = 14, className, color, testId }: NoteTitleIconProps) {
const resolved = resolveNoteIcon(icon)
if (resolved.kind === 'none') return null
if (resolved.kind === 'emoji') {
return (
<span
className={cn('inline-flex shrink-0 items-center justify-center', className)}
style={{ fontSize: size, lineHeight: 1 }}
data-testid={testId}
>
{resolved.value}
</span>
)
}
if (resolved.kind === 'image') {
return (
<img
src={resolved.src}
alt=""
aria-hidden="true"
className={cn('shrink-0 rounded-sm object-cover', className)}
style={{ width: size, height: size }}
onError={(event) => {
event.currentTarget.style.display = 'none'
}}
data-testid={testId}
/>
)
}
return (
<resolved.Icon
width={size}
height={size}
className={cn('shrink-0', className)}
style={color ? { color } : undefined}
data-testid={testId}
/>
)
}

View File

@@ -277,7 +277,8 @@ function toBooleanValue(value: FrontmatterValue): boolean {
return false
}
function autoDetectFromValue(value: FrontmatterValue): PropertyDisplayMode {
function autoDetectFromValue(propKey: string, value: FrontmatterValue): PropertyDisplayMode {
if (propKey.toLowerCase() === 'icon') return 'text'
if (typeof value === 'boolean') return 'boolean'
if (typeof value === 'string' && isUrlValue(value)) return 'url'
if (typeof value === 'string' && isValidCssColor(value) && value.startsWith('#')) return 'color'
@@ -293,7 +294,7 @@ type SmartCellProps = {
function ScalarValueCell({ propKey, value, displayMode, isEditing, vaultStatuses, vaultTags, onStartEdit, onSave, onSaveList, onUpdate }: SmartCellProps) {
const editProps = { value: String(value ?? ''), isEditing, onStartEdit: () => onStartEdit(propKey), onSave: (v: string) => onSave(propKey, v), onCancel: () => onStartEdit(null) }
const resolvedMode = displayMode === 'text' ? autoDetectFromValue(value) : displayMode
const resolvedMode = displayMode === 'text' ? autoDetectFromValue(propKey, value) : displayMode
switch (resolvedMode) {
case 'status':
return <StatusValue propKey={propKey} value={value ?? ''} isEditing={isEditing} vaultStatuses={vaultStatuses} onSave={onSave} onStartEdit={onStartEdit} />
@@ -324,4 +325,3 @@ export function SmartPropertyValueCell(props: SmartCellProps) {
}
return <ScalarValueCell {...props} />
}

View File

@@ -6,7 +6,7 @@ import { useUnifiedSearch } from '../hooks/useUnifiedSearch'
import { getTypeColor, buildTypeEntryMap } from '../utils/typeColors'
import { formatSearchSubtitle } from '../utils/noteListHelpers'
import { getTypeIcon } from './NoteItem'
import { isEmoji } from '../utils/emoji'
import { NoteTitleIcon } from './NoteTitleIcon'
interface SearchPanelProps {
open: boolean
@@ -212,7 +212,7 @@ function SearchContent({
<div className="flex items-center gap-2">
<TypeIcon width={14} height={14} className="shrink-0" style={{ color: typeColor ?? 'var(--muted-foreground)' }} />
<span className="min-w-0 flex-1 truncate text-[13px] font-medium text-foreground">
{entry?.icon && isEmoji(entry.icon) && <span className="mr-1">{entry.icon}</span>}
<NoteTitleIcon icon={entry?.icon} size={14} className="mr-1" />
{entry?.title ?? result.title}
</span>
{noteType && (

View File

@@ -14,7 +14,6 @@ import { CSS } from '@dnd-kit/utilities'
import {
FileText, Trash, Archive, CaretLeft, Tray, CaretRight, CaretDown, Plus, Funnel, PencilSimple,
} from '@phosphor-icons/react'
import { isEmoji } from '../utils/emoji'
import { evaluateView } from '../utils/viewFilters'
import { arrayMove } from '@dnd-kit/sortable'
import { SlidersHorizontal } from 'lucide-react'
@@ -24,6 +23,7 @@ import {
} from './SidebarParts'
import { useDragRegion } from '../hooks/useDragRegion'
import { FolderTree } from './FolderTree'
import { NoteTitleIcon } from './NoteTitleIcon'
interface SidebarProps {
entries: VaultEntry[]
@@ -343,7 +343,6 @@ function SortableFavoriteItem({ entry, isActive, onSelect }: {
entry: VaultEntry; isActive: boolean; onSelect: () => void
}) {
const { attributes, listeners, setNodeRef, transform, transition, isDragging } = useSortable({ id: entry.path })
const icon = entry.icon && isEmoji(entry.icon) ? entry.icon : null
return (
<div ref={setNodeRef} style={{ transform: CSS.Transform.toString(transform), transition, opacity: isDragging ? 0.5 : 1 }} {...attributes} {...listeners}>
<div
@@ -351,7 +350,7 @@ function SortableFavoriteItem({ entry, isActive, onSelect }: {
style={{ padding: '4px 16px 4px 28px' }}
onClick={onSelect}
>
{icon && <span className="shrink-0">{icon}</span>}
<NoteTitleIcon icon={entry.icon} size={14} />
<span className="truncate">{entry.title}</span>
</div>
</div>

View File

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

View File

@@ -1,8 +1,8 @@
import type { VaultEntry } from '../../types'
import { ArrowUpRight } from '@phosphor-icons/react'
import { isEmoji } from '../../utils/emoji'
import { entryStatusTitle } from './shared'
import { StatusSuffix } from './LinkButton'
import { NoteTitleIcon } from '../NoteTitleIcon'
export interface BacklinkItem {
entry: VaultEntry
@@ -25,7 +25,7 @@ function BacklinkEntry({ entry, context, onNavigate }: {
className="flex items-center gap-1 text-xs text-primary"
style={isDimmed ? { color: 'var(--muted-foreground)' } : undefined}
>
{entry.icon && isEmoji(entry.icon) && <span className="shrink-0">{entry.icon}</span>}
<NoteTitleIcon icon={entry.icon} size={14} />
{entry.title}
<StatusSuffix isArchived={entry.archived} />
</span>

View File

@@ -37,6 +37,7 @@ export function InstancesPanel({ entry, entries, typeEntryMap, onNavigate }: {
<LinkButton
key={e.path}
label={e.title}
noteIcon={e.icon}
typeColor={getTypeColor(e.isA, te?.color)}
isArchived={e.archived}
onClick={() => onNavigate(e.title)}

View File

@@ -1,14 +1,15 @@
import type { ComponentType, SVGAttributes } from 'react'
import { X } from '@phosphor-icons/react'
import { NoteTitleIcon } from '../NoteTitleIcon'
export function StatusSuffix({ isArchived }: { isArchived: boolean }) {
if (isArchived) return <span style={{ marginLeft: 4, fontSize: 10, opacity: 0.8 }}>(archived)</span>
return null
}
export function LinkButton({ label, emoji, typeColor, bgColor, isArchived, onClick, onRemove, title, TypeIcon }: {
export function LinkButton({ label, noteIcon, typeColor, bgColor, isArchived, onClick, onRemove, title, TypeIcon }: {
label: string
emoji?: string | null
noteIcon?: string | null
typeColor: string
bgColor?: string
isArchived: boolean
@@ -31,7 +32,7 @@ export function LinkButton({ label, emoji, typeColor, bgColor, isArchived, onCli
title={title}
>
<span className="flex items-center gap-1 flex-1 truncate">
{emoji && <span className="shrink-0">{emoji}</span>}
<NoteTitleIcon icon={noteIcon} size={14} />
{label}
<StatusSuffix isArchived={isArchived} />
</span>

View File

@@ -41,6 +41,7 @@ export function ReferencedByPanel({ items, typeEntryMap, onNavigate }: {
<LinkButton
key={e.path}
label={e.title}
noteIcon={e.icon}
typeColor={getTypeColor(e.isA, te?.color)}
isArchived={e.archived}
onClick={() => onNavigate(e.title)}

View File

@@ -3,7 +3,6 @@ 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)
@@ -33,7 +32,7 @@ export function resolveRefProps(ref: string, entries: VaultEntry[], typeEntryMap
const icon = resolved?.icon
return {
label: wikilinkDisplay(ref),
emoji: icon && isEmoji(icon) ? icon : null,
noteIcon: icon ?? null,
typeColor: getTypeColor(refType, te?.color),
bgColor: getTypeLightColor(refType, te?.color),
isArchived: resolved?.archived ?? false,

View File

@@ -2,7 +2,7 @@ import type { VaultEntry } from '../../types'
import { getTypeColor, getTypeLightColor } from '../../utils/typeColors'
import { getTypeIcon } from '../NoteItem'
import { relativeDate, getDisplayDate } from '../../utils/noteListHelpers'
import { isEmoji } from '../../utils/emoji'
import { NoteTitleIcon } from '../NoteTitleIcon'
export function PinnedCard({ entry, typeEntryMap, onClickNote, showDate }: {
entry: VaultEntry
@@ -19,7 +19,7 @@ export function PinnedCard({ entry, typeEntryMap, onClickNote, showDate }: {
{/* eslint-disable-next-line react-hooks/static-components */}
<Icon width={16} height={16} className="absolute right-3 top-3.5" style={{ color }} data-testid="type-icon" />
<div className="pr-6 text-[14px] font-bold" style={{ color }}>
{entry.icon && isEmoji(entry.icon) && <span className="mr-1">{entry.icon}</span>}
<NoteTitleIcon icon={entry.icon} size={15} className="mr-1" />
{entry.title}
</div>
<div className="mt-1 text-[12px] leading-[1.5] opacity-80" style={{ color, display: '-webkit-box', WebkitLineClamp: 2, WebkitBoxOrient: 'vertical', overflow: 'hidden' }}>{entry.snippet}</div>

View File

@@ -0,0 +1,5 @@
export const FOCUS_NOTE_ICON_PROPERTY_EVENT = 'laputa:focus-note-icon-property'
export function focusNoteIconPropertyEditor(): void {
window.dispatchEvent(new CustomEvent(FOCUS_NOTE_ICON_PROPERTY_EVENT))
}

View File

@@ -78,6 +78,14 @@ describe('useNoteSearch', () => {
expect(result.current.results[0].entry).toBe(entries[0])
})
it('keeps the plain title text and exposes the icon separately', () => {
const withIcon = [makeEntry({ path: '/vault/icon.md', title: 'Icon Note', icon: '🚀' })]
const { result } = renderHook(() => useNoteSearch(withIcon, ''))
expect(result.current.results[0].title).toBe('Icon Note')
expect(result.current.results[0].noteIcon).toBe('🚀')
})
it('starts with selectedIndex 0', () => {
const { result } = renderHook(() => useNoteSearch(entries, ''))
expect(result.current.selectedIndex).toBe(0)

View File

@@ -4,7 +4,6 @@ import { fuzzyMatch, bestSearchRank } from '../utils/fuzzyMatch'
import { getTypeColor, getTypeLightColor, buildTypeEntryMap } from '../utils/typeColors'
import { getTypeIcon } from '../components/NoteItem'
import type { NoteSearchResultItem } from '../components/NoteSearchList'
import { isEmoji } from '../utils/emoji'
const DEFAULT_MAX_RESULTS = 20
@@ -15,10 +14,10 @@ export interface NoteSearchResult extends NoteSearchResultItem {
function toResult(e: VaultEntry, typeEntryMap: Record<string, VaultEntry>): NoteSearchResult {
const noteType = e.isA || undefined
const te = typeEntryMap[e.isA ?? '']
const emojiPrefix = e.icon && isEmoji(e.icon) ? `${e.icon} ` : ''
return {
entry: e,
title: `${emojiPrefix}${e.title}`,
title: e.title,
noteIcon: e.icon,
noteType,
typeColor: noteType ? getTypeColor(e.isA, te?.color) : undefined,
typeLightColor: noteType ? getTypeLightColor(e.isA, te?.color) : undefined,

View File

@@ -12,7 +12,7 @@ import { containsWikilinks } from '../components/DynamicPropertiesPanel'
// Keys to skip showing in Properties (handled by dedicated UI or internal)
// Compared case-insensitively via isVisibleProperty()
const SKIP_KEYS = new Set(['aliases', 'workspace', 'title', 'type', 'is_a', 'is a', '_archived', 'archived', 'archived_at', 'icon', '_favorite', '_favorite_index', '_organized'])
const SKIP_KEYS = new Set(['aliases', 'workspace', 'title', 'type', 'is_a', 'is a', '_archived', 'archived', 'archived_at', '_favorite', '_favorite_index', '_organized'])
function coerceValue(raw: string): FrontmatterValue {
if (raw.toLowerCase() === 'true') return true

View File

@@ -334,7 +334,17 @@ const ICON_MAP: Record<string, ComponentType<IconProps>> = Object.fromEntries(
ICON_OPTIONS.map((o) => [o.name, o.Icon]),
)
function normalizeIconName(name: string): string {
return name.trim().toLowerCase().replace(/[_\s]+/g, '-')
}
/** Resolves a Phosphor icon name to its component, without a fallback. */
export function findIcon(name: string | null | undefined): ComponentType<IconProps> | null {
if (!name) return null
return ICON_MAP[normalizeIconName(name)] ?? null
}
/** Resolves a Phosphor icon name to its component, with fallback to FileText */
export function resolveIcon(name: string | null): ComponentType<IconProps> {
return (name && ICON_MAP[name]) || FileText
return findIcon(name) ?? FileText
}

36
src/utils/noteIcon.ts Normal file
View File

@@ -0,0 +1,36 @@
import type { ComponentType } from 'react'
import type { IconProps } from './iconRegistry'
import { isEmoji } from './emoji'
import { findIcon } from './iconRegistry'
export type ResolvedNoteIcon =
| { kind: 'none' }
| { kind: 'emoji'; value: string }
| { kind: 'image'; src: string }
| { kind: 'phosphor'; Icon: ComponentType<IconProps> }
function isHttpUrl(value: string): boolean {
try {
const url = new URL(value)
return url.protocol === 'http:' || url.protocol === 'https:'
} catch {
return false
}
}
export function hasNoteIconValue(icon: string | null | undefined): boolean {
return typeof icon === 'string' && icon.trim().length > 0
}
export function resolveNoteIcon(icon: string | null | undefined): ResolvedNoteIcon {
if (!hasNoteIconValue(icon)) return { kind: 'none' }
const trimmed = icon.trim()
if (isEmoji(trimmed)) return { kind: 'emoji', value: trimmed }
if (isHttpUrl(trimmed)) return { kind: 'image', src: trimmed }
const Icon = findIcon(trimmed)
if (Icon) return { kind: 'phosphor', Icon }
return { kind: 'none' }
}

View File

@@ -18,6 +18,10 @@ const STATUS_KEY_PATTERNS = ['status']
const DATE_KEY_PATTERNS = ['date', 'deadline', 'due', 'start', 'end', 'scheduled']
const TAGS_KEY_PATTERNS = ['tags', 'keywords', 'categories', 'labels']
function isIconKey(key: string): boolean {
return key.toLowerCase() === 'icon'
}
function keyMatchesPatterns(key: string, patterns: string[]): boolean {
const lower = key.toLowerCase()
return patterns.some(p => lower === p || lower.includes(p))
@@ -28,6 +32,7 @@ function isDateString(value: string): boolean {
}
function detectStringType(key: string, strValue: string): PropertyDisplayMode {
if (isIconKey(key)) return 'text'
if (keyMatchesPatterns(key, STATUS_KEY_PATTERNS)) return 'status'
if (STATUS_VALUES.has(strValue.toLowerCase()) && !keyMatchesPatterns(key, DATE_KEY_PATTERNS)) return 'status'
if (isDateString(strValue)) return 'date'
@@ -38,6 +43,7 @@ function detectStringType(key: string, strValue: string): PropertyDisplayMode {
export function detectPropertyType(key: string, value: FrontmatterValue): PropertyDisplayMode {
if (value === null || value === undefined) return 'text'
if (typeof value === 'boolean') return 'boolean'
if (isIconKey(key)) return 'text'
if (keyMatchesPatterns(key, TAGS_KEY_PATTERNS)) return 'tags'
if (Array.isArray(value)) return 'text'
return detectStringType(key, String(value))