235 lines
8.5 KiB
Vue
235 lines
8.5 KiB
Vue
<script lang="ts">
|
|
import { crosshairCursor, Decoration, dropCursor, EditorView, keymap, ViewPlugin, ViewUpdate, WidgetType, type DecorationSet } from '@codemirror/view';
|
|
import { Annotation, EditorState, RangeValue, SelectionRange, type Range } from '@codemirror/state';
|
|
import { defaultKeymap, history, historyKeymap } from '@codemirror/commands';
|
|
import { bracketMatching, defaultHighlightStyle, foldKeymap, HighlightStyle, indentOnInput, syntaxHighlighting, syntaxTree } from '@codemirror/language';
|
|
import { search, searchKeymap } from '@codemirror/search';
|
|
import { closeBrackets, closeBracketsKeymap, completionKeymap } from '@codemirror/autocomplete';
|
|
import { lintKeymap } from '@codemirror/lint';
|
|
import { markdown, markdownLanguage } from '@codemirror/lang-markdown';
|
|
import { IterMode, Tree } from '@lezer/common';
|
|
import { tags } from '@lezer/highlight';
|
|
const External = Annotation.define<boolean>();
|
|
const Hidden = Decoration.mark({ class: 'hidden' });
|
|
const Bullet = Decoration.mark({ class: '*:hidden before:absolute before:top-2 before:left-0 before:inline-block before:w-2 before:h-2 before:rounded before:bg-light-40 dark:before:bg-dark-40 relative ps-4' });
|
|
const Blockquote = Decoration.line({ class: '*:hidden before:block !ps-4 relative before:absolute before:top-0 before:bottom-0 before:left-0 before:w-1 before:bg-none before:bg-light-30 dark:before:bg-dark-30' });
|
|
|
|
const TagTag = tags.special(tags.content);
|
|
|
|
const intersects = (a: {
|
|
from: number;
|
|
to: number;
|
|
}, b: {
|
|
from: number;
|
|
to: number;
|
|
}) => !(a.to < b.from || b.to < a.from);
|
|
|
|
const highlight = HighlightStyle.define([
|
|
{ tag: tags.heading1, class: 'text-5xl pt-4 pb-2 after:hidden' },
|
|
{ tag: tags.heading2, class: 'text-4xl pt-4 pb-2 ps-1 leading-loose after:hidden' },
|
|
{ tag: tags.heading3, class: 'text-2xl font-bold pt-1 after:hidden' },
|
|
{ tag: tags.heading4, class: 'text-xl font-semibold pt-1 after:hidden variant-cap' },
|
|
{ tag: tags.meta, color: "#404740" },
|
|
{ tag: tags.link, textDecoration: "underline" },
|
|
{ tag: tags.heading, textDecoration: "underline", fontWeight: "bold" },
|
|
{ tag: tags.emphasis, fontStyle: "italic" },
|
|
{ tag: tags.strong, fontWeight: "bold" },
|
|
{ tag: tags.strikethrough, textDecoration: "line-through" },
|
|
{ tag: tags.keyword, color: "#708" },
|
|
{ tag: TagTag, class: 'cursor-default bg-accent-blue bg-opacity-10 hover:bg-opacity-20 text-accent-blue text-sm px-1 ms-1 pb-0.5 rounded-full rounded-se-none border border-accent-blue border-opacity-30' }
|
|
]);
|
|
|
|
class Decorator
|
|
{
|
|
static hiddenNodes: string[] = [
|
|
'HardBreak',
|
|
'LinkMark',
|
|
'EmphasisMark',
|
|
'CodeMark',
|
|
'CodeInfo',
|
|
'URL',
|
|
]
|
|
decorations: DecorationSet;
|
|
constructor(view: EditorView)
|
|
{
|
|
this.decorations = Decoration.set(this.iterate(syntaxTree(view.state), view.visibleRanges, []), true);
|
|
}
|
|
update(update: ViewUpdate)
|
|
{
|
|
if(!update.docChanged && !update.viewportChanged && !update.selectionSet)
|
|
return;
|
|
|
|
this.decorations = this.decorations.update({
|
|
filter: (f, t, v) => false,
|
|
add: this.iterate(syntaxTree(update.state), update.view.visibleRanges, update.state.selection.ranges),
|
|
sort: true,
|
|
});
|
|
}
|
|
iterate(tree: Tree, visible: readonly {
|
|
from: number;
|
|
to: number;
|
|
}[], selection: readonly SelectionRange[]): Range<Decoration>[]
|
|
{
|
|
const decorations: Range<Decoration>[] = [];
|
|
|
|
for (let { from, to } of visible) {
|
|
tree.iterate({
|
|
from, to, mode: IterMode.IgnoreMounts,
|
|
enter: node => {
|
|
if(node.node.parent && selection.some(e => intersects(e, node.node.parent!)))
|
|
return true;
|
|
|
|
else if(node.name === 'HeaderMark')
|
|
decorations.push(Hidden.range(node.from, node.to + 1));
|
|
|
|
else if(Decorator.hiddenNodes.includes(node.name))
|
|
decorations.push(Hidden.range(node.from, node.to));
|
|
|
|
else if(node.matchContext(['BulletList', 'ListItem']) && node.name === 'ListMark')
|
|
decorations.push(Bullet.range(node.from, node.to + 1));
|
|
|
|
else if(node.matchContext(['Blockquote']))
|
|
decorations.push(Blockquote.range(node.from, node.from));
|
|
|
|
return true;
|
|
},
|
|
});
|
|
}
|
|
|
|
return decorations;
|
|
}
|
|
}
|
|
</script>
|
|
|
|
<script setup lang="ts">
|
|
const { autofocus = false } = defineProps<{
|
|
placeholder?: string
|
|
autofocus?: boolean
|
|
}>();
|
|
const model = defineModel<string>();
|
|
|
|
const editor = useTemplateRef('editor');
|
|
const view = ref<EditorView>();
|
|
|
|
onMounted(() => {
|
|
if(editor.value)
|
|
{
|
|
view.value = new EditorView({
|
|
doc: model.value,
|
|
parent: editor.value,
|
|
extensions: [
|
|
markdown({
|
|
base: markdownLanguage,
|
|
extensions: {
|
|
defineNodes: [
|
|
{ name: "Tag", style: TagTag },
|
|
{ name: "TagMark", style: tags.processingInstruction }
|
|
],
|
|
parseInline: [{
|
|
name: "Tag",
|
|
parse(cx, next, pos) {
|
|
if (next != 35 || cx.char(pos + 1) == 35) return -1;
|
|
let elts = [cx.elt("TagMark", pos, pos + 1)];
|
|
for (let i = pos + 1; i < cx.end; i++) {
|
|
let next = cx.char(i);
|
|
if (next == 35)
|
|
return cx.addElement(cx.elt("Tag", pos, i + 1, elts.concat(cx.elt("TagMark", i, i + 1))));
|
|
if (next == 92)
|
|
elts.push(cx.elt("Escape", i, i++ + 2));
|
|
if (next == 32 || next == 9 || next == 10 || next == 13) break;
|
|
}
|
|
return -1
|
|
}
|
|
}],
|
|
}
|
|
}),
|
|
history(),
|
|
search(),
|
|
dropCursor(),
|
|
EditorState.allowMultipleSelections.of(true),
|
|
indentOnInput(),
|
|
syntaxHighlighting(highlight),
|
|
bracketMatching(),
|
|
closeBrackets(),
|
|
crosshairCursor(),
|
|
EditorView.lineWrapping,
|
|
keymap.of([
|
|
...closeBracketsKeymap,
|
|
...defaultKeymap,
|
|
...searchKeymap,
|
|
...historyKeymap,
|
|
...foldKeymap,
|
|
...completionKeymap,
|
|
...lintKeymap
|
|
]),
|
|
EditorView.updateListener.of((viewUpdate: ViewUpdate) => {
|
|
if (viewUpdate.docChanged && !viewUpdate.transactions.some(tr => tr.annotation(External)))
|
|
{
|
|
model.value = viewUpdate.state.doc.toString();
|
|
}
|
|
}),
|
|
EditorView.contentAttributes.of({spellcheck: "true"}),
|
|
ViewPlugin.fromClass(Decorator, {
|
|
decorations: e => e.decorations,
|
|
})
|
|
]
|
|
});
|
|
|
|
if(autofocus)
|
|
{
|
|
view.value.focus();
|
|
}
|
|
}
|
|
});
|
|
|
|
onBeforeUnmount(() => {
|
|
if (view.value)
|
|
{
|
|
view.value?.destroy();
|
|
view.value = undefined;
|
|
}
|
|
});
|
|
|
|
watchEffect(() => {
|
|
if (model.value === void 0) {
|
|
return;
|
|
}
|
|
const currentValue = view.value ? view.value.state.doc.toString() : "";
|
|
if (view.value && model.value !== currentValue) {
|
|
view.value.dispatch({
|
|
changes: { from: 0, to: currentValue.length, insert: model.value || "" },
|
|
annotations: [External.of(true)],
|
|
});
|
|
}
|
|
});
|
|
|
|
defineExpose({ focus: () => editor.value?.focus() });
|
|
</script>
|
|
|
|
<template>
|
|
<div ref="editor" class="flex flex-1 w-full justify-stretch items-stretch py-2 px-1.5 font-sans text-base"></div>
|
|
</template>
|
|
|
|
<style>
|
|
.variant-cap
|
|
{
|
|
font-variant: small-caps;
|
|
}
|
|
.cm-editor
|
|
{
|
|
@apply bg-transparent;
|
|
@apply flex-1 h-full;
|
|
@apply font-sans;
|
|
|
|
@apply text-light-100 dark:text-dark-100;
|
|
}
|
|
.cm-editor .cm-content
|
|
{
|
|
@apply caret-light-100 dark:caret-dark-100;
|
|
}
|
|
.cm-line
|
|
{
|
|
@apply text-base;
|
|
@apply font-sans;
|
|
}
|
|
</style> |