Compare commits

..

1 Commits

Author SHA1 Message Date
Wendelin
dbbcfd1707 WIP open fullscreen code editor in dialog 2025-08-29 16:12:14 +02:00
6 changed files with 121 additions and 456 deletions

View File

@@ -7,20 +7,20 @@ 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 { mdiArrowCollapse, mdiArrowExpand } from "@mdi/js";
import type { HassEntities } from "home-assistant-js-websocket";
import type { PropertyValues } from "lit";
import { css, ReactiveElement, html, render } from "lit";
import { css, html, ReactiveElement, render } from "lit";
import { customElement, property, state } from "lit/decorators";
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 type { HomeAssistant } from "../types";
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";
declare global {
interface HASSDomEvents {
@@ -72,6 +72,8 @@ export class HaCodeEditor extends ReactiveElement {
@state() private _isFullscreen = false;
private _fullscreenDialog?: HTMLDialogElement;
// eslint-disable-next-line @typescript-eslint/consistent-type-imports
private _loadedCodeMirror?: typeof import("../resources/codemirror");
@@ -120,7 +122,7 @@ export class HaCodeEditor extends ReactiveElement {
this.removeEventListener("keydown", stopPropagation);
this.removeEventListener("keydown", this._handleKeyDown);
if (this._isFullscreen) {
this._toggleFullscreen();
this._closeFullscreenDialog();
}
this.updateComplete.then(() => {
this.codemirror!.destroy();
@@ -180,9 +182,6 @@ 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("disableFullscreen")) {
this._updateFullscreenButton();
}
@@ -312,10 +311,93 @@ export class HaCodeEditor extends ReactiveElement {
};
private _toggleFullscreen() {
if (!this._isFullscreen) {
this._openFullscreenDialog();
} else {
this._closeFullscreenDialog();
}
this._isFullscreen = !this._isFullscreen;
this._updateFullscreenButton();
}
private _openFullscreenDialog() {
// Create dialog if it doesn't exist
if (!this._fullscreenDialog) {
this._fullscreenDialog = document.createElement("dialog");
this._fullscreenDialog.style =
"width: calc(100% - 16px); margin: 56px 8px 8px; height: calc(100% - 64px); border: none; border-radius: 8px;";
// Handle dialog close events
this._fullscreenDialog.addEventListener("close", () => {
if (this._isFullscreen) {
this._isFullscreen = false;
this._updateFullscreenButton();
}
});
// Handle click on backdrop to close
this._fullscreenDialog.addEventListener("click", (e) => {
if (e.target === this._fullscreenDialog) {
this._closeFullscreenDialog();
}
});
document.body.appendChild(this._fullscreenDialog);
}
// Clone the editor and move it to dialog
const editorClone = this.cloneNode(true) as HaCodeEditor;
editorClone.classList.add("fullscreen-editor");
// Copy current value to cloned editor
editorClone.value = this.value;
// Listen for changes in the cloned editor
editorClone.addEventListener("value-changed", (e: any) => {
this._value = e.detail.value;
fireEvent(this, "value-changed", { value: this._value });
});
// Add close button
const closeButton = document.createElement("ha-icon-button");
closeButton.path = mdiArrowCollapse;
closeButton.setAttribute("label", "Exit fullscreen");
closeButton.className = "fullscreen-close-button";
closeButton.addEventListener("click", () => this._closeFullscreenDialog());
// Clear dialog content and add new content
this._fullscreenDialog.innerHTML = "";
this._fullscreenDialog.appendChild(editorClone);
this._fullscreenDialog.appendChild(closeButton);
// Show modal dialog
this._fullscreenDialog.showModal();
}
private _closeFullscreenDialog() {
if (this._fullscreenDialog) {
this._fullscreenDialog.close();
// Update original editor with current value
const editorInDialog = this._fullscreenDialog.querySelector(
"ha-code-editor"
) as HaCodeEditor;
if (editorInDialog) {
this._value = editorInDialog.value;
if (this.codemirror) {
this.codemirror.dispatch({
changes: {
from: 0,
to: this.codemirror.state.doc.length,
insert: this._value,
},
});
}
}
}
this._isFullscreen = false;
}
private _handleKeyDown = (e: KeyboardEvent) => {
if (this._isFullscreen && e.key === "Escape") {
e.preventDefault();
@@ -643,40 +725,6 @@ export class HaCodeEditor extends ReactiveElement {
}
}
:host(.fullscreen) {
position: fixed !important;
top: calc(var(--header-height, 56px) + 8px) !important;
left: 8px !important;
right: 8px !important;
bottom: 8px !important;
z-index: 9999 !important;
border-radius: 12px !important;
box-shadow: 0 2px 10px rgba(0, 0, 0, 0.3) !important;
overflow: hidden !important;
background-color: var(
--code-editor-background-color,
var(--card-background-color)
) !important;
margin: 0 !important;
padding-top: var(--safe-area-inset-top) !important;
padding-left: var(--safe-area-inset-left) !important;
padding-right: var(--safe-area-inset-right) !important;
padding-bottom: var(--safe-area-inset-bottom) !important;
box-sizing: border-box !important;
display: block !important;
}
: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);
}
.completion-info {
display: grid;
gap: 3px;

View File

@@ -1,372 +0,0 @@
import { css, html, LitElement, nothing } from "lit";
import { customElement, property, state } from "lit/decorators";
import { classMap } from "lit/directives/class-map";
import { ifDefined } from "lit/directives/if-defined";
import { styleMap } from "lit/directives/style-map";
import { computeCssColor } from "../../../common/color/compute-color";
import { computeDomain } from "../../../common/entity/compute_domain";
import { generateEntityFilter } from "../../../common/entity/entity_filter";
import { formatNumber } from "../../../common/number/format_number";
import "../../../components/ha-card";
import "../../../components/ha-icon";
import "../../../components/ha-ripple";
import "../../../components/tile/ha-tile-icon";
import "../../../components/tile/ha-tile-info";
import type { ActionHandlerEvent } from "../../../data/lovelace/action_handler";
import "../../../state-display/state-display";
import type { HomeAssistant } from "../../../types";
import { actionHandler } from "../common/directives/action-handler-directive";
import { handleAction } from "../common/handle-action";
import { hasAction } from "../common/has-action";
import {
findEntities,
getSummaryLabel,
HOME_SUMMARIES_FILTERS,
HOME_SUMMARIES_ICONS,
type HomeSummary,
} from "../strategies/home/helpers/home-summaries";
import type { LovelaceCard, LovelaceGridOptions } from "../types";
import type { HomeSummaryCard } from "./types";
const COLORS: Record<HomeSummary, string> = {
lights: "amber",
climate: "deep-orange",
security: "blue",
media_players: "purple",
};
@customElement("hui-home-summary-card")
export class HuiHomeSummaryCard extends LitElement implements LovelaceCard {
@property({ attribute: false }) public hass?: HomeAssistant;
@state() private _config?: HomeSummaryCard;
public setConfig(config: HomeSummaryCard): void {
this._config = config;
}
public getCardSize(): number {
return this._config?.vertical ? 2 : 1;
}
public getGridOptions(): LovelaceGridOptions {
const columns = 6;
let min_columns = 6;
let rows = 1;
if (this._config?.vertical) {
rows++;
min_columns = 3;
}
return {
columns,
rows,
min_columns,
min_rows: rows,
};
}
private _handleAction(ev: ActionHandlerEvent) {
handleAction(this, this.hass!, this._config!, ev.detail.action!);
}
private get _hasCardAction() {
return (
hasAction(this._config?.tap_action) ||
hasAction(this._config?.hold_action) ||
hasAction(this._config?.double_tap_action)
);
}
private _computeSummaryState(): string {
if (!this._config || !this.hass) {
return "";
}
const allEntities = Object.keys(this.hass!.states);
const areas = Object.values(this.hass.areas);
const areasFilter = generateEntityFilter(this.hass, {
area: areas.map((area) => area.area_id),
});
const entitiesInsideArea = allEntities.filter(areasFilter);
switch (this._config.summary) {
case "lights": {
// Number of lights on
const lightsFilters = HOME_SUMMARIES_FILTERS.lights.map((filter) =>
generateEntityFilter(this.hass!, filter)
);
const lightEntities = findEntities(entitiesInsideArea, lightsFilters);
const onLights = lightEntities.filter((entityId) => {
const s = this.hass!.states[entityId]?.state;
return s === "on";
});
return onLights.length
? this.hass.localize("ui.card.home-summary.count_lights_on", {
count: onLights.length,
})
: this.hass.localize("ui.card.home-summary.all_lights_off");
}
case "climate": {
// Min/Max temperature of the areas
const areaSensors = areas
.map((area) => area.temperature_entity_id)
.filter(Boolean);
const sensorsValues = areaSensors
.map(
(entityId) => parseFloat(this.hass!.states[entityId!].state) || NaN
)
.filter((value) => !isNaN(value));
if (sensorsValues.length === 0) {
return "";
}
const minTemp = Math.min(...sensorsValues);
const maxTemp = Math.max(...sensorsValues);
if (isNaN(minTemp) || isNaN(maxTemp)) {
return "";
}
const formattedMinTemp = formatNumber(minTemp, this.hass?.locale, {
minimumFractionDigits: 1,
maximumFractionDigits: 1,
});
const formattedMaxTemp = formatNumber(maxTemp, this.hass?.locale, {
minimumFractionDigits: 1,
maximumFractionDigits: 1,
});
return formattedMinTemp === formattedMaxTemp
? `${formattedMinTemp}°`
: `${formattedMinTemp} - ${formattedMaxTemp}°`;
}
case "security": {
// Alarm and lock status
const securityFilters = HOME_SUMMARIES_FILTERS.security.map((filter) =>
generateEntityFilter(this.hass!, filter)
);
const securityEntities = findEntities(
entitiesInsideArea,
securityFilters
);
const locks = securityEntities.filter((entityId) => {
const domain = computeDomain(entityId);
return domain === "lock";
});
const alarms = securityEntities.filter((entityId) => {
const domain = computeDomain(entityId);
return domain === "alarm_control_panel";
});
const disarmedAlarms = alarms.filter((entityId) => {
const s = this.hass!.states[entityId]?.state;
return s === "disarmed";
});
if (!locks.length && !alarms.length) {
return "";
}
const unlockedLocks = locks.filter((entityId) => {
const s = this.hass!.states[entityId]?.state;
return s === "unlocked" || s === "jammed" || s === "open";
});
if (unlockedLocks.length) {
return this.hass.localize(
"ui.card.home-summary.count_locks_unlocked",
{
count: unlockedLocks.length,
}
);
}
if (disarmedAlarms.length) {
return this.hass.localize(
"ui.card.home-summary.count_alarms_disarmed",
{
count: disarmedAlarms.length,
}
);
}
return "All secure";
}
case "media_players": {
// Playing media
const mediaPlayerFilters = HOME_SUMMARIES_FILTERS.media_players.map(
(filter) => generateEntityFilter(this.hass!, filter)
);
const mediaPlayerEntities = findEntities(
entitiesInsideArea,
mediaPlayerFilters
);
const playingMedia = mediaPlayerEntities.filter((entityId) => {
const s = this.hass!.states[entityId]?.state;
return s === "playing";
});
return playingMedia.length
? this.hass.localize("ui.card.home-summary.count_media_playing", {
count: playingMedia.length,
})
: this.hass.localize("ui.card.home-summary.no_media_playing");
}
}
return "";
}
protected render() {
if (!this._config || !this.hass) {
return nothing;
}
const contentClasses = { vertical: Boolean(this._config.vertical) };
const color = computeCssColor(COLORS[this._config.summary]);
const style = {
"--tile-color": color,
};
const secondary = this._computeSummaryState();
const label = getSummaryLabel(this.hass.localize, this._config.summary);
const icon = HOME_SUMMARIES_ICONS[this._config.summary];
return html`
<ha-card style=${styleMap(style)}>
<div
class="background"
@action=${this._handleAction}
.actionHandler=${actionHandler({
hasHold: hasAction(this._config!.hold_action),
hasDoubleClick: hasAction(this._config!.double_tap_action),
})}
role=${ifDefined(this._hasCardAction ? "button" : undefined)}
tabindex=${ifDefined(this._hasCardAction ? "0" : undefined)}
aria-labelledby="info"
>
<ha-ripple .disabled=${!this._hasCardAction}></ha-ripple>
</div>
<div class="container">
<div class="content ${classMap(contentClasses)}">
<ha-tile-icon>
<ha-icon slot="icon" .icon=${icon}></ha-icon>
</ha-tile-icon>
<ha-tile-info
id="info"
.primary=${label}
.secondary=${secondary}
></ha-tile-info>
</div>
</div>
</ha-card>
`;
}
static styles = css`
:host {
--tile-color: var(--state-inactive-color);
-webkit-tap-highlight-color: transparent;
}
ha-card:has(.background:focus-visible) {
--shadow-default: var(--ha-card-box-shadow, 0 0 0 0 transparent);
--shadow-focus: 0 0 0 1px var(--tile-color);
border-color: var(--tile-color);
box-shadow: var(--shadow-default), var(--shadow-focus);
}
ha-card {
--ha-ripple-color: var(--tile-color);
--ha-ripple-hover-opacity: 0.04;
--ha-ripple-pressed-opacity: 0.12;
height: 100%;
transition:
box-shadow 180ms ease-in-out,
border-color 180ms ease-in-out;
display: flex;
flex-direction: column;
justify-content: space-between;
}
ha-card.active {
--tile-color: var(--state-icon-color);
}
[role="button"] {
cursor: pointer;
pointer-events: auto;
}
[role="button"]:focus {
outline: none;
}
.background {
position: absolute;
top: 0;
left: 0;
bottom: 0;
right: 0;
border-radius: var(--ha-card-border-radius, 12px);
margin: calc(-1 * var(--ha-card-border-width, 1px));
overflow: hidden;
}
.container {
margin: calc(-1 * var(--ha-card-border-width, 1px));
display: flex;
flex-direction: column;
flex: 1;
}
.container.horizontal {
flex-direction: row;
}
.content {
position: relative;
display: flex;
flex-direction: row;
align-items: center;
padding: 10px;
flex: 1;
min-width: 0;
box-sizing: border-box;
pointer-events: none;
gap: 10px;
}
.vertical {
flex-direction: column;
text-align: center;
justify-content: center;
}
.vertical ha-tile-info {
width: 100%;
flex: none;
}
ha-tile-icon {
--tile-icon-color: var(--tile-color);
position: relative;
padding: 6px;
margin: -6px;
}
ha-tile-info {
position: relative;
min-width: 0;
transition: background-color 180ms ease-in-out;
box-sizing: border-box;
}
`;
}
declare global {
interface HTMLElementTagNameMap {
"hui-home-summary-card": HuiHomeSummaryCard;
}
}

View File

@@ -26,7 +26,6 @@ import type {
import type { LovelaceHeaderFooterConfig } from "../header-footer/types";
import type { LovelaceHeadingBadgeConfig } from "../heading-badges/types";
import type { TimeFormat } from "../../../data/translation";
import type { HomeSummary } from "../strategies/home/helpers/home-summaries";
export type AlarmPanelCardConfigState =
| "arm_away"
@@ -589,11 +588,3 @@ export interface HeadingCardConfig extends LovelaceCardConfig {
/** @deprecated Use `badges` instead */
entities?: LovelaceHeadingBadgeConfig[];
}
export interface HomeSummaryCard extends LovelaceCardConfig {
summary: HomeSummary;
vertical?: boolean;
tap_action?: ActionConfig;
hold_action?: ActionConfig;
double_tap_action?: ActionConfig;
}

View File

@@ -68,7 +68,6 @@ const LAZY_LOAD_TYPES = {
"energy-sankey": () => import("../cards/energy/hui-energy-sankey-card"),
"entity-filter": () => import("../cards/hui-entity-filter-card"),
error: () => import("../cards/hui-error-card"),
"home-summary": () => import("../cards/hui-home-summary-card"),
gauge: () => import("../cards/hui-gauge-card"),
"history-graph": () => import("../cards/hui-history-graph-card"),
"horizontal-stack": () => import("../cards/hui-horizontal-stack-card"),

View File

@@ -9,12 +9,16 @@ import type { LovelaceViewConfig } from "../../../../data/lovelace/config/view";
import type { HomeAssistant } from "../../../../types";
import type {
AreaCardConfig,
HomeSummaryCard,
ButtonCardConfig,
MarkdownCardConfig,
TileCardConfig,
WeatherForecastCardConfig,
} from "../../cards/types";
import { getAreas } from "../areas/helpers/areas-strategy-helper";
import {
getSummaryLabel,
HOME_SUMMARIES_ICONS,
} from "./helpers/home-summaries";
export interface HomeMainViewStrategyConfig {
type: "home-main";
@@ -110,57 +114,61 @@ export class HomeMainViewStrategy extends ReactiveElement {
heading: hass.localize("ui.panel.lovelace.strategy.home.summaries"),
},
{
type: "home-summary",
summary: "lights",
vertical: true,
type: "button",
icon: HOME_SUMMARIES_ICONS.lights,
name: getSummaryLabel(hass.localize, "lights"),
icon_height: "24px",
grid_options: {
rows: 2,
columns: 4,
},
tap_action: {
action: "navigate",
navigation_path: "lights",
},
} satisfies ButtonCardConfig,
{
type: "button",
icon: HOME_SUMMARIES_ICONS.climate,
name: getSummaryLabel(hass.localize, "climate"),
icon_height: "30px",
grid_options: {
rows: 2,
columns: 4,
},
} satisfies HomeSummaryCard,
{
type: "home-summary",
summary: "climate",
vertical: true,
tap_action: {
action: "navigate",
navigation_path: "climate",
},
} satisfies ButtonCardConfig,
{
type: "button",
icon: HOME_SUMMARIES_ICONS.security,
name: getSummaryLabel(hass.localize, "security"),
icon_height: "30px",
grid_options: {
rows: 2,
columns: 4,
},
} satisfies HomeSummaryCard,
{
type: "home-summary",
summary: "security",
vertical: true,
tap_action: {
action: "navigate",
navigation_path: "security",
},
} satisfies ButtonCardConfig,
{
type: "button",
icon: HOME_SUMMARIES_ICONS.media_players,
name: getSummaryLabel(hass.localize, "media_players"),
icon_height: "30px",
grid_options: {
rows: 2,
columns: 4,
},
} satisfies HomeSummaryCard,
{
type: "home-summary",
summary: "media_players",
vertical: true,
tap_action: {
action: "navigate",
navigation_path: "media-players",
},
grid_options: {
rows: 2,
columns: 4,
},
} satisfies HomeSummaryCard,
} satisfies ButtonCardConfig,
],
};

View File

@@ -201,15 +201,6 @@
"open_door_confirm": "Really open?",
"open_door_done": "Done"
},
"home-summary": {
"all_lights_off": "All off",
"count_lights_on": "{count} {count, plural,\n one {on}\n other {on}\n}",
"count_locks_unlocked": "{count} {count, plural,\n one {unlocked}\n other {unlocked}\n}",
"count_alarms_disarmed": "{count} {count, plural,\n one {disarmed}\n other {disarmed}\n}",
"all_secure": "All secure",
"no_media_playing": "No media playing",
"count_media_playing": "{count} {count, plural,\n one {playing}\n other {playing}\n}"
},
"media_player": {
"source": "Source",
"sound_mode": "Sound mode",