feat v1.33.0: background editor — visual decor layer

- third mode tab: draw lines/rects/ovals/text with grid snap, drag
  preview; select+move (Delete key), erase, color/width/fill controls;
  Esc ladder; text dialog with S/M/L sizes and dblclick re-edit
- decor renders under rooms, visible in every mode, click-transparent
  outside its editor; stored in space.decor with backend schema
  (line/rect/ellipse/text variants), rev/optimistic locking as usual
- smoke_decor.mjs (19 checks); smoke_editor_tabs updated to 3 tabs;
  TESTING/CHANGELOG/UX-MODES same-commit
This commit is contained in:
Matysh
2026-07-22 14:45:25 +03:00
parent f235c8afc5
commit dd930b64f7
16 changed files with 849 additions and 44 deletions
+308 -7
View File
@@ -32,7 +32,7 @@ import './space-card';
import { cardStyles } from './styles';
import { langOf, t, type I18nKey } from './i18n';
const CARD_VERSION = '1.32.1';
const CARD_VERSION = '1.33.0';
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';
@@ -86,7 +86,14 @@ class HouseplanCard extends LitElement {
/** Interaction mode (docs/UX-MODES.md): view = display only, plan = geometry
* editing, devices = marker placement/config. Never persisted — every load
* starts in view. */
private _mode: 'view' | 'plan' | 'devices' = 'view';
private _mode: 'view' | 'plan' | 'devices' | 'decor' = 'view';
// ---- decor (background) editor ----
private _decorTool: 'select' | 'line' | 'rect' | 'ellipse' | 'text' | 'erase' = 'select';
private _decorStyle: { color: string; width: number; fill: boolean } = { color: '#607d8b', width: 3, fill: false };
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;
/** Edit tabs are offered to admins only (hass.user missing → assume admin). */
private get _canEdit(): boolean {
@@ -224,6 +231,11 @@ class HouseplanCard extends LitElement {
_openingInfo: { state: true },
_mergeDialog: { state: true },
_splitSel: { state: true },
_decorTool: { state: true },
_decorStyle: { state: true },
_decorDraft: { state: true },
_decorSel: { state: true },
_decorTextDialog: { state: true },
_areaSel: { state: true },
_nameSel: { state: true },
_roomDialog: { state: true },
@@ -265,6 +277,7 @@ class HouseplanCard extends LitElement {
if (this._settingsDialog) { this._settingsDialog = null; return; }
if (this._markerDialog) { this._markerDialog = null; return; }
if (this._openingDialog) { this._openingDialog = null; return; }
if (this._decorTextDialog) { this._decorTextDialog = null; return; }
if (this._spaceDialog && !this._roomDialog) {
// same semantics as the dialog's Cancel: an import queue is abandoned
this._spaceDialog = null;
@@ -273,6 +286,22 @@ class HouseplanCard extends LitElement {
return;
}
}
if (this._mode === 'decor') {
if ((e.key === 'Delete' || e.key === 'Backspace') && this._decorSel &&
!(e.target as HTMLElement)?.closest?.('input, textarea, select')) {
e.preventDefault();
this._decorDeleteSel();
return;
}
if (e.key === 'Escape') {
e.preventDefault();
if (this._decorDraft) this._decorDraft = null;
else if (this._decorSel) this._decorSel = null;
else if (this._decorTool !== 'select') this._decorTool = 'select';
else this._setMode('view');
}
return;
}
if (!this._markup) return;
const undo = e.key === 'Escape' || ((e.ctrlKey || e.metaKey) && e.key.toLowerCase() === 'z');
if (!undo) return;
@@ -928,6 +957,7 @@ class HouseplanCard extends LitElement {
// do not interfere with icon dragging and markup drawing
if (this._drag || this._markup) return;
if (this._mode === 'devices' && (ev.target as HTMLElement).closest('.dev')) return;
if (this._mode === 'decor' && this._decorPointerDown(ev)) return;
this._pointers.set(ev.pointerId, { x: ev.clientX, y: ev.clientY });
const v = this._viewOr(this._spaceModel().vb);
if (this._pointers.size === 1) {
@@ -942,6 +972,14 @@ class HouseplanCard extends LitElement {
}
private _stagePointerMove(ev: PointerEvent): void {
if (this._decorDraft?.pid === ev.pointerId) {
this._decorDraft = { ...this._decorDraft, b: this._snap(this._svgPoint(ev)) };
return;
}
if (this._decorMove?.pid === ev.pointerId) {
this._decorMoveUpdate(ev);
return;
}
if (!this._pointers.has(ev.pointerId)) {
this._markupMove(ev);
return;
@@ -982,6 +1020,15 @@ class HouseplanCard extends LitElement {
}
private _stagePointerUp(ev: PointerEvent): void {
if (this._decorDraft?.pid === ev.pointerId) {
this._decorCommitDraft();
return;
}
if (this._decorMove?.pid === ev.pointerId) {
if (this._decorMove.moved) this._saveConfig();
this._decorMove = null;
return;
}
this._pointers.delete(ev.pointerId);
if (this._pointers.size < 2) this._pinchStart = null;
if (this._pointers.size === 0) {
@@ -1103,9 +1150,9 @@ class HouseplanCard extends LitElement {
return roomEdges(sp?.rooms || []).map((s) => [s[0] * NORM_W, s[1] * H, s[2] * NORM_W, s[3] * H]);
}
private _setMode(mode: 'view' | 'plan' | 'devices'): void {
private _setMode(mode: 'view' | 'plan' | 'devices' | 'decor'): void {
if (this._mode === mode) return;
if (mode === 'plan' && !this._norm) {
if ((mode === 'plan' || mode === 'decor') && !this._norm) {
this._showToast(this._t('toast.markup_needs_server'));
return;
}
@@ -1119,6 +1166,9 @@ class HouseplanCard extends LitElement {
this._pendingSplit = null;
this._selId = null;
this._tip = null;
this._decorDraft = null;
this._decorSel = null;
this._decorTool = 'select';
}
@@ -1275,6 +1325,255 @@ class HouseplanCard extends LitElement {
return (cm / this._cellCm) * this._gridPitch;
}
// ================= decor (background) layer =================
private get _decorList(): any[] {
const sp = this._curSpaceCfg;
return Array.isArray(sp?.decor) ? sp.decor : [];
}
private get _decorH(): number {
return NORM_W / (this._curSpaceCfg?.aspect || 1);
}
/** Begin a decor gesture. Returns true when the event is consumed (no pan). */
private _decorPointerDown(ev: PointerEvent): boolean {
const t = this._decorTool;
const onShape = (ev.target as HTMLElement).closest?.('.dshape') as SVGElement | null;
if (onShape) return true; // the shape's own handler deals with it
if (t === 'line' || t === 'rect' || t === 'ellipse') {
ev.preventDefault();
const p = this._snap(this._svgPoint(ev));
this._decorDraft = { kind: t, a: p, b: p, pid: ev.pointerId };
(ev.target as HTMLElement).setPointerCapture?.(ev.pointerId);
return true;
}
if (t === 'text') {
const p = this._snap(this._svgPoint(ev));
this._decorTextDialog = {
x: p[0] / NORM_W, y: p[1] / this._decorH,
text: '', size: 'm', color: this._decorStyle.color,
};
return true;
}
this._decorSel = null; // select/erase on empty space clears the selection
return false; // pan is allowed
}
/** Commit the dragged shape (ignore degenerate ones) and persist. */
private _decorCommitDraft(): void {
const d = this._decorDraft;
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;
const W = NORM_W, H = this._decorH;
const st = this._decorStyle;
const id = 'dc' + Date.now().toString(36) + Math.random().toString(36).slice(2, 5);
let shape: any;
if (d.kind === 'line') {
shape = { id, kind: 'line', x1: d.a[0] / W, y1: d.a[1] / H, x2: d.b[0] / W, y2: d.b[1] / H,
color: st.color, width: st.width };
} else {
const x = Math.min(d.a[0], d.b[0]) / W, y = Math.min(d.a[1], d.b[1]) / H;
const w = Math.abs(d.b[0] - d.a[0]) / W, h = Math.abs(d.b[1] - d.a[1]) / H;
shape = { id, kind: d.kind, x, y, w, h, color: st.color, width: st.width, fill: st.fill };
}
const sp = this._curSpaceCfg;
sp.decor = [...this._decorList, shape];
this._decorSel = id;
this._saveConfig();
this.requestUpdate();
}
/** Select tool: pointerdown on a shape starts moving it. */
private _decorShapeDown(ev: PointerEvent, shape: any): void {
if (this._mode !== 'decor') return;
ev.stopPropagation();
ev.preventDefault();
if (this._decorTool === 'erase') {
const sp = this._curSpaceCfg;
sp.decor = this._decorList.filter((x) => x.id !== shape.id);
if (this._decorSel === shape.id) this._decorSel = null;
this._saveConfig();
this.requestUpdate();
return;
}
if (this._decorTool !== 'select') return;
this._decorSel = shape.id;
this._decorMove = {
id: shape.id, start: this._svgPoint(ev), orig: JSON.parse(JSON.stringify(shape)),
pid: ev.pointerId, moved: false,
};
(ev.target as HTMLElement).setPointerCapture?.(ev.pointerId);
}
private _decorMoveUpdate(ev: PointerEvent): void {
const m = this._decorMove!;
const p = this._svgPoint(ev);
const g = this._gridPitch;
const dx = snapToGrid(p[0] - m.start[0], g) / NORM_W;
const dy = snapToGrid(p[1] - m.start[1], g) / this._decorH;
if (dx || dy) m.moved = true;
const sp = this._curSpaceCfg;
sp.decor = this._decorList.map((x) => {
if (x.id !== m.id) return x;
const o = m.orig;
if (x.kind === 'line') return { ...x, x1: o.x1 + dx, y1: o.y1 + dy, x2: o.x2 + dx, y2: o.y2 + dy };
return { ...x, x: o.x + dx, y: o.y + dy };
});
this.requestUpdate();
}
/** Double click on a text shape (select tool) re-opens its dialog. */
private _decorShapeDbl(shape: any): void {
if (this._mode !== 'decor' || 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 };
}
private _decorSaveText(): void {
const d = this._decorTextDialog;
if (!d || !d.text.trim()) { this._decorTextDialog = null; return; }
const sp = this._curSpaceCfg;
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);
} 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 }];
}
this._decorTextDialog = null;
this._saveConfig();
this.requestUpdate();
}
private _decorDeleteSel(): void {
if (!this._decorSel) return;
const sp = this._curSpaceCfg;
sp.decor = this._decorList.filter((x) => x.id !== this._decorSel);
this._decorSel = null;
this._saveConfig();
this.requestUpdate();
}
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' : '');
const down = (e: PointerEvent) => this._decorShapeDown(e, sh);
const dbl = () => this._decorShapeDbl(sh);
if (sh.kind === 'line')
return svg`<line class="${cls}" x1="${sh.x1 * W}" y1="${sh.y1 * H}" x2="${sh.x2 * W}" y2="${sh.y2 * H}"
stroke="${sh.color}" stroke-width="${sh.width}" @pointerdown=${down}></line>`;
if (sh.kind === 'rect')
return svg`<rect class="${cls}" x="${sh.x * W}" y="${sh.y * H}" width="${sh.w * W}" height="${sh.h * H}"
stroke="${sh.color}" stroke-width="${sh.width}"
fill="${sh.fill ? sh.color : 'none'}" fill-opacity="${sh.fill ? 0.25 : 0}" @pointerdown=${down}></rect>`;
if (sh.kind === 'ellipse')
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>`;
return nothing;
});
// живое превью рисуемой фигуры
let draft: unknown = nothing;
const d = this._decorDraft;
if (d) {
const st = this._decorStyle;
if (d.kind === 'line')
draft = svg`<line class="ddraft" x1="${d.a[0]}" y1="${d.a[1]}" x2="${d.b[0]}" y2="${d.b[1]}"
stroke="${st.color}" stroke-width="${st.width}"></line>`;
else {
const x = Math.min(d.a[0], d.b[0]), y = Math.min(d.a[1], d.b[1]);
const w = Math.abs(d.b[0] - d.a[0]), h = Math.abs(d.b[1] - d.a[1]);
draft = d.kind === 'rect'
? svg`<rect class="ddraft" x="${x}" y="${y}" width="${w}" height="${h}" stroke="${st.color}"
stroke-width="${st.width}" fill="${st.fill ? st.color : 'none'}" fill-opacity="${st.fill ? 0.15 : 0}"></rect>`
: svg`<ellipse class="ddraft" cx="${x + w / 2}" cy="${y + h / 2}" rx="${w / 2}" ry="${h / 2}"
stroke="${st.color}" stroke-width="${st.width}" fill="${st.fill ? st.color : 'none'}" fill-opacity="${st.fill ? 0.15 : 0}"></ellipse>`;
}
}
return svg`<g class="decorlayer">${shapes}${draft}</g>` as unknown as TemplateResult;
}
private _renderDecorBar(): TemplateResult {
const tools = [
['select', 'mdi:cursor-default-outline', 'decor.select'],
['line', 'mdi:vector-line', 'decor.line'],
['rect', 'mdi:rectangle-outline', 'decor.rect'],
['ellipse', 'mdi:ellipse-outline', 'decor.ellipse'],
['text', 'mdi:format-text', 'decor.text'],
['erase', 'mdi:eraser', 'decor.erase'],
] as const;
return html`<div class="editbar decorbar">
<ha-icon icon="mdi:draw" class="warn"></ha-icon>
${tools.map(
([t, ic, k]) => html`<button class="btn ${this._decorTool === t ? 'on' : ''}"
@click=${() => { this._decorTool = t; this._decorDraft = null; }}
title=${this._t(k)}>
<ha-icon icon=${ic}></ha-icon><span class="ml">${this._t(k)}</span>
</button>`,
)}
<input type="color" class="dcolor" .value=${this._decorStyle.color}
title=${this._t('decor.color')}
@input=${(e: Event) => (this._decorStyle = { ...this._decorStyle, color: (e.target as HTMLInputElement).value })} />
<select class="dwidth" title=${this._t('decor.width')}
@change=${(e: Event) => (this._decorStyle = { ...this._decorStyle, width: Number((e.target as HTMLSelectElement).value) })}>
${[[1.5, 'decor.w_thin'], [3, 'decor.w_mid'], [6, 'decor.w_thick']].map(
([v, k]) => html`<option value=${v} ?selected=${this._decorStyle.width === v}>${this._t(k as any)}</option>`,
)}
</select>
<label class="dfill"><input type="checkbox" .checked=${this._decorStyle.fill}
@change=${(e: Event) => (this._decorStyle = { ...this._decorStyle, fill: (e.target as HTMLInputElement).checked })} />
${this._t('decor.fill')}</label>
<span class="spacer"></span>
<button class="btn barclose" title=${this._t('title.close_editor')}
@click=${() => this._setMode('view')}>
<ha-icon icon="mdi:close"></ha-icon>
</button>
</div>`;
}
private _renderDecorTextDialog(): TemplateResult {
const d = this._decorTextDialog!;
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>
<label>${this._t('decor.color')}</label>
<input type="color" .value=${d.color}
@input=${(e: Event) => (this._decorTextDialog = { ...d, color: (e.target as HTMLInputElement).value })} />
</div>
<div class="row">
<span class="spacer"></span>
<button class="btn ghost" @click=${() => (this._decorTextDialog = null)}>${this._t('btn.cancel')}</button>
<button class="btn primary" ?disabled=${!d.text.trim()} @click=${() => this._decorSaveText()}>${this._t('btn.save')}</button>
</div>
</div>
</div>`;
}
/** Opening tool: click an existing opening to edit it, or a wall to place one. */
private _openingClick(raw: number[]): void {
const eps = this._gridPitch * 1.5;
@@ -2515,7 +2814,7 @@ class HouseplanCard extends LitElement {
</div>
${this._canEdit
? html`<div class="modes">
${([['plan', 'mdi:floor-plan'], ['devices', 'mdi:tune-variant']] as const).map(
${([['plan', 'mdi:floor-plan'], ['devices', 'mdi:tune-variant'], ['decor', 'mdi:draw']] as const).map(
([m, ic]) => html`<button class="modetab ${this._mode === m ? 'active' : ''}"
title=${this._t(('mode.' + m) as any)}
@click=${() => { if (this._mode !== m) this._setMode(m); }}>
@@ -2542,10 +2841,10 @@ class HouseplanCard extends LitElement {
</button>`
: nothing}
</div>
${this._markup ? this._renderMarkupBar() : this._mode === 'devices' ? this._renderDevicesBar() : nothing}
${this._markup ? this._renderMarkupBar() : this._mode === 'devices' ? this._renderDevicesBar() : this._mode === 'decor' ? this._renderDecorBar() : nothing}
</div>
<div class="stage ${this._markup ? 'markup tool-' + this._tool + (this._tool === 'split' && !this._splitSel ? ' pickstage' : '') : ''} ${space.bg ? '' : 'noplan'} mode-${this._mode}"
<div class="stage ${this._markup ? 'markup tool-' + this._tool + (this._tool === 'split' && !this._splitSel ? ' pickstage' : '') : ''} ${this._mode === 'decor' ? 'dtool-' + this._decorTool : ''} ${space.bg ? '' : 'noplan'} mode-${this._mode}"
style="height:calc(100dvh - 118px)"
@click=${(e: MouseEvent) => this._markupClick(e)}
@wheel=${(e: WheelEvent) => this._onWheel(e)}
@@ -2559,6 +2858,7 @@ class HouseplanCard extends LitElement {
${space.bg
? svg`<image href="${space.bg.href}" x="${space.bg.x}" y="${space.bg.y}" width="${space.bg.w}" height="${space.bg.h}" preserveAspectRatio="none" />`
: nothing}
${this._renderDecorLayer()}
${space.rooms.filter((r) => r.area || this._markup || disp.showBorders).map((r) => {
let cls = 'room ' + (space.bg ? 'overlay' : 'yard') + (this._markup ? ' outlined' : '');
if (this._markup && (r.id === this._mergeSel || r.id === this._splitSel?.roomId))
@@ -2625,6 +2925,7 @@ class HouseplanCard extends LitElement {
${this._mergeDialog ? this._renderMergeDialog() : nothing}
${this._openingDialog ? this._renderOpeningDialog() : nothing}
${this._openingInfo ? this._renderOpeningInfoCard() : nothing}
${this._decorTextDialog ? this._renderDecorTextDialog() : nothing}
${this._spaceDialog ? this._renderSpaceDialog() : nothing}
${this._markerDialog ? this._renderMarkerDialog() : nothing}
${this._infoCard ? this._renderInfoCard() : nothing}