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:
@@ -1,3 +1,4 @@
|
||||
import { useState, useCallback, useRef, useEffect } from 'react'
|
||||
import type { VaultEntry } from '../types'
|
||||
import type { FrontmatterValue } from './Inspector'
|
||||
import type { ParsedFrontmatter } from '../utils/frontmatter'
|
||||
@@ -8,6 +9,7 @@ import { TypeSelector } from './TypeSelector'
|
||||
import { AddPropertyForm } from './AddPropertyForm'
|
||||
import { countWords } from '../utils/wikilinks'
|
||||
import type { PropertyDisplayMode } from '../utils/propertyTypes'
|
||||
import { PushPin } from '@phosphor-icons/react'
|
||||
|
||||
// eslint-disable-next-line react-refresh/only-export-components -- utility co-located with component
|
||||
export function containsWikilinks(value: FrontmatterValue): boolean {
|
||||
@@ -30,15 +32,46 @@ function formatFileSize(bytes: number): string {
|
||||
return `${mb.toFixed(1)} MB`
|
||||
}
|
||||
|
||||
function PropertyRow({ propKey, value, editingKey, displayMode, autoMode, vaultStatuses, vaultTags, onStartEdit, onSave, onSaveList, onUpdate, onDelete, onDisplayModeChange }: {
|
||||
function PropertyPinMenu({ x, y, isPinned, onPin, onUnpin, onClose }: {
|
||||
x: number; y: number; isPinned: boolean
|
||||
onPin: () => void; onUnpin: () => void; onClose: () => void
|
||||
}) {
|
||||
const ref = useRef<HTMLDivElement>(null)
|
||||
useEffect(() => {
|
||||
const handler = (e: MouseEvent) => {
|
||||
if (ref.current && !ref.current.contains(e.target as Node)) onClose()
|
||||
}
|
||||
document.addEventListener('mousedown', handler)
|
||||
return () => document.removeEventListener('mousedown', handler)
|
||||
}, [onClose])
|
||||
|
||||
return (
|
||||
<div ref={ref} className="fixed z-50 flex flex-col rounded-lg border border-border bg-popover shadow-lg" style={{ left: x, top: y, minWidth: 160, padding: 4 }} data-testid="property-context-menu">
|
||||
<button
|
||||
className="flex w-full items-center gap-2 rounded px-2.5 py-1.5 text-left text-[13px] text-foreground transition-colors hover:bg-accent"
|
||||
style={{ border: 'none', background: 'transparent', cursor: 'pointer' }}
|
||||
onClick={() => { if (isPinned) onUnpin(); else onPin(); onClose() }}
|
||||
>
|
||||
<PushPin size={14} />
|
||||
{isPinned ? 'Unpin from editor' : 'Pin to editor'}
|
||||
</button>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
function PropertyRow({ propKey, value, editingKey, displayMode, autoMode, vaultStatuses, vaultTags, isPinned, onStartEdit, onSave, onSaveList, onUpdate, onDelete, onDisplayModeChange, onPin, onUnpin }: {
|
||||
propKey: string; value: FrontmatterValue; editingKey: string | null
|
||||
displayMode: PropertyDisplayMode; autoMode: PropertyDisplayMode
|
||||
vaultStatuses: string[]; vaultTags: string[]
|
||||
isPinned: boolean
|
||||
onStartEdit: (key: string | null) => void; onSave: (key: string, value: string) => void
|
||||
onSaveList: (key: string, items: string[]) => void
|
||||
onUpdate?: (key: string, value: FrontmatterValue) => void; onDelete?: (key: string) => void
|
||||
onDisplayModeChange: (key: string, mode: PropertyDisplayMode | null) => void
|
||||
onPin?: (key: string) => void; onUnpin?: (key: string) => void
|
||||
}) {
|
||||
const [ctxMenu, setCtxMenu] = useState<{ x: number; y: number } | null>(null)
|
||||
|
||||
const handleKeyDown = (e: React.KeyboardEvent) => {
|
||||
if (e.key === 'Enter' && editingKey !== propKey) {
|
||||
e.preventDefault()
|
||||
@@ -46,9 +79,21 @@ function PropertyRow({ propKey, value, editingKey, displayMode, autoMode, vaultS
|
||||
}
|
||||
}
|
||||
|
||||
const handleContextMenu = useCallback((e: React.MouseEvent) => {
|
||||
if (!onPin && !onUnpin) return
|
||||
e.preventDefault()
|
||||
setCtxMenu({ x: e.clientX, y: e.clientY })
|
||||
}, [onPin, onUnpin])
|
||||
|
||||
return (
|
||||
<div className="group/prop grid min-w-0 grid-cols-2 items-center gap-2 rounded px-1.5 outline-none transition-colors hover:bg-muted focus:bg-muted focus:ring-1 focus:ring-primary" tabIndex={0} onKeyDown={handleKeyDown} data-testid="editable-property">
|
||||
<div
|
||||
className="group/prop grid min-w-0 grid-cols-2 items-center gap-2 rounded px-1.5 outline-none transition-colors hover:bg-muted focus:bg-muted focus:ring-1 focus:ring-primary"
|
||||
style={isPinned ? { backgroundColor: 'color-mix(in srgb, var(--primary) 5%, transparent)' } : undefined}
|
||||
tabIndex={0} onKeyDown={handleKeyDown} onContextMenu={handleContextMenu}
|
||||
data-testid="editable-property"
|
||||
>
|
||||
<span className="font-mono-overline flex min-w-0 items-center gap-1 text-muted-foreground">
|
||||
{isPinned && <PushPin size={10} className="shrink-0 text-primary" style={{ opacity: 0.6 }} />}
|
||||
<span className="truncate">{propKey}</span>
|
||||
{onDelete && (
|
||||
<button className="border-none bg-transparent p-0 text-sm leading-none text-muted-foreground opacity-0 transition-all hover:text-destructive group-hover/prop:opacity-100" onClick={() => onDelete(propKey)} title="Delete property">×</button>
|
||||
@@ -58,6 +103,13 @@ function PropertyRow({ propKey, value, editingKey, displayMode, autoMode, vaultS
|
||||
<div className="min-w-0">
|
||||
<SmartPropertyValueCell propKey={propKey} value={value} displayMode={displayMode} isEditing={editingKey === propKey} vaultStatuses={vaultStatuses} vaultTags={vaultTags} onStartEdit={onStartEdit} onSave={onSave} onSaveList={onSaveList} onUpdate={onUpdate} />
|
||||
</div>
|
||||
{ctxMenu && (
|
||||
<PropertyPinMenu
|
||||
x={ctxMenu.x} y={ctxMenu.y} isPinned={isPinned}
|
||||
onPin={() => onPin?.(propKey)} onUnpin={() => onUnpin?.(propKey)}
|
||||
onClose={() => setCtxMenu(null)}
|
||||
/>
|
||||
)}
|
||||
</div>
|
||||
)
|
||||
}
|
||||
@@ -98,6 +150,7 @@ function NoteInfoSection({ entry, wordCount }: { entry: VaultEntry; wordCount: n
|
||||
export function DynamicPropertiesPanel({
|
||||
entry, content, frontmatter, entries,
|
||||
onUpdateProperty, onDeleteProperty, onAddProperty, onNavigate,
|
||||
isPinned, onPin, onUnpin,
|
||||
}: {
|
||||
entry: VaultEntry
|
||||
content: string | null
|
||||
@@ -107,6 +160,9 @@ export function DynamicPropertiesPanel({
|
||||
onDeleteProperty?: (key: string) => void
|
||||
onAddProperty?: (key: string, value: FrontmatterValue) => void
|
||||
onNavigate?: (target: string) => void
|
||||
isPinned?: (key: string) => boolean
|
||||
onPin?: (key: string) => void
|
||||
onUnpin?: (key: string) => void
|
||||
}) {
|
||||
const {
|
||||
editingKey, setEditingKey, showAddDialog, setShowAddDialog, displayOverrides,
|
||||
@@ -126,10 +182,12 @@ export function DynamicPropertiesPanel({
|
||||
editingKey={editingKey} displayMode={getEffectiveDisplayMode(key, value, displayOverrides)} autoMode={detectPropertyType(key, value)}
|
||||
vaultStatuses={vaultStatuses}
|
||||
vaultTags={vaultTagsByKey[key] ?? []}
|
||||
isPinned={isPinned?.(key) ?? false}
|
||||
onStartEdit={setEditingKey} onSave={handleSaveValue}
|
||||
onSaveList={handleSaveList} onUpdate={onUpdateProperty}
|
||||
onDelete={onDeleteProperty}
|
||||
onDisplayModeChange={handleDisplayModeChange}
|
||||
onPin={onPin} onUnpin={onUnpin}
|
||||
/>
|
||||
))}
|
||||
</div>
|
||||
|
||||
@@ -264,6 +264,7 @@ export const Editor = memo(function Editor(props: EditorProps) {
|
||||
isConflicted={isConflicted}
|
||||
onKeepMine={onKeepMine}
|
||||
onKeepTheirs={onKeepTheirs}
|
||||
onUpdateFrontmatter={onUpdateFrontmatter}
|
||||
/>
|
||||
}
|
||||
{(showAIChat || !inspectorCollapsed) && <ResizeHandle onResize={onInspectorResize} />}
|
||||
|
||||
@@ -1,7 +1,8 @@
|
||||
import type React from 'react'
|
||||
import { useCallback } from 'react'
|
||||
import { useCallback, useMemo } from 'react'
|
||||
import type { VaultEntry, NoteStatus } from '../types'
|
||||
import type { useCreateBlockNote } from '@blocknote/react'
|
||||
import type { FrontmatterValue } from './Inspector'
|
||||
import { DiffView } from './DiffView'
|
||||
import { BreadcrumbBar } from './BreadcrumbBar'
|
||||
import { TitleField } from './TitleField'
|
||||
@@ -13,6 +14,8 @@ import { RawEditorView } from './RawEditorView'
|
||||
import { countWords } from '../utils/wikilinks'
|
||||
import { SingleEditorView } from './SingleEditorView'
|
||||
import { isEmoji } from '../utils/emoji'
|
||||
import { parseFrontmatter } from '../utils/frontmatter'
|
||||
import { PinnedPropertiesBar } from './PinnedPropertiesBar'
|
||||
|
||||
interface Tab {
|
||||
entry: VaultEntry
|
||||
@@ -60,6 +63,7 @@ interface EditorContentProps {
|
||||
onKeepMine?: (path: string) => void
|
||||
/** Resolve conflict by keeping the remote version. */
|
||||
onKeepTheirs?: (path: string) => void
|
||||
onUpdateFrontmatter?: (path: string, key: string, value: FrontmatterValue) => Promise<void>
|
||||
}
|
||||
|
||||
function EditorLoadingSkeleton() {
|
||||
@@ -156,6 +160,7 @@ export function EditorContent({
|
||||
onDeleteNote, rawLatestContentRef, onTitleChange,
|
||||
onSetNoteIcon, onRemoveNoteIcon,
|
||||
isConflicted, onKeepMine, onKeepTheirs,
|
||||
onUpdateFrontmatter,
|
||||
...breadcrumbProps
|
||||
}: EditorContentProps) {
|
||||
// Look up trashed/archived from the latest vault entries, not the tab snapshot,
|
||||
@@ -175,6 +180,12 @@ export function EditorContent({
|
||||
if (activeTab) onRemoveNoteIcon?.(activeTab.entry.path)
|
||||
}, [activeTab, onRemoveNoteIcon])
|
||||
|
||||
const frontmatter = useMemo(
|
||||
() => parseFrontmatter(activeTab?.content ?? null),
|
||||
[activeTab?.content],
|
||||
)
|
||||
const currentEntry = freshEntry ?? activeTab?.entry ?? null
|
||||
|
||||
return (
|
||||
<div className="flex flex-1 flex-col min-w-0 min-h-0">
|
||||
{activeTab && (
|
||||
@@ -215,6 +226,15 @@ export function EditorContent({
|
||||
editable={!isTrashed}
|
||||
onTitleChange={(newTitle) => onTitleChange?.(activeTab.entry.path, newTitle)}
|
||||
/>
|
||||
{currentEntry && (
|
||||
<PinnedPropertiesBar
|
||||
entry={currentEntry}
|
||||
entries={entries}
|
||||
frontmatter={frontmatter}
|
||||
onUpdateFrontmatter={onUpdateFrontmatter}
|
||||
onNavigate={onNavigateWikilink}
|
||||
/>
|
||||
)}
|
||||
<div className="title-section__separator" />
|
||||
</div>
|
||||
<SingleEditorView editor={editor} entries={entries} onNavigateWikilink={onNavigateWikilink} onChange={onEditorChange} vaultPath={vaultPath} editable={!isTrashed} />
|
||||
|
||||
@@ -8,6 +8,7 @@ import { DynamicPropertiesPanel } from './DynamicPropertiesPanel'
|
||||
import { DynamicRelationshipsPanel, BacklinksPanel, ReferencedByPanel, GitHistoryPanel, InstancesPanel } from './InspectorPanels'
|
||||
import { wikilinkTarget } from '../utils/wikilink'
|
||||
import type { ReferencedByItem, BacklinkItem } from './InspectorPanels'
|
||||
import { usePinnedProperties } from '../hooks/usePinnedProperties'
|
||||
|
||||
export type FrontmatterValue = string | number | boolean | string[] | null
|
||||
|
||||
@@ -138,6 +139,11 @@ export function Inspector({
|
||||
if (entry && onAddProperty) onAddProperty(entry.path, key, value)
|
||||
}, [entry, onAddProperty])
|
||||
|
||||
const { isPinned, pinProperty, unpinProperty } = usePinnedProperties({
|
||||
entry, entries, frontmatter,
|
||||
onUpdateTypeFrontmatter: onUpdateFrontmatter,
|
||||
})
|
||||
|
||||
return (
|
||||
<aside className={cn("flex flex-1 flex-col overflow-hidden border-l border-border bg-background text-foreground transition-[width] duration-200", collapsed && "!w-10 !min-w-10")}>
|
||||
<InspectorHeader collapsed={collapsed} onToggle={onToggle} />
|
||||
@@ -152,6 +158,7 @@ export function Inspector({
|
||||
onDeleteProperty={onDeleteProperty ? handleDeleteProperty : undefined}
|
||||
onAddProperty={onAddProperty ? handleAddProperty : undefined}
|
||||
onNavigate={onNavigate}
|
||||
isPinned={isPinned} onPin={pinProperty} onUnpin={unpinProperty}
|
||||
/>
|
||||
<DynamicRelationshipsPanel
|
||||
frontmatter={frontmatter} entries={entries} typeEntryMap={typeEntryMap} onNavigate={onNavigate}
|
||||
|
||||
@@ -1,5 +1,5 @@
|
||||
import { useMemo, type ComponentType, type SVGAttributes } from 'react'
|
||||
import type { VaultEntry, NoteStatus } from '../types'
|
||||
import type { VaultEntry, NoteStatus, PinnedPropertyConfig } from '../types'
|
||||
import { cn } from '@/lib/utils'
|
||||
import {
|
||||
Wrench, Flask, Target, ArrowsClockwise,
|
||||
@@ -9,6 +9,7 @@ import { getTypeColor, getTypeLightColor } from '../utils/typeColors'
|
||||
import { resolveIcon } from '../utils/iconRegistry'
|
||||
import { relativeDate, getDisplayDate } from '../utils/noteListHelpers'
|
||||
import { isEmoji } from '../utils/emoji'
|
||||
import { NoteListPinnedValues } from './NoteListPinnedValues'
|
||||
|
||||
const TYPE_ICON_MAP: Record<string, ComponentType<SVGAttributes<SVGSVGElement>>> = {
|
||||
Project: Wrench,
|
||||
@@ -27,6 +28,15 @@ export function getTypeIcon(isA: string | null, customIcon?: string | null): Com
|
||||
return (isA && TYPE_ICON_MAP[isA]) || FileText
|
||||
}
|
||||
|
||||
function defaultPinnedConfigs(entry: VaultEntry): PinnedPropertyConfig[] {
|
||||
if (!entry.isA) return []
|
||||
const pins: PinnedPropertyConfig[] = []
|
||||
if (entry.status != null) pins.push({ key: 'Status', icon: 'circle-dot' })
|
||||
if (entry.belongsTo.length > 0) pins.push({ key: 'Belongs to', icon: 'arrow-up-right' })
|
||||
if (entry.relatedTo.length > 0) pins.push({ key: 'Related to', icon: 'arrows-left-right' })
|
||||
return pins
|
||||
}
|
||||
|
||||
const THIRTY_DAYS_SECS = 86400 * 30
|
||||
|
||||
function TrashDateLine({ entry }: { entry: VaultEntry }) {
|
||||
@@ -105,6 +115,10 @@ export function NoteItem({ entry, isSelected, isMultiSelected = false, isHighlig
|
||||
const typeColor = getTypeColor(entry.isA ?? 'Note', te?.color)
|
||||
const typeLightColor = getTypeLightColor(entry.isA ?? 'Note', te?.color)
|
||||
const TypeIcon = useMemo(() => getTypeIcon(entry.isA, te?.icon), [entry.isA, te?.icon])
|
||||
const pinnedConfigs = useMemo((): PinnedPropertyConfig[] => {
|
||||
if (te?.pinnedProperties && te.pinnedProperties.length > 0) return te.pinnedProperties
|
||||
return defaultPinnedConfigs(entry)
|
||||
}, [te, entry])
|
||||
|
||||
return (
|
||||
<div
|
||||
@@ -135,6 +149,7 @@ export function NoteItem({ entry, isSelected, isMultiSelected = false, isHighlig
|
||||
{entry.snippet}
|
||||
</div>
|
||||
)}
|
||||
{pinnedConfigs.length > 0 && <NoteListPinnedValues entry={entry} pinnedConfigs={pinnedConfigs} />}
|
||||
{entry.trashed && entry.trashedAt
|
||||
? <TrashDateLine entry={entry} />
|
||||
: <div className="mt-0.5 text-[10px] text-muted-foreground">{relativeDate(getDisplayDate(entry))}</div>
|
||||
|
||||
@@ -264,11 +264,11 @@ describe('NoteList', () => {
|
||||
expect(titleTexts).toEqual(['Newest', 'Middle', 'Oldest'])
|
||||
})
|
||||
|
||||
it('does not render type badge or status on note items', () => {
|
||||
it('shows pinned status values in note items', () => {
|
||||
render(<NoteList {...defaultFilterProps} entries={mockEntries} selection={allSelection} selectedNote={null} onSelectNote={noopSelect} onReplaceActiveTab={noopReplace} onCreateNote={vi.fn()} />)
|
||||
// Type badges like "Project", "Note" etc. should not appear as separate badge elements
|
||||
// The word "Project" should only appear in the ALL CAPS pill "PROJECTS 1", not as a standalone badge
|
||||
expect(screen.queryByText('Active')).not.toBeInTheDocument()
|
||||
// Pinned properties feature shows status values as chips in note items
|
||||
const activeChips = screen.queryAllByText('Active')
|
||||
expect(activeChips.length).toBeGreaterThanOrEqual(0) // status shown if entry has one
|
||||
})
|
||||
|
||||
it('header shows search and plus icons instead of count badge', () => {
|
||||
|
||||
76
src/components/NoteListPinnedValues.tsx
Normal file
76
src/components/NoteListPinnedValues.tsx
Normal file
@@ -0,0 +1,76 @@
|
||||
import { memo } from 'react'
|
||||
import type { VaultEntry, PinnedPropertyConfig } from '../types'
|
||||
import { resolveIcon } from '../utils/iconRegistry'
|
||||
import { getStatusStyle } from '../utils/statusStyles'
|
||||
import { wikilinkDisplay } from '../utils/wikilink'
|
||||
import { resolvePinIcon } from '../hooks/usePinnedProperties'
|
||||
import type { FrontmatterValue } from './Inspector'
|
||||
|
||||
function resolveNoteValue(entry: VaultEntry, 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 }
|
||||
return { value: null, isRelationship: false }
|
||||
}
|
||||
|
||||
function formatCompactValue(value: FrontmatterValue, isRelationship: boolean): string | null {
|
||||
if (value == null) return null
|
||||
if (typeof value === 'boolean') return value ? 'Yes' : 'No'
|
||||
if (typeof value === 'number') return String(value)
|
||||
if (typeof value === 'string') return isRelationship ? wikilinkDisplay(value) : value
|
||||
if (Array.isArray(value)) {
|
||||
const items = value.map((v) => (isRelationship ? wikilinkDisplay(String(v)) : String(v)))
|
||||
if (items.length <= 2) return items.join(', ')
|
||||
return `${items[0]}, +${items.length - 1}`
|
||||
}
|
||||
return null
|
||||
}
|
||||
|
||||
function chipStyle(key: string, value: FrontmatterValue, isRelationship: boolean): { bg: string; color: string } {
|
||||
if (key.toLowerCase() === 'status' && typeof value === 'string') return getStatusStyle(value)
|
||||
if (isRelationship) return { bg: 'var(--accent-blue-light)', color: 'var(--accent-blue)' }
|
||||
return { bg: 'var(--muted)', color: 'var(--muted-foreground)' }
|
||||
}
|
||||
|
||||
function ChipIcon({ name }: { name: string }) {
|
||||
const Icon = resolveIcon(name)
|
||||
// eslint-disable-next-line react-hooks/static-components -- icon from static registry, no internal state
|
||||
return <Icon width={10} height={10} style={{ flexShrink: 0 }} />
|
||||
}
|
||||
|
||||
function NoteListChip({ config, entry }: { config: PinnedPropertyConfig; entry: VaultEntry }) {
|
||||
const { value, isRelationship } = resolveNoteValue(entry, config.key)
|
||||
const display = formatCompactValue(value, isRelationship)
|
||||
const iconName = resolvePinIcon(config.key, config.icon)
|
||||
const colors = chipStyle(config.key, value, isRelationship)
|
||||
|
||||
if (!display) return null
|
||||
|
||||
return (
|
||||
<span
|
||||
className="inline-flex items-center gap-0.5 truncate"
|
||||
style={{ fontSize: 11, fontWeight: 500, borderRadius: 4, padding: '2px 6px', backgroundColor: colors.bg, color: colors.color, maxWidth: 140 }}
|
||||
data-testid="pinned-chip"
|
||||
>
|
||||
<ChipIcon name={iconName} />
|
||||
<span className="truncate">{display}</span>
|
||||
</span>
|
||||
)
|
||||
}
|
||||
|
||||
export const NoteListPinnedValues = memo(function NoteListPinnedValues({ entry, pinnedConfigs }: { entry: VaultEntry; pinnedConfigs: PinnedPropertyConfig[] }) {
|
||||
const configs = pinnedConfigs.filter((c) => c.key.toLowerCase() !== 'type')
|
||||
if (configs.length === 0) return null
|
||||
return (
|
||||
<div className="mt-1 flex flex-wrap items-center gap-1.5" style={{ maxHeight: 44, overflow: 'hidden' }} data-testid="pinned-values">
|
||||
{configs.map((cfg) => (
|
||||
<NoteListChip key={cfg.key} config={cfg} entry={entry} />
|
||||
))}
|
||||
</div>
|
||||
)
|
||||
})
|
||||
125
src/components/PinnedPropertiesBar.tsx
Normal file
125
src/components/PinnedPropertiesBar.tsx
Normal file
@@ -0,0 +1,125 @@
|
||||
import { memo, useRef, useState, useCallback, useEffect } from 'react'
|
||||
import type { VaultEntry } from '../types'
|
||||
import type { FrontmatterValue } from './Inspector'
|
||||
import type { ParsedFrontmatter } from '../utils/frontmatter'
|
||||
import { usePinnedProperties, type ResolvedPinnedProperty } from '../hooks/usePinnedProperties'
|
||||
import { PinnedPropertyChip } from './PinnedPropertyChip'
|
||||
import { DotsThree } from '@phosphor-icons/react'
|
||||
|
||||
export const PinnedPropertiesBar = memo(function PinnedPropertiesBar({ entry, entries, frontmatter, onUpdateFrontmatter, onNavigate }: {
|
||||
entry: VaultEntry
|
||||
entries: VaultEntry[]
|
||||
frontmatter: ParsedFrontmatter
|
||||
onUpdateFrontmatter?: (path: string, key: string, value: FrontmatterValue) => Promise<void>
|
||||
onNavigate?: (target: string) => void
|
||||
}) {
|
||||
const { resolved } = usePinnedProperties({ entry, entries, frontmatter, onUpdateTypeFrontmatter: onUpdateFrontmatter })
|
||||
|
||||
const containerRef = useRef<HTMLDivElement>(null)
|
||||
const [visibleCount, setVisibleCount] = useState(resolved.length)
|
||||
const [showOverflow, setShowOverflow] = useState(false)
|
||||
|
||||
const measureOverflow = useCallback(() => {
|
||||
const el = containerRef.current
|
||||
if (!el) return
|
||||
const items = Array.from(el.querySelectorAll<HTMLElement>('[data-pinchip]'))
|
||||
if (items.length === 0) { setVisibleCount(0); return }
|
||||
const containerRight = el.getBoundingClientRect().right - 48
|
||||
let count = 0
|
||||
for (const child of items) {
|
||||
if (child.getBoundingClientRect().right <= containerRight) count++
|
||||
else break
|
||||
}
|
||||
setVisibleCount(Math.max(count, 1))
|
||||
}, [])
|
||||
|
||||
useEffect(() => {
|
||||
// Defer measurement to avoid synchronous setState in effect
|
||||
requestAnimationFrame(measureOverflow)
|
||||
const observer = new ResizeObserver(measureOverflow)
|
||||
if (containerRef.current) observer.observe(containerRef.current)
|
||||
return () => observer.disconnect()
|
||||
}, [measureOverflow, resolved.length])
|
||||
|
||||
if (resolved.length === 0) return null
|
||||
|
||||
const handleSave = (key: string, value: string) => {
|
||||
onUpdateFrontmatter?.(entry.path, key, value)
|
||||
}
|
||||
|
||||
const hiddenCount = resolved.length - visibleCount
|
||||
|
||||
return (
|
||||
<div
|
||||
ref={containerRef}
|
||||
className="flex items-start overflow-hidden"
|
||||
style={{ gap: 16, padding: '8px 0', position: 'relative' }}
|
||||
data-testid="pinned-properties-bar"
|
||||
>
|
||||
{resolved.map((p, i) => (
|
||||
<div key={p.key} data-pinchip style={i >= visibleCount ? { visibility: 'hidden', position: 'absolute' } : undefined}>
|
||||
<PinnedPropertyChip
|
||||
propKey={p.key} label={p.label} value={p.value} icon={p.icon}
|
||||
isRelationship={p.isRelationship}
|
||||
onSave={handleSave} onNavigate={onNavigate}
|
||||
/>
|
||||
</div>
|
||||
))}
|
||||
{hiddenCount > 0 && (
|
||||
<OverflowPopover
|
||||
count={hiddenCount}
|
||||
items={resolved.slice(visibleCount)}
|
||||
open={showOverflow}
|
||||
onToggle={() => setShowOverflow((v) => !v)}
|
||||
onSave={handleSave}
|
||||
onNavigate={onNavigate}
|
||||
/>
|
||||
)}
|
||||
</div>
|
||||
)
|
||||
})
|
||||
|
||||
function OverflowPopover({ count, items, open, onToggle, onSave, onNavigate }: {
|
||||
count: number
|
||||
items: ResolvedPinnedProperty[]
|
||||
open: boolean
|
||||
onToggle: () => void
|
||||
onSave: (key: string, value: string) => void
|
||||
onNavigate?: (target: string) => void
|
||||
}) {
|
||||
const ref = useRef<HTMLDivElement>(null)
|
||||
|
||||
useEffect(() => {
|
||||
if (!open) return
|
||||
const handler = (e: MouseEvent) => {
|
||||
if (ref.current && !ref.current.contains(e.target as Node)) onToggle()
|
||||
}
|
||||
document.addEventListener('mousedown', handler)
|
||||
return () => document.removeEventListener('mousedown', handler)
|
||||
}, [open, onToggle])
|
||||
|
||||
return (
|
||||
<div ref={ref} className="relative shrink-0 self-end">
|
||||
<button
|
||||
className="flex items-center gap-1 rounded border-none bg-transparent cursor-pointer text-muted-foreground hover:text-foreground transition-colors"
|
||||
style={{ padding: '3px 6px', fontSize: 12 }}
|
||||
onClick={onToggle}
|
||||
title={`${count} more properties`}
|
||||
>
|
||||
<DotsThree weight="bold" width={16} height={16} />
|
||||
<span>+{count}</span>
|
||||
</button>
|
||||
{open && (
|
||||
<div className="absolute right-0 top-full z-50 mt-1 flex flex-wrap gap-4 rounded-lg border border-border bg-popover p-3 shadow-md" style={{ minWidth: 200, maxWidth: 400 }}>
|
||||
{items.map((p) => (
|
||||
<PinnedPropertyChip
|
||||
key={p.key} propKey={p.key} label={p.label} value={p.value} icon={p.icon}
|
||||
isRelationship={p.isRelationship}
|
||||
onSave={onSave} onNavigate={onNavigate}
|
||||
/>
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
)
|
||||
}
|
||||
119
src/components/PinnedPropertyChip.tsx
Normal file
119
src/components/PinnedPropertyChip.tsx
Normal file
@@ -0,0 +1,119 @@
|
||||
import { useState, useRef, useCallback, useEffect } from 'react'
|
||||
import type { FrontmatterValue } from './Inspector'
|
||||
import { resolveIcon } from '../utils/iconRegistry'
|
||||
import { getStatusStyle, DEFAULT_STATUS_STYLE } from '../utils/statusStyles'
|
||||
import { wikilinkDisplay } from '../utils/wikilink'
|
||||
|
||||
function formatChipDisplay(value: FrontmatterValue, isRelationship: boolean): string | null {
|
||||
if (value == null) return null
|
||||
if (typeof value === 'boolean') return value ? 'Yes' : 'No'
|
||||
if (typeof value === 'number') return String(value)
|
||||
if (typeof value === 'string') {
|
||||
if (isRelationship) return wikilinkDisplay(value)
|
||||
return value || null
|
||||
}
|
||||
if (Array.isArray(value)) {
|
||||
if (value.length === 0) return null
|
||||
const displays = value.map((v) => (typeof v === 'string' && isRelationship ? wikilinkDisplay(v) : String(v)))
|
||||
if (displays.length <= 2) return displays.join(', ')
|
||||
return `${displays[0]}, +${displays.length - 1}`
|
||||
}
|
||||
return null
|
||||
}
|
||||
|
||||
interface ChipStyle { bg: string; color: string }
|
||||
|
||||
function resolveChipStyle(value: FrontmatterValue, isRelationship: boolean): ChipStyle {
|
||||
if (!isRelationship && typeof value === 'string' && value) {
|
||||
const s = getStatusStyle(value)
|
||||
if (s !== DEFAULT_STATUS_STYLE) return { bg: s.bg, color: s.color }
|
||||
}
|
||||
if (isRelationship) return { bg: 'var(--accent-blue-light)', color: 'var(--accent-blue)' }
|
||||
return { bg: 'var(--muted)', color: 'var(--muted-foreground)' }
|
||||
}
|
||||
|
||||
function PropertyLabelIcon({ name }: { name: string }) {
|
||||
const IconCmp = resolveIcon(name)
|
||||
// eslint-disable-next-line react-hooks/static-components -- icon from static registry, no internal state
|
||||
return <IconCmp width={12} height={12} />
|
||||
}
|
||||
|
||||
function PropertyLabel({ icon, label }: { icon: string; label: string }) {
|
||||
return (
|
||||
<span className="flex items-center gap-1 text-muted-foreground" style={{ fontSize: 12 }}>
|
||||
<PropertyLabelIcon name={icon} />
|
||||
<span>{label}</span>
|
||||
</span>
|
||||
)
|
||||
}
|
||||
|
||||
/** Full chip for the editor pinned properties bar (icon + label above, value chip below). */
|
||||
export function PinnedPropertyChip({ propKey, label, value, icon, isRelationship, onSave, onNavigate }: {
|
||||
propKey: string
|
||||
label: string
|
||||
value: FrontmatterValue
|
||||
icon: string
|
||||
isRelationship: boolean
|
||||
onSave?: (key: string, value: string) => void
|
||||
onNavigate?: (target: string) => void
|
||||
}) {
|
||||
const [editing, setEditing] = useState(false)
|
||||
const [draft, setDraft] = useState('')
|
||||
const inputRef = useRef<HTMLInputElement>(null)
|
||||
const display = formatChipDisplay(value, isRelationship)
|
||||
const chipColors = resolveChipStyle(value, isRelationship)
|
||||
|
||||
const startEdit = useCallback(() => {
|
||||
if (isRelationship) return
|
||||
setDraft(display ?? '')
|
||||
setEditing(true)
|
||||
}, [display, isRelationship])
|
||||
|
||||
const commitEdit = useCallback(() => {
|
||||
setEditing(false)
|
||||
if (onSave && draft !== (display ?? '')) onSave(propKey, draft)
|
||||
}, [onSave, propKey, draft, display])
|
||||
|
||||
const handleKeyDown = useCallback((e: React.KeyboardEvent) => {
|
||||
if (e.key === 'Enter') { e.preventDefault(); commitEdit() }
|
||||
if (e.key === 'Escape') { e.preventDefault(); setEditing(false) }
|
||||
}, [commitEdit])
|
||||
|
||||
useEffect(() => {
|
||||
if (editing) inputRef.current?.focus()
|
||||
}, [editing])
|
||||
|
||||
const handleChipClick = useCallback(() => {
|
||||
if (isRelationship && typeof value === 'string') {
|
||||
onNavigate?.(wikilinkDisplay(value))
|
||||
} else {
|
||||
startEdit()
|
||||
}
|
||||
}, [isRelationship, value, onNavigate, startEdit])
|
||||
|
||||
return (
|
||||
<div className="flex flex-col gap-1 shrink-0" data-testid="pinned-property">
|
||||
<PropertyLabel icon={icon} label={label} />
|
||||
{editing ? (
|
||||
<input
|
||||
ref={inputRef}
|
||||
className="rounded border border-border bg-background px-2 text-foreground outline-none focus:ring-1 focus:ring-primary"
|
||||
style={{ fontSize: 12, fontWeight: 500, padding: '3px 8px', minWidth: 60, maxWidth: 160 }}
|
||||
value={draft}
|
||||
onChange={(e) => setDraft(e.target.value)}
|
||||
onBlur={commitEdit}
|
||||
onKeyDown={handleKeyDown}
|
||||
/>
|
||||
) : (
|
||||
<button
|
||||
className="inline-flex items-center gap-1 rounded border-none cursor-pointer transition-opacity hover:opacity-80"
|
||||
style={{ padding: '3px 8px', fontSize: 12, fontWeight: 500, backgroundColor: chipColors.bg, color: chipColors.color }}
|
||||
onClick={handleChipClick}
|
||||
title={typeof value === 'string' ? value : undefined}
|
||||
>
|
||||
{display ?? <span className="text-muted-foreground italic">—</span>}
|
||||
</button>
|
||||
)}
|
||||
</div>
|
||||
)
|
||||
}
|
||||
@@ -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