+
();
+
+const errorLineField = StateField.define({
+ create: () => null,
+ update(value, tr) {
+ let next = value;
+ for (const e of tr.effects) {
+ if (e.is(setErrorLineEffect)) next = e.value;
+ }
+ return next;
+ },
+});
+
+function errorLineFrom(state: EditorState) {
+ const line = state.field(errorLineField);
+ if (!line || line < 1 || line > state.doc.lines) return null;
+ return state.doc.line(line);
+}
+
+const errorLineExtension = [
+ errorLineField,
+ EditorView.decorations.compute([errorLineField, "doc"], (state): DecorationSet => {
+ const line = errorLineFrom(state);
+ if (!line) return Decoration.none;
+ return Decoration.set([Decoration.line({ class: "cm-errorLine" }).range(line.from)]);
+ }),
+];
+
+export interface EditorOptions {
+ parent: HTMLElement;
+ doc: string;
+ readOnly: boolean;
+ onChange: (value: string) => void;
+ getRemoveKey: () => string | undefined;
+}
+
+export interface EditorController {
+ view: EditorView;
+ setDoc(text: string): void;
+ setErrorLine(line: number | null): void;
+ setReadOnly(readOnly: boolean): void;
+ destroy(): void;
+}
+
+export function createEditor(opts: EditorOptions): EditorController {
+ const readOnlyCompartment = new Compartment();
+ const readOnlyExt = (readOnly: boolean) => [
+ EditorState.readOnly.of(readOnly),
+ EditorView.editable.of(!readOnly),
+ ];
+
+ const holder: { view?: EditorView } = {};
+
+ const state = EditorState.create({
+ doc: opts.doc ?? "",
+ extensions: [
+ lineNumbers(),
+ foldGutter(),
+ history(),
+ bracketMatching(),
+ yaml(),
+ indentOnInput(),
+ keymap.of([indentWithTab, ...defaultKeymap, ...historyKeymap]),
+ errorLineExtension,
+ // emits stable tok-* classes so colors/theme live in YamlEditor.vue CSS
+ syntaxHighlighting(classHighlighter),
+ readOnlyCompartment.of(readOnlyExt(opts.readOnly)),
+ EditorView.updateListener.of((update) => {
+ if (!update.docChanged) return;
+ const value = update.state.doc.toString();
+ // strip a pasted wrapper key (e.g. "power:") and unindent, matching the editor's removeKey
+ const pasted = update.transactions.some((tr) => tr.isUserEvent("input.paste"));
+ const removeKey = pasted ? opts.getRemoveKey() : undefined;
+ if (removeKey) {
+ const cleaned = cleanYaml(value, removeKey);
+ if (cleaned !== value) {
+ // dispatch outside the update cycle to avoid a re-entrant transaction
+ queueMicrotask(() => {
+ const view = holder.view;
+ if (!view) return;
+ view.dispatch({
+ changes: { from: 0, to: view.state.doc.length, insert: cleaned },
+ });
+ });
+ return;
+ }
+ }
+ opts.onChange(value);
+ }),
+ ],
+ });
+
+ const view = new EditorView({ state, parent: opts.parent });
+ holder.view = view;
+
+ return {
+ view,
+ setDoc(text: string) {
+ if (view.state.doc.toString() === text) return;
+ view.dispatch({ changes: { from: 0, to: view.state.doc.length, insert: text } });
+ },
+ setErrorLine(line: number | null) {
+ view.dispatch({ effects: setErrorLineEffect.of(line || null) });
+ },
+ setReadOnly(readOnly: boolean) {
+ view.dispatch({ effects: readOnlyCompartment.reconfigure(readOnlyExt(readOnly)) });
+ },
+ destroy() {
+ view.destroy();
+ },
+ };
+}
diff --git a/package-lock.json b/package-lock.json
index 21e49f08e..b50683c34 100644
--- a/package-lock.json
+++ b/package-lock.json
@@ -7,9 +7,14 @@
"name": "evcc",
"license": "MIT",
"dependencies": {
+ "@codemirror/commands": "^6.8.1",
+ "@codemirror/lang-yaml": "^6.1.2",
+ "@codemirror/language": "^6.11.0",
+ "@codemirror/state": "^6.5.2",
+ "@codemirror/view": "^6.36.5",
"@formkit/drag-and-drop": "^0.6.0",
- "@guolao/vue-monaco-editor": "1.5.5",
"@h2d2/shopicons": "^1.9.1",
+ "@lezer/highlight": "^1.2.1",
"@popperjs/core": "^2.11.8",
"@unhead/vue": "^2.1.15",
"axios": "^1.17.0",
@@ -21,7 +26,6 @@
"countup.js": "^2.10.0",
"dayjs": "^1.11.21",
"echarts": "^6.1.0",
- "monaco-editor": "0.52.2",
"qrcode": "^1.5.4",
"smoothscroll-polyfill": "^0.4.4",
"snarkdown": "^2.0.0",
@@ -1684,6 +1688,80 @@
"node": ">=6.9.0"
}
},
+ "node_modules/@codemirror/autocomplete": {
+ "version": "6.20.3",
+ "resolved": "https://registry.npmjs.org/@codemirror/autocomplete/-/autocomplete-6.20.3.tgz",
+ "integrity": "sha512-tlosUqb+3BbxCxZdu4tKeRghPFC+QM7q4X5YhKV2eCmPG+1r2F3f4AaSz5sCrFqUtX4Jh20VFTKecl16MgiV9g==",
+ "license": "MIT",
+ "dependencies": {
+ "@codemirror/language": "^6.0.0",
+ "@codemirror/state": "^6.0.0",
+ "@codemirror/view": "^6.17.0",
+ "@lezer/common": "^1.0.0"
+ }
+ },
+ "node_modules/@codemirror/commands": {
+ "version": "6.10.3",
+ "resolved": "https://registry.npmjs.org/@codemirror/commands/-/commands-6.10.3.tgz",
+ "integrity": "sha512-JFRiqhKu+bvSkDLI+rUhJwSxQxYb759W5GBezE8Uc8mHLqC9aV/9aTC7yJSqCtB3F00pylrLCwnyS91Ap5ej4Q==",
+ "license": "MIT",
+ "dependencies": {
+ "@codemirror/language": "^6.0.0",
+ "@codemirror/state": "^6.6.0",
+ "@codemirror/view": "^6.27.0",
+ "@lezer/common": "^1.1.0"
+ }
+ },
+ "node_modules/@codemirror/lang-yaml": {
+ "version": "6.1.3",
+ "resolved": "https://registry.npmjs.org/@codemirror/lang-yaml/-/lang-yaml-6.1.3.tgz",
+ "integrity": "sha512-AZ8DJBuXGVHybpBQhmZtgew5//4hv3tdkXnr3vDmOUMJRuB6vn/uuwtmTOTlqEaQFg3hQSVeA90NmvIQyUV6FQ==",
+ "license": "MIT",
+ "dependencies": {
+ "@codemirror/autocomplete": "^6.0.0",
+ "@codemirror/language": "^6.0.0",
+ "@codemirror/state": "^6.0.0",
+ "@lezer/common": "^1.2.0",
+ "@lezer/highlight": "^1.2.0",
+ "@lezer/lr": "^1.0.0",
+ "@lezer/yaml": "^1.0.0"
+ }
+ },
+ "node_modules/@codemirror/language": {
+ "version": "6.12.3",
+ "resolved": "https://registry.npmjs.org/@codemirror/language/-/language-6.12.3.tgz",
+ "integrity": "sha512-QwCZW6Tt1siP37Jet9Tb02Zs81TQt6qQrZR2H+eGMcFsL1zMrk2/b9CLC7/9ieP1fjIUMgviLWMmgiHoJrj+ZA==",
+ "license": "MIT",
+ "dependencies": {
+ "@codemirror/state": "^6.0.0",
+ "@codemirror/view": "^6.23.0",
+ "@lezer/common": "^1.5.0",
+ "@lezer/highlight": "^1.0.0",
+ "@lezer/lr": "^1.0.0",
+ "style-mod": "^4.0.0"
+ }
+ },
+ "node_modules/@codemirror/state": {
+ "version": "6.6.0",
+ "resolved": "https://registry.npmjs.org/@codemirror/state/-/state-6.6.0.tgz",
+ "integrity": "sha512-4nbvra5R5EtiCzr9BTHiTLc+MLXK2QGiAVYMyi8PkQd3SR+6ixar/Q/01Fa21TBIDOZXgeWV4WppsQolSreAPQ==",
+ "license": "MIT",
+ "dependencies": {
+ "@marijn/find-cluster-break": "^1.0.0"
+ }
+ },
+ "node_modules/@codemirror/view": {
+ "version": "6.43.1",
+ "resolved": "https://registry.npmjs.org/@codemirror/view/-/view-6.43.1.tgz",
+ "integrity": "sha512-+BIjw/AG3tDQ4pJgTLPYdAW25eDE66YsvM4LKyVPgGzVgZ4a9Wj1SRX8kPVKgBDdPt8oHtZ15F0qx7p0oOHdHw==",
+ "license": "MIT",
+ "dependencies": {
+ "@codemirror/state": "^6.6.0",
+ "crelt": "^1.0.6",
+ "style-mod": "^4.1.0",
+ "w3c-keyname": "^2.2.4"
+ }
+ },
"node_modules/@emnapi/core": {
"version": "1.10.0",
"resolved": "https://registry.npmjs.org/@emnapi/core/-/core-1.10.0.tgz",
@@ -2309,52 +2387,6 @@
}
}
},
- "node_modules/@guolao/vue-monaco-editor": {
- "version": "1.5.5",
- "resolved": "https://registry.npmjs.org/@guolao/vue-monaco-editor/-/vue-monaco-editor-1.5.5.tgz",
- "integrity": "sha512-NFGImQ8dBYj6ehIxy1DngPRkctB9b6GbxvCm6aXZztNsgm/TtM4u+YM9ZwZHQPlXt7a4IODXoKCcTYEVycBSyA==",
- "license": "MIT",
- "dependencies": {
- "@monaco-editor/loader": "^1.5.0",
- "vue-demi": "latest"
- },
- "peerDependencies": {
- "@vue/composition-api": "^1.7.1",
- "monaco-editor": ">=0.43.0",
- "vue": "^2.6.14 || >=3.0.0"
- },
- "peerDependenciesMeta": {
- "@vue/composition-api": {
- "optional": true
- }
- }
- },
- "node_modules/@guolao/vue-monaco-editor/node_modules/vue-demi": {
- "version": "0.14.10",
- "resolved": "https://registry.npmjs.org/vue-demi/-/vue-demi-0.14.10.tgz",
- "integrity": "sha512-nMZBOwuzabUO0nLgIcc6rycZEebF6eeUfaiQx9+WSk8e29IbLvPU9feI6tqW4kTo3hvoYAJkMh8n8D0fuISphg==",
- "hasInstallScript": true,
- "license": "MIT",
- "bin": {
- "vue-demi-fix": "bin/vue-demi-fix.js",
- "vue-demi-switch": "bin/vue-demi-switch.js"
- },
- "engines": {
- "node": ">=12"
- },
- "funding": {
- "url": "https://github.com/sponsors/antfu"
- },
- "peerDependencies": {
- "@vue/composition-api": "^1.0.0-rc.1",
- "vue": "^3.0.0-0 || ^2.6.0"
- },
- "peerDependenciesMeta": {
- "@vue/composition-api": {
- "optional": true
- }
- }
- },
"node_modules/@h2d2/shopicons": {
"version": "1.9.1",
"resolved": "https://registry.npmjs.org/@h2d2/shopicons/-/shopicons-1.9.1.tgz",
@@ -2661,15 +2693,47 @@
"integrity": "sha512-M5UknZPHRu3DEDWoipU6sE8PdkZ6Z/S+v4dD+Ke8IaNlpdSQah50lz1KtcFBa2vsdOnwbbnxJwVM4wty6udA5w==",
"license": "MIT"
},
- "node_modules/@monaco-editor/loader": {
- "version": "1.5.0",
- "resolved": "https://registry.npmjs.org/@monaco-editor/loader/-/loader-1.5.0.tgz",
- "integrity": "sha512-hKoGSM+7aAc7eRTRjpqAZucPmoNOC4UUbknb/VNoTkEIkCPhqV8LfbsgM1webRM7S/z21eHEx9Fkwx8Z/C/+Xw==",
+ "node_modules/@lezer/common": {
+ "version": "1.5.2",
+ "resolved": "https://registry.npmjs.org/@lezer/common/-/common-1.5.2.tgz",
+ "integrity": "sha512-sxQE460fPZyU3sdc8lafxiPwJHBzZRy/udNFynGQky1SePYBdhkBl1kOagA9uT3pxR8K09bOrmTUqA9wb/PjSQ==",
+ "license": "MIT"
+ },
+ "node_modules/@lezer/highlight": {
+ "version": "1.2.3",
+ "resolved": "https://registry.npmjs.org/@lezer/highlight/-/highlight-1.2.3.tgz",
+ "integrity": "sha512-qXdH7UqTvGfdVBINrgKhDsVTJTxactNNxLk7+UMwZhU13lMHaOBlJe9Vqp907ya56Y3+ed2tlqzys7jDkTmW0g==",
"license": "MIT",
"dependencies": {
- "state-local": "^1.0.6"
+ "@lezer/common": "^1.3.0"
}
},
+ "node_modules/@lezer/lr": {
+ "version": "1.4.10",
+ "resolved": "https://registry.npmjs.org/@lezer/lr/-/lr-1.4.10.tgz",
+ "integrity": "sha512-rnCpTIBafOx4mRp43xOxDJbFipJm/c0cia/V5TiGlhmMa+wsSdoGmUN3w5Bqrks/09Q/D4tNAmWaT8p6NRi77A==",
+ "license": "MIT",
+ "dependencies": {
+ "@lezer/common": "^1.0.0"
+ }
+ },
+ "node_modules/@lezer/yaml": {
+ "version": "1.0.4",
+ "resolved": "https://registry.npmjs.org/@lezer/yaml/-/yaml-1.0.4.tgz",
+ "integrity": "sha512-2lrrHqxalACEbxIbsjhqGpSW8kWpUKuY6RHgnSAFZa6qK62wvnPxA8hGOwOoDbwHcOFs5M4o27mjGu+P7TvBmw==",
+ "license": "MIT",
+ "dependencies": {
+ "@lezer/common": "^1.2.0",
+ "@lezer/highlight": "^1.0.0",
+ "@lezer/lr": "^1.4.0"
+ }
+ },
+ "node_modules/@marijn/find-cluster-break": {
+ "version": "1.0.2",
+ "resolved": "https://registry.npmjs.org/@marijn/find-cluster-break/-/find-cluster-break-1.0.2.tgz",
+ "integrity": "sha512-l0h88YhZFyKdXIFNfSWpyjStDjGHwZ/U7iobcK1cQQD8sejsONdQtTVU+1wVN1PBw40PiiHB1vA5S7VTfQiP9g==",
+ "license": "MIT"
+ },
"node_modules/@napi-rs/wasm-runtime": {
"version": "1.1.4",
"resolved": "https://registry.npmjs.org/@napi-rs/wasm-runtime/-/wasm-runtime-1.1.4.tgz",
@@ -6253,6 +6317,12 @@
"integrity": "sha512-QQpZx7oYxsR+OeITlZe46fY/OQjV11oBqjY8wgIXzLU2jIz8GzOrbMhqKLysGY8bWI3T1ZNrYkwGzKb4JNgyzg==",
"license": "MIT"
},
+ "node_modules/crelt": {
+ "version": "1.0.6",
+ "resolved": "https://registry.npmjs.org/crelt/-/crelt-1.0.6.tgz",
+ "integrity": "sha512-VQ2MBenTq1fWZUH9DJNGti7kKv6EeAuYr3cLwxUWhIu1baTaXh4Ib5W2CqHVqib4/MqbYGJqiL3Zb8GJZr3l4g==",
+ "license": "MIT"
+ },
"node_modules/cross-env": {
"version": "10.1.0",
"resolved": "https://registry.npmjs.org/cross-env/-/cross-env-10.1.0.tgz",
@@ -9546,12 +9616,6 @@
"pathe": "^2.0.1"
}
},
- "node_modules/monaco-editor": {
- "version": "0.52.2",
- "resolved": "https://registry.npmjs.org/monaco-editor/-/monaco-editor-0.52.2.tgz",
- "integrity": "sha512-GEQWEZmfkOGLdd3XK8ryrfWz3AIP8YymVXiPHEdewrUq7mh0qrKrfHLNCXcbB6sTnMLnOZ3ztSiKcciFUkIJwQ==",
- "license": "MIT"
- },
"node_modules/mqtt": {
"version": "5.15.1",
"resolved": "https://registry.npmjs.org/mqtt/-/mqtt-5.15.1.tgz",
@@ -11689,12 +11753,6 @@
"dev": true,
"license": "MIT"
},
- "node_modules/state-local": {
- "version": "1.0.7",
- "resolved": "https://registry.npmjs.org/state-local/-/state-local-1.0.7.tgz",
- "integrity": "sha512-HTEHMNieakEnoe33shBYcZ7NX83ACUjCu8c40iOGEZsngj9zRnkqS9j1pqQPXwobB0ZcVTk27REb7COQ0UR59w==",
- "license": "MIT"
- },
"node_modules/statuses": {
"version": "2.0.2",
"resolved": "https://registry.npmjs.org/statuses/-/statuses-2.0.2.tgz",
@@ -12010,6 +12068,12 @@
"url": "https://github.com/sponsors/sindresorhus"
}
},
+ "node_modules/style-mod": {
+ "version": "4.1.3",
+ "resolved": "https://registry.npmjs.org/style-mod/-/style-mod-4.1.3.tgz",
+ "integrity": "sha512-i/n8VsZydrugj3Iuzll8+x/00GH2vnYsk1eomD8QiRrSAeW6ItbCQDtfXCeJHd0iwiNagqjQkvpvREEPtW3IoQ==",
+ "license": "MIT"
+ },
"node_modules/supports-color": {
"version": "7.2.0",
"resolved": "https://registry.npmjs.org/supports-color/-/supports-color-7.2.0.tgz",
@@ -13882,6 +13946,12 @@
"url": "https://github.com/sponsors/jonschlinkert"
}
},
+ "node_modules/w3c-keyname": {
+ "version": "2.2.8",
+ "resolved": "https://registry.npmjs.org/w3c-keyname/-/w3c-keyname-2.2.8.tgz",
+ "integrity": "sha512-dpojBhNsCNN7T82Tm7k26A6G9ML3NkhDsnw9n/eoxSRlVBB4CEtIQ/KTCLI2Fwf3ataSXRhYFkQi3SlnFwPvPQ==",
+ "license": "MIT"
+ },
"node_modules/wait-on": {
"version": "9.0.10",
"resolved": "https://registry.npmjs.org/wait-on/-/wait-on-9.0.10.tgz",
diff --git a/package.json b/package.json
index a3da95c0c..a6b32ab68 100644
--- a/package.json
+++ b/package.json
@@ -20,9 +20,14 @@
"type": "module",
"main": "index.js",
"dependencies": {
+ "@codemirror/commands": "^6.8.1",
+ "@codemirror/lang-yaml": "^6.1.2",
+ "@codemirror/language": "^6.11.0",
+ "@codemirror/state": "^6.5.2",
+ "@codemirror/view": "^6.36.5",
"@formkit/drag-and-drop": "^0.6.0",
- "@guolao/vue-monaco-editor": "1.5.5",
"@h2d2/shopicons": "^1.9.1",
+ "@lezer/highlight": "^1.2.1",
"@popperjs/core": "^2.11.8",
"@unhead/vue": "^2.1.15",
"axios": "^1.17.0",
@@ -34,7 +39,6 @@
"countup.js": "^2.10.0",
"dayjs": "^1.11.21",
"echarts": "^6.1.0",
- "monaco-editor": "0.52.2",
"qrcode": "^1.5.4",
"smoothscroll-polyfill": "^0.4.4",
"snarkdown": "^2.0.0",
@@ -43,9 +47,6 @@
"vue-i18n": "^11.4.5",
"vue-router": "^5.1.0"
},
- "overrides": {
- "@monaco-editor/loader": "1.5.0"
- },
"engines": {
"npm": ">=10.0.0",
"node": ">=24.0.0"
diff --git a/tests/config-aux.spec.ts b/tests/config-aux.spec.ts
index 427c2a2f1..a6d64317e 100644
--- a/tests/config-aux.spec.ts
+++ b/tests/config-aux.spec.ts
@@ -174,13 +174,13 @@ energy:
);
// no errors
- await expect(editor.locator(".line-numbers.error")).toHaveCount(0);
+ await expect(editor.locator(".cm-errorLine")).toHaveCount(0);
const restResult = modal.getByTestId("test-result");
await expect(restResult).toContainText("Status: unknown");
await restResult.getByRole("link", { name: "validate" }).click();
await expect(restResult).toContainText("Status: failed");
await expect(restResult).toContainText("mapping values are not allowed in this context");
- await expect(editor.locator(".line-numbers.error")).toHaveCount(1);
+ await expect(editor.locator(".cm-errorLine")).toHaveCount(1);
// invalid field error
await editorClear(editor);
@@ -194,7 +194,7 @@ energy:
await restResult.getByRole("link", { name: "validate" }).click();
await expect(restResult).toContainText("Status: failed");
await expect(restResult).toContainText("has invalid keys: apower");
- await expect(editor.locator(".line-numbers.error")).toHaveCount(0);
+ await expect(editor.locator(".cm-errorLine")).toHaveCount(0);
// unknown source error
await editorClear(editor);
@@ -208,7 +208,7 @@ energy:
await restResult.getByRole("link", { name: "validate" }).click();
await expect(restResult).toContainText("Status: failed");
await expect(restResult).toContainText("invalid plugin type: unknown");
- await expect(editor.locator(".line-numbers.error")).toHaveCount(0);
+ await expect(editor.locator(".cm-errorLine")).toHaveCount(0);
// missing required field error
await editorClear(editor);
@@ -222,6 +222,6 @@ energy:
await restResult.getByRole("link", { name: "validate" }).click();
await expect(restResult).toContainText("Status: failed");
await expect(restResult).toContainText("power: missing plugin source");
- await expect(editor.locator(".line-numbers.error")).toHaveCount(0);
+ await expect(editor.locator(".cm-errorLine")).toHaveCount(0);
});
});
diff --git a/tests/config-messaging.spec.ts b/tests/config-messaging.spec.ts
index 38049c3d0..a72861fe8 100644
--- a/tests/config-messaging.spec.ts
+++ b/tests/config-messaging.spec.ts
@@ -42,6 +42,11 @@ test.describe("messaging", async () => {
start:
title: Charge started
msg: Started charging`;
+ // differs from the stored config so saving is an actual change
+ const changedYaml = `events:
+ start:
+ title: Charge started
+ msg: Charging has begun`;
// default content
const editor = modal.getByTestId("yaml-editor");
@@ -55,7 +60,7 @@ test.describe("messaging", async () => {
// clear and enter valid yaml
await editorClear(editor);
- await editorPaste(editor, page, validYaml);
+ await editorPaste(editor, page, changedYaml);
await page.getByRole("button", { name: "Save" }).click();
await expect(modal.getByTestId("error")).not.toBeVisible();
diff --git a/tests/utils.ts b/tests/utils.ts
index 931469c4c..8c57c67cc 100644
--- a/tests/utils.ts
+++ b/tests/utils.ts
@@ -18,25 +18,19 @@ export async function expectModalHidden(modal: Locator): Promise {
}
export async function editorClear(editor: Locator): Promise {
- const firstLine = editor.locator(".view-line").nth(0);
- const content = editor.locator(".view-lines");
+ const content = editor.locator(".cm-content");
// wait for the async content load, otherwise clearing races the arriving text
- await expect(firstLine).not.toHaveText("");
- // retry until the editor is verified empty, keystrokes are dropped while monaco initializes
- const deadline = Date.now() + 10_000;
- do {
- await firstLine.click();
- await editor.page().keyboard.press("ControlOrMeta+KeyA", { delay: 50 });
- await editor.page().keyboard.press("Backspace", { delay: 50 });
- if ((await content.textContent())?.trim() === "") return;
- } while (Date.now() < deadline);
+ await expect(content).not.toHaveText("");
+ await content.click();
+ await editor.page().keyboard.press("ControlOrMeta+KeyA");
+ await editor.page().keyboard.press("Backspace");
await expect(content, "editor should be empty after clearing").toHaveText("");
}
export async function editorPaste(editor: Locator, page: Page, text: string): Promise {
- await editor.locator(".view-line").nth(0).click();
+ await editor.locator(".cm-content").click();
await page.evaluate((text) => navigator.clipboard.writeText(text), text);
- await page.keyboard.press("ControlOrMeta+KeyV", { delay: 100 });
+ await page.keyboard.press("ControlOrMeta+KeyV");
}
export enum LoadpointType {