From 62c1ccf0b457946814946f39f01a7cd35e6c6ab6 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Cl=C3=A9ment=20Pons?= Date: Mon, 3 Nov 2025 11:20:56 +0100 Subject: [PATCH] Add link autocompletion (limited) --- app.vue | 52 +++++++++++++++++++++++++++ db.sqlite | Bin 761856 -> 761856 bytes shared/content.util.ts | 13 +++---- shared/editor.util.ts | 13 +++++-- shared/grammar/wikilink.extension.ts | 49 +++++++++++++++++++++++++ 5 files changed, 118 insertions(+), 9 deletions(-) diff --git a/app.vue b/app.vue index 9d8a99c..bb7c8cf 100644 --- a/app.vue +++ b/app.vue @@ -191,6 +191,58 @@ iconify-icon @apply font-sans; } +.cm-tooltip-autocomplete { + @apply max-w-[400px]; + @apply !bg-light-20; + @apply dark:!bg-dark-20; + @apply !border-light-40; + @apply dark:!border-dark-40; +} + +/* .cm-tooltip-autocomplete > ul { + @apply p-1; +} */ + +.cm-tooltip-autocomplete > ul > li { + @apply flex; + @apply flex-col; + @apply !py-1; + @apply hover:bg-light-30; + @apply dark:hover:bg-dark-30; +} + +.cm-tooltip-autocomplete > ul > li[aria-selected] { + @apply !bg-light-35; + @apply dark:!bg-dark-35; +} + +.cm-completionIcon { + @apply !hidden; +} + +.cm-completionLabel { + @apply px-4; + @apply font-sans; + @apply font-normal; + @apply text-base; + @apply text-light-100; + @apply dark:text-dark-100; +} + +.cm-completionMatchedText { + @apply font-bold; + @apply !no-underline; +} + +.cm-completionDetail { + @apply font-sans; + @apply font-normal; + @apply text-sm; + @apply text-light-60; + @apply dark:text-dark-60; + @apply italic; +} + ::-webkit-scrollbar-corner { @apply bg-transparent; } diff --git a/db.sqlite b/db.sqlite index caebdb6fe2b46462a5b606456501639d76edc2da..f92eaea6d1f66a5d9e2dfa313eb10b28e289c666 100644 GIT binary patch delta 55 zcmZoTpx1CfZ^OTMri`7N68^~psiuau2FA7qrnUy=wg#5A2G+I)wzdZLwg!&22F|ty Ju4N6}3IM6q79{`x delta 55 zcmZoTpx1CfZ^OTMrr4BC3IF7QR8vD+17lkQQ(FUbTLVj518Z9YTU!HrTLVX117}+U J*Rlp~1ptd*6=eVb diff --git a/shared/content.util.ts b/shared/content.util.ts index 83599fc..3090ee5 100644 --- a/shared/content.util.ts +++ b/shared/content.util.ts @@ -116,7 +116,7 @@ export class Content private static root: FileSystemDirectoryHandle; private static _overview: Record>; - private static _reverseMapping: Record; + private static _reverseMapping: Record = {}; private static queue = new AsyncQueue(); static init(): Promise @@ -146,10 +146,6 @@ export class Content Content._overview = {}; await Content.pull(true); } - Content._reverseMapping = Object.values(Content._overview).reduce((p, v) => { - p[v.path] = v.id; - return p; - }, {} as Record); Content._ready = true; } @@ -272,7 +268,12 @@ export class Content } for(const id of deletable) - Content.queue.queue(() => Content.remove(id).then(e => delete Content._overview[id])); + { + Content.queue.queue(() => Content.remove(id).then(e => { + delete Content._reverseMapping[Content._overview[id]!.path]; + delete Content._overview[id]; + })); + } return Content.queue.queue(() => { return Content.write('overview', JSON.stringify(Content._overview), { create: true }); diff --git a/shared/editor.util.ts b/shared/editor.util.ts index fefe6ef..37b04da 100644 --- a/shared/editor.util.ts +++ b/shared/editor.util.ts @@ -3,13 +3,13 @@ import { Annotation, EditorState, SelectionRange, StateField, type Range } from import { defaultKeymap, history, historyKeymap } from '@codemirror/commands'; import { bracketMatching, HighlightStyle, indentOnInput, syntaxHighlighting, syntaxTree } from '@codemirror/language'; import { search, searchKeymap } from '@codemirror/search'; -import { closeBrackets, closeBracketsKeymap, completionKeymap } from '@codemirror/autocomplete'; +import { autocompletion, closeBrackets, closeBracketsKeymap, completionKeymap } from '@codemirror/autocomplete'; import { markdown, markdownLanguage } from '@codemirror/lang-markdown'; import { IterMode, Tree, type SyntaxNodeRef } from '@lezer/common'; import { tags } from '@lezer/highlight'; import { dom } from '#shared/dom.util'; import { callout as calloutExtension } from '#shared/grammar/callout.extension'; -import { wikilink as wikilinkExtension } from '#shared/grammar/wikilink.extension'; +import { wikilink as wikilinkExtension, autocompletion as wikilinkAutocompletion } from '#shared/grammar/wikilink.extension'; import { renderMarkdown } from '#shared/markdown.util'; import prose, { a, blockquote, tag, h1, h2, h3, h4, h5, hr, li, small, table, td, th, callout } from "#shared/proses"; import { tagTag, tag as tagExtension } from './grammar/tag.extension'; @@ -236,14 +236,21 @@ export class MarkdownEditor syntaxHighlighting(highlight), bracketMatching(), closeBrackets(), + autocompletion({ + icons: false, + defaultKeymap: true, + maxRenderedOptions: 10, + activateOnTyping: true, + override: [ wikilinkAutocompletion ] + }), crosshairCursor(), EditorView.lineWrapping, keymap.of([ + ...completionKeymap, ...closeBracketsKeymap, ...defaultKeymap, ...searchKeymap, ...historyKeymap, - ...completionKeymap ]), EditorView.updateListener.of((viewUpdate: ViewUpdate) => { if (viewUpdate.docChanged && !viewUpdate.transactions.some(tr => tr.annotation(External))) diff --git a/shared/grammar/wikilink.extension.ts b/shared/grammar/wikilink.extension.ts index b396480..727b0af 100644 --- a/shared/grammar/wikilink.extension.ts +++ b/shared/grammar/wikilink.extension.ts @@ -1,5 +1,25 @@ +import type { CompletionContext, CompletionResult } from '@codemirror/autocomplete'; import type { Element, MarkdownConfig } from '@lezer/markdown'; import { styleTags, tags } from '@lezer/highlight'; +import { Content } from '../content.util'; + +function fuzzyMatch(text: string, search: string): number { + const textLower = text.toLowerCase().normalize('NFC'); + const searchLower = search.toLowerCase().normalize('NFC'); + + let searchIndex = 0; + let score = 0; + + for (let i = 0; i < textLower.length && searchIndex < searchLower.length; i++) { + if (textLower[i] === searchLower[searchIndex]) { + score += 1; + if (i === searchIndex) score += 2; // Bonus for sequential match + searchIndex++; + } + } + + return searchIndex === searchLower.length ? score : 0; +} export const wikilink: MarkdownConfig = { defineNodes: [ @@ -69,3 +89,32 @@ export const wikilink: MarkdownConfig = { }) ] }; +export const autocompletion = (context: CompletionContext): CompletionResult | null => { + const word = context.matchBefore(/\[\[[\w\s-]*/); + if (!word || (word.from === word.to && !context.explicit)) + return null; + + const searchTerm = word.text.slice(2).toLowerCase(); + + const options = Object.values(Content.files).filter(e => e.type !== 'folder').map(e => ({ ...e, score: fuzzyMatch(e.title, searchTerm) })).filter(e => e.score > 0).sort((a, b) => b.score - a.score).slice(0, 50); + + return { + from: word.from + 2, + options: options.map(e => ({ + label: e.title, + detail: e.path, + apply: (view, completion, from, to) => { + view.dispatch({ + changes: { + from: word.from, + to: word.to, + insert: `[[${e.path}]]` + }, + selection: { anchor: word.from + e.path.length + 2 } + }); + }, + type: 'text' + })), + validFor: /^[\[\w\s-]*$/, + } +}; \ No newline at end of file