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}