diff --git a/src/components/ha-code-editor.ts b/src/components/ha-code-editor.ts
index a5b04b87f6..9a9666d954 100644
--- a/src/components/ha-code-editor.ts
+++ b/src/components/ha-code-editor.ts
@@ -5,9 +5,16 @@ import type {
CompletionResult,
CompletionSource,
} from "@codemirror/autocomplete";
+import { undo, undoDepth, redo, redoDepth } from "@codemirror/commands";
import type { Extension, TransactionSpec } from "@codemirror/state";
import type { EditorView, KeyBinding, ViewUpdate } from "@codemirror/view";
-import { mdiArrowExpand, mdiArrowCollapse } from "@mdi/js";
+import {
+ mdiArrowExpand,
+ mdiArrowCollapse,
+ mdiContentCopy,
+ mdiUndo,
+ mdiRedo,
+} from "@mdi/js";
import type { HassEntities } from "home-assistant-js-websocket";
import type { PropertyValues } from "lit";
import { css, ReactiveElement, html, render } from "lit";
@@ -16,11 +23,14 @@ import memoizeOne from "memoize-one";
import { fireEvent } from "../common/dom/fire_event";
import { stopPropagation } from "../common/dom/stop_propagation";
import { getEntityContext } from "../common/entity/context/get_entity_context";
+import { copyToClipboard } from "../common/util/copy-clipboard";
import type { HomeAssistant } from "../types";
+import { showToast } from "../util/toast";
+import "./ha-code-editor-completion-items";
import type { CompletionItem } from "./ha-code-editor-completion-items";
import "./ha-icon";
-import "./ha-icon-button";
-import "./ha-code-editor-completion-items";
+import "./ha-icon-button-toolbar";
+import type { HaIconButtonToolbar } from "./ha-icon-button-toolbar";
declare global {
interface HASSDomEvents {
@@ -68,13 +78,24 @@ export class HaCodeEditor extends ReactiveElement {
@property({ type: Boolean, attribute: "disable-fullscreen" })
public disableFullscreen = false;
+ @property({ type: Boolean, attribute: "has-toolbar" })
+ public hasToolbar = true;
+
@state() private _value = "";
@state() private _isFullscreen = false;
+ @state() private _canUndo = false;
+
+ @state() private _canRedo = false;
+
+ @state() private _canCopy = false;
+
// eslint-disable-next-line @typescript-eslint/consistent-type-imports
private _loadedCodeMirror?: typeof import("../resources/codemirror");
+ private _editorToolbar?: HaIconButtonToolbar;
+
private _iconList?: Completion[];
public set value(value: string) {
@@ -119,9 +140,7 @@ export class HaCodeEditor extends ReactiveElement {
super.disconnectedCallback();
this.removeEventListener("keydown", stopPropagation);
this.removeEventListener("keydown", this._handleKeyDown);
- if (this._isFullscreen) {
- this._toggleFullscreen();
- }
+ this._updateFullscreenState(false);
this.updateComplete.then(() => {
this.codemirror!.destroy();
delete this.codemirror;
@@ -157,6 +176,7 @@ export class HaCodeEditor extends ReactiveElement {
this._loadedCodeMirror!.EditorView!.editable.of(!this.readOnly)
),
});
+ this._updateToolbarButtons();
}
if (changedProps.has("linewrap")) {
transactions.push({
@@ -177,14 +197,25 @@ export class HaCodeEditor extends ReactiveElement {
if (transactions.length > 0) {
this.codemirror.dispatch(...transactions);
}
+ if (changedProps.has("hasToolbar")) {
+ this._updateToolbar();
+ }
if (changedProps.has("error")) {
this.classList.toggle("error-state", this.error);
}
if (changedProps.has("_isFullscreen")) {
this.classList.toggle("fullscreen", this._isFullscreen);
+ this._updateToolbarButtons();
+ }
+ if (
+ changedProps.has("_canCopy") ||
+ changedProps.has("_canUndo") ||
+ changedProps.has("_canRedo")
+ ) {
+ this._updateToolbarButtons();
}
if (changedProps.has("disableFullscreen")) {
- this._updateFullscreenButton();
+ this._updateFullscreenState();
}
}
@@ -253,6 +284,7 @@ export class HaCodeEditor extends ReactiveElement {
}
}
+ // Create the code editor
this.codemirror = new this._loadedCodeMirror.EditorView({
state: this._loadedCodeMirror.EditorState.create({
doc: this._value,
@@ -260,71 +292,160 @@ export class HaCodeEditor extends ReactiveElement {
}),
parent: this.renderRoot,
});
+ this._canCopy = this._value?.length > 0;
- this._updateFullscreenButton();
+ // Update the toolbar. Creating it if required
+ this._updateToolbar();
}
- private _updateFullscreenButton() {
- const existingButton = this.renderRoot.querySelector(".fullscreen-button");
+ private _fullscreenLabel(): string {
+ if (this._isFullscreen)
+ return (
+ this.hass?.localize("ui.components.yaml-editor.exit_fullscreen") ||
+ "Exit fullscreen"
+ );
+ return (
+ this.hass?.localize("ui.components.yaml-editor.enter_fullscreen") ||
+ "Enter fullscreen"
+ );
+ }
- if (this.disableFullscreen) {
- // Remove button if it exists and fullscreen is disabled
- if (existingButton) {
- existingButton.remove();
- }
- // Exit fullscreen if currently in fullscreen mode
- if (this._isFullscreen) {
- this._isFullscreen = false;
- }
+ private _fullscreenIcon(): string {
+ return this._isFullscreen ? mdiArrowCollapse : mdiArrowExpand;
+ }
+
+ private _createEditorToolbar(): HaIconButtonToolbar {
+ // Create the editor toolbar element
+ const editorToolbar = document.createElement("ha-icon-button-toolbar");
+ editorToolbar.classList.add("code-editor-toolbar");
+ editorToolbar.items = [];
+ return editorToolbar;
+ }
+
+ private _updateToolbar() {
+ // Show/Hide the toolbar if we have one.
+ this.classList.toggle("hasToolbar", this.hasToolbar);
+
+ // Update fullscreen state. Handles toolbar and fullscreen mode being disabled.
+ this._updateFullscreenState();
+
+ // If we don't have a toolbar, nothing to update
+ if (!this.hasToolbar) {
return;
}
- // Create button if it doesn't exist
- if (!existingButton) {
- const button = document.createElement("ha-icon-button");
- (button as any).path = this._isFullscreen
- ? mdiArrowCollapse
- : mdiArrowExpand;
- button.setAttribute(
- "label",
- this._isFullscreen ? "Exit fullscreen" : "Enter fullscreen"
- );
- button.classList.add("fullscreen-button");
- // Use bound method to ensure proper this context
- button.addEventListener("click", this._handleFullscreenClick);
- this.renderRoot.appendChild(button);
- } else {
- // Update existing button
- (existingButton as any).path = this._isFullscreen
- ? mdiArrowCollapse
- : mdiArrowExpand;
- existingButton.setAttribute(
- "label",
- this._isFullscreen ? "Exit fullscreen" : "Enter fullscreen"
- );
+ // If we don't yet have the toolbar, create it.
+ if (!this._editorToolbar) {
+ this._editorToolbar = this._createEditorToolbar();
}
+
+ // Ensure all toolbar buttons are correctly configured.
+ this._updateToolbarButtons();
+
+ // Render the toolbar. This must be placed as a child of the code
+ // mirror element to ensure it doesn't affect the positioning and
+ // size of codemirror.
+ this.codemirror?.dom.appendChild(this._editorToolbar);
}
+ private _updateToolbarButtons() {
+ // Re-render all toolbar items.
+ if (!this._editorToolbar) {
+ return;
+ }
+
+ this._editorToolbar.items = [
+ {
+ id: "undo",
+ disabled: !this._canUndo,
+ label: this.hass?.localize("ui.common.undo") || "Undo",
+ path: mdiUndo,
+ action: (e: Event) => this._handleUndoClick(e),
+ },
+ {
+ id: "redo",
+ disabled: !this._canRedo,
+ label: this.hass?.localize("ui.common.redo") || "Redo",
+ path: mdiRedo,
+ action: (e: Event) => this._handleRedoClick(e),
+ },
+ {
+ id: "copy",
+ disabled: !this._canCopy,
+ label:
+ this.hass?.localize("ui.components.yaml-editor.copy_to_clipboard") ||
+ "Copy to Clipboard",
+ path: mdiContentCopy,
+ action: (e: Event) => this._handleClipboardClick(e),
+ },
+ {
+ id: "fullscreen",
+ disabled: this.disableFullscreen,
+ label: this._fullscreenLabel(),
+ path: this._fullscreenIcon(),
+ action: (e: Event) => this._handleFullscreenClick(e),
+ },
+ ];
+ }
+
+ private _updateFullscreenState(
+ fullscreen: boolean = this._isFullscreen
+ ): boolean {
+ // Update the current fullscreen state based on selected value. If fullscreen
+ // is disabled, or we have no toolbar, ensure we are not in fullscreen mode.
+ this._isFullscreen =
+ fullscreen && !this.disableFullscreen && this.hasToolbar;
+ // Return whether successfully in requested state
+ return this._isFullscreen === fullscreen;
+ }
+
+ private _handleClipboardClick = async (e: Event) => {
+ e.preventDefault();
+ e.stopPropagation();
+ if (this.value) {
+ await copyToClipboard(this.value);
+ showToast(this, {
+ message:
+ this.hass?.localize("ui.common.copied_clipboard") ||
+ "Copied to clipboard",
+ });
+ }
+ };
+
+ private _handleUndoClick = (e: Event) => {
+ e.preventDefault();
+ e.stopPropagation();
+ if (!this.codemirror) {
+ return;
+ }
+ undo(this.codemirror);
+ };
+
+ private _handleRedoClick = (e: Event) => {
+ e.preventDefault();
+ e.stopPropagation();
+ if (!this.codemirror) {
+ return;
+ }
+ redo(this.codemirror);
+ };
+
private _handleFullscreenClick = (e: Event) => {
e.preventDefault();
e.stopPropagation();
- this._toggleFullscreen();
+ this._updateFullscreenState(!this._isFullscreen);
};
- private _toggleFullscreen() {
- this._isFullscreen = !this._isFullscreen;
- this._updateFullscreenButton();
- }
-
private _handleKeyDown = (e: KeyboardEvent) => {
- if (this._isFullscreen && e.key === "Escape") {
+ if (
+ (e.key === "Escape" &&
+ this._isFullscreen &&
+ this._updateFullscreenState(false)) ||
+ (e.key === "F11" && this._updateFullscreenState(true))
+ ) {
+ // If we successfully performed the action, stop it propagating further.
e.preventDefault();
e.stopPropagation();
- this._toggleFullscreen();
- } else if (e.key === "F11" && !this.disableFullscreen) {
- e.preventDefault();
- e.stopPropagation();
- this._toggleFullscreen();
}
};
@@ -592,10 +713,13 @@ export class HaCodeEditor extends ReactiveElement {
}
private _onUpdate = (update: ViewUpdate): void => {
+ this._canUndo = !this.readOnly && undoDepth(update.state) > 0;
+ this._canRedo = !this.readOnly && redoDepth(update.state) > 0;
if (!update.docChanged) {
return;
}
this._value = update.state.doc.toString();
+ this._canCopy = this._value?.length > 0;
fireEvent(this, "value-changed", { value: this._value });
};
@@ -614,39 +738,33 @@ export class HaCodeEditor extends ReactiveElement {
:host {
position: relative;
display: block;
+ --code-editor-toolbar-height: 28px;
}
:host(.error-state) .cm-gutters {
- border-color: var(--error-state-color, red);
+ border-color: var(--error-state-color, var(--error-color)) !important;
}
- .fullscreen-button {
- position: absolute;
- top: 8px;
- right: 8px;
- z-index: 1;
- color: var(--secondary-text-color);
- background-color: var(--secondary-background-color);
- border-radius: 50%;
- opacity: 0.9;
- transition: opacity 0.2s;
- --mdc-icon-button-size: 32px;
- --mdc-icon-size: 18px;
- /* Ensure button is clickable on iOS */
- cursor: pointer;
- -webkit-tap-highlight-color: transparent;
- touch-action: manipulation;
+ :host(.hasToolbar) .cm-gutters {
+ padding-top: 0;
}
- .fullscreen-button:hover,
- .fullscreen-button:active {
- opacity: 1;
+ :host(.hasToolbar) .cm-focused .cm-gutters {
+ padding-top: 1px;
}
- @media (hover: none) {
- .fullscreen-button {
- opacity: 0.8;
- }
+ :host(.error-state) .cm-content {
+ border-color: var(--error-state-color, var(--error-color)) !important;
+ }
+
+ :host(.hasToolbar) .cm-content {
+ border: none;
+ border-top: 1px solid var(--secondary-text-color);
+ }
+
+ :host(.hasToolbar) .cm-focused .cm-content {
+ border-top: 2px solid var(--primary-color);
+ padding-top: 15px;
}
:host(.fullscreen) {
@@ -655,7 +773,7 @@ export class HaCodeEditor extends ReactiveElement {
left: 8px !important;
right: 8px !important;
bottom: 8px !important;
- z-index: 9999 !important;
+ z-index: 6;
border-radius: 12px !important;
box-shadow: 0 2px 10px rgba(0, 0, 0, 0.3) !important;
overflow: hidden !important;
@@ -672,15 +790,28 @@ export class HaCodeEditor extends ReactiveElement {
display: block !important;
}
+ :host(.hasToolbar) .cm-editor {
+ padding-top: var(--code-editor-toolbar-height);
+ }
+
:host(.fullscreen) .cm-editor {
height: 100% !important;
max-height: 100% !important;
border-radius: 0 !important;
}
- :host(.fullscreen) .fullscreen-button {
- top: calc(var(--safe-area-inset-top, 0px) + 8px);
- right: calc(var(--safe-area-inset-right, 0px) + 8px);
+ :host(:not(.hasToolbar)) .code-editor-toolbar {
+ display: none !important;
+ }
+
+ .code-editor-toolbar {
+ --icon-button-toolbar-height: var(--code-editor-toolbar-height);
+ --icon-button-toolbar-color: var(
+ --code-editor-gutter-color,
+ var(--secondary-background-color, whitesmoke)
+ );
+ border-top-left-radius: var(--ha-border-radius-sm);
+ border-top-right-radius: var(--ha-border-radius-sm);
}
.completion-info {
diff --git a/src/components/ha-icon-button-toolbar.ts b/src/components/ha-icon-button-toolbar.ts
new file mode 100644
index 0000000000..742d0ab125
--- /dev/null
+++ b/src/components/ha-icon-button-toolbar.ts
@@ -0,0 +1,126 @@
+import type { TemplateResult } from "lit";
+import { css, html, LitElement } from "lit";
+import { customElement, property, queryAll } from "lit/decorators";
+import "./ha-icon";
+import "./ha-icon-button";
+import type { HaIconButton } from "./ha-icon-button";
+import "./ha-icon-button-group";
+import "./ha-tooltip";
+
+export interface HaIconButtonToolbarItem {
+ [key: string]: any;
+ path: string;
+ label: string;
+ id?: string;
+ disabled?: boolean;
+ tooltip?: string;
+ action?: (e: Event) => any;
+}
+
+@customElement("ha-icon-button-toolbar")
+export class HaIconButtonToolbar extends LitElement {
+ @property({ type: Array, attribute: false })
+ public items: (HaIconButtonToolbarItem | string)[] = [];
+
+ @queryAll("ha-icon-button") private _buttons?: HaIconButton[];
+
+ // Returns all toolbar buttons, or undefined if there are none.
+ // Optionally returns only those with matching selector.
+ public findToolbarButtons(selector = ""): HaIconButton[] | undefined {
+ // Search for all toolbar buttons
+ const toolbarButtons = this._buttons?.filter((button) =>
+ button.classList.contains("icon-toolbar-button")
+ );
+ if (!toolbarButtons || !toolbarButtons.length) return undefined;
+ if (!selector.length) return toolbarButtons;
+ // Filter by user class if provided
+ const classButtons = toolbarButtons.filter((button) =>
+ button.querySelector(selector)
+ );
+ return classButtons.length ? classButtons : undefined;
+ }
+
+ // Returns a toolbar button based on the provided id.
+ // Will return undefined if not found.
+ public findToolbarButtonById(id): HaIconButton | undefined {
+ // Find the specified id
+ const element = this.shadowRoot?.getElementById(id);
+ if (!element || element.localName !== "ha-icon-button") return undefined;
+ return element as HaIconButton;
+ }
+
+ protected render(): TemplateResult {
+ return html`
+
+ ${this.items.map((item) =>
+ typeof item === "string"
+ ? html``
+ : html`${item.tooltip ?? ""}
+ `
+ )}
+
+ `;
+ }
+
+ static styles = css`
+ :host {
+ position: absolute;
+ top: 0px;
+ width: 100%;
+ display: flex;
+ flex-direction: row-reverse;
+ background-color: var(
+ --icon-button-toolbar-color,
+ var(--secondary-background-color, whitesmoke)
+ );
+ --icon-button-toolbar-height: 32px;
+ --icon-button-toolbar-button: calc(
+ var(--icon-button-toolbar-height) - 4px
+ );
+ --icon-button-toolbar-icon: calc(
+ var(--icon-button-toolbar-height) - 10px
+ );
+ }
+
+ .icon-toolbar-divider {
+ height: var(--icon-button-toolbar-icon);
+ margin: 0px 4px;
+ border: 0.5px solid
+ var(--divider-color, var(--secondary-text-color, transparent));
+ }
+
+ .icon-toolbar-buttongroup {
+ background-color: transparent;
+ padding-right: 4px;
+ height: var(--icon-button-toolbar-height);
+ gap: 8px;
+ }
+
+ .icon-toolbar-button {
+ color: var(--secondary-text-color);
+ --mdc-icon-button-size: var(--icon-button-toolbar-button);
+ --mdc-icon-size: var(--icon-button-toolbar-icon);
+ /* Ensure button is clickable on iOS */
+ cursor: pointer;
+ -webkit-tap-highlight-color: transparent;
+ touch-action: manipulation;
+ }
+ `;
+}
+
+declare global {
+ interface HTMLElementTagNameMap {
+ "ha-icon-button-toolbar": HaIconButtonToolbar;
+ }
+}
diff --git a/src/panels/config/automation/ha-automation-editor.ts b/src/panels/config/automation/ha-automation-editor.ts
index df1cf16310..d0d7264b3b 100644
--- a/src/panels/config/automation/ha-automation-editor.ts
+++ b/src/panels/config/automation/ha-automation-editor.ts
@@ -593,7 +593,6 @@ export class HaAutomationEditor extends UndoRedoMixin<
`
: nothing}