From f047f8e3c9fdf64cc6d3138de98f4207f32e8e6e Mon Sep 17 00:00:00 2001 From: lucaronin Date: Wed, 25 Feb 2026 13:39:10 +0100 Subject: [PATCH] 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 --- src/components/Editor.test.tsx | 8 ++- src/components/Editor.tsx | 32 ++++++++++++ src/mock-tauri/mock-content.ts | 43 +++++++++++++++ src/mock-tauri/mock-entries.ts | 75 +++++++++++++++++++++++++++ src/utils/personMentionSuggestions.ts | 22 ++++++++ 5 files changed, 179 insertions(+), 1 deletion(-) create mode 100644 src/utils/personMentionSuggestions.ts diff --git a/src/components/Editor.test.tsx b/src/components/Editor.test.tsx index 996409fd..d0fe66a5 100644 --- a/src/components/Editor.test.tsx +++ b/src/components/Editor.test.tsx @@ -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 Promise> = {} // eslint-disable-next-line @typescript-eslint/no-explicit-any -- mock let capturedGetItems: ((query: string) => Promise) | 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', () => ({ diff --git a/src/components/Editor.tsx b/src/components/Editor.tsx index 1c70f872..366d2124 100644 --- a/src/components/Editor.tsx +++ b/src/components/Editor.tsx @@ -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 => { + 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 (
{isDragOver && ( @@ -187,6 +213,12 @@ function SingleEditorView({ editor, entries, onNavigateWikilink, onChange }: { e suggestionMenuComponent={WikilinkSuggestionMenu} onItemClick={(item: WikilinkSuggestionItem) => item.onItemClick()} /> + item.onItemClick()} + />
) diff --git a/src/mock-tauri/mock-content.ts b/src/mock-tauri/mock-content.ts index 02fe0dd3..24e4a038 100644 --- a/src/mock-tauri/mock-content.ts +++ b/src/mock-tauri/mock-content.ts @@ -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 diff --git a/src/mock-tauri/mock-entries.ts b/src/mock-tauri/mock-entries.ts index 5a07f134..4ad72f0a 100644 --- a/src/mock-tauri/mock-entries.ts +++ b/src/mock-tauri/mock-entries.ts @@ -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', diff --git a/src/utils/personMentionSuggestions.ts b/src/utils/personMentionSuggestions.ts new file mode 100644 index 00000000..4b2abd60 --- /dev/null +++ b/src/utils/personMentionSuggestions.ts @@ -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( + 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)) + ) + ) +}