mirror of
https://github.com/Matysh/houseplan-card
synced 2026-10-03 21:28:59 +00:00
+21
-16
@@ -894,6 +894,7 @@ export class HouseplanCard extends LitElement {
|
||||
this._setMode(mode, animate);
|
||||
}
|
||||
public hass?: any;
|
||||
public panelHost = false;
|
||||
private _config?: CardConfig;
|
||||
|
||||
private _space = 'f1';
|
||||
@@ -1083,12 +1084,11 @@ export class HouseplanCard extends LitElement {
|
||||
* Until the server answers, fail CLOSED: missing `hass.user` must never
|
||||
* open the editors (`=== true`, not `!== false`).
|
||||
*/
|
||||
private get _canEdit(): boolean {
|
||||
if (!this._norm) return false;
|
||||
if (this._serverCanWrite === true) return true;
|
||||
if (this._serverCanWrite === false) return false;
|
||||
private get _canManageConfiguration(): boolean {
|
||||
if (this._serverCanWrite !== null) return this._serverCanWrite;
|
||||
return this.hass?.user?.is_admin === true;
|
||||
}
|
||||
private get _canEdit(): boolean { return this._norm && this._canManageConfiguration; }
|
||||
|
||||
/** Legacy alias: markup machinery is active exactly in plan mode. */
|
||||
private get _kiosk(): boolean {
|
||||
@@ -2516,6 +2516,7 @@ export class HouseplanCard extends LitElement {
|
||||
_tapConfirm: { state: true },
|
||||
_dangerConfirm: { state: true },
|
||||
hass: { attribute: false },
|
||||
panelHost: { type: Boolean, attribute: 'panel-host', reflect: true },
|
||||
_config: { state: true },
|
||||
_space: { state: true },
|
||||
_layout: { state: true },
|
||||
@@ -3834,6 +3835,8 @@ export class HouseplanCard extends LitElement {
|
||||
return 12;
|
||||
}
|
||||
|
||||
public getGridOptions(): { columns: 'full' } { return { columns: 'full' }; }
|
||||
|
||||
// ================= MODEL RESOLUTION (server configuration) =================
|
||||
|
||||
/** Whether a server configuration with spaces exists (otherwise — onboarding). */
|
||||
@@ -4134,7 +4137,8 @@ export class HouseplanCard extends LitElement {
|
||||
const card = this.renderRoot.querySelector('ha-card');
|
||||
if (!card) return;
|
||||
const own = stage.getBoundingClientRect().top - card.getBoundingClientRect().top;
|
||||
const above = Math.min(Math.max(card.getBoundingClientRect().top, 0), 120);
|
||||
const above = this.panelHost ? 0
|
||||
: Math.min(Math.max(card.getBoundingClientRect().top, 0), 120);
|
||||
const t = Math.round(own + above);
|
||||
// `t` is already an integer. Ignoring a one-pixel delta left the View
|
||||
// stage one pixel shorter after an editor collapse and made the fitted
|
||||
@@ -4163,6 +4167,7 @@ export class HouseplanCard extends LitElement {
|
||||
if (
|
||||
this._serverStorage &&
|
||||
this._loadOk &&
|
||||
this._canManageConfiguration &&
|
||||
this._model.length === 0 &&
|
||||
!this._spaceDialog &&
|
||||
!this._importDialog &&
|
||||
@@ -11268,9 +11273,9 @@ export class HouseplanCard extends LitElement {
|
||||
const fixed = this._fixedFloorState(model);
|
||||
if (fixed.kind === 'pending') {
|
||||
return html`<ha-card data-fixed-floor-state="pending">
|
||||
<div class="head">
|
||||
${this.panelHost ? nothing : html`<div class="head">
|
||||
<div class="title"><ha-icon icon="mdi:home-city"></ha-icon>${this._config.title || this._t('card.title')}</div>
|
||||
</div>
|
||||
</div>`}
|
||||
<div class="empty" role="status" aria-live="polite">
|
||||
<ha-icon icon="mdi:loading" class="big fixedfloor-loading"></ha-icon>
|
||||
<p>${this._t('fixed_floor.loading')}</p>
|
||||
@@ -11281,9 +11286,9 @@ export class HouseplanCard extends LitElement {
|
||||
return html`<ha-card
|
||||
data-fixed-floor-state="invalid"
|
||||
data-fixed-floor-reason=${fixed.reason}>
|
||||
<div class="head">
|
||||
${this.panelHost ? nothing : html`<div class="head">
|
||||
<div class="title"><ha-icon icon="mdi:home-city"></ha-icon>${this._config.title || this._t('card.title')}</div>
|
||||
</div>
|
||||
</div>`}
|
||||
<div class="empty fixedfloor-error" role="alert" aria-live="assertive">
|
||||
<ha-icon icon="mdi:alert-circle-outline" class="big"></ha-icon>
|
||||
<p><b>${this._t('fixed_floor.invalid_title')}</b></p>
|
||||
@@ -11301,18 +11306,18 @@ export class HouseplanCard extends LitElement {
|
||||
data-ha-registry-access=${diagnostics.registry.access}
|
||||
data-ha-disabled-bindings=${diagnostics.bindings.ha_disabled}
|
||||
data-ha-unverified-bindings=${diagnostics.bindings.unverified}>
|
||||
<div class="head">
|
||||
${this.panelHost ? nothing : html`<div class="head">
|
||||
<div class="title"><ha-icon icon="mdi:home-city"></ha-icon>${this._config.title || this._t('card.title')}</div>
|
||||
</div>
|
||||
</div>`}
|
||||
<div class="empty">
|
||||
<ha-icon icon="mdi:floor-plan" class="big"></ha-icon>
|
||||
<p>${this._t('empty.no_spaces')}</p>
|
||||
${this._serverStorage
|
||||
${this._serverStorage && this._canManageConfiguration
|
||||
? html`<p class="muted">${this._t('empty.add_first')}</p>
|
||||
<button class="btn on" @click=${() => this._openSpaceDialog('create')}>
|
||||
<ha-icon icon="mdi:plus"></ha-icon>${this._t('btn.add_space')}
|
||||
</button>`
|
||||
: html`<p class="muted">${this._t('empty.install')}</p>`}
|
||||
: html`<p class="muted">${this._t(this._serverStorage ? 'empty.read_only' : 'empty.install')}</p>`}
|
||||
</div>
|
||||
${this._spaceDialog
|
||||
? (this._onboardingRuntime || this._editorRuntime) ? this._renderSpaceDialog() : nothing
|
||||
@@ -11403,10 +11408,10 @@ export class HouseplanCard extends LitElement {
|
||||
@click=${this._touchGestureGuard}>
|
||||
<div class="hdr ${this._kiosk ? 'kioskhide' : ''}">
|
||||
<div class="head">
|
||||
<div class="title">
|
||||
${this.panelHost ? nothing : html`<div class="title">
|
||||
<ha-icon icon="mdi:home-city"></ha-icon>
|
||||
${this._config.title || this._t('card.title')}
|
||||
</div>
|
||||
</div>`}
|
||||
<div class="tabs" @pointermove=${(e: PointerEvent) => this._tabPointerMove(e)}>
|
||||
${navigationSpaces.map(
|
||||
(s) => html`<button
|
||||
@@ -11516,7 +11521,7 @@ export class HouseplanCard extends LitElement {
|
||||
<div class="stage ${iso ? `projection-iso ${deviceThemeClass(this._renderPlanHass)}` : ''} ${this._markup ? 'markup tool-' + this._tool + (this._tool === 'split' && !this._splitSel ? ' pickstage' : '') + (this._tool === 'wallthick' && this._wallThickHover ? ' wallhot' : '') : ''} ${this._mode === 'decor' ? 'dtool-' + this._decorTool : ''} ${space.bg ? '' : 'noplan'} mode-${this._mode}${this._bdMovable ? ' bdgrab' : ''}${this._bdDrag ? ' bdgrabbing' : ''}${dayCycle ? ` daycycle phase-${dayCycle.phase}` : ''}${this._booting ? ' hpboot' : ''}${this._bootSoft ? ' hpsettle' : ''}${this._modeTransitionBusy ? ' mode-transition' : ''}"
|
||||
data-hp-iso-stage=${iso ? '3' : nothing} data-hp-iso-structural-builds=${iso ? this._isoStructuralBuildCount : nothing}
|
||||
?inert=${this._modeTransitionBusy}
|
||||
style="height:${modeVisual ? `${modeVisual.stageHeight}px` : this._kiosk ? '100dvh' : `calc(100dvh - ${this._hdrH}px)`}${transitionStageBg ? `;background:${transitionStageBg}` : ''};--hp-cell-visual-scale:${gridVisualScale(this._cellCm)};--wall-fill:${this._fillColors.wall_fill.c};--wall-fill-op:${this._fillColors.wall_fill.a};--hp-mode-architecture-opacity:${modeVisual ? modeVisual.architectureOpacity : this._mode === 'decor' ? 0.35 : 1};--hp-mode-view-weight:${modeVisual?.viewWeight ?? (this._mode === 'view' ? 1 : 0)};--hp-mode-editor-weight:${modeVisual?.editorWeight ?? (this._mode === 'view' ? 0 : 1)}${modeVisual ? `;--hp-mode-paper:${modeVisual.paperColor}` : ''}${dayCycle ? `;${dayCycleStageVars(dayCycle)}` : ''}"
|
||||
style="height:${modeVisual ? `${modeVisual.stageHeight}px` : this.panelHost ? 'auto' : this._kiosk ? '100dvh' : `calc(100dvh - ${this._hdrH}px)`}${transitionStageBg ? `;background:${transitionStageBg}` : ''};--hp-cell-visual-scale:${gridVisualScale(this._cellCm)};--wall-fill:${this._fillColors.wall_fill.c};--wall-fill-op:${this._fillColors.wall_fill.a};--hp-mode-architecture-opacity:${modeVisual ? modeVisual.architectureOpacity : this._mode === 'decor' ? 0.35 : 1};--hp-mode-view-weight:${modeVisual?.viewWeight ?? (this._mode === 'view' ? 1 : 0)};--hp-mode-editor-weight:${modeVisual?.editorWeight ?? (this._mode === 'view' ? 0 : 1)}${modeVisual ? `;--hp-mode-paper:${modeVisual.paperColor}` : ''}${dayCycle ? `;${dayCycleStageVars(dayCycle)}` : ''}"
|
||||
@click=${(e: MouseEvent) => this._markupClick(e)}
|
||||
@wheel=${(e: WheelEvent) => this._onWheel(e)}
|
||||
@pointerdown=${(e: PointerEvent) => { this._notePointer(e); this._stagePointerDown(e); }}
|
||||
|
||||
@@ -0,0 +1,189 @@
|
||||
import './houseplan-card';
|
||||
|
||||
interface HouseplanCardElement extends HTMLElement {
|
||||
hass?: unknown;
|
||||
panelHost: boolean;
|
||||
setConfig(config: { type: 'custom:houseplan-card' }): void;
|
||||
}
|
||||
|
||||
interface HomeAssistantPanelHost {
|
||||
localize?: (key: string) => string | undefined;
|
||||
}
|
||||
|
||||
/** Home Assistant custom-panel shell; the existing card owns all product state. */
|
||||
export class HouseplanPanel extends HTMLElement {
|
||||
private _hass?: HomeAssistantPanelHost;
|
||||
private _narrow = false;
|
||||
private _route?: unknown;
|
||||
private _panel?: unknown;
|
||||
private _card?: HouseplanCardElement;
|
||||
private _menuButton?: HTMLButtonElement;
|
||||
|
||||
public constructor() {
|
||||
super();
|
||||
this.attachShadow({ mode: 'open' });
|
||||
}
|
||||
|
||||
public connectedCallback(): void {
|
||||
this._ensureShell();
|
||||
}
|
||||
|
||||
public get hass(): HomeAssistantPanelHost | undefined { return this._hass; }
|
||||
public set hass(value: HomeAssistantPanelHost | undefined) {
|
||||
this._hass = value;
|
||||
this._ensureShell();
|
||||
if (this._card) this._card.hass = value;
|
||||
this._syncMenuLabel();
|
||||
}
|
||||
|
||||
public get narrow(): boolean { return this._narrow; }
|
||||
public set narrow(value: boolean) {
|
||||
this._narrow = Boolean(value);
|
||||
this.toggleAttribute('narrow', this._narrow);
|
||||
}
|
||||
|
||||
public get route(): unknown { return this._route; }
|
||||
public set route(value: unknown) { this._route = value; }
|
||||
|
||||
public get panel(): unknown { return this._panel; }
|
||||
public set panel(value: unknown) { this._panel = value; }
|
||||
|
||||
private _ensureShell(): void {
|
||||
if (this._card || !this.shadowRoot) return;
|
||||
|
||||
const style = document.createElement('style');
|
||||
style.textContent = `
|
||||
:host {
|
||||
display: block;
|
||||
width: 100%;
|
||||
height: 100%;
|
||||
min-width: 0;
|
||||
min-height: 0;
|
||||
overflow: hidden;
|
||||
color: var(--primary-text-color);
|
||||
background: var(--primary-background-color);
|
||||
}
|
||||
.page {
|
||||
width: 100%;
|
||||
height: 100%;
|
||||
min-width: 0;
|
||||
min-height: 0;
|
||||
display: grid;
|
||||
grid-template-rows: auto minmax(0, 1fr);
|
||||
overflow: hidden;
|
||||
}
|
||||
.appbar {
|
||||
box-sizing: border-box;
|
||||
min-width: 0;
|
||||
min-height: var(--header-height, 56px);
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 12px;
|
||||
padding: 0 16px;
|
||||
background: var(--app-header-background-color, var(--primary-background-color));
|
||||
color: var(--app-header-text-color, var(--primary-text-color));
|
||||
border-bottom: 1px solid var(--divider-color);
|
||||
z-index: 1;
|
||||
}
|
||||
.menu {
|
||||
box-sizing: border-box;
|
||||
width: 44px;
|
||||
height: 44px;
|
||||
min-width: 44px;
|
||||
min-height: 44px;
|
||||
margin: 0;
|
||||
padding: 0;
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
border: 0;
|
||||
border-radius: 50%;
|
||||
color: inherit;
|
||||
background: transparent;
|
||||
cursor: pointer;
|
||||
}
|
||||
.menu:focus-visible {
|
||||
outline: 2px solid var(--primary-color);
|
||||
outline-offset: 2px;
|
||||
}
|
||||
@media (hover: hover) {
|
||||
.menu:hover { background: var(--secondary-background-color); }
|
||||
}
|
||||
.menu ha-icon { --mdc-icon-size: 24px; }
|
||||
.title {
|
||||
min-width: 0;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 10px;
|
||||
font-size: 20px;
|
||||
font-weight: 500;
|
||||
line-height: 1.2;
|
||||
}
|
||||
.title ha-icon {
|
||||
--mdc-icon-size: 24px;
|
||||
color: var(--primary-color);
|
||||
}
|
||||
.content {
|
||||
min-width: 0;
|
||||
min-height: 0;
|
||||
overflow: hidden;
|
||||
}
|
||||
houseplan-card {
|
||||
width: 100%;
|
||||
height: 100%;
|
||||
min-width: 0;
|
||||
min-height: 0;
|
||||
}
|
||||
`;
|
||||
|
||||
const page = document.createElement('div');
|
||||
page.className = 'page';
|
||||
const appbar = document.createElement('header');
|
||||
appbar.className = 'appbar';
|
||||
appbar.setAttribute('role', 'toolbar');
|
||||
|
||||
const menu = document.createElement('button');
|
||||
menu.className = 'menu';
|
||||
menu.type = 'button';
|
||||
menu.innerHTML = '<ha-icon icon="mdi:menu"></ha-icon>';
|
||||
menu.addEventListener('click', () => {
|
||||
menu.dispatchEvent(new CustomEvent('hass-toggle-menu', {
|
||||
bubbles: true,
|
||||
composed: true,
|
||||
}));
|
||||
});
|
||||
this._menuButton = menu;
|
||||
|
||||
const title = document.createElement('div');
|
||||
title.className = 'title';
|
||||
title.setAttribute('role', 'heading');
|
||||
title.setAttribute('aria-level', '1');
|
||||
title.innerHTML = '<ha-icon icon="mdi:floor-plan"></ha-icon><span>House Plan</span>';
|
||||
appbar.append(menu, title);
|
||||
|
||||
const content = document.createElement('main');
|
||||
content.className = 'content';
|
||||
const card = document.createElement('houseplan-card') as HouseplanCardElement;
|
||||
card.panelHost = true;
|
||||
card.setConfig({ type: 'custom:houseplan-card' });
|
||||
if (this._hass !== undefined) card.hass = this._hass;
|
||||
content.append(card);
|
||||
page.append(appbar, content);
|
||||
this.shadowRoot.append(style, page);
|
||||
this._card = card;
|
||||
this._syncMenuLabel();
|
||||
}
|
||||
|
||||
private _syncMenuLabel(): void {
|
||||
if (!this._menuButton) return;
|
||||
const label = this._hass?.localize?.('ui.common.menu')
|
||||
|| this._hass?.localize?.('ui.panel.lovelace.menu')
|
||||
|| 'Menu';
|
||||
this._menuButton.setAttribute('aria-label', label);
|
||||
this._menuButton.title = label;
|
||||
}
|
||||
}
|
||||
|
||||
if (!customElements.get('houseplan-panel')) {
|
||||
customElements.define('houseplan-panel', HouseplanPanel);
|
||||
}
|
||||
@@ -53,6 +53,7 @@
|
||||
"count.devices": "{n} Entwicklung",
|
||||
"empty.no_spaces": "Noch keine Bereiche.",
|
||||
"empty.add_first": "Ersten Bereich hinzufügen und einen Grundriss hochladen.",
|
||||
"empty.read_only": "Es sind keine Bereiche eingerichtet. Bitten Sie einen Administrator, einen hinzuzufügen.",
|
||||
"empty.install": "Installieren Sie die House Plan-Integration und fügen Sie sie unter „Geräte & Dienste“ hinzu.",
|
||||
"btn.add_space": "Bereich hinzufügen",
|
||||
"btn.cancel": "Abbrechen",
|
||||
|
||||
@@ -53,6 +53,7 @@
|
||||
"count.devices": "{n} dev.",
|
||||
"empty.no_spaces": "No spaces yet.",
|
||||
"empty.add_first": "Add the first space and upload a floor plan.",
|
||||
"empty.read_only": "No spaces are configured. Ask an administrator to add one.",
|
||||
"empty.install": "Install the House Plan integration and add it in \"Devices & services\".",
|
||||
"btn.add_space": "Add space",
|
||||
"btn.cancel": "Cancel",
|
||||
|
||||
@@ -53,6 +53,7 @@
|
||||
"count.devices": "{n} app.",
|
||||
"empty.no_spaces": "Aucun espace pour le moment.",
|
||||
"empty.add_first": "Ajoutez le premier espace et importez un plan.",
|
||||
"empty.read_only": "Aucun espace n’est configuré. Demandez à un administrateur d’en ajouter un.",
|
||||
"empty.install": "Installez l’intégration House Plan et ajoutez-la dans « Appareils et services ».",
|
||||
"btn.add_space": "Ajouter un espace",
|
||||
"btn.cancel": "Annuler",
|
||||
|
||||
@@ -53,6 +53,7 @@
|
||||
"count.devices": "{n} устр.",
|
||||
"empty.no_spaces": "Пространств пока нет.",
|
||||
"empty.add_first": "Добавьте первое пространство и загрузите план этажа.",
|
||||
"empty.read_only": "Пространства не настроены. Попросите администратора добавить первое.",
|
||||
"empty.install": "Установите интеграцию House Plan и добавьте запись в «Устройства и службы».",
|
||||
"btn.add_space": "Добавить пространство",
|
||||
"btn.cancel": "Отмена",
|
||||
|
||||
@@ -37,6 +37,46 @@ export const baseStyles = css`
|
||||
display: block;
|
||||
position: relative;
|
||||
}
|
||||
:host([panel-host]) {
|
||||
width: 100%;
|
||||
height: 100%;
|
||||
min-width: 0;
|
||||
min-height: 0;
|
||||
overflow: hidden;
|
||||
}
|
||||
:host([panel-host]) ha-card {
|
||||
box-sizing: border-box;
|
||||
width: 100%;
|
||||
height: 100%;
|
||||
min-width: 0;
|
||||
min-height: 0;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
overflow: hidden;
|
||||
border: 0;
|
||||
border-radius: 0;
|
||||
box-shadow: none;
|
||||
--ha-card-border-radius: 0;
|
||||
--ha-card-box-shadow: none;
|
||||
}
|
||||
:host([panel-host]) .hdr {
|
||||
position: relative;
|
||||
top: auto;
|
||||
flex: 0 0 auto;
|
||||
border-radius: 0;
|
||||
}
|
||||
:host([panel-host]) .stage,
|
||||
:host([panel-host]) .empty {
|
||||
flex: 1 1 auto;
|
||||
min-height: 0;
|
||||
}
|
||||
:host([panel-host]) .stage.mode-transition {
|
||||
flex: 0 0 auto;
|
||||
}
|
||||
:host([panel-host]) .empty {
|
||||
box-sizing: border-box;
|
||||
overflow: auto;
|
||||
}
|
||||
ha-card {
|
||||
overflow: visible; /* overflow:hidden breaks position:sticky on the header */
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user