v1.59.0-beta.2: live text labels, text block frame, warm-memo owners
Validate / hacs (push) Failing after 7s
Validate / hassfest (push) Failing after 7s
Validate / frontend (push) Successful in 2m29s
Validate / backend (push) Failing after 8m30s
Validate / smoke (push) Failing after 13m24s

This commit is contained in:
Matysh
2026-08-04 23:44:59 +03:00
parent 1397a71f84
commit 0ee80a6a52
25 changed files with 3103 additions and 1064 deletions
+555 -64
View File
@@ -25,6 +25,8 @@ import {
referencedContentUrls,
DISPLAY_MODES, TAP_ACTIONS, SPACE_FILL_MODES, ROOM_FILL_MODES,
coverService, coverMoving, coverEntityOf, COVER_GUARDED_CLASSES,
liveText, decorTextScale, decorTextLines,
DECOR_TEXT_BASE, DECOR_TEXT_SCALE_MIN, DECOR_TEXT_SCALE_MAX,
} from './logic';
import {
planEdgeDrag, applyEdgeDrag, clampEdgeDrag, applyRoomScale, clampRoomScale,
@@ -62,7 +64,7 @@ import {
import { alignAllToGrid, type AlignReport } from './align-grid';
import { langOf, t, type I18nKey } from './i18n';
const CARD_VERSION = '1.59.0-beta.1';
const CARD_VERSION = '1.59.0-beta.2';
/** 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
@@ -119,27 +121,98 @@ type WarmViewport = {
* half-filled device dialog with its uploaded pdfs survives for free. */
type WarmDialogKind = 'space' | 'marker' | 'settings' | 'opening' | 'decorText' | 'rules' | 'room' | 'info' | 'openingInfo';
type WarmDialog = { kind: WarmDialogKind; space: string; mode: string; data: any };
/** AUD-159B1-01: one entry per CARD PLACEMENT, not per key. Two cards with an
* identical config on one view share the key, so the key alone cannot say
* whose viewport this is; `place`/`idx` (the parent element the card was
* mounted in, and its position among that parent's children) identify the
* DOM slot, and `owner` the live instance sitting in it. A re-mount into the
* same slot inherits the entry; a different card never does. */
type WarmEntry = {
/** generation id of the instance that currently owns the slot */
owner: number;
/** the parent element the owner was mounted in (weak — never keep DOM alive) */
place: WeakRef<Node> | null;
/** the owner's index among that parent's children */
idx: number;
/** the owner is attached; a dead slot is a tombstone waiting for a successor */
live: boolean;
hdrH: number;
stageH: number;
vp: WarmViewport | null;
dlg: WarmDialog | null;
/** when the instance that wrote `dlg` detached; 0 = it is still alive */
freed: number;
/** the TTL timer that frees `dlg` once it can no longer be revived */
evict: number;
};
const warmBoot = new Map<string, WarmEntry>();
const warmBoot = new Map<string, WarmEntry[]>();
let warmGen = 0;
/** `location.pathname` is the dashboard AND the view path: two Lovelace views
* never share a key, so a card that comes back on another view boots cold
* instead of inheriting a stranger's viewport (AUD-159B1-01). The hash is
* deliberately out — `#space=` is OUR deep link, not another placement. */
const warmBootKey = (config: unknown): string =>
`${window.innerWidth}x${window.innerHeight}|${JSON.stringify(config ?? {})}`;
`${window.innerWidth}x${window.innerHeight}|${location.pathname}|${JSON.stringify(config ?? {})}`;
/** A dialog is revived only if the instance that owned it died THIS long ago.
* A Lovelace rebuild detaches and re-attaches within one task; a user who
* walked off to another dashboard view and came back later must not be met
* by a dialog they have long forgotten opening. */
const WARM_REVIVE_MS = 10000;
let WARM_REVIVE_MS = 10000;
/** The memo gains a key on every window RESIZE and never loses one; the
* values used to be two numbers, and now they can hold a dialog draft (a
* plan pdf among it). Keep the last few viewports — a stale entry only
* costs the next card at that size a cold boot. */
const WARM_MAX_KEYS = 8;
/** How many placements of ONE key are remembered. More identical cards than
* this on one view and the oldest dead slot is dropped — it only costs that
* placement a cold boot. */
const WARM_MAX_SLOTS = 4;
/** Which slot of `list` belongs to the instance now claiming it, and may we
* trust it with the viewport/dialog (`sure`) or only with the settled height?
* Ranked, best first (AUD-159B1-01):
* 4 — same parent, same index: literally the DOM slot we are standing in,
* which is how Lovelace replaces a card (the predecessor may still be
* attached for another task — that is the case the audit reproduced);
* 0 — some OTHER placement whose owner is still attached: a neighbouring
* card with an identical config, never ours to inherit;
* 3 — same parent, shifted index, owner gone: still our placement;
* 2 — a tombstone whose placement is gone with its subtree — the ordinary
* Lovelace rebuild, where the container is rebuilt too.
* A tie means two candidates are equally plausible: then only the settled
* height is adopted, and it is the same for all of them anyway. */
const warmMatch = (
list: WarmEntry[],
gen: number,
place: Node | null,
idx: number,
): { slot: WarmEntry | null; sure: boolean } => {
const score = (s: WarmEntry): number => {
const same = !!place && s.place?.deref() === place;
if (same && s.idx === idx) return 4;
if (s.live) return 0;
return same ? 3 : 2;
};
let best: WarmEntry | null = null;
let bestScore = 0;
let ties = 0;
let newest: WarmEntry | null = null;
for (const s of list) {
if (s.owner === gen) continue;
newest = s;
const sc = score(s);
if (sc <= 0) continue;
if (sc > bestScore) { best = s; bestScore = sc; ties = 1; }
else if (sc === bestScore) ties++;
}
if (!best || ties > 1) return { slot: best || newest, sure: false };
return { slot: best, sure: true };
};
/** Rotation step of a decor text block — the same 5° a device icon turns in
* (marker dialog). Shift drags past it, as past every other snap. */
const DT_ANGLE_STEP = 5;
/** Line spacing of a multi-line label, in font sizes. */
const DT_LINE = 1.2;
const LS_KEY = 'houseplan_card_layout_v1';
const LS_CFG = 'houseplan_card_cfg_v1'; // cache of the server config+layout for instant rendering
const LS_ZOOM = 'houseplan_card_zoom_v1';
@@ -268,7 +341,23 @@ class HouseplanCard extends LitElement {
private _decorDraft: { kind: 'line' | 'rect' | 'ellipse'; a: number[]; b: number[]; pid: number } | null = null;
private _decorMove: { id: string; start: number[]; orig: any; pid: number; moved: boolean } | null = null;
private _decorSel: string | null = null;
private _decorTextDialog: { id?: string; x: number; y: number; text: string; size: 's' | 'm' | 'l'; color: string } | null = null;
/** The text dialog. `size` is gone from the UI (the block is scaled by its
* corners now); `entity`/`attr`/`unit` are the live link (docs/LIVE-TEXT.md). */
private _decorTextDialog: {
id?: string; x: number; y: number; text: string; color: string;
entity?: string; attr?: string; unit?: string;
} | null = null;
/** The measured box of the selected text block, its own (unrotated) frame. */
private _dtBox: { id: string; x: number; y: number; w: number; h: number } | null = null;
/** A live corner (scale) or rotate gesture on the selected text block. */
private _dtDrag: {
id: string; kind: 'scale' | 'rotate'; pid: number;
/** the anchor the block is scaled and rotated about (render units) */
ax: number; ay: number;
/** distance / bearing of the pointer at the start, and the stored values */
r0: number; a0: number; scale0: number; angle0: number;
moved: boolean;
} | null = null;
/**
* The live backdrop gesture (docs/BACKDROP.md §2): moving the picture by its
* body, or scaling it UNIFORMLY by a corner handle about the opposite corner.
@@ -572,6 +661,11 @@ class HouseplanCard extends LitElement {
private _warmVpArmed = false;
private _warmRevivePending = false;
private _warmReviveTimer?: number;
/** AUD-159B1-01: this instance's identity in the memo — its generation, the
* key it settled under and the slot (card placement) it owns. */
private _warmGen = ++warmGen;
private _warmKey: string | null = null;
private _warmSlot: WarmEntry | null = null;
private _hashApplied = false;
private _navApplied = false; // the saved space was restored (or the user navigated)
// ---- kiosk (wall device) mode ----
@@ -670,6 +764,8 @@ class HouseplanCard extends LitElement {
_decorSel: { state: true },
_decorTextDialog: { state: true },
_bdDrag: { state: true },
_dtBox: { state: true },
_dtDrag: { state: true },
_kioskDialog: { state: true },
_vacFit: { state: true },
_kioskDots: { state: true },
@@ -724,6 +820,8 @@ class HouseplanCard extends LitElement {
// DEV-B703-02: a reattach mid-outage must keep revalidating — the retry
// timer died in disconnectedCallback
if (!this._loadOk && this._serverCfg && this.hass) this._scheduleLoadRetry();
// AUD-159B1-01: the placement is only knowable once we are IN the DOM.
if (!this._warmSlot && this._config) this._warmAdopt();
// DEV-B703-03: one task later the element Lovelace replaced has detached
// — only then is its open dialog ours to take over.
if (this._warmVp && !this._warmRevivePending && this._warmReviveTimer === undefined) {
@@ -774,11 +872,18 @@ class HouseplanCard extends LitElement {
clearTimeout(this._layoutSyncTimer);
// DEV-B703-03: the last thing this instance was showing, then the
// tombstone that lets exactly one successor adopt the open dialog.
// AUD-159B1-02: the snapshot runs while `_warmRevivePending` is still
// TRUE. An instance that never got to consume its predecessor's dialog
// has nothing of its own to record there, and the old order (clear the
// flag, then snapshot) had it write `dlg: null` over somebody else's
// unsaved draft — a second Lovelace rebuild inside one task destroyed
// the draft before the first successor could restore it. Now the draft
// simply travels down the chain until one of them lives long enough.
this._warmSnapshot();
this._warmRevivePending = false;
clearTimeout(this._warmReviveTimer);
this._warmReviveTimer = undefined;
this._warmSnapshot();
this._warmPatch({ freed: Date.now() });
this._warmRelease();
super.disconnectedCallback();
}
@@ -893,33 +998,32 @@ class HouseplanCard extends LitElement {
return { type: 'custom:houseplan-card' };
}
/** Test hook (smokes): forget the warm re-mount memo — a cold page again. */
public static _warmBootReset(): void {
/** Test hook (smokes): forget the warm re-mount memo — a cold page again.
* `ttl` retunes WARM_REVIVE_MS so a smoke can watch the TTL expire without
* standing still for ten seconds (AUD-159B1-03); omitted = back to 10 s. */
public static _warmBootReset(ttl?: number): void {
for (const list of warmBoot.values()) for (const s of list) clearTimeout(s.evict);
warmBoot.clear();
WARM_REVIVE_MS = ttl && ttl > 0 ? ttl : 10000;
}
/** Test hook (smokes): what the memo is holding — slots per key, and how
* many of them still keep a dialog payload alive. */
public static _warmBootStats(): { keys: number; slots: number; dlgs: number; drafts: string[] } {
let slots = 0, dlgs = 0;
const drafts: string[] = [];
for (const list of warmBoot.values()) {
for (const s of list) {
slots++;
if (s.dlg) { dlgs++; drafts.push(s.dlg.kind); }
}
}
return { keys: warmBoot.size, slots, dlgs, drafts };
}
public setConfig(config: CardConfig): void {
this._config = { icon_size: 2.5, show_temperature: true, live_states: true, show_signal: true, ...config };
if (this._config.kiosk) { this._booting = false; this._bootFading = false; } // kiosk: 100dvh, nothing to settle
else {
// DEV-B703-01: this page already booted an identical card at this
// viewport — adopt its settled header height and skip the veil
// entirely: the card reveals synchronously in the final geometry (the
// saved zoom is armed below, HP-1551). _bootSoft covers any residual
// chrome drift with a glide instead of a snap.
const warm = warmBoot.get(warmBootKey(this._config));
if (warm) {
this._booting = false;
this._bootFading = false;
this._hdrH = warm.hdrH;
this._warmVp = warm.vp; // adopted below, once the model is in hand
this._bootSoft = true; // timer armed in connectedCallback...
if (this.isConnected) { // ...unless setConfig re-runs while attached
clearTimeout(this._bootSoftTimer);
this._bootSoftTimer = window.setTimeout(() => { this._bootSoft = false; }, BOOT_SOFT_MS);
}
}
}
if (config.default_floor) this._space = config.default_floor;
try {
this._zoomBySpace = JSON.parse(localStorage.getItem(LS_ZOOM) || '{}') || {};
@@ -962,7 +1066,127 @@ class HouseplanCard extends LitElement {
// arm it BEFORE the first view computation, so the very first paint is
// already at the user's zoom.
if (this._mode === 'view' && !this._view) this._zoom = this._zoomBySpace[this._space] || 1;
this._warmAdoptViewport(config);
// AUD-159B1-01: the memo is claimed by DOM slot, and Lovelace calls
// setConfig BEFORE it inserts the element — so the claim waits for
// 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();
}
/**
* DEV-B703-01/03 + AUD-159B1-01: find the memo of the card that used to sit
* in THIS placement and step into it — the settled header height (no veil,
* synchronous reveal in the final geometry) and, when the slot is provably
* ours, the whole viewport. `_bootSoft` covers residual chrome drift with a
* glide instead of a snap.
*/
private _warmAdopt(): void {
if (this._config?.kiosk) return;
const key = warmBootKey(this._config);
if (this._warmKey === key && this._warmSlot) return; // already sitting in it
if (this._warmSlot) this._warmRelease(); // the config (or the window) changed under us
const place = this.parentNode;
const idx = this._warmIdx(place);
const list = warmBoot.get(key);
if (!list || !list.length) return; // cold page — the full protective boot
// the same element re-attached (Lovelace moves cards around): our own slot
// 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) {
clearTimeout(mine.evict); mine.evict = 0; mine.freed = 0; mine.live = true;
this._warmSlot = mine;
this._warmKey = key;
return;
}
const { slot, sure } = warmMatch(list, this._warmGen, place, idx);
if (!slot) return;
this._booting = false;
this._bootFading = false;
this._hdrH = slot.hdrH;
this._bootSoft = true; // timer armed in connectedCallback...
if (this.isConnected) { // ...unless we are claiming while already attached
clearTimeout(this._bootSoftTimer);
this._bootSoftTimer = window.setTimeout(() => { this._bootSoft = false; }, BOOT_SOFT_MS);
}
this._warmKey = key;
if (sure) {
clearTimeout(slot.evict); // the dialog is ours now, not the TTL's
slot.evict = 0;
slot.owner = this._warmGen;
slot.place = place ? new WeakRef(place) : null;
slot.idx = idx;
slot.live = true;
this._warmSlot = slot;
this._warmVp = slot.vp; // adopted below, once the model is in hand
this._warmAdoptViewport(this._config!);
} else {
// Two identical cards on one view and no way to tell which slot is ours:
// the height is interchangeable (they settle at the same chrome), the
// viewport and the dialog belong to somebody and must not be guessed.
this._warmSlot = {
owner: this._warmGen, place: place ? new WeakRef(place) : null, idx, live: true,
hdrH: slot.hdrH, stageH: slot.stageH, vp: null, dlg: null, freed: 0, evict: 0,
};
list.push(this._warmSlot);
this._warmTrim(list);
}
}
/** This element's position among its parent's children (-1 if unmounted). */
private _warmIdx(place: Node | null): number {
const kids = (place as Element | null)?.children;
if (!kids) return -1;
for (let i = 0; i < kids.length; i++) if (kids[i] === this) return i;
return -1;
}
/** Let go of the slot: the placement is still there, this instance is not. */
private _warmRelease(): void {
const s = this._warmSlot;
const key = this._warmKey;
this._warmSlot = null;
this._warmKey = null;
if (!s || !key) return;
s.freed = Date.now();
if (s.owner === this._warmGen) s.live = false;
this._warmScheduleEvict(s, key);
}
/** Drop the oldest slot that nobody is sitting in. */
private _warmTrim(list: WarmEntry[]): void {
while (list.length > WARM_MAX_SLOTS) {
const i = list.findIndex((s) => !s.live);
if (i < 0) break;
clearTimeout(list[i].evict);
list.splice(i, 1);
}
}
/**
* AUD-159B1-03: the TTL used to be a rule checked at revive time only, so a
* draft nobody came back for stayed reachable from module scope until the
* page reloaded — with the space dialog that means a whole plan file held as
* base64 (8 MiB allowed by the backend). Free the payload the moment it
* stops being revivable; the height and the viewport are bytes, they stay.
*/
private _warmScheduleEvict(s: WarmEntry, key: string): void {
clearTimeout(s.evict);
if (!s.dlg) return;
const freed = s.freed;
const gen = s.owner;
s.evict = window.setTimeout(() => {
s.evict = 0;
if (s.freed !== freed || s.owner !== gen) return; // a successor took over
// the dialog is unrevivable now, whoever is sitting in the slot
s.dlg = null;
// a slot nobody re-claimed is also what makes the NEXT claim ambiguous
const list = warmBoot.get(key);
if (!s.live && list && list.length > 1) {
const i = list.indexOf(s);
if (i >= 0) list.splice(i, 1);
}
}, WARM_REVIVE_MS + 250);
}
/**
@@ -1003,14 +1227,31 @@ class HouseplanCard extends LitElement {
private _warmPatch(patch: Partial<WarmEntry>, create = false): void {
if (this._config?.kiosk) return;
const k = warmBootKey(this._config);
const cur = warmBoot.get(k);
if (!cur && !create) return;
warmBoot.set(k, { hdrH: this._hdrH, stageH: 0, vp: null, dlg: null, freed: 0, ...(cur || {}), ...patch });
while (warmBoot.size > WARM_MAX_KEYS) {
const oldest = warmBoot.keys().next().value; // Map keeps insertion order
if (oldest === undefined || oldest === k) break;
warmBoot.delete(oldest);
// The key carries the window size and the view path: once either changes
// under a LIVE card, its slot describes a geometry that is no longer on
// screen, and the next mount at the new size must boot cold (documented
// in docs/WARM-REMOUNT.md §1) — so write nothing rather than lie.
if (this._warmSlot && this._warmKey !== k) return;
if (!this._warmSlot) {
if (!create) return;
const place = this.parentNode;
this._warmKey = k;
this._warmSlot = {
owner: this._warmGen, place: place ? new WeakRef(place) : null, idx: this._warmIdx(place), live: true,
hdrH: this._hdrH, stageH: 0, vp: null, dlg: null, freed: 0, evict: 0,
};
const list = warmBoot.get(k) || [];
list.push(this._warmSlot);
warmBoot.set(k, list);
this._warmTrim(list);
while (warmBoot.size > WARM_MAX_KEYS) {
const oldest = warmBoot.keys().next().value; // Map keeps insertion order
if (oldest === undefined || oldest === k) break;
for (const s of warmBoot.get(oldest) || []) clearTimeout(s.evict);
warmBoot.delete(oldest);
}
}
Object.assign(this._warmSlot, patch);
}
private _warmViewportState(): WarmViewport {
@@ -1078,6 +1319,13 @@ class HouseplanCard extends LitElement {
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();
// AUD-159B1-01: the placement is re-measured from the live DOM, so a card
// that was moved (a sibling added above it) still names its own slot.
if (this.isConnected && this._warmSlot?.owner === this._warmGen) {
const place = this.parentNode;
patch.place = place ? new WeakRef(place) : null;
patch.idx = this._warmIdx(place);
}
this._warmPatch(patch);
}
@@ -1089,12 +1337,14 @@ class HouseplanCard extends LitElement {
*/
private _warmReviveDialog(): void {
this._warmRevivePending = false;
const k = warmBootKey(this._config);
const e = warmBoot.get(k);
const e = this._warmSlot; // AUD-159B1-01: OUR slot, never a neighbour's
this._warmReviveTimer = undefined;
if (!e || !e.dlg) return;
const d = e.dlg;
warmBoot.set(k, { ...e, dlg: null, freed: 0 }); // consume-once: no zombie on the third mount
if (!e.freed || Date.now() - e.freed > WARM_REVIVE_MS) return; // owner alive, or gone long ago
const freed = e.freed;
e.dlg = null; e.freed = 0; // consume-once: no zombie on the third mount
clearTimeout(e.evict); e.evict = 0;
if (!freed || Date.now() - freed > WARM_REVIVE_MS) return; // owner alive, or gone long ago
if (d.space !== this._space || d.mode !== this._mode) return; // never in another space/editor
switch (d.kind) {
case 'space': this._spaceDialog = { ...d.data, busy: false, savedBusy: false }; break;
@@ -1329,6 +1579,7 @@ class HouseplanCard extends LitElement {
protected updated(): void {
this._skyRelease();
this._warmSnapshot(); // DEV-B703-03: the memo follows what is on screen
this._dtMeasure(); // the selected label's frame follows the glyphs
const stage = this._stageEl;
if (stage && !this._roViewport) {
this._roViewport = new ResizeObserver(() => this._refitView());
@@ -2570,6 +2821,10 @@ class HouseplanCard extends LitElement {
}
private _stagePointerMove(ev: PointerEvent): void {
if (this._dtDrag?.pid === ev.pointerId) {
this._dtMove(ev);
return;
}
if (this._bdDrag?.pid === ev.pointerId) {
this._bdMove(ev);
return;
@@ -2672,6 +2927,10 @@ class HouseplanCard extends LitElement {
}
}
}
if (this._dtDrag?.pid === ev.pointerId) {
this._dtUp();
return;
}
if (this._bdDrag?.pid === ev.pointerId) {
this._bdUp();
return;
@@ -2942,6 +3201,8 @@ class HouseplanCard extends LitElement {
this._decorSel = null;
this._decorTool = 'select';
this._bdDrag = null;
this._dtDrag = null;
this._dtBox = null;
this._saveNav();
}
@@ -3487,10 +3748,12 @@ class HouseplanCard extends LitElement {
return true;
}
if (t === 'text') {
// …and the press did NOT land on an existing label: those are the one
// exception to the inertness above (see _decorShapeDown).
const p = this._snap(this._svgPoint(ev), ev);
this._decorTextDialog = {
x: clampCanvasN(p[0] / NORM_W), y: clampCanvasN(p[1] / this._decorH),
text: '', size: 'm', color: this._decorStyle.color,
text: '', color: this._decorStyle.color,
};
return true;
}
@@ -3547,6 +3810,19 @@ class HouseplanCard extends LitElement {
// backdrop tool grabs the picture). Swallowing it here was the bug — the
// click on a line end did nothing but keep the old selection alive.
const t = this._decorTool;
// ONE exception (owner, 2026-08-04): under the TEXT tool an existing LABEL
// is a target again, and pressing it opens its editor instead of starting
// a new label on top of it. Everything else stays inert — a press on a
// line or a rectangle under the text tool still reaches the stage and
// creates a new label there (the CSS keeps them pointer-inert, this is
// the belt to that pair of braces).
if (t === 'text') {
if (shape.kind !== 'text') return;
ev.stopPropagation();
ev.preventDefault();
this._decorOpenText(shape);
return;
}
if (t !== 'select' && t !== 'erase') return;
ev.stopPropagation();
ev.preventDefault();
@@ -3606,27 +3882,171 @@ class HouseplanCard extends LitElement {
/** Double click on a text shape (select tool) re-opens its dialog. */
private _decorShapeDbl(shape: any): void {
if (this._mode !== 'decor' || this._decorTool !== 'select' || shape.kind !== 'text') return;
this._decorTextDialog = { id: shape.id, x: shape.x, y: shape.y,
text: shape.text, size: shape.size || 'm', color: shape.color };
this._decorOpenText(shape);
}
/** Open the editor of an existing label (double click, or the text tool). */
private _decorOpenText(shape: any): void {
this._decorTextDialog = {
id: shape.id, x: shape.x, y: shape.y, text: shape.text, color: shape.color,
entity: shape.entity || '', attr: shape.attr || '', unit: shape.unit || '',
};
}
/** The link fields as they are STORED: only what the user actually filled in
* ends up in the config, so a plain label stays byte-for-byte a plain one. */
private _decorTextLink(d: { entity?: string; attr?: string; unit?: string }): Record<string, string> {
const out: Record<string, string> = {};
const ent = (d.entity || '').trim();
if (!ent) return out;
out.entity = ent;
const attr = (d.attr || '').trim();
if (attr) out.attr = attr;
const unit = (d.unit || '').trim();
if (unit) out.unit = unit;
return out;
}
private _decorSaveText(): void {
const d = this._decorTextDialog;
if (!d || !d.text.trim()) { this._decorTextDialog = null; return; }
// The user's own line breaks are kept; the surrounding whitespace is not.
const text = String(d?.text ?? '').replace(/\r\n?/g, '\n').trim();
if (!d || !text) { this._decorTextDialog = null; return; }
const sp = this._curSpaceCfg;
const link = this._decorTextLink(d);
if (d.id) {
sp.decor = this._decorList.map((x) => x.id === d.id
? { ...x, text: d.text.trim(), size: d.size, color: d.color } : x);
sp.decor = this._decorList.map((x) => {
if (x.id !== d.id) return x;
// the OLD link is dropped wholesale: clearing the entity in the dialog
// must clear the attribute and the unit with it, not leave orphans
const { entity, attr, unit, ...rest } = x;
return { ...rest, text, color: d.color, ...link };
});
} else {
const id = 'dc' + Date.now().toString(36) + Math.random().toString(36).slice(2, 5);
sp.decor = [...this._decorList, { id, kind: 'text', x: d.x, y: d.y,
text: d.text.trim(), size: d.size, color: d.color }];
text, color: d.color, ...link }];
this._decorSel = id;
}
this._decorTextDialog = null;
this._saveConfig();
this.requestUpdate();
}
// ---- the text block: scale by a corner, rotate by its handle ----
// The mechanics are the backdrop frame's (docs/BACKDROP.md §2), reused
// rather than reinvented: chrome that never takes a pointer, finger-sized
// handles that always do, the gesture written live into the config and
// PERSISTED only if something actually moved. What differs is the pivot —
// a label has an anchor (its x/y), not a box, so both gestures are about
// that anchor and the text never walks away from the point it was placed at.
/** The selected text shape, when the select tool has one. */
private get _dtSel(): any | null {
if (this._mode !== 'decor' || this._decorTool !== 'select' || !this._decorSel) return null;
const sh = this._decorList.find((x) => x.id === this._decorSel);
return sh && sh.kind === 'text' ? sh : null;
}
/** Write scale/angle into the shape — live, without saving. */
private _dtApply(id: string, patch: { scale?: number; angle?: number }): void {
const sp = this._curSpaceCfg;
if (!sp) return;
sp.decor = this._decorList.map((x) => {
if (x.id !== id) return x;
// the first drag replaces the legacy font size with the scale it means,
// so a shape never states its size twice
const { size, ...rest } = x;
const out: any = { ...rest };
if (patch.scale !== undefined) out.scale = Number(patch.scale.toFixed(4));
else if (x.scale === undefined && size !== undefined) out.scale = decorTextScale(x);
if (patch.angle !== undefined) {
if (patch.angle) out.angle = Number(patch.angle.toFixed(2));
else delete out.angle; // straight again = the field goes away
}
return out;
});
this._cfgEpoch++;
this.requestUpdate();
}
private _dtStart(ev: PointerEvent, kind: 'scale' | 'rotate'): void {
const sh = this._dtSel;
if (!sh) return;
ev.stopPropagation();
ev.preventDefault();
const ax = sh.x * NORM_W, ay = sh.y * this._decorH;
const p = this._svgPoint(ev);
this._dtDrag = {
id: sh.id, kind, pid: ev.pointerId, ax, ay,
r0: Math.hypot(p[0] - ax, p[1] - ay),
a0: (Math.atan2(p[1] - ay, p[0] - ax) * 180) / Math.PI,
scale0: decorTextScale(sh), angle0: Number(sh.angle) || 0,
moved: false,
};
capturePointer(ev);
}
private _dtMove(ev: PointerEvent): void {
const d = this._dtDrag;
if (!d) return;
const p = this._svgPoint(ev);
if (d.kind === 'scale') {
// uniform, about the anchor: the distance from the anchor is invariant
// under the block's own rotation, so a rotated block scales the same way
const r = Math.hypot(p[0] - d.ax, p[1] - d.ay);
if (d.r0 < 1e-6) return;
const k = Math.min(DECOR_TEXT_SCALE_MAX, Math.max(DECOR_TEXT_SCALE_MIN, d.scale0 * (r / d.r0)));
if (Math.abs(k - d.scale0) > 1e-6) d.moved = true;
this._dtApply(d.id, { scale: k });
return;
}
const a = (Math.atan2(p[1] - d.ay, p[0] - d.ax) * 180) / Math.PI;
let ang = d.angle0 + (a - d.a0);
// 5° steps — the step the device icons rotate in; Shift is the way past a
// snap everywhere in this card (docs/CANVAS.md §9.4), so it is here too
if (!ev.shiftKey) ang = Math.round(ang / DT_ANGLE_STEP) * DT_ANGLE_STEP;
ang = ((ang % 360) + 360) % 360;
if (ang > 180) ang -= 360;
if (Math.abs(ang - d.angle0) > 1e-6) d.moved = true;
this._dtApply(d.id, { angle: ang });
}
private _dtUp(): void {
const d = this._dtDrag;
this._dtDrag = null;
if (d?.moved) this._saveConfig();
this.requestUpdate();
}
/**
* Measure the selected label's own box. SVG can only tell us how big a text
* actually came out once it is in the DOM, so the frame is one render
* behind — and it is re-measured whenever the text, its scale or the
* selection changes. Guarded against the render→measure→render loop by
* comparing the numbers before asking for another update.
*/
private _dtMeasure(): void {
const sh = this._dtSel;
if (!sh) {
if (this._dtBox) { this._dtBox = null; this.requestUpdate(); }
return;
}
const el = this.renderRoot.querySelector(`text.dtext[data-id="${sh.id}"]`) as SVGGraphicsElement | null;
if (!el || typeof (el as any).getBBox !== 'function') return;
let b: DOMRect;
try { b = el.getBBox(); } catch { return; } // not rendered yet (hidden card)
if (!b || (!b.width && !b.height)) return;
const box = { id: sh.id, x: b.x, y: b.y, w: b.width, h: b.height };
const cur = this._dtBox;
const same = cur && cur.id === box.id && Math.abs(cur.x - box.x) < 0.01
&& Math.abs(cur.y - box.y) < 0.01 && Math.abs(cur.w - box.w) < 0.01
&& Math.abs(cur.h - box.h) < 0.01;
if (same) return;
this._dtBox = box;
this.requestUpdate();
}
private _decorDeleteSel(): void {
if (!this._decorSel) return;
const sp = this._curSpaceCfg;
@@ -3836,9 +4256,37 @@ class HouseplanCard extends LitElement {
</g>` as unknown as TemplateResult;
}
/**
* The selected text block's frame: a dashed outline, four corner handles
* that scale it uniformly and one handle above it that turns it. Same
* mechanics and same handle size as the backdrop frame (docs/BACKDROP.md
* §2) — finger-sized in SCREEN terms, so it stays grabbable at any zoom —
* and it rides the block's own rotation, so the corners stay at the corners.
*/
private _renderTextFrame(view: { x: number; y: number; w: number; h: number }): TemplateResult | typeof nothing {
const sh = this._dtSel;
const b = this._dtBox;
if (!sh || !b || b.id !== sh.id) return nothing;
const hr = Math.max(view.w, view.h) * 0.018;
const ax = sh.x * NORM_W, ay = sh.y * this._decorH;
const ang = Number(sh.angle) || 0;
const corners: [number, number, string][] = [
[-1, -1, 'nwse'], [1, -1, 'nesw'], [1, 1, 'nwse'], [-1, 1, 'nesw'],
];
const arm = hr * 2.2;
return svg`<g class="dtframe" transform=${ang ? `rotate(${ang} ${ax} ${ay})` : nothing}>
<rect class="dtbox" x="${b.x}" y="${b.y}" width="${b.w}" height="${b.h}"></rect>
<line class="dtstem" x1="${b.x + b.w / 2}" y1="${b.y}" x2="${b.x + b.w / 2}" y2="${b.y - arm}"></line>
<circle class="dthandle dtrot" cx="${b.x + b.w / 2}" cy="${b.y - arm}" r="${hr.toFixed(1)}"
@pointerdown=${(e: PointerEvent) => this._dtStart(e, 'rotate')}></circle>
${corners.map(([sx, sy, cur]) => svg`<circle class="dthandle dt-${cur}"
cx="${sx < 0 ? b.x : b.x + b.w}" cy="${sy < 0 ? b.y : b.y + b.h}" r="${hr.toFixed(1)}"
@pointerdown=${(e: PointerEvent) => this._dtStart(e, 'scale')}></circle>`)}
</g>` as unknown as TemplateResult;
}
private _renderDecorLayer(): TemplateResult {
const W = NORM_W, H = this._decorH;
const TXT = { s: 14, m: 20, l: 30 } as Record<string, number>;
const editing = this._mode === 'decor';
const shapes = this._decorList.map((sh) => {
const cls = 'dshape' + (editing && this._decorSel === sh.id ? ' dsel' : '');
@@ -3857,9 +4305,25 @@ class HouseplanCard extends LitElement {
return svg`<ellipse class="${cls}" cx="${(sh.x + sh.w / 2) * W}" cy="${(sh.y + sh.h / 2) * H}"
rx="${(sh.w / 2) * W}" ry="${(sh.h / 2) * H}" stroke="${sh.color}" stroke-width="${sh.width}"
fill="${sh.fill ? sh.color : 'none'}" fill-opacity="${sh.fill ? 0.25 : 0}" @pointerdown=${down}></ellipse>`;
if (sh.kind === 'text')
return svg`<text class="${cls} dtext" x="${sh.x * W}" y="${sh.y * H}" fill="${sh.color}"
font-size="${TXT[sh.size] || TXT.m}" @pointerdown=${down} @dblclick=${dbl}>${sh.text}</text>`;
if (sh.kind === 'text') {
// The label is painted from the LIVE value on every render — the same
// `hass` the rest of the card reads, no polling of its own. Without an
// entity `liveText` gives the stored text back byte-for-byte, so a
// plain label is the plain label it always was (docs/LIVE-TEXT.md).
const fs = DECOR_TEXT_BASE * decorTextScale(sh);
const lines = decorTextLines(liveText(sh.text, sh, this.hass));
const ax = sh.x * W, ay = sh.y * H;
const ang = Number(sh.angle) || 0;
// the block is centred on its anchor, horizontally (the layer's
// text-anchor) and vertically — so adding a second line grows the
// label in both directions instead of pushing the first one up
const y0 = ay - ((lines.length - 1) * fs * DT_LINE) / 2;
return svg`<text class="${cls} dtext" data-id="${sh.id}" x="${ax}" y="${ay}" fill="${sh.color}"
font-size="${fs}" transform=${ang ? `rotate(${ang} ${ax} ${ay})` : nothing}
@pointerdown=${down} @dblclick=${dbl}>${lines.map(
(ln, i) => svg`<tspan x="${ax}" y="${y0 + i * fs * DT_LINE}">${ln}</tspan>`,
)}</text>`;
}
return nothing;
});
// живое превью рисуемой фигуры
@@ -3940,27 +4404,53 @@ class HouseplanCard extends LitElement {
private _renderDecorTextDialog(): TemplateResult {
const d = this._decorTextDialog!;
const ent = (d.entity || '').trim();
const st = ent ? this.hass?.states?.[ent] : null;
const ownUnit = String(st?.attributes?.unit_of_measurement ?? '');
// the preview is the REAL renderer: one substitution, one truth
const preview = liveText(d.text, d, this.hass);
return html`<div class="menuwrap dialogwrap" @click=${() => (this._decorTextDialog = null)}>
<div class="dialog" @click=${(e: Event) => e.stopPropagation()}>
<div class="hd"><ha-icon icon="mdi:format-text"></ha-icon>${this._t('decor.text_title')}</div>
<div class="body">
<label>${this._t('decor.text_label')}</label>
<input class="namein" .value=${d.text} autofocus
@input=${(e: Event) => (this._decorTextDialog = { ...d, text: (e.target as HTMLInputElement).value })}
@keydown=${(e: KeyboardEvent) => { if (e.key === 'Enter') this._decorSaveText(); }} />
<label>${this._t('decor.text_size')}</label>
<div class="radiorow">
${(['s', 'm', 'l'] as const).map(
(sz) => html`<label class="srcrow inline">
<input type="radio" name="dtsize" .checked=${d.size === sz}
@change=${() => (this._decorTextDialog = { ...d, size: sz })} />
<span>${this._t(('decor.size_' + sz) as any)}</span>
</label>`,
)}
</div>
${''/* a textarea, not an input: the user's own line breaks are kept
and rendered (centred). Enter is a NEW LINE here, so saving
moved to Ctrl/Cmd+Enter and the button. */}
<textarea class="namein dtarea" rows="3" maxlength="200" .value=${d.text} autofocus
@input=${(e: Event) => (this._decorTextDialog = { ...d, text: (e.target as HTMLTextAreaElement).value })}
@keydown=${(e: KeyboardEvent) => {
e.stopPropagation();
if (e.key === 'Enter' && (e.ctrlKey || e.metaKey)) this._decorSaveText();
}}></textarea>
<div class="rhint">${this._t(ent ? 'decor.text_hint_live' : 'decor.text_hint')}</div>
<label>${this._t('decor.color')}</label>
<input type="color" .value=${d.color}
@input=${(e: Event) => (this._decorTextDialog = { ...d, color: (e.target as HTMLInputElement).value })} />
<label class="dispsection">${this._t('decor.live_group')}</label>
<div class="rhint">${this._t('decor.live_hint')}</div>
<label>${this._t('decor.live_entity')}</label>
<input class="namein" type="text" list="hp-dtext-ents" placeholder=${this._t('decor.live_entity_ph')}
.value=${d.entity || ''}
@input=${(e: Event) => (this._decorTextDialog = { ...d, entity: (e.target as HTMLInputElement).value })} />
<datalist id="hp-dtext-ents">
${Object.keys(this.hass?.states || {}).map((id) => html`<option value=${id}></option>`)}
</datalist>
${ent ? html`
<label>${this._t('decor.live_attr')}</label>
<input class="namein" type="text" list="hp-dtext-attrs" placeholder=${this._t('decor.live_attr_ph')}
.value=${d.attr || ''}
@input=${(e: Event) => (this._decorTextDialog = { ...d, attr: (e.target as HTMLInputElement).value })} />
<datalist id="hp-dtext-attrs">
${Object.keys(st?.attributes || {}).map((a) => html`<option value=${a}></option>`)}
</datalist>
<label>${this._t('decor.live_unit')}</label>
<input class="namein" type="text" .value=${d.unit || ''}
placeholder=${(d.attr || '').trim() ? '' : ownUnit}
@input=${(e: Event) => (this._decorTextDialog = { ...d, unit: (e.target as HTMLInputElement).value })} />
<label>${this._t('decor.live_preview')}</label>
<div class="dtpreview">${decorTextLines(preview).map((ln) => html`<div>${ln}</div>`)}</div>
` : nothing}
</div>
<div class="row">
<span class="spacer"></span>
@@ -6581,6 +7071,7 @@ class HouseplanCard extends LitElement {
grabbable (docs/BACKDROP.md §2). It exists only in the
backdrop editor, where rooms and devices are pointer-inert. */}
${this._renderBackdropFrame(view)}
${this._renderTextFrame(view)}
</svg>
${''/* docs/CANVAS.md §6: an icon is a percentage of the PLAN and
scales with it when you zoom — the behaviour the card always
+10 -4
View File
@@ -273,10 +273,16 @@
"decor.fill": "Fill",
"decor.text_title": "Text label",
"decor.text_label": "Text",
"decor.text_size": "Size",
"decor.size_s": "Small",
"decor.size_m": "Medium",
"decor.size_l": "Large",
"decor.text_hint": "A new line here is a new line on the plan; the block is centred. Drag a corner to resize it, the handle above it to turn it.",
"decor.text_hint_live": "{} is where the value lands. Without it the value is added at the end. A new line here is a new line on the plan.",
"decor.live_group": "Live value",
"decor.live_hint": "The label may show one entity's value — as Home Assistant reports it, with no rounding. A dead entity shows a dash.",
"decor.live_entity": "Entity",
"decor.live_entity_ph": "none — a plain label",
"decor.live_attr": "Attribute",
"decor.live_attr_ph": "the state",
"decor.live_unit": "Unit",
"decor.live_preview": "Preview",
"decor.backdrop": "Backdrop image",
"decor.backdrop_hint": "Drag the picture to move it, pull a corner to resize it. Shift — off the grid.",
"decor.backdrop_reset": "Reset the picture",
+10 -4
View File
@@ -273,10 +273,16 @@
"decor.fill": "Залить",
"decor.text_title": "Надпись",
"decor.text_label": "Текст",
"decor.text_size": "Размер",
"decor.size_s": "Мелкий",
"decor.size_m": "Средний",
"decor.size_l": "Крупный",
"decor.text_hint": "Перевод строки здесь — перевод строки на плане; блок выравнивается по центру. Тяните угол — меняет размер, ручка сверху — поворот.",
"decor.text_hint_live": "{} — место, куда встанет значение. Без него значение добавится в конец. Перевод строки здесь — перевод строки на плане.",
"decor.live_group": "Живое значение",
"decor.live_hint": "Надпись может показывать значение одной сущности — так, как его отдаёт Home Assistant, без округления. Мёртвая сущность — прочерк.",
"decor.live_entity": "Сущность",
"decor.live_entity_ph": "нет — обычная надпись",
"decor.live_attr": "Атрибут",
"decor.live_attr_ph": "состояние",
"decor.live_unit": "Единица",
"decor.live_preview": "Предпросмотр",
"decor.backdrop": "Картинка-подложка",
"decor.backdrop_hint": "Тяните картинку — двигается, тяните угол — меняет размер. Shift — мимо сетки.",
"decor.backdrop_reset": "Вернуть картинку",
+99
View File
@@ -910,6 +910,105 @@ export function floorsOf(hass: any): FloorInfo[] {
return list;
}
// ---------------- live text on a decor label (docs/LIVE-TEXT.md) -------------
/** What a dead sensor says. A label that vanishes with its entity is worse
* than one that admits it has no data. */
export const LIVE_TEXT_DASH = '—';
/** A caption is a caption: an attribute that turns out to be a 4 KB string
* must not become the plan's wallpaper. */
export const LIVE_TEXT_VALUE_MAX = 60;
/** The one placeholder. Not `{{ }}`: this is a substitution, not a template
* language — see docs/LIVE-TEXT.md for why that line is where it is. */
export const LIVE_TEXT_SLOT = '{}';
export interface LiveTextLink {
/** entity id whose value lands in the label; absent = a plain static label */
entity?: string | null;
/** attribute to read instead of the state */
attr?: string | null;
/** suffix; absent = the entity's own unit_of_measurement (state only) */
unit?: string | null;
}
/** One attribute/state value as text, or null when there is nothing to show. */
function liveRaw(raw: unknown): string | null {
if (raw === undefined || raw === null) return null;
if (Array.isArray(raw)) {
const s = raw.map((v) => (v === null || v === undefined ? '' : String(v))).join(', ');
return s ? s.slice(0, LIVE_TEXT_VALUE_MAX) : null;
}
if (typeof raw === 'object') return null; // a dict is not a caption
const s = String(raw);
return s === '' ? null : s.slice(0, LIVE_TEXT_VALUE_MAX);
}
/**
* The live value of a linked label, unit included — exactly as HA reports it.
* No rounding, no reformatting, no localised separators: rounding belongs to
* the sensor's `display_precision`, and duplicating it here would make two
* sources of truth (docs/LIVE-TEXT.md).
*
* Unit resolution: an explicit `unit` always wins. An empty one inherits the
* entity's `unit_of_measurement` ONLY when the STATE is being read — a
* `battery_level` attribute on a °C sensor must not come out as «73 °C».
*/
export function liveTextValue(hass: any, link: LiveTextLink | null | undefined): string {
const id = (link?.entity || '').trim();
if (!id) return '';
const st = hass?.states?.[id];
const state = st?.state;
if (!st || state === undefined || state === null || state === ''
|| state === 'unavailable' || state === 'unknown') return LIVE_TEXT_DASH;
const attr = (link?.attr || '').trim();
const val = liveRaw(attr ? st.attributes?.[attr] : state);
if (val === null) return LIVE_TEXT_DASH; // the attribute is not on this entity
const own = attr ? '' : String(st.attributes?.unit_of_measurement ?? '').trim();
const unit = String(link?.unit ?? '').trim() || own;
return unit ? `${val} ${unit}` : val;
}
/**
* The label as it must be painted: the template with its first `{}` replaced
* by the live value. A template without the placeholder gets the value
* appended after a space, so picking an entity and typing nothing sensible
* still shows something useful. Only the FIRST `{}` is substituted — one
* label, one value (docs/LIVE-TEXT.md «not a multi-entity widget»).
* Without an entity the text is returned byte-for-byte.
*/
export function liveText(text: string | null | undefined, link: LiveTextLink | null | undefined, hass: any): string {
const tpl = text ?? '';
if (!(link?.entity || '').trim()) return tpl;
const v = liveTextValue(hass, link);
const i = tpl.indexOf(LIVE_TEXT_SLOT);
if (i >= 0) return tpl.slice(0, i) + v + tpl.slice(i + LIVE_TEXT_SLOT.length);
return tpl ? `${tpl} ${v}` : v;
}
export const DECOR_TEXT_BASE = 20; // px at scale 1 — what 'm' has always been
export const DECOR_TEXT_SCALE_MIN = 0.15;
export const DECOR_TEXT_SCALE_MAX = 20;
/**
* The font multiplier of a decor text shape. `scale` is the field the corner
* handles write; the legacy `size` ('s'|'m'|'l') is read as the multiplier it
* used to render at (14/20/30 px against the base 20), so a label drawn
* before the handles existed comes back at exactly its old size without any
* migration. An explicit `scale` wins — it is the newer, finer statement of
* the same thing, and the first drag replaces `size` with it.
*/
export function decorTextScale(shape: { scale?: unknown; size?: unknown } | null | undefined): number {
const s = Number(shape?.scale);
if (Number.isFinite(s) && s > 0) return Math.min(DECOR_TEXT_SCALE_MAX, Math.max(DECOR_TEXT_SCALE_MIN, s));
const legacy: Record<string, number> = { s: 0.7, m: 1, l: 1.5 };
return legacy[String(shape?.size ?? '')] ?? 1;
}
/** The lines of a decor label. Explicit newlines only: the label never wraps
* by itself, so a caption cannot reflow (and jump) on a state change. */
export function decorTextLines(s: string | null | undefined): string[] {
return String(s ?? '').replace(/\r\n?/g, '\n').split('\n');
}
/** Substitute every occurrence of {name} placeholders in a template string. */
export function subst(s: string, vars?: Record<string, string | number>): string {
if (!vars) return s;
+54
View File
@@ -896,6 +896,15 @@ export const cardStyles = css`
.stage.mode-decor.dtool-ellipse .decorlayer .dshape,
.stage.mode-decor.dtool-text .decorlayer .dshape,
.stage.mode-decor.dtool-backdrop .decorlayer .dshape { pointer-events: none; }
/* ONE exception (owner, 2026-08-04): under the TEXT tool an existing LABEL
is a target again — pressing it opens its editor instead of starting a
new label on top of the old one. Only labels: a line or a rectangle
under the text tool stays inert, so the press reaches the stage and a
new label is created there. */
.stage.mode-decor.dtool-text .decorlayer .dshape.dtext {
pointer-events: visiblePainted;
cursor: text;
}
.decorlayer .dsel {
filter: drop-shadow(0 0 3px var(--hp-accent));
}
@@ -945,6 +954,51 @@ export const cardStyles = css`
transform: translate(-50%, -50%);
border: 1px solid var(--hp-accent);
}
/* the selected text block's frame — same chrome rules as the backdrop's:
the outline never takes a pointer, the handles always do, and they are
finger-sized because this is dragged on a tablet too */
.dtframe .dtbox {
fill: none;
stroke: var(--hp-accent);
stroke-width: 1.5;
stroke-dasharray: 7 5;
vector-effect: non-scaling-stroke;
pointer-events: none;
opacity: 0.85;
}
.dtframe .dtstem {
stroke: var(--hp-accent);
stroke-width: 1.5;
vector-effect: non-scaling-stroke;
pointer-events: none;
opacity: 0.85;
}
.dtframe .dthandle {
fill: var(--hp-accent);
stroke: #fff;
stroke-width: 1.5;
vector-effect: non-scaling-stroke;
pointer-events: all;
touch-action: none;
}
.dtframe .dt-nwse { cursor: nwse-resize; }
.dtframe .dt-nesw { cursor: nesw-resize; }
.dtframe .dtrot { cursor: grab; }
.dtarea {
resize: vertical;
min-height: 3.4em;
font: inherit;
line-height: 1.35;
}
.dtpreview {
padding: 6px 8px;
border-radius: 8px;
background: rgba(127, 127, 127, 0.12);
text-align: center;
white-space: pre-wrap;
word-break: break-word;
font-weight: 600;
}
.stage.mode-decor.dtool-line, .stage.mode-decor.dtool-rect,
.stage.mode-decor.dtool-ellipse, .stage.mode-decor.dtool-text {
cursor: crosshair;