Add code editor to YAML editor (#2609)

* Add code editor to YAML editor

* Add code editor to raw config editor

* Remove hui-yaml-editor

* Update src/panels/lovelace/components/hui-code-editor.ts

Co-Authored-By: bramkragten <mail@bramkragten.nl>

* Update src/panels/lovelace/components/hui-code-editor.ts

Co-Authored-By: bramkragten <mail@bramkragten.nl>

* Rename to hui-yaml-editor

* Lint and tab

* Fix empty editor

* Lint

* Use codemirror for comment and edit detection + some styling

* Add save action (ctrl+s/cmd+s) to card editor

* Move save to the instance

* Delete save for now

* Remove yaml-change event on init
This commit is contained in:
Paulus Schoutsen 2019-01-30 13:03:17 -08:00 committed by GitHub
commit 7cb2b743fa
No known key found for this signature in database
GPG Key ID: 4AEE18F83AFDEB23
7 changed files with 177 additions and 178 deletions

View File

@ -68,6 +68,7 @@
"@webcomponents/webcomponentsjs": "^2.2.0",
"chart.js": "~2.7.2",
"chartjs-chart-timeline": "^0.2.1",
"codemirror": "^5.43.0",
"deep-clone-simple": "^1.1.1",
"es6-object-assign": "^1.1.0",
"eslint-import-resolver-webpack": "^0.10.1",
@ -102,6 +103,7 @@
"@babel/preset-typescript": "^7.1.0",
"@gfx/zopfli": "^1.0.9",
"@types/chai": "^4.1.7",
"@types/codemirror": "^0.0.71",
"@types/mocha": "^5.2.5",
"babel-eslint": "^10",
"babel-loader": "^8.0.4",

View File

@ -0,0 +1,93 @@
// @ts-ignore
import CodeMirror from "codemirror";
import "codemirror/mode/yaml/yaml";
// @ts-ignore
import codeMirrorCSS from "codemirror/lib/codemirror.css";
import { fireEvent } from "../../../common/dom/fire_event";
declare global {
interface HASSDomEvents {
"yaml-changed": {
value: string;
};
}
}
export class HuiYamlEditor extends HTMLElement {
public codemirror: CodeMirror;
private _value: string;
public constructor() {
super();
this._value = "";
const shadowRoot = this.attachShadow({ mode: "open" });
shadowRoot.innerHTML = `
<style>
${codeMirrorCSS}
.CodeMirror {
height: var(--code-mirror-height, 300px);
direction: var(--code-mirror-direction, ltr);
}
.CodeMirror-gutters {
border-right: 1px solid var(--paper-input-container-color, var(--secondary-text-color));
background-color: var(--paper-dialog-background-color, var(--primary-background-color));
transition: 0.2s ease border-right;
}
.CodeMirror-focused .CodeMirror-gutters {
border-right: 2px solid var(--paper-input-container-focus-color, var(--primary-color));;
}
.CodeMirror-linenumber {
color: var(--paper-dialog-color, var(--primary-text-color));
}
</style>`;
}
set value(value: string) {
if (this.codemirror) {
if (value !== this.codemirror.getValue()) {
this.codemirror.setValue(value);
}
}
this._value = value;
}
get value(): string {
return this.codemirror.getValue();
}
get hasComments(): boolean {
return this.shadowRoot!.querySelector("span.cm-comment") ? true : false;
}
public connectedCallback(): void {
if (!this.codemirror) {
this.codemirror = CodeMirror(this.shadowRoot, {
value: this._value,
lineNumbers: true,
mode: "yaml",
tabSize: 2,
autofocus: true,
extraKeys: {
Tab: (cm: CodeMirror) => {
const spaces = Array(cm.getOption("indentUnit") + 1).join(" ");
cm.replaceSelection(spaces);
},
},
});
this.codemirror.on("changes", () => this._onChange());
} else {
this.codemirror.refresh();
}
}
private _onChange(): void {
fireEvent(this, "yaml-changed", { value: this.codemirror.getValue() });
}
}
declare global {
interface HTMLElementTagNameMap {
"hui-yaml-editor": HuiYamlEditor;
}
}
window.customElements.define("hui-yaml-editor", HuiYamlEditor);

View File

@ -23,23 +23,22 @@ import { HomeAssistant } from "../../../../types";
import { LovelaceCardConfig } from "../../../../data/lovelace";
import { fireEvent } from "../../../../common/dom/fire_event";
import "./hui-yaml-editor";
import "../../components/hui-yaml-editor";
// This is not a duplicate import, one is for types, one is for element.
// tslint:disable-next-line
import { HuiYamlEditor } from "../../components/hui-yaml-editor";
import "./hui-card-preview";
// This is not a duplicate import, one is for types, one is for element.
// tslint:disable-next-line
import { HuiCardPreview } from "./hui-card-preview";
import { LovelaceCardEditor, Lovelace } from "../../types";
import { YamlChangedEvent, ConfigValue, ConfigError } from "../types";
import { extYamlSchema } from "../yaml-ext-schema";
import { ConfigValue, ConfigError } from "../types";
import { EntityConfig } from "../../entity-rows/types";
import { getCardElementTag } from "../../common/get-card-element-tag";
import { addCard, replaceCard } from "../config-util";
declare global {
interface HASSDomEvents {
"yaml-changed": {
yaml: string;
};
"entities-changed": {
entities: EntityConfig[];
};
@ -120,8 +119,7 @@ export class HuiEditCard extends LitElement {
? this._configElement
: html`
<hui-yaml-editor
.hass="${this.hass}"
.yaml="${this._configValue!.value}"
.value="${this._configValue!.value}"
@yaml-changed="${this._handleYamlChanged}"
></hui-yaml-editor>
`}
@ -195,6 +193,11 @@ export class HuiEditCard extends LitElement {
await this.updateComplete;
this._loading = false;
this._resizeDialog();
if (!this._uiEditor) {
setTimeout(() => {
this.yamlEditor.codemirror.refresh();
}, 1);
}
}
private async _resizeDialog(): Promise<void> {
@ -217,9 +220,7 @@ export class HuiEditCard extends LitElement {
const cardConf: LovelaceCardConfig =
this._configValue!.format === "yaml"
? yaml.safeLoad(this._configValue!.value!, {
schema: extYamlSchema,
})
? yaml.safeLoad(this._configValue!.value!)
: this._configValue!.value!;
try {
@ -241,12 +242,12 @@ export class HuiEditCard extends LitElement {
}
}
private _handleYamlChanged(ev: YamlChangedEvent): void {
this._configValue = { format: "yaml", value: ev.detail.yaml };
private _handleYamlChanged(ev: CustomEvent): void {
this._configValue = { format: "yaml", value: ev.detail.value };
try {
const config = yaml.safeLoad(this._configValue.value, {
schema: extYamlSchema,
}) as LovelaceCardConfig;
const config = yaml.safeLoad(
this._configValue.value
) as LovelaceCardConfig;
this._updatePreview(config);
this._configState = "OK";
} catch (err) {
@ -263,7 +264,9 @@ export class HuiEditCard extends LitElement {
this._updatePreview(value);
}
private _updatePreview(config: LovelaceCardConfig) {
private async _updatePreview(config: LovelaceCardConfig) {
await this.updateComplete;
if (!this._previewEl) {
return;
}
@ -295,9 +298,7 @@ export class HuiEditCard extends LitElement {
this._uiEditor = !this._uiEditor;
} else if (this._configElement && this._configValue!.format === "yaml") {
const yamlConfig = this._configValue!.value;
const cardConfig = yaml.safeLoad(yamlConfig, {
schema: extYamlSchema,
}) as LovelaceCardConfig;
const cardConfig = yaml.safeLoad(yamlConfig) as LovelaceCardConfig;
this._uiEditor = !this._uiEditor;
if (cardConfig.type !== this._cardType) {
const succes = await this._loadConfigElement(cardConfig);
@ -356,6 +357,7 @@ export class HuiEditCard extends LitElement {
configElement = await elClass.getConfigElement();
} else {
this._configValue = { format: "yaml", value: yaml.safeDump(conf) };
this._updatePreview(conf);
this._uiEditor = false;
this._configElement = null;
return false;
@ -372,6 +374,7 @@ export class HuiEditCard extends LitElement {
format: "yaml",
value: yaml.safeDump(conf),
};
this._updatePreview(conf);
this._uiEditor = false;
this._configElement = null;
return false;
@ -398,6 +401,10 @@ export class HuiEditCard extends LitElement {
}
}
private get yamlEditor(): HuiYamlEditor {
return this.shadowRoot!.querySelector("hui-yaml-editor")!;
}
static get styles(): CSSResult[] {
return [
haStyleDialog,

View File

@ -1,64 +0,0 @@
import {
html,
LitElement,
PropertyDeclarations,
TemplateResult,
} from "lit-element";
import "@polymer/paper-input/paper-textarea";
import { HomeAssistant } from "../../../../types";
import { fireEvent } from "../../../../common/dom/fire_event";
export class HuiYAMLEditor extends LitElement {
protected hass?: HomeAssistant;
private _yaml?: string;
static get properties(): PropertyDeclarations {
return { _yaml: {} };
}
set yaml(yaml: string) {
if (yaml === undefined) {
return;
} else {
this._yaml = yaml;
}
}
protected render(): TemplateResult | void {
return html`
${this.renderStyle()}
<paper-textarea
max-rows="10"
.value="${this._yaml}"
@value-changed="${this._valueChanged}"
></paper-textarea>
`;
}
private renderStyle(): TemplateResult {
return html`
<style>
paper-textarea {
--paper-input-container-shared-input-style_-_font-family: monospace;
}
</style>
`;
}
private _valueChanged(ev: Event): void {
const target = ev.target! as any;
this._yaml = target.value;
fireEvent(this, "yaml-changed", {
yaml: target.value,
});
}
}
declare global {
interface HTMLElementTagNameMap {
"hui-yaml-editor": HuiYAMLEditor;
}
}
customElements.define("hui-yaml-editor", HuiYAMLEditor);

View File

@ -1,22 +0,0 @@
import yaml from "js-yaml";
const secretYamlType = new yaml.Type("!secret", {
kind: "scalar",
construct(data) {
data = data || "";
return "!secret " + data;
},
});
const includeYamlType = new yaml.Type("!include", {
kind: "scalar",
construct(data) {
data = data || "";
return "!include " + data;
},
});
export const extYamlSchema = yaml.Schema.create([
secretYamlType,
includeYamlType,
]);

View File

@ -14,8 +14,10 @@ import { Lovelace } from "./types";
import "../../components/ha-icon";
import { haStyle } from "../../resources/ha-style";
const TAB_INSERT = " ";
import "./components/hui-yaml-editor";
// This is not a duplicate import, one is for types, one is for element.
// tslint:disable-next-line
import { HuiYamlEditor } from "./components/hui-yaml-editor";
const lovelaceStruct = struct.interface({
title: "string?",
@ -28,16 +30,13 @@ class LovelaceFullConfigEditor extends LitElement {
public closeEditor?: () => void;
private _saving?: boolean;
private _changed?: boolean;
private _hashAdded?: boolean;
private _hash?: boolean;
private _generation?: number;
static get properties() {
return {
lovelace: {},
_saving: {},
_changed: {},
_hashAdded: {},
_hash: {},
};
}
@ -51,11 +50,6 @@ class LovelaceFullConfigEditor extends LitElement {
@click="${this._closeEditor}"
></paper-icon-button>
<div main-title>Edit Config</div>
${this._hash
? html`
<span class="comments">Comments will be not be saved!</span>
`
: ""}
<paper-button @click="${this._handleSave}">Save</paper-button>
<ha-icon
class="save-button
@ -67,52 +61,27 @@ class LovelaceFullConfigEditor extends LitElement {
</app-toolbar>
</app-header>
<div class="content">
<textarea
autocomplete="off"
autocorrect="off"
autocapitalize="off"
spellcheck="false"
@input="${this._yamlChanged}"
></textarea>
<hui-yaml-editor @yaml-changed="${this._yamlChanged}">
</hui-yaml-editor>
</div>
</app-header-layout>
`;
}
protected firstUpdated() {
const textArea = this.textArea;
textArea.value = yaml.safeDump(this.lovelace!.config);
textArea.addEventListener("keydown", (e) => {
if (e.keyCode === 51) {
this._hashAdded = true;
return;
}
if (e.keyCode !== 9) {
return;
}
e.preventDefault();
// tab was pressed, get caret position/selection
const val = textArea.value;
const start = textArea.selectionStart;
const end = textArea.selectionEnd;
// set textarea value to: text before caret + tab + text after caret
textArea.value =
val.substring(0, start) + TAB_INSERT + val.substring(end);
// put caret at right position again
textArea.selectionStart = textArea.selectionEnd =
start + TAB_INSERT.length;
});
this.yamlEditor.value = yaml.safeDump(this.lovelace!.config);
this.yamlEditor.codemirror.clearHistory();
this._generation = this.yamlEditor.codemirror.changeGeneration(true);
}
static get styles(): CSSResult[] {
return [
haStyle,
css`
:host {
--code-mirror-height: 100%;
}
app-header-layout {
height: 100vh;
}
@ -132,16 +101,8 @@ class LovelaceFullConfigEditor extends LitElement {
height: calc(100vh - 68px);
}
textarea {
box-sizing: border-box;
hui-code-editor {
height: 100%;
width: 100%;
resize: none;
border: 0;
outline: 0;
font-size: 12pt;
font-family: "Courier New", Courier, monospace;
padding: 8px;
}
.save-button {
@ -158,6 +119,20 @@ class LovelaceFullConfigEditor extends LitElement {
];
}
private _yamlChanged() {
if (!this._generation) {
return;
}
this._changed = !this.yamlEditor.codemirror.isClean(this._generation);
if (this._changed && !window.onbeforeunload) {
window.onbeforeunload = () => {
return true;
};
} else if (!this._changed && window.onbeforeunload) {
window.onbeforeunload = null;
}
}
private _closeEditor() {
if (this._changed) {
if (
@ -173,10 +148,10 @@ class LovelaceFullConfigEditor extends LitElement {
private async _handleSave() {
this._saving = true;
if (this._hashAdded) {
if (this.yamlEditor.hasComments) {
if (
!confirm(
"Your config might contain comments, these will not be saved. Do you want to continue?"
"Your config contains comment(s), these will not be saved. Do you want to continue?"
)
) {
return;
@ -185,7 +160,7 @@ class LovelaceFullConfigEditor extends LitElement {
let value;
try {
value = yaml.safeLoad(this.textArea.value);
value = yaml.safeLoad(this.yamlEditor.value);
} catch (err) {
alert(`Unable to parse YAML: ${err}`);
this._saving = false;
@ -202,25 +177,14 @@ class LovelaceFullConfigEditor extends LitElement {
} catch (err) {
alert(`Unable to save YAML: ${err}`);
}
this._generation = this.yamlEditor.codemirror.changeGeneration(true);
window.onbeforeunload = null;
this._saving = false;
this._changed = false;
this._hashAdded = false;
}
private _yamlChanged() {
this._hash = this._hashAdded || this.textArea.value.includes("#");
if (this._changed) {
return;
}
window.onbeforeunload = () => {
return true;
};
this._changed = true;
}
private get textArea(): HTMLTextAreaElement {
return this.shadowRoot!.querySelector("textarea")!;
private get yamlEditor(): HuiYamlEditor {
return this.shadowRoot!.querySelector("hui-yaml-editor")!;
}
}

View File

@ -1436,6 +1436,13 @@
resolved "https://registry.yarnpkg.com/@types/clone/-/clone-0.1.30.tgz#e7365648c1b42136a59c7d5040637b3b5c83b614"
integrity sha1-5zZWSMG0ITalnH1QQGN7O1yDthQ=
"@types/codemirror@^0.0.71":
version "0.0.71"
resolved "https://registry.yarnpkg.com/@types/codemirror/-/codemirror-0.0.71.tgz#861f1bcb3100c0a064567c5400f2981cf4ae8ca7"
integrity sha512-b2oEEnno1LIGKMR7uBEsr40al1UijF1HEpRn0+Yf1xOLl24iQgB7DBpZVMM7y54G5wCNoclDrRO65E6KHPNO2w==
dependencies:
"@types/tern" "*"
"@types/compression@^0.0.33":
version "0.0.33"
resolved "https://registry.yarnpkg.com/@types/compression/-/compression-0.0.33.tgz#95dc733a2339aa846381d7f1377792d2553dc27d"
@ -1477,7 +1484,7 @@
resolved "https://registry.yarnpkg.com/@types/escape-html/-/escape-html-0.0.20.tgz#cae698714dd61ebee5ab3f2aeb9a34ba1011735a"
integrity sha512-6dhZJLbA7aOwkYB2GDGdIqJ20wmHnkDzaxV9PJXe7O02I2dSFTERzRB6JrX6cWKaS+VqhhY7cQUMCbO5kloFUw==
"@types/estree@0.0.39":
"@types/estree@*", "@types/estree@0.0.39":
version "0.0.39"
resolved "https://registry.yarnpkg.com/@types/estree/-/estree-0.0.39.tgz#e177e699ee1b8c22d23174caaa7422644389509f"
integrity sha512-EYNwp3bU+98cpU4lAWYYL7Zz+2gryWH1qbdDTidVd6hkiR6weksdbMadyXKXNPEkQFhXM+hVO9ZygomHXp+AIw==
@ -1850,6 +1857,13 @@
dependencies:
"@types/node" "*"
"@types/tern@*":
version "0.22.1"
resolved "https://registry.yarnpkg.com/@types/tern/-/tern-0.22.1.tgz#d96467553128794f42fbe7ba8f60b520acffb817"
integrity sha512-CRzPRkg8hYLwunsj61r+rqPJQbiCIEQqlMMY/0k7krgIsoSaFgGg1ZH2f9qaR1YpenaMl6PnlTtUkCbNH/uo+A==
dependencies:
"@types/estree" "*"
"@types/through@*":
version "0.0.29"
resolved "https://registry.yarnpkg.com/@types/through/-/through-0.0.29.tgz#72943aac922e179339c651fa34a4428a4d722f93"
@ -4281,6 +4295,11 @@ code-point-at@^1.0.0:
resolved "https://registry.yarnpkg.com/code-point-at/-/code-point-at-1.1.0.tgz#0d070b4d043a5bea33a2f1a40e2edb3d9a4ccf77"
integrity sha1-DQcLTQQ6W+ozovGkDi7bPZpMz3c=
codemirror@^5.43.0:
version "5.43.0"
resolved "https://registry.yarnpkg.com/codemirror/-/codemirror-5.43.0.tgz#2454b5e0f7005dc9945ab7b0d9594ccf233da040"
integrity sha512-mljwQWUaWIf85I7QwTBryF2ASaIvmYAL4s5UCanCJFfKeXOKhrqdHWdHiZWAMNT+hjLTCnVx2S/SYTORIgxsgA==
collection-visit@^1.0.0:
version "1.0.0"
resolved "https://registry.yarnpkg.com/collection-visit/-/collection-visit-1.0.0.tgz#4bc0373c164bc3291b4d368c829cf1a80a59dca0"