feat: drag-and-drop reordering for pinned properties bar

Uses @dnd-kit/sortable with horizontal strategy. Drag order
persists to _pinned_properties in the type definition frontmatter.

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
This commit is contained in:
Test
2026-03-25 10:22:35 +01:00
parent 47b5e45696
commit 5c7693902d
3 changed files with 73 additions and 26 deletions

View File

@@ -14,6 +14,9 @@ import { RawEditorView } from './RawEditorView'
import { countWords } from '../utils/wikilinks'
import { SingleEditorView } from './SingleEditorView'
import { isEmoji } from '../utils/emoji'
import { PinnedPropertiesBar } from './PinnedPropertiesBar'
import { parseFrontmatter } from '../utils/frontmatter'
import type { FrontmatterValue } from './Inspector'
import { parseFrontmatter } from '../utils/frontmatter'
import { PinnedPropertiesBar } from './PinnedPropertiesBar'

View File

@@ -10,6 +10,7 @@ import { resolveIcon } from '../utils/iconRegistry'
import { relativeDate, getDisplayDate } from '../utils/noteListHelpers'
import { isEmoji } from '../utils/emoji'
import { NoteListPinnedValues } from './NoteListPinnedValues'
import { NoteListPinnedValues } from './NoteListPinnedValues'
const TYPE_ICON_MAP: Record<string, ComponentType<SVGAttributes<SVGSVGElement>>> = {
Project: Wrench,
@@ -150,6 +151,7 @@ export function NoteItem({ entry, isSelected, isMultiSelected = false, isHighlig
</div>
)}
{pinnedConfigs.length > 0 && <NoteListPinnedValues entry={entry} pinnedConfigs={pinnedConfigs} />}
{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>

View File

@@ -1,4 +1,7 @@
import { memo, useRef, useState, useCallback, useEffect } from 'react'
import { DndContext, closestCenter, PointerSensor, useSensor, useSensors, type DragEndEvent } from '@dnd-kit/core'
import { SortableContext, useSortable, horizontalListSortingStrategy } from '@dnd-kit/sortable'
import { CSS } from '@dnd-kit/utilities'
import type { VaultEntry } from '../types'
import type { FrontmatterValue } from './Inspector'
import type { ParsedFrontmatter } from '../utils/frontmatter'
@@ -13,7 +16,23 @@ export const PinnedPropertiesBar = memo(function PinnedPropertiesBar({ entry, en
onUpdateFrontmatter?: (path: string, key: string, value: FrontmatterValue) => Promise<void>
onNavigate?: (target: string) => void
}) {
const { resolved } = usePinnedProperties({ entry, entries, frontmatter, onUpdateTypeFrontmatter: onUpdateFrontmatter })
const { resolved, pinnedConfigs, reorderPins } = usePinnedProperties({ entry, entries, frontmatter, onUpdateTypeFrontmatter: onUpdateFrontmatter })
const sensors = useSensors(
useSensor(PointerSensor, { activationConstraint: { distance: 5 } }),
)
const handleDragEnd = useCallback((event: DragEndEvent) => {
const { active, over } = event
if (!over || active.id === over.id) return
const oldIndex = pinnedConfigs.findIndex((c) => c.key === active.id)
const newIndex = pinnedConfigs.findIndex((c) => c.key === over.id)
if (oldIndex === -1 || newIndex === -1) return
const reordered = [...pinnedConfigs]
const [moved] = reordered.splice(oldIndex, 1)
reordered.splice(newIndex, 0, moved)
reorderPins(reordered)
}, [pinnedConfigs, reorderPins])
const containerRef = useRef<HTMLDivElement>(null)
const [visibleCount, setVisibleCount] = useState(resolved.length)
@@ -49,36 +68,59 @@ export const PinnedPropertiesBar = memo(function PinnedPropertiesBar({ entry, en
const hiddenCount = resolved.length - visibleCount
const sortableIds = resolved.map((p) => p.key)
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}
/>
<DndContext sensors={sensors} collisionDetection={closestCenter} onDragEnd={handleDragEnd}>
<SortableContext items={sortableIds} strategy={horizontalListSortingStrategy}>
<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) => (
<SortableChip key={p.key} prop={p} visible={i < visibleCount} onSave={handleSave} onNavigate={onNavigate} />
))}
{hiddenCount > 0 && (
<OverflowPopover
count={hiddenCount}
items={resolved.slice(visibleCount)}
open={showOverflow}
onToggle={() => setShowOverflow((v) => !v)}
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>
</SortableContext>
</DndContext>
)
})
function SortableChip({ prop, visible, onSave, onNavigate }: {
prop: ResolvedPinnedProperty; visible: boolean
onSave: (key: string, value: string) => void; onNavigate?: (target: string) => void
}) {
const { attributes, listeners, setNodeRef, transform, transition, isDragging } = useSortable({ id: prop.key })
const style = {
transform: CSS.Transform.toString(transform),
transition,
opacity: isDragging ? 0.5 : 1,
...(visible ? {} : { visibility: 'hidden' as const, position: 'absolute' as const }),
}
return (
<div ref={setNodeRef} data-pinchip style={style} {...attributes} {...listeners}>
<PinnedPropertyChip
propKey={prop.key} label={prop.label} value={prop.value} icon={prop.icon}
isRelationship={prop.isRelationship}
onSave={onSave} onNavigate={onNavigate}
/>
</div>
)
}
function OverflowPopover({ count, items, open, onToggle, onSave, onNavigate }: {
count: number
items: ResolvedPinnedProperty[]