mirror of
https://github.com/Matysh/houseplan-card
synced 2026-07-31 16:38:31 +00:00
feat v1.23.1: openings — hover outline, drag along walls, double-click properties
- Hover: an accent outline hugs the opening's wall strip + grab cursor. - Drag (view mode): re-snaps continuously to the nearest derived wall; snapToWall now normalizes the angle to [-90,90) — neighbouring rooms yield the same wall in opposite directions, which used to flip the hinge while dragging across segment boundaries. setPointerCapture wrapped in try/catch (an inactive pointerId must not kill the drag). - Click still opens the status card (via a 250ms timer); double click opens the properties dialog from view mode. Hit zone is now a slightly thicker strip along the wall instead of the whole swing square (fewer accidental hovers). +1 test (77). Verified live: drag moved a test door exactly along a vertical wall (angle 90 -> -90), click/dblclick/markup-tool paths all работают; the user's 12 real openings untouched.
This commit is contained in:
@@ -11,7 +11,7 @@ PLANS_DIR = "houseplan/plans" # relative to the HA configuration directory
|
||||
FILES_URL = "/houseplan_files/files"
|
||||
FILES_DIR = "houseplan/files"
|
||||
CONF_ADMIN_ONLY = "admin_only"
|
||||
VERSION = "1.23.0"
|
||||
VERSION = "1.23.1"
|
||||
|
||||
DEFAULT_CONFIG: dict = {
|
||||
"spaces": [],
|
||||
|
||||
File diff suppressed because one or more lines are too long
@@ -16,5 +16,5 @@
|
||||
"issue_tracker": "https://github.com/Matysh/houseplan-card/issues",
|
||||
"requirements": [],
|
||||
"single_config_entry": true,
|
||||
"version": "1.23.0"
|
||||
"version": "1.23.1"
|
||||
}
|
||||
|
||||
Vendored
+44
-22
File diff suppressed because one or more lines are too long
@@ -1,5 +1,18 @@
|
||||
# Changelog
|
||||
|
||||
## v1.23.1 — 2026-07-17 (openings: hover, drag along walls, double-click properties)
|
||||
- **Hover affordance**: an accent outline hugs the opening's wall strip on hover, with a
|
||||
grab cursor — placed openings now look grabbable.
|
||||
- **Drag along walls** (view mode): an opening re-snaps continuously to the nearest derived
|
||||
wall while dragged; too far from any wall → it stays put. `snapToWall` now normalizes the
|
||||
angle to [-90, 90) — two rooms share a wall with OPPOSITE edge directions, and without this
|
||||
a drag across segment boundaries flipped the hinge side back and forth. Saved on release.
|
||||
- **Click / double click**: a single click still opens the status card (now via a 250 ms
|
||||
timer); a **double click opens the properties dialog** right from view mode. The markup
|
||||
"Opening" tool behaves as before. Hit zone made slightly thicker and is now a strip along
|
||||
the wall (previously it covered the whole swing square, causing accidental hovers).
|
||||
- (+1 test: 76 → 77.)
|
||||
|
||||
## v1.23.0 — 2026-07-17 (doors & windows with live open/lock state)
|
||||
Visual language after easy-floorplan (MIT); the placement model is ours.
|
||||
- **New markup tool "Opening"**: click next to a wall → the opening snaps onto the nearest
|
||||
|
||||
+2
-2
@@ -13,14 +13,14 @@
|
||||
|
||||
| Item | State |
|
||||
|---|---|
|
||||
| Version | **v1.23.0** everywhere (manifest, const.py, package.json, CARD_VERSION) |
|
||||
| Version | **v1.23.1** everywhere (manifest, const.py, package.json, CARD_VERSION) |
|
||||
| GitHub | https://github.com/Matysh/houseplan-card — branch `main`, releases v1.9.3…v1.11.2 |
|
||||
| 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 |
|
||||
| Brands | Ships **inside the integration**: `custom_components/houseplan/brand/{icon,icon@2x,logo,logo@2x}.png` (HA ≥2026.3 local-brands mechanism). home-assistant/brands PR #10700 was auto-closed — that repo no longer accepts custom integrations |
|
||||
| Home instance | ha.jbstudio.pro (SSH port 323, key `ha_jb`), deployed v1.11.2, installed *via HACS* (custom repo) — updates flow through HACS now |
|
||||
| Localization | UI en/ru (`src/i18n.ts`), auto by `hass.locale` + `language` card option; codebase and docs are English-first (`README.ru.md` is the Russian copy) |
|
||||
| Tests | 76 frontend (node:test, incl. a 12-test buildDevices suite on a fake hass) + 10 pure backend (anywhere) + 12 HA-harness backend (CI only, py3.13; skipped locally — sandbox has py3.10) |
|
||||
| Tests | 77 frontend (node:test, incl. a 12-test buildDevices suite on a fake hass) + 10 pure backend (anywhere) + 12 HA-harness backend (CI only, py3.13; skipped locally — sandbox has py3.10) |
|
||||
|
||||
## Recent milestones (details in CHANGELOG.md)
|
||||
|
||||
|
||||
@@ -223,3 +223,9 @@ require hands on real hardware — they remain for the human pass.
|
||||
the lock can NOT be toggled from the plan
|
||||
- [ ] Flip toggles mirror the hinge side and the swing side
|
||||
- [ ] Click an existing opening with the tool → edit dialog; Delete removes it
|
||||
- [ ] (v1.23.1) Hovering an opening in view mode shows the accent outline + grab cursor
|
||||
- [ ] Dragging an opening slides it along walls (re-snapping, incl. around corners) and saves
|
||||
on release; dragging far away from walls leaves it in place; hinge does not flip while
|
||||
crossing wall-segment boundaries
|
||||
- [ ] Single click still opens the status card; double click opens the properties dialog;
|
||||
a drag does NOT open either
|
||||
|
||||
+1
-1
@@ -1,6 +1,6 @@
|
||||
{
|
||||
"name": "houseplan-card",
|
||||
"version": "1.23.0",
|
||||
"version": "1.23.1",
|
||||
"description": "Interactive house plan Lovelace card for Home Assistant",
|
||||
"license": "MIT",
|
||||
"type": "module",
|
||||
|
||||
+90
-16
@@ -30,7 +30,7 @@ import './space-card';
|
||||
import { cardStyles } from './styles';
|
||||
import { langOf, t, type I18nKey } from './i18n';
|
||||
|
||||
const CARD_VERSION = '1.23.0';
|
||||
const CARD_VERSION = '1.23.1';
|
||||
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';
|
||||
@@ -96,7 +96,9 @@ class HouseplanCard extends LitElement {
|
||||
flipV: boolean;
|
||||
x: number; y: number; angle: number; // render units (from the wall snap)
|
||||
} | null = null;
|
||||
private _openingInfo: OpeningCfg | null = null; // first room picked for a merge
|
||||
private _openingInfo: OpeningCfg | 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 _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
|
||||
@@ -1155,17 +1157,7 @@ class HouseplanCard extends LitElement {
|
||||
(o) => Math.hypot(raw[0] - o.rx, raw[1] - o.ry) <= Math.max(o.rlen / 2, eps),
|
||||
);
|
||||
if (hit) {
|
||||
this._openingDialog = {
|
||||
id: hit.id,
|
||||
type: hit.type,
|
||||
lengthCm: Math.round((hit.rlen / this._gridPitch) * this._cellCm),
|
||||
contact: hit.contact || '',
|
||||
lock: hit.lock || '',
|
||||
invert: !!hit.invert,
|
||||
flipH: !!hit.flip_h,
|
||||
flipV: !!hit.flip_v,
|
||||
x: hit.rx, y: hit.ry, angle: hit.angle,
|
||||
};
|
||||
this._editOpening(hit);
|
||||
return;
|
||||
}
|
||||
const snap = snapToWall(raw, this._spaceModel().rooms, eps);
|
||||
@@ -1180,6 +1172,82 @@ class HouseplanCard extends LitElement {
|
||||
};
|
||||
}
|
||||
|
||||
/** Open the properties dialog for an existing opening. */
|
||||
private _editOpening(o: OpeningCfg & { rx: number; ry: number; rlen: number }): void {
|
||||
this._openingDialog = {
|
||||
id: o.id,
|
||||
type: o.type,
|
||||
lengthCm: Math.round((o.rlen / this._gridPitch) * this._cellCm),
|
||||
contact: o.contact || '',
|
||||
lock: o.lock || '',
|
||||
invert: !!o.invert,
|
||||
flipH: !!o.flip_h,
|
||||
flipV: !!o.flip_v,
|
||||
x: o.rx, y: o.ry, angle: o.angle,
|
||||
};
|
||||
}
|
||||
|
||||
/** Drag an opening along the walls (view mode): it re-snaps continuously. */
|
||||
private _opPointerDown(ev: PointerEvent, o: OpeningCfg): void {
|
||||
if (this._markup) return;
|
||||
ev.preventDefault();
|
||||
ev.stopPropagation();
|
||||
try {
|
||||
(ev.target as Element).setPointerCapture?.(ev.pointerId);
|
||||
} catch {
|
||||
/* an inactive pointerId (synthetic events, some browsers) must not kill the drag */
|
||||
}
|
||||
this._opDrag = { id: o.id, moved: false };
|
||||
}
|
||||
|
||||
private _opPointerMove(ev: PointerEvent, o: OpeningCfg): void {
|
||||
if (!this._opDrag || this._opDrag.id !== o.id) return;
|
||||
const raw = this._svgPoint(ev);
|
||||
const snap = snapToWall(raw, this._spaceModel().rooms, this._gridPitch * 4);
|
||||
if (!snap) return; // too far from any wall: the opening stays where it was
|
||||
this._opDrag.moved = true;
|
||||
const sp = this._curSpaceCfg;
|
||||
const cfg = sp?.openings?.find((x: OpeningCfg) => x.id === o.id);
|
||||
if (!cfg) return;
|
||||
cfg.x = snap.x / NORM_W;
|
||||
cfg.y = snap.y / this._spaceH;
|
||||
cfg.angle = snap.angle;
|
||||
this.requestUpdate();
|
||||
}
|
||||
|
||||
private _opPointerUp(ev: PointerEvent, o: OpeningCfg): void {
|
||||
if (!this._opDrag || this._opDrag.id !== o.id) return;
|
||||
const moved = this._opDrag.moved;
|
||||
if (moved) this._saveConfig();
|
||||
// keep the flag until the click event that follows pointerup, then let it go
|
||||
if (moved) window.setTimeout(() => (this._opDrag = null), 0);
|
||||
else this._opDrag = null;
|
||||
}
|
||||
|
||||
/** 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 {
|
||||
ev.stopPropagation();
|
||||
if (this._markup) return;
|
||||
if (this._opDrag?.moved) return; // that click was the tail of a drag
|
||||
if (this._opClickTimer) window.clearTimeout(this._opClickTimer);
|
||||
this._opClickTimer = window.setTimeout(() => {
|
||||
this._opClickTimer = null;
|
||||
this._openingInfo = o;
|
||||
}, 250);
|
||||
}
|
||||
|
||||
/** Double click: the properties dialog. */
|
||||
private _opDblClick(ev: MouseEvent, o: OpeningCfg & { rx: number; ry: number; rlen: number }): void {
|
||||
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 {
|
||||
const d = this._openingDialog;
|
||||
const sp = this._curSpaceCfg;
|
||||
@@ -2517,14 +2585,20 @@ class HouseplanCard extends LitElement {
|
||||
</g>
|
||||
</g>`;
|
||||
}
|
||||
return svg`<g transform="translate(${o.rx} ${o.ry}) rotate(${o.angle})">
|
||||
return svg`<g class="opening" transform="translate(${o.rx} ${o.ry}) rotate(${o.angle})">
|
||||
<g transform="scale(${sx} ${sy})">
|
||||
<line x1="${-half}" y1="${-jamb / 2}" x2="${-half}" y2="${jamb / 2}" stroke="${base}" stroke-width="2.5"></line>
|
||||
<line x1="${half}" y1="${-jamb / 2}" x2="${half}" y2="${jamb / 2}" stroke="${base}" stroke-width="2.5"></line>
|
||||
${body}
|
||||
</g>
|
||||
<rect class="op-hit" x="${-half - 6}" y="${-o.rlen - 8}" width="${o.rlen + 12}" height="${o.rlen * 2 + 16}"
|
||||
@click=${(e: MouseEvent) => { e.stopPropagation(); if (!this._markup) this._openingInfo = o; }}></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"
|
||||
@click=${(e: MouseEvent) => this._opClick(e, o)}
|
||||
@dblclick=${(e: MouseEvent) => this._opDblClick(e, o)}
|
||||
@pointerdown=${(e: PointerEvent) => this._opPointerDown(e, o)}
|
||||
@pointermove=${(e: PointerEvent) => this._opPointerMove(e, o)}
|
||||
@pointerup=${(e: PointerEvent) => this._opPointerUp(e, o)}
|
||||
@pointercancel=${(e: PointerEvent) => this._opPointerUp(e, o)}></rect>
|
||||
</g>`;
|
||||
})}`;
|
||||
}
|
||||
|
||||
+7
-1
@@ -97,7 +97,13 @@ export function snapToWall(
|
||||
const d = Math.hypot(p[0] - q[0], p[1] - q[1]);
|
||||
if (d < bestD) {
|
||||
bestD = d;
|
||||
best = { x: q[0], y: q[1], angle: (Math.atan2(dy, dx) * 180) / Math.PI };
|
||||
// normalize to [-90, 90): two rooms sharing a wall yield the same edge in
|
||||
// OPPOSITE directions — without this, dragging an opening across segment
|
||||
// boundaries would flip its hinge side back and forth
|
||||
let angle = (Math.atan2(dy, dx) * 180) / Math.PI;
|
||||
if (angle >= 90) angle -= 180;
|
||||
else if (angle < -90) angle += 180;
|
||||
best = { x: q[0], y: q[1], angle };
|
||||
}
|
||||
}
|
||||
return best;
|
||||
|
||||
+17
-1
@@ -233,10 +233,26 @@ export const cardStyles = css`
|
||||
stroke-width: 1.5;
|
||||
transition: stroke-dashoffset 0.6s ease;
|
||||
}
|
||||
/* hover affordance: a rounded outline hugging the wall strip + a grab cursor */
|
||||
.op-outline {
|
||||
fill: none;
|
||||
stroke: var(--hp-accent);
|
||||
stroke-width: 1.5;
|
||||
opacity: 0;
|
||||
transition: opacity 0.15s;
|
||||
pointer-events: none;
|
||||
}
|
||||
g.opening:hover .op-outline {
|
||||
opacity: 0.9;
|
||||
}
|
||||
.op-hit {
|
||||
fill: transparent;
|
||||
cursor: pointer;
|
||||
cursor: grab;
|
||||
pointer-events: auto;
|
||||
touch-action: none; /* drags, not scrolls, on touch */
|
||||
}
|
||||
.op-hit:active {
|
||||
cursor: grabbing;
|
||||
}
|
||||
.stage.markup .op-hit {
|
||||
pointer-events: none; /* markup clicks go to the stage tools */
|
||||
|
||||
@@ -432,3 +432,15 @@ test('openingAmount: doors default open, windows closed; outages freeze the defa
|
||||
assert.equal(openingAmount('door', 'off', true), 1);
|
||||
assert.equal(openingAmount('door', 'unavailable', true), 1);
|
||||
});
|
||||
|
||||
test('snapToWall: the angle is normalized to [-90, 90) so opposite edge directions cannot flip a dragged opening', () => {
|
||||
// the same wall as seen from two neighbouring rooms (opposite winding)
|
||||
const a = [{ poly: [[0, 0], [10, 0], [10, 10], [0, 10]] }];
|
||||
const b = [{ poly: [[10, 0], [0, 0], [0, 10], [10, 10]] }]; // reversed
|
||||
const sa = snapToWall([5, 0.4], a, 1);
|
||||
const sb = snapToWall([5, 0.4], b, 1);
|
||||
assert.equal(sa.angle, sb.angle);
|
||||
assert.ok(sa.angle >= -90 && sa.angle < 90);
|
||||
const v = snapToWall([10.3, 5], a, 1); // vertical wall
|
||||
assert.ok(v.angle >= -90 && v.angle < 90);
|
||||
});
|
||||
|
||||
Reference in New Issue
Block a user