Files
n8n-enterprise-unlocked/packages/editor-ui/src/components/HtmlEditor/HtmlEditor.vue
कारतोफ्फेलस्क्रिप्ट™ 68cff4c59e refactor(editor): Improve linting for component and prop names (no-changelog) (#8169)
2023-12-28 09:49:58 +01:00

292 lines
7.6 KiB
Vue

<template>
<div ref="htmlEditor"></div>
</template>
<script lang="ts">
import { defineComponent } from 'vue';
import { format } from 'prettier';
import htmlParser from 'prettier/plugins/html';
import cssParser from 'prettier/plugins/postcss';
import jsParser from 'prettier/plugins/babel';
import * as estree from 'prettier/plugins/estree';
import { htmlLanguage, autoCloseTags, html } from 'codemirror-lang-html-n8n';
import { autocompletion } from '@codemirror/autocomplete';
import { indentWithTab, insertNewlineAndIndent, history, redo } from '@codemirror/commands';
import {
bracketMatching,
ensureSyntaxTree,
foldGutter,
indentOnInput,
LanguageSupport,
} from '@codemirror/language';
import type { Extension } from '@codemirror/state';
import { EditorState } from '@codemirror/state';
import type { ViewUpdate } from '@codemirror/view';
import {
dropCursor,
EditorView,
highlightActiveLine,
highlightActiveLineGutter,
keymap,
lineNumbers,
} from '@codemirror/view';
import { n8nCompletionSources } from '@/plugins/codemirror/completions/addCompletions';
import { expressionInputHandler } from '@/plugins/codemirror/inputHandlers/expression.inputHandler';
import { highlighter } from '@/plugins/codemirror/resolvableHighlighter';
import { htmlEditorEventBus } from '@/event-bus';
import { expressionManager } from '@/mixins/expressionManager';
import { theme } from './theme';
import { nonTakenRanges } from './utils';
import type { Range, Section } from './types';
export default defineComponent({
name: 'HtmlEditor',
mixins: [expressionManager],
props: {
modelValue: {
type: String,
required: true,
},
isReadOnly: {
type: Boolean,
default: false,
},
rows: {
type: Number,
default: -1,
},
disableExpressionColoring: {
type: Boolean,
default: false,
},
disableExpressionCompletions: {
type: Boolean,
default: false,
},
},
data() {
return {
editor: {} as EditorView,
};
},
computed: {
doc(): string {
return this.editor.state.doc.toString();
},
extensions(): Extension[] {
function htmlWithCompletions() {
return new LanguageSupport(
htmlLanguage,
n8nCompletionSources().map((source) => htmlLanguage.data.of(source)),
);
}
return [
bracketMatching(),
autocompletion(),
this.disableExpressionCompletions ? html() : htmlWithCompletions(),
autoCloseTags,
expressionInputHandler(),
keymap.of([
indentWithTab,
{ key: 'Enter', run: insertNewlineAndIndent },
{ key: 'Mod-Shift-z', run: redo },
]),
indentOnInput(),
theme({
isReadOnly: this.isReadOnly,
}),
lineNumbers(),
highlightActiveLineGutter(),
history(),
foldGutter(),
dropCursor(),
indentOnInput(),
highlightActiveLine(),
EditorView.editable.of(!this.isReadOnly),
EditorState.readOnly.of(this.isReadOnly),
EditorView.updateListener.of((viewUpdate: ViewUpdate) => {
if (!viewUpdate.docChanged) return;
this.editorState = this.editor.state;
this.getHighlighter()?.removeColor(this.editor, this.htmlSegments);
this.getHighlighter()?.addColor(this.editor, this.resolvableSegments);
// eslint-disable-next-line @typescript-eslint/no-base-to-string
this.$emit('update:modelValue', this.editor?.state.doc.toString());
}),
];
},
sections(): Section[] {
const { state } = this.editor;
const fullTree = ensureSyntaxTree(this.editor.state, this.doc.length);
if (fullTree === null) {
throw new Error(`Failed to parse syntax tree for: ${this.doc}`);
}
let documentRange: Range = [-1, -1];
const styleRanges: Range[] = [];
const scriptRanges: Range[] = [];
fullTree.cursor().iterate((node) => {
if (node.type.name === 'Document') {
documentRange = [node.from, node.to];
}
if (node.type.name === 'StyleSheet') {
styleRanges.push([node.from - '<style>'.length, node.to + '</style>'.length]);
}
if (node.type.name === 'Script') {
scriptRanges.push([node.from - '<script>'.length, node.to + ('<' + '/script>').length]);
// typing the closing script tag in full causes ESLint, Prettier and Vite to crash
}
});
const htmlRanges = nonTakenRanges(documentRange, [...styleRanges, ...scriptRanges]);
const styleSections: Section[] = styleRanges.map(([start, end]) => ({
kind: 'style' as const,
range: [start, end],
content: state.sliceDoc(start, end).replace(/<\/?style>/g, ''),
}));
const scriptSections: Section[] = scriptRanges.map(([start, end]) => ({
kind: 'script' as const,
range: [start, end],
content: state.sliceDoc(start, end).replace(/<\/?script>/g, ''),
}));
const htmlSections: Section[] = htmlRanges.map(([start, end]) => ({
kind: 'html' as const,
range: [start, end] as Range,
content: state.sliceDoc(start, end).replace(/<\/html>/g, ''),
// opening tag may contain attributes, e.g. <html lang="en">
}));
return [...styleSections, ...scriptSections, ...htmlSections].sort(
(a, b) => a.range[0] - b.range[0],
);
},
},
mounted() {
htmlEditorEventBus.on('format-html', this.format);
let doc = this.modelValue;
if (this.modelValue === '' && this.rows > 0) {
doc = '\n'.repeat(this.rows - 1);
}
const state = EditorState.create({ doc, extensions: this.extensions });
this.editor = new EditorView({ parent: this.root(), state });
this.editorState = this.editor.state;
this.getHighlighter()?.addColor(this.editor, this.resolvableSegments);
},
beforeUnmount() {
htmlEditorEventBus.off('format-html', this.format);
},
methods: {
root() {
const rootRef = this.$refs.htmlEditor as HTMLDivElement | undefined;
if (!rootRef) {
throw new Error('Expected div with ref "htmlEditor"');
}
return rootRef;
},
isMissingHtmlTags() {
const zerothSection = this.sections.at(0);
return (
!zerothSection?.content.trim().startsWith('<html') &&
!zerothSection?.content.trim().endsWith('</html>')
);
},
async format() {
if (this.sections.length === 1 && this.isMissingHtmlTags()) {
const zerothSection = this.sections.at(0) as Section;
const formatted = (
await format(zerothSection.content, {
parser: 'html',
plugins: [htmlParser],
})
).trim();
return this.editor.dispatch({
changes: { from: 0, to: this.doc.length, insert: formatted },
});
}
const formatted = [];
for (const { kind, content } of this.sections) {
if (kind === 'style') {
const formattedStyle = await format(content, {
parser: 'css',
plugins: [cssParser],
});
formatted.push(`<style>\n${formattedStyle}</style>`);
}
if (kind === 'script') {
const formattedScript = await format(content, {
parser: 'babel',
plugins: [jsParser, estree],
});
formatted.push(`<script>\n${formattedScript}<` + '/script>');
// typing the closing script tag in full causes ESLint, Prettier and Vite to crash
}
if (kind === 'html') {
const match = content.match(/(?<pre>[\s\S]*<html[\s\S]*?>)(?<rest>[\s\S]*)/);
if (!match?.groups?.pre || !match.groups?.rest) continue;
// Prettier cannot format pre-HTML section, e.g. <!DOCTYPE html>, so keep as is
const { pre, rest } = match.groups;
const formattedRest = await format(rest, {
parser: 'html',
plugins: [htmlParser],
});
formatted.push(`${pre}\n${formattedRest}</html>`);
}
}
if (formatted.length === 0) return;
this.editor.dispatch({
changes: { from: 0, to: this.doc.length, insert: formatted.join('\n\n') },
});
},
getHighlighter() {
if (this.disableExpressionColoring) return;
return highlighter;
},
},
});
</script>
<style lang="scss" module></style>