-
Notifications
You must be signed in to change notification settings - Fork 9.2k
Commit
This commit does not belong to any branch on this repository, and may belong to a fork outside of the repository.
* ✨ 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
Showing
25 changed files
with
1,049 additions
and
12 deletions.
There are no files selected for viewing
This file contains bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
This file contains bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
210 changes: 210 additions & 0 deletions
210
packages/editor-ui/src/components/HtmlEditor/HtmlEditor.vue
This file contains bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
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> |
This file contains bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
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' }, | ||
]), | ||
), | ||
]; |
This file contains bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
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; | ||
}; |
This file contains bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
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; | ||
} |
Oops, something went wrong.