feat: HTML node (#5107)
* ✨ Create HTML templating node PoC * ♻️ Apply feedback * 🐛 Scope CSS selectors * ✏️ Adjust description * ✏️ Adjust placeholder * ⚡ Replace two custom files with package output * ➕ Add `codemirror-lang-html-n8n` * 👕 Appease linter * 🧪 Skip event bus tests * ⏪ Revert "Skip event bus tests" This reverts commit 5702585d0de3b8465660567132e9003e78f1104c. * ✏️ Update codex * 🧹 Cleanup * 🐛 Restore original for `continueOnFail` * ⚡ Improve `getResolvables`
This commit is contained in:
210
packages/editor-ui/src/components/HtmlEditor/HtmlEditor.vue
Normal file
210
packages/editor-ui/src/components/HtmlEditor/HtmlEditor.vue
Normal file
@@ -0,0 +1,210 @@
|
||||
<template>
|
||||
<div ref="htmlEditor" class="ph-no-capture"></div>
|
||||
</template>
|
||||
|
||||
<script lang="ts">
|
||||
import mixins from 'vue-typed-mixins';
|
||||
import prettier from 'prettier/standalone';
|
||||
import htmlParser from 'prettier/parser-html';
|
||||
import cssParser from 'prettier/parser-postcss';
|
||||
import jsParser from 'prettier/parser-babel';
|
||||
import { html } from 'codemirror-lang-html-n8n';
|
||||
import { autocompletion } from '@codemirror/autocomplete';
|
||||
import { indentWithTab, insertNewlineAndIndent, history } from '@codemirror/commands';
|
||||
import { bracketMatching, ensureSyntaxTree, foldGutter, indentOnInput } from '@codemirror/language';
|
||||
import { EditorState, Extension } from '@codemirror/state';
|
||||
import {
|
||||
dropCursor,
|
||||
EditorView,
|
||||
highlightActiveLine,
|
||||
highlightActiveLineGutter,
|
||||
keymap,
|
||||
lineNumbers,
|
||||
ViewUpdate,
|
||||
} from '@codemirror/view';
|
||||
|
||||
import { expressionInputHandler } from '@/plugins/codemirror/inputHandlers/expression.inputHandler';
|
||||
import { highlighter } from '@/plugins/codemirror/resolvableHighlighter';
|
||||
import { htmlEditorEventBus } from '@/event-bus/html-editor-event-bus';
|
||||
import { expressionManager } from '@/mixins/expressionManager';
|
||||
import { theme } from './theme';
|
||||
import { nonTakenRanges } from './utils';
|
||||
import type { Range, Section } from './types';
|
||||
|
||||
export default mixins(expressionManager).extend({
|
||||
name: 'HtmlEditor',
|
||||
props: {
|
||||
html: {
|
||||
type: String,
|
||||
},
|
||||
isReadOnly: {
|
||||
type: Boolean,
|
||||
default: false,
|
||||
},
|
||||
},
|
||||
data() {
|
||||
return {
|
||||
editor: {} as EditorView,
|
||||
};
|
||||
},
|
||||
computed: {
|
||||
doc(): string {
|
||||
return this.editor.state.doc.toString();
|
||||
},
|
||||
|
||||
extensions(): Extension[] {
|
||||
return [
|
||||
bracketMatching(),
|
||||
autocompletion(),
|
||||
html({ autoCloseTags: true }),
|
||||
expressionInputHandler(),
|
||||
keymap.of([indentWithTab, { key: 'Enter', run: insertNewlineAndIndent }]),
|
||||
indentOnInput(),
|
||||
theme,
|
||||
lineNumbers(),
|
||||
highlightActiveLineGutter(),
|
||||
history(),
|
||||
foldGutter(),
|
||||
dropCursor(),
|
||||
indentOnInput(),
|
||||
highlightActiveLine(),
|
||||
EditorState.readOnly.of(this.isReadOnly),
|
||||
EditorView.updateListener.of((viewUpdate: ViewUpdate) => {
|
||||
if (!viewUpdate.docChanged) return;
|
||||
|
||||
highlighter.removeColor(this.editor, this.htmlSegments);
|
||||
highlighter.addColor(this.editor, this.resolvableSegments);
|
||||
|
||||
this.$emit('valueChanged', this.doc);
|
||||
}),
|
||||
];
|
||||
},
|
||||
|
||||
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],
|
||||
);
|
||||
},
|
||||
},
|
||||
|
||||
methods: {
|
||||
root() {
|
||||
const root = this.$refs.htmlEditor as HTMLDivElement | undefined;
|
||||
|
||||
if (!root) throw new Error('Expected div with ref "htmlEditor"');
|
||||
|
||||
return root;
|
||||
},
|
||||
|
||||
format() {
|
||||
const formatted = [];
|
||||
|
||||
for (const { kind, content } of this.sections) {
|
||||
if (kind === 'style') {
|
||||
const formattedStyle = prettier.format(content, {
|
||||
parser: 'css',
|
||||
plugins: [cssParser],
|
||||
});
|
||||
|
||||
formatted.push(`<style>\n${formattedStyle}</style>`);
|
||||
}
|
||||
|
||||
if (kind === 'script') {
|
||||
const formattedScript = prettier.format(content, {
|
||||
parser: 'babel',
|
||||
plugins: [jsParser],
|
||||
});
|
||||
|
||||
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 = prettier.format(rest, {
|
||||
parser: 'html',
|
||||
plugins: [htmlParser],
|
||||
});
|
||||
|
||||
formatted.push(`${pre}\n${formattedRest}</html>`);
|
||||
}
|
||||
}
|
||||
|
||||
this.editor.dispatch({
|
||||
changes: { from: 0, to: this.doc.length, insert: formatted.join('\n\n') },
|
||||
});
|
||||
},
|
||||
},
|
||||
|
||||
mounted() {
|
||||
htmlEditorEventBus.$on('format-html', this.format);
|
||||
|
||||
const state = EditorState.create({ doc: this.html, extensions: this.extensions });
|
||||
|
||||
this.editor = new EditorView({ parent: this.root(), state });
|
||||
|
||||
highlighter.addColor(this.editor, this.resolvableSegments);
|
||||
},
|
||||
|
||||
destroyed() {
|
||||
htmlEditorEventBus.$off('format-html', this.format);
|
||||
},
|
||||
});
|
||||
</script>
|
||||
|
||||
<style lang="scss" module></style>
|
||||
85
packages/editor-ui/src/components/HtmlEditor/theme.ts
Normal file
85
packages/editor-ui/src/components/HtmlEditor/theme.ts
Normal file
@@ -0,0 +1,85 @@
|
||||
import { HighlightStyle, syntaxHighlighting } from '@codemirror/language';
|
||||
import { EditorView } from '@codemirror/view';
|
||||
import { tags } from '@lezer/highlight';
|
||||
|
||||
export const theme = [
|
||||
EditorView.theme({
|
||||
'&': {
|
||||
'font-size': '0.8em',
|
||||
border: 'var(--border-base)',
|
||||
borderRadius: 'var(--border-radius-base)',
|
||||
backgroundColor: 'var(--color-code-background)',
|
||||
color: 'var(--color-code-foreground)',
|
||||
},
|
||||
'.cm-content': {
|
||||
fontFamily: "Menlo, Consolas, 'DejaVu Sans Mono', monospace !important",
|
||||
caretColor: 'var(--color-code-caret)',
|
||||
},
|
||||
'.cm-cursor, .cm-dropCursor': {
|
||||
borderLeftColor: 'var(--color-code-caret)',
|
||||
},
|
||||
'&.cm-focused .cm-selectionBackgroundm .cm-selectionBackground, .cm-content ::selection': {
|
||||
backgroundColor: 'var(--color-code-selection)',
|
||||
},
|
||||
'.cm-activeLine': {
|
||||
backgroundColor: 'var(--color-code-lineHighlight)',
|
||||
},
|
||||
'.cm-activeLineGutter': {
|
||||
backgroundColor: 'var(--color-code-lineHighlight)',
|
||||
},
|
||||
'.cm-gutters': {
|
||||
backgroundColor: 'var(--color-code-gutterBackground)',
|
||||
color: 'var(--color-code-gutterForeground)',
|
||||
},
|
||||
'.cm-scroller': {
|
||||
overflow: 'auto',
|
||||
maxHeight: '350px',
|
||||
},
|
||||
}),
|
||||
syntaxHighlighting(
|
||||
HighlightStyle.define([
|
||||
{ tag: tags.keyword, color: '#c678dd' },
|
||||
{
|
||||
tag: [tags.name, tags.deleted, tags.character, tags.propertyName, tags.macroName],
|
||||
color: '#e06c75',
|
||||
},
|
||||
{ tag: [tags.function(tags.variableName), tags.labelName], color: '#61afef' },
|
||||
{ tag: [tags.color, tags.constant(tags.name), tags.standard(tags.name)], color: '#d19a66' },
|
||||
{ tag: [tags.definition(tags.name), tags.separator], color: '#abb2bf' },
|
||||
{
|
||||
tag: [
|
||||
tags.typeName,
|
||||
tags.className,
|
||||
tags.number,
|
||||
tags.changed,
|
||||
tags.annotation,
|
||||
tags.modifier,
|
||||
tags.self,
|
||||
tags.namespace,
|
||||
],
|
||||
color: '#e06c75',
|
||||
},
|
||||
{
|
||||
tag: [
|
||||
tags.operator,
|
||||
tags.operatorKeyword,
|
||||
tags.url,
|
||||
tags.escape,
|
||||
tags.regexp,
|
||||
tags.link,
|
||||
tags.special(tags.string),
|
||||
],
|
||||
color: '#56b6c2',
|
||||
},
|
||||
{ tag: [tags.meta, tags.comment], color: '#7d8799' },
|
||||
{ tag: tags.strong, fontWeight: 'bold' },
|
||||
{ tag: tags.emphasis, fontStyle: 'italic' },
|
||||
{ tag: tags.strikethrough, textDecoration: 'line-through' },
|
||||
{ tag: tags.link, color: '#7d8799', textDecoration: 'underline' },
|
||||
{ tag: tags.heading, fontWeight: 'bold', color: '#e06c75' },
|
||||
{ tag: [tags.atom, tags.bool, tags.special(tags.variableName)], color: '#d19a66' },
|
||||
{ tag: [tags.processingInstruction, tags.string, tags.inserted], color: '#98c379' },
|
||||
{ tag: tags.invalid, color: 'red', 'font-weight': 'bold' },
|
||||
]),
|
||||
),
|
||||
];
|
||||
7
packages/editor-ui/src/components/HtmlEditor/types.ts
Normal file
7
packages/editor-ui/src/components/HtmlEditor/types.ts
Normal file
@@ -0,0 +1,7 @@
|
||||
export type Range = [number, number];
|
||||
|
||||
export type Section = {
|
||||
kind: 'html' | 'script' | 'style';
|
||||
content: string;
|
||||
range: Range;
|
||||
};
|
||||
40
packages/editor-ui/src/components/HtmlEditor/utils.ts
Normal file
40
packages/editor-ui/src/components/HtmlEditor/utils.ts
Normal file
@@ -0,0 +1,40 @@
|
||||
import type { Range } from './types';
|
||||
|
||||
/**
|
||||
* Return the ranges of a full range that are _not_ within the taken ranges,
|
||||
* assuming sorted taken ranges. e.g. `[0, 10]` and `[[2, 3], [7, 8]]`
|
||||
* return `[[0, 1], [4, 6], [9, 10]]`
|
||||
*/
|
||||
export function nonTakenRanges(fullRange: Range, takenRanges: Range[]) {
|
||||
const found = [];
|
||||
|
||||
const [fullStart, fullEnd] = fullRange;
|
||||
let i = fullStart;
|
||||
let curStart = fullStart;
|
||||
|
||||
takenRanges = [...takenRanges];
|
||||
|
||||
while (i < fullEnd) {
|
||||
if (takenRanges.length === 0) {
|
||||
found.push([curStart, fullEnd]);
|
||||
break;
|
||||
}
|
||||
|
||||
const [takenStart, takenEnd] = takenRanges[0];
|
||||
|
||||
if (i < takenStart) {
|
||||
i++;
|
||||
continue;
|
||||
}
|
||||
|
||||
if (takenStart !== fullStart) {
|
||||
found.push([curStart, i - 1]);
|
||||
}
|
||||
|
||||
i = takenEnd + 1;
|
||||
curStart = takenEnd + 1;
|
||||
takenRanges.shift();
|
||||
}
|
||||
|
||||
return found;
|
||||
}
|
||||
Reference in New Issue
Block a user