vue-codemirror6
Version:
CodeMirror6 Component for vue2 and vue3.
749 lines (715 loc) • 20.6 kB
text/typescript
// Helpers
// CodeMirror
import { indentWithTab } from '@codemirror/commands';
import { indentUnit, type LanguageSupport } from '@codemirror/language';
import {
diagnosticCount as linterDagnosticCount,
forceLinting,
linter,
lintGutter,
type Diagnostic,
type LintSource,
} from '@codemirror/lint';
import {
Compartment,
EditorSelection,
EditorState,
StateEffect,
type Transaction,
type Extension,
type SelectionRange,
type StateField,
type Text,
} from '@codemirror/state';
import {
EditorView,
keymap,
placeholder,
type ViewUpdate,
} from '@codemirror/view';
import { basicSetup, minimalSetup } from 'codemirror';
import {
computed,
defineComponent,
nextTick,
onMounted,
onUnmounted,
ref,
shallowRef,
watch,
type ComputedRef,
type PropType,
type Ref,
type ShallowRef,
type WritableComputedRef,
} from 'vue-demi';
import type { StyleSpec } from 'style-mod';
import h, { slot } from '@/helpers/h-demi';
/** CodeMirror Component */
export default defineComponent({
/** Component Name */
name: 'CodeMirror',
/** Model Definition */
model: {
prop: 'modelValue',
event: 'update:modelValue',
},
/** Props Definition */
props: {
/** Model value */
modelValue: {
type: String as PropType<string | Text>,
default: '',
},
/**
* Theme
*
* @see {@link https://codemirror.net/docs/ref/#view.EditorView^theme}
*/
theme: {
type: Object as PropType<Record<string, StyleSpec>>,
default: () => {
return {};
},
},
/** Dark Mode */
dark: {
type: Boolean,
default: false,
},
/**
* Use Basic Setup
*
* @see {@link https://codemirror.net/docs/ref/#codemirror.basicSetup}
*/
basic: {
type: Boolean,
default: false,
},
/**
* Use Minimal Setup (The basic setting has priority.)
*
* @see {@link https://codemirror.net/docs/ref/#codemirror.minimalSetup}
*/
minimal: {
type: Boolean,
default: false,
},
/**
* Placeholder
*
* @see {@link https://codemirror.net/docs/ref/#view.placeholder}
*/
placeholder: {
type: String as PropType<string | HTMLElement>,
default: undefined,
},
/**
* Line wrapping
*
* An extension that enables line wrapping in the editor (by setting CSS white-space to pre-wrap in the content).
*
* @see {@link https://codemirror.net/docs/ref/#view.EditorView%5ElineWrapping}
*/
wrap: {
type: Boolean,
default: false,
},
/**
* Allow tab key indent.
*
* @see {@link https://codemirror.net/examples/tab/}
*/
tab: {
type: Boolean,
default: false,
},
/**
* Tab character
*/
indentUnit: {
type: String,
default: undefined,
},
/**
* Allow Multiple Selection.
*
* @see {@link https://codemirror.net/docs/ref/#state.EditorState^allowMultipleSelections}
*/
allowMultipleSelections: {
type: Boolean,
default: false,
},
/**
* Tab size
*
* @see {@link https://codemirror.net/docs/ref/#state.EditorState^tabSize}
*/
tabSize: {
type: Number,
default: undefined,
},
/**
* Set line break (separetor) char.
*
* @see {@link https://codemirror.net/docs/ref/#state.EditorState^lineSeparator}
*/
lineSeparator: {
type: String,
default: undefined,
},
/**
* Readonly
*
* @see {@link https://codemirror.net/docs/ref/#state.EditorState^readOnly}
*/
readonly: {
type: Boolean,
default: false,
},
/**
* Disable input.
*
* This is the reversed value of the CodeMirror editable.
* Similar to `readonly`, but setting this value to true disables dragging.
*
* @see {@link https://codemirror.net/docs/ref/#view.EditorView^editable}
*/
disabled: {
type: Boolean,
default: false,
},
/**
* Additional Extension
*
* @see {@link https://codemirror.net/docs/ref/#state.Extension}
*/
extensions: {
type: Array as PropType<Extension[]>,
default: () => {
return [];
},
},
/**
* Language Phreses
*
* @see {@link https://codemirror.net/examples/translate/}
*/
phrases: {
type: Object as PropType<Record<string, string>>,
default: () => undefined,
},
/**
* CodeMirror Language
*
* @see {@link https://codemirror.net/docs/ref/#language}
*/
lang: {
type: Object as PropType<LanguageSupport>,
default: () => undefined,
},
/**
* CodeMirror Linter
*
* @see {@link https://codemirror.net/docs/ref/#lint.linter}
*/
linter: {
type: Function as PropType<LintSource | any>,
default: undefined,
},
/**
* Linter Config
*
* @see {@link https://codemirror.net/docs/ref/#lint.linter^config}
*/
linterConfig: {
type: Object,
default: () => {
return {};
},
},
/**
* Forces any linters configured to run when the editor is idle to run right away.
*
* @see {@link https://codemirror.net/docs/ref/#lint.forceLinting}
*/
forceLinting: {
type: Boolean,
default: false,
},
/**
* Show Linter Gutter
*
* An area to 🔴 the lines with errors will be displayed.
* This feature is not enabled if `linter` is not specified.
*
* @see {@link https://codemirror.net/docs/ref/#lint.lintGutter}
*/
gutter: {
type: Boolean,
default: false,
},
/**
* Gutter Config
*
* @see {@link https://codemirror.net/docs/ref/#lint.lintGutter^config}
*/
gutterConfig: {
type: Object,
default: () => undefined,
},
/**
* Using tag
*/
tag: {
type: String,
default: 'div',
},
/**
* Allows an external update to scroll the form.
* @see {@link https://codemirror.net/docs/ref/#state.TransactionSpec.scrollIntoView}
*/
scrollIntoView: {
type: Boolean,
default: true,
},
},
/** Emits */
emits: {
/** Model Update */
'update:modelValue': (_value: string | Text = '') => true,
/** CodeMirror ViewUpdate */
update: (_value: ViewUpdate) => true,
/** CodeMirror onReady */
ready: (_value: {
view: EditorView;
state: EditorState;
container: HTMLElement;
}) => true,
/** CodeMirror onFocus */
focus: (_value: boolean) => true,
/** State Changed */
change: (_value: EditorState) => true,
/** CodeMirror onDestroy */
destroy: () => true,
},
/**
* Setup
*
* @param props - Props
* @param context - Context
*/
setup(props, context) {
/** Editor DOM */
const editor: Ref<HTMLElement | undefined> = ref();
/** Internal value */
const doc: Ref<string | Text> = ref(props.modelValue);
/**
* CodeMirror Editor View
*
* @see {@link https://codemirror.net/docs/ref/#view.EditorView}
*/
const view: ShallowRef<EditorView> = shallowRef(new EditorView());
/**
* Focus
*
* @see {@link https://codemirror.net/docs/ref/#view.EditorView.hasFocus}
*/
const focus: WritableComputedRef<boolean> = computed({
get: () => view.value.hasFocus,
set: f => {
if (f) {
view.value.focus();
}
},
});
/**
* Editor Selection
*
* @see {@link https://codemirror.net/docs/ref/#state.EditorSelection}
*/
const selection: WritableComputedRef<EditorSelection> = computed({
get: () => view.value.state.selection,
set: s => view.value.dispatch({ selection: s }),
});
/** Cursor Position */
const cursor: WritableComputedRef<number> = computed({
get: () => view.value.state.selection.main.head,
set: a => view.value.dispatch({ selection: { anchor: a } }),
});
/** JSON */
const json: WritableComputedRef<Record<string, StateField<any>>> = computed(
{
get: () => view.value.state.toJSON(),
set: j => view.value.setState(EditorState.fromJSON(j)),
}
);
/** Text length */
const length: Ref<number> = ref(0);
/**
* Returns the number of active lint diagnostics in the given state.
*
* @see {@link https://codemirror.net/docs/ref/#lint.diagnosticCount}
*/
const diagnosticCount: Ref<number> = ref(0);
/** Get CodeMirror Extension */
const extensions: ComputedRef<Extension[]> = computed(() => {
// Synamic Reconfiguration
// @see https://codemirror.net/examples/config/
const language = new Compartment();
const tabSize = new Compartment();
if (props.basic && props.minimal) {
throw '[Vue CodeMirror] Both basic and minimal cannot be specified.';
}
// TODO: Ignore previous prop was not changed.
return [
// Toggle basic setup
props.basic && !props.minimal ? basicSetup : undefined,
// Toggle minimal setup
props.minimal && !props.basic ? minimalSetup : undefined,
// ViewUpdate event listener
EditorView.updateListener.of((update: ViewUpdate): void => {
// Emit focus status
context.emit('focus', view.value.hasFocus);
// Update count
length.value = view.value.state.doc?.length;
if (update.changes.empty || !update.docChanged) {
// Suppress event firing if no change
return;
}
if (props.linter) {
// Linter process
if (props.forceLinting) {
// If forceLinting enabled, first liting.
forceLinting(view.value);
}
// Count diagnostics.
diagnosticCount.value = (
props.linter(view.value) as readonly Diagnostic[]
).length;
}
context.emit('update', update);
}),
// Toggle light/dark mode.
EditorView.theme(props.theme, { dark: props.dark }),
// Toggle line wrapping
props.wrap ? EditorView.lineWrapping : undefined,
// Indent with tab
props.tab ? keymap.of([indentWithTab]) : undefined,
// Tab character
props.indentUnit ? indentUnit.of(props.indentUnit) : undefined,
// Allow Multiple Selections
EditorState.allowMultipleSelections.of(props.allowMultipleSelections),
// Indent tab size
props.tabSize
? tabSize.of(EditorState.tabSize.of(props.tabSize))
: undefined,
// locale settings
props.phrases ? EditorState.phrases.of(props.phrases) : undefined,
// Readonly option
EditorState.readOnly.of(props.readonly),
// Editable option
EditorView.editable.of(!props.disabled),
// Set Line break char
props.lineSeparator
? EditorState.lineSeparator.of(props.lineSeparator)
: undefined,
// Lang
props.lang ? language.of(props.lang) : undefined,
// Append Linter settings
props.linter ? linter(props.linter, props.linterConfig) : undefined,
// Show 🔴 to error line when linter enabled.
props.linter && props.gutter
? lintGutter(props.gutterConfig)
: undefined,
// Placeholder
props.placeholder ? placeholder(props.placeholder) : undefined,
// Append Extensions
...props.extensions,
].filter((extension): extension is Extension => !!extension);
});
// Extension (mostly props) Changed
watch(
extensions,
exts => {
view.value?.dispatch({
effects: StateEffect.reconfigure.of(exts),
});
},
{ immediate: true }
);
// for parent-to-child binding.
watch(
() => props.modelValue,
async value => {
if (
view.value.composing || // IME fix
view.value.state.doc.toJSON().join(props.lineSeparator ?? '\n') ===
value // don't need to update
) {
// Do not commit CodeMirror's store.
return;
}
// Range Fix ?
// https://github.com/logue/vue-codemirror6/issues/27
const isSelectionOutOfRange = !view.value.state.selection.ranges.every(
range => range.anchor < value.length && range.head < value.length
);
// Update
view.value.dispatch({
changes: { from: 0, to: view.value.state.doc.length, insert: value },
selection: isSelectionOutOfRange
? { anchor: 0, head: 0 }
: view.value.state.selection,
scrollIntoView: props.scrollIntoView,
});
},
{ immediate: true }
);
/** When loaded */
onMounted(async () => {
/** Initial value */
let value: string | Text = doc.value;
if (!editor.value) {
return;
}
if (editor.value.childNodes[0]) {
// when slot mode, overwrite initial value
if (doc.value !== '') {
console.warn(
'[CodeMirror.vue] The <code-mirror> tag contains child elements that overwrite the `v-model` values.'
);
}
value = (editor.value.childNodes[0] as HTMLElement).innerText.trim();
}
// Register Codemirror
view.value = new EditorView({
parent: editor.value,
state: EditorState.create({ doc: value, extensions: extensions.value }),
dispatch: (tr: Transaction) => {
view.value.update([tr]);
if (tr.changes.empty || !tr.docChanged) {
// if not change value, no fire emit event
return;
}
// console.log(view.state.doc.toString(), tr);
// state.toString() is not defined, so use toJSON and toText function to convert string.
context.emit('update:modelValue', tr.state.doc.toString() ?? '');
// Emit EditorState
context.emit('change', tr.state);
},
});
await nextTick();
context.emit('ready', {
view: view.value,
state: view.value.state,
container: editor.value,
});
});
/** Destroy */
onUnmounted(() => {
view.value.destroy();
context.emit('destroy');
});
/**
* Forces any linters configured to run when the editor is idle to run right away.
*
* @see {@link https://codemirror.net/docs/ref/#lint.forceLinting}
*/
const lint = (): void => {
if (!props.linter || !view.value) {
return;
}
if (props.forceLinting) {
forceLinting(view.value);
}
diagnosticCount.value = linterDagnosticCount(view.value.state);
};
/**
* Force Reconfigure Extension
*
* @see {@link https://codemirror.net/examples/config/#top-level-reconfiguration}
*/
const forceReconfigure = (): void => {
// Deconfigure all Extensions
view.value?.dispatch({
effects: StateEffect.reconfigure.of([]),
});
// Register extensions
view.value?.dispatch({
effects: StateEffect.appendConfig.of(extensions.value),
});
};
/* ----- Bellow is experimental. ------ */
/**
* Get the text between the given points in the editor.
*
* @param from - start line number
* @param to - end line number
*/
const getRange = (from?: number, to?: number): string | undefined =>
view.value.state.sliceDoc(from, to);
/**
* Get the content of line.
*
* @param number - line number
*/
const getLine = (number: number): string =>
view.value.state.doc.line(number + 1).text;
/** Get the number of lines in the editor. */
const lineCount = (): number => view.value.state.doc.lines;
/** Retrieve one end of the primary selection. */
const getCursor = (): number => view.value.state.selection.main.head;
/** Retrieves a list of all current selections. */
const listSelections = (): readonly SelectionRange[] => {
let _view$value$state$sel;
return (_view$value$state$sel = view.value.state.selection.ranges) !==
null && _view$value$state$sel !== undefined
? _view$value$state$sel
: [];
};
/** Get the currently selected code. */
const getSelection = (): string => {
let _view$value$state$sli;
return (_view$value$state$sli = view.value.state.sliceDoc(
view.value.state.selection.main.from,
view.value.state.selection.main.to
)) !== null && _view$value$state$sli !== undefined
? _view$value$state$sli
: '';
};
/**
* The length of the given array should be the same as the number of active selections.
* Replaces the content of the selections with the strings in the array.
*/
const getSelections = (): string[] => {
const s = view.value.state;
if (!s) {
return [];
}
return s.selection.ranges.map((r: { from: number; to: number }) =>
s.sliceDoc(r.from, r.to)
);
};
/** Return true if any text is selected. */
const somethingSelected = (): boolean =>
view.value.state.selection.ranges.some(
(r: { empty: boolean }) => !r.empty
);
/**
* Replace the part of the document between from and to with the given string.
*
* @param replacement - replacement text
* @param from - start string at position
* @param to - insert the string at position
*/
const replaceRange = (
replacement: string | Text,
from: number,
to: number
): void =>
view.value.dispatch({
changes: { from, to, insert: replacement },
});
/**
* Replace the selection(s) with the given string.
* By default, the new selection ends up after the inserted text.
*
* @param replacement - replacement text
*/
const replaceSelection = (replacement: string | Text): void =>
view.value.dispatch(view.value.state.replaceSelection(replacement));
/**
* Set the cursor position.
*
* @param position - position.
*/
const setCursor = (position: number): void =>
view.value.dispatch({ selection: { anchor: position } });
/**
* Set a single selection range.
*
* @param anchor - anchor position
* @param head -
*/
const setSelection = (anchor: number, head?: number): void =>
view.value.dispatch({ selection: { anchor, head } });
/**
* Sets a new set of selections. There must be at least one selection in the given array.
*
* @param ranges - Selection range
* @param primary -
*/
const setSelections = (
ranges: readonly SelectionRange[],
primary?: number
): void =>
view.value.dispatch({
selection: EditorSelection.create(ranges, primary),
});
/**
* Applies the given function to all existing selections, and calls extendSelections on the result.
*
* @param f - function
*/
const extendSelectionsBy = (f: any): void =>
view.value.dispatch({
selection: EditorSelection.create(
selection.value.ranges.map((r: SelectionRange) => r.extend(f(r)))
),
});
const exposed = {
editor,
view,
cursor,
selection,
focus,
length,
json,
diagnosticCount,
dom: view.value.contentDOM,
lint,
forceReconfigure,
// Bellow is CodeMirror5's function
getRange,
getLine,
lineCount,
getCursor,
listSelections,
getSelection,
getSelections,
somethingSelected,
replaceRange,
replaceSelection,
setCursor,
setSelection,
setSelections,
extendSelectionsBy,
};
/** Export properties and functions */
context.expose(exposed);
return exposed;
},
render() {
// <template>
// <div ref="editor" class="vue-codemirror">
// <aside v-show="!context.slots.default" aria-hidden><slot /></aside>
// </div>
// </template>
return h(
this.$props.tag,
{
ref: 'editor',
class: 'vue-codemirror',
},
this.$slots.default
? // Hide original content
h(
'aside',
{ style: 'display: none;', 'aria-hidden': 'true' },
slot(this.$slots.default)
)
: undefined
);
},
});