161 lines
6.8 KiB
TypeScript
161 lines
6.8 KiB
TypeScript
import type { EditorView } from '@codemirror/view';
|
|
import type { Completion, CompletionContext, CompletionResult } from '@codemirror/autocomplete';
|
|
import type { Element, MarkdownConfig } from '@lezer/markdown';
|
|
import { styleTags, tags } from '@lezer/highlight';
|
|
import { Content } from '../content';
|
|
import { selectAll } from 'hast-util-select';
|
|
|
|
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: [
|
|
'Wikilink', //Whole group
|
|
'WikilinkMeta', //Meta characters ([[ & ]])
|
|
'WikilinkHref', //Link
|
|
'WikilinkTitle', //Title (always visible)
|
|
],
|
|
parseInline: [{
|
|
name: 'Wikilink',
|
|
before: 'Link',
|
|
parse(cx, next, pos)
|
|
{
|
|
// 91 == '['
|
|
if (next !== 91 || cx.slice(pos, pos + 1).charCodeAt(0) !== 91) return -1;
|
|
|
|
const match = /!?\[\[([^\[\]\|\#]+)?(#+[^\[\]\|\#]+)?(\|[^\[\]\|\#]+)?\]\]/.exec(cx.slice(pos, cx.end));
|
|
if(!match) return -1;
|
|
|
|
const start = pos, children: Element[] = [], end = start + match[0].length;
|
|
|
|
if(match[0] === '[[]]') return end;
|
|
|
|
children.push(cx.elt('WikilinkMeta', start, start + 2));
|
|
|
|
if(match[1] && !match[2] && !match[3]) //Link only
|
|
{
|
|
children.push(cx.elt('WikilinkTitle', start + 2, end - 2));
|
|
}
|
|
else if(!match[1] && match[2] && match[3]) //Hash and title
|
|
{
|
|
children.push(cx.elt('WikilinkHref', start + 2, start + 2 + match[2].length));
|
|
children.push(cx.elt('WikilinkMeta', start + 2 + match[2].length, start + 2 + match[2].length + 1));
|
|
children.push(cx.elt('WikilinkTitle', start + 2 + match[2].length + 1, start + 2 + match[2].length + match[3].length));
|
|
}
|
|
else if(!match[1] && !match[2] && match[3]) //Hash only
|
|
{
|
|
children.push(cx.elt('WikilinkTitle', start + 2, end - 2));
|
|
}
|
|
else if(match[1] && match[2] && !match[3]) //Link and hash
|
|
{
|
|
children.push(cx.elt('WikilinkHref', start + 2, start + 2 + match[1].length));
|
|
children.push(cx.elt('WikilinkTitle', start + 2 + match[1].length, start + 2 + match[1].length + match[2].length));
|
|
}
|
|
else if(match[1] && !match[2] && match[3]) //Link and title
|
|
{
|
|
children.push(cx.elt('WikilinkHref', start + 2, start + 2 + match[1].length));
|
|
children.push(cx.elt('WikilinkMeta', start + 2 + match[1].length, start + 2 + match[1].length + 1));
|
|
children.push(cx.elt('WikilinkTitle', start + 2 + match[1].length + 1, start + 2 + match[1].length + match[3].length));
|
|
}
|
|
else if(match[1] && match[2] && match[3]) //Link, hash and title
|
|
{
|
|
children.push(cx.elt('WikilinkHref', start + 2, start + 2 + match[1].length + match[2].length));
|
|
children.push(cx.elt('WikilinkMeta', start + 2 + match[1].length + match[2].length, start + 2 + match[1].length + match[2].length + 1));
|
|
children.push(cx.elt('WikilinkTitle', start + 2 + match[1].length + match[2].length + 1, start + 2 + match[1].length + match[2].length + match[3].length));
|
|
}
|
|
|
|
children.push(cx.elt('WikilinkMeta', end - 2, end));
|
|
|
|
return cx.addElement(cx.elt('Wikilink', start, end, children));
|
|
},
|
|
}],
|
|
props: [
|
|
styleTags({
|
|
'Wikilink': tags.special(tags.content),
|
|
'WikilinkMeta': tags.meta,
|
|
'WikilinkHref': tags.link,
|
|
'WikilinkTitle': tags.special(tags.link),
|
|
})
|
|
]
|
|
};
|
|
|
|
export const autocompletion = (context: CompletionContext): CompletionResult | Promise<CompletionResult | null> | null => {
|
|
const header = context.matchBefore(/\[\[[^\[\]\|\#]+#[^\[\]\|\#]*/);
|
|
if(!header || (header.from === header.to && !context.explicit))
|
|
{
|
|
const word = context.matchBefore(/\[\[[\w\s-]*/);
|
|
if (!word || (word.from === word.to && !context.explicit)) return null;
|
|
|
|
const options = Object.values(Content.files).filter(e => e.type !== 'folder');
|
|
|
|
return {
|
|
from: word.from + 2,
|
|
options: options.map(e => ({
|
|
label: e.title,
|
|
detail: e.path,
|
|
apply: (view, completion, from, to) => {
|
|
const closed = view.state.sliceDoc(from, to + 2).endsWith(']]');
|
|
view.dispatch({
|
|
changes: {
|
|
from: from - 2,
|
|
to: to,
|
|
insert: closed ? `[[${completion.detail}` : `[[${completion.detail}]]`
|
|
},
|
|
selection: { anchor: from + (completion.detail?.length ?? 0) }
|
|
});
|
|
},
|
|
type: 'text',
|
|
})),
|
|
commitCharacters: ['#', '|'],
|
|
validFor: /^[\[\w\s-]*$/,
|
|
}
|
|
}
|
|
else
|
|
{
|
|
const path = header.text.match(/^\[\[([^\[\]\|\#]+)#/);
|
|
|
|
if(!path || !path[1]) return null;
|
|
const content = Content.getFromPath(path[1]);
|
|
if(!content || content.type !== 'markdown') return null;
|
|
|
|
return (async () => {
|
|
const headers = selectAll('h1, h2, h3, h4, h5, h6', await useMarkdown().parse((await Content.getContent(content.id))!.content as string));
|
|
|
|
return {
|
|
from: header.from + path[1]!.length + 3,
|
|
options: headers.map(e => ({
|
|
label: e.properties.id as string,
|
|
apply: (view, completion, from, to) => {
|
|
const closed = view.state.sliceDoc(from, to + 2).endsWith(']]');
|
|
view.dispatch({
|
|
changes: {
|
|
from: from,
|
|
to: to,
|
|
insert: closed ? `${completion.label}` : `${completion.label}]]`
|
|
},
|
|
selection: { anchor: from + (completion.label?.length ?? 0) }
|
|
});
|
|
},
|
|
type: 'text',
|
|
})),
|
|
commitCharacters: ['#', '|'],
|
|
validFor: new RegExp(`\\[\\[${path[1]}#[^\[\]\|\#]*`),
|
|
};
|
|
})();
|
|
}
|
|
}; |