From 771409d30bc955d1a42898c6feaeec533a328733 Mon Sep 17 00:00:00 2001 From: lucaronin Date: Tue, 7 Apr 2026 21:09:06 +0200 Subject: [PATCH] feat: add note icon property support --- docs/ABSTRACTIONS.md | 3 +- docs/ARCHITECTURE.md | 2 +- src/App.tsx | 21 ++--- src/components/BreadcrumbBar.test.tsx | 9 +-- src/components/BreadcrumbBar.tsx | 6 +- .../DynamicPropertiesPanel.test.tsx | 38 ++++++--- src/components/DynamicPropertiesPanel.tsx | 30 +++++-- src/components/Editor.tsx | 7 -- src/components/EditorContent.tsx | 29 ++----- src/components/NoteAutocomplete.tsx | 4 + src/components/NoteIcon.test.tsx | 78 +++++++++---------- src/components/NoteIcon.tsx | 76 ++++-------------- src/components/NoteItem.test.tsx | 32 ++++++++ src/components/NoteItem.tsx | 4 +- src/components/NoteSearchList.tsx | 3 + src/components/NoteTitleIcon.test.tsx | 33 ++++++++ src/components/NoteTitleIcon.tsx | 54 +++++++++++++ src/components/PropertyValueCells.tsx | 6 +- src/components/SearchPanel.tsx | 4 +- src/components/Sidebar.tsx | 5 +- src/components/editorSchema.tsx | 18 ++--- src/components/inspector/BacklinksPanel.tsx | 4 +- src/components/inspector/InstancesPanel.tsx | 1 + src/components/inspector/LinkButton.tsx | 7 +- .../inspector/ReferencedByPanel.tsx | 1 + src/components/inspector/shared.ts | 3 +- src/components/note-list/PinnedCard.tsx | 4 +- src/components/noteIconPropertyEvents.ts | 5 ++ src/hooks/useNoteSearch.test.ts | 8 ++ src/hooks/useNoteSearch.ts | 5 +- src/hooks/usePropertyPanelState.ts | 2 +- src/utils/iconRegistry.ts | 12 ++- src/utils/noteIcon.ts | 36 +++++++++ src/utils/propertyTypes.ts | 6 ++ 34 files changed, 358 insertions(+), 198 deletions(-) create mode 100644 src/components/NoteTitleIcon.test.tsx create mode 100644 src/components/NoteTitleIcon.tsx create mode 100644 src/components/noteIconPropertyEvents.ts create mode 100644 src/utils/noteIcon.ts diff --git a/docs/ABSTRACTIONS.md b/docs/ABSTRACTIONS.md index 8eb9fd79..8e733057 100644 --- a/docs/ABSTRACTIONS.md +++ b/docs/ABSTRACTIONS.md @@ -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 | diff --git a/docs/ARCHITECTURE.md b/docs/ARCHITECTURE.md index 289ebc87..2d62dd9a 100644 --- a/docs/ARCHITECTURE.md +++ b/docs/ARCHITECTURE.md @@ -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. diff --git a/src/App.tsx b/src/App.tsx index 3c397716..9e122d03 100644 --- a/src/App.tsx +++ b/src/App.tsx @@ -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([]) const [toastMessage, setToastMessage] = useState(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} diff --git a/src/components/BreadcrumbBar.test.tsx b/src/components/BreadcrumbBar.test.tsx index e5118b8c..a7614f8e 100644 --- a/src/components/BreadcrumbBar.test.tsx +++ b/src/components/BreadcrumbBar.test.tsx @@ -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() - // 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() - 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', () => { diff --git a/src/components/BreadcrumbBar.tsx b/src/components/BreadcrumbBar.tsx index aa63cfc2..e3e1b317 100644 --- a/src/components/BreadcrumbBar.tsx +++ b/src/components/BreadcrumbBar.tsx @@ -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 }) {
{typeLabel} - {filenameStem} + + + {filenameStem} +
) } diff --git a/src/components/DynamicPropertiesPanel.test.tsx b/src/components/DynamicPropertiesPanel.test.tsx index 7d4e3937..0bfa7136 100644 --- a/src/components/DynamicPropertiesPanel.test.tsx +++ b/src/components/DynamicPropertiesPanel.test.tsx @@ -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( { /> ) 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', () => { @@ -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( { ) 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( { /> ) 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( + + ) + + 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() diff --git a/src/components/DynamicPropertiesPanel.tsx b/src/components/DynamicPropertiesPanel.tsx index cde97957..847d6a9f 100644 --- a/src/components/DynamicPropertiesPanel.tsx +++ b/src/components/DynamicPropertiesPanel.tsx @@ -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 (
@@ -136,8 +156,8 @@ export function DynamicPropertiesPanel({ onDisplayModeChange={handleDisplayModeChange} /> ))} - {missingSuggested.map(key => ( - handleSuggestedAdd(key)} /> + {missingSuggested.map(({ key, label }) => ( + handleSuggestedAdd(key)} /> ))}
{showAddDialog diff --git a/src/components/Editor.tsx b/src/components/Editor.tsx index b3421b67..2f785f92 100644 --- a/src/components/Editor.tsx +++ b/src/components/Editor.tsx @@ -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} diff --git a/src/components/EditorContent.tsx b/src/components/EditorContent.tsx index 191a6caa..2c52aed2 100644 --- a/src/components/EditorContent.tsx +++ b/src/components/EditorContent.tsx @@ -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 /** 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
} @@ -242,14 +229,14 @@ export function EditorContent({
{showTitleSection && ( <> - {!emojiIcon && ( + {!hasDisplayIcon && (
- +
)} -
- {emojiIcon && ( - +
+ {hasDisplayIcon && ( + )} {item.TypeIcon && } + {item.title} {item.noteType && ( diff --git a/src/components/NoteIcon.test.tsx b/src/components/NoteIcon.test.tsx index 2bdc5b95..9a178bdd 100644 --- a/src/components/NoteIcon.test.tsx +++ b/src/components/NoteIcon.test.tsx @@ -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( {}} onRemoveIcon={() => {}} />) + render() expect(screen.getByTestId('note-icon-add')).toBeInTheDocument() expect(screen.queryByTestId('note-icon-display')).not.toBeInTheDocument() }) it('displays the emoji when icon is set', () => { - render( {}} onRemoveIcon={() => {}} />) + render() 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( {}} 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() + expect(screen.getByTestId('note-icon-display').querySelector('svg')).toBeInTheDocument() }) - it('opens emoji picker when add button is clicked', () => { - render( {}} onRemoveIcon={() => {}} />) + it('displays image icons when icon is set to a url', () => { + render() + 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() 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( {}} onRemoveIcon={() => {}} />) + it('focuses the icon property when existing icon is clicked', () => { + const handler = vi.fn() + window.addEventListener(FOCUS_NOTE_ICON_PROPERTY_EVENT, handler) + + render() 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( {}} 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( {}} 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( {}} />) - 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( {}} onRemoveIcon={() => {}} />) + render() expect(screen.queryByTestId('note-icon-add')).not.toBeInTheDocument() }) it('disables icon click when not editable', () => { - render( {}} onRemoveIcon={() => {}} />) + render() const display = screen.getByTestId('note-icon-display') expect(display).toBeDisabled() }) - it('opens picker on laputa:open-icon-picker event', () => { - render( {}} 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() 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) }) }) diff --git a/src/components/NoteIcon.tsx b/src/components/NoteIcon.tsx index fff01b74..33761467 100644 --- a/src/components/NoteIcon.tsx +++ b/src/components/NoteIcon.tsx @@ -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 (
{hasIcon ? ( ) : ( editable && ( ) )} - {showRemove && hasIcon && ( -
- - -
- )} - {pickerOpen && ( - setPickerOpen(false)} - /> - )}
) } diff --git a/src/components/NoteItem.test.tsx b/src/components/NoteItem.test.tsx index abeb3d95..a2f5b7c2 100644 --- a/src/components/NoteItem.test.tsx +++ b/src/components/NoteItem.test.tsx @@ -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( + + ) + + expect(screen.getByTestId('note-title-icon').tagName.toLowerCase()).toBe('svg') + }) + + it('renders an image note icon in the note row', () => { + render( + + ) + + const icon = screen.getByTestId('note-title-icon') + expect(icon.tagName.toLowerCase()).toBe('img') + expect(icon).toHaveAttribute('src', 'https://example.com/favicon.png') + }) +}) diff --git a/src/components/NoteItem.tsx b/src/components/NoteItem.tsx index b0d25088..88bc4a72 100644 --- a/src/components/NoteItem.tsx +++ b/src/components/NoteItem.tsx @@ -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>> = { Project: Wrench, @@ -224,7 +224,7 @@ export function NoteItem({ entry, isSelected, isMultiSelected = false, isHighlig
{noteStatus !== 'clean' && !isBinary && } - {entry.icon && isEmoji(entry.icon) && {entry.icon}} + {entry.title} {!isBinary && }
diff --git a/src/components/NoteSearchList.tsx b/src/components/NoteSearchList.tsx index 741f8fb1..3c2883df 100644 --- a/src/components/NoteSearchList.tsx +++ b/src/components/NoteSearchList.tsx @@ -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({ style={item.typeColor ? { color: item.typeColor } : undefined} /> )} + {item.title} {item.noteType && ( diff --git a/src/components/NoteTitleIcon.test.tsx b/src/components/NoteTitleIcon.test.tsx new file mode 100644 index 00000000..d342ed91 --- /dev/null +++ b/src/components/NoteTitleIcon.test.tsx @@ -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() + + expect(screen.getByTestId('note-title-icon')).toHaveTextContent('🚀') + }) + + it('renders a Phosphor icon when the name is recognized', () => { + render() + + 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() + + 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() + + expect(screen.queryByTestId('note-title-icon')).not.toBeInTheDocument() + expect(container).toBeEmptyDOMElement() + }) +}) diff --git a/src/components/NoteTitleIcon.tsx b/src/components/NoteTitleIcon.tsx new file mode 100644 index 00000000..35f38327 --- /dev/null +++ b/src/components/NoteTitleIcon.tsx @@ -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 ( + + {resolved.value} + + ) + } + + if (resolved.kind === 'image') { + return ( + { + event.currentTarget.style.display = 'none' + }} + data-testid={testId} + /> + ) + } + + return ( + + ) +} diff --git a/src/components/PropertyValueCells.tsx b/src/components/PropertyValueCells.tsx index 4322e38d..a39a24fa 100644 --- a/src/components/PropertyValueCells.tsx +++ b/src/components/PropertyValueCells.tsx @@ -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 @@ -324,4 +325,3 @@ export function SmartPropertyValueCell(props: SmartCellProps) { } return } - diff --git a/src/components/SearchPanel.tsx b/src/components/SearchPanel.tsx index fbd32aab..03f55072 100644 --- a/src/components/SearchPanel.tsx +++ b/src/components/SearchPanel.tsx @@ -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({
- {entry?.icon && isEmoji(entry.icon) && {entry.icon}} + {entry?.title ?? result.title} {noteType && ( diff --git a/src/components/Sidebar.tsx b/src/components/Sidebar.tsx index 870e0442..0f206e21 100644 --- a/src/components/Sidebar.tsx +++ b/src/components/Sidebar.tsx @@ -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 (
- {icon && {icon}} + {entry.title}
diff --git a/src/components/editorSchema.tsx b/src/components/editorSchema.tsx index bc01e8c6..2758fd97 100644 --- a/src/components/editorSchema.tsx +++ b/src/components/editorSchema.tsx @@ -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 ( - {emoji && {emoji}{' '}} + {text} ) diff --git a/src/components/inspector/BacklinksPanel.tsx b/src/components/inspector/BacklinksPanel.tsx index 45074072..c04a9d4d 100644 --- a/src/components/inspector/BacklinksPanel.tsx +++ b/src/components/inspector/BacklinksPanel.tsx @@ -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) && {entry.icon}} + {entry.title} diff --git a/src/components/inspector/InstancesPanel.tsx b/src/components/inspector/InstancesPanel.tsx index fbfc9ef7..2a24b61d 100644 --- a/src/components/inspector/InstancesPanel.tsx +++ b/src/components/inspector/InstancesPanel.tsx @@ -37,6 +37,7 @@ export function InstancesPanel({ entry, entries, typeEntryMap, onNavigate }: { onNavigate(e.title)} diff --git a/src/components/inspector/LinkButton.tsx b/src/components/inspector/LinkButton.tsx index 1fbf6334..d5908501 100644 --- a/src/components/inspector/LinkButton.tsx +++ b/src/components/inspector/LinkButton.tsx @@ -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 (archived) 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} > - {emoji && {emoji}} + {label} diff --git a/src/components/inspector/ReferencedByPanel.tsx b/src/components/inspector/ReferencedByPanel.tsx index 59862f64..44d90f07 100644 --- a/src/components/inspector/ReferencedByPanel.tsx +++ b/src/components/inspector/ReferencedByPanel.tsx @@ -41,6 +41,7 @@ export function ReferencedByPanel({ items, typeEntryMap, onNavigate }: { onNavigate(e.title)} diff --git a/src/components/inspector/shared.ts b/src/components/inspector/shared.ts index bcd2beed..5b24e8e7 100644 --- a/src/components/inspector/shared.ts +++ b/src/components/inspector/shared.ts @@ -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, diff --git a/src/components/note-list/PinnedCard.tsx b/src/components/note-list/PinnedCard.tsx index a7971fea..4f4573cc 100644 --- a/src/components/note-list/PinnedCard.tsx +++ b/src/components/note-list/PinnedCard.tsx @@ -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 */}
- {entry.icon && isEmoji(entry.icon) && {entry.icon}} + {entry.title}
{entry.snippet}
diff --git a/src/components/noteIconPropertyEvents.ts b/src/components/noteIconPropertyEvents.ts new file mode 100644 index 00000000..003665f3 --- /dev/null +++ b/src/components/noteIconPropertyEvents.ts @@ -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)) +} diff --git a/src/hooks/useNoteSearch.test.ts b/src/hooks/useNoteSearch.test.ts index 6d93389e..dacce8cd 100644 --- a/src/hooks/useNoteSearch.test.ts +++ b/src/hooks/useNoteSearch.test.ts @@ -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) diff --git a/src/hooks/useNoteSearch.ts b/src/hooks/useNoteSearch.ts index 424333f2..824bc754 100644 --- a/src/hooks/useNoteSearch.ts +++ b/src/hooks/useNoteSearch.ts @@ -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): 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, diff --git a/src/hooks/usePropertyPanelState.ts b/src/hooks/usePropertyPanelState.ts index 97bf8608..26d21827 100644 --- a/src/hooks/usePropertyPanelState.ts +++ b/src/hooks/usePropertyPanelState.ts @@ -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 diff --git a/src/utils/iconRegistry.ts b/src/utils/iconRegistry.ts index c17cc8d5..0713c33e 100644 --- a/src/utils/iconRegistry.ts +++ b/src/utils/iconRegistry.ts @@ -334,7 +334,17 @@ const ICON_MAP: Record> = 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 | 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 { - return (name && ICON_MAP[name]) || FileText + return findIcon(name) ?? FileText } diff --git a/src/utils/noteIcon.ts b/src/utils/noteIcon.ts new file mode 100644 index 00000000..ef5ca17e --- /dev/null +++ b/src/utils/noteIcon.ts @@ -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 } + +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' } +} diff --git a/src/utils/propertyTypes.ts b/src/utils/propertyTypes.ts index 7930b77a..877c84aa 100644 --- a/src/utils/propertyTypes.ts +++ b/src/utils/propertyTypes.ts @@ -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))