feat: pinned properties — inline bar in editor + values in note list
- PinnedPropertiesBar: horizontal bar below title with icon + label + editable value chips, overflow popover for hidden properties - PinnedPropertyChip: inline-editable chip with status/relationship colors - NoteListPinnedValues: compact value-only chips under note titles - Pin/unpin context menu (right-click) in Properties panel with highlight - Real-time sync: _pinned_properties changes propagate via frontmatterToEntryPatch - Default pinned properties (status, belongs_to, related_to) for types without config - Per-type config stored in type definition frontmatter as _pinned_properties Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
This commit is contained in:
@@ -4,6 +4,7 @@ import type { VaultEntry } from '../types'
|
||||
import type { FrontmatterValue } from '../components/Inspector'
|
||||
import { updateMockFrontmatter, deleteMockFrontmatterProperty } from './mockFrontmatterHelpers'
|
||||
import { updateMockContent, trackMockChange } from '../mock-tauri'
|
||||
import { parsePinnedConfig } from './usePinnedProperties'
|
||||
|
||||
const ENTRY_DELETE_MAP: Record<string, Partial<VaultEntry>> = {
|
||||
type: { isA: null }, is_a: { isA: null }, status: { status: null }, color: { color: null },
|
||||
@@ -42,9 +43,16 @@ export function frontmatterToEntryPatch(
|
||||
): EntryPatchResult {
|
||||
const k = key.toLowerCase().replace(/\s+/g, '_')
|
||||
if (op === 'delete') {
|
||||
if (k === '_pinned_properties') return { patch: { pinnedProperties: [] }, relationshipPatch: null }
|
||||
const relPatch: RelationshipPatch = { [key]: null }
|
||||
return { patch: ENTRY_DELETE_MAP[k] ?? {}, relationshipPatch: relPatch }
|
||||
}
|
||||
// Handle _pinned_properties for Type entries
|
||||
if (k === '_pinned_properties' && Array.isArray(value)) {
|
||||
const pinned = parsePinnedConfig(value.map(String))
|
||||
return { patch: { pinnedProperties: pinned }, relationshipPatch: null }
|
||||
}
|
||||
|
||||
const str = value != null ? String(value) : null
|
||||
const arr = Array.isArray(value) ? value.map(String) : []
|
||||
const updates: Record<string, Partial<VaultEntry>> = {
|
||||
|
||||
157
src/hooks/usePinnedProperties.ts
Normal file
157
src/hooks/usePinnedProperties.ts
Normal file
@@ -0,0 +1,157 @@
|
||||
import { useMemo, useCallback } from 'react'
|
||||
import type { VaultEntry, PinnedPropertyConfig } from '../types'
|
||||
import type { FrontmatterValue } from '../components/Inspector'
|
||||
import type { ParsedFrontmatter } from '../utils/frontmatter'
|
||||
|
||||
const DEFAULT_ICONS: Record<string, string> = {
|
||||
status: 'circle-dot',
|
||||
date: 'calendar',
|
||||
'belongs to': 'arrow-up-right',
|
||||
'related to': 'arrows-left-right',
|
||||
'due date': 'calendar',
|
||||
}
|
||||
|
||||
export function resolvePinIcon(key: string, explicit: string | null): string {
|
||||
if (explicit) return explicit
|
||||
return DEFAULT_ICONS[key.toLowerCase()] ?? 'arrow-up-right'
|
||||
}
|
||||
|
||||
export function serialisePinnedConfig(configs: PinnedPropertyConfig[]): string[] {
|
||||
return configs.map((c) => (c.icon ? `${c.key}:${c.icon}` : c.key))
|
||||
}
|
||||
|
||||
export function parsePinnedConfig(items: string[]): PinnedPropertyConfig[] {
|
||||
return items.map((s) => {
|
||||
const sep = s.indexOf(':')
|
||||
if (sep === -1) return { key: s.trim(), icon: null }
|
||||
return { key: s.slice(0, sep).trim(), icon: s.slice(sep + 1).trim() || null }
|
||||
})
|
||||
}
|
||||
|
||||
function computeDefaults(entries: VaultEntry[], typeName: string): PinnedPropertyConfig[] {
|
||||
const sample = entries.find((e) => e.isA === typeName)
|
||||
if (!sample) return []
|
||||
const pins: PinnedPropertyConfig[] = []
|
||||
if (sample.status != null) pins.push({ key: 'Status', icon: 'circle-dot' })
|
||||
if (sample.belongsTo.length > 0) pins.push({ key: 'Belongs to', icon: 'arrow-up-right' })
|
||||
if (sample.relatedTo.length > 0) pins.push({ key: 'Related to', icon: 'arrows-left-right' })
|
||||
return pins
|
||||
}
|
||||
|
||||
export interface ResolvedPinnedProperty {
|
||||
key: string
|
||||
icon: string
|
||||
label: string
|
||||
value: FrontmatterValue
|
||||
isRelationship: boolean
|
||||
}
|
||||
|
||||
function resolveValue(
|
||||
entry: VaultEntry,
|
||||
frontmatter: ParsedFrontmatter,
|
||||
key: string,
|
||||
): { value: FrontmatterValue; isRelationship: boolean } {
|
||||
const lowerKey = key.toLowerCase().replace(/\s+/g, '_')
|
||||
if (lowerKey === 'status') return { value: entry.status, isRelationship: false }
|
||||
const relValue = entry.relationships?.[key]
|
||||
if (relValue && relValue.length > 0) {
|
||||
return { value: relValue.length === 1 ? relValue[0] : relValue, isRelationship: true }
|
||||
}
|
||||
const propValue = entry.properties?.[key]
|
||||
if (propValue != null) return { value: propValue, isRelationship: false }
|
||||
const fmValue = frontmatter[key]
|
||||
if (fmValue != null) return { value: fmValue, isRelationship: false }
|
||||
return { value: null, isRelationship: false }
|
||||
}
|
||||
|
||||
function formatLabel(key: string): string {
|
||||
return key.replace(/_/g, ' ')
|
||||
}
|
||||
|
||||
export interface UsePinnedPropertiesResult {
|
||||
pinnedConfigs: PinnedPropertyConfig[]
|
||||
resolved: ResolvedPinnedProperty[]
|
||||
pinProperty: (key: string, icon?: string) => void
|
||||
unpinProperty: (key: string) => void
|
||||
updatePinIcon: (key: string, icon: string) => void
|
||||
reorderPins: (configs: PinnedPropertyConfig[]) => void
|
||||
isPinned: (key: string) => boolean
|
||||
}
|
||||
|
||||
export function usePinnedProperties({
|
||||
entry,
|
||||
entries,
|
||||
frontmatter,
|
||||
onUpdateTypeFrontmatter,
|
||||
}: {
|
||||
entry: VaultEntry | null
|
||||
entries: VaultEntry[]
|
||||
frontmatter: ParsedFrontmatter
|
||||
onUpdateTypeFrontmatter?: (typePath: string, key: string, value: FrontmatterValue) => Promise<void>
|
||||
}): UsePinnedPropertiesResult {
|
||||
// eslint-disable-next-line react-hooks/preserve-manual-memoization -- type lookup intentionally memoized
|
||||
const typeEntry = useMemo(() => {
|
||||
if (!entry?.isA) return null
|
||||
return entries.find((e) => e.isA === 'Type' && e.title === entry.isA) ?? null
|
||||
}, [entry?.isA, entries])
|
||||
|
||||
// eslint-disable-next-line react-hooks/preserve-manual-memoization -- pin config intentionally memoized
|
||||
const pinnedConfigs = useMemo((): PinnedPropertyConfig[] => {
|
||||
if (!entry?.isA) return []
|
||||
if (typeEntry && typeEntry.pinnedProperties.length > 0) return typeEntry.pinnedProperties
|
||||
return computeDefaults(entries, entry.isA)
|
||||
}, [entry?.isA, typeEntry, entries])
|
||||
|
||||
const resolved = useMemo((): ResolvedPinnedProperty[] => {
|
||||
if (!entry) return []
|
||||
return pinnedConfigs.map((cfg) => {
|
||||
const { value, isRelationship } = resolveValue(entry, frontmatter, cfg.key)
|
||||
return {
|
||||
key: cfg.key,
|
||||
icon: resolvePinIcon(cfg.key, cfg.icon),
|
||||
label: formatLabel(cfg.key),
|
||||
value,
|
||||
isRelationship,
|
||||
}
|
||||
})
|
||||
}, [entry, pinnedConfigs, frontmatter])
|
||||
|
||||
const savePins = useCallback(
|
||||
(newConfigs: PinnedPropertyConfig[]) => {
|
||||
if (!typeEntry || !onUpdateTypeFrontmatter) return
|
||||
const serialised = serialisePinnedConfig(newConfigs)
|
||||
onUpdateTypeFrontmatter(typeEntry.path, '_pinned_properties', serialised)
|
||||
},
|
||||
[typeEntry, onUpdateTypeFrontmatter],
|
||||
)
|
||||
|
||||
const pinProperty = useCallback(
|
||||
(key: string, icon?: string) => {
|
||||
if (pinnedConfigs.some((c) => c.key === key)) return
|
||||
savePins([...pinnedConfigs, { key, icon: icon ?? null }])
|
||||
},
|
||||
[pinnedConfigs, savePins],
|
||||
)
|
||||
|
||||
const unpinProperty = useCallback(
|
||||
(key: string) => savePins(pinnedConfigs.filter((c) => c.key !== key)),
|
||||
[pinnedConfigs, savePins],
|
||||
)
|
||||
|
||||
const updatePinIcon = useCallback(
|
||||
(key: string, icon: string) => savePins(pinnedConfigs.map((c) => (c.key === key ? { ...c, icon } : c))),
|
||||
[pinnedConfigs, savePins],
|
||||
)
|
||||
|
||||
const reorderPins = useCallback(
|
||||
(configs: PinnedPropertyConfig[]) => savePins(configs),
|
||||
[savePins],
|
||||
)
|
||||
|
||||
const isPinned = useCallback(
|
||||
(key: string) => pinnedConfigs.some((c) => c.key === key),
|
||||
[pinnedConfigs],
|
||||
)
|
||||
|
||||
return { pinnedConfigs, resolved, pinProperty, unpinProperty, updatePinIcon, reorderPins, isPinned }
|
||||
}
|
||||
Reference in New Issue
Block a user