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:
@@ -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', () => ({
|
||||
|
||||
@@ -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>
|
||||
)
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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',
|
||||
|
||||
22
src/utils/personMentionSuggestions.ts
Normal file
22
src/utils/personMentionSuggestions.ts
Normal 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))
|
||||
)
|
||||
)
|
||||
}
|
||||
Reference in New Issue
Block a user