Skip to content

Commit

Permalink
feat: HTML node (#5107)
Browse files Browse the repository at this point in the history
* ✨ 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 5702585.

* ✏️ Update codex

* 🧹 Cleanup

* 🐛 Restore original for `continueOnFail`

* ⚡ Improve `getResolvables`
  • Loading branch information
ivov authored Jan 26, 2023
1 parent a1710fb commit 74e6f5d
Show file tree
Hide file tree
Showing 25 changed files with 1,049 additions and 12 deletions.
2 changes: 2 additions & 0 deletions packages/editor-ui/package.json
Original file line number Diff line number Diff line change
Expand Up @@ -40,6 +40,7 @@
"@fortawesome/free-solid-svg-icons": "^5.15.3",
"@fortawesome/vue-fontawesome": "^2.0.2",
"axios": "^0.21.1",
"codemirror-lang-html-n8n": "^1.0.0",
"codemirror-lang-n8n-expression": "^0.1.0",
"dateformat": "^3.0.3",
"esprima-next": "5.8.4",
Expand All @@ -62,6 +63,7 @@
"n8n-workflow": "~0.133.2",
"normalize-wheel": "^1.0.1",
"pinia": "^2.0.22",
"prettier": "^2.8.2",
"prismjs": "^1.17.1",
"timeago.js": "^4.0.2",
"uuid": "^8.3.2",
Expand Down
2 changes: 1 addition & 1 deletion packages/editor-ui/src/Interface.ts
Original file line number Diff line number Diff line change
Expand Up @@ -1102,7 +1102,7 @@ export interface IModalState {
httpNodeParameters?: string;
}

export type IRunDataDisplayMode = 'table' | 'json' | 'binary' | 'schema';
export type IRunDataDisplayMode = 'table' | 'json' | 'binary' | 'schema' | 'html';
export type NodePanelType = 'input' | 'output';

export interface TargetItem {
Expand Down
210 changes: 210 additions & 0 deletions packages/editor-ui/src/components/HtmlEditor/HtmlEditor.vue
Original file line number Diff line number Diff line change
@@ -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 changes: 85 additions & 0 deletions packages/editor-ui/src/components/HtmlEditor/theme.ts
Original file line number Diff line number Diff line change
@@ -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 changes: 7 additions & 0 deletions packages/editor-ui/src/components/HtmlEditor/types.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,7 @@
export type Range = [number, number];

export type Section = {
kind: 'html' | 'script' | 'style';
content: string;
range: Range;
};
40 changes: 40 additions & 0 deletions packages/editor-ui/src/components/HtmlEditor/utils.ts
Original file line number Diff line number Diff line change
@@ -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;
}
Loading

0 comments on commit 74e6f5d

Please sign in to comment.