Compare commits
10 Commits
v0.2026040
...
v0.2026040
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
b9879b15c9 | ||
|
|
32a30b40b9 | ||
|
|
5dc2fe9341 | ||
|
|
b390dbaf77 | ||
|
|
81eb5e844d | ||
|
|
05dd46c8d0 | ||
|
|
746efacf28 | ||
|
|
02a6622e64 | ||
|
|
8979b71bda | ||
|
|
13edf894fb |
@@ -1,2 +1,2 @@
|
||||
HOTSPOT_THRESHOLD=9.68
|
||||
AVERAGE_THRESHOLD=9.3
|
||||
AVERAGE_THRESHOLD=9.32
|
||||
|
||||
@@ -1,14 +1,11 @@
|
||||
import { Plus, X, CalendarBlank, WarningCircle } from '@phosphor-icons/react'
|
||||
import { format } from 'date-fns'
|
||||
import { Plus, X, WarningCircle } from '@phosphor-icons/react'
|
||||
import { Button } from '@/components/ui/button'
|
||||
import { Input } from '@/components/ui/input'
|
||||
import { Calendar } from '@/components/ui/calendar'
|
||||
import { Popover, PopoverContent, PopoverTrigger } from '@/components/ui/popover'
|
||||
import { Select, SelectContent, SelectItem, SelectTrigger, SelectValue } from '@/components/ui/select'
|
||||
import { cn } from '@/lib/utils'
|
||||
import type { FilterCondition, FilterOp, FilterGroup, FilterNode } from '../types'
|
||||
import { parseDateFilterInput } from '../utils/filterDates'
|
||||
import { FilterFieldCombobox } from './FilterFieldCombobox'
|
||||
import { DateValueInput } from './filter-builder/DateValueInput'
|
||||
|
||||
const OPERATORS: { value: FilterOp; label: string }[] = [
|
||||
{ value: 'equals', label: 'equals' },
|
||||
@@ -81,43 +78,6 @@ function OperatorSelect({ value, onChange }: {
|
||||
)
|
||||
}
|
||||
|
||||
function DateValueInput({ value, onChange }: { value: string; onChange: (v: string) => void }) {
|
||||
const selected = value ? parseDateFilterInput(value) ?? undefined : undefined
|
||||
return (
|
||||
<div className="flex flex-1 min-w-0 items-center gap-1">
|
||||
<Input
|
||||
className="h-8 flex-1 min-w-0 text-sm"
|
||||
placeholder='YYYY-MM-DD or "10 days ago"'
|
||||
value={value}
|
||||
onChange={(e) => onChange(e.target.value)}
|
||||
data-testid="date-value-input"
|
||||
/>
|
||||
<Popover>
|
||||
<PopoverTrigger asChild>
|
||||
<Button
|
||||
type="button"
|
||||
variant="outline"
|
||||
size="sm"
|
||||
data-testid="date-picker-trigger"
|
||||
className="h-8 w-8 shrink-0 px-0"
|
||||
title={selected ? format(selected, 'MMM d, yyyy') : 'Pick a date'}
|
||||
aria-label={selected ? `Open date picker (${format(selected, 'MMM d, yyyy')})` : 'Open date picker'}
|
||||
>
|
||||
<CalendarBlank size={14} className="shrink-0 text-muted-foreground" />
|
||||
</Button>
|
||||
</PopoverTrigger>
|
||||
<PopoverContent className="w-auto p-0" align="start">
|
||||
<Calendar
|
||||
mode="single"
|
||||
selected={selected}
|
||||
onSelect={(day) => onChange(day ? format(day, 'yyyy-MM-dd') : '')}
|
||||
/>
|
||||
</PopoverContent>
|
||||
</Popover>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
function TextValueInput({ value, onChange, regexEnabled, regexSupported, invalidRegex, onToggleRegex }: {
|
||||
value: string
|
||||
onChange: (v: string) => void
|
||||
|
||||
@@ -1,7 +1,8 @@
|
||||
import { CaretUpDown } from '@phosphor-icons/react'
|
||||
import { useId, useMemo, useRef, useState } from 'react'
|
||||
import { useEffect, useId, useMemo, useRef, useState, type ChangeEvent, type FocusEvent, type KeyboardEvent, type RefObject } from 'react'
|
||||
import { Input } from '@/components/ui/input'
|
||||
import { cn } from '@/lib/utils'
|
||||
import { Popover, PopoverAnchor, PopoverContent } from '@/components/ui/popover'
|
||||
import { FilterFieldOptionsList } from './filter-builder/FilterFieldOptionsList'
|
||||
|
||||
const CONTENT_FIELDS = new Set(['body'])
|
||||
|
||||
@@ -47,8 +48,154 @@ function initialHighlightIndex(options: string[], currentValue: string): number
|
||||
return currentIndex >= 0 ? currentIndex : 0
|
||||
}
|
||||
|
||||
function optionTestId(field: string): string {
|
||||
return `filter-field-option-${field.replace(/[^a-z0-9_-]+/gi, '-')}`
|
||||
function stepHighlightedIndex(current: number, optionCount: number, direction: 'next' | 'previous'): number {
|
||||
if (current < 0) return direction === 'next' ? 0 : optionCount - 1
|
||||
if (direction === 'next') return (current + 1) % optionCount
|
||||
return (current - 1 + optionCount) % optionCount
|
||||
}
|
||||
|
||||
function handleFilterFieldKeyDown({
|
||||
event,
|
||||
open,
|
||||
options,
|
||||
highlightedIndex,
|
||||
openCombobox,
|
||||
setHighlightedIndex,
|
||||
selectOption,
|
||||
closeCombobox,
|
||||
}: {
|
||||
event: KeyboardEvent<HTMLInputElement>
|
||||
open: boolean
|
||||
options: string[]
|
||||
highlightedIndex: number
|
||||
openCombobox: () => void
|
||||
setHighlightedIndex: (updater: number | ((current: number) => number)) => void
|
||||
selectOption: (value: string) => void
|
||||
closeCombobox: () => void
|
||||
}) {
|
||||
switch (event.key) {
|
||||
case 'ArrowDown':
|
||||
event.preventDefault()
|
||||
if (!open) {
|
||||
openCombobox()
|
||||
return
|
||||
}
|
||||
if (options.length === 0) return
|
||||
setHighlightedIndex((current) => stepHighlightedIndex(current, options.length, 'next'))
|
||||
return
|
||||
case 'ArrowUp':
|
||||
event.preventDefault()
|
||||
if (!open) {
|
||||
openCombobox()
|
||||
return
|
||||
}
|
||||
if (options.length === 0) return
|
||||
setHighlightedIndex((current) => stepHighlightedIndex(current, options.length, 'previous'))
|
||||
return
|
||||
case 'Enter':
|
||||
if (!open || highlightedIndex < 0 || options[highlightedIndex] === undefined) return
|
||||
event.preventDefault()
|
||||
selectOption(options[highlightedIndex])
|
||||
return
|
||||
case 'Escape':
|
||||
if (!open) return
|
||||
event.preventDefault()
|
||||
closeCombobox()
|
||||
return
|
||||
default:
|
||||
return
|
||||
}
|
||||
}
|
||||
|
||||
function FilterFieldInput({
|
||||
inputRef,
|
||||
open,
|
||||
query,
|
||||
value,
|
||||
listboxId,
|
||||
highlightedIndex,
|
||||
onFocus,
|
||||
onChange,
|
||||
onKeyDown,
|
||||
}: {
|
||||
inputRef: RefObject<HTMLInputElement | null>
|
||||
open: boolean
|
||||
query: string
|
||||
value: string
|
||||
listboxId: string
|
||||
highlightedIndex: number
|
||||
onFocus: () => void
|
||||
onChange: (event: ChangeEvent<HTMLInputElement>) => void
|
||||
onKeyDown: (event: KeyboardEvent<HTMLInputElement>) => void
|
||||
}) {
|
||||
return (
|
||||
<>
|
||||
<Input
|
||||
ref={inputRef}
|
||||
value={open ? query : value}
|
||||
onFocus={onFocus}
|
||||
onChange={onChange}
|
||||
onKeyDown={onKeyDown}
|
||||
role="combobox"
|
||||
aria-autocomplete="list"
|
||||
aria-controls={listboxId}
|
||||
aria-expanded={open}
|
||||
aria-activedescendant={highlightedIndex >= 0 ? `${listboxId}-option-${highlightedIndex}` : undefined}
|
||||
className="h-8 pr-7 text-sm"
|
||||
data-testid="filter-field-combobox-input"
|
||||
/>
|
||||
<CaretUpDown
|
||||
size={14}
|
||||
className="pointer-events-none absolute right-2 top-1/2 -translate-y-1/2 text-muted-foreground"
|
||||
aria-hidden="true"
|
||||
/>
|
||||
</>
|
||||
)
|
||||
}
|
||||
|
||||
function FilterFieldPopoverPanel({
|
||||
open,
|
||||
contentWidth,
|
||||
listboxId,
|
||||
fieldGroups,
|
||||
options,
|
||||
highlightedIndex,
|
||||
onHighlight,
|
||||
onSelect,
|
||||
}: {
|
||||
open: boolean
|
||||
contentWidth: number
|
||||
listboxId: string
|
||||
fieldGroups: FieldGroup[]
|
||||
options: string[]
|
||||
highlightedIndex: number
|
||||
onHighlight: (index: number) => void
|
||||
onSelect: (value: string) => void
|
||||
}) {
|
||||
if (!open) return null
|
||||
|
||||
return (
|
||||
<PopoverContent
|
||||
align="start"
|
||||
sideOffset={4}
|
||||
className="max-h-60 overflow-y-auto p-1"
|
||||
style={{ width: contentWidth }}
|
||||
onOpenAutoFocus={(event) => event.preventDefault()}
|
||||
onCloseAutoFocus={(event) => event.preventDefault()}
|
||||
data-testid="filter-field-combobox-popover"
|
||||
>
|
||||
<div id={listboxId} role="listbox" data-testid="filter-field-combobox-options">
|
||||
<FilterFieldOptionsList
|
||||
listboxId={listboxId}
|
||||
fieldGroups={fieldGroups}
|
||||
options={options}
|
||||
highlightedIndex={highlightedIndex}
|
||||
onHighlight={onHighlight}
|
||||
onSelect={onSelect}
|
||||
/>
|
||||
</div>
|
||||
</PopoverContent>
|
||||
)
|
||||
}
|
||||
|
||||
export function FilterFieldCombobox({ value, fields, onChange }: FilterFieldComboboxProps) {
|
||||
@@ -56,6 +203,7 @@ export function FilterFieldCombobox({ value, fields, onChange }: FilterFieldComb
|
||||
const [query, setQuery] = useState(value)
|
||||
const [hasTyped, setHasTyped] = useState(false)
|
||||
const [highlightedIndex, setHighlightedIndex] = useState(-1)
|
||||
const [contentWidth, setContentWidth] = useState<number>(220)
|
||||
const rootRef = useRef<HTMLDivElement>(null)
|
||||
const inputRef = useRef<HTMLInputElement>(null)
|
||||
const listboxId = useId()
|
||||
@@ -88,128 +236,79 @@ export function FilterFieldCombobox({ value, fields, onChange }: FilterFieldComb
|
||||
setOpen(false)
|
||||
}
|
||||
|
||||
useEffect(() => {
|
||||
if (!open) return
|
||||
|
||||
const updateWidth = () => {
|
||||
const nextWidth = rootRef.current?.getBoundingClientRect().width ?? 220
|
||||
setContentWidth(Math.max(nextWidth, 220))
|
||||
}
|
||||
|
||||
updateWidth()
|
||||
window.addEventListener('resize', updateWidth)
|
||||
return () => window.removeEventListener('resize', updateWidth)
|
||||
}, [open])
|
||||
|
||||
const handleBlur = (event: FocusEvent<HTMLDivElement>) => {
|
||||
if (rootRef.current?.contains(event.relatedTarget as Node | null)) return
|
||||
closeCombobox()
|
||||
}
|
||||
|
||||
const handleInputChange = (event: ChangeEvent<HTMLInputElement>) => {
|
||||
const nextQuery = event.target.value
|
||||
const nextGroups = buildFieldGroups(fields, value, nextQuery)
|
||||
const nextOptions = flattenGroups(nextGroups)
|
||||
setOpen(true)
|
||||
setQuery(nextQuery)
|
||||
setHasTyped(true)
|
||||
setHighlightedIndex(nextOptions.length > 0 ? 0 : -1)
|
||||
}
|
||||
|
||||
const handleInputKeyDown = (event: KeyboardEvent<HTMLInputElement>) => {
|
||||
handleFilterFieldKeyDown({
|
||||
event,
|
||||
open,
|
||||
options,
|
||||
highlightedIndex,
|
||||
openCombobox,
|
||||
setHighlightedIndex,
|
||||
selectOption,
|
||||
closeCombobox,
|
||||
})
|
||||
}
|
||||
|
||||
return (
|
||||
<div
|
||||
ref={rootRef}
|
||||
className="relative flex-1 min-w-[160px]"
|
||||
onBlur={(event) => {
|
||||
if (rootRef.current?.contains(event.relatedTarget as Node | null)) return
|
||||
closeCombobox()
|
||||
}}
|
||||
data-testid="filter-field-combobox"
|
||||
>
|
||||
<Input
|
||||
ref={inputRef}
|
||||
value={open ? query : value}
|
||||
onFocus={() => openCombobox()}
|
||||
onChange={(event) => {
|
||||
const nextQuery = event.target.value
|
||||
const nextGroups = buildFieldGroups(fields, value, nextQuery)
|
||||
const nextOptions = flattenGroups(nextGroups)
|
||||
setOpen(true)
|
||||
setQuery(nextQuery)
|
||||
setHasTyped(true)
|
||||
setHighlightedIndex(nextOptions.length > 0 ? 0 : -1)
|
||||
}}
|
||||
onKeyDown={(event) => {
|
||||
if (event.key === 'ArrowDown') {
|
||||
event.preventDefault()
|
||||
if (!open) {
|
||||
openCombobox()
|
||||
return
|
||||
}
|
||||
if (options.length === 0) return
|
||||
setHighlightedIndex((current) => {
|
||||
if (current < 0) return 0
|
||||
return (current + 1) % options.length
|
||||
})
|
||||
return
|
||||
}
|
||||
|
||||
if (event.key === 'ArrowUp') {
|
||||
event.preventDefault()
|
||||
if (!open) {
|
||||
openCombobox()
|
||||
return
|
||||
}
|
||||
if (options.length === 0) return
|
||||
setHighlightedIndex((current) => {
|
||||
if (current < 0) return options.length - 1
|
||||
return (current - 1 + options.length) % options.length
|
||||
})
|
||||
return
|
||||
}
|
||||
|
||||
if (event.key === 'Enter') {
|
||||
if (!open || highlightedIndex < 0 || options[highlightedIndex] === undefined) return
|
||||
event.preventDefault()
|
||||
selectOption(options[highlightedIndex])
|
||||
return
|
||||
}
|
||||
|
||||
if (event.key === 'Escape') {
|
||||
if (!open) return
|
||||
event.preventDefault()
|
||||
closeCombobox()
|
||||
}
|
||||
}}
|
||||
role="combobox"
|
||||
aria-autocomplete="list"
|
||||
aria-controls={listboxId}
|
||||
aria-expanded={open}
|
||||
aria-activedescendant={highlightedIndex >= 0 ? `${listboxId}-option-${highlightedIndex}` : undefined}
|
||||
className="h-8 pr-7 text-sm"
|
||||
data-testid="filter-field-combobox-input"
|
||||
/>
|
||||
<CaretUpDown
|
||||
size={14}
|
||||
className="pointer-events-none absolute right-2 top-1/2 -translate-y-1/2 text-muted-foreground"
|
||||
aria-hidden="true"
|
||||
/>
|
||||
{open && (
|
||||
<Popover open={open}>
|
||||
<PopoverAnchor asChild>
|
||||
<div
|
||||
id={listboxId}
|
||||
role="listbox"
|
||||
className="absolute left-0 top-full z-50 mt-1 max-h-60 w-full min-w-[220px] overflow-y-auto rounded-md border border-border bg-popover p-1 shadow-md"
|
||||
data-testid="filter-field-combobox-options"
|
||||
ref={rootRef}
|
||||
className="relative flex-1 min-w-[160px]"
|
||||
onBlur={handleBlur}
|
||||
data-testid="filter-field-combobox"
|
||||
>
|
||||
{options.length === 0 ? (
|
||||
<div className="px-2 py-6 text-center text-sm text-muted-foreground" data-testid="filter-field-combobox-empty">
|
||||
No results
|
||||
</div>
|
||||
) : (
|
||||
fieldGroups.map((group, groupIndex) => (
|
||||
<div key={group.key}>
|
||||
{groupIndex > 0 && <div className="my-1 border-t border-border" />}
|
||||
{group.options.map((field) => {
|
||||
const optionIndex = options.indexOf(field)
|
||||
return (
|
||||
<button
|
||||
key={field}
|
||||
id={`${listboxId}-option-${optionIndex}`}
|
||||
type="button"
|
||||
role="option"
|
||||
aria-selected={optionIndex === highlightedIndex}
|
||||
className={cn(
|
||||
'flex w-full items-center rounded px-2 py-1.5 text-left text-sm',
|
||||
optionIndex === highlightedIndex
|
||||
? 'bg-accent text-accent-foreground'
|
||||
: 'text-foreground hover:bg-accent hover:text-accent-foreground',
|
||||
)}
|
||||
onMouseDown={(event) => event.preventDefault()}
|
||||
onMouseEnter={() => setHighlightedIndex(optionIndex)}
|
||||
onClick={() => selectOption(field)}
|
||||
data-testid={optionTestId(field)}
|
||||
>
|
||||
<span className="truncate">{field}</span>
|
||||
</button>
|
||||
)
|
||||
})}
|
||||
</div>
|
||||
))
|
||||
)}
|
||||
<FilterFieldInput
|
||||
inputRef={inputRef}
|
||||
open={open}
|
||||
query={query}
|
||||
value={value}
|
||||
listboxId={listboxId}
|
||||
highlightedIndex={highlightedIndex}
|
||||
onFocus={openCombobox}
|
||||
onChange={handleInputChange}
|
||||
onKeyDown={handleInputKeyDown}
|
||||
/>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</PopoverAnchor>
|
||||
<FilterFieldPopoverPanel
|
||||
open={open}
|
||||
contentWidth={contentWidth}
|
||||
listboxId={listboxId}
|
||||
fieldGroups={fieldGroups}
|
||||
options={options}
|
||||
highlightedIndex={highlightedIndex}
|
||||
onHighlight={setHighlightedIndex}
|
||||
onSelect={selectOption}
|
||||
/>
|
||||
</Popover>
|
||||
)
|
||||
}
|
||||
|
||||
@@ -134,6 +134,46 @@ describe('NoteItem', () => {
|
||||
expect(onClickNote).toHaveBeenCalledWith(linkedProject, expect.objectContaining({ metaKey: true }))
|
||||
})
|
||||
|
||||
it('falls back to the built-in type icon for relationship chips when the Type has no custom icon', () => {
|
||||
const linkedTopic = makeEntry({
|
||||
path: '/vault/topic/ai.md',
|
||||
filename: 'ai.md',
|
||||
title: 'AI',
|
||||
isA: 'topic',
|
||||
})
|
||||
const topicType = makeEntry({
|
||||
path: '/vault/type/topic.md',
|
||||
filename: 'topic.md',
|
||||
title: 'Topic',
|
||||
isA: 'Type',
|
||||
color: 'green',
|
||||
icon: null,
|
||||
})
|
||||
const sourceEntry = makeEntry({
|
||||
path: '/vault/note/source.md',
|
||||
filename: 'source.md',
|
||||
title: 'Source',
|
||||
isA: 'Note',
|
||||
relationships: { Topics: ['[[topic/ai]]'] },
|
||||
})
|
||||
|
||||
render(
|
||||
<NoteItem
|
||||
entry={sourceEntry}
|
||||
isSelected={false}
|
||||
typeEntryMap={{ Topic: topicType, topic: topicType }}
|
||||
allEntries={[sourceEntry, linkedTopic, topicType]}
|
||||
displayPropsOverride={['Topics']}
|
||||
onClickNote={vi.fn()}
|
||||
/>,
|
||||
)
|
||||
|
||||
const chip = screen.getByTestId('property-chip-topics-0')
|
||||
expect(chip).toHaveTextContent('Ai')
|
||||
expect(chip).toHaveStyle({ color: 'var(--accent-green)', backgroundColor: 'var(--accent-green-light)' })
|
||||
expect(chip.querySelector('svg')).not.toBeNull()
|
||||
})
|
||||
|
||||
it('opens URL chips on Cmd+click only and keeps regular clicks inert', () => {
|
||||
const entry = makeEntry({
|
||||
path: '/vault/note/source.md',
|
||||
|
||||
@@ -10,18 +10,20 @@ describe('NoteTitleIcon', () => {
|
||||
})
|
||||
|
||||
it('renders a Phosphor icon when the name is recognized', () => {
|
||||
render(<NoteTitleIcon icon="cooking pot" testId="note-title-icon" />)
|
||||
const { container } = render(<NoteTitleIcon icon="cooking pot" testId="note-title-icon" className="mr-1" />)
|
||||
|
||||
const icon = screen.getByTestId('note-title-icon')
|
||||
expect(icon.tagName.toLowerCase()).toBe('svg')
|
||||
expect(container.querySelector('span.inline-flex.mr-1')).not.toBeNull()
|
||||
})
|
||||
|
||||
it('renders an image when the icon is an http url', () => {
|
||||
render(<NoteTitleIcon icon="https://example.com/favicon.png" testId="note-title-icon" />)
|
||||
const { container } = render(<NoteTitleIcon icon="https://example.com/favicon.png" testId="note-title-icon" className="mr-1" />)
|
||||
|
||||
const icon = screen.getByTestId('note-title-icon')
|
||||
expect(icon.tagName.toLowerCase()).toBe('img')
|
||||
expect(icon).toHaveAttribute('src', 'https://example.com/favicon.png')
|
||||
expect(container.querySelector('span.inline-flex.mr-1')).not.toBeNull()
|
||||
})
|
||||
|
||||
it('renders nothing for an unrecognized icon value', () => {
|
||||
|
||||
@@ -9,6 +9,21 @@ interface NoteTitleIconProps {
|
||||
testId?: string
|
||||
}
|
||||
|
||||
function IconWrapper({
|
||||
children,
|
||||
className,
|
||||
size,
|
||||
}: Pick<NoteTitleIconProps, 'className' | 'size'> & { children: React.ReactNode }) {
|
||||
return (
|
||||
<span
|
||||
className={cn('inline-flex shrink-0 items-center justify-center align-middle', className)}
|
||||
style={{ width: size, height: size, lineHeight: 1 }}
|
||||
>
|
||||
{children}
|
||||
</span>
|
||||
)
|
||||
}
|
||||
|
||||
export function NoteTitleIcon({ icon, size = 14, className, color, testId }: NoteTitleIconProps) {
|
||||
const resolved = resolveNoteIcon(icon)
|
||||
|
||||
@@ -16,39 +31,40 @@ export function NoteTitleIcon({ icon, size = 14, className, color, testId }: Not
|
||||
|
||||
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>
|
||||
<IconWrapper className={className} size={size}>
|
||||
<span style={{ fontSize: size, lineHeight: 1 }} data-testid={testId}>
|
||||
{resolved.value}
|
||||
</span>
|
||||
</IconWrapper>
|
||||
)
|
||||
}
|
||||
|
||||
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}
|
||||
/>
|
||||
<IconWrapper className={className} size={size}>
|
||||
<img
|
||||
src={resolved.src}
|
||||
alt=""
|
||||
aria-hidden="true"
|
||||
className="block h-full w-full rounded-sm object-cover"
|
||||
onError={(event) => {
|
||||
event.currentTarget.style.display = 'none'
|
||||
}}
|
||||
data-testid={testId}
|
||||
/>
|
||||
</IconWrapper>
|
||||
)
|
||||
}
|
||||
|
||||
return (
|
||||
<resolved.Icon
|
||||
width={size}
|
||||
height={size}
|
||||
className={cn('shrink-0', className)}
|
||||
style={color ? { color } : undefined}
|
||||
data-testid={testId}
|
||||
/>
|
||||
<IconWrapper className={className} size={size}>
|
||||
<resolved.Icon
|
||||
width={size}
|
||||
height={size}
|
||||
className="block"
|
||||
style={color ? { color } : undefined}
|
||||
data-testid={testId}
|
||||
/>
|
||||
</IconWrapper>
|
||||
)
|
||||
}
|
||||
|
||||
@@ -117,4 +117,18 @@ describe('EditorContentLayout', () => {
|
||||
expect(titleRow?.querySelector('[data-testid="note-icon-display"]')).not.toBeNull()
|
||||
expect(titleRow?.querySelector('[data-testid="title-field-input"]')).not.toBeNull()
|
||||
})
|
||||
|
||||
it('shows the loading skeleton instead of stale editor chrome while switching tabs', () => {
|
||||
const { container } = render(
|
||||
<EditorContentLayout
|
||||
{...createModel({
|
||||
activeTab: null,
|
||||
isLoadingNewTab: true,
|
||||
})}
|
||||
/>,
|
||||
)
|
||||
|
||||
expect(container.querySelector('.animate-pulse')).not.toBeNull()
|
||||
expect(screen.queryByTestId('title-field-input')).not.toBeInTheDocument()
|
||||
})
|
||||
})
|
||||
|
||||
@@ -302,7 +302,11 @@ export function EditorContentLayout(model: EditorContentModel) {
|
||||
} = model
|
||||
|
||||
if (!activeTab) {
|
||||
return <div className="flex flex-1 flex-col min-w-0 min-h-0" />
|
||||
return (
|
||||
<div className="flex flex-1 flex-col min-w-0 min-h-0">
|
||||
{isLoadingNewTab && showEditor && <EditorLoadingSkeleton />}
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
return (
|
||||
|
||||
@@ -62,6 +62,26 @@ describe('deriveEditorContentState', () => {
|
||||
content: '---\ntitle: Legacy Project\n---\nBody without a heading',
|
||||
})
|
||||
|
||||
expect(state.hasH1).toBe(false)
|
||||
expect(state.showTitleSection).toBe(false)
|
||||
})
|
||||
|
||||
it('hides the legacy title section when a frontmatter title drives the display title', () => {
|
||||
const state = deriveState({
|
||||
entry: baseEntry,
|
||||
content: '---\ntitle: Spring 2026\nstatus: Active\n---\n## Goals',
|
||||
})
|
||||
|
||||
expect(state.hasH1).toBe(false)
|
||||
expect(state.showTitleSection).toBe(false)
|
||||
})
|
||||
|
||||
it('keeps the title section when the document title still comes from the filename', () => {
|
||||
const state = deriveState({
|
||||
entry: baseEntry,
|
||||
content: '---\nstatus: Active\n---\nBody without a heading',
|
||||
})
|
||||
|
||||
expect(state.hasH1).toBe(false)
|
||||
expect(state.showTitleSection).toBe(true)
|
||||
})
|
||||
|
||||
@@ -1,5 +1,5 @@
|
||||
import type { NoteStatus, VaultEntry } from '../../types'
|
||||
import { extractH1TitleFromContent } from '../../utils/noteTitle'
|
||||
import { contentDefinesDisplayTitle, extractH1TitleFromContent } from '../../utils/noteTitle'
|
||||
import { countWords } from '../../utils/wikilinks'
|
||||
|
||||
export interface EditorContentTab {
|
||||
@@ -14,6 +14,19 @@ interface EditorContentStateInput {
|
||||
activeStatus: NoteStatus
|
||||
}
|
||||
|
||||
interface TitleSectionState {
|
||||
hasDisplayTitle: boolean
|
||||
hasH1: boolean
|
||||
}
|
||||
|
||||
interface VisibilityState {
|
||||
effectiveRawMode: boolean
|
||||
isDeletedPreview: boolean
|
||||
isNonMarkdownText: boolean
|
||||
showEditor: boolean
|
||||
showTitleSection: boolean
|
||||
}
|
||||
|
||||
export interface EditorContentState {
|
||||
freshEntry: VaultEntry | undefined
|
||||
isArchived: boolean
|
||||
@@ -36,10 +49,53 @@ function contentHasTopLevelH1(activeTab: EditorContentTab | null): boolean {
|
||||
return activeTab ? extractH1TitleFromContent(activeTab.content) !== null : false
|
||||
}
|
||||
|
||||
function contentDefinesTitle(activeTab: EditorContentTab | null): boolean {
|
||||
return activeTab ? contentDefinesDisplayTitle(activeTab.content) : false
|
||||
}
|
||||
|
||||
function resolveHasH1(activeTab: EditorContentTab | null, freshEntry: VaultEntry | undefined): boolean {
|
||||
return contentHasTopLevelH1(activeTab) || freshEntry?.hasH1 === true || activeTab?.entry.hasH1 === true
|
||||
}
|
||||
|
||||
function resolveHasDisplayTitle(activeTab: EditorContentTab | null, hasH1: boolean): boolean {
|
||||
return hasH1 || contentDefinesTitle(activeTab)
|
||||
}
|
||||
|
||||
function deriveTitleSectionState(activeTab: EditorContentTab | null, freshEntry: VaultEntry | undefined): TitleSectionState {
|
||||
const hasH1 = resolveHasH1(activeTab, freshEntry)
|
||||
return {
|
||||
hasDisplayTitle: resolveHasDisplayTitle(activeTab, hasH1),
|
||||
hasH1,
|
||||
}
|
||||
}
|
||||
|
||||
function deriveVisibilityState(input: {
|
||||
activeStatus: NoteStatus
|
||||
activeTab: EditorContentTab | null
|
||||
freshEntry: VaultEntry | undefined
|
||||
hasDisplayTitle: boolean
|
||||
rawMode: boolean
|
||||
}): VisibilityState {
|
||||
const {
|
||||
activeStatus,
|
||||
activeTab,
|
||||
freshEntry,
|
||||
hasDisplayTitle,
|
||||
rawMode,
|
||||
} = input
|
||||
const isDeletedPreview = !!activeTab && !freshEntry
|
||||
const isNonMarkdownText = activeTab?.entry.fileKind === 'text'
|
||||
const effectiveRawMode = rawMode || isNonMarkdownText
|
||||
|
||||
return {
|
||||
isDeletedPreview,
|
||||
isNonMarkdownText,
|
||||
effectiveRawMode,
|
||||
showEditor: !effectiveRawMode,
|
||||
showTitleSection: !isDeletedPreview && !hasDisplayTitle && !isUnsavedUntitledDraft(activeTab, activeStatus),
|
||||
}
|
||||
}
|
||||
|
||||
function isUnsavedUntitledDraft(activeTab: EditorContentTab | null, activeStatus: NoteStatus): boolean {
|
||||
if (!activeTab) return false
|
||||
if (!activeTab.entry.filename.startsWith('untitled-')) return false
|
||||
@@ -53,22 +109,20 @@ export function deriveEditorContentState({
|
||||
activeStatus,
|
||||
}: EditorContentStateInput): EditorContentState {
|
||||
const freshEntry = findFreshEntry(activeTab, entries)
|
||||
const isDeletedPreview = !!activeTab && !freshEntry
|
||||
const hasH1 = resolveHasH1(activeTab, freshEntry)
|
||||
const isNonMarkdownText = activeTab?.entry.fileKind === 'text'
|
||||
const effectiveRawMode = rawMode || isNonMarkdownText
|
||||
const showEditor = !effectiveRawMode
|
||||
const showTitleSection = !isDeletedPreview && !hasH1 && !isUnsavedUntitledDraft(activeTab, activeStatus)
|
||||
const titleState = deriveTitleSectionState(activeTab, freshEntry)
|
||||
const visibilityState = deriveVisibilityState({
|
||||
activeStatus,
|
||||
activeTab,
|
||||
freshEntry,
|
||||
hasDisplayTitle: titleState.hasDisplayTitle,
|
||||
rawMode,
|
||||
})
|
||||
|
||||
return {
|
||||
freshEntry,
|
||||
isArchived: freshEntry?.archived ?? activeTab?.entry.archived ?? false,
|
||||
hasH1,
|
||||
isDeletedPreview,
|
||||
isNonMarkdownText,
|
||||
effectiveRawMode,
|
||||
showEditor,
|
||||
showTitleSection,
|
||||
hasH1: titleState.hasH1,
|
||||
...visibilityState,
|
||||
path: activeTab?.entry.path ?? '',
|
||||
wordCount: activeTab ? countWords(activeTab.content) : 0,
|
||||
}
|
||||
|
||||
59
src/components/filter-builder/DateValueInput.test.tsx
Normal file
59
src/components/filter-builder/DateValueInput.test.tsx
Normal file
@@ -0,0 +1,59 @@
|
||||
import { useState } from 'react'
|
||||
import { afterEach, describe, expect, it, vi } from 'vitest'
|
||||
import { act, fireEvent, render, screen } from '@testing-library/react'
|
||||
import { DateValueInput } from './DateValueInput'
|
||||
|
||||
describe('DateValueInput', () => {
|
||||
afterEach(() => {
|
||||
vi.useRealTimers()
|
||||
})
|
||||
|
||||
function renderControlledDateValueInput(initialValue = '') {
|
||||
function ControlledDateValueInput() {
|
||||
const [value, setValue] = useState(initialValue)
|
||||
return <DateValueInput value={value} onChange={setValue} />
|
||||
}
|
||||
|
||||
return render(<ControlledDateValueInput />)
|
||||
}
|
||||
|
||||
it('shows a debounced resolved-date preview while focused', async () => {
|
||||
vi.useFakeTimers()
|
||||
vi.setSystemTime(new Date('2026-04-08T12:00:00Z'))
|
||||
|
||||
renderControlledDateValueInput()
|
||||
|
||||
const input = screen.getByTestId('date-value-input')
|
||||
fireEvent.focus(input)
|
||||
fireEvent.change(input, { target: { value: '10 days ago' } })
|
||||
|
||||
expect(screen.queryByTestId('date-value-preview')).not.toBeInTheDocument()
|
||||
|
||||
await act(async () => {
|
||||
vi.advanceTimersByTime(250)
|
||||
})
|
||||
|
||||
expect(screen.getByTestId('date-value-preview')).toHaveTextContent('Resolves to March 29, 2026')
|
||||
})
|
||||
|
||||
it('shows a neutral hint for unrecognized input and hides the preview on blur', async () => {
|
||||
vi.useFakeTimers()
|
||||
|
||||
renderControlledDateValueInput()
|
||||
|
||||
const input = screen.getByTestId('date-value-input')
|
||||
fireEvent.focus(input)
|
||||
fireEvent.change(input, { target: { value: 'eventually maybe' } })
|
||||
|
||||
await act(async () => {
|
||||
vi.advanceTimersByTime(250)
|
||||
})
|
||||
|
||||
expect(screen.getByTestId('date-value-preview-unrecognized')).toHaveTextContent('Not recognized')
|
||||
|
||||
fireEvent.blur(input)
|
||||
|
||||
expect(screen.queryByTestId('date-value-preview')).not.toBeInTheDocument()
|
||||
expect(screen.queryByTestId('date-value-preview-unrecognized')).not.toBeInTheDocument()
|
||||
})
|
||||
})
|
||||
78
src/components/filter-builder/DateValueInput.tsx
Normal file
78
src/components/filter-builder/DateValueInput.tsx
Normal file
@@ -0,0 +1,78 @@
|
||||
import { useEffect, useState } from 'react'
|
||||
import { CalendarBlank } from '@phosphor-icons/react'
|
||||
import { format } from 'date-fns'
|
||||
import { Button } from '@/components/ui/button'
|
||||
import { Input } from '@/components/ui/input'
|
||||
import { Calendar } from '@/components/ui/calendar'
|
||||
import { Popover, PopoverContent, PopoverTrigger } from '@/components/ui/popover'
|
||||
import { parseDateFilterInput } from '@/utils/filterDates'
|
||||
|
||||
const DATE_PREVIEW_DEBOUNCE_MS = 250
|
||||
|
||||
export function DateValueInput({ value, onChange }: { value: string; onChange: (v: string) => void }) {
|
||||
const selected = value ? parseDateFilterInput(value) ?? undefined : undefined
|
||||
const [showPreview, setShowPreview] = useState(false)
|
||||
const [debouncedValue, setDebouncedValue] = useState(value)
|
||||
|
||||
useEffect(() => {
|
||||
const timeoutId = window.setTimeout(() => setDebouncedValue(value), DATE_PREVIEW_DEBOUNCE_MS)
|
||||
return () => window.clearTimeout(timeoutId)
|
||||
}, [value])
|
||||
|
||||
const previewValue = showPreview ? debouncedValue.trim() : ''
|
||||
const resolvedPreview = previewValue ? parseDateFilterInput(previewValue) : null
|
||||
const previewLabel = resolvedPreview
|
||||
? format(resolvedPreview, 'MMMM d, yyyy')
|
||||
: previewValue
|
||||
? 'Not recognized'
|
||||
: null
|
||||
|
||||
return (
|
||||
<div className="flex flex-1 min-w-0 flex-col gap-1">
|
||||
<div className="flex min-w-0 items-center gap-1">
|
||||
<Input
|
||||
className="h-8 flex-1 min-w-0 text-sm"
|
||||
placeholder='YYYY-MM-DD or "10 days ago"'
|
||||
value={value}
|
||||
onChange={(e) => {
|
||||
setShowPreview(true)
|
||||
onChange(e.target.value)
|
||||
}}
|
||||
onFocus={() => setShowPreview(true)}
|
||||
onBlur={() => setShowPreview(false)}
|
||||
data-testid="date-value-input"
|
||||
/>
|
||||
<Popover>
|
||||
<PopoverTrigger asChild>
|
||||
<Button
|
||||
type="button"
|
||||
variant="outline"
|
||||
size="sm"
|
||||
data-testid="date-picker-trigger"
|
||||
className="h-8 w-8 shrink-0 px-0"
|
||||
title={selected ? format(selected, 'MMM d, yyyy') : 'Pick a date'}
|
||||
aria-label={selected ? `Open date picker (${format(selected, 'MMM d, yyyy')})` : 'Open date picker'}
|
||||
>
|
||||
<CalendarBlank size={14} className="shrink-0 text-muted-foreground" />
|
||||
</Button>
|
||||
</PopoverTrigger>
|
||||
<PopoverContent className="w-auto p-0" align="start">
|
||||
<Calendar
|
||||
mode="single"
|
||||
selected={selected}
|
||||
onSelect={(day) => onChange(day ? format(day, 'yyyy-MM-dd') : '')}
|
||||
/>
|
||||
</PopoverContent>
|
||||
</Popover>
|
||||
</div>
|
||||
{previewLabel && (
|
||||
<div
|
||||
className="pl-1 text-[11px] text-muted-foreground"
|
||||
data-testid={resolvedPreview ? 'date-value-preview' : 'date-value-preview-unrecognized'}
|
||||
>
|
||||
{resolvedPreview ? `Resolves to ${previewLabel}` : previewLabel}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
)
|
||||
}
|
||||
22
src/components/filter-builder/FilterFieldCombobox.test.tsx
Normal file
22
src/components/filter-builder/FilterFieldCombobox.test.tsx
Normal file
@@ -0,0 +1,22 @@
|
||||
import { describe, expect, it, vi } from 'vitest'
|
||||
import { fireEvent, render, screen } from '@testing-library/react'
|
||||
import { FilterFieldCombobox } from '../FilterFieldCombobox'
|
||||
|
||||
describe('FilterFieldCombobox', () => {
|
||||
it('renders its option list outside the clipped field container', () => {
|
||||
render(
|
||||
<div className="h-12 overflow-hidden">
|
||||
<FilterFieldCombobox value="status" fields={['status', 'title', 'Owner']} onChange={vi.fn()} />
|
||||
</div>,
|
||||
)
|
||||
|
||||
const root = screen.getByTestId('filter-field-combobox')
|
||||
const input = screen.getByTestId('filter-field-combobox-input')
|
||||
|
||||
fireEvent.focus(input)
|
||||
|
||||
const listbox = screen.getByRole('listbox')
|
||||
expect(listbox).toBeInTheDocument()
|
||||
expect(root.contains(listbox)).toBe(false)
|
||||
})
|
||||
})
|
||||
69
src/components/filter-builder/FilterFieldOptionsList.tsx
Normal file
69
src/components/filter-builder/FilterFieldOptionsList.tsx
Normal file
@@ -0,0 +1,69 @@
|
||||
import { cn } from '@/lib/utils'
|
||||
|
||||
interface FieldGroup {
|
||||
key: 'property' | 'content'
|
||||
label: string
|
||||
options: string[]
|
||||
}
|
||||
|
||||
function optionTestId(field: string): string {
|
||||
return `filter-field-option-${field.replace(/[^a-z0-9_-]+/gi, '-')}`
|
||||
}
|
||||
|
||||
export function FilterFieldOptionsList({
|
||||
listboxId,
|
||||
fieldGroups,
|
||||
options,
|
||||
highlightedIndex,
|
||||
onHighlight,
|
||||
onSelect,
|
||||
}: {
|
||||
listboxId: string
|
||||
fieldGroups: FieldGroup[]
|
||||
options: string[]
|
||||
highlightedIndex: number
|
||||
onHighlight: (index: number) => void
|
||||
onSelect: (field: string) => void
|
||||
}) {
|
||||
if (options.length === 0) {
|
||||
return (
|
||||
<div className="px-2 py-6 text-center text-sm text-muted-foreground" data-testid="filter-field-combobox-empty">
|
||||
No results
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
return (
|
||||
<>
|
||||
{fieldGroups.map((group, groupIndex) => (
|
||||
<div key={group.key}>
|
||||
{groupIndex > 0 && <div className="my-1 border-t border-border" />}
|
||||
{group.options.map((field) => {
|
||||
const optionIndex = options.indexOf(field)
|
||||
return (
|
||||
<button
|
||||
key={field}
|
||||
id={`${listboxId}-option-${optionIndex}`}
|
||||
type="button"
|
||||
role="option"
|
||||
aria-selected={optionIndex === highlightedIndex}
|
||||
className={cn(
|
||||
'flex w-full items-center rounded px-2 py-1.5 text-left text-sm',
|
||||
optionIndex === highlightedIndex
|
||||
? 'bg-accent text-accent-foreground'
|
||||
: 'text-foreground hover:bg-accent hover:text-accent-foreground',
|
||||
)}
|
||||
onMouseDown={(event) => event.preventDefault()}
|
||||
onMouseEnter={() => onHighlight(optionIndex)}
|
||||
onClick={() => onSelect(field)}
|
||||
data-testid={optionTestId(field)}
|
||||
>
|
||||
<span className="truncate">{field}</span>
|
||||
</button>
|
||||
)
|
||||
})}
|
||||
</div>
|
||||
))}
|
||||
</>
|
||||
)
|
||||
}
|
||||
@@ -1,17 +1,17 @@
|
||||
import { createElement, useMemo, useState, type CSSProperties, type MouseEvent } from 'react'
|
||||
import { createElement, useMemo, useState, type ComponentType, type CSSProperties, type MouseEvent, type ReactNode, type SVGAttributes } from 'react'
|
||||
import { Link } from '@phosphor-icons/react'
|
||||
import { cn } from '@/lib/utils'
|
||||
import type { VaultEntry } from '../../types'
|
||||
import { findIcon } from '../../utils/iconRegistry'
|
||||
import { resolveNoteIcon } from '../../utils/noteIcon'
|
||||
import { getTypeColor, getTypeLightColor } from '../../utils/typeColors'
|
||||
import { isUrlValue, normalizeUrl, openExternalUrl } from '../../utils/url'
|
||||
import { resolveEntry, wikilinkDisplay, wikilinkTarget } from '../../utils/wikilink'
|
||||
import { getTypeIcon } from './typeIcon'
|
||||
|
||||
interface PropertyChipValue {
|
||||
label: string
|
||||
noteIcon: string | null
|
||||
typeIcon: string | null
|
||||
typeIcon: ComponentType<SVGAttributes<SVGSVGElement>> | null
|
||||
style?: CSSProperties
|
||||
action?: { kind: 'note'; entry: VaultEntry } | { kind: 'url'; url: string }
|
||||
tone: 'neutral' | 'relationship' | 'url'
|
||||
@@ -46,8 +46,16 @@ function formatChipLabel(value: unknown): string | null {
|
||||
return raw.length > 40 ? `${raw.slice(0, 37)}…` : raw
|
||||
}
|
||||
|
||||
function resolveTargetTypeEntry(targetEntry: VaultEntry, typeEntryMap: Record<string, VaultEntry>): VaultEntry | undefined {
|
||||
return targetEntry.isA ? (typeEntryMap[targetEntry.isA] ?? typeEntryMap[targetEntry.isA.toLowerCase()]) : undefined
|
||||
}
|
||||
|
||||
function findMatchingKey(values: Record<string, unknown>, propName: string): string | undefined {
|
||||
return Object.keys(values).find((key) => key.toLowerCase() === propName.toLowerCase())
|
||||
}
|
||||
|
||||
function resolveRelationshipChipStyle(targetEntry: VaultEntry, typeEntryMap: Record<string, VaultEntry>): CSSProperties | undefined {
|
||||
const typeEntry = targetEntry.isA ? (typeEntryMap[targetEntry.isA] ?? typeEntryMap[targetEntry.isA.toLowerCase()]) : undefined
|
||||
const typeEntry = resolveTargetTypeEntry(targetEntry, typeEntryMap)
|
||||
const color = getTypeColor(targetEntry.isA, typeEntry?.color)
|
||||
const backgroundColor = getTypeLightColor(targetEntry.isA, typeEntry?.color)
|
||||
if (color === 'var(--muted-foreground)' && backgroundColor === 'var(--muted)') return undefined
|
||||
@@ -72,11 +80,11 @@ function resolveRelationshipChip(
|
||||
}
|
||||
}
|
||||
|
||||
const typeEntry = targetEntry.isA ? (typeEntryMap[targetEntry.isA] ?? typeEntryMap[targetEntry.isA.toLowerCase()]) : undefined
|
||||
const typeEntry = resolveTargetTypeEntry(targetEntry, typeEntryMap)
|
||||
return {
|
||||
label,
|
||||
noteIcon: targetEntry.icon ?? null,
|
||||
typeIcon: targetEntry.isA ? typeEntry?.icon ?? null : null,
|
||||
typeIcon: targetEntry.isA ? getTypeIcon(targetEntry.isA, typeEntry?.icon) : null,
|
||||
style: resolveRelationshipChipStyle(targetEntry, typeEntryMap),
|
||||
action: { kind: 'note', entry: targetEntry },
|
||||
tone: 'relationship',
|
||||
@@ -107,6 +115,30 @@ function resolveScalarChip(value: unknown): PropertyChipValue | null {
|
||||
}
|
||||
}
|
||||
|
||||
function resolveRelationshipChipValues(
|
||||
entry: VaultEntry,
|
||||
propName: string,
|
||||
allEntries: VaultEntry[],
|
||||
typeEntryMap: Record<string, VaultEntry>,
|
||||
): PropertyChipValue[] | null {
|
||||
const relationshipKey = findMatchingKey(entry.relationships, propName)
|
||||
if (!relationshipKey) return null
|
||||
return entry.relationships[relationshipKey]
|
||||
.map((ref) => resolveRelationshipChip(ref, allEntries, typeEntryMap))
|
||||
.filter((chip): chip is PropertyChipValue => chip !== null)
|
||||
}
|
||||
|
||||
function resolveScalarChipValues(entry: VaultEntry, propName: string): PropertyChipValue[] {
|
||||
const propertyKey = findMatchingKey(entry.properties, propName)
|
||||
if (!propertyKey) return []
|
||||
|
||||
const rawValue = entry.properties[propertyKey]
|
||||
const values = Array.isArray(rawValue) ? rawValue : [rawValue]
|
||||
return values
|
||||
.map((value) => resolveScalarChip(value))
|
||||
.filter((chip): chip is PropertyChipValue => chip !== null)
|
||||
}
|
||||
|
||||
function resolvePropertyChipValues(
|
||||
entry: VaultEntry,
|
||||
propName: string,
|
||||
@@ -118,40 +150,24 @@ function resolvePropertyChipValues(
|
||||
return statusChip ? [statusChip] : []
|
||||
}
|
||||
|
||||
const relationshipKey = Object.keys(entry.relationships).find((key) => key.toLowerCase() === propName.toLowerCase())
|
||||
if (relationshipKey) {
|
||||
return entry.relationships[relationshipKey]
|
||||
.map((ref) => resolveRelationshipChip(ref, allEntries, typeEntryMap))
|
||||
.filter((chip): chip is PropertyChipValue => chip !== null)
|
||||
}
|
||||
|
||||
const propertyKey = Object.keys(entry.properties).find((key) => key.toLowerCase() === propName.toLowerCase())
|
||||
if (!propertyKey) return []
|
||||
|
||||
const rawValue = entry.properties[propertyKey]
|
||||
const values = Array.isArray(rawValue) ? rawValue : [rawValue]
|
||||
return values
|
||||
.map((value) => resolveScalarChip(value))
|
||||
.filter((chip): chip is PropertyChipValue => chip !== null)
|
||||
return resolveRelationshipChipValues(entry, propName, allEntries, typeEntryMap) ?? resolveScalarChipValues(entry, propName)
|
||||
}
|
||||
|
||||
function PropertyChipIcon({
|
||||
noteIcon,
|
||||
function RelationshipTypeIcon({
|
||||
typeIcon,
|
||||
tone,
|
||||
}: {
|
||||
noteIcon?: string | null
|
||||
typeIcon?: string | null
|
||||
tone: PropertyChipValue['tone']
|
||||
typeIcon?: ComponentType<SVGAttributes<SVGSVGElement>> | null
|
||||
}) {
|
||||
const [imageFailed, setImageFailed] = useState(false)
|
||||
|
||||
if (tone === 'url') {
|
||||
return <Link aria-hidden="true" width={11} height={11} className="shrink-0" />
|
||||
}
|
||||
if (!typeIcon) return null
|
||||
return createElement(typeIcon, { 'aria-hidden': true, width: 11, height: 11, className: 'shrink-0' })
|
||||
}
|
||||
|
||||
function renderResolvedNoteIcon(
|
||||
noteIcon: string | null | undefined,
|
||||
imageFailed: boolean,
|
||||
onImageError: () => void,
|
||||
): ReactNode {
|
||||
const resolvedNoteIcon = resolveNoteIcon(noteIcon)
|
||||
const TypeIcon = findIcon(typeIcon)
|
||||
|
||||
if (resolvedNoteIcon.kind === 'emoji') {
|
||||
return (
|
||||
@@ -165,20 +181,37 @@ function PropertyChipIcon({
|
||||
return <resolvedNoteIcon.Icon aria-hidden="true" width={11} height={11} className="shrink-0" />
|
||||
}
|
||||
|
||||
if (resolvedNoteIcon.kind === 'image' && !imageFailed) {
|
||||
return (
|
||||
<img
|
||||
src={resolvedNoteIcon.src}
|
||||
alt=""
|
||||
aria-hidden="true"
|
||||
className="h-[11px] w-[11px] shrink-0 rounded-sm object-cover"
|
||||
onError={() => setImageFailed(true)}
|
||||
/>
|
||||
)
|
||||
if (resolvedNoteIcon.kind !== 'image' || imageFailed) return null
|
||||
|
||||
return (
|
||||
<img
|
||||
src={resolvedNoteIcon.src}
|
||||
alt=""
|
||||
aria-hidden="true"
|
||||
className="h-[11px] w-[11px] shrink-0 rounded-sm object-cover"
|
||||
onError={onImageError}
|
||||
/>
|
||||
)
|
||||
}
|
||||
|
||||
function PropertyChipIcon({
|
||||
noteIcon,
|
||||
typeIcon,
|
||||
tone,
|
||||
}: {
|
||||
noteIcon?: string | null
|
||||
typeIcon?: ComponentType<SVGAttributes<SVGSVGElement>> | null
|
||||
tone: PropertyChipValue['tone']
|
||||
}) {
|
||||
const [imageFailed, setImageFailed] = useState(false)
|
||||
|
||||
if (tone === 'url') {
|
||||
return <Link aria-hidden="true" width={11} height={11} className="shrink-0" />
|
||||
}
|
||||
|
||||
if (!TypeIcon) return null
|
||||
return createElement(TypeIcon, { 'aria-hidden': true, width: 11, height: 11, className: 'shrink-0' })
|
||||
const noteIconElement = renderResolvedNoteIcon(noteIcon, imageFailed, () => setImageFailed(true))
|
||||
if (noteIconElement) return noteIconElement
|
||||
return <RelationshipTypeIcon typeIcon={typeIcon} />
|
||||
}
|
||||
|
||||
async function handleChipClick(
|
||||
|
||||
37
src/components/note-item/typeIcon.ts
Normal file
37
src/components/note-item/typeIcon.ts
Normal file
@@ -0,0 +1,37 @@
|
||||
import type { ComponentType, SVGAttributes } from 'react'
|
||||
import {
|
||||
ArrowsClockwise,
|
||||
CalendarBlank,
|
||||
FileText,
|
||||
Flask,
|
||||
StackSimple,
|
||||
Tag,
|
||||
Target,
|
||||
Users,
|
||||
Wrench,
|
||||
} from '@phosphor-icons/react'
|
||||
import { resolveIcon } from '../../utils/iconRegistry'
|
||||
|
||||
const TYPE_ICON_MAP: Record<string, ComponentType<SVGAttributes<SVGSVGElement>>> = {
|
||||
Project: Wrench,
|
||||
project: Wrench,
|
||||
Experiment: Flask,
|
||||
experiment: Flask,
|
||||
Responsibility: Target,
|
||||
responsibility: Target,
|
||||
Procedure: ArrowsClockwise,
|
||||
procedure: ArrowsClockwise,
|
||||
Person: Users,
|
||||
person: Users,
|
||||
Event: CalendarBlank,
|
||||
event: CalendarBlank,
|
||||
Topic: Tag,
|
||||
topic: Tag,
|
||||
Type: StackSimple,
|
||||
type: StackSimple,
|
||||
}
|
||||
|
||||
export function getTypeIcon(isA: string | null, customIcon?: string | null): ComponentType<SVGAttributes<SVGSVGElement>> {
|
||||
if (customIcon) return resolveIcon(customIcon)
|
||||
return (isA && TYPE_ICON_MAP[isA]) || FileText
|
||||
}
|
||||
@@ -36,11 +36,11 @@ export function useEditorSaveWithLinks(config: {
|
||||
const filename = path.split('/').pop() ?? path
|
||||
const fmPatch = {
|
||||
...frontmatterPatch,
|
||||
...deriveDisplayTitleState(
|
||||
...deriveDisplayTitleState({
|
||||
content,
|
||||
filename,
|
||||
typeof frontmatterPatch.title === 'string' ? frontmatterPatch.title : null,
|
||||
),
|
||||
frontmatterTitle: typeof frontmatterPatch.title === 'string' ? frontmatterPatch.title : null,
|
||||
}),
|
||||
}
|
||||
const fmKey = JSON.stringify(fmPatch)
|
||||
if (fmKey !== prevFmKeyRef.current) {
|
||||
|
||||
@@ -185,6 +185,41 @@ function makeMockEditor(docRef: { current: unknown[] }) {
|
||||
describe('useEditorTabSwap raw mode sync', () => {
|
||||
afterEach(() => { vi.restoreAllMocks() })
|
||||
|
||||
it('swaps in the new note when the path updates before tabs catch up', async () => {
|
||||
vi.spyOn(document, 'querySelector').mockReturnValue({ scrollTop: 0 } as unknown as Element)
|
||||
vi.spyOn(window, 'requestAnimationFrame').mockImplementation((cb) => { cb(0); return 0 })
|
||||
|
||||
const docRef = { current: blocksA as unknown[] }
|
||||
const mockEditor = makeMockEditor(docRef)
|
||||
Object.defineProperty(mockEditor, 'document', { get: () => docRef.current })
|
||||
|
||||
const tabA = makeTab('a.md', 'Note A')
|
||||
const tabB = makeTab('b.md', 'March 2024')
|
||||
|
||||
const { rerender } = renderHook(
|
||||
({ tabs, activeTabPath, rawMode }) => useEditorTabSwap({
|
||||
tabs, activeTabPath, editor: mockEditor as never, rawMode,
|
||||
}),
|
||||
{ initialProps: { tabs: [tabA], activeTabPath: 'a.md', rawMode: false as boolean } },
|
||||
)
|
||||
|
||||
await act(() => new Promise(r => setTimeout(r, 0)))
|
||||
mockEditor.tryParseMarkdownToBlocks.mockClear()
|
||||
mockEditor.replaceBlocks.mockClear()
|
||||
|
||||
rerender({ tabs: [tabA], activeTabPath: 'b.md', rawMode: false })
|
||||
await act(() => new Promise(r => setTimeout(r, 0)))
|
||||
expect(mockEditor.tryParseMarkdownToBlocks).not.toHaveBeenCalled()
|
||||
|
||||
rerender({ tabs: [tabB], activeTabPath: 'b.md', rawMode: false })
|
||||
await act(() => new Promise(r => setTimeout(r, 0)))
|
||||
|
||||
expect(mockEditor.tryParseMarkdownToBlocks).toHaveBeenCalledWith(
|
||||
expect.stringContaining('March 2024'),
|
||||
)
|
||||
expect(mockEditor.replaceBlocks).toHaveBeenCalled()
|
||||
})
|
||||
|
||||
it('re-parses from tab.content when rawMode transitions from true to false', async () => {
|
||||
vi.spyOn(document, 'querySelector').mockReturnValue({ scrollTop: 0 } as unknown as Element)
|
||||
vi.spyOn(window, 'requestAnimationFrame').mockImplementation((cb) => { cb(0); return 0 })
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
import { useCallback, useEffect, useRef } from 'react'
|
||||
import { useCallback, useEffect, useRef, type MutableRefObject } from 'react'
|
||||
import type { useCreateBlockNote } from '@blocknote/react'
|
||||
import type { VaultEntry } from '../types'
|
||||
import { splitFrontmatter, preProcessWikilinks, injectWikilinks, restoreWikilinksInBlocks } from '../utils/wikilinks'
|
||||
@@ -9,6 +9,10 @@ interface Tab {
|
||||
content: string
|
||||
}
|
||||
|
||||
// eslint-disable-next-line @typescript-eslint/no-explicit-any -- BlockNote block arrays
|
||||
type EditorBlocks = any[]
|
||||
type CachedTabState = { blocks: EditorBlocks; scrollTop: number }
|
||||
|
||||
interface UseEditorTabSwapOptions {
|
||||
tabs: Tab[]
|
||||
activeTabPath: string | null
|
||||
@@ -27,15 +31,17 @@ export function extractEditorBody(rawFileContent: string): string {
|
||||
|
||||
/** Extract H1 text from the editor's first block, or null if not an H1. */
|
||||
export function getH1TextFromBlocks(blocks: unknown[]): string | null {
|
||||
if (!blocks?.length) return null
|
||||
const first = blocks[0] as {
|
||||
const first = blocks?.[0] as {
|
||||
type?: string
|
||||
props?: { level?: number }
|
||||
content?: Array<{ type?: string; text?: string }>
|
||||
}
|
||||
if (first.type !== 'heading' || first.props?.level !== 1) return null
|
||||
if (!Array.isArray(first.content)) return null
|
||||
const text = first.content
|
||||
} | undefined
|
||||
const content = first?.type === 'heading' && first.props?.level === 1 && Array.isArray(first.content)
|
||||
? first.content
|
||||
: null
|
||||
if (!content) return null
|
||||
|
||||
const text = content
|
||||
.filter(item => item.type === 'text')
|
||||
.map(item => item.text || '')
|
||||
.join('')
|
||||
@@ -47,6 +53,395 @@ export function replaceTitleInFrontmatter(frontmatter: string, newTitle: string)
|
||||
return frontmatter.replace(/^(title:\s*).+$/m, `$1${newTitle}`)
|
||||
}
|
||||
|
||||
function readEditorScrollTop(): number {
|
||||
const scrollEl = document.querySelector('.editor__blocknote-container')
|
||||
return scrollEl?.scrollTop ?? 0
|
||||
}
|
||||
|
||||
function cacheEditorState(
|
||||
cache: Map<string, CachedTabState>,
|
||||
path: string,
|
||||
blocks: EditorBlocks,
|
||||
) {
|
||||
cache.set(path, {
|
||||
blocks,
|
||||
scrollTop: readEditorScrollTop(),
|
||||
})
|
||||
}
|
||||
|
||||
function buildFastPathBlocks(preprocessed: string): EditorBlocks | null {
|
||||
if (!preprocessed.trim()) {
|
||||
return [{ type: 'paragraph', content: [] }]
|
||||
}
|
||||
|
||||
const h1OnlyMatch = preprocessed.trim().match(/^# (.+)$/)
|
||||
if (!h1OnlyMatch) return null
|
||||
|
||||
return [
|
||||
{ type: 'heading', props: { level: 1, textColor: 'default', backgroundColor: 'default', textAlignment: 'left' }, content: [{ type: 'text', text: h1OnlyMatch[1], styles: {} }], children: [] },
|
||||
{ type: 'paragraph', content: [], children: [] },
|
||||
]
|
||||
}
|
||||
|
||||
async function parseMarkdownBlocks(
|
||||
editor: ReturnType<typeof useCreateBlockNote>,
|
||||
preprocessed: string,
|
||||
): Promise<EditorBlocks> {
|
||||
const result = editor.tryParseMarkdownToBlocks(preprocessed)
|
||||
// eslint-disable-next-line @typescript-eslint/no-explicit-any -- tryParseMarkdownToBlocks returns sync or async BlockNote blocks
|
||||
if (result && typeof (result as any).then === 'function') {
|
||||
return (result as unknown as Promise<EditorBlocks>)
|
||||
}
|
||||
return result as EditorBlocks
|
||||
}
|
||||
|
||||
async function resolveBlocksForTarget(
|
||||
editor: ReturnType<typeof useCreateBlockNote>,
|
||||
cache: Map<string, CachedTabState>,
|
||||
targetPath: string,
|
||||
content: string,
|
||||
): Promise<CachedTabState> {
|
||||
const cached = cache.get(targetPath)
|
||||
if (cached) return cached
|
||||
|
||||
const body = extractEditorBody(content)
|
||||
const preprocessed = preProcessWikilinks(body)
|
||||
const fastPathBlocks = buildFastPathBlocks(preprocessed)
|
||||
if (fastPathBlocks) {
|
||||
const nextState = { blocks: fastPathBlocks, scrollTop: 0 }
|
||||
cache.set(targetPath, nextState)
|
||||
return nextState
|
||||
}
|
||||
|
||||
const parsed = await parseMarkdownBlocks(editor, preprocessed)
|
||||
const withWikilinks = injectWikilinks(parsed)
|
||||
if (withWikilinks.length > 0) {
|
||||
cache.set(targetPath, { blocks: withWikilinks, scrollTop: 0 })
|
||||
}
|
||||
return { blocks: withWikilinks, scrollTop: 0 }
|
||||
}
|
||||
|
||||
function applyBlocksToEditor(
|
||||
editor: ReturnType<typeof useCreateBlockNote>,
|
||||
blocks: EditorBlocks,
|
||||
scrollTop: number,
|
||||
suppressChangeRef: MutableRefObject<boolean>,
|
||||
) {
|
||||
suppressChangeRef.current = true
|
||||
try {
|
||||
const current = editor.document
|
||||
if (current.length > 0 && blocks.length > 0) {
|
||||
editor.replaceBlocks(current, blocks)
|
||||
} else if (blocks.length > 0) {
|
||||
editor.insertBlocks(blocks, current[0], 'before')
|
||||
}
|
||||
} catch (err) {
|
||||
console.error('applyBlocks failed, trying fallback:', err)
|
||||
try {
|
||||
const html = editor.blocksToHTMLLossy(blocks)
|
||||
editor._tiptapEditor.commands.setContent(html)
|
||||
} catch (err2) {
|
||||
console.error('Fallback also failed:', err2)
|
||||
}
|
||||
} finally {
|
||||
queueMicrotask(() => { suppressChangeRef.current = false })
|
||||
}
|
||||
|
||||
requestAnimationFrame(() => {
|
||||
const scrollEl = document.querySelector('.editor__blocknote-container')
|
||||
if (scrollEl) scrollEl.scrollTop = scrollTop
|
||||
})
|
||||
}
|
||||
|
||||
function findActiveTab(tabs: Tab[], activeTabPath: string | null): Tab | undefined {
|
||||
return activeTabPath
|
||||
? tabs.find(tab => tab.entry.path === activeTabPath)
|
||||
: undefined
|
||||
}
|
||||
|
||||
function useLatestRef<T>(value: T): MutableRefObject<T> {
|
||||
const ref = useRef(value)
|
||||
useEffect(() => {
|
||||
ref.current = value
|
||||
}, [value])
|
||||
return ref
|
||||
}
|
||||
|
||||
function useEditorMountState(
|
||||
editor: ReturnType<typeof useCreateBlockNote>,
|
||||
editorMountedRef: MutableRefObject<boolean>,
|
||||
pendingSwapRef: MutableRefObject<(() => void) | null>,
|
||||
) {
|
||||
useEffect(() => {
|
||||
if (editor.prosemirrorView) {
|
||||
editorMountedRef.current = true
|
||||
}
|
||||
const cleanup = editor.onMount(() => {
|
||||
editorMountedRef.current = true
|
||||
if (pendingSwapRef.current) {
|
||||
const swap = pendingSwapRef.current
|
||||
pendingSwapRef.current = null
|
||||
queueMicrotask(swap)
|
||||
}
|
||||
})
|
||||
return cleanup
|
||||
}, [editor, editorMountedRef, pendingSwapRef])
|
||||
}
|
||||
|
||||
function useEditorChangeHandler(options: {
|
||||
editor: ReturnType<typeof useCreateBlockNote>
|
||||
tabsRef: MutableRefObject<Tab[]>
|
||||
onContentChangeRef: MutableRefObject<((path: string, content: string) => void) | undefined>
|
||||
prevActivePathRef: MutableRefObject<string | null>
|
||||
suppressChangeRef: MutableRefObject<boolean>
|
||||
}) {
|
||||
const {
|
||||
editor,
|
||||
tabsRef,
|
||||
onContentChangeRef,
|
||||
prevActivePathRef,
|
||||
suppressChangeRef,
|
||||
} = options
|
||||
|
||||
return useCallback(() => {
|
||||
if (suppressChangeRef.current) return
|
||||
const path = prevActivePathRef.current
|
||||
if (!path) return
|
||||
|
||||
const tab = tabsRef.current.find(t => t.entry.path === path)
|
||||
if (!tab) return
|
||||
|
||||
const blocks = editor.document
|
||||
const restored = restoreWikilinksInBlocks(blocks)
|
||||
const bodyMarkdown = compactMarkdown(editor.blocksToMarkdownLossy(restored as typeof blocks))
|
||||
const [frontmatter] = splitFrontmatter(tab.content)
|
||||
onContentChangeRef.current?.(path, `${frontmatter}${bodyMarkdown}`)
|
||||
}, [editor, onContentChangeRef, prevActivePathRef, suppressChangeRef, tabsRef])
|
||||
}
|
||||
|
||||
function consumeRawModeTransition(
|
||||
prevRawModeRef: MutableRefObject<boolean>,
|
||||
rawMode: boolean | undefined,
|
||||
) {
|
||||
const rawModeJustEnded = prevRawModeRef.current && !rawMode
|
||||
prevRawModeRef.current = !!rawMode
|
||||
return rawModeJustEnded
|
||||
}
|
||||
|
||||
function cachePreviousTabOnPathChange(options: {
|
||||
prevPath: string | null
|
||||
pathChanged: boolean
|
||||
editorMountedRef: MutableRefObject<boolean>
|
||||
cache: Map<string, CachedTabState>
|
||||
editor: ReturnType<typeof useCreateBlockNote>
|
||||
}) {
|
||||
const { prevPath, pathChanged, editorMountedRef, cache, editor } = options
|
||||
if (!prevPath || !pathChanged || !editorMountedRef.current) return
|
||||
cacheEditorState(cache, prevPath, editor.document)
|
||||
}
|
||||
|
||||
function rememberPendingTabArrival(
|
||||
activeTabPath: string | null,
|
||||
activeTab: Tab | undefined,
|
||||
pendingTabArrivalPathRef: MutableRefObject<string | null>,
|
||||
) {
|
||||
if (!activeTabPath) {
|
||||
pendingTabArrivalPathRef.current = null
|
||||
return false
|
||||
}
|
||||
if (activeTab) {
|
||||
pendingTabArrivalPathRef.current = null
|
||||
return true
|
||||
}
|
||||
pendingTabArrivalPathRef.current = activeTabPath
|
||||
return false
|
||||
}
|
||||
|
||||
function handleStableActivePath(options: {
|
||||
pathChanged: boolean
|
||||
rawModeJustEnded: boolean
|
||||
activeTabPath: string | null
|
||||
activeTab: Tab | undefined
|
||||
pendingTabArrival: boolean
|
||||
cache: Map<string, CachedTabState>
|
||||
editor: ReturnType<typeof useCreateBlockNote>
|
||||
editorMountedRef: MutableRefObject<boolean>
|
||||
rawSwapPendingRef: MutableRefObject<boolean>
|
||||
}) {
|
||||
const {
|
||||
pathChanged,
|
||||
rawModeJustEnded,
|
||||
activeTabPath,
|
||||
activeTab,
|
||||
pendingTabArrival,
|
||||
cache,
|
||||
editor,
|
||||
editorMountedRef,
|
||||
rawSwapPendingRef,
|
||||
} = options
|
||||
|
||||
if (pathChanged) return false
|
||||
if (rawModeJustEnded && activeTabPath) {
|
||||
cache.delete(activeTabPath)
|
||||
rawSwapPendingRef.current = true
|
||||
return false
|
||||
}
|
||||
if (pendingTabArrival) return false
|
||||
if (rawSwapPendingRef.current) return true
|
||||
|
||||
if (activeTabPath && activeTab && editorMountedRef.current) {
|
||||
cacheEditorState(cache, activeTabPath, editor.document)
|
||||
}
|
||||
return true
|
||||
}
|
||||
|
||||
function scheduleTabSwap(options: {
|
||||
editor: ReturnType<typeof useCreateBlockNote>
|
||||
cache: Map<string, CachedTabState>
|
||||
targetPath: string
|
||||
activeTab: Tab
|
||||
pendingSwapRef: MutableRefObject<(() => void) | null>
|
||||
prevActivePathRef: MutableRefObject<string | null>
|
||||
rawSwapPendingRef: MutableRefObject<boolean>
|
||||
suppressChangeRef: MutableRefObject<boolean>
|
||||
}) {
|
||||
const {
|
||||
editor,
|
||||
cache,
|
||||
targetPath,
|
||||
activeTab,
|
||||
pendingSwapRef,
|
||||
prevActivePathRef,
|
||||
rawSwapPendingRef,
|
||||
suppressChangeRef,
|
||||
} = options
|
||||
|
||||
const doSwap = () => {
|
||||
if (prevActivePathRef.current !== targetPath) return
|
||||
rawSwapPendingRef.current = false
|
||||
void resolveBlocksForTarget(editor, cache, targetPath, activeTab.content)
|
||||
.then(({ blocks, scrollTop }) => {
|
||||
if (prevActivePathRef.current !== targetPath) return
|
||||
applyBlocksToEditor(editor, blocks, scrollTop, suppressChangeRef)
|
||||
})
|
||||
.catch((err: unknown) => {
|
||||
console.error('Failed to parse/swap editor content:', err)
|
||||
})
|
||||
}
|
||||
|
||||
if (editor.prosemirrorView) {
|
||||
queueMicrotask(doSwap)
|
||||
return
|
||||
}
|
||||
pendingSwapRef.current = doSwap
|
||||
}
|
||||
|
||||
function useTabSwapEffect(options: {
|
||||
tabs: Tab[]
|
||||
activeTabPath: string | null
|
||||
editor: ReturnType<typeof useCreateBlockNote>
|
||||
rawMode?: boolean
|
||||
tabCacheRef: MutableRefObject<Map<string, CachedTabState>>
|
||||
prevActivePathRef: MutableRefObject<string | null>
|
||||
editorMountedRef: MutableRefObject<boolean>
|
||||
pendingSwapRef: MutableRefObject<(() => void) | null>
|
||||
pendingTabArrivalPathRef: MutableRefObject<string | null>
|
||||
prevRawModeRef: MutableRefObject<boolean>
|
||||
rawSwapPendingRef: MutableRefObject<boolean>
|
||||
suppressChangeRef: MutableRefObject<boolean>
|
||||
}) {
|
||||
const {
|
||||
tabs,
|
||||
activeTabPath,
|
||||
editor,
|
||||
rawMode,
|
||||
tabCacheRef,
|
||||
prevActivePathRef,
|
||||
editorMountedRef,
|
||||
pendingSwapRef,
|
||||
pendingTabArrivalPathRef,
|
||||
prevRawModeRef,
|
||||
rawSwapPendingRef,
|
||||
suppressChangeRef,
|
||||
} = options
|
||||
|
||||
useEffect(() => {
|
||||
const cache = tabCacheRef.current
|
||||
const prevPath = prevActivePathRef.current
|
||||
const pathChanged = prevPath !== activeTabPath
|
||||
const activeTab = findActiveTab(tabs, activeTabPath)
|
||||
const pendingTabArrival = activeTabPath !== null
|
||||
&& pendingTabArrivalPathRef.current === activeTabPath
|
||||
const rawModeJustEnded = consumeRawModeTransition(prevRawModeRef, rawMode)
|
||||
|
||||
if (rawMode) return
|
||||
cachePreviousTabOnPathChange({ prevPath, pathChanged, editorMountedRef, cache, editor })
|
||||
prevActivePathRef.current = activeTabPath
|
||||
|
||||
if (handleStableActivePath({
|
||||
pathChanged,
|
||||
rawModeJustEnded,
|
||||
activeTabPath,
|
||||
activeTab,
|
||||
pendingTabArrival,
|
||||
cache,
|
||||
editor,
|
||||
editorMountedRef,
|
||||
rawSwapPendingRef,
|
||||
})) {
|
||||
return
|
||||
}
|
||||
|
||||
if (!rememberPendingTabArrival(activeTabPath, activeTab, pendingTabArrivalPathRef)) {
|
||||
return
|
||||
}
|
||||
const targetPath = activeTabPath
|
||||
const readyActiveTab = activeTab
|
||||
if (!targetPath || !readyActiveTab) return
|
||||
|
||||
scheduleTabSwap({
|
||||
editor,
|
||||
cache,
|
||||
targetPath,
|
||||
activeTab: readyActiveTab,
|
||||
pendingSwapRef,
|
||||
prevActivePathRef,
|
||||
rawSwapPendingRef,
|
||||
suppressChangeRef,
|
||||
})
|
||||
}, [
|
||||
activeTabPath,
|
||||
editor,
|
||||
editorMountedRef,
|
||||
pendingSwapRef,
|
||||
pendingTabArrivalPathRef,
|
||||
prevActivePathRef,
|
||||
prevRawModeRef,
|
||||
rawMode,
|
||||
rawSwapPendingRef,
|
||||
suppressChangeRef,
|
||||
tabCacheRef,
|
||||
tabs,
|
||||
])
|
||||
}
|
||||
|
||||
function useTabCacheCleanup(
|
||||
tabs: Tab[],
|
||||
tabCacheRef: MutableRefObject<Map<string, CachedTabState>>,
|
||||
) {
|
||||
const tabPathsRef = useRef<Set<string>>(new Set())
|
||||
|
||||
useEffect(() => {
|
||||
const currentPaths = new Set(tabs.map(t => t.entry.path))
|
||||
for (const path of tabPathsRef.current) {
|
||||
if (!currentPaths.has(path)) {
|
||||
tabCacheRef.current.delete(path)
|
||||
}
|
||||
}
|
||||
tabPathsRef.current = currentPaths
|
||||
}, [tabs, tabCacheRef])
|
||||
}
|
||||
|
||||
/**
|
||||
* Manages the tab content-swap machinery for the BlockNote editor.
|
||||
*
|
||||
@@ -59,241 +454,40 @@ export function replaceTitleInFrontmatter(frontmatter: string, newTitle: string)
|
||||
* Returns `handleEditorChange`, the onChange callback for SingleEditorView.
|
||||
*/
|
||||
export function useEditorTabSwap({ tabs, activeTabPath, editor, onContentChange, rawMode }: UseEditorTabSwapOptions) {
|
||||
// Cache parsed blocks + scroll position per tab path for instant switching
|
||||
// eslint-disable-next-line @typescript-eslint/no-explicit-any -- BlockNote block arrays
|
||||
const tabCacheRef = useRef<Map<string, { blocks: any[]; scrollTop: number }>>(new Map())
|
||||
const tabCacheRef = useRef<Map<string, CachedTabState>>(new Map())
|
||||
const prevActivePathRef = useRef<string | null>(null)
|
||||
const editorMountedRef = useRef(false)
|
||||
const pendingSwapRef = useRef<(() => void) | null>(null)
|
||||
const pendingTabArrivalPathRef = useRef<string | null>(null)
|
||||
const prevRawModeRef = useRef(!!rawMode)
|
||||
// Guard: prevents a subsequent effect run from re-caching stale blocks
|
||||
// while a raw-mode swap is still pending in a microtask/pendingSwap.
|
||||
const rawSwapPendingRef = useRef(false)
|
||||
|
||||
// Suppress onChange during programmatic content swaps (tab switching / initial load)
|
||||
const suppressChangeRef = useRef(false)
|
||||
const onContentChangeRef = useLatestRef(onContentChange)
|
||||
const tabsRef = useLatestRef(tabs)
|
||||
const handleEditorChange = useEditorChangeHandler({
|
||||
editor,
|
||||
tabsRef,
|
||||
onContentChangeRef,
|
||||
prevActivePathRef,
|
||||
suppressChangeRef,
|
||||
})
|
||||
|
||||
// Keep refs to callbacks for the onChange handler
|
||||
const onContentChangeRef = useRef(onContentChange)
|
||||
onContentChangeRef.current = onContentChange
|
||||
const tabsRef = useRef(tabs)
|
||||
tabsRef.current = tabs
|
||||
|
||||
// Track editor mount state
|
||||
useEffect(() => {
|
||||
// Check if already mounted (prosemirrorView exists)
|
||||
if (editor.prosemirrorView) {
|
||||
editorMountedRef.current = true
|
||||
}
|
||||
const cleanup = editor.onMount(() => {
|
||||
editorMountedRef.current = true
|
||||
// Execute any pending content swap that was queued before mount.
|
||||
// Defer via queueMicrotask so BlockNote's internal flushSync calls
|
||||
// don't collide with React's commit phase.
|
||||
if (pendingSwapRef.current) {
|
||||
const swap = pendingSwapRef.current
|
||||
pendingSwapRef.current = null
|
||||
queueMicrotask(swap)
|
||||
}
|
||||
})
|
||||
return cleanup
|
||||
}, [editor])
|
||||
|
||||
// onChange handler: serialize editor blocks → markdown, reconstruct full file, call save
|
||||
const handleEditorChange = useCallback(() => {
|
||||
if (suppressChangeRef.current) return
|
||||
const path = prevActivePathRef.current
|
||||
if (!path) return
|
||||
|
||||
const tab = tabsRef.current.find(t => t.entry.path === path)
|
||||
if (!tab) return
|
||||
|
||||
// Convert blocks → markdown, restoring wikilinks first
|
||||
const blocks = editor.document
|
||||
const restored = restoreWikilinksInBlocks(blocks)
|
||||
const bodyMarkdown = compactMarkdown(editor.blocksToMarkdownLossy(restored as typeof blocks))
|
||||
|
||||
// Reconstruct full file: frontmatter + body (which now includes H1 if present)
|
||||
const [frontmatter] = splitFrontmatter(tab.content)
|
||||
const fullContent = `${frontmatter}${bodyMarkdown}`
|
||||
|
||||
onContentChangeRef.current?.(path, fullContent)
|
||||
}, [editor])
|
||||
|
||||
// Swap document content when active tab changes.
|
||||
// Uses queueMicrotask to defer BlockNote mutations outside React's commit phase,
|
||||
// avoiding flushSync-inside-lifecycle errors that silently prevent content from rendering.
|
||||
useEffect(() => {
|
||||
const cache = tabCacheRef.current
|
||||
const prevPath = prevActivePathRef.current
|
||||
const pathChanged = prevPath !== activeTabPath
|
||||
|
||||
// Detect raw mode transition: true → false means we need to re-parse
|
||||
// from tab.content since the cached blocks are stale.
|
||||
const rawModeJustEnded = prevRawModeRef.current && !rawMode
|
||||
prevRawModeRef.current = !!rawMode
|
||||
|
||||
// While raw mode is active the BlockNote editor is hidden — skip all
|
||||
// swap logic to avoid touching the invisible editor.
|
||||
if (rawMode) return
|
||||
|
||||
// Save current editor state + scroll position for the tab we're leaving
|
||||
if (prevPath && pathChanged && editorMountedRef.current) {
|
||||
const scrollEl = document.querySelector('.editor__blocknote-container')
|
||||
cache.set(prevPath, {
|
||||
blocks: editor.document,
|
||||
scrollTop: scrollEl?.scrollTop ?? 0,
|
||||
})
|
||||
}
|
||||
prevActivePathRef.current = activeTabPath
|
||||
|
||||
if (!pathChanged) {
|
||||
if (rawModeJustEnded && activeTabPath) {
|
||||
// Raw mode just ended — invalidate stale cached blocks so we
|
||||
// re-parse from the latest tab.content below.
|
||||
cache.delete(activeTabPath)
|
||||
rawSwapPendingRef.current = true
|
||||
} else {
|
||||
// While a raw-mode swap is pending (scheduled via microtask), a second
|
||||
// effect run can fire due to the tabs prop updating. Skip re-caching
|
||||
// stale editor.document to avoid poisoning the cache before doSwap runs.
|
||||
if (rawSwapPendingRef.current) return
|
||||
|
||||
// When tab content updates but the active tab stays the same (e.g. after
|
||||
// Cmd+S save), refresh the cache with the current editor blocks so a later
|
||||
// tab switch doesn't revert to stale content. Do NOT re-apply blocks —
|
||||
// the editor already shows the user's edits.
|
||||
if (activeTabPath && editorMountedRef.current) {
|
||||
const scrollEl = document.querySelector('.editor__blocknote-container')
|
||||
cache.set(activeTabPath, {
|
||||
blocks: editor.document,
|
||||
scrollTop: scrollEl?.scrollTop ?? 0,
|
||||
})
|
||||
}
|
||||
return
|
||||
}
|
||||
}
|
||||
|
||||
if (!activeTabPath) return
|
||||
|
||||
const tab = tabs.find(t => t.entry.path === activeTabPath)
|
||||
if (!tab) return
|
||||
|
||||
// eslint-disable-next-line @typescript-eslint/no-explicit-any -- BlockNote's PartialBlock generic is extremely complex
|
||||
const applyBlocks = (blocks: any[], scrollTop = 0) => {
|
||||
suppressChangeRef.current = true
|
||||
try {
|
||||
const current = editor.document
|
||||
if (current.length > 0 && blocks.length > 0) {
|
||||
editor.replaceBlocks(current, blocks)
|
||||
} else if (blocks.length > 0) {
|
||||
editor.insertBlocks(blocks, current[0], 'before')
|
||||
}
|
||||
} catch (err) {
|
||||
console.error('applyBlocks failed, trying fallback:', err)
|
||||
try {
|
||||
const html = editor.blocksToHTMLLossy(blocks)
|
||||
editor._tiptapEditor.commands.setContent(html)
|
||||
} catch (err2) {
|
||||
console.error('Fallback also failed:', err2)
|
||||
}
|
||||
} finally {
|
||||
// Re-enable change detection on next microtask, after BlockNote
|
||||
// finishes its internal state updates from the content swap
|
||||
queueMicrotask(() => { suppressChangeRef.current = false })
|
||||
}
|
||||
// Restore scroll position after layout updates from the content swap
|
||||
requestAnimationFrame(() => {
|
||||
const scrollEl = document.querySelector('.editor__blocknote-container')
|
||||
if (scrollEl) scrollEl.scrollTop = scrollTop
|
||||
})
|
||||
}
|
||||
|
||||
const targetPath = activeTabPath
|
||||
|
||||
const doSwap = () => {
|
||||
// Guard: bail if user switched tabs since this swap was scheduled
|
||||
if (prevActivePathRef.current !== targetPath) return
|
||||
rawSwapPendingRef.current = false
|
||||
|
||||
if (cache.has(targetPath)) {
|
||||
const cached = cache.get(targetPath)!
|
||||
applyBlocks(cached.blocks, cached.scrollTop)
|
||||
return
|
||||
}
|
||||
|
||||
const body = extractEditorBody(tab.content)
|
||||
const preprocessed = preProcessWikilinks(body)
|
||||
|
||||
// Fast path: empty body (e.g. newly created notes). Skip the
|
||||
// potentially-async markdown parser and set a single empty paragraph
|
||||
// so the editor is immediately interactive.
|
||||
if (!preprocessed.trim()) {
|
||||
const emptyDoc = [{ type: 'paragraph', content: [] }]
|
||||
cache.set(targetPath, { blocks: emptyDoc, scrollTop: 0 })
|
||||
applyBlocks(emptyDoc)
|
||||
return
|
||||
}
|
||||
|
||||
// Fast path: H1-only content (e.g. newly created notes that just have
|
||||
// the title heading). Build blocks directly to stay instant.
|
||||
const h1OnlyMatch = preprocessed.trim().match(/^# (.+)$/)
|
||||
if (h1OnlyMatch) {
|
||||
const h1Doc = [
|
||||
{ type: 'heading', props: { level: 1, textColor: 'default', backgroundColor: 'default', textAlignment: 'left' }, content: [{ type: 'text', text: h1OnlyMatch[1], styles: {} }], children: [] },
|
||||
{ type: 'paragraph', content: [], children: [] },
|
||||
]
|
||||
cache.set(targetPath, { blocks: h1Doc, scrollTop: 0 })
|
||||
applyBlocks(h1Doc)
|
||||
return
|
||||
}
|
||||
|
||||
try {
|
||||
const result = editor.tryParseMarkdownToBlocks(preprocessed)
|
||||
// eslint-disable-next-line @typescript-eslint/no-explicit-any -- BlockNote block arrays
|
||||
const handleBlocks = (blocks: any[]) => {
|
||||
if (prevActivePathRef.current !== targetPath) return
|
||||
const withWikilinks = injectWikilinks(blocks)
|
||||
// Only cache non-empty results to avoid poisoning the cache
|
||||
if (withWikilinks.length > 0) {
|
||||
cache.set(targetPath, { blocks: withWikilinks, scrollTop: 0 })
|
||||
}
|
||||
applyBlocks(withWikilinks)
|
||||
}
|
||||
/* eslint-disable @typescript-eslint/no-explicit-any -- tryParseMarkdownToBlocks returns sync or async BlockNote blocks */
|
||||
if (result && typeof (result as any).then === 'function') {
|
||||
(result as unknown as Promise<any[]>).then(handleBlocks).catch((err: unknown) => {
|
||||
console.error('Async markdown parse failed:', err)
|
||||
})
|
||||
} else {
|
||||
handleBlocks(result as any[])
|
||||
}
|
||||
/* eslint-enable @typescript-eslint/no-explicit-any */
|
||||
} catch (err) {
|
||||
console.error('Failed to parse/swap editor content:', err)
|
||||
}
|
||||
}
|
||||
|
||||
if (editor.prosemirrorView) {
|
||||
// Defer the swap outside React's commit phase so BlockNote's internal
|
||||
// flushSync calls don't collide with React's rendering lifecycle.
|
||||
queueMicrotask(doSwap)
|
||||
} else {
|
||||
pendingSwapRef.current = doSwap
|
||||
}
|
||||
}, [activeTabPath, tabs, editor, rawMode])
|
||||
|
||||
// Clean up cache entries when tabs are closed
|
||||
const tabPathsRef = useRef<Set<string>>(new Set())
|
||||
useEffect(() => {
|
||||
const currentPaths = new Set(tabs.map(t => t.entry.path))
|
||||
for (const path of tabPathsRef.current) {
|
||||
if (!currentPaths.has(path)) {
|
||||
tabCacheRef.current.delete(path)
|
||||
}
|
||||
}
|
||||
tabPathsRef.current = currentPaths
|
||||
}, [tabs])
|
||||
useEditorMountState(editor, editorMountedRef, pendingSwapRef)
|
||||
useTabSwapEffect({
|
||||
tabs,
|
||||
activeTabPath,
|
||||
editor,
|
||||
rawMode,
|
||||
tabCacheRef,
|
||||
prevActivePathRef,
|
||||
editorMountedRef,
|
||||
pendingSwapRef,
|
||||
pendingTabArrivalPathRef,
|
||||
prevRawModeRef,
|
||||
rawSwapPendingRef,
|
||||
suppressChangeRef,
|
||||
})
|
||||
useTabCacheCleanup(tabs, tabCacheRef)
|
||||
|
||||
return { handleEditorChange, editorMountedRef }
|
||||
}
|
||||
|
||||
@@ -33,6 +33,26 @@ const makeEntry = (overrides: Partial<VaultEntry> = {}): VaultEntry => ({
|
||||
...overrides,
|
||||
})
|
||||
|
||||
type HookState = { current: ReturnType<typeof useTabManagement> }
|
||||
|
||||
async function selectNote(result: HookState, overrides: Partial<VaultEntry>) {
|
||||
await act(async () => {
|
||||
await result.current.handleSelectNote(makeEntry(overrides))
|
||||
})
|
||||
}
|
||||
|
||||
async function replaceActiveNote(result: HookState, overrides: Partial<VaultEntry>) {
|
||||
await act(async () => {
|
||||
await result.current.handleReplaceActiveTab(makeEntry(overrides))
|
||||
})
|
||||
}
|
||||
|
||||
function expectSingleActiveTab(result: HookState, path: string) {
|
||||
expect(result.current.tabs).toHaveLength(1)
|
||||
expect(result.current.tabs[0].entry.path).toBe(path)
|
||||
expect(result.current.activeTabPath).toBe(path)
|
||||
}
|
||||
|
||||
describe('useTabManagement (single-note model)', () => {
|
||||
beforeEach(() => {
|
||||
vi.clearAllMocks()
|
||||
@@ -47,41 +67,47 @@ describe('useTabManagement (single-note model)', () => {
|
||||
describe('handleSelectNote', () => {
|
||||
it('opens a note and sets it active', async () => {
|
||||
const { result } = renderHook(() => useTabManagement())
|
||||
const entry = makeEntry({ path: '/vault/note/a.md' })
|
||||
await selectNote(result, { path: '/vault/note/a.md' })
|
||||
expectSingleActiveTab(result, '/vault/note/a.md')
|
||||
})
|
||||
|
||||
await act(async () => {
|
||||
await result.current.handleSelectNote(entry)
|
||||
it('switches the active path immediately while the next note is still loading', async () => {
|
||||
const { mockInvoke } = await import('../mock-tauri')
|
||||
|
||||
let resolveContent: (value: string) => void
|
||||
vi.mocked(mockInvoke).mockImplementationOnce(
|
||||
() => new Promise<string>((resolve) => { resolveContent = resolve }),
|
||||
)
|
||||
|
||||
const { result } = renderHook(() => useTabManagement())
|
||||
void act(() => {
|
||||
void result.current.handleSelectNote(makeEntry({ path: '/vault/note/pending.md', title: 'Pending' }))
|
||||
})
|
||||
|
||||
expect(result.current.tabs).toHaveLength(1)
|
||||
expect(result.current.tabs[0].entry.path).toBe('/vault/note/a.md')
|
||||
expect(result.current.activeTabPath).toBe('/vault/note/a.md')
|
||||
expect(result.current.activeTabPath).toBe('/vault/note/pending.md')
|
||||
expect(result.current.tabs).toEqual([])
|
||||
|
||||
await act(async () => {
|
||||
resolveContent!('# Pending content')
|
||||
})
|
||||
|
||||
expect(result.current.tabs[0].entry.path).toBe('/vault/note/pending.md')
|
||||
expect(result.current.tabs[0].content).toBe('# Pending content')
|
||||
})
|
||||
|
||||
it('replaces the current note when selecting a different one', async () => {
|
||||
const { result } = renderHook(() => useTabManagement())
|
||||
|
||||
await act(async () => {
|
||||
await result.current.handleSelectNote(makeEntry({ path: '/vault/a.md', title: 'A' }))
|
||||
})
|
||||
await act(async () => {
|
||||
await result.current.handleSelectNote(makeEntry({ path: '/vault/b.md', title: 'B' }))
|
||||
})
|
||||
|
||||
expect(result.current.tabs).toHaveLength(1)
|
||||
expect(result.current.tabs[0].entry.path).toBe('/vault/b.md')
|
||||
expect(result.current.activeTabPath).toBe('/vault/b.md')
|
||||
await selectNote(result, { path: '/vault/a.md', title: 'A' })
|
||||
await selectNote(result, { path: '/vault/b.md', title: 'B' })
|
||||
expectSingleActiveTab(result, '/vault/b.md')
|
||||
})
|
||||
|
||||
it('is a no-op when selecting the already-open note', async () => {
|
||||
const { result } = renderHook(() => useTabManagement())
|
||||
const entry = makeEntry({ path: '/vault/a.md' })
|
||||
|
||||
const entry = { path: '/vault/a.md' }
|
||||
await selectNote(result, entry)
|
||||
await act(async () => {
|
||||
await result.current.handleSelectNote(entry)
|
||||
})
|
||||
await act(async () => {
|
||||
await result.current.handleSelectNote(entry)
|
||||
await result.current.handleSelectNote(makeEntry(entry))
|
||||
})
|
||||
|
||||
expect(result.current.tabs).toHaveLength(1)
|
||||
@@ -93,11 +119,7 @@ describe('useTabManagement (single-note model)', () => {
|
||||
const warnSpy = vi.spyOn(console, 'warn').mockImplementation(() => {})
|
||||
|
||||
const { result } = renderHook(() => useTabManagement())
|
||||
const entry = makeEntry()
|
||||
|
||||
await act(async () => {
|
||||
await result.current.handleSelectNote(entry)
|
||||
})
|
||||
await selectNote(result, {})
|
||||
|
||||
expect(result.current.tabs).toHaveLength(1)
|
||||
expect(result.current.tabs[0].content).toBe('')
|
||||
@@ -108,31 +130,17 @@ describe('useTabManagement (single-note model)', () => {
|
||||
describe('handleReplaceActiveTab', () => {
|
||||
it('replaces the current note with a new entry', async () => {
|
||||
const { result } = renderHook(() => useTabManagement())
|
||||
|
||||
await act(async () => {
|
||||
await result.current.handleSelectNote(makeEntry({ path: '/vault/a.md', title: 'A' }))
|
||||
})
|
||||
|
||||
const replacement = makeEntry({ path: '/vault/b.md', title: 'B' })
|
||||
await act(async () => {
|
||||
await result.current.handleReplaceActiveTab(replacement)
|
||||
})
|
||||
|
||||
expect(result.current.tabs).toHaveLength(1)
|
||||
expect(result.current.tabs[0].entry.path).toBe('/vault/b.md')
|
||||
expect(result.current.activeTabPath).toBe('/vault/b.md')
|
||||
await selectNote(result, { path: '/vault/a.md', title: 'A' })
|
||||
await replaceActiveNote(result, { path: '/vault/b.md', title: 'B' })
|
||||
expectSingleActiveTab(result, '/vault/b.md')
|
||||
})
|
||||
|
||||
it('is a no-op when replacing with the same entry', async () => {
|
||||
const { result } = renderHook(() => useTabManagement())
|
||||
const entry = makeEntry({ path: '/vault/a.md' })
|
||||
|
||||
const entry = { path: '/vault/a.md' }
|
||||
await selectNote(result, entry)
|
||||
await act(async () => {
|
||||
await result.current.handleSelectNote(entry)
|
||||
})
|
||||
|
||||
await act(async () => {
|
||||
await result.current.handleReplaceActiveTab(entry)
|
||||
await result.current.handleReplaceActiveTab(makeEntry(entry))
|
||||
})
|
||||
|
||||
expect(result.current.tabs).toHaveLength(1)
|
||||
@@ -140,14 +148,8 @@ describe('useTabManagement (single-note model)', () => {
|
||||
|
||||
it('opens a note when no note is active', async () => {
|
||||
const { result } = renderHook(() => useTabManagement())
|
||||
const entry = makeEntry({ path: '/vault/a.md' })
|
||||
|
||||
await act(async () => {
|
||||
await result.current.handleReplaceActiveTab(entry)
|
||||
})
|
||||
|
||||
expect(result.current.tabs).toHaveLength(1)
|
||||
expect(result.current.activeTabPath).toBe('/vault/a.md')
|
||||
await replaceActiveNote(result, { path: '/vault/a.md' })
|
||||
expectSingleActiveTab(result, '/vault/a.md')
|
||||
})
|
||||
})
|
||||
|
||||
@@ -189,10 +191,7 @@ describe('useTabManagement (single-note model)', () => {
|
||||
describe('closeAllTabs', () => {
|
||||
it('clears the note and active path', async () => {
|
||||
const { result } = renderHook(() => useTabManagement())
|
||||
|
||||
await act(async () => {
|
||||
await result.current.handleSelectNote(makeEntry({ path: '/vault/a.md' }))
|
||||
})
|
||||
await selectNote(result, { path: '/vault/a.md' })
|
||||
|
||||
act(() => {
|
||||
result.current.closeAllTabs()
|
||||
@@ -212,9 +211,7 @@ describe('useTabManagement (single-note model)', () => {
|
||||
await vi.waitFor(() => expect(vi.mocked(mockInvoke)).toHaveBeenCalledTimes(1))
|
||||
|
||||
const { result } = renderHook(() => useTabManagement())
|
||||
await act(async () => {
|
||||
await result.current.handleSelectNote(makeEntry({ path: '/vault/note/pre.md', title: 'Pre' }))
|
||||
})
|
||||
await selectNote(result, { path: '/vault/note/pre.md', title: 'Pre' })
|
||||
|
||||
expect(result.current.tabs[0].content).toBe('# Prefetched content')
|
||||
expect(vi.mocked(mockInvoke)).toHaveBeenCalledTimes(1)
|
||||
@@ -231,9 +228,7 @@ describe('useTabManagement (single-note model)', () => {
|
||||
vi.mocked(mockInvoke).mockResolvedValue('# Fresh')
|
||||
|
||||
const { result } = renderHook(() => useTabManagement())
|
||||
await act(async () => {
|
||||
await result.current.handleSelectNote(makeEntry({ path: '/vault/note/stale.md', title: 'Stale' }))
|
||||
})
|
||||
await selectNote(result, { path: '/vault/note/stale.md', title: 'Stale' })
|
||||
|
||||
expect(result.current.tabs[0].content).toBe('# Fresh')
|
||||
expect(vi.mocked(mockInvoke)).toHaveBeenCalledTimes(2)
|
||||
|
||||
@@ -49,6 +49,69 @@ async function loadNoteContent(path: string): Promise<string> {
|
||||
|
||||
export type { Tab }
|
||||
|
||||
function syncActiveTabPath(
|
||||
activeTabPathRef: React.MutableRefObject<string | null>,
|
||||
setActiveTabPath: React.Dispatch<React.SetStateAction<string | null>>,
|
||||
path: string | null,
|
||||
) {
|
||||
activeTabPathRef.current = path
|
||||
setActiveTabPath(path)
|
||||
}
|
||||
|
||||
function setSingleTab(
|
||||
tabsRef: React.MutableRefObject<Tab[]>,
|
||||
setTabs: React.Dispatch<React.SetStateAction<Tab[]>>,
|
||||
nextTab: Tab,
|
||||
) {
|
||||
tabsRef.current = [nextTab]
|
||||
setTabs([nextTab])
|
||||
}
|
||||
|
||||
function isAlreadyViewingPath(
|
||||
tabsRef: React.MutableRefObject<Tab[]>,
|
||||
activeTabPathRef: React.MutableRefObject<string | null>,
|
||||
path: string,
|
||||
) {
|
||||
return activeTabPathRef.current === path || tabsRef.current.some((tab) => tab.entry.path === path)
|
||||
}
|
||||
|
||||
async function navigateToEntry(options: {
|
||||
entry: VaultEntry
|
||||
navSeqRef: React.MutableRefObject<number>
|
||||
tabsRef: React.MutableRefObject<Tab[]>
|
||||
activeTabPathRef: React.MutableRefObject<string | null>
|
||||
setTabs: React.Dispatch<React.SetStateAction<Tab[]>>
|
||||
setActiveTabPath: React.Dispatch<React.SetStateAction<string | null>>
|
||||
}) {
|
||||
const {
|
||||
entry,
|
||||
navSeqRef,
|
||||
tabsRef,
|
||||
activeTabPathRef,
|
||||
setTabs,
|
||||
setActiveTabPath,
|
||||
} = options
|
||||
|
||||
if (entry.fileKind === 'binary') return
|
||||
if (isAlreadyViewingPath(tabsRef, activeTabPathRef, entry.path)) {
|
||||
syncActiveTabPath(activeTabPathRef, setActiveTabPath, entry.path)
|
||||
return
|
||||
}
|
||||
|
||||
const seq = ++navSeqRef.current
|
||||
syncActiveTabPath(activeTabPathRef, setActiveTabPath, entry.path)
|
||||
|
||||
try {
|
||||
const content = await loadNoteContent(entry.path)
|
||||
if (navSeqRef.current !== seq) return
|
||||
setSingleTab(tabsRef, setTabs, { entry, content })
|
||||
} catch (err) {
|
||||
console.warn('Failed to load note content:', err)
|
||||
if (navSeqRef.current !== seq) return
|
||||
setSingleTab(tabsRef, setTabs, { entry, content: '' })
|
||||
}
|
||||
}
|
||||
|
||||
export function useTabManagement() {
|
||||
// Single-note model: tabs has 0 or 1 elements.
|
||||
const [tabs, setTabs] = useState<Tab[]>([])
|
||||
@@ -63,64 +126,41 @@ export function useTabManagement() {
|
||||
|
||||
/** Open a note — replaces the current note (single-note model). */
|
||||
const handleSelectNote = useCallback(async (entry: VaultEntry) => {
|
||||
// Binary files cannot be opened
|
||||
if (entry.fileKind === 'binary') return
|
||||
// Already viewing this note — no-op
|
||||
if (tabsRef.current.some(t => t.entry.path === entry.path)) {
|
||||
setActiveTabPath(entry.path)
|
||||
return
|
||||
}
|
||||
const seq = ++navSeqRef.current
|
||||
try {
|
||||
const content = await loadNoteContent(entry.path)
|
||||
if (navSeqRef.current === seq) {
|
||||
setTabs([{ entry, content }])
|
||||
setActiveTabPath(entry.path)
|
||||
}
|
||||
} catch (err) {
|
||||
console.warn('Failed to load note content:', err)
|
||||
if (navSeqRef.current === seq) {
|
||||
setTabs([{ entry, content: '' }])
|
||||
setActiveTabPath(entry.path)
|
||||
}
|
||||
}
|
||||
await navigateToEntry({
|
||||
entry,
|
||||
navSeqRef,
|
||||
tabsRef,
|
||||
activeTabPathRef,
|
||||
setTabs,
|
||||
setActiveTabPath,
|
||||
})
|
||||
}, [])
|
||||
|
||||
const handleSwitchTab = useCallback((path: string) => { setActiveTabPath(path) }, [])
|
||||
const handleSwitchTab = useCallback((path: string) => {
|
||||
syncActiveTabPath(activeTabPathRef, setActiveTabPath, path)
|
||||
}, [])
|
||||
|
||||
/** Open a tab with known content — no IPC round-trip. Used for newly created notes. */
|
||||
const openTabWithContent = useCallback((entry: VaultEntry, content: string) => {
|
||||
setTabs([{ entry, content }])
|
||||
setActiveTabPath(entry.path)
|
||||
setSingleTab(tabsRef, setTabs, { entry, content })
|
||||
syncActiveTabPath(activeTabPathRef, setActiveTabPath, entry.path)
|
||||
}, [])
|
||||
|
||||
const handleReplaceActiveTab = useCallback(async (entry: VaultEntry) => {
|
||||
// Binary files cannot be opened
|
||||
if (entry.fileKind === 'binary') return
|
||||
// In single-note model, replace is the same as select
|
||||
if (tabsRef.current.some(t => t.entry.path === entry.path)) {
|
||||
setActiveTabPath(entry.path)
|
||||
return
|
||||
}
|
||||
const seq = ++navSeqRef.current
|
||||
try {
|
||||
const content = await loadNoteContent(entry.path)
|
||||
if (navSeqRef.current === seq) {
|
||||
setTabs([{ entry, content }])
|
||||
setActiveTabPath(entry.path)
|
||||
}
|
||||
} catch (err) {
|
||||
console.warn('Failed to load note content:', err)
|
||||
if (navSeqRef.current === seq) {
|
||||
setTabs([{ entry, content: '' }])
|
||||
setActiveTabPath(entry.path)
|
||||
}
|
||||
}
|
||||
await navigateToEntry({
|
||||
entry,
|
||||
navSeqRef,
|
||||
tabsRef,
|
||||
activeTabPathRef,
|
||||
setTabs,
|
||||
setActiveTabPath,
|
||||
})
|
||||
}, [])
|
||||
|
||||
const closeAllTabs = useCallback(() => {
|
||||
tabsRef.current = []
|
||||
setTabs([])
|
||||
setActiveTabPath(null)
|
||||
syncActiveTabPath(activeTabPathRef, setActiveTabPath, null)
|
||||
}, [])
|
||||
|
||||
return {
|
||||
|
||||
@@ -18,6 +18,12 @@ describe('filterDates', () => {
|
||||
expect(parsed && format(parsed, 'yyyy-MM-dd')).toBe('2026-03-28')
|
||||
})
|
||||
|
||||
it('parses numeric relative year phrases', () => {
|
||||
const reference = new Date('2026-04-08T12:00:00Z')
|
||||
const parsed = parseDateFilterInput('10 years ago', reference)
|
||||
expect(parsed && format(parsed, 'yyyy-MM-dd')).toBe('2016-04-08')
|
||||
})
|
||||
|
||||
it('parses word-based relative week phrases', () => {
|
||||
const reference = new Date('2026-04-07T12:00:00Z')
|
||||
const parsed = parseDateFilterInput('one week ago', reference)
|
||||
|
||||
@@ -1,5 +1,11 @@
|
||||
import { describe, expect, it } from 'vitest'
|
||||
import { deriveDisplayTitleState, extractH1TitleFromContent, filenameStemToTitle } from './noteTitle'
|
||||
import {
|
||||
contentDefinesDisplayTitle,
|
||||
deriveDisplayTitleState,
|
||||
extractFrontmatterTitleFromContent,
|
||||
extractH1TitleFromContent,
|
||||
filenameStemToTitle,
|
||||
} from './noteTitle'
|
||||
|
||||
describe('filenameStemToTitle', () => {
|
||||
it('converts kebab-case filenames into title case', () => {
|
||||
@@ -23,10 +29,32 @@ describe('extractH1TitleFromContent', () => {
|
||||
})
|
||||
})
|
||||
|
||||
describe('extractFrontmatterTitleFromContent', () => {
|
||||
it('extracts the frontmatter title when present', () => {
|
||||
const content = '---\ntitle: Legacy Title\nstatus: Active\n---\n## Body'
|
||||
expect(extractFrontmatterTitleFromContent(content)).toBe('Legacy Title')
|
||||
})
|
||||
|
||||
it('returns null when the frontmatter title is missing', () => {
|
||||
expect(extractFrontmatterTitleFromContent('---\nstatus: Active\n---\n## Body')).toBeNull()
|
||||
})
|
||||
})
|
||||
|
||||
describe('contentDefinesDisplayTitle', () => {
|
||||
it('returns true when the document title comes from frontmatter', () => {
|
||||
const content = '---\ntitle: Spring 2026\n---\n## Goals'
|
||||
expect(contentDefinesDisplayTitle(content)).toBe(true)
|
||||
})
|
||||
|
||||
it('returns false when title still comes from the filename', () => {
|
||||
expect(contentDefinesDisplayTitle('Body only')).toBe(false)
|
||||
})
|
||||
})
|
||||
|
||||
describe('deriveDisplayTitleState', () => {
|
||||
it('prefers H1 over frontmatter title and filename', () => {
|
||||
const content = '---\ntitle: Legacy Title\n---\n# Updated Title\n\nBody'
|
||||
expect(deriveDisplayTitleState(content, 'legacy-title.md', 'Legacy Title')).toEqual({
|
||||
expect(deriveDisplayTitleState({ content, filename: 'legacy-title.md', frontmatterTitle: 'Legacy Title' })).toEqual({
|
||||
title: 'Updated Title',
|
||||
hasH1: true,
|
||||
})
|
||||
@@ -34,14 +62,22 @@ describe('deriveDisplayTitleState', () => {
|
||||
|
||||
it('falls back to frontmatter title when no H1 is present', () => {
|
||||
const content = '---\ntitle: Legacy Title\n---\nBody'
|
||||
expect(deriveDisplayTitleState(content, 'legacy-title.md', 'Legacy Title')).toEqual({
|
||||
expect(deriveDisplayTitleState({ content, filename: 'legacy-title.md', frontmatterTitle: 'Legacy Title' })).toEqual({
|
||||
title: 'Legacy Title',
|
||||
hasH1: false,
|
||||
})
|
||||
})
|
||||
|
||||
it('reads the frontmatter title from content when no explicit title is passed', () => {
|
||||
const content = '---\ntitle: Spring 2026\n---\n## Goals'
|
||||
expect(deriveDisplayTitleState({ content, filename: 'spring-2026.md' })).toEqual({
|
||||
title: 'Spring 2026',
|
||||
hasH1: false,
|
||||
})
|
||||
})
|
||||
|
||||
it('falls back to filename title when there is no H1 or frontmatter title', () => {
|
||||
expect(deriveDisplayTitleState('Body only', 'renamed-note.md')).toEqual({
|
||||
expect(deriveDisplayTitleState({ content: 'Body only', filename: 'renamed-note.md' })).toEqual({
|
||||
title: 'Renamed Note',
|
||||
hasH1: false,
|
||||
})
|
||||
|
||||
@@ -1,5 +1,22 @@
|
||||
import { parseFrontmatter } from './frontmatter'
|
||||
import { splitFrontmatter } from './wikilinks'
|
||||
|
||||
interface ResolvedContentTitle {
|
||||
source: 'h1' | 'frontmatter'
|
||||
title: string
|
||||
}
|
||||
|
||||
interface DisplayTitleInput {
|
||||
content: string
|
||||
filename: string
|
||||
frontmatterTitle?: string | null
|
||||
}
|
||||
|
||||
interface DisplayTitleState {
|
||||
title: string
|
||||
hasH1: boolean
|
||||
}
|
||||
|
||||
function replaceWikilinkAliases(text: string): string {
|
||||
return text.replace(/\[\[[^|\]]+\|([^\]]+)\]\]/g, '$1')
|
||||
}
|
||||
@@ -49,20 +66,46 @@ export function extractH1TitleFromContent(content: string): string | null {
|
||||
return null
|
||||
}
|
||||
|
||||
export function deriveDisplayTitleState(
|
||||
content: string,
|
||||
filename: string,
|
||||
frontmatterTitle?: string | null,
|
||||
): { title: string, hasH1: boolean } {
|
||||
export function extractFrontmatterTitleFromContent(content: string): string | null {
|
||||
const title = parseFrontmatter(content).title
|
||||
if (typeof title !== 'string') return null
|
||||
const trimmed = title.trim()
|
||||
return trimmed || null
|
||||
}
|
||||
|
||||
function resolveContentTitle(content: string, frontmatterTitle?: string | null): ResolvedContentTitle | null {
|
||||
const h1Title = extractH1TitleFromContent(content)
|
||||
if (h1Title) {
|
||||
return { title: h1Title, hasH1: true }
|
||||
return { title: h1Title, source: 'h1' }
|
||||
}
|
||||
|
||||
const trimmedFrontmatterTitle = frontmatterTitle?.trim()
|
||||
if (trimmedFrontmatterTitle) {
|
||||
return { title: trimmedFrontmatterTitle, hasH1: false }
|
||||
const resolvedFrontmatterTitle = frontmatterTitle?.trim() || extractFrontmatterTitleFromContent(content)
|
||||
if (resolvedFrontmatterTitle) {
|
||||
return { title: resolvedFrontmatterTitle, source: 'frontmatter' }
|
||||
}
|
||||
|
||||
return { title: filenameStemToTitle(filename), hasH1: false }
|
||||
return null
|
||||
}
|
||||
|
||||
export function contentDefinesDisplayTitle(content: string): boolean {
|
||||
return resolveContentTitle(content) !== null
|
||||
}
|
||||
|
||||
export function deriveDisplayTitleState({
|
||||
content,
|
||||
filename,
|
||||
frontmatterTitle,
|
||||
}: DisplayTitleInput): DisplayTitleState {
|
||||
const resolvedTitle = resolveContentTitle(content, frontmatterTitle)
|
||||
if (resolvedTitle) {
|
||||
return {
|
||||
title: resolvedTitle.title,
|
||||
hasH1: resolvedTitle.source === 'h1',
|
||||
}
|
||||
}
|
||||
|
||||
return {
|
||||
title: filenameStemToTitle(filename),
|
||||
hasH1: false,
|
||||
}
|
||||
}
|
||||
|
||||
10
tests/fixtures/test-vault/quarter/spring-2026.md
vendored
Normal file
10
tests/fixtures/test-vault/quarter/spring-2026.md
vendored
Normal file
@@ -0,0 +1,10 @@
|
||||
---
|
||||
title: Spring 2026
|
||||
Is A: Quarter
|
||||
Status: Active
|
||||
---
|
||||
|
||||
## Goals
|
||||
|
||||
- [[Alpha Project]]
|
||||
- [[Note B]]
|
||||
@@ -69,12 +69,17 @@ test('creating an untitled draft hides the legacy title section in the editor',
|
||||
await expect(page.locator('.title-section[data-title-ui-visible]')).toHaveCount(0)
|
||||
})
|
||||
|
||||
test('@smoke older notes with an H1 do not render the legacy title section', async ({ page }) => {
|
||||
test('@smoke older notes with a document title do not render the legacy title section', async ({ page }) => {
|
||||
await openNote(page, 'Alpha Project')
|
||||
|
||||
await expect(page.locator('.bn-editor')).toBeVisible({ timeout: 5_000 })
|
||||
await expect(page.getByTestId('title-field-input')).toHaveCount(0)
|
||||
await expect(page.locator('.title-section[data-title-ui-visible]')).toHaveCount(0)
|
||||
|
||||
await openNote(page, 'Spring 2026')
|
||||
await expect(page.locator('.bn-editor')).toBeVisible({ timeout: 5_000 })
|
||||
await expect(page.getByTestId('title-field-input')).toHaveCount(0)
|
||||
await expect(page.locator('.title-section[data-title-ui-visible]')).toHaveCount(0)
|
||||
})
|
||||
|
||||
test('@smoke edited H1 titles drive note list, search, and wikilink autocomplete', async ({ page }) => {
|
||||
|
||||
Reference in New Issue
Block a user