feat: add @mention autocomplete for Person entries

- Type @ in editor to trigger person-specific autocomplete
- Filters vault entries to show only Person type notes
- Inserts standard [[Person Name]] wikilink on selection
- Lower query threshold (1 char vs 2) since person list is smaller
- Add 3 more Person entries to mock data for testing
- Reuses existing WikilinkSuggestionMenu component

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
This commit is contained in:
lucaronin
2026-02-25 13:39:10 +01:00
parent d2cb395f65
commit f047f8e3c9
5 changed files with 179 additions and 1 deletions

View File

@@ -28,13 +28,19 @@ vi.mock('@blocknote/core/extensions', () => ({
filterSuggestionItems: (...args: unknown[]) => mockFilterSuggestionItems(...args),
}))
// eslint-disable-next-line @typescript-eslint/no-explicit-any -- mock
const capturedGetItemsByTrigger: Record<string, (query: string) => Promise<any[]>> = {}
// eslint-disable-next-line @typescript-eslint/no-explicit-any -- mock
let capturedGetItems: ((query: string) => Promise<any[]>) | null = null
vi.mock('@blocknote/react', () => ({
createReactInlineContentSpec: () => ({ render: () => null }),
useCreateBlockNote: () => mockEditor,
// eslint-disable-next-line @typescript-eslint/no-explicit-any -- mock
SuggestionMenuController: (props: any) => { capturedGetItems = props.getItems; return null },
SuggestionMenuController: (props: any) => {
capturedGetItemsByTrigger[props.triggerCharacter] = props.getItems
if (props.triggerCharacter === '[[') capturedGetItems = props.getItems
return null
},
}))
vi.mock('@blocknote/mantine', () => ({

View File

@@ -15,6 +15,7 @@ import { BreadcrumbBar } from './BreadcrumbBar'
import { useEditorTheme } from '../hooks/useTheme'
import { splitFrontmatter, preProcessWikilinks, injectWikilinks, restoreWikilinksInBlocks, countWords } from '../utils/wikilinks'
import { preFilterWikilinks, deduplicateByPath, disambiguateTitles, MAX_RESULTS, MIN_QUERY_LENGTH } from '../utils/wikilinkSuggestions'
import { filterPersonMentions, PERSON_MENTION_MIN_QUERY } from '../utils/personMentionSuggestions'
import { resolveWikilinkColor as resolveColor } from '../utils/wikilinkColors'
import { getTypeColor } from '../utils/typeColors'
import { WikilinkSuggestionMenu, type WikilinkSuggestionItem } from './WikilinkSuggestionMenu'
@@ -169,6 +170,31 @@ function SingleEditorView({ editor, entries, onNavigateWikilink, onChange }: { e
}))
}, [baseItems, editor])
const getPersonMentionItems = useCallback(async (query: string): Promise<WikilinkSuggestionItem[]> => {
if (query.length < PERSON_MENTION_MIN_QUERY) return []
const candidates = filterPersonMentions(baseItems, query)
const items = candidates.map(item => ({
...item,
onItemClick: () => {
editor.insertInlineContent([
{
type: 'wikilink' as const,
props: { target: item.entryTitle },
},
" ",
])
},
}))
const filtered = filterSuggestionItems(items, query).slice(0, MAX_RESULTS)
const final = disambiguateTitles(deduplicateByPath(filtered))
return final.map(({ group, ...rest }) => ({
...rest,
noteType: group,
typeColor: getTypeColor(group),
}))
}, [baseItems, editor])
return (
<div ref={containerRef} className={`editor__blocknote-container${isDragOver ? ' editor__blocknote-container--drag-over' : ''}`} style={cssVars as React.CSSProperties}>
{isDragOver && (
@@ -187,6 +213,12 @@ function SingleEditorView({ editor, entries, onNavigateWikilink, onChange }: { e
suggestionMenuComponent={WikilinkSuggestionMenu}
onItemClick={(item: WikilinkSuggestionItem) => item.onItemClick()}
/>
<SuggestionMenuController
triggerCharacter="@"
getItems={getPersonMentionItems}
suggestionMenuComponent={WikilinkSuggestionMenu}
onItemClick={(item: WikilinkSuggestionItem) => item.onItemClick()}
/>
</BlockNoteView>
</div>
)

View File

@@ -432,6 +432,49 @@ Belongs to:
# AI Agents Primer
AI agents are autonomous systems that can plan, execute, and adapt to achieve goals.
`,
'/Users/luca/Laputa/person/maria-bianchi.md': `---
title: Maria Bianchi
type: Person
aliases:
- Maria
---
# Maria Bianchi
## Role
Product designer — leads UX research and design sprints for the app.
## Contact
- Email: maria@example.com
- Slack: @maria
`,
'/Users/luca/Laputa/person/marco-verdi.md': `---
title: Marco Verdi
type: Person
aliases:
- Marco
---
# Marco Verdi
## Role
Frontend engineer — focuses on React performance and accessibility.
## Contact
- Email: marco@example.com
`,
'/Users/luca/Laputa/person/elena-russo.md': `---
title: Elena Russo
type: Person
aliases:
- Elena
---
# Elena Russo
## Role
Content strategist — plans newsletter topics and manages the editorial calendar.
`,
'/Users/luca/Laputa/type/project.md': `---
type: Type

View File

@@ -250,6 +250,81 @@ export const MOCK_ENTRIES: VaultEntry[] = [
color: null,
order: null,
},
{
path: '/Users/luca/Laputa/person/maria-bianchi.md',
filename: 'maria-bianchi.md',
title: 'Maria Bianchi',
isA: 'Person',
aliases: ['Maria'],
belongsTo: [],
relatedTo: [],
status: null,
owner: null,
cadence: null,
archived: false,
trashed: false,
trashedAt: null,
modifiedAt: now - 86400 * 3,
createdAt: now - 86400 * 150,
fileSize: 280,
snippet: 'Product designer — leads UX research and design sprints for the app.',
relationships: {
'Type': ['[[type/person]]'],
},
icon: null,
color: null,
order: null,
},
{
path: '/Users/luca/Laputa/person/marco-verdi.md',
filename: 'marco-verdi.md',
title: 'Marco Verdi',
isA: 'Person',
aliases: ['Marco'],
belongsTo: [],
relatedTo: [],
status: null,
owner: null,
cadence: null,
archived: false,
trashed: false,
trashedAt: null,
modifiedAt: now - 86400 * 5,
createdAt: now - 86400 * 120,
fileSize: 240,
snippet: 'Frontend engineer — focuses on React performance and accessibility.',
relationships: {
'Type': ['[[type/person]]'],
},
icon: null,
color: null,
order: null,
},
{
path: '/Users/luca/Laputa/person/elena-russo.md',
filename: 'elena-russo.md',
title: 'Elena Russo',
isA: 'Person',
aliases: ['Elena'],
belongsTo: [],
relatedTo: [],
status: null,
owner: null,
cadence: null,
archived: false,
trashed: false,
trashedAt: null,
modifiedAt: now - 86400 * 10,
createdAt: now - 86400 * 90,
fileSize: 200,
snippet: 'Content strategist — plans newsletter topics and manages the editorial calendar.',
relationships: {
'Type': ['[[type/person]]'],
},
icon: null,
color: null,
order: null,
},
{
path: '/Users/luca/Laputa/event/2026-02-14-laputa-app-kickoff.md',
filename: '2026-02-14-laputa-app-kickoff.md',

View File

@@ -0,0 +1,22 @@
import type { WikilinkBaseItem } from './wikilinkSuggestions'
/** Person mentions need only 1 character to start suggesting (smaller candidate set). */
export const PERSON_MENTION_MIN_QUERY = 1
/**
* Pre-filter person mention candidates: only items whose group is 'Person',
* matched by case-insensitive substring on title or aliases.
*/
export function filterPersonMentions<T extends WikilinkBaseItem>(
items: T[],
query: string,
): T[] {
if (query.length < PERSON_MENTION_MIN_QUERY) return []
const lowerQuery = query.toLowerCase()
return items.filter(item =>
item.group === 'Person' && (
item.title.toLowerCase().includes(lowerQuery) ||
item.aliases.some(a => a.toLowerCase().includes(lowerQuery))
)
)
}