changemaker.lite/admin/src/utils/wikiLinkCompletion.ts

118 lines
4.0 KiB
TypeScript

/**
* Monaco Editor wiki-link autocomplete provider.
*
* Registers a completion provider for markdown that triggers on `[`
* and provides file suggestions when the user types `[[`.
*/
import type { FileNode } from '@/types/api';
interface FlatFile {
name: string;
path: string;
}
const IMAGE_EXTENSIONS = new Set(['.png', '.jpg', '.jpeg', '.gif', '.svg', '.webp', '.ico']);
function isImage(name: string): boolean {
const dot = name.lastIndexOf('.');
return dot >= 0 && IMAGE_EXTENSIONS.has(name.substring(dot).toLowerCase());
}
function flattenFiles(nodes: FileNode[]): FlatFile[] {
const out: FlatFile[] = [];
for (const n of nodes) {
if (n.isDirectory) {
if (n.children) out.push(...flattenFiles(n.children));
} else {
out.push({ path: n.path, name: n.name });
}
}
return out;
}
/**
* Register a wiki-link `[[` autocomplete provider for Monaco's markdown mode.
*
* @param monaco - The Monaco namespace (from onMount callback)
* @param getFileTree - Callback returning the current file tree
* @returns IDisposable to unregister the provider
*/
export function registerWikiLinkCompletion(
monaco: typeof import('monaco-editor'),
getFileTree: () => FileNode[],
): import('monaco-editor').IDisposable {
return monaco.languages.registerCompletionItemProvider('markdown', {
triggerCharacters: ['['],
provideCompletionItems(model, position) {
// Check that we're in a `[[` context
const lineContent = model.getLineContent(position.lineNumber);
const textBefore = lineContent.substring(0, position.column - 1);
// Must end with `[[` (possibly with partial text after it)
const wikiStart = textBefore.lastIndexOf('[[');
if (wikiStart < 0) return { suggestions: [] };
// Make sure there's no `]]` between wikiStart and cursor (not already closed)
const between = textBefore.substring(wikiStart + 2);
if (between.includes(']]')) return { suggestions: [] };
// Check if this is an image embed (![[)
const isEmbed = wikiStart > 0 && textBefore[wikiStart - 1] === '!';
// The partial query the user has typed after `[[`
const query = between.toLowerCase();
// Build the range from after `[[` to current cursor (or past any auto-closed `]]`)
const startCol = wikiStart + 3; // +2 for `[[`, +1 for 1-based
const textAfter = lineContent.substring(position.column - 1);
// If Monaco auto-closed brackets, there may be `]]` right after cursor — consume it
const closingMatch = textAfter.match(/^\]{1,2}/);
const extraClose = closingMatch ? closingMatch[0].length : 0;
const range = new monaco.Range(
position.lineNumber,
startCol,
position.lineNumber,
position.column + extraClose,
);
const files = flattenFiles(getFileTree());
const suggestions: import('monaco-editor').languages.CompletionItem[] = [];
for (const file of files) {
const fileIsImage = isImage(file.name);
// If user typed `![[`, prefer images; if `[[`, prefer docs
// But show all files regardless — just sort differently
const displayName = file.name.endsWith('.md')
? file.name.slice(0, -3)
: file.name;
// Filter by query
if (query && !displayName.toLowerCase().includes(query) && !file.path.toLowerCase().includes(query)) {
continue;
}
// For wiki-links, we insert just the name (hook resolves the path)
const insertName = file.name.endsWith('.md')
? file.name.slice(0, -3)
: file.name;
suggestions.push({
label: displayName,
kind: fileIsImage
? monaco.languages.CompletionItemKind.File
: monaco.languages.CompletionItemKind.Reference,
detail: file.path,
insertText: insertName + ']]',
range,
// Sort: prioritize images for ![[, docs for [[
sortText: (isEmbed ? (fileIsImage ? '0' : '1') : (fileIsImage ? '1' : '0')) + displayName,
});
}
return { suggestions };
},
});
}