diff --git a/src/components/ha-code-editor.ts b/src/components/ha-code-editor.ts index 190b456fbd..be825c2b42 100644 --- a/src/components/ha-code-editor.ts +++ b/src/components/ha-code-editor.ts @@ -6,6 +6,7 @@ import type { } from "@codemirror/autocomplete"; import type { Extension, TransactionSpec } from "@codemirror/state"; import type { EditorView, KeyBinding, ViewUpdate } from "@codemirror/view"; +import { mdiArrowExpand, mdiArrowCollapse } from "@mdi/js"; import type { HassEntities } from "home-assistant-js-websocket"; import type { PropertyValues } from "lit"; import { css, ReactiveElement } from "lit"; @@ -15,6 +16,7 @@ import { fireEvent } from "../common/dom/fire_event"; import { stopPropagation } from "../common/dom/stop_propagation"; import type { HomeAssistant } from "../types"; import "./ha-icon"; +import "./ha-icon-button"; declare global { interface HASSDomEvents { @@ -59,8 +61,13 @@ export class HaCodeEditor extends ReactiveElement { @property({ type: Boolean }) public error = false; + @property({ type: Boolean, attribute: "enable-fullscreen" }) + public enableFullscreen = true; + @state() private _value = ""; + @state() private _isFullscreen = false; + // eslint-disable-next-line @typescript-eslint/consistent-type-imports private _loadedCodeMirror?: typeof import("../resources/codemirror"); @@ -92,6 +99,7 @@ export class HaCodeEditor extends ReactiveElement { this.requestUpdate(); } this.addEventListener("keydown", stopPropagation); + this.addEventListener("keydown", this._handleKeyDown); // This is unreachable as editor will not exist yet, // but focus should not behave like this for good a11y. // (@steverep to fix in autofocus PR) @@ -106,6 +114,10 @@ export class HaCodeEditor extends ReactiveElement { public disconnectedCallback() { super.disconnectedCallback(); this.removeEventListener("keydown", stopPropagation); + this.removeEventListener("keydown", this._handleKeyDown); + if (this._isFullscreen) { + this._toggleFullscreen(); + } this.updateComplete.then(() => { this.codemirror!.destroy(); delete this.codemirror; @@ -164,6 +176,12 @@ export class HaCodeEditor extends ReactiveElement { if (changedProps.has("error")) { this.classList.toggle("error-state", this.error); } + if (changedProps.has("_isFullscreen")) { + this.classList.toggle("fullscreen", this._isFullscreen); + } + if (changedProps.has("enableFullscreen")) { + this._updateFullscreenButton(); + } } private get _mode() { @@ -238,8 +256,74 @@ export class HaCodeEditor extends ReactiveElement { }), parent: this.renderRoot, }); + + this._updateFullscreenButton(); } + private _updateFullscreenButton() { + const existingButton = this.renderRoot.querySelector(".fullscreen-button"); + + if (!this.enableFullscreen) { + // 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; + } + 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" + ); + } + } + + private _handleFullscreenClick = (e: Event) => { + e.preventDefault(); + e.stopPropagation(); + this._toggleFullscreen(); + }; + + private _toggleFullscreen() { + this._isFullscreen = !this._isFullscreen; + this._updateFullscreenButton(); + } + + private _handleKeyDown = (e: KeyboardEvent) => { + if (this._isFullscreen && e.key === "Escape") { + e.preventDefault(); + e.stopPropagation(); + this._toggleFullscreen(); + } else if (e.key === "F11" && this.enableFullscreen) { + e.preventDefault(); + e.stopPropagation(); + this._toggleFullscreen(); + } + }; + private _getStates = memoizeOne((states: HassEntities): Completion[] => { if (!states) { return []; @@ -460,9 +544,78 @@ export class HaCodeEditor extends ReactiveElement { }; static styles = css` + :host { + position: relative; + display: block; + } + :host(.error-state) .cm-gutters { border-color: var(--error-state-color, red); } + + .fullscreen-button { + position: absolute; + top: 8px; + right: 8px; + z-index: 10; + color: var(--secondary-text-color); + background-color: var(--card-background-color); + border-radius: 50%; + opacity: 0.6; + 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; + } + + .fullscreen-button:hover, + .fullscreen-button:active { + opacity: 1; + } + + @media (hover: none) { + .fullscreen-button { + opacity: 0.8; + } + } + + :host(.fullscreen) { + position: fixed !important; + top: var(--header-height, 56px) !important; + left: 0 !important; + right: 0 !important; + bottom: 0 !important; + z-index: 9999 !important; + background-color: var(--primary-background-color) !important; + margin: 0 !important; + padding: 16px !important; + /* Respect iOS safe areas while accounting for header */ + padding-top: max(16px, env(safe-area-inset-top)) !important; + padding-left: max(16px, env(safe-area-inset-left)) !important; + padding-right: max(16px, env(safe-area-inset-right)) !important; + padding-bottom: max(16px, env(safe-area-inset-bottom)) !important; + box-sizing: border-box !important; + display: flex !important; + flex-direction: column !important; + } + + :host(.fullscreen) .cm-editor { + height: 100% !important; + max-height: 100% !important; + border-radius: 0 !important; + } + + :host(.fullscreen) .fullscreen-button { + position: fixed; + top: calc( + var(--header-height, 56px) + max(8px, env(safe-area-inset-top)) + ); + right: max(24px, calc(env(safe-area-inset-right) + 8px)); + z-index: 10000; + } `; }