mirror of
https://github.com/Matysh/houseplan-card
synced 2026-10-02 04:38:55 +00:00
v1.60.0: harden background editing and device state
This commit is contained in:
+84
-16
@@ -61,8 +61,8 @@ export function isTempEntity(hass: any, eid: string): boolean {
|
||||
* outrank the vacuum/climate/cover/etc. entity it configures.
|
||||
*/
|
||||
const DEVICE_STATE_DOMAINS = [
|
||||
'vacuum', 'lawn_mower', 'climate', 'light', 'cover', 'lock', 'valve',
|
||||
'alarm_control_panel', 'water_heater', 'media_player', 'fan', 'humidifier',
|
||||
'vacuum', 'lawn_mower', 'climate', 'media_player', 'light', 'cover', 'lock', 'valve',
|
||||
'alarm_control_panel', 'water_heater', 'fan', 'humidifier',
|
||||
'siren', 'camera', 'remote',
|
||||
];
|
||||
|
||||
@@ -147,7 +147,10 @@ export interface LightSourceDevice {
|
||||
hidden?: boolean;
|
||||
entities: string[];
|
||||
primary?: string;
|
||||
/** Runtime-effective controls; marker.controls remains the persisted list. */
|
||||
controls?: string[];
|
||||
marker?: {
|
||||
binding?: string;
|
||||
room_id?: string | null;
|
||||
is_light?: boolean | null;
|
||||
controls?: string[] | null;
|
||||
@@ -166,6 +169,24 @@ export interface ResolvedLightSource<D extends LightSourceDevice = LightSourceDe
|
||||
on: boolean;
|
||||
}
|
||||
|
||||
/**
|
||||
* Controls are OTHER light targets operated by this marker. A marker already
|
||||
* operates its own bound entity/device through the normal tap action; storing
|
||||
* one of those same entities in controls made a plain fan/socket look like an
|
||||
* explicitly configured light source. `is_light` is the sole explicit way to
|
||||
* say that the bound switch itself drives a real fixture.
|
||||
*/
|
||||
export function effectiveMarkerControls(
|
||||
binding: string | null | undefined,
|
||||
controls: readonly string[] | null | undefined,
|
||||
ownEntities: readonly string[] = [],
|
||||
): string[] {
|
||||
const own = new Set(ownEntities);
|
||||
if (binding?.startsWith('entity:')) own.add(binding.slice('entity:'.length));
|
||||
return [...new Set(controls || [])]
|
||||
.filter((eid) => isControllable(eid) && !own.has(eid));
|
||||
}
|
||||
|
||||
function lightSourceBelongsToRoom(d: LightSourceDevice, room: LightSourceRoom): boolean {
|
||||
if (typeof room === 'string') return d.area === room;
|
||||
// An explicit marker-to-room binding is more precise than the HA area and
|
||||
@@ -175,8 +196,12 @@ function lightSourceBelongsToRoom(d: LightSourceDevice, room: LightSourceRoom):
|
||||
return !!room.area && d.area === room.area;
|
||||
}
|
||||
|
||||
function lightEntitiesOf(d: LightSourceDevice): { eids: string[]; via: ResolvedLightSource['via'] } {
|
||||
const controls = (d.marker?.controls || []).filter(isControllable);
|
||||
function lightEntitiesOf(hass: any, d: LightSourceDevice): { eids: string[]; via: ResolvedLightSource['via'] } {
|
||||
const controls = effectiveMarkerControls(
|
||||
d.marker?.binding,
|
||||
d.controls ?? d.marker?.controls,
|
||||
d.entities,
|
||||
);
|
||||
if (controls.length) return { eids: controls, via: 'controls' };
|
||||
|
||||
if (d.marker?.is_light === true) {
|
||||
@@ -190,6 +215,15 @@ function lightEntitiesOf(d: LightSourceDevice): { eids: string[]; via: ResolvedL
|
||||
return { eids: primary ? [primary] : controllable.slice(0, 1), via: 'forced' };
|
||||
}
|
||||
|
||||
// Automatic light discovery describes the DEVICE, not every auxiliary
|
||||
// entity it happens to expose. TVs, soundbars, air cleaners and similar
|
||||
// devices often publish a light.* for a status LED / display illumination;
|
||||
// letting that entity win turned the whole marker yellow and made it a room
|
||||
// light source. The resolved functional role is the shared systemic answer.
|
||||
// Explicit controls/is_light above still override it when the user says the
|
||||
// auxiliary light really is a plan light.
|
||||
const role = d.primary || resolvedDeviceStateEntities(hass, d.entities)[0];
|
||||
if (role && !role.startsWith('light.')) return { eids: [], via: 'light' };
|
||||
return { eids: d.entities.filter((eid) => eid.startsWith('light.')), via: 'light' };
|
||||
}
|
||||
|
||||
@@ -208,7 +242,7 @@ export function resolvedLightSources<D extends LightSourceDevice>(
|
||||
const seen = new Set<string>();
|
||||
for (const d of devices) {
|
||||
if (d.hidden || (room != null && !lightSourceBelongsToRoom(d, room))) continue;
|
||||
const { eids, via } = lightEntitiesOf(d);
|
||||
const { eids, via } = lightEntitiesOf(hass, d);
|
||||
for (const eid of eids) {
|
||||
if (!eid || seen.has(eid)) continue;
|
||||
seen.add(eid);
|
||||
@@ -353,6 +387,33 @@ export interface RemovedPlanBindings {
|
||||
entities: Set<string>;
|
||||
}
|
||||
|
||||
export interface DeletePlanMarkerResult {
|
||||
markers: Marker[];
|
||||
cleanupIds: Set<string>;
|
||||
}
|
||||
|
||||
/** Remove one marker binding without letting the shared literal `virtual`
|
||||
* identify every manual marker. Real HA bindings are unique and therefore
|
||||
* deduplicated; virtual markers have identity only by id. Non-virtual
|
||||
* deletion leaves a hidden tombstone so an older cached card degrades to a
|
||||
* hidden marker instead of visibly resurrecting it. */
|
||||
export function deletePlanMarkerRecords(
|
||||
markers: readonly Marker[], id: string, binding: string, virtual: boolean,
|
||||
): DeletePlanMarkerResult {
|
||||
const cleanupIds = new Set<string>([id]);
|
||||
const kept = markers.filter((marker) => {
|
||||
const drop = marker.id === id || (!virtual && marker.binding === binding);
|
||||
if (drop) cleanupIds.add(marker.id);
|
||||
return !drop;
|
||||
});
|
||||
return {
|
||||
markers: virtual
|
||||
? kept
|
||||
: [...kept, { id, binding, removed: true, hidden: true }],
|
||||
cleanupIds,
|
||||
};
|
||||
}
|
||||
|
||||
/** Bindings explicitly deleted from the plan, kept as minimal tombstones. */
|
||||
export function removedPlanBindings(markers?: readonly Marker[] | null): RemovedPlanBindings {
|
||||
const devices = new Set<string>();
|
||||
@@ -394,10 +455,13 @@ export function isRemovedPlanSource(
|
||||
}
|
||||
|
||||
function applyMarker(item: DevItem, m: Marker, hass: any, removed: RemovedPlanBindings): void {
|
||||
const controls = (m.controls || []).filter((eid) => !isRemovedPlanEntity(hass, eid, removed));
|
||||
item.marker = controls.length === (m.controls || []).length
|
||||
? m
|
||||
: { ...m, controls: controls.length ? controls : null };
|
||||
const controls = effectiveMarkerControls(m.binding, m.controls, item.entities)
|
||||
.filter((eid) => !isRemovedPlanEntity(hass, eid, removed));
|
||||
// Keep persisted configuration lossless. Runtime consumers use the filtered
|
||||
// projection above; the dialog edits the original list and therefore cannot
|
||||
// silently write a temporarily suppressed control out of the marker.
|
||||
item.marker = m;
|
||||
item.controls = controls;
|
||||
if (m.hidden) item.hidden = true;
|
||||
if (m.name) item.name = m.name;
|
||||
if (m.icon) item.icon = m.icon;
|
||||
@@ -430,7 +494,9 @@ export function seedHiddenBindings(ctx: Omit<BuildCtx, 'showAll' | 'loc'>): stri
|
||||
if (!area || !areaToSpace[area]) continue;
|
||||
if (dev.entry_type === 'service') continue;
|
||||
if (marked.has('device:' + dev.id)) continue;
|
||||
const entIds = (entsBy[dev.id] || []).filter((eid) => !isRemovedPlanEntity(h, eid, removed));
|
||||
// An entity tombstone suppresses that standalone binding, not the same
|
||||
// entity as data belonging to a still-live parent device.
|
||||
const entIds = entsBy[dev.id] || [];
|
||||
const dom = domainOfDevice(h, dev, entIds);
|
||||
let nonPhysical =
|
||||
excluded.has(dom)
|
||||
@@ -473,7 +539,7 @@ export function buildDevices(ctx: BuildCtx): DevItem[] {
|
||||
if (claimed.has('device:' + dev.id)) continue; // a marker will take over below
|
||||
const marker = markerFor('device', dev.id);
|
||||
if (marker && marker.hidden && !settings.filter_seeded) continue; // legacy: dropped entirely
|
||||
const entIds = (entsBy[dev.id] || []).filter((eid) => !isRemovedPlanEntity(h, eid, removed));
|
||||
const entIds = entsBy[dev.id] || [];
|
||||
const dom = domainOfDevice(h, dev, entIds);
|
||||
// LEGACY runtime filter: only while the config is not yet materialised
|
||||
// (docs/FILTERING.md). A seeded config hides by explicit marker flags.
|
||||
@@ -543,7 +609,7 @@ export function buildDevices(ctx: BuildCtx): DevItem[] {
|
||||
const area = m.area || dev?.area_id || '';
|
||||
const space = (area && areaToSpace[area]) || m.space || firstSpaceId;
|
||||
const entIds = dev
|
||||
? (entsBy[dev.id] || []).filter((eid) => !isRemovedPlanEntity(h, eid, removed))
|
||||
? (entsBy[dev.id] || [])
|
||||
: [];
|
||||
let icon = dev
|
||||
? resolveIcon(h, dev.name_by_user || dev.name || '', dev.model, entIds, iconRules)
|
||||
@@ -632,7 +698,6 @@ export function sourceValue(
|
||||
): number | null {
|
||||
if (!src) return null;
|
||||
if (isRemovedPlanSource(hass, src, markers)) return null;
|
||||
const removed = removedPlanBindings(markers);
|
||||
const i = src.indexOf(':');
|
||||
if (i < 0) return null;
|
||||
const k = src.slice(0, i);
|
||||
@@ -646,8 +711,7 @@ export function sourceValue(
|
||||
if (k === 'device') {
|
||||
const entIds = Object.entries(hass.entities as Record<string, any>)
|
||||
.filter(([, r]) => (r as any).device_id === ref)
|
||||
.map(([eid]) => eid)
|
||||
.filter((eid) => !isRemovedPlanEntity(hass, eid, removed));
|
||||
.map(([eid]) => eid);
|
||||
return kind === 'temp' ? tempFor(hass, entIds) : humFor(hass, entIds);
|
||||
}
|
||||
return null;
|
||||
@@ -726,7 +790,11 @@ export function areaClimateMap(
|
||||
// area -> device (or lone entity) -> the entities that belong to it
|
||||
const byArea = new Map<string, Map<string, { name: string; model?: string; ents: string[] }>>();
|
||||
for (const [eid, reg] of Object.entries<any>(hass.entities)) {
|
||||
if (isRemovedPlanEntity(hass, eid, removed)) continue;
|
||||
// A device tombstone suppresses all of its data. An entity tombstone only
|
||||
// suppresses a standalone entity; inside its live parent device it remains
|
||||
// available to device state, cards and room aggregates.
|
||||
if ((reg.device_id && removed.devices.has(reg.device_id))
|
||||
|| (!reg.device_id && removed.entities.has(eid))) continue;
|
||||
const dev = reg.device_id ? hass.devices?.[reg.device_id] : null;
|
||||
const area = reg.area_id || dev?.area_id || null;
|
||||
if (!area) continue;
|
||||
|
||||
@@ -47,6 +47,30 @@ export const normalizeAngle = (value: unknown): number => {
|
||||
return n > 180 ? n - 360 : n;
|
||||
};
|
||||
|
||||
/**
|
||||
* Axis-aligned boxes keep their stored top-left on the lattice. For an
|
||||
* oriented resize, however, resizeDecorBox has already derived this top-left
|
||||
* from the fixed opposite corner. Snapping it again translates the whole box
|
||||
* and breaks the fixed-corner contract.
|
||||
*/
|
||||
export function resizedBoxTopLeft(
|
||||
box: Pick<DecorBox, 'x' | 'y'>,
|
||||
angle: unknown,
|
||||
grid: (point: number[]) => number[],
|
||||
): number[] {
|
||||
return normalizeAngle(angle) ? [box.x, box.y] : grid([box.x, box.y]);
|
||||
}
|
||||
|
||||
/** A rectangle/ellipse must have two non-zero axes; a line only needs length. */
|
||||
export function validDecorDraft(
|
||||
kind: 'line' | 'rect' | 'ellipse',
|
||||
a: number[], b: number[], minSize: number,
|
||||
): boolean {
|
||||
const dx = Math.abs(b[0] - a[0]), dy = Math.abs(b[1] - a[1]);
|
||||
if (kind === 'line') return Math.hypot(dx, dy) >= minSize;
|
||||
return dx >= minSize && dy >= minSize;
|
||||
}
|
||||
|
||||
/** Render units represented by a physical number of centimetres. */
|
||||
export const decorCmToUnits = (cm: unknown, cellCm: number, gridPitch: number): number => {
|
||||
const n = Number(cm);
|
||||
|
||||
+331
-82
@@ -75,6 +75,7 @@ import {
|
||||
areaTemp, areaHum, sourceValue, areaClimateMap,
|
||||
resolvedLightSources, resolvedLightState, resolvedLightStats,
|
||||
resolvedDeviceStateEntities, removedPlanBindings, isRemovedPlanEntity,
|
||||
deletePlanMarkerRecords, effectiveMarkerControls, resolveIcon,
|
||||
type AreaClimate,
|
||||
} from './devices';
|
||||
import type {
|
||||
@@ -103,12 +104,14 @@ import {
|
||||
DEFAULT_DECOR_STYLE, boxAnchors, boxCorners, clamp01, decorCmToUnits,
|
||||
decorStrokeUnits, decorStyleOf, decorStylePatch,
|
||||
decorUnitsToCm, mergeSnapGeometry, normalizeAngle, resizeDecorBox,
|
||||
snapDecorPoint, type DecorBox, type SnapGeometry,
|
||||
resizedBoxTopLeft, snapDecorPoint, validDecorDraft,
|
||||
type DecorBox, type SnapGeometry,
|
||||
} from './editors/decor/geometry';
|
||||
|
||||
const CARD_VERSION = '1.60.0-beta.1';
|
||||
const CARD_VERSION = '1.60.0';
|
||||
/** Keeps every previously valid scale at the maximum 20 cm grid scale lossless. */
|
||||
const DECOR_TEXT_CM_MAX = 2000;
|
||||
const CELL_CM_MAX = 1000;
|
||||
/** HP-1552 boot-veil timing (AUD-1552-02). The veil holds for at least
|
||||
* BOOT_MIN_MS; every stage-height change restarts a BOOT_QUIET_MS
|
||||
* trailing-quiescence requirement (chrome still settling near the cap
|
||||
@@ -122,6 +125,19 @@ const BOOT_MAX_MS = 1200;
|
||||
/** AUD-1552-02: post-reveal grace during which late chrome shifts glide
|
||||
* (CSS height transition on the stage) instead of snapping. */
|
||||
const BOOT_SOFT_MS = 1500;
|
||||
/** A long-backgrounded browser tab may resume through several stale/partial
|
||||
* layout frames (HA chrome, dynamic viewport and a websocket warm re-mount
|
||||
* do not necessarily wake in the same frame). In normal View we keep those
|
||||
* frames behind the stage background and reveal one viewport computed from
|
||||
* a quiet, measurable stage. Quick tab switches stay instant; kiosk and all
|
||||
* editors deliberately bypass this path. */
|
||||
const RESUME_LONG_HIDDEN_MS = 15000;
|
||||
const RESUME_RECENT_MS = 15000;
|
||||
const RESUME_MIN_MS = 220;
|
||||
const RESUME_QUIET_MS = 80;
|
||||
const RESUME_MAX_MS = 750;
|
||||
let pageHiddenAt = typeof document !== 'undefined' && document.visibilityState === 'hidden' ? Date.now() : 0;
|
||||
let pageResumedAt = 0;
|
||||
/** DEV-B703-01: warm re-mount memo — MODULE scope, so it lives with the loaded
|
||||
* PAGE, not with any card instance. Lovelace re-creates card elements when
|
||||
* the websocket reconnects after a long-backgrounded tab; the fresh instance
|
||||
@@ -412,6 +428,9 @@ class HouseplanCard extends LitElement {
|
||||
before: SpaceGeometryState | null;
|
||||
} | null = null;
|
||||
private _decorSel: string | null = null;
|
||||
/** Pending eraser-tool confirmation. Delete/Backspace remains the direct
|
||||
* command for an explicitly selected object. */
|
||||
private _decorEraseConfirm: { id: string; kind: DecorShape['kind'] } | null = null;
|
||||
/** The text dialog. Live references are part of `text`; `pickerEntity` is
|
||||
* only transient UI state and is never persisted (docs/LIVE-TEXT.md). */
|
||||
private _decorTextDialog: {
|
||||
@@ -789,6 +808,7 @@ class HouseplanCard extends LitElement {
|
||||
* _restoreZoom) or applied twice (the dialog revival). */
|
||||
private _warmVp: WarmViewport | null = null;
|
||||
private _warmVpArmed = false;
|
||||
private _warmLongReturn = false;
|
||||
private _warmRevivePending = false;
|
||||
private _warmReviveTimer?: number;
|
||||
/** AUD-159B1-01: this instance's identity in the memo — its generation, the
|
||||
@@ -829,15 +849,31 @@ class HouseplanCard extends LitElement {
|
||||
private _unsubTrail?: () => void;
|
||||
private _vacJumpOnce = false;
|
||||
private _vacVisHandler = () => {
|
||||
if (document.visibilityState === 'hidden') {
|
||||
if (!pageHiddenAt) pageHiddenAt = Date.now();
|
||||
return;
|
||||
}
|
||||
if (document.visibilityState === 'visible') {
|
||||
const now = Date.now();
|
||||
if (pageHiddenAt && now - pageHiddenAt >= RESUME_LONG_HIDDEN_MS) pageResumedAt = now;
|
||||
pageHiddenAt = 0;
|
||||
this._vacJumpOnce = true;
|
||||
// A hidden tab paints nothing, so the 45 s sky transition stood still
|
||||
// while the sun kept moving: come back on the RIGHT colour, then breathe
|
||||
// again (docs/SUN.md, owner 2026-08-04).
|
||||
this._skyElev = null;
|
||||
this._tip = null;
|
||||
this._hoverRoom = null;
|
||||
if (now - pageResumedAt <= RESUME_RECENT_MS) this._beginResumeSettle();
|
||||
this.requestUpdate();
|
||||
}
|
||||
};
|
||||
private _resumeSettling = false;
|
||||
private _resumeRaf = 0;
|
||||
private _resumeStarted = 0;
|
||||
private _resumeLastSize = '';
|
||||
private _resumeLastChange = 0;
|
||||
private _viewportInvalidAt = 0;
|
||||
private _vacFit: { markerId: string; source: string; mapId: string; p: FitParams;
|
||||
drag: null | { kind: 'move' | 'scale'; sx: number; sy: number; p0: FitParams;
|
||||
fx: number; fy: number } } | null = null;
|
||||
@@ -903,6 +939,7 @@ class HouseplanCard extends LitElement {
|
||||
_decorStyle: { state: true },
|
||||
_decorDraft: { state: true },
|
||||
_decorSel: { state: true },
|
||||
_decorEraseConfirm: { state: true },
|
||||
_decorTextDialog: { state: true },
|
||||
_decorShapeDialog: { state: true },
|
||||
_backdropDialog: { state: true },
|
||||
@@ -972,11 +1009,23 @@ class HouseplanCard extends LitElement {
|
||||
this._warmRevivePending = true;
|
||||
this._warmReviveTimer = window.setTimeout(() => this._warmReviveDialog(), 0);
|
||||
}
|
||||
// A successor created by Lovelace after the visibility event did not see
|
||||
// that event itself. Module time bridges that small reconnect gap; if no
|
||||
// card instance survived to hear `visible`, consume the pending hidden
|
||||
// timestamp here instead.
|
||||
const now = Date.now();
|
||||
if (document.visibilityState === 'visible' && pageHiddenAt) {
|
||||
if (now - pageHiddenAt >= RESUME_LONG_HIDDEN_MS) pageResumedAt = now;
|
||||
pageHiddenAt = 0;
|
||||
}
|
||||
if (this._warmLongReturn || now - pageResumedAt <= RESUME_RECENT_MS) this._beginResumeSettle();
|
||||
this._warmLongReturn = false;
|
||||
}
|
||||
|
||||
public disconnectedCallback(): void {
|
||||
document.removeEventListener('visibilitychange', this._vacVisHandler);
|
||||
if (this._vacRaf) { cancelAnimationFrame(this._vacRaf); this._vacRaf = 0; }
|
||||
if (this._resumeRaf) { cancelAnimationFrame(this._resumeRaf); this._resumeRaf = 0; }
|
||||
if (this._skySnapRaf) { cancelAnimationFrame(this._skySnapRaf); this._skySnapRaf = 0; }
|
||||
for (const rt of this._activityRt.values()) clearTimeout(rt.timer); // pending activity-window repaints
|
||||
window.removeEventListener('keydown', this._keyHandler);
|
||||
@@ -1041,6 +1090,7 @@ class HouseplanCard extends LitElement {
|
||||
if (e.key === 'Escape') {
|
||||
// close the topmost open dialog; info popups first, then editors
|
||||
if (this._tapConfirm) { this._tapConfirm = null; return; }
|
||||
if (this._decorEraseConfirm) { this._decorEraseConfirm = null; return; }
|
||||
if (this._openingInfo) { this._openingInfo = null; return; }
|
||||
if (this._infoCard) { this._infoCard = null; return; }
|
||||
if (this._rulesDialog) { this._rulesDialog = null; return; }
|
||||
@@ -1278,7 +1328,11 @@ class HouseplanCard extends LitElement {
|
||||
// connectedCallback (still before the first render, so nothing flashes).
|
||||
// A setConfig on a card that is already attached (the editor's live
|
||||
// preview) re-claims right here, because the config is part of the key.
|
||||
if (this.isConnected) this._warmAdopt();
|
||||
if (this.isConnected) {
|
||||
this._warmAdopt();
|
||||
if (this._warmLongReturn) this._beginResumeSettle();
|
||||
this._warmLongReturn = false;
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
@@ -1301,6 +1355,7 @@ class HouseplanCard extends LitElement {
|
||||
// is waiting for us, and our live state is newer than anything in it
|
||||
const mine = list.find((s) => s.owner === this._warmGen);
|
||||
if (mine) {
|
||||
this._warmLongReturn = !!mine.freed && Date.now() - mine.freed >= RESUME_LONG_HIDDEN_MS;
|
||||
clearTimeout(mine.evict); mine.evict = 0; mine.freed = 0; mine.live = true;
|
||||
this._warmSlot = mine;
|
||||
this._warmKey = key;
|
||||
@@ -1308,6 +1363,7 @@ class HouseplanCard extends LitElement {
|
||||
}
|
||||
const { slot, sure } = warmMatch(list, this._warmGen, place, idx);
|
||||
if (!slot) return;
|
||||
this._warmLongReturn = !!slot.freed && Date.now() - slot.freed >= RESUME_LONG_HIDDEN_MS;
|
||||
this._booting = false;
|
||||
this._bootFading = false;
|
||||
this._hdrH = slot.hdrH;
|
||||
@@ -1530,7 +1586,9 @@ class HouseplanCard extends LitElement {
|
||||
* next one must open at — and a dialog closed with Esc/Cancel/Save writes
|
||||
* `dlg: null` here on the very next render, so it can never come back. */
|
||||
private _warmSnapshot(): void {
|
||||
if (this._booting || this._config?.kiosk) return;
|
||||
// The hidden resume frame still carries the pre-suspension viewport. Do
|
||||
// not replace the warm memo with it; the settled frame writes the truth.
|
||||
if (this._booting || this._resumeSettling || this._config?.kiosk) return;
|
||||
const patch: Partial<WarmEntry> = { vp: this._warmViewportState() };
|
||||
// do not overwrite the snapshot we are about to revive FROM
|
||||
if (!this._warmRevivePending) patch.dlg = this._warmDialogState();
|
||||
@@ -1626,6 +1684,9 @@ class HouseplanCard extends LitElement {
|
||||
/** Bumped by every config mutation — the model/geometry cache key (audit L1). */
|
||||
private _cfgEpoch = 0;
|
||||
private _modelCache: { key: string; model: SpaceModel[] } | null = null;
|
||||
private _decorSnapCache: {
|
||||
epoch: number; space: string; height: number; exclude: string; geometry: SnapGeometry;
|
||||
} | null = null;
|
||||
|
||||
/** Cheap structural fingerprint of the config (audit L1 cache key). */
|
||||
private _cfgFingerprint(): string {
|
||||
@@ -2457,29 +2518,24 @@ class HouseplanCard extends LitElement {
|
||||
return tempFor(this.hass, d.entities);
|
||||
}
|
||||
|
||||
/** Every HA entity owned by a dialog binding. */
|
||||
private _bindingEntities(binding: string): string[] {
|
||||
if (binding.startsWith('entity:')) return [binding.slice(7)];
|
||||
if (!binding.startsWith('device:')) return [];
|
||||
const ref = binding.slice(7);
|
||||
return Object.entries<any>(this.hass?.entities || {})
|
||||
.filter(([, reg]) => reg?.device_id === ref)
|
||||
.map(([eid]) => eid);
|
||||
}
|
||||
|
||||
/** Does the dialog's binding carry a climate entity? Gates the opt-in checkbox. */
|
||||
private _bindingHasClimate(binding: string): boolean {
|
||||
if (binding.startsWith('entity:')) return binding.slice(7).startsWith('climate.');
|
||||
if (binding.startsWith('device:')) {
|
||||
const ref = binding.slice(7);
|
||||
for (const [eid, reg] of Object.entries<any>(this.hass?.entities || {})) {
|
||||
if (reg?.device_id === ref && eid.startsWith('climate.')) return true;
|
||||
}
|
||||
}
|
||||
return false;
|
||||
return this._bindingEntities(binding).some((eid) => eid.startsWith('climate.'));
|
||||
}
|
||||
|
||||
/** The cover entity behind the dialog's binding, or null. */
|
||||
private _bindingCoverEntity(binding: string): string | null {
|
||||
if (binding.startsWith('entity:')) return coverEntityOf([binding.slice(7)]);
|
||||
if (binding.startsWith('device:')) {
|
||||
const ref = binding.slice(7);
|
||||
const eids = Object.entries<any>(this.hass?.entities || {})
|
||||
.filter(([, reg]) => reg?.device_id === ref)
|
||||
.map(([eid]) => eid);
|
||||
return coverEntityOf(eids);
|
||||
}
|
||||
return null;
|
||||
return coverEntityOf(this._bindingEntities(binding));
|
||||
}
|
||||
|
||||
/**
|
||||
@@ -2898,9 +2954,79 @@ class HouseplanCard extends LitElement {
|
||||
this._bootSoft = false;
|
||||
}
|
||||
|
||||
/** Hide only the unstable plan frames after a genuinely long tab sleep.
|
||||
* The header remains usable and the existing stage background stays put;
|
||||
* once its measured size has been quiet, viewport + reveal land in the
|
||||
* same Lit update. */
|
||||
private _beginResumeSettle(): void {
|
||||
if (this._kiosk || this._mode !== 'view' || this._booting || this._resumeSettling) return;
|
||||
this._resumeSettling = true;
|
||||
this._resumeStarted = performance.now();
|
||||
this._resumeLastSize = '';
|
||||
this._resumeLastChange = this._resumeStarted;
|
||||
this._viewportInvalidAt = 0;
|
||||
this.requestUpdate();
|
||||
if (this._resumeRaf) cancelAnimationFrame(this._resumeRaf);
|
||||
this._resumeRaf = requestAnimationFrame(() => this._resumeSettleTick());
|
||||
}
|
||||
|
||||
private _resumeSettleTick(): void {
|
||||
this._resumeRaf = 0;
|
||||
if (!this._resumeSettling || !this.isConnected) return;
|
||||
if (this._kiosk || this._mode !== 'view') {
|
||||
this._resumeSettling = false;
|
||||
this.requestUpdate();
|
||||
return;
|
||||
}
|
||||
const now = performance.now();
|
||||
const stage = this._stageEl;
|
||||
const measurable = !!stage && stage.clientWidth > 0 && stage.clientHeight > 0;
|
||||
if (measurable) {
|
||||
const size = `${stage!.clientWidth}x${stage!.clientHeight}`;
|
||||
if (size !== this._resumeLastSize) {
|
||||
this._resumeLastSize = size;
|
||||
this._resumeLastChange = now;
|
||||
}
|
||||
}
|
||||
const elapsed = now - this._resumeStarted;
|
||||
const settled = measurable && elapsed >= RESUME_MIN_MS && now - this._resumeLastChange >= RESUME_QUIET_MS;
|
||||
if (settled || elapsed >= RESUME_MAX_MS) {
|
||||
const cur = this._view;
|
||||
this._resumeSettling = false;
|
||||
if (measurable) {
|
||||
this._applyView(
|
||||
this._zoom,
|
||||
cur ? cur.x + cur.w / 2 : undefined,
|
||||
cur ? cur.y + cur.h / 2 : undefined,
|
||||
);
|
||||
}
|
||||
this.requestUpdate();
|
||||
return;
|
||||
}
|
||||
this._resumeRaf = requestAnimationFrame(() => this._resumeSettleTick());
|
||||
}
|
||||
|
||||
/** Recompute the view for a new scene size, preserving zoom and center. */
|
||||
private _refitView(): void {
|
||||
if (!this._stageEl) return;
|
||||
const stage = this._stageEl;
|
||||
// ResizeObserver may deliver a zero/transitional box while a browser tab
|
||||
// is frozen or while Lovelace replaces the card. Mutating `_view` from
|
||||
// that box is the scale jump observed on return.
|
||||
if (!stage || document.visibilityState !== 'visible' || stage.clientWidth <= 0 || stage.clientHeight <= 0) {
|
||||
if (!this._viewportInvalidAt) this._viewportInvalidAt = Date.now();
|
||||
return;
|
||||
}
|
||||
if (this._viewportInvalidAt) {
|
||||
const invalidFor = Date.now() - this._viewportInvalidAt;
|
||||
this._viewportInvalidAt = 0;
|
||||
// Lovelace's own view tabs can hide a card without hiding `document`.
|
||||
// A long zero-box interval is the equivalent suspension signal.
|
||||
if (invalidFor >= RESUME_LONG_HIDDEN_MS && !this._resumeSettling) {
|
||||
this._beginResumeSettle();
|
||||
if (this._resumeSettling) return;
|
||||
}
|
||||
}
|
||||
if (this._resumeSettling) return;
|
||||
const cur = this._view;
|
||||
this._applyView(this._zoom, cur ? cur.x + cur.w / 2 : undefined, cur ? cur.y + cur.h / 2 : undefined);
|
||||
this.requestUpdate();
|
||||
@@ -3379,6 +3505,10 @@ class HouseplanCard extends LitElement {
|
||||
this._showToast(this._t('toast.markup_needs_server'));
|
||||
return;
|
||||
}
|
||||
// A live decor transform is a transaction. Switching tabs must cancel and
|
||||
// restore it, not merely forget its pointer record after the config has
|
||||
// already been mutated by move/resize.
|
||||
if (this._decorMove || this._dtDrag || this._bdDrag) this._cancelDecorGesture();
|
||||
const baseChanges = !this._spaceModel().bg && (mode === 'view') !== (this._mode === 'view');
|
||||
if (this._mode === 'view' && mode !== 'view') {
|
||||
// remember the view-mode viewport: whatever zooming happens inside the
|
||||
@@ -3441,6 +3571,7 @@ class HouseplanCard extends LitElement {
|
||||
this._hoverRoom = null;
|
||||
this._decorDraft = null;
|
||||
this._decorSel = null;
|
||||
this._decorMove = null;
|
||||
this._backdropDialog = null;
|
||||
// Every Background session starts predictably on Select. The image has an
|
||||
// explicit tool of its own; only that tool may claim its body or frame.
|
||||
@@ -3669,6 +3800,12 @@ class HouseplanCard extends LitElement {
|
||||
}
|
||||
|
||||
private _undoGeometry = (): void => {
|
||||
if (this._decorDraft) { this._decorDraft = null; this.requestUpdate(); return; }
|
||||
if (this._decorMove || this._dtDrag || this._bdDrag) {
|
||||
this._cancelDecorGesture();
|
||||
return;
|
||||
}
|
||||
if (this._rszDrag) { this._rszCancelDrag(); return; }
|
||||
const command = this._geometryHistory.undo();
|
||||
if (!command) return;
|
||||
if (!this._applyGeometryState(command.before)) {
|
||||
@@ -3679,6 +3816,14 @@ class HouseplanCard extends LitElement {
|
||||
};
|
||||
|
||||
private _redoGeometry = (): void => {
|
||||
// Redo and Undo share the same transaction boundary. The first invocation
|
||||
// cancels an in-progress transform; only the next one navigates history.
|
||||
if (this._decorDraft) { this._decorDraft = null; this.requestUpdate(); return; }
|
||||
if (this._decorMove || this._dtDrag || this._bdDrag) {
|
||||
this._cancelDecorGesture();
|
||||
return;
|
||||
}
|
||||
if (this._rszDrag) { this._rszCancelDrag(); return; }
|
||||
const command = this._geometryHistory.redo();
|
||||
if (!command) return;
|
||||
if (!this._applyGeometryState(command.after)) {
|
||||
@@ -4238,6 +4383,12 @@ class HouseplanCard extends LitElement {
|
||||
|
||||
/** Magnet candidates are intentionally limited to decor and room contours. */
|
||||
private _decorSnapGeometry(excludeId?: string): SnapGeometry {
|
||||
const cacheKey = excludeId || '';
|
||||
const cached = this._decorSnapCache;
|
||||
if (cached && cached.epoch === this._cfgEpoch && cached.space === this._space
|
||||
&& cached.height === this._decorH && cached.exclude === cacheKey) {
|
||||
return cached.geometry;
|
||||
}
|
||||
const parts: SnapGeometry[] = [];
|
||||
for (const shape of this._decorList) {
|
||||
if (shape.id === excludeId) continue;
|
||||
@@ -4266,7 +4417,12 @@ class HouseplanCard extends LitElement {
|
||||
segments: poly.map((p, i) => ({ a: p, b: poly[(i + 1) % poly.length] })),
|
||||
});
|
||||
}
|
||||
return mergeSnapGeometry(parts);
|
||||
const geometry = mergeSnapGeometry(parts);
|
||||
this._decorSnapCache = {
|
||||
epoch: this._cfgEpoch, space: this._space, height: this._decorH,
|
||||
exclude: cacheKey, geometry,
|
||||
};
|
||||
return geometry;
|
||||
}
|
||||
|
||||
private _decorSnap(raw: number[], pointerType = 'mouse', excludeId?: string): number[] {
|
||||
@@ -4284,7 +4440,9 @@ class HouseplanCard extends LitElement {
|
||||
const sp = this._curSpaceCfg;
|
||||
if (!sp) return;
|
||||
sp.decor = this._decorList.map((shape) => shape.id === id ? { ...shape, ...patch } : shape);
|
||||
this._cfgEpoch++;
|
||||
// The edited shape is excluded from the gesture's magnet candidates, so
|
||||
// keep the cached geometry of every *other* object across pointermoves.
|
||||
// A different exclude id/cache key rebuilds it when the next gesture starts.
|
||||
this.requestUpdate();
|
||||
}
|
||||
|
||||
@@ -4374,7 +4532,7 @@ class HouseplanCard extends LitElement {
|
||||
this._decorDraft = null;
|
||||
if (!d) return;
|
||||
const min = this._gridPitch * 0.5;
|
||||
if (Math.hypot(d.b[0] - d.a[0], d.b[1] - d.a[1]) < min) return;
|
||||
if (!validDecorDraft(d.kind, d.a, d.b, min)) return;
|
||||
const W = NORM_W, H = this._decorH;
|
||||
const st = this._decorStyle;
|
||||
const before = this._geometrySnapshot();
|
||||
@@ -4425,13 +4583,7 @@ class HouseplanCard extends LitElement {
|
||||
ev.stopPropagation();
|
||||
ev.preventDefault();
|
||||
if (t === 'erase') {
|
||||
const before = this._geometrySnapshot();
|
||||
const sp = this._curSpaceCfg;
|
||||
sp.decor = this._decorList.filter((x) => x.id !== shape.id);
|
||||
if (this._decorSel === shape.id) this._decorSel = null;
|
||||
this._recordGeometry(this._t('history.decor_delete'), before);
|
||||
this._saveConfig();
|
||||
this.requestUpdate();
|
||||
this._decorEraseConfirm = { id: shape.id, kind: shape.kind };
|
||||
return;
|
||||
}
|
||||
this._decorSel = shape.id;
|
||||
@@ -4668,9 +4820,12 @@ class HouseplanCard extends LitElement {
|
||||
decorCmToUnits(Number(d.sizeHCm), this._cellCm, this._gridPitch), this._gridPitch,
|
||||
));
|
||||
const cx = shape.x * NORM_W + oldW / 2, cy = shape.y * this._decorH + oldH / 2;
|
||||
const topLeft = this._snap([cx - w / 2, cy - h / 2]);
|
||||
const { width: _legacyWidth, angle: _oldAngle, ...rest } = shape;
|
||||
const angle = normalizeAngle(d.angle);
|
||||
const topLeft = resizedBoxTopLeft(
|
||||
{ x: cx - w / 2, y: cy - h / 2 }, angle,
|
||||
(point) => this._snap(point),
|
||||
);
|
||||
const { width: _legacyWidth, angle: _oldAngle, ...rest } = shape;
|
||||
return { ...rest, ...visual,
|
||||
x: clampCanvasN(topLeft[0] / NORM_W), y: clampCanvasN(topLeft[1] / this._decorH),
|
||||
w: w / NORM_W, h: h / this._decorH,
|
||||
@@ -4877,17 +5032,27 @@ class HouseplanCard extends LitElement {
|
||||
this.requestUpdate();
|
||||
}
|
||||
|
||||
private _decorDeleteSel(): void {
|
||||
if (!this._decorSel) return;
|
||||
private _deleteDecor(id: string): void {
|
||||
if (!this._decorList.some((shape) => shape.id === id)) return;
|
||||
const before = this._geometrySnapshot();
|
||||
const sp = this._curSpaceCfg;
|
||||
sp.decor = this._decorList.filter((x) => x.id !== this._decorSel);
|
||||
this._decorSel = null;
|
||||
sp.decor = this._decorList.filter((shape) => shape.id !== id);
|
||||
if (this._decorSel === id) this._decorSel = null;
|
||||
this._recordGeometry(this._t('history.decor_delete'), before);
|
||||
this._saveConfig();
|
||||
this.requestUpdate();
|
||||
}
|
||||
|
||||
private _decorDeleteSel(): void {
|
||||
if (this._decorSel) this._deleteDecor(this._decorSel);
|
||||
}
|
||||
|
||||
private _confirmDecorErase(): void {
|
||||
const pending = this._decorEraseConfirm;
|
||||
this._decorEraseConfirm = null;
|
||||
if (pending) this._deleteDecor(pending.id);
|
||||
}
|
||||
|
||||
// ================= the furniture library (docs/FURNITURE.md) =================
|
||||
|
||||
/** The walls a piece of furniture can stick to: the DERIVED room edges of
|
||||
@@ -5018,13 +5183,18 @@ class HouseplanCard extends LitElement {
|
||||
const sp = this._curSpaceCfg;
|
||||
if (!sp) return;
|
||||
const W = NORM_W, H = this._decorH;
|
||||
const topLeft = this._snap([box.x, box.y]);
|
||||
sp.decor = this._decorList.map((s) => (s.id === id ? {
|
||||
...s,
|
||||
x: clampCanvasN(topLeft[0] / W), y: clampCanvasN(topLeft[1] / H),
|
||||
w: Math.max(this._gridPitch / W, Math.min(CANVAS_LIMIT * 2, box.w / W)),
|
||||
h: Math.max(this._gridPitch / H, Math.min(CANVAS_LIMIT * 2, box.h / H)),
|
||||
} : s));
|
||||
sp.decor = this._decorList.map((s) => {
|
||||
if (s.id !== id) return s;
|
||||
const topLeft = resizedBoxTopLeft(
|
||||
box, (s as any).angle, (point) => this._snap(point),
|
||||
);
|
||||
return {
|
||||
...s,
|
||||
x: clampCanvasN(topLeft[0] / W), y: clampCanvasN(topLeft[1] / H),
|
||||
w: Math.max(this._gridPitch / W, Math.min(CANVAS_LIMIT * 2, box.w / W)),
|
||||
h: Math.max(this._gridPitch / H, Math.min(CANVAS_LIMIT * 2, box.h / H)),
|
||||
} as DecorShape;
|
||||
});
|
||||
this._cfgEpoch++;
|
||||
this.requestUpdate();
|
||||
}
|
||||
@@ -5172,7 +5342,10 @@ class HouseplanCard extends LitElement {
|
||||
),
|
||||
));
|
||||
const cx = current.x + current.w / 2, cy = current.y + current.h / 2;
|
||||
const topLeft = this._snap([cx - w / 2, cy - h / 2]);
|
||||
const topLeft = resizedBoxTopLeft(
|
||||
{ x: cx - w / 2, y: cy - h / 2 }, d.angle,
|
||||
(point) => this._snap(point),
|
||||
);
|
||||
this._bdApply((topLeft[0] - base.x) / NORM_W, (topLeft[1] - base.y) / NORM_W,
|
||||
w / base.w, h / base.h, d.angle);
|
||||
this._backdropDialog = null;
|
||||
@@ -5296,7 +5469,9 @@ class HouseplanCard extends LitElement {
|
||||
|| Math.abs(box.x - d.rect0.x) > 1e-9 || Math.abs(box.y - d.rect0.y) > 1e-9;
|
||||
if (!changed && !d.moved) return;
|
||||
d.moved ||= changed;
|
||||
const topLeft = this._snap([box.x, box.y]);
|
||||
const topLeft = resizedBoxTopLeft(
|
||||
box, d.p0.angle, (point) => this._snap(point),
|
||||
);
|
||||
this._bdApply((topLeft[0] - b.x) / NORM_W, (topLeft[1] - b.y) / NORM_W,
|
||||
sx, sy, d.p0.angle);
|
||||
}
|
||||
@@ -5560,7 +5735,6 @@ class HouseplanCard extends LitElement {
|
||||
const undoName = this._geometryHistory.undoName;
|
||||
const redoName = this._geometryHistory.redoName;
|
||||
return html`<div class="editbar decorbar">
|
||||
<ha-icon icon="mdi:draw" class="warn"></ha-icon>
|
||||
${tools.map(
|
||||
([t, ic, k]) => html`<button class="btn dtool ${this._decorTool === t ? 'on' : ''}"
|
||||
@click=${() => {
|
||||
@@ -5575,11 +5749,12 @@ class HouseplanCard extends LitElement {
|
||||
</button>`,
|
||||
)}
|
||||
<hp-color-opacity .label=${this._t('decor.color')} .color=${this._decorStyle.color}
|
||||
.opacity=${this._decorStyle.opacity}
|
||||
.opacity=${this._decorStyle.opacity} .opacityLabel=${this._t('space.opacity')}
|
||||
@hp-color-opacity-change=${(e: CustomEvent<{ color: string; opacity: number }>) =>
|
||||
(this._decorStyle = { ...this._decorStyle, ...e.detail })}></hp-color-opacity>
|
||||
<label class="drawwall">${this._t('decor.width')}
|
||||
<input type="number" min="0.1" max="100" step="0.1"
|
||||
<input type="number" min=${this._decorSmallField(0.1)}
|
||||
max=${this._decorSmallField(100)} step="0.1"
|
||||
.value=${String(this._decorSmallField(this._decorStyle.widthCm))}
|
||||
@input=${(e: Event) => (this._decorStyle = { ...this._decorStyle,
|
||||
widthCm: this._decorSmallCm(Number((e.target as HTMLInputElement).value)) })} />
|
||||
@@ -5589,7 +5764,8 @@ class HouseplanCard extends LitElement {
|
||||
@change=${(e: Event) => (this._decorStyle = { ...this._decorStyle, fill: (e.target as HTMLInputElement).checked })} />
|
||||
${this._t('decor.fill')}</label>
|
||||
<hp-color-opacity .label=${this._t('decor.fill_color')} .color=${this._decorStyle.fillColor}
|
||||
.opacity=${this._decorStyle.fillOpacity} .disabled=${!this._decorStyle.fill}
|
||||
.opacity=${this._decorStyle.fillOpacity} .opacityLabel=${this._t('space.opacity')}
|
||||
.disabled=${!this._decorStyle.fill}
|
||||
@hp-color-opacity-change=${(e: CustomEvent<{ color: string; opacity: number }>) =>
|
||||
(this._decorStyle = { ...this._decorStyle,
|
||||
fillColor: e.detail.color, fillOpacity: e.detail.opacity })}></hp-color-opacity>` : nothing}
|
||||
@@ -5623,6 +5799,24 @@ class HouseplanCard extends LitElement {
|
||||
</div>`;
|
||||
}
|
||||
|
||||
private _renderDecorEraseConfirm(): TemplateResult {
|
||||
const pending = this._decorEraseConfirm!;
|
||||
const kind = this._t(`decor.${pending.kind}` as any);
|
||||
return html`<hp-dialog .hass=${this.hass} .title=${this._t('decor.erase_confirm_title')}
|
||||
icon="mdi:eraser" dismiss-on-scrim @hp-close=${() => (this._decorEraseConfirm = null)}>
|
||||
<div class="body"><p>${this._t('confirm.erase_decor', { kind })}</p></div>
|
||||
<div class="row" slot="footer">
|
||||
<span class="spacer"></span>
|
||||
<button class="btn ghost" @click=${() => (this._decorEraseConfirm = null)}>
|
||||
${this._t('btn.cancel')}
|
||||
</button>
|
||||
<button class="btn danger" @click=${this._confirmDecorErase}>
|
||||
<ha-icon icon="mdi:eraser"></ha-icon>${this._t('decor.erase')}
|
||||
</button>
|
||||
</div>
|
||||
</hp-dialog>`;
|
||||
}
|
||||
|
||||
private _renderDecorTextDialog(): TemplateResult {
|
||||
const d = this._decorTextDialog!;
|
||||
const ent = (d.pickerEntity || '').trim();
|
||||
@@ -5649,6 +5843,7 @@ class HouseplanCard extends LitElement {
|
||||
if (e.key === 'Enter' && (e.ctrlKey || e.metaKey)) this._decorSaveText();
|
||||
}}></textarea>
|
||||
<hp-color-opacity .label=${this._t('decor.color')} .color=${d.color} .opacity=${d.opacity}
|
||||
.opacityLabel=${this._t('space.opacity')}
|
||||
@hp-color-opacity-change=${(e: CustomEvent<{ color: string; opacity: number }>) =>
|
||||
(this._decorTextDialog = { ...d, ...e.detail })}></hp-color-opacity>
|
||||
<label>${this._t('decor.text_size')}</label>
|
||||
@@ -5706,21 +5901,24 @@ class HouseplanCard extends LitElement {
|
||||
<div class="body">
|
||||
${d.kind === 'furniture' ? html`
|
||||
<label>${this._t('furn.symbol')}</label>
|
||||
<select class="namein" .value=${d.symbol || ''}
|
||||
<select class="namein"
|
||||
@change=${(e: Event) => (this._decorShapeDialog = {
|
||||
...d, symbol: (e.target as HTMLSelectElement).value,
|
||||
})}>
|
||||
${FURNITURE_GROUPS.map((group) => html`<optgroup label=${this._t(`furn.group_${group}` as any)}>
|
||||
${furnitureOfGroup(group).map((symbol) => html`<option value=${symbol.id}>
|
||||
${furnitureOfGroup(group).map((symbol) => html`<option value=${symbol.id}
|
||||
?selected=${symbol.id === d.symbol}>
|
||||
${this._t(`furn.sym_${symbol.id}` as any)}
|
||||
</option>`)}
|
||||
</optgroup>`)}
|
||||
</select>` : nothing}
|
||||
<hp-color-opacity .label=${this._t('decor.color')} .color=${d.color} .opacity=${d.opacity}
|
||||
.opacityLabel=${this._t('space.opacity')}
|
||||
@hp-color-opacity-change=${(e: CustomEvent<{ color: string; opacity: number }>) =>
|
||||
(this._decorShapeDialog = { ...d, ...e.detail })}></hp-color-opacity>
|
||||
<label>${this._t('decor.width')}</label>
|
||||
<div class="colorrow"><input class="namein" type="number" min="0.1" max="100" step="0.1"
|
||||
<div class="colorrow"><input class="namein" type="number"
|
||||
min=${this._decorSmallField(0.1)} max=${this._decorSmallField(100)} step="0.1"
|
||||
.value=${String(this._decorSmallField(d.widthCm))}
|
||||
@input=${(e: Event) => (this._decorShapeDialog = {
|
||||
...d, widthCm: this._decorSmallCm(Number((e.target as HTMLInputElement).value)),
|
||||
@@ -5751,7 +5949,8 @@ class HouseplanCard extends LitElement {
|
||||
...d, fill: (e.target as HTMLInputElement).checked,
|
||||
})} />${this._t('decor.fill')}</label>
|
||||
<hp-color-opacity .label=${this._t('decor.fill_color')}
|
||||
.color=${d.fillColor || d.color} .opacity=${d.fillOpacity ?? 0.25} .disabled=${!d.fill}
|
||||
.color=${d.fillColor || d.color} .opacity=${d.fillOpacity ?? 0.25}
|
||||
.opacityLabel=${this._t('space.opacity')} .disabled=${!d.fill}
|
||||
@hp-color-opacity-change=${(e: CustomEvent<{ color: string; opacity: number }>) =>
|
||||
(this._decorShapeDialog = { ...d,
|
||||
fillColor: e.detail.color, fillOpacity: e.detail.opacity })}></hp-color-opacity>` : nothing}
|
||||
@@ -6253,17 +6452,20 @@ class HouseplanCard extends LitElement {
|
||||
const stage = this._stageEl;
|
||||
const v = this._viewOr(this._baseVb());
|
||||
const px = stage && stage.clientWidth && v.w ? stage.clientWidth / v.w : 1;
|
||||
const stroke = disp?.color || 'var(--hp-muted)';
|
||||
const stroke = disp?.color || 'var(--hp-muted, #607d8b)';
|
||||
const solid = wallBodyNeedsSolid(united.depthUnits, px);
|
||||
const wf = this._fillColors.wall_fill;
|
||||
// Fill colour UNDER the hatch (owner 2026-08-05): both, never one instead
|
||||
// of the other. When the body is thinner than ~3px on screen the hatch
|
||||
// collapses into noise — keep the solid fill alone.
|
||||
return svg`<g class="wallbodies" style="--room-stroke:${stroke};--wall-fill:${wf.c};--wall-fill-op:${wf.a}">
|
||||
<path class="wallbody-fill" d="${united.d}"></path>
|
||||
<path class="wallbody-fill" d="${united.d}"
|
||||
fill="${wf.c}" fill-opacity="${wf.a}" fill-rule="evenodd"
|
||||
stroke="none" pointer-events="none"></path>
|
||||
<path class="wallbody ${solid ? 'solid' : ''}"
|
||||
data-hp="wall" data-id="union" data-kind="union"
|
||||
d="${united.d}"></path>
|
||||
d="${united.d}" fill="${solid ? 'none' : 'url(#hp-wall-hatch)'}" fill-rule="evenodd"
|
||||
stroke="${stroke}" stroke-width="0.6" pointer-events="none"></path>
|
||||
</g>` as unknown as TemplateResult;
|
||||
}
|
||||
|
||||
@@ -6998,7 +7200,9 @@ class HouseplanCard extends LitElement {
|
||||
tapConfirm: d.marker?.tap_confirm === true,
|
||||
runFilter: '',
|
||||
defaultTap: d.primary?.split('.')[0] === 'light' ? 'toggle' : 'info',
|
||||
controls: [...(d.marker?.controls || [])],
|
||||
// Keep temporarily inactive external targets for lossless editing, but
|
||||
// discard the invalid legacy self-reference of an entity marker.
|
||||
controls: effectiveMarkerControls(d.marker?.binding, d.marker?.controls, d.entities),
|
||||
controlsFilter: '',
|
||||
isLight: d.marker?.is_light === true,
|
||||
useClimateTemp: d.marker?.use_climate_temp === true,
|
||||
@@ -7119,6 +7323,33 @@ class HouseplanCard extends LitElement {
|
||||
return filtered.slice(0, 200);
|
||||
}
|
||||
|
||||
/** Effective auto icon for a binding selected but not saved yet. */
|
||||
private _autoIconForBinding(binding: string): string {
|
||||
if (binding === 'virtual') return 'mdi:map-marker';
|
||||
const [kind, ref] = binding.split(':');
|
||||
if (!ref) return '';
|
||||
if (kind === 'device') {
|
||||
const dev = this.hass.devices?.[ref];
|
||||
if (!dev) return 'mdi:help-circle';
|
||||
const entities = Object.entries<any>(this.hass.entities || {})
|
||||
.filter(([, reg]) => reg?.device_id === ref)
|
||||
.map(([eid]) => eid);
|
||||
if (entities.some((eid) => eid.startsWith('lock.'))) return 'mdi:lock';
|
||||
return resolveIcon(
|
||||
this.hass, dev.name_by_user || dev.name || '', dev.model, entities, this._iconRules,
|
||||
);
|
||||
}
|
||||
if (kind === 'entity') {
|
||||
const reg = this.hass.entities?.[ref];
|
||||
const state = this.hass.states?.[ref];
|
||||
const name = reg?.name || state?.attributes?.friendly_name || ref;
|
||||
return ref.startsWith('lock.')
|
||||
? 'mdi:lock'
|
||||
: resolveIcon(this.hass, name, '', [ref], this._iconRules);
|
||||
}
|
||||
return '';
|
||||
}
|
||||
|
||||
/** List of rooms across all spaces for a virtual device. */
|
||||
private _allRoomsFlat(): { value: string; label: string }[] {
|
||||
const res: { value: string; label: string }[] = [];
|
||||
@@ -7239,6 +7470,9 @@ class HouseplanCard extends LitElement {
|
||||
.filter((m) => m.removed && m.binding === dlg.binding)
|
||||
.map((m) => m.id);
|
||||
const replacingRemoved = replacedRemovedIds.length > 0;
|
||||
const controls = effectiveMarkerControls(
|
||||
dlg.binding, dlg.controls, this._bindingEntities(dlg.binding),
|
||||
);
|
||||
// the vacuum block is edited live outside the dialog transaction —
|
||||
// the rebuild below must carry it over, not erase it
|
||||
const prevVac = cfg.markers.find((m0: Marker) => m0.id === id || m0.id === oldId)?.vacuum || null;
|
||||
@@ -7256,7 +7490,7 @@ class HouseplanCard extends LitElement {
|
||||
tap_action: dlg.tapAction || null,
|
||||
tap_target: dlg.tapAction === 'run' ? dlg.tapTarget || null : null,
|
||||
tap_confirm: dlg.tapConfirm ? true : null,
|
||||
controls: dlg.controls.length ? dlg.controls : null,
|
||||
controls: controls.length ? controls : null,
|
||||
// pdfs may be rewritten below when rebinding changes the marker id
|
||||
is_light: dlg.isLight ? true : null,
|
||||
use_climate_temp: dlg.useClimateTemp ? true : null,
|
||||
@@ -7392,18 +7626,11 @@ class HouseplanCard extends LitElement {
|
||||
? 'virtual'
|
||||
: d.bindingKind && d.bindingRef ? `${d.bindingKind}:${d.bindingRef}` : '';
|
||||
if (!binding) return;
|
||||
const cleanupIds = new Set<string>([d.id]);
|
||||
// Remove every duplicate of this plan binding. A non-virtual binding gets
|
||||
// one minimal tombstone: it blocks auto-discovery and every aggregate but
|
||||
// is intentionally ignored by the Add-device candidate list.
|
||||
cfg.markers = cfg.markers.filter((m) => {
|
||||
const drop = m.id === d.id || m.binding === binding;
|
||||
if (drop) cleanupIds.add(m.id);
|
||||
return !drop;
|
||||
});
|
||||
if (d.bindingKind !== 'virtual') {
|
||||
cfg.markers.push({ id: d.id, binding, removed: true });
|
||||
}
|
||||
const deletion = deletePlanMarkerRecords(
|
||||
cfg.markers, d.id, binding, d.bindingKind === 'virtual',
|
||||
);
|
||||
cfg.markers = deletion.markers;
|
||||
const cleanupIds = deletion.cleanupIds;
|
||||
this._markerDialog = { ...dlg, busy: true };
|
||||
try {
|
||||
await this._saveConfigNow();
|
||||
@@ -7734,7 +7961,8 @@ class HouseplanCard extends LitElement {
|
||||
label_lqi: d.labelLqi,
|
||||
label_light: d.labelLight,
|
||||
};
|
||||
sp.cell_cm = Number.isFinite(d.cellCm) && d.cellCm > 0 ? d.cellCm : 5;
|
||||
sp.cell_cm = Number.isFinite(d.cellCm) && d.cellCm > 0
|
||||
? Math.min(CELL_CM_MAX, d.cellCm) : 5;
|
||||
// Nothing to clean up from here: the backend collects the superseded
|
||||
// file inside the same locked transaction that accepted this config
|
||||
// (review R3-1). A cleanup driven from the client could not be ordered
|
||||
@@ -8986,7 +9214,7 @@ class HouseplanCard extends LitElement {
|
||||
${this._mode === 'decor' && this._decorTool === 'furniture' ? this._renderFurnPalette() : nothing}
|
||||
</div>
|
||||
|
||||
<div class="stage ${this._markup ? 'markup tool-' + this._tool + (this._tool === 'split' && !this._splitSel ? ' pickstage' : '') + ((this._tool === 'openwall' || this._tool === 'closewall') && this._openWallHover ? ' wallhot' : '') + (this._tool === 'wallthick' && this._wallThickHover ? ' wallhot' : '') : ''} ${this._mode === 'decor' ? 'dtool-' + this._decorTool : ''} ${space.bg ? '' : 'noplan'} mode-${this._mode}${this._bdMovable ? ' bdgrab' : ''}${this._bdDrag ? ' bdgrabbing' : ''}${dayNight ? ' daynight' : ''}${dayNight && this._skySnap ? ' skysnap' : ''}${this._booting ? ' hpboot' : ''}${this._bootSoft ? ' hpsettle' : ''}"
|
||||
<div class="stage ${this._markup ? 'markup tool-' + this._tool + (this._tool === 'split' && !this._splitSel ? ' pickstage' : '') + ((this._tool === 'openwall' || this._tool === 'closewall') && this._openWallHover ? ' wallhot' : '') + (this._tool === 'wallthick' && this._wallThickHover ? ' wallhot' : '') : ''} ${this._mode === 'decor' ? 'dtool-' + this._decorTool : ''} ${space.bg ? '' : 'noplan'} mode-${this._mode}${this._bdMovable ? ' bdgrab' : ''}${this._bdDrag ? ' bdgrabbing' : ''}${dayNight ? ' daynight' : ''}${dayNight && this._skySnap ? ' skysnap' : ''}${this._booting ? ' hpboot' : ''}${this._bootSoft ? ' hpsettle' : ''}${this._resumeSettling && this._mode === 'view' && !this._kiosk ? ' hpresume' : ''}"
|
||||
style="height:${this._kiosk ? '100dvh' : `calc(100dvh - ${this._hdrH}px)`}${stageBg ? `;background:${stageBg}` : ''};--wall-fill:${this._fillColors.wall_fill.c};--wall-fill-op:${this._fillColors.wall_fill.a}"
|
||||
@click=${(e: MouseEvent) => this._markupClick(e)}
|
||||
@wheel=${(e: WheelEvent) => this._onWheel(e)}
|
||||
@@ -9275,6 +9503,7 @@ class HouseplanCard extends LitElement {
|
||||
${this._decorTextDialog ? this._renderDecorTextDialog() : nothing}
|
||||
${this._decorShapeDialog ? this._renderDecorShapeDialog() : nothing}
|
||||
${this._backdropDialog ? this._renderBackdropDialog() : nothing}
|
||||
${this._decorEraseConfirm ? this._renderDecorEraseConfirm() : nothing}
|
||||
${this._spaceDialog ? this._renderSpaceDialog() : nothing}
|
||||
${this._markerDialog ? this._renderMarkerDialog() : nothing}
|
||||
${this._infoCard ? this._renderInfoCard() : nothing}
|
||||
@@ -11044,7 +11273,7 @@ class HouseplanCard extends LitElement {
|
||||
const d = this._infoCard!;
|
||||
const st = d.primary ? this.hass.states[d.primary] : undefined;
|
||||
const stateTxt = st ? hassValue(this.hass, d.primary)?.text ?? st.state : null;
|
||||
const controls = (d.marker?.controls || []).filter(isControllable);
|
||||
const controls = (d.controls ?? d.marker?.controls ?? []).filter(isControllable);
|
||||
return html`<hp-dialog .hass=${this.hass} .title=${d.name} .icon=${d.icon}
|
||||
dismiss-on-scrim @hp-close=${() => (this._infoCard = null)}>
|
||||
<div class="body">
|
||||
@@ -11108,7 +11337,7 @@ class HouseplanCard extends LitElement {
|
||||
? html`<div class="infodesc muted">${this._t('info.none')}</div>`
|
||||
: nothing}
|
||||
</div>
|
||||
<div class="row" slot="footer">
|
||||
<div class="row infofooter" slot="footer">
|
||||
<button class="btn" @click=${() => { const dd = d; this._infoCard = null; this._openMarkerDialog(dd); }}>
|
||||
<ha-icon icon="mdi:pencil"></ha-icon>${this._t('btn.edit')}
|
||||
</button>
|
||||
@@ -11127,6 +11356,7 @@ class HouseplanCard extends LitElement {
|
||||
const d = this._markerDialog!;
|
||||
const isVirtual = d.bindingMode === 'virtual';
|
||||
const cands = this._bindingCandidates();
|
||||
const ownEntities = this._bindingEntities(d.binding);
|
||||
const curLabel = (() => {
|
||||
if (isVirtual) return null;
|
||||
const found = cands.find((c) => c.value === d.binding);
|
||||
@@ -11148,7 +11378,11 @@ class HouseplanCard extends LitElement {
|
||||
<div class="bindsel">
|
||||
<label class="srcrow">
|
||||
<input type="radio" name="bmode" .checked=${d.bindingMode === 'virtual'}
|
||||
@change=${() => (this._markerDialog = { ...d, bindingMode: 'virtual', binding: 'virtual', bindingOpen: false })} />
|
||||
@change=${() => (this._markerDialog = {
|
||||
...d, bindingMode: 'virtual', binding: 'virtual', bindingOpen: false,
|
||||
controls: effectiveMarkerControls('virtual', d.controls),
|
||||
autoIcon: this._autoIconForBinding('virtual'),
|
||||
})} />
|
||||
<span>${this._t('marker.virtual_option')}</span>
|
||||
</label>
|
||||
<div class="bindharow">
|
||||
@@ -11183,7 +11417,13 @@ class HouseplanCard extends LitElement {
|
||||
<div class="candlist">
|
||||
${cands.map(
|
||||
(c) => html`<div class="cand ${c.value === d.binding ? 'sel' : ''}"
|
||||
@click=${() => (this._markerDialog = { ...d, binding: c.value, bindingOpen: false })}>
|
||||
@click=${() => (this._markerDialog = {
|
||||
...d, binding: c.value, bindingOpen: false,
|
||||
controls: effectiveMarkerControls(
|
||||
c.value, d.controls, this._bindingEntities(c.value),
|
||||
),
|
||||
autoIcon: this._autoIconForBinding(c.value),
|
||||
})}>
|
||||
<span class="cl">${c.label}</span><span class="cs">${c.sub}</span>
|
||||
</div>`,
|
||||
)}
|
||||
@@ -11266,7 +11506,8 @@ class HouseplanCard extends LitElement {
|
||||
${d.controlsFilter.trim()
|
||||
? html`<div class="ctrllist">
|
||||
${Object.keys(this.hass.states)
|
||||
.filter((eid) => isControllable(eid) && !d.controls.includes(eid))
|
||||
.filter((eid) => effectiveMarkerControls(d.binding, [eid], ownEntities).length > 0
|
||||
&& !d.controls.includes(eid))
|
||||
.filter((eid) => {
|
||||
const q = d.controlsFilter.trim().toLowerCase();
|
||||
const name = String(this.hass.states[eid]?.attributes?.friendly_name || '');
|
||||
@@ -11323,7 +11564,11 @@ class HouseplanCard extends LitElement {
|
||||
@input=${(e: Event) => (this._markerDialog = { ...d, icon: (e.target as HTMLInputElement).value })} />`}
|
||||
${!d.icon && d.autoIcon
|
||||
? html`<p class="muted iconauto"><ha-icon icon=${d.autoIcon}></ha-icon>
|
||||
${this._t('marker.icon_auto', { icon: d.autoIcon })}</p>`
|
||||
<span>${this._t('marker.icon_auto', { icon: d.autoIcon })}</span>
|
||||
<button class="btn ghost" type="button"
|
||||
@click=${() => (this._markerDialog = { ...d, icon: d.autoIcon })}>
|
||||
${this._t('marker.icon_pin_auto')}
|
||||
</button></p>`
|
||||
: nothing}
|
||||
|
||||
<label>${this._t('marker.display_label')}</label>
|
||||
@@ -11471,10 +11716,14 @@ class HouseplanCard extends LitElement {
|
||||
|
||||
<label>${this._t('space.scale_label')}</label>
|
||||
<div class="colorrow">
|
||||
<input class="namein tempin" type="number" min="0.1" step="0.1" .value=${String(d.cellCm)}
|
||||
<input class="namein tempin" type="number" min="0.1" max=${CELL_CM_MAX}
|
||||
step="0.1" .value=${String(d.cellCm)}
|
||||
@input=${(e: Event) => {
|
||||
const n = parseFloat((e.target as HTMLInputElement).value);
|
||||
this._spaceDialog = { ...d, cellCm: Number.isFinite(n) && n > 0 ? n : d.cellCm };
|
||||
this._spaceDialog = {
|
||||
...d, cellCm: Number.isFinite(n) && n > 0
|
||||
? Math.min(CELL_CM_MAX, n) : d.cellCm,
|
||||
};
|
||||
}} />
|
||||
<span class="opl">${this._t('space.scale_unit')}</span>
|
||||
</div>
|
||||
|
||||
+194
-27
@@ -1,38 +1,191 @@
|
||||
import { LitElement, css, html } from 'lit';
|
||||
import { LitElement, css, html, nothing } from 'lit';
|
||||
|
||||
/** Shared colour + opacity field used by decor defaults and object dialogs. */
|
||||
/**
|
||||
* Compact colour + opacity picker used by decor defaults and object dialogs.
|
||||
*
|
||||
* Only the swatch lives in the parent toolbar/form. The alpha controls are in
|
||||
* the picker itself, so every consumer gets the same compact layout without
|
||||
* relying on the still-inconsistent native `input[type=color][alpha]` support.
|
||||
*/
|
||||
export class HpColorOpacity extends LitElement {
|
||||
public label = '';
|
||||
public opacityLabel = 'Opacity';
|
||||
public color = '#607d8b';
|
||||
public opacity = 1;
|
||||
public disabled = false;
|
||||
|
||||
private _open = false;
|
||||
private _alignEnd = false;
|
||||
|
||||
static properties = {
|
||||
label: { type: String },
|
||||
opacityLabel: { type: String, attribute: 'opacity-label' },
|
||||
color: { type: String },
|
||||
opacity: { type: Number },
|
||||
disabled: { type: Boolean, reflect: true },
|
||||
_open: { state: true },
|
||||
_alignEnd: { state: true },
|
||||
};
|
||||
|
||||
static styles = css`
|
||||
:host { display: inline-flex; align-items: center; gap: 8px; min-width: 0; }
|
||||
label { display: inline-flex; align-items: center; gap: 6px; min-width: 0; }
|
||||
.label { font-size: 12px; color: var(--secondary-text-color, #9aa4ad); white-space: nowrap; }
|
||||
input[type="color"] { width: 30px; height: 28px; padding: 0; border: 0; background: none; cursor: pointer; }
|
||||
input[type="range"] { width: 78px; min-width: 48px; accent-color: var(--primary-color, #03a9f4); }
|
||||
input[type="number"] {
|
||||
width: 48px; box-sizing: border-box; border-radius: 6px; padding: 4px 5px;
|
||||
color: var(--primary-text-color, #fff); background: var(--input-fill-color, transparent);
|
||||
border: 1px solid var(--divider-color, #666); font: inherit;
|
||||
:host {
|
||||
position: relative;
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
gap: 6px;
|
||||
min-width: 0;
|
||||
}
|
||||
.label {
|
||||
font-size: 12px;
|
||||
color: var(--secondary-text-color, #9aa4ad);
|
||||
white-space: nowrap;
|
||||
}
|
||||
.trigger {
|
||||
width: 34px;
|
||||
height: 30px;
|
||||
flex: none;
|
||||
box-sizing: border-box;
|
||||
padding: 3px;
|
||||
border: 1px solid var(--divider-color, #666);
|
||||
border-radius: 7px;
|
||||
background:
|
||||
linear-gradient(45deg, #b8b8b8 25%, transparent 25%) 0 0 / 10px 10px,
|
||||
linear-gradient(45deg, transparent 75%, #b8b8b8 75%) 0 0 / 10px 10px,
|
||||
linear-gradient(45deg, transparent 75%, #b8b8b8 75%) 5px -5px / 10px 10px,
|
||||
linear-gradient(45deg, #b8b8b8 25%, #eee 25%) 5px 5px / 10px 10px;
|
||||
cursor: pointer;
|
||||
}
|
||||
.trigger:hover,
|
||||
.trigger:focus-visible,
|
||||
.trigger[aria-expanded='true'] {
|
||||
border-color: var(--primary-color, #03a9f4);
|
||||
outline: none;
|
||||
box-shadow: 0 0 0 1px var(--primary-color, #03a9f4);
|
||||
}
|
||||
.swatch {
|
||||
display: block;
|
||||
width: 100%;
|
||||
height: 100%;
|
||||
border-radius: 3px;
|
||||
box-shadow: inset 0 0 0 1px rgb(0 0 0 / 0.18);
|
||||
pointer-events: none;
|
||||
}
|
||||
.picker {
|
||||
position: absolute;
|
||||
z-index: 120;
|
||||
top: calc(100% + 7px);
|
||||
left: 0;
|
||||
width: 226px;
|
||||
box-sizing: border-box;
|
||||
display: grid;
|
||||
gap: 10px;
|
||||
padding: 12px;
|
||||
color: var(--primary-text-color, #fff);
|
||||
background: var(--card-background-color, #202126);
|
||||
border: 1px solid var(--primary-color, #03a9f4);
|
||||
border-radius: 10px;
|
||||
box-shadow: 0 10px 28px rgb(0 0 0 / 0.34);
|
||||
}
|
||||
.picker.end {
|
||||
right: 0;
|
||||
left: auto;
|
||||
}
|
||||
.row {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 8px;
|
||||
min-width: 0;
|
||||
}
|
||||
.caption {
|
||||
flex: 1;
|
||||
min-width: 0;
|
||||
overflow: hidden;
|
||||
color: var(--secondary-text-color, #9aa4ad);
|
||||
font-size: 12px;
|
||||
text-overflow: ellipsis;
|
||||
white-space: nowrap;
|
||||
}
|
||||
input[type='color'] {
|
||||
width: 76px;
|
||||
height: 34px;
|
||||
flex: none;
|
||||
padding: 2px;
|
||||
border: 1px solid var(--divider-color, #666);
|
||||
border-radius: 7px;
|
||||
background: transparent;
|
||||
cursor: pointer;
|
||||
}
|
||||
input[type='range'] {
|
||||
width: auto;
|
||||
min-width: 0;
|
||||
flex: 1;
|
||||
accent-color: var(--primary-color, #03a9f4);
|
||||
}
|
||||
input[type='number'] {
|
||||
width: 50px;
|
||||
flex: none;
|
||||
box-sizing: border-box;
|
||||
border: 1px solid var(--divider-color, #666);
|
||||
border-radius: 6px;
|
||||
padding: 4px 5px;
|
||||
color: var(--primary-text-color, #fff);
|
||||
background: var(--input-fill-color, transparent);
|
||||
font: inherit;
|
||||
}
|
||||
.pct {
|
||||
color: var(--secondary-text-color, #9aa4ad);
|
||||
font-size: 12px;
|
||||
}
|
||||
:host([disabled]) {
|
||||
opacity: .5;
|
||||
pointer-events: none;
|
||||
}
|
||||
.pct { font-size: 12px; color: var(--secondary-text-color, #9aa4ad); }
|
||||
:host([disabled]) { opacity: .5; pointer-events: none; }
|
||||
`;
|
||||
|
||||
connectedCallback(): void {
|
||||
super.connectedCallback();
|
||||
document.addEventListener('pointerdown', this._outsidePointerDown, true);
|
||||
this.addEventListener('keydown', this._keyDown, true);
|
||||
}
|
||||
|
||||
disconnectedCallback(): void {
|
||||
document.removeEventListener('pointerdown', this._outsidePointerDown, true);
|
||||
this.removeEventListener('keydown', this._keyDown, true);
|
||||
super.disconnectedCallback();
|
||||
}
|
||||
|
||||
private _outsidePointerDown = (event: PointerEvent): void => {
|
||||
if (this._open && !event.composedPath().includes(this)) this._open = false;
|
||||
};
|
||||
|
||||
private _keyDown = (event: KeyboardEvent): void => {
|
||||
if (!this._open || event.key !== 'Escape') return;
|
||||
event.preventDefault();
|
||||
event.stopImmediatePropagation();
|
||||
this._open = false;
|
||||
this.updateComplete.then(() => this.renderRoot.querySelector<HTMLButtonElement>('.trigger')?.focus());
|
||||
};
|
||||
|
||||
private _toggle(): void {
|
||||
if (this.disabled) return;
|
||||
this._open = !this._open;
|
||||
this._alignEnd = false;
|
||||
if (!this._open) return;
|
||||
this.updateComplete.then(() => {
|
||||
const popup = this.renderRoot.querySelector<HTMLElement>('.picker');
|
||||
if (popup && popup.getBoundingClientRect().right > window.innerWidth - 8) this._alignEnd = true;
|
||||
});
|
||||
}
|
||||
|
||||
private _emit(color: string, opacity: number): void {
|
||||
const value = Number.isFinite(opacity) ? opacity : Number(this.opacity) || 0;
|
||||
const clamped = Math.min(1, Math.max(0, value));
|
||||
// Keep the open picker responsive even before its parent has completed the
|
||||
// reactive round-trip and passed the new pair back as properties.
|
||||
this.color = color;
|
||||
this.opacity = clamped;
|
||||
this.dispatchEvent(new CustomEvent('hp-color-opacity-change', {
|
||||
detail: { color, opacity: Math.min(1, Math.max(0, value)) },
|
||||
detail: { color, opacity: clamped },
|
||||
bubbles: true,
|
||||
composed: true,
|
||||
}));
|
||||
@@ -40,19 +193,33 @@ export class HpColorOpacity extends LitElement {
|
||||
|
||||
render() {
|
||||
const pct = Math.round(Math.min(1, Math.max(0, Number(this.opacity) || 0)) * 100);
|
||||
return html`<label>
|
||||
${this.label ? html`<span class="label">${this.label}</span>` : ''}
|
||||
<input type="color" .value=${this.color} .disabled=${this.disabled}
|
||||
aria-label=${this.label || 'Color'}
|
||||
@input=${(e: Event) => this._emit((e.target as HTMLInputElement).value, pct / 100)} />
|
||||
<input type="range" min="0" max="100" step="1" .value=${String(pct)} .disabled=${this.disabled}
|
||||
aria-label=${`${this.label || 'Color'} opacity`}
|
||||
@input=${(e: Event) => this._emit(this.color, Number((e.target as HTMLInputElement).value) / 100)} />
|
||||
<input type="number" min="0" max="100" step="1" .value=${String(pct)} .disabled=${this.disabled}
|
||||
aria-label=${`${this.label || 'Color'} opacity percent`}
|
||||
@change=${(e: Event) => this._emit(this.color, Number((e.target as HTMLInputElement).value) / 100)} />
|
||||
<span class="pct">%</span>
|
||||
</label>`;
|
||||
const title = `${this.label || 'Color'}: ${this.color}, ${pct}%`;
|
||||
return html`
|
||||
${this.label ? html`<span class="label">${this.label}</span>` : nothing}
|
||||
<button class="trigger" type="button" .disabled=${this.disabled}
|
||||
aria-label=${title} aria-haspopup="dialog" aria-expanded=${this._open ? 'true' : 'false'}
|
||||
title=${title} @click=${this._toggle}>
|
||||
<span class="swatch" style=${`background:${this.color};opacity:${pct / 100}`}></span>
|
||||
</button>
|
||||
${this._open && !this.disabled ? html`
|
||||
<div class="picker ${this._alignEnd ? 'end' : ''}" role="dialog" aria-label=${this.label || 'Color'}>
|
||||
<div class="row">
|
||||
<span class="caption">${this.label || 'Color'}</span>
|
||||
<input type="color" .value=${this.color} aria-label=${this.label || 'Color'}
|
||||
@input=${(e: Event) => this._emit((e.target as HTMLInputElement).value, this.opacity)} />
|
||||
</div>
|
||||
<div class="row">
|
||||
<span class="caption">${this.opacityLabel}</span>
|
||||
<input type="range" min="0" max="100" step="1" .value=${String(pct)}
|
||||
aria-label=${this.opacityLabel}
|
||||
@input=${(e: Event) => this._emit(this.color, Number((e.target as HTMLInputElement).value) / 100)} />
|
||||
<input type="number" min="0" max="100" step="1" .value=${String(pct)}
|
||||
aria-label=${`${this.opacityLabel}, %`}
|
||||
@change=${(e: Event) => this._emit(this.color, Number((e.target as HTMLInputElement).value) / 100)} />
|
||||
<span class="pct">%</span>
|
||||
</div>
|
||||
</div>` : nothing}
|
||||
`;
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
+6
-7
@@ -9,7 +9,6 @@
|
||||
"btn.save": "Save",
|
||||
"btn.close": "Close",
|
||||
"btn.delete": "Delete",
|
||||
"btn.remove": "Remove",
|
||||
"btn.edit": "Edit",
|
||||
"btn.open_in_ha": "Open in HA",
|
||||
"btn.reset": "Reset",
|
||||
@@ -150,6 +149,7 @@
|
||||
"confirm.delete_room": "Delete room \"{name}\"?",
|
||||
"confirm.merge_rooms": "Merge rooms \"{a}\" and \"{b}\"?",
|
||||
"confirm.remove_marker": "Delete \"{name}\" from the plan? The device will disappear completely and stop contributing to plan data. You can add it again later.",
|
||||
"confirm.erase_decor": "Erase the {kind} object? You can undo this action from the editor history.",
|
||||
"confirm.delete_space": "Delete space \"{title}\" with all its rooms and markup?",
|
||||
"toast.pos_save_failed": "Failed to save position: {err}",
|
||||
"toast.no_entity": "The device has no suitable entity",
|
||||
@@ -300,16 +300,14 @@
|
||||
"decor.ellipse": "Oval",
|
||||
"decor.text": "Text",
|
||||
"decor.erase": "Erase",
|
||||
"decor.erase_confirm_title": "Erase object?",
|
||||
"decor.color": "Color",
|
||||
"decor.width": "Line width",
|
||||
"decor.w_thin": "Thin",
|
||||
"decor.w_mid": "Medium",
|
||||
"decor.w_thick": "Thick",
|
||||
"decor.fill": "Fill",
|
||||
"decor.fill_color": "Fill color",
|
||||
"decor.length": "Length",
|
||||
"decor.size": "Size",
|
||||
"decor.angle": "Angle, °",
|
||||
"decor.angle": "Rotation angle, °",
|
||||
"decor.text_size": "Text size",
|
||||
"decor.backdrop_properties": "Plan backdrop properties",
|
||||
"decor.text_title": "Text label",
|
||||
@@ -326,6 +324,7 @@
|
||||
"decor.backdrop_reset": "Reset the picture",
|
||||
"decor.backdrop_reset_done": "The picture is back at its original place and size",
|
||||
"marker.icon_auto": "Auto: {icon} (by icon rules; pick one to override)",
|
||||
"marker.icon_pin_auto": "Pin",
|
||||
"mode.plan_tip": "Plan editor — the geometry of the home: draw and split/merge rooms, bind them to HA areas, place doors and windows, move room cards, set the scale",
|
||||
"mode.devices_tip": "Device editor — everything about icons: drag to position, click to edit binding/icon/display, add virtual devices, icon rules",
|
||||
"mode.decor_tip": "Background editor — purely visual decor under the plan: lines, rectangles, ovals and text labels that never react to clicks",
|
||||
@@ -338,8 +337,8 @@
|
||||
"gs.glow_radius": "Glow radius",
|
||||
"gs.unit_m": "m",
|
||||
"gs.unit_ft": "ft",
|
||||
"marker.controls_label": "Controls light sources",
|
||||
"marker.controls_hint": "With tap action “Toggle”, a click flips all bound lights at once (any on → all off). The icon mirrors their state.",
|
||||
"marker.controls_label": "Controls other light sources",
|
||||
"marker.controls_hint": "With tap action “Toggle”, a click flips every source added here. The marker’s own bound entity is controlled directly and cannot be added; use the switch below to classify it as a light.",
|
||||
"marker.controls_filter": "Search lights and switches…",
|
||||
"info.controls": "Controls",
|
||||
"marker.glow_radius_label": "Glow radius (light-sources fill)",
|
||||
|
||||
+7
-8
@@ -9,7 +9,6 @@
|
||||
"btn.save": "Сохранить",
|
||||
"btn.close": "Закрыть",
|
||||
"btn.delete": "Удалить",
|
||||
"btn.remove": "Убрать",
|
||||
"btn.edit": "Редактировать",
|
||||
"btn.open_in_ha": "Открыть в HA",
|
||||
"btn.reset": "Сброс",
|
||||
@@ -150,6 +149,7 @@
|
||||
"confirm.delete_room": "Удалить комнату «{name}»?",
|
||||
"confirm.merge_rooms": "Слить комнаты «{a}» и «{b}»?",
|
||||
"confirm.remove_marker": "Удалить «{name}» с плана? Устройство исчезнет полностью и перестанет участвовать в данных плана. Позже его можно будет добавить заново.",
|
||||
"confirm.erase_decor": "Стереть объект «{kind}»? Действие можно отменить из истории редактора.",
|
||||
"confirm.delete_space": "Удалить пространство «{title}» со всеми комнатами и разметкой?",
|
||||
"toast.pos_save_failed": "Не удалось сохранить позицию: {err}",
|
||||
"toast.no_entity": "У устройства нет подходящей сущности",
|
||||
@@ -300,16 +300,14 @@
|
||||
"decor.ellipse": "Овал",
|
||||
"decor.text": "Надпись",
|
||||
"decor.erase": "Стереть",
|
||||
"decor.erase_confirm_title": "Стереть объект?",
|
||||
"decor.color": "Цвет",
|
||||
"decor.width": "Толщина линии",
|
||||
"decor.w_thin": "Тонкая",
|
||||
"decor.w_mid": "Средняя",
|
||||
"decor.w_thick": "Толстая",
|
||||
"decor.fill": "Залить",
|
||||
"decor.fill": "Заливка",
|
||||
"decor.fill_color": "Цвет заливки",
|
||||
"decor.length": "Длина",
|
||||
"decor.size": "Размер",
|
||||
"decor.angle": "Угол, °",
|
||||
"decor.angle": "Угол поворота, °",
|
||||
"decor.text_size": "Размер текста",
|
||||
"decor.backdrop_properties": "Свойства картинки-подложки",
|
||||
"decor.text_title": "Надпись",
|
||||
@@ -326,6 +324,7 @@
|
||||
"decor.backdrop_reset": "Вернуть картинку",
|
||||
"decor.backdrop_reset_done": "Картинка вернулась на своё место и в свой размер",
|
||||
"marker.icon_auto": "Авто: {icon} (по правилам иконок; выберите свою, чтобы заменить)",
|
||||
"marker.icon_pin_auto": "Закрепить",
|
||||
"mode.plan_tip": "Редактор плана — геометрия дома: рисование и объединение/разделение комнат, привязка к зонам HA, двери и окна, карточки комнат, масштаб",
|
||||
"mode.devices_tip": "Редактор устройств — всё про значки: перетаскивание, клик — настройка привязки/иконки/отображения, виртуальные устройства, правила иконок",
|
||||
"mode.decor_tip": "Редактор подложки — чисто визуальный декор под планом: линии, прямоугольники, овалы и надписи, не реагирующие на клики",
|
||||
@@ -338,8 +337,8 @@
|
||||
"gs.glow_radius": "Радиус свечения",
|
||||
"gs.unit_m": "м",
|
||||
"gs.unit_ft": "фут",
|
||||
"marker.controls_label": "Управляет источниками света",
|
||||
"marker.controls_hint": "При действии «Переключить» клик разом переключает все привязанные источники (горит хоть один → выключить все). Значок отражает их состояние.",
|
||||
"marker.controls_label": "Управляет другими источниками света",
|
||||
"marker.controls_hint": "При действии «Переключить» клик разом переключает все добавленные здесь источники. Собственная сущность маркера управляется напрямую и сюда не добавляется; чтобы считать её светом, включите флаг ниже.",
|
||||
"marker.controls_filter": "Поиск ламп и выключателей…",
|
||||
"info.controls": "Управляет",
|
||||
"marker.glow_radius_label": "Радиус свечения (заливка «Свет по источникам»)",
|
||||
|
||||
+51
-15
@@ -9,6 +9,7 @@
|
||||
*/
|
||||
|
||||
import { alignAllToGrid, type AlignReport } from './align-grid';
|
||||
import { effectiveMarkerControls } from './devices';
|
||||
import {
|
||||
DECOR_TEXT_BASE, decorTextScale, liveTextReference, liveTextToken, roomPoly,
|
||||
} from './logic';
|
||||
@@ -17,14 +18,20 @@ import {
|
||||
projectOnSeg, rekeyOpenSpansAfterMove, resolveOpenCuts, sanitizeOpenSpans,
|
||||
snapOpenPoint, spanToEntry, syncOpenToFromCuts,
|
||||
} from './open-spans';
|
||||
import { GRID_PITCH, GRID_STEP_N, NORM_W, spaceModels } from './space-geometry';
|
||||
import {
|
||||
GRID_PITCH, GRID_STEP_N, NORM_W, PLAN_SCALE_MAX, PLAN_SCALE_MIN, spaceModels,
|
||||
} from './space-geometry';
|
||||
import {
|
||||
degradeWalls, normalizeWallIntervals, rekeyWallsAfterMove,
|
||||
} from './wall-thickness';
|
||||
|
||||
/** Bump when a new lossless maintenance pass is added. */
|
||||
export const PLAN_MODEL_VERSION = 2;
|
||||
export const PLAN_MODEL_VERSION = 3;
|
||||
const DEFAULT_CELL_CM = 5;
|
||||
const DECOR_WIDTH_CM_MIN = 0.1;
|
||||
const DECOR_WIDTH_CM_MAX = 100;
|
||||
const DECOR_TEXT_CM_MIN = 0.1;
|
||||
const DECOR_TEXT_CM_MAX = 2000;
|
||||
|
||||
export interface OptimizeReport extends AlignReport {
|
||||
modelFrom: number;
|
||||
@@ -48,6 +55,9 @@ export interface OptimizeResult {
|
||||
|
||||
const clone = <T>(value: T): T => JSON.parse(JSON.stringify(value));
|
||||
const own = (o: any, key: string): boolean => Object.prototype.hasOwnProperty.call(o, key);
|
||||
const clamp = (value: number, min: number, max: number): number => (
|
||||
Math.min(max, Math.max(min, value))
|
||||
);
|
||||
|
||||
const modelOf = (space: any): any => (
|
||||
spaceModels({ spaces: [space], markers: [], settings: {} } as any)[0]
|
||||
@@ -75,6 +85,13 @@ const migrateLosslessly = (config: any): number => {
|
||||
let n = 0;
|
||||
for (const marker of config.markers || []) {
|
||||
if (marker.display === 'ripple') { marker.display = 'icon_ripple'; n++; }
|
||||
if (Array.isArray(marker.controls)) {
|
||||
const controls = effectiveMarkerControls(marker.binding, marker.controls);
|
||||
if (JSON.stringify(controls) !== JSON.stringify(marker.controls)) {
|
||||
marker.controls = controls.length ? controls : null;
|
||||
n++;
|
||||
}
|
||||
}
|
||||
const vacuum = marker.vacuum;
|
||||
if (vacuum && own(vacuum, 'trail')) {
|
||||
if (!['never', 'cleaning', 'always'].includes(vacuum.trail_mode)) {
|
||||
@@ -89,20 +106,29 @@ const migrateLosslessly = (config: any): number => {
|
||||
if (own(space, 'segments')) { delete space.segments; n++; }
|
||||
if (own(space, 'plan_scale')) {
|
||||
const k = Number(space.plan_scale);
|
||||
if (Number.isFinite(k) && k > 0) {
|
||||
if (Number.isFinite(k) && k >= PLAN_SCALE_MIN && k <= PLAN_SCALE_MAX) {
|
||||
if (!own(space, 'plan_scale_x')) space.plan_scale_x = k;
|
||||
if (!own(space, 'plan_scale_y')) space.plan_scale_y = k;
|
||||
delete space.plan_scale;
|
||||
n++;
|
||||
}
|
||||
delete space.plan_scale;
|
||||
n++;
|
||||
}
|
||||
const cellCm = Number(space.cell_cm) > 0 ? Number(space.cell_cm) : DEFAULT_CELL_CM;
|
||||
const rawCellCm = Number(space.cell_cm);
|
||||
const cellCm = Number.isFinite(rawCellCm) && rawCellCm > 0
|
||||
? rawCellCm : DEFAULT_CELL_CM;
|
||||
for (const shape of space.decor || []) {
|
||||
if (own(shape, 'width')) {
|
||||
if (!own(shape, 'width_cm'))
|
||||
shape.width_cm = Number((((Number(shape.width) / GRID_PITCH) * cellCm)).toFixed(6));
|
||||
delete shape.width;
|
||||
n++;
|
||||
const legacyWidth = Number(shape.width);
|
||||
if (own(shape, 'width_cm') || Number.isFinite(legacyWidth)) {
|
||||
if (!own(shape, 'width_cm'))
|
||||
shape.width_cm = Number(clamp(
|
||||
(legacyWidth / GRID_PITCH) * cellCm,
|
||||
DECOR_WIDTH_CM_MIN,
|
||||
DECOR_WIDTH_CM_MAX,
|
||||
).toFixed(6));
|
||||
delete shape.width;
|
||||
n++;
|
||||
}
|
||||
}
|
||||
if ((shape?.kind === 'rect' || shape?.kind === 'ellipse') && shape.fill === true) {
|
||||
if (!own(shape, 'fill_color')) { shape.fill_color = shape.color || '#607d8b'; n++; }
|
||||
@@ -114,8 +140,10 @@ const migrateLosslessly = (config: any): number => {
|
||||
// legacy representation to centimetres without changing its current
|
||||
// appearance at this space's scale.
|
||||
if (shape.size_cm === undefined) {
|
||||
shape.size_cm = Number((
|
||||
((DECOR_TEXT_BASE * decorTextScale(shape)) / GRID_PITCH) * cellCm
|
||||
shape.size_cm = Number(clamp(
|
||||
((DECOR_TEXT_BASE * decorTextScale(shape)) / GRID_PITCH) * cellCm,
|
||||
DECOR_TEXT_CM_MIN,
|
||||
DECOR_TEXT_CM_MAX,
|
||||
).toFixed(6));
|
||||
delete shape.scale;
|
||||
delete shape.size;
|
||||
@@ -294,20 +322,28 @@ export function optimizePlans(configIn: any, layoutIn: Record<string, any>): Opt
|
||||
if (canonicalAfter !== canonicalBefore) canonicalized++;
|
||||
}
|
||||
|
||||
if (modelFrom !== PLAN_MODEL_VERSION) {
|
||||
// A version marker is bookkeeping, not maintenance by itself. Persist it
|
||||
// only alongside a real config/layout transformation; otherwise an already
|
||||
// canonical plan would forever offer an Optimize action that changes no
|
||||
// user data. Never downgrade a model written by a newer client.
|
||||
const meaningfulChanged = JSON.stringify(config) !== original
|
||||
|| JSON.stringify(aligned.layout) !== originalLayout;
|
||||
if (modelFrom < PLAN_MODEL_VERSION && meaningfulChanged) {
|
||||
config.model_version = PLAN_MODEL_VERSION;
|
||||
migrated++;
|
||||
}
|
||||
|
||||
const changed = JSON.stringify(config) !== original
|
||||
|| JSON.stringify(aligned.layout) !== originalLayout;
|
||||
const modelTo = Number.isInteger(Number(config.model_version))
|
||||
? Number(config.model_version)
|
||||
: modelFrom;
|
||||
return {
|
||||
config,
|
||||
layout: aligned.layout,
|
||||
report: {
|
||||
...alignReport,
|
||||
modelFrom,
|
||||
modelTo: PLAN_MODEL_VERSION,
|
||||
modelTo,
|
||||
migrated,
|
||||
canonicalized,
|
||||
wallsMerged,
|
||||
|
||||
+1
-1
@@ -31,7 +31,7 @@ export const DEFAULT_ICON_RULES: IconRule[] = [
|
||||
{ pattern: 'термоголов|trv|radiator', icon: 'mdi:radiator' },
|
||||
{ pattern: 'чайник|kettle|термопот', icon: 'mdi:kettle' },
|
||||
{ pattern: 'сауна|sauna|harvia|парная|парилк', icon: 'mdi:hot-tub' },
|
||||
{ pattern: 'температ|temperature|climate sensor', icon: 'mdi:thermometer' },
|
||||
{ pattern: 'температ|temperature|thermometer|climate sensor', icon: 'mdi:thermometer' },
|
||||
{ pattern: 'qingping|air monitor|молекул|air quality', icon: 'mdi:air-filter' },
|
||||
{ pattern: 'штор|curtain|blind|shade', icon: 'mdi:roller-shade' },
|
||||
{ pattern: 'розетк|plug|socket|outlet', icon: 'mdi:power-socket-de' },
|
||||
|
||||
+5
-2
@@ -244,9 +244,12 @@ export function renderSpaceStatic(o: StaticRenderOpts): TemplateResult | null {
|
||||
${roomShapes}
|
||||
${wallUnion
|
||||
? svg`<g class="wallbodies" style="--room-stroke:${disp.color}">
|
||||
<path class="wallbody-fill" d="${wallUnion.d}"></path>
|
||||
<path class="wallbody-fill" d="${wallUnion.d}"
|
||||
fill="${colors.wall_fill.c}" fill-opacity="${colors.wall_fill.a}" fill-rule="evenodd"
|
||||
stroke="none" pointer-events="none"></path>
|
||||
<path class="wallbody ${solidWall ? 'solid' : ''}" data-hp="wall" data-id="union" data-kind="union"
|
||||
d="${wallUnion.d}"></path>
|
||||
d="${wallUnion.d}" fill="${solidWall ? 'none' : 'url(#hp-wall-hatch)'}" fill-rule="evenodd"
|
||||
stroke="${disp.color || 'var(--hp-muted, #607d8b)'}" stroke-width="0.6" pointer-events="none"></path>
|
||||
</g>`
|
||||
: nothing}
|
||||
</svg>
|
||||
|
||||
+41
-2
@@ -274,6 +274,16 @@ export const cardStyles = css`
|
||||
.stage.hpboot .zoombadge {
|
||||
visibility: hidden;
|
||||
}
|
||||
/* A long-suspended normal View wakes through transient HA/viewport
|
||||
geometry. Keep those few frames behind the already-correct stage
|
||||
background; the final viewport and reveal are committed together.
|
||||
This class is never set in kiosk or an editor. */
|
||||
.stage.hpresume .zoomwrap,
|
||||
.stage.hpresume .zoombadge,
|
||||
.stage.hpresume .farhint,
|
||||
.stage.hpresume .homearrow {
|
||||
visibility: hidden;
|
||||
}
|
||||
/* AUD-1552-02: post-veil grace — HA chrome landing after the cap moves
|
||||
the stage height smoothly; the viewport ResizeObserver refits the plan
|
||||
along the transition, so a late panel glides instead of jumping. */
|
||||
@@ -843,6 +853,8 @@ export const cardStyles = css`
|
||||
margin: var(--sp-2) 0 0;
|
||||
}
|
||||
.iconauto ha-icon { --mdc-icon-size: 18px; }
|
||||
.iconauto span { flex: 1; }
|
||||
.iconauto .btn { min-height: 32px; padding: 0 var(--sp-3); }
|
||||
.rlhandle {
|
||||
display: none;
|
||||
position: absolute;
|
||||
@@ -1012,7 +1024,8 @@ export const cardStyles = css`
|
||||
pointer-events: none;
|
||||
opacity: 0.85;
|
||||
}
|
||||
.dtframe .dtstem {
|
||||
.dtframe .dtstem,
|
||||
.bdframe .dtstem {
|
||||
stroke: var(--hp-accent);
|
||||
stroke-width: 1.5;
|
||||
vector-effect: non-scaling-stroke;
|
||||
@@ -1051,7 +1064,10 @@ export const cardStyles = css`
|
||||
.stage.mode-decor.dtool-ellipse, .stage.mode-decor.dtool-text {
|
||||
cursor: crosshair;
|
||||
}
|
||||
.stage.mode-decor.dtool-erase .decorlayer .dshape { cursor: crosshair; }
|
||||
.stage.mode-decor.dtool-erase,
|
||||
.stage.mode-decor.dtool-erase .decorlayer .dshape {
|
||||
cursor: url("data:image/svg+xml,%3Csvg xmlns='http://www.w3.org/2000/svg' width='24' height='24' viewBox='0 0 24 24'%3E%3Cg transform='rotate(-45 12 12)'%3E%3Crect x='7' y='2' width='10' height='18' rx='2' fill='%23fff' stroke='%23111' stroke-width='1.5'/%3E%3Cpath d='M7 13h10v5a2 2 0 0 1-2 2H9a2 2 0 0 1-2-2z' fill='%23ff9f43' stroke='%23111' stroke-width='1.5'/%3E%3C/g%3E%3C/svg%3E") 5 22, pointer;
|
||||
}
|
||||
.stage.mode-decor .room, .stage.mode-decor .devlayer { pointer-events: none; }
|
||||
.stage.mode-decor .oplock { pointer-events: none; }
|
||||
/* decor mode: everything but the decor itself fades back */
|
||||
@@ -1079,6 +1095,13 @@ export const cardStyles = css`
|
||||
.decorbar .dfill {
|
||||
display: inline-flex; align-items: center; gap: var(--sp-2); font-size: var(--fs-s); cursor: pointer;
|
||||
}
|
||||
.decorbar .dfill input[type="checkbox"] {
|
||||
width: 16px;
|
||||
height: 16px;
|
||||
flex: none;
|
||||
margin: 0;
|
||||
padding: 0;
|
||||
}
|
||||
.decorbar hp-color-opacity { flex: 0 0 auto; }
|
||||
hp-dialog .dfill {
|
||||
display: inline-flex;
|
||||
@@ -2320,6 +2343,22 @@ export const cardStyles = css`
|
||||
align-items: center;
|
||||
flex-wrap: wrap;
|
||||
}
|
||||
/* Device info can have Edit + Open in HA + Close. A small HA dialog is
|
||||
narrower than those three Russian-labelled actions; without wrapping,
|
||||
HA's end-aligned footer pushed the first button outside the surface. */
|
||||
hp-dialog .row.infofooter {
|
||||
align-items: center;
|
||||
flex-wrap: wrap;
|
||||
gap: var(--sp-3);
|
||||
}
|
||||
hp-dialog .row.infofooter .btn {
|
||||
flex-shrink: 0;
|
||||
}
|
||||
@media (max-width: 480px) {
|
||||
hp-dialog .row.infofooter {
|
||||
padding: var(--sp-4) var(--sp-5);
|
||||
}
|
||||
}
|
||||
.markeractions,
|
||||
.markersaveactions {
|
||||
display: flex;
|
||||
|
||||
@@ -147,6 +147,9 @@ export interface DevItem {
|
||||
hum?: number | null;
|
||||
virtual?: boolean;
|
||||
marker?: Marker; // linked config marker (metadata, overrides)
|
||||
/** Runtime-effective controls. The marker retains the user's complete
|
||||
* persisted list so transient tombstones cannot erase it on the next save. */
|
||||
controls?: string[];
|
||||
bindingKind?: 'device' | 'entity' | 'virtual';
|
||||
bindingRef?: string; // device_id / entity_id
|
||||
link?: string | null;
|
||||
|
||||
Reference in New Issue
Block a user