diff --git a/package.json b/package.json index 27d46263b1..c86c879370 100644 --- a/package.json +++ b/package.json @@ -109,6 +109,7 @@ "element-internals-polyfill": "1.3.10", "fuse.js": "7.0.0", "google-timezones-json": "1.2.0", + "gridstack": "10.0.1", "hls.js": "1.5.1", "home-assistant-js-websocket": "9.1.0", "idb-keyval": "6.2.1", diff --git a/src/components/media-player/dialog-media-manage.ts b/src/components/media-player/dialog-media-manage.ts index b7b1158e4e..7cbd147168 100644 --- a/src/components/media-player/dialog-media-manage.ts +++ b/src/components/media-player/dialog-media-manage.ts @@ -25,7 +25,6 @@ import "../ha-dialog"; import "../ha-dialog-header"; import "../ha-svg-icon"; import "../ha-tip"; -import "./ha-media-player-browse"; import "./ha-media-upload-button"; import type { MediaManageDialogParams } from "./show-media-manage-dialog"; import { isComponentLoaded } from "../../common/config/is_component_loaded"; diff --git a/src/panels/lovelace/cards/hui-grid-card.ts b/src/panels/lovelace/cards/hui-grid-card.ts index 97b336c529..b0c205f423 100644 --- a/src/panels/lovelace/cards/hui-grid-card.ts +++ b/src/panels/lovelace/cards/hui-grid-card.ts @@ -73,6 +73,7 @@ class HuiGridCard extends HuiStackCard { super.sharedStyles, css` #root { + height: 100%; display: grid; grid-template-columns: repeat( var(--grid-card-column-count, ${DEFAULT_COLUMNS}), diff --git a/src/panels/lovelace/cards/hui-tile-card.ts b/src/panels/lovelace/cards/hui-tile-card.ts index 70d3d13063..65d72492e9 100644 --- a/src/panels/lovelace/cards/hui-tile-card.ts +++ b/src/panels/lovelace/cards/hui-tile-card.ts @@ -453,12 +453,14 @@ export class HuiTileCard extends LitElement implements LovelaceCard { .secondary=${localizedState} > - + ${this._config.features?.length + ? html`` + : nothing} `; } @@ -481,6 +483,9 @@ export class HuiTileCard extends LitElement implements LovelaceCard { transition: box-shadow 180ms ease-in-out, border-color 180ms ease-in-out; + display: flex; + flex-direction: column; + justify-content: space-around; } ha-card.active { --tile-color: var(--state-icon-color); diff --git a/src/panels/lovelace/create-element/create-view-element.ts b/src/panels/lovelace/create-element/create-view-element.ts index 158cf2da31..43ea3221c2 100644 --- a/src/panels/lovelace/create-element/create-view-element.ts +++ b/src/panels/lovelace/create-element/create-view-element.ts @@ -9,6 +9,7 @@ const ALWAYS_LOADED_LAYOUTS = new Set(["masonry"]); const LAZY_LOAD_LAYOUTS = { panel: () => import("../views/hui-panel-view"), sidebar: () => import("../views/hui-sidebar-view"), + manual: () => import("../views/hui-manual-view"), }; export const createViewElement = ( diff --git a/src/panels/lovelace/editor/card-editor/hui-dialog-create-card.ts b/src/panels/lovelace/editor/card-editor/hui-dialog-create-card.ts index 1752a24174..4c557c7237 100644 --- a/src/panels/lovelace/editor/card-editor/hui-dialog-create-card.ts +++ b/src/panels/lovelace/editor/card-editor/hui-dialog-create-card.ts @@ -212,6 +212,7 @@ export class HuiCreateDialogCard showEditCardDialog(this, { lovelaceConfig: this._params!.lovelaceConfig, + preSaveConfig: this._params!.preSaveConfig, saveConfig: this._params!.saveConfig, path: this._params!.path, cardConfig: config, diff --git a/src/panels/lovelace/editor/card-editor/hui-dialog-edit-card.ts b/src/panels/lovelace/editor/card-editor/hui-dialog-edit-card.ts index dace73f087..d2fe1840f7 100644 --- a/src/panels/lovelace/editor/card-editor/hui-dialog-edit-card.ts +++ b/src/panels/lovelace/editor/card-editor/hui-dialog-edit-card.ts @@ -367,17 +367,20 @@ export class HuiDialogEditCard return; } this._saving = true; + const cardConfig = this._params?.preSaveConfig + ? await this._params.preSaveConfig(this._cardConfig!) + : this._cardConfig!; await this._params!.saveConfig( this._params!.path.length === 1 ? addCard( this._params!.lovelaceConfig, this._params!.path as [number], - this._cardConfig! + cardConfig ) : replaceCard( this._params!.lovelaceConfig, this._params!.path as [number, number], - this._cardConfig! + cardConfig ) ); this._saving = false; diff --git a/src/panels/lovelace/editor/card-editor/show-create-card-dialog.ts b/src/panels/lovelace/editor/card-editor/show-create-card-dialog.ts index dedd6d1b6a..3981e8b3fa 100644 --- a/src/panels/lovelace/editor/card-editor/show-create-card-dialog.ts +++ b/src/panels/lovelace/editor/card-editor/show-create-card-dialog.ts @@ -1,8 +1,12 @@ import { fireEvent } from "../../../../common/dom/fire_event"; +import { LovelaceCardConfig } from "../../../../data/lovelace/config/card"; import type { LovelaceConfig } from "../../../../data/lovelace/config/types"; export interface CreateCardDialogParams { lovelaceConfig: LovelaceConfig; + preSaveConfig?: ( + config: LovelaceCardConfig + ) => LovelaceCardConfig | Promise; saveConfig: (config: LovelaceConfig) => void; path: [number] | [number, number]; entities?: string[]; // We can pass entity id's that will be added to the config when a card is picked diff --git a/src/panels/lovelace/editor/card-editor/show-edit-card-dialog.ts b/src/panels/lovelace/editor/card-editor/show-edit-card-dialog.ts index f6b55e3b0e..2673401628 100644 --- a/src/panels/lovelace/editor/card-editor/show-edit-card-dialog.ts +++ b/src/panels/lovelace/editor/card-editor/show-edit-card-dialog.ts @@ -4,6 +4,9 @@ import type { LovelaceConfig } from "../../../../data/lovelace/config/types"; export interface EditCardDialogParams { lovelaceConfig: LovelaceConfig; + preSaveConfig?: ( + config: LovelaceCardConfig + ) => LovelaceCardConfig | Promise; saveConfig: (config: LovelaceConfig) => void; path: [number] | [number, number]; cardConfig?: LovelaceCardConfig; diff --git a/src/panels/lovelace/editor/view-editor/hui-view-editor.ts b/src/panels/lovelace/editor/view-editor/hui-view-editor.ts index b693848a8d..be3c100b70 100644 --- a/src/panels/lovelace/editor/view-editor/hui-view-editor.ts +++ b/src/panels/lovelace/editor/view-editor/hui-view-editor.ts @@ -9,6 +9,7 @@ import type { SchemaUnion } from "../../../../components/ha-form/types"; import type { HomeAssistant } from "../../../../types"; import { DEFAULT_VIEW_LAYOUT, + MANUAL_VIEW_LAYOUT, PANEL_VIEW_LAYOUT, SIDEBAR_VIEW_LAYOUT, } from "../../views/const"; @@ -53,6 +54,7 @@ export class HuiViewEditor extends LitElement { DEFAULT_VIEW_LAYOUT, SIDEBAR_VIEW_LAYOUT, PANEL_VIEW_LAYOUT, + MANUAL_VIEW_LAYOUT, ] as const ).map((type) => ({ value: type, diff --git a/src/panels/lovelace/views/const.ts b/src/panels/lovelace/views/const.ts index 5cc4709bbb..718f8ee2ae 100644 --- a/src/panels/lovelace/views/const.ts +++ b/src/panels/lovelace/views/const.ts @@ -1,4 +1,5 @@ export const DEFAULT_VIEW_LAYOUT = "masonry"; export const PANEL_VIEW_LAYOUT = "panel"; export const SIDEBAR_VIEW_LAYOUT = "sidebar"; +export const MANUAL_VIEW_LAYOUT = "manual"; export const VIEWS_NO_BADGE_SUPPORT = [PANEL_VIEW_LAYOUT, SIDEBAR_VIEW_LAYOUT]; diff --git a/src/panels/lovelace/views/hui-manual-view.ts b/src/panels/lovelace/views/hui-manual-view.ts new file mode 100644 index 0000000000..02d8c86f70 --- /dev/null +++ b/src/panels/lovelace/views/hui-manual-view.ts @@ -0,0 +1,358 @@ +import { mdiCursorMove, mdiDelete, mdiPencil, mdiPlus } from "@mdi/js"; +import { GridStack, GridStackWidget } from "gridstack"; +import gridStackStyleExtra from "gridstack/dist/gridstack-extra.min.css"; +import gridStackStyle from "gridstack/dist/gridstack.min.css"; +import { + CSSResultGroup, + LitElement, + PropertyValues, + TemplateResult, + css, + html, + nothing, + unsafeCSS, +} from "lit"; +import { customElement, property } from "lit/decorators"; +import { classMap } from "lit/directives/class-map"; +import { fireEvent } from "../../../common/dom/fire_event"; +import { computeRTL } from "../../../common/util/compute_rtl"; +import "../../../components/entity/ha-state-label-badge"; +import "../../../components/ha-svg-icon"; +import type { LovelaceViewElement } from "../../../data/lovelace"; +import type { LovelaceViewConfig } from "../../../data/lovelace/config/view"; +import type { HomeAssistant } from "../../../types"; +import type { HuiErrorCard } from "../cards/hui-error-card"; +import { createCardElement } from "../custom-card-helpers"; +import { replaceView } from "../editor/config-util"; +import type { Lovelace, LovelaceBadge, LovelaceCard } from "../types"; + +@customElement("hui-manual-view") +export class ManualView extends LitElement implements LovelaceViewElement { + @property({ attribute: false }) public hass!: HomeAssistant; + + @property({ attribute: false }) public lovelace?: Lovelace; + + @property({ type: Boolean }) public narrow = false; + + @property({ type: Number }) public index?: number; + + @property({ type: Boolean }) public isStrategy = false; + + @property({ attribute: false }) public cards: Array< + LovelaceCard | HuiErrorCard + > = []; + + @property({ attribute: false }) public badges: LovelaceBadge[] = []; + + public setConfig(_config: LovelaceViewConfig): void {} + + private _grid?: GridStack; + + connectedCallback() { + super.connectedCallback(); + if (this.hasUpdated) { + this._setupGrid(); + } + } + + disconnectedCallback() { + super.disconnectedCallback(); + this._grid?.destroy(false); + this._grid = undefined; + } + + protected render(): TemplateResult { + return html` + ${this.badges.length > 0 + ? html`
${this.badges}
` + : ""} +
+ ${this.cards.map( + (card, i) => + html`
+ ${this.lovelace?.editMode + ? html`
+ + + +
` + : nothing} +
${card}
+
` + )} +
+ ${this.lovelace?.editMode + ? html` + + + + ` + : ""} + `; + } + + firstUpdated(changed) { + super.firstUpdated(changed); + this._setupGrid(); + } + + updated(changedProperties: PropertyValues) { + if ( + changedProperties.has("lovelace") && + this.lovelace?.editMode !== changedProperties.get("lovelace")?.editMode + ) { + if (this.lovelace?.editMode) { + this._grid!.setStatic(false); + this._grid!.setAnimation(true); + // this.grid.addWidget( + // '
hello
', + // { w: 3 } + // ); + } else { + this._grid!.setStatic(true); + this._grid!.setAnimation(false); + } + } + + if ( + changedProperties.has("cards") && + changedProperties.get("cards") && + !this.lovelace?.editMode + ) { + this._grid!.load( + ( + this.lovelace?.config.views[this.index!] as LovelaceViewConfig + ).cards?.map((card, i) => ({ + id: i.toString(), + ...card.view_layout, + })) || [], + false + ); + } + } + + public willUpdate(changedProperties: PropertyValues) { + super.willUpdate(changedProperties); + + if ( + changedProperties.has("lovelace") && + this.lovelace?.editMode !== changedProperties.get("lovelace")?.editMode + ) { + if (this.lovelace?.editMode) { + import("./default-view-editable"); + } else if (this._grid) { + this._saveLayout(); + } + } + + if (changedProperties.has("hass")) { + const oldHass = changedProperties.get("hass") as + | HomeAssistant + | undefined; + + if (this.hass!.dockedSidebar !== oldHass?.dockedSidebar) { + // this._updateColumns(); + return; + } + } + + if (changedProperties.has("narrow")) { + // this._updateColumns(); + return; + } + + const oldLovelace = changedProperties.get("lovelace") as + | Lovelace + | undefined; + + if ( + changedProperties.has("cards") || + (changedProperties.has("lovelace") && + oldLovelace && + (oldLovelace.config !== this.lovelace!.config || + oldLovelace.editMode !== this.lovelace!.editMode)) + ) { + // this._createColumns(); + } + } + + private async _editCard(ev): Promise { + const index = ev.target.index; + fireEvent(this, "ll-edit-card", { path: [this.index!, index] }); + } + + private async _deleteCard(ev): Promise { + const index = ev.target.index; + fireEvent(this, "ll-delete-card", { + path: [this.index!, index], + confirm: true, + }); + } + + private async _addCard(): Promise { + fireEvent(this, "ll-create-card", { + preSaveConfig: async (config) => { + const card = createCardElement(config); + const height = await card.getCardSize(); + const add = this._grid!.addWidget({ + w: 3, + h: height, + content: "", + }); + return { + ...config, + view_layout: { + x: add.gridstackNode!.x, + y: add.gridstackNode!.y, + w: add.gridstackNode!.w, + h: add.gridstackNode!.h, + }, + }; + }, + }); + } + + private async _saveLayout(): Promise { + if (!this._grid || !this.lovelace?.editMode) { + return; + } + const layouts = this._grid.save(false) as GridStackWidget[]; + layouts + .sort((a, b) => Number(a.id!) - Number(b.id!)) + .forEach((layout) => { + delete layout.id; + }); + const cardConfigs = ( + this.lovelace?.config.views[this.index!] as LovelaceViewConfig + ).cards?.map((card, i) => ({ + ...card, + view_layout: layouts[i], + })); + await this.lovelace!.saveConfig( + replaceView(this.hass!, this.lovelace!.config, this.index!, { + ...this.lovelace!.config.views[this.index!], + cards: cardConfigs, + }) + ); + } + + private _setupGrid(): void { + this._grid = GridStack.init( + { + cellHeight: 60, + animate: false, + columnOpts: { + layout: "moveScale", + // breakpointForWindow: true, // test window vs grid size + breakpoints: [ + { w: 700, c: 1 }, + { w: 850, c: 4 }, + { w: 950, c: 8 }, + { w: 1100, c: 12 }, + ], + }, + minRow: 5, + sizeToContent: false, + handleClass: "handle", + staticGrid: true, + margin: 4, + }, + this.shadowRoot!.querySelector(".grid-stack") as HTMLElement + ); + this._grid.load( + ( + this.lovelace?.config.views[this.index!] as LovelaceViewConfig + ).cards?.map((card, i) => ({ + id: i.toString(), + ...card.view_layout, + })) || [], + false + ); + this._grid.on("dragstop resizestop", () => { + this._saveLayout(); + }); + } + + static get styles(): CSSResultGroup { + return css` + ${unsafeCSS(gridStackStyle)} + ${unsafeCSS(gridStackStyleExtra)} + :host { + display: block; + padding-top: 4px; + } + .grid-stack { + height: 100vh; + margin: 4px; + } + .grid-stack-item { + position: relative; + } + + .controls { + display: none; + z-index: 999; += } + + .grid-stack-item:hover .controls { + position: absolute; + display: flex; + top: 8px; + right: 8px; + } + + .handle { + width: 24px; + height: 24px; + padding: 12px; + cursor: move; /* fallback if grab cursor is unsupported */ + cursor: grab !important; + } + + + .badges { + margin: 8px 16px; + font-size: 85%; + text-align: center; + } + ha-fab { + position: fixed; + right: calc(16px + env(safe-area-inset-right)); + bottom: calc(16px + env(safe-area-inset-bottom)); + z-index: 1; + } + + ha-fab.rtl { + right: auto; + left: calc(16px + env(safe-area-inset-left)); + } + `; + } +} + +declare global { + interface HTMLElementTagNameMap { + "hui-manual-view": ManualView; + } +} diff --git a/src/panels/lovelace/views/hui-view.ts b/src/panels/lovelace/views/hui-view.ts index 1d78c5e7a8..f579e40ae0 100644 --- a/src/panels/lovelace/views/hui-view.ts +++ b/src/panels/lovelace/views/hui-view.ts @@ -35,7 +35,13 @@ import { declare global { // for fire event interface HASSDomEvents { - "ll-create-card": undefined; + "ll-create-card": + | { + preSaveConfig?: ( + config: LovelaceCardConfig + ) => LovelaceCardConfig | Promise; + } + | undefined; "ll-edit-card": { path: [number] | [number, number] }; "ll-delete-card": { path: [number] | [number, number]; confirm: boolean }; } @@ -236,9 +242,10 @@ export class HUIView extends ReactiveElement { private _createLayoutElement(config: LovelaceViewConfig): void { this._layoutElement = createViewElement(config) as LovelaceViewElement; this._layoutElementType = config.type; - this._layoutElement.addEventListener("ll-create-card", () => { + this._layoutElement.addEventListener("ll-create-card", (ev) => { showCreateCardDialog(this, { lovelaceConfig: this.lovelace.config, + preSaveConfig: ev.detail.preSaveConfig, saveConfig: this.lovelace.saveConfig, path: [this.index], }); diff --git a/src/translations/en.json b/src/translations/en.json index 3f17573f9c..729913a357 100644 --- a/src/translations/en.json +++ b/src/translations/en.json @@ -4984,7 +4984,8 @@ "types": { "masonry": "Masonry (default)", "sidebar": "Sidebar", - "panel": "Panel (1 card)" + "panel": "Panel (1 card)", + "manual": "Manual" }, "subview": "Subview", "subview_helper": "Subviews don't appear in tabs and have a back button.", diff --git a/yarn.lock b/yarn.lock index f5b253c303..fff8b5c09a 100644 --- a/yarn.lock +++ b/yarn.lock @@ -9235,6 +9235,13 @@ __metadata: languageName: node linkType: hard +"gridstack@npm:10.0.1": + version: 10.0.1 + resolution: "gridstack@npm:10.0.1" + checksum: 5310d1e299f01bba68162d1cf69725248a3a4baee2088276927597f74894da6a432981d2e1879464faee91f0011fc4f6cd8b71dceb812a175bb7cbcf4ab7ea8b + languageName: node + linkType: hard + "gulp-cli@npm:^2.2.0": version: 2.3.0 resolution: "gulp-cli@npm:2.3.0" @@ -9613,6 +9620,7 @@ __metadata: fuse.js: "npm:7.0.0" glob: "npm:10.3.10" google-timezones-json: "npm:1.2.0" + gridstack: "npm:10.0.1" gulp: "npm:4.0.2" gulp-flatmap: "npm:1.0.2" gulp-json-transform: "npm:0.4.8"