mirror of
https://github.com/Abdulazizzn/n8n-enterprise-unlocked.git
synced 2025-12-17 10:02:05 +00:00
## Summary <img width="1240" alt="image" src="https://github.com/n8n-io/n8n/assets/8850410/2819f4ce-c343-431a-8a88-a1bc9c4b572a"> <img width="2649" alt="image" src="https://github.com/n8n-io/n8n/assets/8850410/36862aaf-cc4c-4668-bdc8-cf5a6f00babe"> 1. Add code node and open it 3. Click the fullscreen button in the bottom right 4. A fullscreen dialog should appear and allow editing the code 5. Changes made in the fullscreen dialog should be applied to the original code editor when closed It should work the same way for HTML/SQL/JSON editors ⚠️ Modal layout was updated so that modals/dialogs are centered, try to test some modals ## Related tickets and issues https://linear.app/n8n/issue/NODE-1009/add-fullscreen-view-to-code-node ## Review / Merge checklist - [ ] PR title and summary are descriptive. **Remember, the title automatically goes into the changelog. Use `(no-changelog)` otherwise.** ([conventions](https://github.com/n8n-io/n8n/blob/master/.github/pull_request_title_conventions.md)) - [ ] [Docs updated](https://github.com/n8n-io/n8n-docs) or follow-up ticket created. - [ ] Tests included. > A bug is not considered fixed, unless a test is added to prevent it from happening again. > A feature is not complete without tests. --------- Co-authored-by: Giulio Andreini <andreini@netseven.it>
314 lines
8.1 KiB
Vue
314 lines
8.1 KiB
Vue
<template>
|
|
<div :class="$style.editor">
|
|
<div ref="htmlEditor"></div>
|
|
<slot name="suffix" />
|
|
</div>
|
|
</template>
|
|
|
|
<script lang="ts">
|
|
import { autocompletion } from '@codemirror/autocomplete';
|
|
import { history, redo, undo } from '@codemirror/commands';
|
|
import {
|
|
LanguageSupport,
|
|
bracketMatching,
|
|
ensureSyntaxTree,
|
|
foldGutter,
|
|
indentOnInput,
|
|
} from '@codemirror/language';
|
|
import type { Extension } from '@codemirror/state';
|
|
import { EditorState, Prec } from '@codemirror/state';
|
|
import type { ViewUpdate } from '@codemirror/view';
|
|
import {
|
|
EditorView,
|
|
dropCursor,
|
|
highlightActiveLine,
|
|
highlightActiveLineGutter,
|
|
keymap,
|
|
lineNumbers,
|
|
} from '@codemirror/view';
|
|
import { autoCloseTags, html, htmlLanguage } from 'codemirror-lang-html-n8n';
|
|
import { format } from 'prettier';
|
|
import jsParser from 'prettier/plugins/babel';
|
|
import * as estree from 'prettier/plugins/estree';
|
|
import htmlParser from 'prettier/plugins/html';
|
|
import cssParser from 'prettier/plugins/postcss';
|
|
import { defineComponent } from 'vue';
|
|
|
|
import { htmlEditorEventBus } from '@/event-bus';
|
|
import { expressionManager } from '@/mixins/expressionManager';
|
|
import { n8nCompletionSources } from '@/plugins/codemirror/completions/addCompletions';
|
|
import { expressionInputHandler } from '@/plugins/codemirror/inputHandlers/expression.inputHandler';
|
|
import { highlighter } from '@/plugins/codemirror/resolvableHighlighter';
|
|
import { enterKeyMap, tabKeyMap } from '../CodeNodeEditor/baseExtensions';
|
|
import { codeNodeEditorTheme } from '../CodeNodeEditor/theme';
|
|
import type { Range, Section } from './types';
|
|
import { nonTakenRanges } from './utils';
|
|
|
|
export default defineComponent({
|
|
name: 'HtmlEditor',
|
|
mixins: [expressionManager],
|
|
props: {
|
|
modelValue: {
|
|
type: String,
|
|
required: true,
|
|
},
|
|
isReadOnly: {
|
|
type: Boolean,
|
|
default: false,
|
|
},
|
|
fillParent: {
|
|
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(),
|
|
Prec.highest(
|
|
keymap.of([
|
|
...tabKeyMap,
|
|
...enterKeyMap,
|
|
{ key: 'Mod-z', run: undo },
|
|
{ key: 'Mod-Shift-z', run: redo },
|
|
]),
|
|
),
|
|
indentOnInput(),
|
|
codeNodeEditorTheme({
|
|
isReadOnly: this.isReadOnly,
|
|
maxHeight: this.fillParent ? '100%' : '40vh',
|
|
minHeight: '20vh',
|
|
rows: this.rows,
|
|
highlightColors: 'html',
|
|
}),
|
|
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>
|
|
.editor {
|
|
height: 100%;
|
|
|
|
& > div {
|
|
height: 100%;
|
|
}
|
|
}
|
|
</style>
|