feat: standardize canonical wikilink targets
This commit is contained in:
@@ -413,7 +413,7 @@ describe('wikilink autocomplete', () => {
|
||||
expect(items.length).toBeGreaterThan(0)
|
||||
items[0].onItemClick()
|
||||
expect(mockEditor.insertInlineContent).toHaveBeenCalledWith([
|
||||
{ type: 'wikilink', props: { target: 'vault/project/test|Alpha Project' } },
|
||||
{ type: 'wikilink', props: { target: 'vault/project/test' } },
|
||||
' ',
|
||||
])
|
||||
})
|
||||
@@ -566,7 +566,7 @@ describe('person @mention autocomplete', () => {
|
||||
expect(items.length).toBeGreaterThan(0)
|
||||
items[0].onItemClick()
|
||||
expect(mockEditor.insertInlineContent).toHaveBeenCalledWith([
|
||||
{ type: 'wikilink', props: { target: 'vault/person/matteo-cellini|Matteo Cellini' } },
|
||||
{ type: 'wikilink', props: { target: 'vault/person/matteo-cellini' } },
|
||||
' ',
|
||||
])
|
||||
})
|
||||
|
||||
@@ -75,6 +75,7 @@ export function EditorRightPanel({
|
||||
content={inspectorContent}
|
||||
entries={entries}
|
||||
gitHistory={gitHistory}
|
||||
vaultPath={vaultPath}
|
||||
onNavigate={onNavigateWikilink}
|
||||
onViewCommitDiff={onViewCommitDiff}
|
||||
onUpdateFrontmatter={onUpdateFrontmatter}
|
||||
|
||||
@@ -24,6 +24,7 @@ interface InspectorProps {
|
||||
content: string | null
|
||||
entries: VaultEntry[]
|
||||
gitHistory: GitCommit[]
|
||||
vaultPath?: string
|
||||
onNavigate: (target: string) => void
|
||||
onViewCommitDiff?: (commitHash: string) => void
|
||||
onUpdateFrontmatter?: (path: string, key: string, value: FrontmatterValue) => Promise<void>
|
||||
@@ -41,6 +42,7 @@ export function Inspector({
|
||||
content,
|
||||
entries,
|
||||
gitHistory,
|
||||
vaultPath,
|
||||
onNavigate,
|
||||
onViewCommitDiff,
|
||||
onUpdateFrontmatter,
|
||||
@@ -96,6 +98,7 @@ export function Inspector({
|
||||
frontmatter={frontmatter}
|
||||
entries={entries}
|
||||
typeEntryMap={typeEntryMap}
|
||||
vaultPath={vaultPath}
|
||||
onNavigate={onNavigate}
|
||||
onAddProperty={onAddProperty ? handleAddProperty : undefined}
|
||||
onUpdateProperty={onUpdateFrontmatter ? handleUpdateProperty : undefined}
|
||||
|
||||
@@ -179,7 +179,7 @@ describe('DynamicRelationshipsPanel', () => {
|
||||
fireEvent.change(screen.getByPlaceholderText('Relationship name'), { target: { value: 'Related to' } })
|
||||
fireEvent.change(screen.getByPlaceholderText('Note title'), { target: { value: 'AI' } })
|
||||
fireEvent.click(screen.getByTestId('submit-add-relationship'))
|
||||
expect(onAddProperty).toHaveBeenCalledWith('Related to', '[[AI]]')
|
||||
expect(onAddProperty).toHaveBeenCalledWith('Related to', '[[topic/ai]]')
|
||||
})
|
||||
|
||||
it('cancels add relationship form', () => {
|
||||
@@ -427,14 +427,14 @@ describe('DynamicRelationshipsPanel', () => {
|
||||
const input = screen.getByTestId('add-relation-ref-input')
|
||||
fireEvent.change(input, { target: { value: 'AI' } })
|
||||
fireEvent.keyDown(input, { key: 'Enter' })
|
||||
expect(onUpdateProperty).toHaveBeenCalledWith('Belongs to', ['[[project/my-project]]', '[[AI]]'])
|
||||
expect(onUpdateProperty).toHaveBeenCalledWith('Belongs to', ['[[project/my-project]]', '[[topic/ai]]'])
|
||||
})
|
||||
|
||||
it('does not add duplicate refs', () => {
|
||||
render(
|
||||
<DynamicRelationshipsPanel
|
||||
typeEntryMap={{}}
|
||||
frontmatter={{ 'Belongs to': ['[[AI]]'] }}
|
||||
frontmatter={{ 'Belongs to': ['[[topic/ai]]'] }}
|
||||
entries={entries}
|
||||
onNavigate={onNavigate}
|
||||
onUpdateProperty={onUpdateProperty}
|
||||
@@ -533,7 +533,7 @@ describe('DynamicRelationshipsPanel', () => {
|
||||
fireEvent.click(screen.getByTestId('create-and-open-option'))
|
||||
expect(onCreateAndOpenNote).toHaveBeenCalledWith('Brand New Note')
|
||||
await vi.waitFor(() => {
|
||||
expect(onUpdateProperty).toHaveBeenCalledWith('Belongs to', ['[[project/my-project]]', '[[Brand New Note]]'])
|
||||
expect(onUpdateProperty).toHaveBeenCalledWith('Belongs to', ['[[project/my-project]]', '[[brand-new-note]]'])
|
||||
})
|
||||
})
|
||||
|
||||
@@ -647,7 +647,7 @@ describe('DynamicRelationshipsPanel', () => {
|
||||
fireEvent.click(screen.getByTestId('create-and-open-option'))
|
||||
expect(onCreateAndOpenNote).toHaveBeenCalledWith('New Person')
|
||||
await vi.waitFor(() => {
|
||||
expect(onAddProperty).toHaveBeenCalledWith('Mentions', '[[New Person]]')
|
||||
expect(onAddProperty).toHaveBeenCalledWith('Mentions', '[[new-person]]')
|
||||
})
|
||||
})
|
||||
})
|
||||
|
||||
@@ -1,7 +1,6 @@
|
||||
import { describe, it, expect, vi } from 'vitest'
|
||||
import { render, screen, act } from '@testing-library/react'
|
||||
import { RawEditorView } from './RawEditorView'
|
||||
import { extractWikilinkQuery, detectYamlError } from '../utils/rawEditorUtils'
|
||||
|
||||
function entry(title: string, path = `/vault/note/${title}.md`) {
|
||||
return {
|
||||
@@ -23,61 +22,6 @@ const defaultProps = {
|
||||
onSave: vi.fn(),
|
||||
}
|
||||
|
||||
describe('extractWikilinkQuery', () => {
|
||||
it('returns null when no [[ trigger', () => {
|
||||
expect(extractWikilinkQuery('hello world', 5)).toBeNull()
|
||||
})
|
||||
|
||||
it('returns empty string immediately after [[', () => {
|
||||
const text = 'see [['
|
||||
expect(extractWikilinkQuery(text, text.length)).toBe('')
|
||||
})
|
||||
|
||||
it('returns query after [[', () => {
|
||||
const text = 'see [[Proj'
|
||||
expect(extractWikilinkQuery(text, text.length)).toBe('Proj')
|
||||
})
|
||||
|
||||
it('returns null when ]] closes the link', () => {
|
||||
const text = '[[Proj]]'
|
||||
expect(extractWikilinkQuery(text, text.length)).toBeNull()
|
||||
})
|
||||
|
||||
it('returns null when newline is in query', () => {
|
||||
const text = '[[Proj\ncontinued'
|
||||
expect(extractWikilinkQuery(text, text.length)).toBeNull()
|
||||
})
|
||||
|
||||
it('handles cursor before end of text', () => {
|
||||
const text = '[[Proj after'
|
||||
expect(extractWikilinkQuery(text, 6)).toBe('Proj')
|
||||
})
|
||||
})
|
||||
|
||||
describe('detectYamlError', () => {
|
||||
it('returns null for content without frontmatter', () => {
|
||||
expect(detectYamlError('# Title\n\nSome content.')).toBeNull()
|
||||
})
|
||||
|
||||
it('returns null for valid frontmatter', () => {
|
||||
expect(detectYamlError('---\ntitle: My Note\n---\n\n# Title')).toBeNull()
|
||||
})
|
||||
|
||||
it('returns error for unclosed frontmatter', () => {
|
||||
const error = detectYamlError('---\ntitle: My Note\n\n# Title')
|
||||
expect(error).toContain('Unclosed frontmatter')
|
||||
})
|
||||
|
||||
it('returns error for tab indentation in frontmatter', () => {
|
||||
const error = detectYamlError('---\n\ttitle: My Note\n---\n')
|
||||
expect(error).toContain('tab indentation')
|
||||
})
|
||||
|
||||
it('returns null for content not starting with ---', () => {
|
||||
expect(detectYamlError('Not frontmatter')).toBeNull()
|
||||
})
|
||||
})
|
||||
|
||||
describe('RawEditorView', () => {
|
||||
it('renders CodeMirror container', () => {
|
||||
render(<RawEditorView {...defaultProps} />)
|
||||
|
||||
@@ -1,22 +1,21 @@
|
||||
import { useRef, useState, useCallback, useEffect, useMemo } from 'react'
|
||||
import { trackEvent } from '../lib/telemetry'
|
||||
import type { EditorView } from '@codemirror/view'
|
||||
import { preFilterWikilinks, deduplicateByPath, MIN_QUERY_LENGTH } from '../utils/wikilinkSuggestions'
|
||||
import { attachClickHandlers, enrichSuggestionItems } from '../utils/suggestionEnrichment'
|
||||
import { MIN_QUERY_LENGTH } from '../utils/wikilinkSuggestions'
|
||||
import { buildTypeEntryMap } from '../utils/typeColors'
|
||||
import { NoteSearchList } from './NoteSearchList'
|
||||
import { extractWikilinkQuery, detectYamlError } from '../utils/rawEditorUtils'
|
||||
import {
|
||||
buildRawEditorAutocompleteState,
|
||||
buildRawEditorBaseItems,
|
||||
detectYamlError,
|
||||
extractWikilinkQuery,
|
||||
getRawEditorDropdownPosition,
|
||||
replaceActiveWikilinkQuery,
|
||||
type RawEditorAutocompleteState,
|
||||
} from '../utils/rawEditorUtils'
|
||||
import { useCodeMirror } from '../hooks/useCodeMirror'
|
||||
import type { WikilinkSuggestionItem } from './WikilinkSuggestionMenu'
|
||||
import type { VaultEntry } from '../types'
|
||||
|
||||
interface AutocompleteState {
|
||||
caretTop: number
|
||||
caretLeft: number
|
||||
selectedIndex: number
|
||||
items: WikilinkSuggestionItem[]
|
||||
}
|
||||
|
||||
export interface RawEditorViewProps {
|
||||
content: string
|
||||
path: string
|
||||
@@ -32,13 +31,6 @@ export interface RawEditorViewProps {
|
||||
const DEBOUNCE_MS = 500
|
||||
const DROPDOWN_MAX_HEIGHT = 200
|
||||
|
||||
function getCursorCoords(view: EditorView): { top: number; left: number } | null {
|
||||
const pos = view.state.selection.main.head
|
||||
const coords = view.coordsAtPos(pos)
|
||||
if (!coords) return null
|
||||
return { top: coords.bottom, left: coords.left }
|
||||
}
|
||||
|
||||
export function RawEditorView({ content, path, entries, onContentChange, onSave, latestContentRef, vaultPath }: RawEditorViewProps) {
|
||||
const containerRef = useRef<HTMLDivElement>(null)
|
||||
const debounceRef = useRef<ReturnType<typeof setTimeout> | null>(null)
|
||||
@@ -52,23 +44,14 @@ export function RawEditorView({ content, path, entries, onContentChange, onSave,
|
||||
useEffect(() => { onContentChangeRef.current = onContentChange }, [onContentChange])
|
||||
useEffect(() => { onSaveRef.current = onSave }, [onSave])
|
||||
|
||||
const [autocomplete, setAutocomplete] = useState<AutocompleteState | null>(null)
|
||||
const [autocomplete, setAutocomplete] = useState<RawEditorAutocompleteState | null>(null)
|
||||
const [yamlError, setYamlError] = useState<string | null>(() => detectYamlError(content))
|
||||
|
||||
const typeEntryMap = useMemo(() => buildTypeEntryMap(entries), [entries])
|
||||
|
||||
const baseItems = useMemo(
|
||||
() => deduplicateByPath(entries.map(entry => ({
|
||||
title: entry.title,
|
||||
aliases: [...new Set([entry.filename.replace(/\.md$/, ''), ...entry.aliases])],
|
||||
group: entry.isA || 'Note',
|
||||
entryTitle: entry.title,
|
||||
path: entry.path,
|
||||
}))),
|
||||
[entries],
|
||||
)
|
||||
const baseItems = useMemo(() => buildRawEditorBaseItems(entries), [entries])
|
||||
|
||||
const insertWikilinkRef = useRef<(entryTitle: string) => void>(() => {})
|
||||
const insertWikilinkRef = useRef<(target: string) => void>(() => {})
|
||||
|
||||
const latestContentRefStable = useRef(latestContentRef)
|
||||
useEffect(() => { latestContentRefStable.current = latestContentRef }, [latestContentRef])
|
||||
@@ -91,12 +74,15 @@ export function RawEditorView({ content, path, entries, onContentChange, onSave,
|
||||
setAutocomplete(null)
|
||||
return
|
||||
}
|
||||
const coords = getCursorCoords(view)
|
||||
if (!coords) { setAutocomplete(null); return }
|
||||
const candidates = preFilterWikilinks(baseItems, query)
|
||||
const withHandlers = attachClickHandlers(candidates, (title: string) => insertWikilinkRef.current(title), vaultPath ?? '')
|
||||
const items = enrichSuggestionItems(withHandlers, query, typeEntryMap)
|
||||
setAutocomplete({ caretTop: coords.top, caretLeft: coords.left, selectedIndex: 0, items })
|
||||
const nextAutocomplete = buildRawEditorAutocompleteState({
|
||||
view,
|
||||
baseItems,
|
||||
query,
|
||||
typeEntryMap,
|
||||
onInsertTarget: (target: string) => insertWikilinkRef.current(target),
|
||||
vaultPath: vaultPath ?? '',
|
||||
})
|
||||
setAutocomplete(nextAutocomplete)
|
||||
}, [baseItems, typeEntryMap, vaultPath])
|
||||
|
||||
const handleSave = useCallback(() => {
|
||||
@@ -120,30 +106,25 @@ export function RawEditorView({ content, path, entries, onContentChange, onSave,
|
||||
onEscape: handleEscape,
|
||||
})
|
||||
|
||||
const insertWikilink = useCallback((entryTitle: string) => {
|
||||
const insertWikilink = useCallback((target: string) => {
|
||||
const view = viewRef.current
|
||||
if (!view) return
|
||||
const cursor = view.state.selection.main.head
|
||||
const doc = view.state.doc.toString()
|
||||
const before = doc.slice(0, cursor)
|
||||
const triggerIdx = before.lastIndexOf('[[')
|
||||
if (triggerIdx === -1) return
|
||||
|
||||
const after = doc.slice(cursor)
|
||||
const newText = `${doc.slice(0, triggerIdx)}[[${entryTitle}]]${after}`
|
||||
const newCursor = triggerIdx + entryTitle.length + 4
|
||||
const replacement = replaceActiveWikilinkQuery(doc, cursor, target)
|
||||
if (!replacement) return
|
||||
|
||||
view.dispatch({
|
||||
changes: { from: 0, to: doc.length, insert: newText },
|
||||
selection: { anchor: newCursor },
|
||||
changes: { from: 0, to: doc.length, insert: replacement.text },
|
||||
selection: { anchor: replacement.cursor },
|
||||
})
|
||||
trackEvent('wikilink_inserted')
|
||||
setAutocomplete(null)
|
||||
|
||||
if (debounceRef.current) clearTimeout(debounceRef.current)
|
||||
debounceRef.current = null
|
||||
latestDocRef.current = newText
|
||||
onContentChangeRef.current(pathRef.current, newText)
|
||||
latestDocRef.current = replacement.text
|
||||
onContentChangeRef.current(pathRef.current, replacement.text)
|
||||
|
||||
view.focus()
|
||||
}, [viewRef])
|
||||
@@ -165,9 +146,9 @@ export function RawEditorView({ content, path, entries, onContentChange, onSave,
|
||||
} else if (e.key === 'Enter') {
|
||||
e.preventDefault()
|
||||
const item = autocomplete.items[autocomplete.selectedIndex]
|
||||
if (item) insertWikilink(item.entryTitle ?? item.title)
|
||||
if (item) item.onItemClick()
|
||||
}
|
||||
}, [autocomplete, insertWikilink])
|
||||
}, [autocomplete])
|
||||
|
||||
// Flush pending debounce on unmount
|
||||
useEffect(() => {
|
||||
@@ -179,15 +160,7 @@ export function RawEditorView({ content, path, entries, onContentChange, onSave,
|
||||
}
|
||||
}, [])
|
||||
|
||||
const dropdownBelow = autocomplete
|
||||
? autocomplete.caretTop + 20 + DROPDOWN_MAX_HEIGHT <= window.innerHeight
|
||||
: true
|
||||
const dropdownTop = autocomplete
|
||||
? (dropdownBelow ? autocomplete.caretTop + 4 : autocomplete.caretTop - DROPDOWN_MAX_HEIGHT - 24)
|
||||
: 0
|
||||
const dropdownLeft = autocomplete
|
||||
? Math.min(autocomplete.caretLeft, window.innerWidth - 260)
|
||||
: 0
|
||||
const dropdownPosition = getRawEditorDropdownPosition(autocomplete, DROPDOWN_MAX_HEIGHT, window)
|
||||
|
||||
return (
|
||||
<div className="flex flex-1 flex-col min-h-0 relative" style={{ background: 'var(--background)' }} onKeyDown={handleAutocompleteKey} role="presentation">
|
||||
@@ -212,8 +185,8 @@ export function RawEditorView({ content, path, entries, onContentChange, onSave,
|
||||
<div
|
||||
className="fixed z-50 min-w-64 max-w-xs rounded-md border shadow-lg overflow-auto"
|
||||
style={{
|
||||
top: dropdownTop,
|
||||
left: dropdownLeft,
|
||||
top: dropdownPosition.top,
|
||||
left: dropdownPosition.left,
|
||||
maxHeight: DROPDOWN_MAX_HEIGHT,
|
||||
background: 'var(--popover)',
|
||||
borderColor: 'var(--border)',
|
||||
@@ -224,7 +197,7 @@ export function RawEditorView({ content, path, entries, onContentChange, onSave,
|
||||
items={autocomplete.items}
|
||||
selectedIndex={autocomplete.selectedIndex}
|
||||
getItemKey={(item, i) => `${item.title}-${item.path ?? i}`}
|
||||
onItemClick={(item) => insertWikilink(item.entryTitle ?? item.title)}
|
||||
onItemClick={(item) => item.onItemClick()}
|
||||
onItemHover={(i) => setAutocomplete(prev => prev ? { ...prev, selectedIndex: i } : null)}
|
||||
/>
|
||||
</div>
|
||||
|
||||
@@ -6,7 +6,12 @@ import { containsWikilinks } from '../DynamicPropertiesPanel'
|
||||
import type { FrontmatterValue } from '../Inspector'
|
||||
import { NoteSearchList } from '../NoteSearchList'
|
||||
import { useNoteSearch } from '../../hooks/useNoteSearch'
|
||||
import { resolveEntry } from '../../utils/wikilink'
|
||||
import {
|
||||
resolveEntry,
|
||||
canonicalWikilinkTargetForEntry,
|
||||
canonicalWikilinkTargetForTitle,
|
||||
formatWikilinkRef,
|
||||
} from '../../utils/wikilink'
|
||||
import { isWikilink, resolveRefProps } from './shared'
|
||||
import { LinkButton } from './LinkButton'
|
||||
|
||||
@@ -15,6 +20,61 @@ function hasExactTitleMatch(entries: VaultEntry[], title: string): boolean {
|
||||
return resolveEntry(entries, title) !== undefined
|
||||
}
|
||||
|
||||
function inferVaultPath(entries: VaultEntry[]): string {
|
||||
if (entries.length === 0) return ''
|
||||
const segments = entries.map((entry) => entry.path.split('/').slice(0, -1))
|
||||
const prefix: string[] = []
|
||||
const maxDepth = Math.min(...segments.map((parts) => parts.length))
|
||||
for (let i = 0; i < maxDepth; i += 1) {
|
||||
const segment = segments[0][i]
|
||||
if (segments.every((parts) => parts[i] === segment)) prefix.push(segment)
|
||||
else break
|
||||
}
|
||||
return prefix.join('/')
|
||||
}
|
||||
|
||||
function canonicalRefForEntry(entry: VaultEntry, vaultPath: string): string {
|
||||
return formatWikilinkRef(canonicalWikilinkTargetForEntry(entry, vaultPath))
|
||||
}
|
||||
|
||||
function canonicalRefForTitle(title: string, entries: VaultEntry[], vaultPath: string): string {
|
||||
return formatWikilinkRef(canonicalWikilinkTargetForTitle(title, entries, vaultPath))
|
||||
}
|
||||
|
||||
function shouldShowSearchDropdown(focused: boolean, trimmed: string, resultCount: number, showCreate: boolean): boolean {
|
||||
return focused && trimmed.length > 0 && (resultCount > 0 || showCreate)
|
||||
}
|
||||
|
||||
function confirmRelationshipSelection({
|
||||
showCreate,
|
||||
selectedIndex,
|
||||
createIndex,
|
||||
trimmed,
|
||||
selectedEntry,
|
||||
onCreate,
|
||||
onSelectEntry,
|
||||
onFallback,
|
||||
}: {
|
||||
showCreate: boolean
|
||||
selectedIndex: number
|
||||
createIndex: number
|
||||
trimmed: string
|
||||
selectedEntry?: VaultEntry
|
||||
onCreate?: (title: string) => void
|
||||
onSelectEntry?: (entry: VaultEntry) => void
|
||||
onFallback?: () => void
|
||||
}): void {
|
||||
if (showCreate && selectedIndex === createIndex && trimmed) {
|
||||
onCreate?.(trimmed)
|
||||
return
|
||||
}
|
||||
if (selectedEntry) {
|
||||
onSelectEntry?.(selectedEntry)
|
||||
return
|
||||
}
|
||||
onFallback?.()
|
||||
}
|
||||
|
||||
/** Shared keyboard navigation for search dropdowns with an optional "create" item. */
|
||||
function useSearchKeyboard(
|
||||
search: ReturnType<typeof useNoteSearch>,
|
||||
@@ -90,7 +150,7 @@ function CreateAndOpenOption({ title, selected, onClick, onHover }: {
|
||||
|
||||
function SearchDropdownWithCreate({ search, onSelect, query, entries, onCreateAndOpen }: {
|
||||
search: ReturnType<typeof useNoteSearch>
|
||||
onSelect: (title: string) => void
|
||||
onSelect: (entry: VaultEntry) => void
|
||||
query: string
|
||||
entries: VaultEntry[]
|
||||
onCreateAndOpen?: (title: string) => void
|
||||
@@ -108,7 +168,7 @@ function SearchDropdownWithCreate({ search, onSelect, query, entries, onCreateAn
|
||||
items={search.results}
|
||||
selectedIndex={search.selectedIndex}
|
||||
getItemKey={(item) => item.entry.path}
|
||||
onItemClick={(item) => onSelect(item.entry.title)}
|
||||
onItemClick={(item) => onSelect(item.entry)}
|
||||
onItemHover={(i) => search.setSelectedIndex(i)}
|
||||
className="max-h-[160px] overflow-y-auto"
|
||||
/>
|
||||
@@ -125,11 +185,12 @@ function SearchDropdownWithCreate({ search, onSelect, query, entries, onCreateAn
|
||||
)
|
||||
}
|
||||
|
||||
function InlineAddNote({ entries, onAdd, onCreateAndOpenNote }: {
|
||||
entries: VaultEntry[]
|
||||
onAdd: (noteTitle: string) => void
|
||||
onCreateAndOpenNote?: (title: string) => Promise<boolean>
|
||||
}) {
|
||||
function useInlineAddNoteState(
|
||||
entries: VaultEntry[],
|
||||
vaultPath: string,
|
||||
onAdd: (ref: string) => void,
|
||||
onCreateAndOpenNote?: (title: string) => Promise<boolean>,
|
||||
) {
|
||||
const [active, setActive] = useState(false)
|
||||
const [query, setQuery] = useState('')
|
||||
const inputRef = useRef<HTMLInputElement>(null)
|
||||
@@ -138,25 +199,82 @@ function InlineAddNote({ entries, onAdd, onCreateAndOpenNote }: {
|
||||
const trimmed = query.trim()
|
||||
const { showCreate, createIndex, totalItems } = useCreateOption(entries, trimmed, search.results.length, !!onCreateAndOpenNote)
|
||||
|
||||
const dismiss = useCallback(() => { setQuery(''); setActive(false) }, [])
|
||||
const dismiss = useCallback(() => {
|
||||
setQuery('')
|
||||
setActive(false)
|
||||
}, [])
|
||||
|
||||
const selectAndClose = useCallback((title: string) => {
|
||||
onAdd(title)
|
||||
const selectAndClose = useCallback((ref: string) => {
|
||||
onAdd(ref)
|
||||
dismiss()
|
||||
}, [onAdd, dismiss])
|
||||
|
||||
const handleCreateAndOpen = useCreateAndOpen(onCreateAndOpenNote, onAdd, dismiss)
|
||||
const selectEntryAndClose = useCallback((entry: VaultEntry) => {
|
||||
selectAndClose(canonicalRefForEntry(entry, vaultPath))
|
||||
}, [selectAndClose, vaultPath])
|
||||
|
||||
const handleCreateAndOpen = useCreateAndOpen(
|
||||
onCreateAndOpenNote,
|
||||
(title) => onAdd(canonicalRefForTitle(title, entries, vaultPath)),
|
||||
dismiss,
|
||||
)
|
||||
|
||||
const handleFallback = useCallback(() => {
|
||||
if (!trimmed) return
|
||||
selectAndClose(canonicalRefForTitle(trimmed, entries, vaultPath))
|
||||
}, [trimmed, selectAndClose, entries, vaultPath])
|
||||
|
||||
const handleConfirm = useCallback(() => {
|
||||
if (showCreate && search.selectedIndex === createIndex) {
|
||||
handleCreateAndOpen(trimmed)
|
||||
return
|
||||
}
|
||||
const title = search.selectedEntry?.title ?? trimmed
|
||||
if (title) selectAndClose(title)
|
||||
}, [search.selectedEntry, search.selectedIndex, trimmed, selectAndClose, showCreate, createIndex, handleCreateAndOpen])
|
||||
confirmRelationshipSelection({
|
||||
showCreate,
|
||||
selectedIndex: search.selectedIndex,
|
||||
createIndex,
|
||||
trimmed,
|
||||
selectedEntry: search.selectedEntry,
|
||||
onCreate: handleCreateAndOpen,
|
||||
onSelectEntry: selectEntryAndClose,
|
||||
onFallback: handleFallback,
|
||||
})
|
||||
}, [showCreate, search.selectedIndex, search.selectedEntry, createIndex, trimmed, handleCreateAndOpen, selectEntryAndClose, handleFallback])
|
||||
|
||||
const handleKeyDown = useSearchKeyboard(search, totalItems, handleConfirm, dismiss)
|
||||
const showDropdown = shouldShowSearchDropdown(active, trimmed, search.results.length, showCreate)
|
||||
|
||||
return {
|
||||
active,
|
||||
setActive,
|
||||
query,
|
||||
setQuery,
|
||||
inputRef,
|
||||
search,
|
||||
dismiss,
|
||||
handleKeyDown,
|
||||
showDropdown,
|
||||
selectEntryAndClose,
|
||||
showCreate,
|
||||
handleCreateAndOpen,
|
||||
}
|
||||
}
|
||||
|
||||
function InlineAddNote({ entries, vaultPath, onAdd, onCreateAndOpenNote }: {
|
||||
entries: VaultEntry[]
|
||||
vaultPath: string
|
||||
onAdd: (ref: string) => void
|
||||
onCreateAndOpenNote?: (title: string) => Promise<boolean>
|
||||
}) {
|
||||
const {
|
||||
active,
|
||||
setActive,
|
||||
query,
|
||||
setQuery,
|
||||
inputRef,
|
||||
search,
|
||||
dismiss,
|
||||
handleKeyDown,
|
||||
showDropdown,
|
||||
selectEntryAndClose,
|
||||
handleCreateAndOpen,
|
||||
} = useInlineAddNoteState(entries, vaultPath, onAdd, onCreateAndOpenNote)
|
||||
|
||||
if (!active) {
|
||||
return (
|
||||
@@ -171,8 +289,6 @@ function InlineAddNote({ entries, onAdd, onCreateAndOpenNote }: {
|
||||
)
|
||||
}
|
||||
|
||||
const showDropdown = trimmed.length > 0 && (search.results.length > 0 || showCreate)
|
||||
|
||||
return (
|
||||
<div className="relative mt-1">
|
||||
<div className="group/add relative flex items-center">
|
||||
@@ -197,7 +313,7 @@ function InlineAddNote({ entries, onAdd, onCreateAndOpenNote }: {
|
||||
{showDropdown && (
|
||||
<SearchDropdownWithCreate
|
||||
search={search}
|
||||
onSelect={selectAndClose}
|
||||
onSelect={selectEntryAndClose}
|
||||
query={query}
|
||||
entries={entries}
|
||||
onCreateAndOpen={onCreateAndOpenNote ? (title) => { handleCreateAndOpen(title) } : undefined}
|
||||
@@ -207,11 +323,11 @@ function InlineAddNote({ entries, onAdd, onCreateAndOpenNote }: {
|
||||
)
|
||||
}
|
||||
|
||||
function RelationshipGroup({ label, refs, entries, typeEntryMap, onNavigate, onRemoveRef, onAddRef, onCreateAndOpenNote }: {
|
||||
label: string; refs: string[]; entries: VaultEntry[]; typeEntryMap: Record<string, VaultEntry>
|
||||
function RelationshipGroup({ label, refs, entries, typeEntryMap, vaultPath, onNavigate, onRemoveRef, onAddRef, onCreateAndOpenNote }: {
|
||||
label: string; refs: string[]; entries: VaultEntry[]; typeEntryMap: Record<string, VaultEntry>; vaultPath: string
|
||||
onNavigate: (target: string) => void
|
||||
onRemoveRef?: (ref: string) => void
|
||||
onAddRef?: (noteTitle: string) => void
|
||||
onAddRef?: (ref: string) => void
|
||||
onCreateAndOpenNote?: (title: string) => Promise<boolean>
|
||||
}) {
|
||||
if (refs.length === 0) return null
|
||||
@@ -234,6 +350,7 @@ function RelationshipGroup({ label, refs, entries, typeEntryMap, onNavigate, onR
|
||||
{onAddRef && (
|
||||
<InlineAddNote
|
||||
entries={entries}
|
||||
vaultPath={vaultPath}
|
||||
onAdd={onAddRef}
|
||||
onCreateAndOpenNote={onCreateAndOpenNote}
|
||||
/>
|
||||
@@ -269,22 +386,30 @@ function NoteTargetInput({ entries, value, onChange, onSubmit, onCancel, onCreat
|
||||
const trimmed = value.trim()
|
||||
const { showCreate, createIndex, totalItems } = useCreateOption(entries, trimmed, search.results.length, !!onCreateAndOpenNote)
|
||||
|
||||
const handleConfirm = useCallback(() => {
|
||||
if (showCreate && search.selectedIndex === createIndex) {
|
||||
onSubmitWithCreate?.(trimmed)
|
||||
} else if (search.selectedEntry) {
|
||||
onChange(search.selectedEntry.title)
|
||||
setFocused(false)
|
||||
} else {
|
||||
onSubmit?.()
|
||||
}
|
||||
}, [showCreate, search.selectedIndex, search.selectedEntry, createIndex, trimmed, onChange, onSubmit, onSubmitWithCreate])
|
||||
const selectEntry = useCallback((entry: VaultEntry) => {
|
||||
onChange(entry.title)
|
||||
setFocused(false)
|
||||
}, [onChange])
|
||||
|
||||
const handleEscape = useCallback(() => { onCancel?.() }, [onCancel])
|
||||
const handleConfirm = useCallback(() => {
|
||||
confirmRelationshipSelection({
|
||||
showCreate,
|
||||
selectedIndex: search.selectedIndex,
|
||||
createIndex,
|
||||
trimmed,
|
||||
selectedEntry: search.selectedEntry,
|
||||
onCreate: onSubmitWithCreate,
|
||||
onSelectEntry: selectEntry,
|
||||
onFallback: onSubmit,
|
||||
})
|
||||
}, [showCreate, search.selectedIndex, search.selectedEntry, createIndex, trimmed, onSubmitWithCreate, selectEntry, onSubmit])
|
||||
|
||||
const handleEscape = useCallback(() => {
|
||||
onCancel?.()
|
||||
}, [onCancel])
|
||||
|
||||
const handleKeyDown = useSearchKeyboard(search, totalItems, handleConfirm, handleEscape)
|
||||
|
||||
const showDropdown = focused && trimmed.length > 0 && (search.results.length > 0 || showCreate)
|
||||
const showDropdown = shouldShowSearchDropdown(focused, trimmed, search.results.length, showCreate)
|
||||
|
||||
return (
|
||||
<div className="relative">
|
||||
@@ -301,7 +426,7 @@ function NoteTargetInput({ entries, value, onChange, onSubmit, onCancel, onCreat
|
||||
{showDropdown && (
|
||||
<SearchDropdownWithCreate
|
||||
search={search}
|
||||
onSelect={(title) => { onChange(title); setFocused(false) }}
|
||||
onSelect={selectEntry}
|
||||
query={value}
|
||||
entries={entries}
|
||||
onCreateAndOpen={onCreateAndOpenNote ? (title) => onSubmitWithCreate?.(title) : undefined}
|
||||
@@ -311,8 +436,56 @@ function NoteTargetInput({ entries, value, onChange, onSubmit, onCancel, onCreat
|
||||
)
|
||||
}
|
||||
|
||||
function AddRelationshipForm({ entries, onAddProperty, onCreateAndOpenNote }: {
|
||||
function useRelationshipPanelState(
|
||||
frontmatter: ParsedFrontmatter,
|
||||
entries: VaultEntry[],
|
||||
vaultPath: string | undefined,
|
||||
onAddProperty?: (key: string, value: FrontmatterValue) => void,
|
||||
onUpdateProperty?: (key: string, value: FrontmatterValue) => void,
|
||||
onDeleteProperty?: (key: string) => void,
|
||||
) {
|
||||
const relationshipEntries = useMemo(() => extractRelationshipRefs(frontmatter), [frontmatter])
|
||||
const resolvedVaultPath = useMemo(() => vaultPath ?? inferVaultPath(entries), [vaultPath, entries])
|
||||
|
||||
const handleRemoveRef = useCallback((key: string, refToRemove: string) => {
|
||||
if (!onUpdateProperty || !onDeleteProperty) return
|
||||
const group = relationshipEntries.find(g => g.key === key)
|
||||
if (!group) return
|
||||
const result = updateRefsForRemoval(group.refs, refToRemove)
|
||||
if (result === null) onDeleteProperty(key)
|
||||
else onUpdateProperty(key, result)
|
||||
}, [relationshipEntries, onUpdateProperty, onDeleteProperty])
|
||||
|
||||
const handleAddRef = useCallback((key: string, ref: string) => {
|
||||
if (!onUpdateProperty) return
|
||||
const existing = relationshipEntries.find(g => g.key === key)?.refs ?? []
|
||||
const result = updateRefsForAddition(existing, ref)
|
||||
if (result !== false) onUpdateProperty(key, result)
|
||||
}, [relationshipEntries, onUpdateProperty])
|
||||
|
||||
const canEdit = !!onUpdateProperty && !!onDeleteProperty
|
||||
const existingRelKeys = useMemo(
|
||||
() => new Set(relationshipEntries.map(g => g.key.toLowerCase())),
|
||||
[relationshipEntries],
|
||||
)
|
||||
const missingSuggestedRels = useMemo(
|
||||
() => (onAddProperty ? SUGGESTED_RELATIONSHIPS.filter(r => !existingRelKeys.has(r.toLowerCase())) : []),
|
||||
[onAddProperty, existingRelKeys],
|
||||
)
|
||||
|
||||
return {
|
||||
relationshipEntries,
|
||||
resolvedVaultPath,
|
||||
handleRemoveRef,
|
||||
handleAddRef,
|
||||
canEdit,
|
||||
missingSuggestedRels,
|
||||
}
|
||||
}
|
||||
|
||||
function AddRelationshipForm({ entries, vaultPath, onAddProperty, onCreateAndOpenNote }: {
|
||||
entries: VaultEntry[]
|
||||
vaultPath: string
|
||||
onAddProperty: (key: string, value: FrontmatterValue) => void
|
||||
onCreateAndOpenNote?: (title: string) => Promise<boolean>
|
||||
}) {
|
||||
@@ -327,16 +500,16 @@ function AddRelationshipForm({ entries, onAddProperty, onCreateAndOpenNote }: {
|
||||
|
||||
const submitForm = useCallback((targetOverride?: string) => {
|
||||
const key = relKey.trim()
|
||||
const target = (targetOverride ?? relTarget).trim()
|
||||
if (!key || !target) return
|
||||
onAddProperty(key, `[[${target}]]`)
|
||||
const rawTarget = (targetOverride ?? relTarget).trim()
|
||||
if (!key || !rawTarget) return
|
||||
onAddProperty(key, canonicalRefForTitle(rawTarget, entries, vaultPath))
|
||||
resetForm()
|
||||
}, [relKey, relTarget, onAddProperty, resetForm])
|
||||
}, [relKey, relTarget, entries, vaultPath, onAddProperty, resetForm])
|
||||
|
||||
const addPropertyForKey = useCallback((title: string) => {
|
||||
const key = relKey.trim()
|
||||
if (key) onAddProperty(key, `[[${title}]]`)
|
||||
}, [relKey, onAddProperty])
|
||||
if (key) onAddProperty(key, canonicalRefForTitle(title, entries, vaultPath))
|
||||
}, [relKey, entries, vaultPath, onAddProperty])
|
||||
|
||||
const handleCreateAndSubmit = useCreateAndOpen(onCreateAndOpenNote, addPropertyForKey, resetForm)
|
||||
|
||||
@@ -381,10 +554,9 @@ function updateRefsForRemoval(refs: string[], refToRemove: string): FrontmatterV
|
||||
return remaining.length === 1 ? remaining[0] : remaining
|
||||
}
|
||||
|
||||
function updateRefsForAddition(refs: string[], noteTitle: string): FrontmatterValue | false {
|
||||
const newRef = `[[${noteTitle}]]`
|
||||
if (refs.includes(newRef)) return false
|
||||
const updated = [...refs, newRef]
|
||||
function updateRefsForAddition(refs: string[], refToAdd: string): FrontmatterValue | false {
|
||||
if (refs.includes(refToAdd)) return false
|
||||
const updated = [...refs, refToAdd]
|
||||
return updated.length === 1 ? updated[0] : updated
|
||||
}
|
||||
|
||||
@@ -396,10 +568,11 @@ function DisabledLinkButton() {
|
||||
|
||||
const SUGGESTED_RELATIONSHIPS = ['Belongs to', 'Related to', 'Has'] as const
|
||||
|
||||
function SuggestedRelationshipSlot({ label, entries, onAdd, onCreateAndOpenNote }: {
|
||||
function SuggestedRelationshipSlot({ label, entries, vaultPath, onAdd, onCreateAndOpenNote }: {
|
||||
label: string
|
||||
entries: VaultEntry[]
|
||||
onAdd: (noteTitle: string) => void
|
||||
vaultPath: string
|
||||
onAdd: (ref: string) => void
|
||||
onCreateAndOpenNote?: (title: string) => Promise<boolean>
|
||||
}) {
|
||||
return (
|
||||
@@ -407,6 +580,7 @@ function SuggestedRelationshipSlot({ label, entries, onAdd, onCreateAndOpenNote
|
||||
<span className="mb-1 block text-[12px] text-muted-foreground/50">{label}</span>
|
||||
<InlineAddNote
|
||||
entries={entries}
|
||||
vaultPath={vaultPath}
|
||||
onAdd={onAdd}
|
||||
onCreateAndOpenNote={onCreateAndOpenNote}
|
||||
/>
|
||||
@@ -414,50 +588,30 @@ function SuggestedRelationshipSlot({ label, entries, onAdd, onCreateAndOpenNote
|
||||
)
|
||||
}
|
||||
|
||||
export function DynamicRelationshipsPanel({ frontmatter, entries, typeEntryMap, onNavigate, onAddProperty, onUpdateProperty, onDeleteProperty, onCreateAndOpenNote }: {
|
||||
frontmatter: ParsedFrontmatter; entries: VaultEntry[]; typeEntryMap: Record<string, VaultEntry>
|
||||
export function DynamicRelationshipsPanel({ frontmatter, entries, typeEntryMap, vaultPath, onNavigate, onAddProperty, onUpdateProperty, onDeleteProperty, onCreateAndOpenNote }: {
|
||||
frontmatter: ParsedFrontmatter; entries: VaultEntry[]; typeEntryMap: Record<string, VaultEntry>; vaultPath?: string
|
||||
onNavigate: (target: string) => void
|
||||
onAddProperty?: (key: string, value: FrontmatterValue) => void
|
||||
onUpdateProperty?: (key: string, value: FrontmatterValue) => void
|
||||
onDeleteProperty?: (key: string) => void
|
||||
onCreateAndOpenNote?: (title: string) => Promise<boolean>
|
||||
}) {
|
||||
const relationshipEntries = useMemo(() => extractRelationshipRefs(frontmatter), [frontmatter])
|
||||
|
||||
const handleRemoveRef = useCallback((key: string, refToRemove: string) => {
|
||||
if (!onUpdateProperty || !onDeleteProperty) return
|
||||
const group = relationshipEntries.find(g => g.key === key)
|
||||
if (!group) return
|
||||
const result = updateRefsForRemoval(group.refs, refToRemove)
|
||||
if (result === null) onDeleteProperty(key)
|
||||
else onUpdateProperty(key, result)
|
||||
}, [relationshipEntries, onUpdateProperty, onDeleteProperty])
|
||||
|
||||
const handleAddRef = useCallback((key: string, noteTitle: string) => {
|
||||
if (!onUpdateProperty) return
|
||||
const existing = relationshipEntries.find(g => g.key === key)?.refs ?? []
|
||||
const result = updateRefsForAddition(existing, noteTitle)
|
||||
if (result !== false) onUpdateProperty(key, result)
|
||||
}, [relationshipEntries, onUpdateProperty])
|
||||
|
||||
const canEdit = !!onUpdateProperty && !!onDeleteProperty
|
||||
|
||||
const existingRelKeys = useMemo(
|
||||
() => new Set(relationshipEntries.map(g => g.key.toLowerCase())),
|
||||
[relationshipEntries],
|
||||
)
|
||||
|
||||
const missingSuggestedRels = onAddProperty
|
||||
? SUGGESTED_RELATIONSHIPS.filter(r => !existingRelKeys.has(r.toLowerCase()))
|
||||
: []
|
||||
const {
|
||||
relationshipEntries,
|
||||
resolvedVaultPath,
|
||||
handleRemoveRef,
|
||||
handleAddRef,
|
||||
canEdit,
|
||||
missingSuggestedRels,
|
||||
} = useRelationshipPanelState(frontmatter, entries, vaultPath, onAddProperty, onUpdateProperty, onDeleteProperty)
|
||||
|
||||
return (
|
||||
<div>
|
||||
{relationshipEntries.map(({ key, refs }) => (
|
||||
<RelationshipGroup
|
||||
key={key} label={key} refs={refs} entries={entries} typeEntryMap={typeEntryMap} onNavigate={onNavigate}
|
||||
key={key} label={key} refs={refs} entries={entries} typeEntryMap={typeEntryMap} vaultPath={resolvedVaultPath} onNavigate={onNavigate}
|
||||
onRemoveRef={canEdit ? (ref) => handleRemoveRef(key, ref) : undefined}
|
||||
onAddRef={canEdit ? (noteTitle) => handleAddRef(key, noteTitle) : undefined}
|
||||
onAddRef={canEdit ? (ref) => handleAddRef(key, ref) : undefined}
|
||||
onCreateAndOpenNote={canEdit ? onCreateAndOpenNote : undefined}
|
||||
/>
|
||||
))}
|
||||
@@ -466,12 +620,13 @@ export function DynamicRelationshipsPanel({ frontmatter, entries, typeEntryMap,
|
||||
key={label}
|
||||
label={label}
|
||||
entries={entries}
|
||||
onAdd={(noteTitle) => onAddProperty!(label, `[[${noteTitle}]]`)}
|
||||
vaultPath={resolvedVaultPath}
|
||||
onAdd={(ref) => onAddProperty!(label, ref)}
|
||||
onCreateAndOpenNote={onCreateAndOpenNote}
|
||||
/>
|
||||
))}
|
||||
{onAddProperty
|
||||
? <AddRelationshipForm entries={entries} onAddProperty={onAddProperty} onCreateAndOpenNote={onCreateAndOpenNote} />
|
||||
? <AddRelationshipForm entries={entries} vaultPath={resolvedVaultPath} onAddProperty={onAddProperty} onCreateAndOpenNote={onCreateAndOpenNote} />
|
||||
: <DisabledLinkButton />
|
||||
}
|
||||
</div>
|
||||
|
||||
@@ -11,10 +11,10 @@ function targetMatchesEntry(target: string, entryPath: string, matchTargets: Set
|
||||
return false
|
||||
}
|
||||
|
||||
function refsMatchTargets(refs: string[], targets: Set<string>): boolean {
|
||||
function refsMatchTargets(refs: string[], entryPath: string, targets: Set<string>): boolean {
|
||||
return refs.some((ref) => {
|
||||
const target = wikilinkTarget(ref)
|
||||
return targets.has(target) || targets.has(target.split('/').pop() ?? '')
|
||||
return targetMatchesEntry(target, entryPath, targets)
|
||||
})
|
||||
}
|
||||
|
||||
@@ -29,7 +29,7 @@ export function useReferencedBy(entry: VaultEntry | null, entries: VaultEntry[])
|
||||
for (const other of entries) {
|
||||
if (other.path === entry.path) continue
|
||||
for (const [key, refs] of Object.entries(other.relationships)) {
|
||||
if (key !== 'Type' && refsMatchTargets(refs, matchTargets)) {
|
||||
if (key !== 'Type' && refsMatchTargets(refs, entry.path, matchTargets)) {
|
||||
results.push({ entry: other, viaKey: key })
|
||||
}
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user