feat v1.25.0: three interaction modes — View / Plan / Devices (docs/UX-MODES.md)

- View (always the default): display + device interaction only; no dragging of
  icons/labels/openings; panning can start on an icon; clean header
- Plan: markup tools, openings editing (click-to-edit, drag along walls), label
  drag, space dialogs, fill palette; orange frame; icons hidden but labels visible
- Devices: icon drag only here, click opens the marker editor directly; add/
  show-all/reset/rules buttons; accent frame
- segmented mode control (admins only); removed: markup toggle button, view-mode
  opening drag/dblclick (v1.23.1), 'drag anywhere' (v1.9 reversed)
- README en+ru updated; TESTING.md Modes section; smoke_modes.mjs
This commit is contained in:
Matysh
2026-07-22 10:11:02 +03:00
parent 9d0e2cab00
commit 185604396d
17 changed files with 432 additions and 134 deletions
+2 -2
View File
@@ -119,7 +119,7 @@ Later you can add as many spaces as you like (floors, yard, garage) with the **
### Step 2. Outline the rooms ### Step 2. Outline the rooms
After the first space is added, the card switches to markup mode by itself. Click grid points, connecting them with lines, and close the room outline by clicking the first point. After the first space is added, the card switches to the **Plan** tab by itself. The card has three mode tabs in the header — **View** (default: display and device control only, nothing can be moved or edited), **Plan** (rooms, openings, labels, space settings) and **Devices** (placing and configuring markers); the edit tabs are shown to administrators. In Plan, click grid points, connecting them with lines, and close the room outline by clicking the first point.
As soon as the outline is closed, the room-save dialog appears. Here you need to **bind the room to a Home Assistant area** — this is exactly what enables the automation. For utility rooms with no devices (hall, sauna) there is a **"No area"** button. As soon as the outline is closed, the room-save dialog appears. Here you need to **bind the room to a Home Assistant area** — this is exactly what enables the automation. For utility rooms with no devices (hall, sauna) there is a **"No area"** button.
@@ -166,7 +166,7 @@ The mouse wheel or the ** / ⊹ / ** buttons zoom the plan in and out; on
### Step 5. Put the icons in their places ### Step 5. Put the icons in their places
Device icons can be **dragged with the mouse at any time** — no separate "edit mode" needs to be enabled. Positions are saved on the server and are identical in all browsers and devices. The **↺** button in the header restores the automatic layout. Switch to the **Devices** tab to arrange icons: drag them with the mouse, click one to open its editor. In **View** mode nothing can be moved — panning the map never displaces a sensor (a top user request). Positions are saved on the server and are identical in all browsers and devices. The **↺** button restores the automatic layout.
![Dragging icons — available at all times](docs/images/06-edit.png) ![Dragging icons — available at all times](docs/images/06-edit.png)
+1 -1
View File
@@ -168,7 +168,7 @@ title: План дома
### Шаг 5. Расставьте значки по местам ### Шаг 5. Расставьте значки по местам
Значки устройств можно **перетаскивать мышью в любой момент** — отдельный «режим правки» включать не нужно. Позиции сохраняются на сервере и одинаковы во всех браузерах и устройствах. Кнопка **↺** в шапке возвращает автоматическую раскладку. Расставлять значки нужно на вкладке **«Устройства»**: там они перетаскиваются мышью, а клик открывает редактор. В режиме **«Просмотр»** ничего сдвинуть нельзя — панорамирование карты больше не сдвигает датчики (главная просьба пользователей). Позиции сохраняются на сервере и одинаковы во всех браузерах и устройствах. Кнопка **↺** возвращает автоматическую раскладку.
![Перетаскивание значков — доступно всегда](docs/images/06-edit.png) ![Перетаскивание значков — доступно всегда](docs/images/06-edit.png)
+1 -1
View File
@@ -11,7 +11,7 @@ PLANS_DIR = "houseplan/plans" # relative to the HA configuration directory
FILES_URL = "/houseplan_files/files" FILES_URL = "/houseplan_files/files"
FILES_DIR = "houseplan/files" FILES_DIR = "houseplan/files"
CONF_ADMIN_ONLY = "admin_only" CONF_ADMIN_ONLY = "admin_only"
VERSION = "1.24.2" VERSION = "1.25.0"
DEFAULT_CONFIG: dict = { DEFAULT_CONFIG: dict = {
"spaces": [], "spaces": [],
File diff suppressed because one or more lines are too long
+1 -1
View File
@@ -16,5 +16,5 @@
"issue_tracker": "https://github.com/Matysh/houseplan-card/issues", "issue_tracker": "https://github.com/Matysh/houseplan-card/issues",
"requirements": [], "requirements": [],
"single_config_entry": true, "single_config_entry": true,
"version": "1.24.2" "version": "1.25.0"
} }
+79
View File
@@ -0,0 +1,79 @@
// UX modes shell (v1.25.0): view is display-only; plan/devices gate the tools.
import { launch } from './serve.mjs';
const { page, browser } = await launch();
const out = {};
const q = (sel) => page.evaluate((s) => (window.__card.shadowRoot || window.__card.renderRoot).querySelectorAll(s).length, sel);
const st = () => page.evaluate(() => {
const c = window.__card;
const sr = c.shadowRoot || c.renderRoot;
return {
mode: c._mode,
modeTabs: sr.querySelectorAll('.modetab').length,
active: sr.querySelector('.modetab.active')?.textContent.trim(),
editBtns: sr.querySelectorAll('.head .btn:not(.zb)').length,
gears: sr.querySelectorAll('.tabedit').length,
markupBar: !!sr.querySelector('.editbar'),
stageClass: sr.querySelector('.stage').className,
};
});
// 1) старт: view, чистая шапка
out.start = await st();
// 2) drag в view ничего не двигает и не мешает пану
await page.evaluate(() => {
const c = window.__card;
const d = c._devices.find((x) => x.space === 'f1');
const before = { ...c._pos(d) };
c._pointerDown({ preventDefault(){}, clientX: 10, clientY: 10, target: { setPointerCapture(){} }, pointerId: 1 }, d);
c._pointerMove({ clientX: 90, clientY: 60 }, d);
c._pointerUp({}, d);
const after = { ...c._pos(d) };
window.__viewDragMoved = Math.abs(after.x - before.x) + Math.abs(after.y - before.y) > 0.5;
clearTimeout(c._holdTimer);
});
out.viewDragMoved = await page.evaluate(() => window.__viewDragMoved);
// long-press в view открывает инфо
await page.evaluate(async () => {
const c = window.__card;
const d = c._devices.find((x) => x.space === 'f1');
c._pointerDown({ preventDefault(){}, clientX: 10, clientY: 10, target: { setPointerCapture(){} }, pointerId: 2 }, d);
await new Promise((r) => setTimeout(r, 700));
});
out.viewHoldInfo = await page.evaluate(() => { const r = !!window.__card._infoCard; window.__card._infoCard = null; window.__card._holdFired = false; return r; });
// 3) режим План
await page.evaluate(() => window.__card._setMode('plan'));
await page.waitForTimeout(200);
out.plan = await st();
out.planIconsHidden = await page.evaluate(() => {
const sr = window.__card.shadowRoot || window.__card.renderRoot;
const dev = sr.querySelector('.dev');
return dev ? getComputedStyle(dev).display === 'none' : 'no-dev';
});
// 4) режим Устройства: drag работает, клик открывает редактор
await page.evaluate(() => window.__card._setMode('devices'));
await page.waitForTimeout(200);
out.devices = await st();
out.devDragWorks = await page.evaluate(() => {
const c = window.__card;
const d = c._devices.find((x) => x.space === 'f1');
const before = { ...c._pos(d) };
c._pointerDown({ preventDefault(){}, clientX: 10, clientY: 10, target: { setPointerCapture(){} }, pointerId: 3 }, d);
c._pointerMove({ clientX: 100, clientY: 70 }, d);
c._pointerUp({}, d);
const after = { ...c._pos(d) };
return Math.abs(after.x - before.x) + Math.abs(after.y - before.y) > 0.5;
});
out.devClickOpensEditor = await page.evaluate(async () => {
const c = window.__card;
c._drag = null;
const d = c._devices.find((x) => x.space === 'f1');
c._clickDevice({ stopPropagation(){} }, d);
await c.updateComplete;
const r = !!c._markerDialog;
c._markerDialog = null;
return r;
});
// 5) назад в view
await page.evaluate(() => window.__card._setMode('view'));
out.backToView = (await st()).mode;
console.log(JSON.stringify(out, null, 1));
await browser.close();
File diff suppressed because one or more lines are too long
File diff suppressed because one or more lines are too long
+62 -21
View File
File diff suppressed because one or more lines are too long
+16
View File
@@ -1,5 +1,21 @@
# Changelog # Changelog
## v1.25.0 — 2026-07-21 (three interaction modes: View / Plan / Devices)
The approved UX redesign (docs/UX-MODES.md), confirmed by user feedback (#3):
- **View** (default, and the only mode after every load): display and device
interaction only — tap/long-press/tooltips/pan/zoom. Nothing can be dragged or
edited; panning may start on top of an icon and never displaces it. The header
carries only space tabs, the counter, zoom and the mode switcher.
- **Plan**: everything about geometry and appearance — room outline/delete/merge/
split tools, openings (placement, drag along walls, click-to-edit), room-label
dragging, per-space gear dialog, add space, the ⚙ fill palette. Orange stage frame.
- **Devices**: marker work — icon dragging lives ONLY here, a click opens the
editor directly; add device, show-all curation, reset layout, icon rules. Accent frame.
- The mode switcher is a segmented control shown to administrators; the standalone
markup toggle button is gone, "drag anywhere" (v1.9) is consciously reversed,
and the v1.23.1 view-mode opening drag/double-click moved into Plan.
## v1.24.2 — 2026-07-16 (lights fill: a color for rooms with no light sources) ## v1.24.2 — 2026-07-16 (lights fill: a color for rooms with no light sources)
- The "Fill: lights" group in General settings gained a third color — **"No light - The "Fill: lights" group in General settings gained a third color — **"No light
sources"**. Its default opacity is 0, so rooms without any lights stay unfilled sources"**. Its default opacity is 0, so rooms without any lights stay unfilled
+1 -1
View File
@@ -13,7 +13,7 @@
| Item | State | | Item | State |
|---|---| |---|---|
| Version | **v1.24.2** everywhere (manifest, const.py, package.json, CARD_VERSION) | | Version | **v1.25.0** everywhere (manifest, const.py, package.json, CARD_VERSION) |
| GitHub | https://github.com/Matysh/houseplan-card — branch `main`, releases v1.9.3…**v1.23.1** (latest published 2026-07-17, bundle auto-attached by release.yml) | | GitHub | https://github.com/Matysh/houseplan-card — branch `main`, releases v1.9.3…**v1.23.1** (latest published 2026-07-17, bundle auto-attached by release.yml) |
| CI | `.github/workflows/validate.yml` (hacs + hassfest + frontend + backend) — **fully green** since v1.11.1; `release.yml` auto-attaches the card bundle (needs `permissions: contents: write`, fixed) | | CI | `.github/workflows/validate.yml` (hacs + hassfest + frontend + backend) — **fully green** since v1.11.1; `release.yml` auto-attaches the card bundle (needs `permissions: contents: write`, fixed) |
| HACS | Works as custom repository (id 1290210112 on the home instance). **Inclusion PR: https://github.com/hacs/default/pull/9004** (queue ≈2 months as of 2026-07). Lesson: #8995 was auto-closed by hacs-bot — the PR body MUST be their exact template with every checkbox ticked and all 3 links (release, HACS action run, hassfest run); a custom body gets closed without discussion | | HACS | Works as custom repository (id 1290210112 on the home instance). **Inclusion PR: https://github.com/hacs/default/pull/9004** (queue ≈2 months as of 2026-07). Lesson: #8995 was auto-closed by hacs-bot — the PR body MUST be their exact template with every checkbox ticked and all 3 links (release, HACS action run, hassfest run); a custom body gets closed without discussion |
+14
View File
@@ -29,6 +29,20 @@ Run the *core flows* (marked ★ below) in each environment at least once per mi
- [ ] Removal: delete entry → Lovelace resource entry disappears; `.storage/houseplan.*` survives; reinstall picks the old config up - [ ] Removal: delete entry → Lovelace resource entry disappears; `.storage/houseplan.*` survives; reinstall picks the old config up
- [ ] Diagnostics download works; personal fields (name/link/description/pdfs) are `**REDACTED**` [auto] - [ ] Diagnostics download works; personal fields (name/link/description/pdfs) are `**REDACTED**` [auto]
## Modes (v1.25.0) ★
- [ ] The card always loads in **View**; edit modes are never restored [auto]
- [ ] View: pan/zoom/space-switch/tap/long-press/tooltips only — dragging an icon,
label or opening does nothing; panning may start on top of an icon [auto]
- [ ] View header: space tabs + count + zoom + mode tabs, ZERO edit buttons [auto]
- [ ] Plan: markup toolbar, space gears, +space, ⚙ palette; device icons hidden,
labels/openings draggable; orange stage frame [auto]
- [ ] Devices: icon drag works, click opens the marker editor directly; +/👁/↺/⬡
buttons; accent stage frame [auto]
- [ ] Mode tabs hidden for non-admin users; segmented control highlights the active mode
- [ ] Opening: single click in View shows the info card; in Plan a click edits it;
double-click no longer does anything special [auto]
## Onboarding ★ ## Onboarding ★
- [ ] Empty config, HA has floors → floors-import wizard offers them sorted by level [auto] - [ ] Empty config, HA has floors → floors-import wizard offers them sorted by level [auto]
+1 -1
View File
@@ -1,6 +1,6 @@
{ {
"name": "houseplan-card", "name": "houseplan-card",
"version": "1.24.2", "version": "1.25.0",
"description": "Interactive house plan Lovelace card for Home Assistant", "description": "Interactive house plan Lovelace card for Home Assistant",
"license": "MIT", "license": "MIT",
"type": "module", "type": "module",
+78 -58
View File
@@ -31,7 +31,7 @@ import './space-card';
import { cardStyles } from './styles'; import { cardStyles } from './styles';
import { langOf, t, type I18nKey } from './i18n'; import { langOf, t, type I18nKey } from './i18n';
const CARD_VERSION = '1.24.2'; const CARD_VERSION = '1.25.0';
const LS_KEY = 'houseplan_card_layout_v1'; 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_CFG = 'houseplan_card_cfg_v1'; // cache of the server config+layout for instant rendering
const LS_ZOOM = 'houseplan_card_zoom_v1'; const LS_ZOOM = 'houseplan_card_zoom_v1';
@@ -81,7 +81,20 @@ class HouseplanCard extends LitElement {
private _toastTimer?: number; private _toastTimer?: number;
// --- room markup editor --- // --- room markup editor ---
private _markup = false; /** 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';
/** Edit tabs are offered to admins only (hass.user missing → assume admin). */
private get _canEdit(): boolean {
return this._norm && this.hass?.user?.is_admin !== false;
}
/** Legacy alias: markup machinery is active exactly in plan mode. */
private get _markup(): boolean {
return this._mode === 'plan';
}
private _tool: MarkupTool = 'draw'; private _tool: MarkupTool = 'draw';
private _path: number[][] = []; // current outline (render units, vertices snapped to the grid) private _path: number[][] = []; // current outline (render units, vertices snapped to the grid)
private _cursorPt: number[] | null = null; private _cursorPt: number[] | null = null;
@@ -99,7 +112,6 @@ class HouseplanCard extends LitElement {
} | null = null; } | null = null;
private _openingInfo: OpeningCfg | null = null; private _openingInfo: OpeningCfg | null = null;
private _opDrag: { id: string; moved: boolean } | null = null; private _opDrag: { id: string; moved: boolean } | null = null;
private _opClickTimer: number | null = null; // first room picked for a merge
private _mergeDialog: { aId: string; bId: string; poly: number[][]; pick: 'a' | 'b' } | null = null; private _mergeDialog: { aId: string; bId: string; poly: number[][]; pick: 'a' | 'b' } | null = null;
private _splitSel: { roomId: string; a: number[] | null } | null = null; // room being cut + first wall point private _splitSel: { roomId: string; a: number[] | null } | null = null; // room being cut + first wall point
// a split is applied only when the new room's dialog is confirmed — cancel leaves the room intact // a split is applied only when the new room's dialog is confirmed — cancel leaves the room intact
@@ -196,7 +208,7 @@ class HouseplanCard extends LitElement {
_selId: { state: true }, _selId: { state: true },
_toast: { state: true }, _toast: { state: true },
_serverCfg: { state: true }, _serverCfg: { state: true },
_markup: { state: true }, _mode: { state: true },
_tool: { state: true }, _tool: { state: true },
_path: { state: true }, _path: { state: true },
_cursorPt: { state: true }, _cursorPt: { state: true },
@@ -682,7 +694,11 @@ class HouseplanCard extends LitElement {
private _clickDevice(ev: MouseEvent, d: DevItem): void { private _clickDevice(ev: MouseEvent, d: DevItem): void {
ev.stopPropagation(); ev.stopPropagation();
if (this._drag?.moved || this._suppressClick || this._holdFired) return; if (this._drag?.moved || this._suppressClick || this._holdFired) return;
if (this._markup) return; if (this._mode === 'plan') return;
if (this._mode === 'devices') {
this._openMarkerDialog(d);
return;
}
const domain = d.primary ? d.primary.split('.')[0] : null; const domain = d.primary ? d.primary.split('.')[0] : null;
const action = resolveTapAction(d.tapAction, this._config?.tap_action, domain); const action = resolveTapAction(d.tapAction, this._config?.tap_action, domain);
if (action === 'toggle' && d.primary) { if (action === 'toggle' && d.primary) {
@@ -825,7 +841,7 @@ class HouseplanCard extends LitElement {
private _stagePointerDown(ev: PointerEvent): void { private _stagePointerDown(ev: PointerEvent): void {
// do not interfere with icon dragging and markup drawing // do not interfere with icon dragging and markup drawing
if (this._drag || this._markup) return; if (this._drag || this._markup) return;
if ((ev.target as HTMLElement).closest('.dev')) return; if (this._mode === 'devices' && (ev.target as HTMLElement).closest('.dev')) return;
this._pointers.set(ev.pointerId, { x: ev.clientX, y: ev.clientY }); this._pointers.set(ev.pointerId, { x: ev.clientX, y: ev.clientY });
const v = this._viewOr(this._spaceModel().vb); const v = this._viewOr(this._spaceModel().vb);
if (this._pointers.size === 1) { if (this._pointers.size === 1) {
@@ -858,7 +874,10 @@ class HouseplanCard extends LitElement {
} else if (this._panStart) { } else if (this._panStart) {
const ddx = ev.clientX - this._panStart.sx; const ddx = ev.clientX - this._panStart.sx;
const ddy = ev.clientY - this._panStart.sy; const ddy = ev.clientY - this._panStart.sy;
if (Math.abs(ddx) + Math.abs(ddy) > 4) this._suppressClick = true; if (Math.abs(ddx) + Math.abs(ddy) > 4) {
this._suppressClick = true;
clearTimeout(this._holdTimer);
}
if (this._zoom > 1 && this._view) { if (this._zoom > 1 && this._view) {
const stage = this._stageEl!; const stage = this._stageEl!;
const v = this._view; const v = this._view;
@@ -892,23 +911,23 @@ class HouseplanCard extends LitElement {
} }
private _pointerDown(ev: PointerEvent, d: DevItem): void { private _pointerDown(ev: PointerEvent, d: DevItem): void {
if (this._markup) return; // in markup mode icons are not dragged if (this._mode === 'plan') return; // icons are hidden in plan mode anyway
if (this._mode === 'view') {
// view: no drag, no capture — panning may start on an icon; only the
// long-press timer runs (cancelled by stage movement)
this._holdFired = false;
clearTimeout(this._holdTimer);
this._holdTimer = window.setTimeout(() => {
this._holdFired = true;
this._infoCard = d;
}, 600);
return;
}
ev.preventDefault(); ev.preventDefault();
const p = this._pos(d); const p = this._pos(d);
this._drag = { id: d.id, sx: ev.clientX, sy: ev.clientY, ox: p.x, oy: p.y, moved: false }; this._drag = { id: d.id, sx: ev.clientX, sy: ev.clientY, ox: p.x, oy: p.y, moved: false };
(ev.target as HTMLElement).setPointerCapture(ev.pointerId); (ev.target as HTMLElement).setPointerCapture(ev.pointerId);
this._tip = null; this._tip = null;
// long-press always opens the info card — keeps metadata reachable
// even when the tap action is set to toggle
this._holdFired = false;
clearTimeout(this._holdTimer);
this._holdTimer = window.setTimeout(() => {
if (this._drag && this._drag.id === d.id && !this._drag.moved) {
this._holdFired = true;
this._drag = null;
this._infoCard = d;
}
}, 600);
} }
private _pointerMove(ev: PointerEvent, d: DevItem): void { private _pointerMove(ev: PointerEvent, d: DevItem): void {
@@ -998,12 +1017,13 @@ class HouseplanCard extends LitElement {
return roomEdges(sp?.rooms || []).map((s) => [s[0] * NORM_W, s[1] * H, s[2] * NORM_W, s[3] * H]); return roomEdges(sp?.rooms || []).map((s) => [s[0] * NORM_W, s[1] * H, s[2] * NORM_W, s[3] * H]);
} }
private _toggleMarkup(): void { private _setMode(mode: 'view' | 'plan' | 'devices'): void {
if (!this._norm) { if (this._mode === mode) return;
if (mode === 'plan' && !this._norm) {
this._showToast(this._t('toast.markup_needs_server')); this._showToast(this._t('toast.markup_needs_server'));
return; return;
} }
this._markup = !this._markup; this._mode = mode;
this._path = []; this._path = [];
this._cursorPt = null; this._cursorPt = null;
this._tool = 'draw'; this._tool = 'draw';
@@ -1011,8 +1031,11 @@ class HouseplanCard extends LitElement {
this._mergeDialog = null; this._mergeDialog = null;
this._splitSel = null; this._splitSel = null;
this._pendingSplit = null; this._pendingSplit = null;
this._selId = null;
this._tip = null;
} }
private _svgPoint(ev: MouseEvent): number[] { private _svgPoint(ev: MouseEvent): number[] {
const stage = this.renderRoot.querySelector('.stage') as HTMLElement; const stage = this.renderRoot.querySelector('.stage') as HTMLElement;
const r = stage.getBoundingClientRect(); const r = stage.getBoundingClientRect();
@@ -1198,7 +1221,7 @@ class HouseplanCard extends LitElement {
/** Drag an opening along the walls (view mode): it re-snaps continuously. */ /** Drag an opening along the walls (view mode): it re-snaps continuously. */
private _opPointerDown(ev: PointerEvent, o: OpeningCfg): void { private _opPointerDown(ev: PointerEvent, o: OpeningCfg): void {
if (this._markup) return; if (this._mode !== 'plan') return;
ev.preventDefault(); ev.preventDefault();
ev.stopPropagation(); ev.stopPropagation();
try { try {
@@ -1236,25 +1259,16 @@ class HouseplanCard extends LitElement {
/** Click: the status card (delayed so a double click can cancel it). */ /** Click: the status card (delayed so a double click can cancel it). */
private _opClick(ev: MouseEvent, o: OpeningCfg & { rx: number; ry: number; rlen: number }): void { private _opClick(ev: MouseEvent, o: OpeningCfg & { rx: number; ry: number; rlen: number }): void {
ev.stopPropagation(); ev.stopPropagation();
if (this._markup) return;
if (this._opDrag?.moved) return; // that click was the tail of a drag if (this._opDrag?.moved) return; // that click was the tail of a drag
if (this._opClickTimer) window.clearTimeout(this._opClickTimer); if (this._mode === 'view') {
this._opClickTimer = window.setTimeout(() => { // view: an opening is a status object — show the door/lock info card
this._opClickTimer = null;
this._openingInfo = o; this._openingInfo = o;
}, 250); return;
} }
if (this._mode === 'plan' && this._tool !== 'opening') {
/** Double click: the properties dialog. */ // plan: any click on an opening edits it (the Opening tool also does)
private _opDblClick(ev: MouseEvent, o: OpeningCfg & { rx: number; ry: number; rlen: number }): void { this._editOpening(o);
ev.stopPropagation();
if (this._markup) return;
if (this._opClickTimer) {
window.clearTimeout(this._opClickTimer);
this._opClickTimer = null;
} }
this._openingInfo = null;
this._editOpening(o);
} }
private _saveOpening(): void { private _saveOpening(): void {
@@ -1972,7 +1986,7 @@ class HouseplanCard extends LitElement {
// guide the user onward: straight into room markup mode // guide the user onward: straight into room markup mode
this._importTotal = 0; this._importTotal = 0;
this._space = this._serverCfg!.spaces[0]?.id || this._space; this._space = this._serverCfg!.spaces[0]?.id || this._space;
this._markup = true; this._mode = 'plan';
this._tool = 'draw'; this._tool = 'draw';
this._path = []; this._path = [];
this._cursorPt = null; this._cursorPt = null;
@@ -2062,7 +2076,7 @@ class HouseplanCard extends LitElement {
else if (this._importTotal > 0 && this._model.length) { else if (this._importTotal > 0 && this._model.length) {
this._importTotal = 0; this._importTotal = 0;
this._space = this._serverCfg!.spaces[0]?.id || this._space; this._space = this._serverCfg!.spaces[0]?.id || this._space;
this._markup = true; this._mode = 'plan';
this._showToast(this._t('import.done')); this._showToast(this._t('import.done'));
} }
} }
@@ -2337,7 +2351,7 @@ class HouseplanCard extends LitElement {
this._restoreZoom(); this._restoreZoom();
}} }}
> >
${s.title}${this._norm ${s.title}${this._norm && this._mode === 'plan'
? html`<ha-icon class="tabedit" icon="mdi:cog-outline" ? html`<ha-icon class="tabedit" icon="mdi:cog-outline"
title=${this._t('title.configure_space')} title=${this._t('title.configure_space')}
@click=${(e: Event) => { @click=${(e: Event) => {
@@ -2347,13 +2361,24 @@ class HouseplanCard extends LitElement {
: nothing} : nothing}
</button>`, </button>`,
)} )}
${this._norm ${this._norm && this._mode === 'plan'
? html`<button class="tab tabadd" title=${this._t('title.add_space')} ? html`<button class="tab tabadd" title=${this._t('title.add_space')}
@click=${() => this._openSpaceDialog('create')}> @click=${() => this._openSpaceDialog('create')}>
<ha-icon icon="mdi:plus"></ha-icon> <ha-icon icon="mdi:plus"></ha-icon>
</button>` </button>`
: nothing} : nothing}
</div> </div>
${this._canEdit
? html`<div class="modes">
${([['view', 'mdi:eye-outline'], ['plan', 'mdi:floor-plan'], ['devices', 'mdi:tune-variant']] as const).map(
([m, ic]) => html`<button class="modetab ${this._mode === m ? 'active' : ''}"
title=${this._t(('mode.' + m) as any)}
@click=${() => this._setMode(m)}>
<ha-icon icon=${ic}></ha-icon><span class="ml">${this._t(('mode.' + m) as any)}</span>
</button>`,
)}
</div>`
: nothing}
<span class="count">${this._t('count.devices', { n: devs.length })}</span> <span class="count">${this._t('count.devices', { n: devs.length })}</span>
<span class="spacer"></span> <span class="spacer"></span>
<div class="zoomctl"> <div class="zoomctl">
@@ -2362,14 +2387,12 @@ class HouseplanCard extends LitElement {
title=${this._t('title.zoom_reset')}><ha-icon icon="mdi:fit-to-page-outline"></ha-icon></button> title=${this._t('title.zoom_reset')}><ha-icon icon="mdi:fit-to-page-outline"></ha-icon></button>
<button class="btn zb" @click=${() => this._stepZoom(1)} title=${this._t('title.zoom_in')}><ha-icon icon="mdi:plus"></ha-icon></button> <button class="btn zb" @click=${() => this._stepZoom(1)} title=${this._t('title.zoom_in')}><ha-icon icon="mdi:plus"></ha-icon></button>
</div> </div>
${this._norm ${this._norm && this._mode === 'devices'
? html`<button class="btn" @click=${() => this._openMarkerDialog()} ? html`<button class="btn" @click=${() => this._openMarkerDialog()}
title=${this._t('title.add_device')}> title=${this._t('title.add_device')}>
<ha-icon icon="mdi:plus-box-outline"></ha-icon> <ha-icon icon="mdi:plus-box-outline"></ha-icon>
</button>` </button>
: nothing} <button class="btn ${this._showAll ? 'on' : ''}" @click=${this._toggleShowAll}
${this._norm
? html`<button class="btn ${this._showAll ? 'on' : ''}" @click=${this._toggleShowAll}
title=${this._t('title.show_all')}> title=${this._t('title.show_all')}>
<ha-icon icon="${this._showAll ? 'mdi:eye' : 'mdi:eye-off-outline'}"></ha-icon> <ha-icon icon="${this._showAll ? 'mdi:eye' : 'mdi:eye-off-outline'}"></ha-icon>
</button> </button>
@@ -2378,20 +2401,18 @@ class HouseplanCard extends LitElement {
</button> </button>
<button class="btn" @click=${this._openRulesDialog} title=${this._t('title.icon_rules')}> <button class="btn" @click=${this._openRulesDialog} title=${this._t('title.icon_rules')}>
<ha-icon icon="mdi:shape-plus-outline"></ha-icon> <ha-icon icon="mdi:shape-plus-outline"></ha-icon>
</button> </button>`
<button class="btn" @click=${this._openSettingsDialog} title=${this._t('title.general_settings')}> : nothing}
${this._norm && this._mode === 'plan'
? html`<button class="btn" @click=${this._openSettingsDialog} title=${this._t('title.general_settings')}>
<ha-icon icon="mdi:cog-outline"></ha-icon> <ha-icon icon="mdi:cog-outline"></ha-icon>
</button>` </button>`
: nothing} : nothing}
<button class="btn ${this._markup ? 'on' : ''}" @click=${this._toggleMarkup}
title=${this._t('title.markup')}>
<ha-icon icon="mdi:vector-square-edit"></ha-icon>
</button>
</div> </div>
${this._markup ? this._renderMarkupBar() : nothing} ${this._markup ? this._renderMarkupBar() : nothing}
</div> </div>
<div class="stage ${this._markup ? 'markup' : ''} ${space.bg ? '' : 'noplan'}" <div class="stage ${this._markup ? 'markup' : ''} ${space.bg ? '' : 'noplan'} mode-${this._mode}"
style="height:calc(100dvh - 118px)" style="height:calc(100dvh - 118px)"
@click=${(e: MouseEvent) => this._markupClick(e)} @click=${(e: MouseEvent) => this._markupClick(e)}
@wheel=${(e: WheelEvent) => this._onWheel(e)} @wheel=${(e: WheelEvent) => this._onWheel(e)}
@@ -2553,7 +2574,7 @@ class HouseplanCard extends LitElement {
/** Room-name labels are dragged exactly like device icons (same layout store). */ /** Room-name labels are dragged exactly like device icons (same layout store). */
private _labelDown(ev: PointerEvent, r: RoomCfg, spaceId: string): void { private _labelDown(ev: PointerEvent, r: RoomCfg, spaceId: string): void {
if (this._markup) return; if (this._mode !== 'plan') return;
ev.preventDefault(); ev.preventDefault();
ev.stopPropagation(); ev.stopPropagation();
const p = this._labelPos(r, spaceId); const p = this._labelPos(r, spaceId);
@@ -2693,7 +2714,6 @@ class HouseplanCard extends LitElement {
<rect class="op-outline" x="${-half - 10}" y="-16" width="${o.rlen + 20}" height="32" rx="6"></rect> <rect class="op-outline" x="${-half - 10}" y="-16" width="${o.rlen + 20}" height="32" rx="6"></rect>
<rect class="op-hit" x="${-half - 12}" y="-20" width="${o.rlen + 24}" height="40" <rect class="op-hit" x="${-half - 12}" y="-20" width="${o.rlen + 24}" height="40"
@click=${(e: MouseEvent) => this._opClick(e, o)} @click=${(e: MouseEvent) => this._opClick(e, o)}
@dblclick=${(e: MouseEvent) => this._opDblClick(e, o)}
@pointerdown=${(e: PointerEvent) => this._opPointerDown(e, o)} @pointerdown=${(e: PointerEvent) => this._opPointerDown(e, o)}
@pointermove=${(e: PointerEvent) => this._opPointerMove(e, o)} @pointermove=${(e: PointerEvent) => this._opPointerMove(e, o)}
@pointerup=${(e: PointerEvent) => this._opPointerUp(e, o)} @pointerup=${(e: PointerEvent) => this._opPointerUp(e, o)}
@@ -2719,7 +2739,7 @@ class HouseplanCard extends LitElement {
const top = ((py - view.y) / view.h) * 100; const top = ((py - view.y) / view.h) * 100;
return html`<div class="oplock ${locked ? 'locked' : known ? 'unlocked' : 'unknown'}" return html`<div class="oplock ${locked ? 'locked' : known ? 'unlocked' : 'unknown'}"
style="left:${left}%;top:${top}%" style="left:${left}%;top:${top}%"
@click=${(e: MouseEvent) => { e.stopPropagation(); if (!this._markup) this._openingInfo = o; }}> @click=${(e: MouseEvent) => { e.stopPropagation(); if (this._mode === 'view') this._openingInfo = o; }}>
<ha-icon icon="${locked ? 'mdi:lock' : known ? 'mdi:lock-open-variant' : 'mdi:lock-question'}"></ha-icon> <ha-icon icon="${locked ? 'mdi:lock' : known ? 'mdi:lock-open-variant' : 'mdi:lock-question'}"></ha-icon>
</div>`; </div>`;
})}`; })}`;
+4 -1
View File
@@ -239,5 +239,8 @@
"gs.reset": "Reset to defaults", "gs.reset": "Reset to defaults",
"gs.saved": "General settings saved", "gs.saved": "General settings saved",
"space.show_lqi": "Show zigbee signal (LQI) next to devices", "space.show_lqi": "Show zigbee signal (LQI) next to devices",
"gs.light_none": "No light sources" "gs.light_none": "No light sources",
"mode.view": "View",
"mode.plan": "Plan",
"mode.devices": "Devices"
} }
+4 -1
View File
@@ -239,5 +239,8 @@
"gs.reset": "Сбросить к умолчаниям", "gs.reset": "Сбросить к умолчаниям",
"gs.saved": "Общие настройки сохранены", "gs.saved": "Общие настройки сохранены",
"space.show_lqi": "Показывать зигби-сигнал (LQI) у устройств", "space.show_lqi": "Показывать зигби-сигнал (LQI) у устройств",
"gs.light_none": "Нет источников света" "gs.light_none": "Нет источников света",
"mode.view": "Просмотр",
"mode.plan": "План",
"mode.devices": "Устройства"
} }
+42 -3
View File
@@ -341,6 +341,7 @@ export const cardStyles = css`
.ripple.active i:nth-child(n + 2) { display: none; } .ripple.active i:nth-child(n + 2) { display: none; }
} }
.roomlabel { .roomlabel {
pointer-events: none; /* draggable only in plan mode (rule below) */
position: absolute; position: absolute;
transform: translate(-50%, -50%); transform: translate(-50%, -50%);
font-size: calc(var(--icon-size, 2.5cqw) * 0.5); font-size: calc(var(--icon-size, 2.5cqw) * 0.5);
@@ -348,10 +349,10 @@ export const cardStyles = css`
letter-spacing: 0.04em; letter-spacing: 0.04em;
white-space: nowrap; white-space: nowrap;
cursor: grab; cursor: grab;
pointer-events: auto;
user-select: none; user-select: none;
z-index: 1; z-index: 1;
} }
.stage.markup .roomlabel { pointer-events: auto; }
.roomlabel:active { cursor: grabbing; } .roomlabel:active { cursor: grabbing; }
.measurelayer { .measurelayer {
position: absolute; position: absolute;
@@ -388,8 +389,46 @@ export const cardStyles = css`
.stage.markup .room { .stage.markup .room {
pointer-events: none; pointer-events: none;
} }
.stage.markup .devlayer { .stage.markup .devlayer .dev {
display: none; /* in markup mode icons must not get in the way */ display: none; /* in plan mode the icons do not get in the way; labels stay */
}
/* mode frames: the edit modes are visible at a glance */
.stage.mode-plan {
outline: 2px solid #ffc14d;
outline-offset: -2px;
}
.stage.mode-devices {
outline: 2px solid var(--hp-accent);
outline-offset: -2px;
}
.modes {
display: inline-flex;
gap: 2px;
background: rgba(127, 127, 127, 0.12);
border-radius: 10px;
padding: 3px;
}
.modetab {
display: inline-flex;
align-items: center;
gap: 5px;
border: 0;
background: transparent;
color: var(--hp-muted);
padding: 5px 10px;
border-radius: 8px;
font-size: 12.5px;
font-weight: 600;
cursor: pointer;
font-family: inherit;
}
.modetab ha-icon { --mdc-icon-size: 15px; }
.modetab.active {
background: var(--hp-accent);
color: var(--text-primary-color, #fff);
}
@media (max-width: 720px) {
.modetab .ml { display: none; }
} }
.room.picked { .room.picked {
stroke: #ffc14d; stroke: #ffc14d;