diff --git a/src/components/DynamicPropertiesPanel.tsx b/src/components/DynamicPropertiesPanel.tsx index 67e12da8..91bb4254 100644 --- a/src/components/DynamicPropertiesPanel.tsx +++ b/src/components/DynamicPropertiesPanel.tsx @@ -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(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 ( +
+ +
+ ) +} + +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 ( -
+
+ {isPinned && } {propKey} {onDelete && ( @@ -58,6 +103,13 @@ function PropertyRow({ propKey, value, editingKey, displayMode, autoMode, vaultS
+ {ctxMenu && ( + onPin?.(propKey)} onUnpin={() => onUnpin?.(propKey)} + onClose={() => setCtxMenu(null)} + /> + )}
) } @@ -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} /> ))}
diff --git a/src/components/Editor.tsx b/src/components/Editor.tsx index c427b717..4be7ca95 100644 --- a/src/components/Editor.tsx +++ b/src/components/Editor.tsx @@ -264,6 +264,7 @@ export const Editor = memo(function Editor(props: EditorProps) { isConflicted={isConflicted} onKeepMine={onKeepMine} onKeepTheirs={onKeepTheirs} + onUpdateFrontmatter={onUpdateFrontmatter} /> } {(showAIChat || !inspectorCollapsed) && } diff --git a/src/components/EditorContent.tsx b/src/components/EditorContent.tsx index afc1e5b2..59ed14d7 100644 --- a/src/components/EditorContent.tsx +++ b/src/components/EditorContent.tsx @@ -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 } 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 (
{activeTab && ( @@ -215,6 +226,15 @@ export function EditorContent({ editable={!isTrashed} onTitleChange={(newTitle) => onTitleChange?.(activeTab.entry.path, newTitle)} /> + {currentEntry && ( + + )}
diff --git a/src/components/Inspector.tsx b/src/components/Inspector.tsx index 2b397fcb..d3b50c83 100644 --- a/src/components/Inspector.tsx +++ b/src/components/Inspector.tsx @@ -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 (