vue-codemirror6
Version:
CodeMirror6 Component for vue2 and vue3.
817 lines (783 loc) • 25 kB
text/typescript
import { indentWithTab } from '@codemirror/commands';
import { indentUnit, type LanguageSupport } from '@codemirror/language';
import {
diagnosticCount as linterDiagnosticCount,
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 KeyBinding,
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
* This is the unit of indentation used when the editor is configured to indent with tabs.
* It is also used to determine the size of the tab character when the editor is configured to use tabs for indentation..
*
* @see {@link https://codemirror.net/docs/ref/#state.EditorState^indentUnit}
*/
indentUnit: {
type: String,
default: undefined,
},
/**
* Allow Multiple Selection.
* This allows the editor to have multiple selections at the same time.
* This is useful for editing multiple parts of the document at once.
* If this is set to true, the editor will allow multiple selections.
* If this is set to false, the editor will only allow a single selection.
*
* @see {@link https://codemirror.net/docs/ref/#state.EditorState^allowMultipleSelections}
*/
allowMultipleSelections: {
type: Boolean,
default: false,
},
/**
* Tab size
* This is the number of spaces that a tab character represents in the editor.
* It is used to determine the size of the tab character when the editor is configured to use tabs for indentation.
* If this is set to a number, the editor will use that number of spaces for each tab character.
*
* @see {@link https://codemirror.net/docs/ref/#state.EditorState^tabSize}
*/
tabSize: {
type: Number,
default: undefined,
},
/**
* Set line break (separetor) char.
*
* This is the character that is used to separate lines in the editor.
* It is used to determine the line break character when the editor is configured to use a specific line break character.
*
* @see {@link https://codemirror.net/docs/ref/#state.EditorState^lineSeparator}
*/
lineSeparator: {
type: String,
default: undefined,
},
/**
* Readonly
*
* This is a CodeMirror Facet that allows you to set the editor to read-only mode.
* When this is set to true, the editor will not allow any changes to be made to the document.
* This is useful for displaying code that should not be edited, such as documentation or examples.
* If this is set to false, the editor will allow changes to be made to the document.
*
* @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
*
* You can use this to add any additional extensions that you want to use in the editor.
*
* @see {@link https://codemirror.net/docs/ref/#state.Extension}
*/
extensions: {
type: Array as PropType<Extension[]>,
default: () => {
return [];
},
},
/**
* Language Phreses
*
* This is a CodeMirror Facet that allows you to define custom phrases for the editor.
* It can be used to override default phrases or add new ones.
* This is useful for translating the editor to different languages or for customizing the editor's UI.
*
* @see {@link https://codemirror.net/examples/translate/}
*/
phrases: {
type: Object as PropType<Record<string, string>>,
default: undefined,
},
/**
* CodeMirror Language
*
* This is a CodeMirror Facet that allows you to define the language of the editor.
* It can be used to enable syntax highlighting and other language-specific features.
* It is useful for displaying code in a specific language, such as JavaScript, Python, or HTML.
*
* @see {@link https://codemirror.net/docs/ref/#language}
*/
lang: {
type: Object as PropType<LanguageSupport>,
default: undefined,
},
/**
* CodeMirror Linter
*
* This is a CodeMirror Facet that allows you to define a linter for the editor.
* It can be used to check the code for errors and warnings, and to provide feedback to the user.
* It is useful for displaying code in a specific language, such as JavaScript, Python, or HTML.
* This is useful for providing feedback to the user about the code they are writing.
*
* @see {@link https://codemirror.net/docs/ref/#lint.linter}
*/
linter: {
type: Function as PropType<LintSource | any>,
default: undefined,
},
/**
* Linter Config
*
* This is a CodeMirror Facet that allows you to define the configuration for the linter.
* It can be used to specify options for the linter, such as the severity of errors and warnings, and to customize the behavior of the linter.
* This is useful for providing feedback to the user about the code they are writing.
*
* @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.
*
* This is useful for running linters on the initial load of the editor, or when the user has made changes to the code and wants to see the results immediately.
*
* @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
*
* This is a CodeMirror Facet that allows you to define the configuration for the gutter.
* It can be used to specify options for the gutter, such as the size of the gutter, the position of the gutter, and to customize the behavior of the gutter.
* This is useful for providing feedback to the user about the code they are writing.
*
* @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.
*
* This is useful for scrolling the editor to a specific position when the user has made changes to the code and wants to see the results immediately.
* If this is set to true, the editor will scroll to the position specified in the transaction.
* If this is set to false, the editor will not scroll to the position specified in the transaction.
*
* @see {@link https://codemirror.net/docs/ref/#state.TransactionSpec.scrollIntoView}
*/
scrollIntoView: {
type: Boolean,
default: true,
},
/**
* Key map
* This is a CodeMirror Facet that allows you to define custom key bindings.
* It can be used to override default key bindings or add new ones.
*
* @see {@link https://codemirror.net/docs/ref/#view.keymap}
*/
keymap: {
type: Array as PropType<KeyBinding[]>,
default: () => [],
},
},
/** 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 new Error(
'[Vue CodeMirror] Both basic and minimal cannot be specified.'
);
}
/** Keymap */
let keymaps: KeyBinding[] = [];
if (props.keymap && props.keymap.length > 0) {
// If keymap is specified, use it.
keymaps = props.keymap;
}
if (props.tab) {
// If tab is enabled, add indentWithTab to keymap.
keymaps.push(indentWithTab);
}
// 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,
// 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,
// Keymap and Indent with Tab
keymaps.length !== 0 ? keymap.of(keymaps) : undefined,
// Append Extensions
...props.extensions,
].filter((extension): extension is Extension => !!extension); // Filter undefined
});
// 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 = linterDiagnosticCount(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
);
},
});