castopod/app/Resources/js/modules/code-editor.ts
2024-12-17 15:11:45 +00:00

223 lines
5.6 KiB
TypeScript

import { indentWithTab } from "@codemirror/commands";
import { html as htmlLang } from "@codemirror/lang-html";
import { xml } from "@codemirror/lang-xml";
import {
defaultHighlightStyle,
syntaxHighlighting,
} from "@codemirror/language";
import { Compartment, EditorState, Extension } from "@codemirror/state";
import { keymap, ViewUpdate } from "@codemirror/view";
import { basicSetup, EditorView } from "codemirror";
import { prettify as prettifyHTML, minify as minifyHTML } from "htmlfy";
import { css, html, LitElement, TemplateResult } from "lit";
import {
customElement,
property,
queryAssignedNodes,
state,
} from "lit/decorators.js";
import xmlFormat from "xml-formatter";
const language = new Compartment();
@customElement("code-editor")
export class XMLEditor extends LitElement {
@queryAssignedNodes({ slot: "textarea" })
_textarea!: NodeListOf<HTMLTextAreaElement>;
@property()
lang = "html";
@state()
editorState!: EditorState;
@state()
editorView!: EditorView;
_textareaEvents = [
{
events: ["focus", "invalid"],
onEvent: (_: Event, editor: EditorView) => {
// focus editor when textarea is focused or invalid
editor.focus();
},
},
];
firstUpdated(): void {
const minHeightEditor = EditorView.baseTheme({
".cm-content, .cm-gutter": {
minHeight: this._textarea[0].clientHeight + "px",
},
});
const extensions: Extension[] = [
basicSetup,
keymap.of([indentWithTab]),
minHeightEditor,
syntaxHighlighting(defaultHighlightStyle),
EditorView.updateListener.of((viewUpdate: ViewUpdate) => {
if (viewUpdate.docChanged) {
// Document changed, minify and update textarea value
switch (this.lang) {
case "xml":
this._textarea[0].value = minifyXML(
viewUpdate.state.doc.toString()
);
break;
case "html":
this._textarea[0].value = minifyHTML(
viewUpdate.state.doc.toString()
);
break;
default:
this._textarea[0].value = viewUpdate.state.doc.toString();
break;
}
}
}),
];
let editorContents = "";
switch (this.lang) {
case "xml":
editorContents = formatXML(this._textarea[0].value);
extensions.push(language.of(xml()));
break;
case "html":
editorContents = prettifyHTML(this._textarea[0].value);
extensions.push(language.of(htmlLang()));
break;
default:
break;
}
this.editorState = EditorState.create({
doc: editorContents,
extensions: extensions,
});
this.editorView = new EditorView({
state: this.editorState,
root: this.shadowRoot as ShadowRoot,
parent: this.shadowRoot as ShadowRoot,
});
// hide textarea
this._textarea[0].style.position = "absolute";
this._textarea[0].style.opacity = "0";
this._textarea[0].style.zIndex = "-9999";
this._textarea[0].style.pointerEvents = "none";
for (const event of this._textareaEvents) {
event.events.forEach((name) => {
this._textarea[0].addEventListener(name, (e) =>
event.onEvent(e, this.editorView)
);
});
}
}
disconnectedCallback(): void {
super.disconnectedCallback();
for (const event of this._textareaEvents) {
event.events.forEach((name) => {
this._textarea[0].removeEventListener(name, (e) =>
event.onEvent(e, this.editorView)
);
});
}
}
static styles = css`
.cm-editor {
border-radius: 0.5rem;
overflow: hidden;
border: 3px solid hsl(var(--color-border-contrast));
background-color: hsl(var(--color-background-elevated));
}
.cm-editor.cm-focused {
outline: 2px solid transparent;
box-shadow:
0 0 0 2px hsl(var(--color-background-elevated)),
0 0 0 calc(4px) hsl(var(--color-accent-base));
}
.cm-gutters {
background-color: hsl(var(--color-background-elevated)) !important;
}
.cm-activeLine {
background-color: hsla(
var(--color-background-highlight) / 0.25
) !important;
}
.cm-activeLineGutter {
background-color: hsl(var(--color-background-highlight)) !important;
}
.ͼ4 .cm-line {
caret-color: hsl(var(--color-text-base)) !important;
}
.ͼ1 .cm-cursor {
border: none;
}
`;
render(): TemplateResult<1> {
return html`<slot name="textarea"></slot>`;
}
}
function formatXML(contents: string) {
if (contents === "") {
return contents;
}
let editorContents = "";
try {
editorContents = xmlFormat(contents, {
indentation: " ",
});
} catch {
// xml doesn't have a root node
editorContents = xmlFormat("<root>" + contents + "</root>", {
indentation: " ",
});
// remove root, unnecessary lines and indents
editorContents = editorContents
.replace(/^<root>/, "")
.replace(/<\/root>$/, "")
.replace(/^\s*[\r\n]/gm, "")
.replace(/[\r\n] {2}/gm, "\r\n")
.trim();
}
return editorContents;
}
function minifyXML(contents: string) {
if (contents === "") {
return contents;
}
let minifiedContent = "";
try {
minifiedContent = xmlFormat.minify(contents, {
collapseContent: true,
});
} catch {
minifiedContent = xmlFormat.minify(`<root>${contents}</root>`, {
collapseContent: true,
});
// remove root
minifiedContent = minifiedContent
.replace(/^<root>/, "")
.replace(/<\/root>$/, "");
}
return minifiedContent;
}