feat: add note icon property support
This commit is contained in:
@@ -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 |
|
||||
|
||||
@@ -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.
|
||||
|
||||
|
||||
21
src/App.tsx
21
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<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}
|
||||
|
||||
@@ -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', () => {
|
||||
|
||||
@@ -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>
|
||||
)
|
||||
}
|
||||
|
||||
@@ -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()
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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}
|
||||
|
||||
@@ -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}
|
||||
|
||||
@@ -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 && (
|
||||
|
||||
@@ -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)
|
||||
})
|
||||
})
|
||||
|
||||
@@ -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>
|
||||
)
|
||||
}
|
||||
|
||||
@@ -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')
|
||||
})
|
||||
})
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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 && (
|
||||
|
||||
33
src/components/NoteTitleIcon.test.tsx
Normal file
33
src/components/NoteTitleIcon.test.tsx
Normal 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()
|
||||
})
|
||||
})
|
||||
54
src/components/NoteTitleIcon.tsx
Normal file
54
src/components/NoteTitleIcon.tsx
Normal 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}
|
||||
/>
|
||||
)
|
||||
}
|
||||
@@ -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} />
|
||||
}
|
||||
|
||||
|
||||
@@ -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 && (
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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>
|
||||
)
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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)}
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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)}
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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>
|
||||
|
||||
5
src/components/noteIconPropertyEvents.ts
Normal file
5
src/components/noteIconPropertyEvents.ts
Normal 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))
|
||||
}
|
||||
@@ -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)
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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
36
src/utils/noteIcon.ts
Normal 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' }
|
||||
}
|
||||
@@ -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))
|
||||
|
||||
Reference in New Issue
Block a user