mirror of
https://github.com/Abdulazizzn/n8n-enterprise-unlocked.git
synced 2025-12-17 10:02:05 +00:00
## Summary This is part-1 of refactoring our code editors to extract different type of editors into their own components. In part-2 we'll 1. delete a of unused or duplicate code 2. switch to a `useEditor` composable to bring more UX consistency across all the code editors. ## Review / Merge checklist - [x] PR title and summary are descriptive - [x] Tests included
292 lines
7.7 KiB
Vue
292 lines
7.7 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, undo } 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: 4,
|
|
},
|
|
disableExpressionColoring: {
|
|
type: Boolean,
|
|
default: false,
|
|
},
|
|
disableExpressionCompletions: {
|
|
type: Boolean,
|
|
default: false,
|
|
},
|
|
},
|
|
data() {
|
|
return {
|
|
editor: null as EditorView | null,
|
|
editorState: null as EditorState | null,
|
|
};
|
|
},
|
|
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-z', run: undo },
|
|
{ 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.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>
|