mirror of
https://github.com/Matysh/houseplan-card
synced 2026-10-02 04:38:55 +00:00
@@ -0,0 +1,68 @@
|
||||
export interface DevicePlacement {
|
||||
x: number;
|
||||
y: number;
|
||||
s?: string;
|
||||
}
|
||||
|
||||
export interface DeviceLayoutEntry extends Record<string, unknown> {
|
||||
x: number;
|
||||
y: number;
|
||||
s?: string;
|
||||
k?: number;
|
||||
}
|
||||
|
||||
export type DeviceLayout = Record<string, DeviceLayoutEntry>;
|
||||
|
||||
export interface DevicePositionState {
|
||||
deviceId: string;
|
||||
spaceId: string;
|
||||
placement: DevicePlacement | null;
|
||||
}
|
||||
|
||||
export function devicePlacement(layout: DeviceLayout, deviceId: string): DevicePlacement | null {
|
||||
const entry = layout[deviceId];
|
||||
if (!entry || !Number.isFinite(entry.x) || !Number.isFinite(entry.y)) return null;
|
||||
return {
|
||||
x: entry.x,
|
||||
y: entry.y,
|
||||
...(typeof entry.s === 'string' ? { s: entry.s } : {}),
|
||||
};
|
||||
}
|
||||
|
||||
export function sameDevicePlacement(
|
||||
left: DevicePlacement | null,
|
||||
right: DevicePlacement | null,
|
||||
): boolean {
|
||||
if (left === null || right === null) return left === right;
|
||||
return left.x === right.x && left.y === right.y && left.s === right.s;
|
||||
}
|
||||
|
||||
/**
|
||||
* Apply only placement fields. Existing/future sibling fields deliberately
|
||||
* survive an update; a null placement removes the complete persisted position
|
||||
* record because the layout schema does not permit a record without x/y.
|
||||
*/
|
||||
export function applyDevicePlacement(
|
||||
layout: DeviceLayout,
|
||||
deviceId: string,
|
||||
placement: DevicePlacement | null,
|
||||
): DeviceLayout {
|
||||
if (placement === null) {
|
||||
if (!(deviceId in layout)) return layout;
|
||||
const next = { ...layout };
|
||||
delete next[deviceId];
|
||||
return next;
|
||||
}
|
||||
|
||||
const current = { ...(layout[deviceId] || {}) } as Record<string, unknown>;
|
||||
delete current.x;
|
||||
delete current.y;
|
||||
delete current.s;
|
||||
const entry: DeviceLayoutEntry = {
|
||||
...current,
|
||||
x: placement.x,
|
||||
y: placement.y,
|
||||
...(placement.s !== undefined ? { s: placement.s } : {}),
|
||||
};
|
||||
return { ...layout, [deviceId]: entry };
|
||||
}
|
||||
+277
-50
@@ -241,6 +241,14 @@ import { hasTranslation, langOf, t, type I18nKey } from './i18n';
|
||||
import { LANGUAGE_RUNTIME, subscribeLanguageLoadFailures } from './i18n/registry';
|
||||
import { languageLoadingTemplate, languageRenderGate } from './i18n/language-runtime';
|
||||
import { CommandStack } from './command-stack';
|
||||
import {
|
||||
applyDevicePlacement,
|
||||
devicePlacement,
|
||||
sameDevicePlacement,
|
||||
type DeviceLayout,
|
||||
type DevicePlacement,
|
||||
type DevicePositionState,
|
||||
} from './device-position-history';
|
||||
import { resolvedSvgScreenBlend, svgScreenBlendSupported } from './glow-blend';
|
||||
import {
|
||||
buildGlowClipGeometry, buildLightBarrierScene, createGlowRuntimeState,
|
||||
@@ -789,6 +797,21 @@ interface DeviceInboxDialogState {
|
||||
|
||||
type FixedFloorState = FixedFloorSelection | { kind: 'pending'; value: unknown };
|
||||
|
||||
interface DeviceDragState {
|
||||
id: string;
|
||||
spaceId: string;
|
||||
displayName: string;
|
||||
pointerId: number;
|
||||
source: Element | null;
|
||||
sx: number;
|
||||
sy: number;
|
||||
ox: number;
|
||||
oy: number;
|
||||
moved: boolean;
|
||||
before: DevicePlacement | null;
|
||||
start: DevicePlacement;
|
||||
}
|
||||
|
||||
export class HouseplanCard extends LitElement {
|
||||
public _editorRuntime: import('./houseplan-editor-runtime').HouseplanEditorRuntime | null = null;
|
||||
public _onboardingRuntime: import('./houseplan-onboarding-runtime').HouseplanOnboardingRuntime | null = null;
|
||||
@@ -925,7 +948,7 @@ export class HouseplanCard extends LitElement {
|
||||
private _config?: CardConfig;
|
||||
|
||||
private _space = 'f1';
|
||||
private _layout: Record<string, { x: number; y: number; s?: string; k?: number }> = {};
|
||||
private _layout: DeviceLayout = {};
|
||||
private _serverStorage = false;
|
||||
private _loadOk = false;
|
||||
/** null until config/get answers; then mirrors auth.may_write for this user. */
|
||||
@@ -1702,6 +1725,9 @@ export class HouseplanCard extends LitElement {
|
||||
private _tool: MarkupTool = 'draw';
|
||||
/** UX-04: one named, 50-step command history for every plan-geometry tool. */
|
||||
private _geometryHistory = new CommandStack<SpaceGeometryState>(50);
|
||||
/** #74: independent session-local history for manual device placements. */
|
||||
private _devicePositionHistory = new CommandStack<DevicePositionState>(50);
|
||||
private _devicePositionBusy = false;
|
||||
/** Wall-thickness tool dialog (docs/WALL-THICKNESS.md). */
|
||||
private _wallDialog: {
|
||||
a: number[]; b: number[];
|
||||
@@ -2401,6 +2427,7 @@ export class HouseplanCard extends LitElement {
|
||||
};
|
||||
|
||||
private _drag: { id: string; sx: number; sy: number; ox: number; oy: number; moved: boolean } | null = null;
|
||||
private _deviceDrag: DeviceDragState | null = null;
|
||||
private _rlResize: { id: string; space: string; k0: number; cx: number; cy: number; d0: number } | null = null;
|
||||
private _holdTimer?: number;
|
||||
private _holdFired = false;
|
||||
@@ -2642,6 +2669,7 @@ export class HouseplanCard extends LitElement {
|
||||
this._bootTimer = undefined; // AUD-1552-01: a cleared id must not block the reconnect watcher
|
||||
clearTimeout(this._bootSoftTimer);
|
||||
this._saveConfigDebounced.flush(); // never leave an edit unsent on teardown
|
||||
this._cancelDeviceDrag();
|
||||
window.removeEventListener('hashchange', this._onHashChange);
|
||||
this._labsUnsub?.();
|
||||
this._labsUnsub = undefined;
|
||||
@@ -2813,6 +2841,24 @@ export class HouseplanCard extends LitElement {
|
||||
}
|
||||
return;
|
||||
}
|
||||
if (this._mode === 'devices') {
|
||||
if ((undo || redo) && inField) return;
|
||||
if (redo) {
|
||||
e.preventDefault();
|
||||
this._redoDevicePosition();
|
||||
return;
|
||||
}
|
||||
if (undo) {
|
||||
e.preventDefault();
|
||||
this._undoDevicePosition();
|
||||
return;
|
||||
}
|
||||
if (e.key === 'Escape' && this._deviceDrag) {
|
||||
e.preventDefault();
|
||||
this._cancelDeviceDrag();
|
||||
}
|
||||
return;
|
||||
}
|
||||
if (!this._markup) return;
|
||||
if ((undo || redo) && inField) return; // keep native text-field history
|
||||
if ((e.key === 'Delete' || e.key === 'Backspace') && this._physicalSel
|
||||
@@ -3810,6 +3856,7 @@ export class HouseplanCard extends LitElement {
|
||||
this._pinchStart = null;
|
||||
this._swipeStart = null;
|
||||
this._drag = null;
|
||||
this._deviceDrag = null;
|
||||
this._rlResize = null;
|
||||
this._vacFit = null;
|
||||
this._compassDrag = false;
|
||||
@@ -3817,6 +3864,7 @@ export class HouseplanCard extends LitElement {
|
||||
this._mode = 'view';
|
||||
this._clearGeometryGesture();
|
||||
this._geometryHistory.clear();
|
||||
this._devicePositionHistory.clear();
|
||||
this._resumeDraftBySpace = {};
|
||||
this._tip = null;
|
||||
this._hoverRoom = null;
|
||||
@@ -4045,6 +4093,8 @@ export class HouseplanCard extends LitElement {
|
||||
// A genuinely different baseline invalidates local geometry undo. A
|
||||
// reconnect echo with identical content deliberately does not.
|
||||
this._geometryHistory.clear();
|
||||
this._devicePositionHistory.clear();
|
||||
this._cancelDeviceDrag();
|
||||
this._pendingPhysicalWrites.clear();
|
||||
if (this._serverCfg) this._clearGeometryGesture();
|
||||
this._serverCfg = nextConfig;
|
||||
@@ -4073,6 +4123,8 @@ export class HouseplanCard extends LitElement {
|
||||
layoutChanged = nextLayoutFingerprint !== (this._layoutContentFingerprint
|
||||
|| contentFingerprint(this._layout));
|
||||
if (layoutChanged) {
|
||||
this._devicePositionHistory.clear();
|
||||
this._cancelDeviceDrag();
|
||||
this._layout = nextLayout;
|
||||
this._layoutContentFingerprint = nextLayoutFingerprint;
|
||||
}
|
||||
@@ -4741,12 +4793,18 @@ export class HouseplanCard extends LitElement {
|
||||
const resp = await this.hass.callWS({ type: 'houseplan/layout/get' });
|
||||
const remote = resp?.layout || {};
|
||||
const merged: Record<string, any> = { ...remote };
|
||||
for (const [id, pos] of mine) merged[id] = pos;
|
||||
const fingerprint = contentFingerprint(merged);
|
||||
if (fingerprint !== (this._layoutContentFingerprint || contentFingerprint(this._layout))) {
|
||||
this._layout = merged;
|
||||
this._layoutContentFingerprint = fingerprint;
|
||||
for (const [id, pos] of mine) {
|
||||
if (pos === null) delete merged[id];
|
||||
else merged[id] = pos;
|
||||
}
|
||||
const fingerprint = contentFingerprint(merged);
|
||||
const differsFromCurrent = fingerprint !== contentFingerprint(this._layout);
|
||||
if (differsFromCurrent) {
|
||||
this._cancelDeviceDrag();
|
||||
this._devicePositionHistory.clear();
|
||||
this._layout = merged;
|
||||
}
|
||||
this._layoutContentFingerprint = fingerprint;
|
||||
this._layoutRev = resp?.rev ?? this._layoutRev;
|
||||
this._canOptimizeUndo = !!resp?.can_optimize_undo;
|
||||
this._haIntegrationVersion = typeof resp?.integration_version === 'string'
|
||||
@@ -4764,7 +4822,12 @@ export class HouseplanCard extends LitElement {
|
||||
|
||||
private _dirtyPos = new Set<string>();
|
||||
/** Positions sent to the server and not acknowledged yet (HP-1461-02). */
|
||||
private _sentPos = new Map<string, { s?: string; x: number; y: number }>();
|
||||
private _sentPos = new Map<string, DeviceLayout[string] | null>();
|
||||
|
||||
private _persistLocalLayout(): void {
|
||||
this._layout = canonicalizeLayoutGeometry(this._layout);
|
||||
localStorage.setItem(LS_KEY, JSON.stringify(this._layout));
|
||||
}
|
||||
|
||||
private _persistLayout = debounce(() => {
|
||||
if (this._serverStorage) {
|
||||
@@ -4786,8 +4849,7 @@ export class HouseplanCard extends LitElement {
|
||||
}
|
||||
this._cacheSnapshot();
|
||||
} else {
|
||||
this._layout = canonicalizeLayoutGeometry(this._layout);
|
||||
localStorage.setItem(LS_KEY, JSON.stringify(this._layout));
|
||||
this._persistLocalLayout();
|
||||
}
|
||||
}, 600);
|
||||
|
||||
@@ -4850,8 +4912,10 @@ export class HouseplanCard extends LitElement {
|
||||
|| this._devices.find((d) => d.id === this._infoCard?.id)?.bindingStatus?.kind === 'ha_disabled') {
|
||||
this._closeInfoCard();
|
||||
}
|
||||
if (this._drag && this._devices.find((d) => d.id === this._drag!.id)?.bindingStatus?.kind === 'ha_disabled') {
|
||||
this._drag = null;
|
||||
if (this._deviceDrag
|
||||
&& this._devices.find((d) => d.id === this._deviceDrag!.id)?.bindingStatus?.kind === 'ha_disabled') {
|
||||
this._cancelDeviceDrag();
|
||||
this._devicePositionHistory.clear();
|
||||
}
|
||||
clearTimeout(this._holdTimer);
|
||||
this._holdFired = false;
|
||||
@@ -4998,24 +5062,135 @@ export class HouseplanCard extends LitElement {
|
||||
return snapPt(spaceCenter(s));
|
||||
}
|
||||
|
||||
private _devicePlacementForCanvas(d: DevItem, x: number, y: number): DevicePlacement {
|
||||
if (!this._norm) return { x: Math.round(x), y: Math.round(y) };
|
||||
const g = this._gridPitch;
|
||||
const gx = Math.round(x / g) * g;
|
||||
const gy = Math.round(y / g) * g;
|
||||
return {
|
||||
s: d.space,
|
||||
x: clampCanvasN(gx / NORM_W),
|
||||
y: clampCanvasN(gy / NORM_W),
|
||||
};
|
||||
}
|
||||
|
||||
private _previewDevicePlacement(deviceId: string, placement: DevicePlacement | null): void {
|
||||
this._layout = applyDevicePlacement(this._layout, deviceId, placement);
|
||||
this.requestUpdate();
|
||||
}
|
||||
|
||||
/** Abort an uncommitted device preview. Returns true when a drag existed. */
|
||||
private _cancelDeviceDrag(): boolean {
|
||||
const drag = this._deviceDrag;
|
||||
if (!drag) return false;
|
||||
this._deviceDrag = null;
|
||||
try {
|
||||
if (drag.source?.hasPointerCapture?.(drag.pointerId)) {
|
||||
drag.source.releasePointerCapture(drag.pointerId);
|
||||
}
|
||||
} catch {
|
||||
// Pointer capture may already have ended before the cancellation signal.
|
||||
}
|
||||
this._previewDevicePlacement(drag.id, drag.before);
|
||||
return true;
|
||||
}
|
||||
|
||||
/** One serial, point-wise position write used by drag commit and history. */
|
||||
private async _persistDevicePlacement(
|
||||
deviceId: string,
|
||||
placement: DevicePlacement | null,
|
||||
): Promise<void> {
|
||||
this._layout = applyDevicePlacement(this._layout, deviceId, placement);
|
||||
if (this._serverStorage) {
|
||||
let pending: DeviceLayout[string] | null = null;
|
||||
let registered = false;
|
||||
try {
|
||||
let response: unknown;
|
||||
if (placement === null) {
|
||||
pending = null;
|
||||
this._sentPos.set(deviceId, pending);
|
||||
registered = true;
|
||||
response = await this.hass.callWS({
|
||||
type: 'houseplan/layout/delete', device_id: deviceId,
|
||||
});
|
||||
} else {
|
||||
const pos = canonicalizePosition(this._layout[deviceId]);
|
||||
pending = pos;
|
||||
this._sentPos.set(deviceId, pending);
|
||||
registered = true;
|
||||
response = await this.hass.callWS({
|
||||
type: 'houseplan/layout/update', device_id: deviceId, pos,
|
||||
});
|
||||
}
|
||||
this._noteLayoutRev(response);
|
||||
this._layoutContentFingerprint = contentFingerprint(this._layout);
|
||||
this._cacheSnapshot();
|
||||
} finally {
|
||||
if (registered && this._sentPos.get(deviceId) === pending) this._sentPos.delete(deviceId);
|
||||
}
|
||||
return;
|
||||
}
|
||||
this._persistLocalLayout();
|
||||
}
|
||||
|
||||
private _devicePositionStateValid(state: DevicePositionState): boolean {
|
||||
const device = this._devices.find((candidate) => candidate.id === state.deviceId);
|
||||
return !!device
|
||||
&& device.space === state.spaceId
|
||||
&& device.bindingStatus?.kind !== 'ha_disabled'
|
||||
&& !!this._spaceModelById(state.spaceId);
|
||||
}
|
||||
|
||||
private async _runDevicePositionHistory(direction: 'undo' | 'redo'): Promise<void> {
|
||||
if (this._cancelDeviceDrag() || this._devicePositionBusy) return;
|
||||
const command = direction === 'undo'
|
||||
? this._devicePositionHistory.undo()
|
||||
: this._devicePositionHistory.redo();
|
||||
if (!command) return;
|
||||
const target = direction === 'undo' ? command.before : command.after;
|
||||
if (!this._devicePositionStateValid(target)) {
|
||||
this._devicePositionHistory.clear();
|
||||
this._showToast(this._t('history.device_stale'));
|
||||
this.requestUpdate();
|
||||
return;
|
||||
}
|
||||
|
||||
const rollback = devicePlacement(this._layout, target.deviceId);
|
||||
if (target.spaceId !== this._space) {
|
||||
this._commitSpace(target.spaceId);
|
||||
this._restoreZoom();
|
||||
}
|
||||
this._devicePositionBusy = true;
|
||||
this._previewDevicePlacement(target.deviceId, target.placement);
|
||||
try {
|
||||
await this._persistDevicePlacement(target.deviceId, target.placement);
|
||||
this._showToast(this._t(direction === 'undo' ? 'history.undone' : 'history.redone', {
|
||||
name: command.name,
|
||||
}));
|
||||
} catch (error: unknown) {
|
||||
this._previewDevicePlacement(target.deviceId, rollback);
|
||||
if (direction === 'undo') this._devicePositionHistory.redo();
|
||||
else this._devicePositionHistory.undo();
|
||||
this._showToast(this._t('toast.pos_save_failed', { err: this._errText(error) }));
|
||||
} finally {
|
||||
this._devicePositionBusy = false;
|
||||
this.requestUpdate();
|
||||
}
|
||||
}
|
||||
|
||||
private _undoDevicePosition(): void {
|
||||
void this._runDevicePositionHistory('undo');
|
||||
}
|
||||
|
||||
private _redoDevicePosition(): void {
|
||||
void this._runDevicePositionHistory('redo');
|
||||
}
|
||||
|
||||
private _savePos(d: DevItem, x: number, y: number): void {
|
||||
if (!this._spaceModelById(d.space)) return;
|
||||
if (this._norm) {
|
||||
// The icon center snaps to the nodes of the same grid as the room markup
|
||||
// (docs/CANVAS.md §9). UX-05 has no free-position escape hatch.
|
||||
const g = this._gridPitch;
|
||||
const gx = Math.round(x / g) * g;
|
||||
const gy = Math.round(y / g) * g;
|
||||
|
||||
const prevK = (this._layout[d.id] as any)?.k;
|
||||
this._layout = {
|
||||
...this._layout,
|
||||
[d.id]: { s: d.space, x: clampCanvasN(gx / NORM_W), y: clampCanvasN(gy / NORM_W),
|
||||
...(prevK ? { k: prevK } : {}) },
|
||||
};
|
||||
} else {
|
||||
this._layout = { ...this._layout, [d.id]: { x: Math.round(x), y: Math.round(y) } };
|
||||
}
|
||||
this._layout = applyDevicePlacement(
|
||||
this._layout, d.id, this._devicePlacementForCanvas(d, x, y),
|
||||
);
|
||||
this._dirtyPos.add(d.id);
|
||||
this._persistLayout();
|
||||
}
|
||||
@@ -5191,7 +5366,7 @@ export class HouseplanCard extends LitElement {
|
||||
// if a future CSS change accidentally makes a marker a hit target again.
|
||||
if (this._mode !== 'view' && this._mode !== 'devices') return;
|
||||
ev.stopPropagation();
|
||||
if (this._drag?.moved || this._suppressClick || this._holdFired) return;
|
||||
if (this._deviceDrag?.moved || this._suppressClick || this._holdFired) return;
|
||||
if (this._mode === 'devices') {
|
||||
this._openMarkerDialog(d);
|
||||
return;
|
||||
@@ -6103,7 +6278,7 @@ export class HouseplanCard extends LitElement {
|
||||
}
|
||||
}
|
||||
// do not interfere with icon and label dragging
|
||||
if (this._drag) return;
|
||||
if (this._drag || this._deviceDrag) return;
|
||||
if (this._markup) {
|
||||
// Drawing is CLICK-based, so gestures coexist with it: a finger that
|
||||
// MOVES pans, two fingers pinch, and _suppressClick keeps the release
|
||||
@@ -6370,26 +6545,44 @@ export class HouseplanCard extends LitElement {
|
||||
}
|
||||
// A disabled ghost is a service entry point, not a movable plan object.
|
||||
// Leave the click intact so the settings dialog still opens.
|
||||
if (d.bindingStatus?.kind === 'ha_disabled') return;
|
||||
if (d.bindingStatus?.kind === 'ha_disabled' || this._devicePositionBusy) return;
|
||||
if (this._deviceDrag) {
|
||||
if (this._deviceDrag.pointerId !== ev.pointerId) this._cancelDeviceDrag();
|
||||
return;
|
||||
}
|
||||
ev.preventDefault();
|
||||
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._deviceDrag = {
|
||||
id: d.id,
|
||||
spaceId: d.space,
|
||||
displayName: d.name,
|
||||
pointerId: ev.pointerId,
|
||||
source: ev.currentTarget as Element | null,
|
||||
sx: ev.clientX,
|
||||
sy: ev.clientY,
|
||||
ox: p.x,
|
||||
oy: p.y,
|
||||
moved: false,
|
||||
before: devicePlacement(this._layout, d.id),
|
||||
start: this._devicePlacementForCanvas(d, p.x, p.y),
|
||||
};
|
||||
capturePointer(ev);
|
||||
this._tip = null;
|
||||
}
|
||||
|
||||
private _pointerMove(ev: PointerEvent, d: DevItem): void {
|
||||
if (this._mode !== 'devices') return;
|
||||
if (!this._drag || this._drag.id !== d.id) return;
|
||||
const drag = this._deviceDrag;
|
||||
if (!drag || drag.id !== d.id || drag.pointerId !== ev.pointerId) return;
|
||||
const stage = this.renderRoot.querySelector('.stage') as HTMLElement;
|
||||
if (!stage) return;
|
||||
const vb = this._baseVb();
|
||||
const rect = stage.getBoundingClientRect();
|
||||
const v = this._viewOr(vb);
|
||||
const dx = ((ev.clientX - this._drag.sx) / rect.width) * v.w;
|
||||
const dy = ((ev.clientY - this._drag.sy) / rect.height) * v.h;
|
||||
if (Math.abs(ev.clientX - this._drag.sx) + Math.abs(ev.clientY - this._drag.sy) > 3) {
|
||||
this._drag.moved = true;
|
||||
const dx = ((ev.clientX - drag.sx) / rect.width) * v.w;
|
||||
const dy = ((ev.clientY - drag.sy) / rect.height) * v.h;
|
||||
if (Math.abs(ev.clientX - drag.sx) + Math.abs(ev.clientY - drag.sy) > 3) {
|
||||
drag.moved = true;
|
||||
clearTimeout(this._holdTimer);
|
||||
}
|
||||
// DEV-B58-01. This used to be clamped into `vb` — the CONTENT FRAME — with
|
||||
@@ -6399,21 +6592,54 @@ export class HouseplanCard extends LitElement {
|
||||
// where the new room was going to be. The plan has no edges any more
|
||||
// (docs/CANVAS.md §9); the only bound is the garbage limit the backend
|
||||
// enforces, and it is the SAME ±5000 on both sides of the wire.
|
||||
const nx = clampCanvasR(this._drag.ox + dx);
|
||||
const ny = clampCanvasR(this._drag.oy + dy);
|
||||
this._savePos(d, nx, ny);
|
||||
const nx = clampCanvasR(drag.ox + dx);
|
||||
const ny = clampCanvasR(drag.oy + dy);
|
||||
this._previewDevicePlacement(d.id, this._devicePlacementForCanvas(d, nx, ny));
|
||||
}
|
||||
|
||||
private _pointerUp(_ev: PointerEvent, d: DevItem): void {
|
||||
private _pointerUp(ev: PointerEvent, d: DevItem): void {
|
||||
clearTimeout(this._holdTimer);
|
||||
if (this._mode !== 'devices') return;
|
||||
if (!this._drag || this._drag.id !== d.id) return;
|
||||
const moved = this._drag.moved;
|
||||
this._drag = moved ? this._drag : null;
|
||||
if (moved) {
|
||||
this._selId = d.id;
|
||||
window.setTimeout(() => (this._drag = null), 0);
|
||||
const drag = this._deviceDrag;
|
||||
if (!drag || drag.id !== d.id || drag.pointerId !== ev.pointerId) return;
|
||||
this._deviceDrag = null;
|
||||
const after = devicePlacement(this._layout, d.id);
|
||||
if (!drag.moved || after === null || sameDevicePlacement(after, drag.start)) {
|
||||
this._previewDevicePlacement(d.id, drag.before);
|
||||
return;
|
||||
}
|
||||
|
||||
this._selId = d.id;
|
||||
this._suppressClick = true;
|
||||
window.setTimeout(() => { this._suppressClick = false; }, 0);
|
||||
this._devicePositionBusy = true;
|
||||
this.requestUpdate();
|
||||
void this._persistDevicePlacement(d.id, after)
|
||||
.then(() => {
|
||||
const name = this._t('history.device_move', { name: drag.displayName });
|
||||
this._devicePositionHistory.push({
|
||||
name,
|
||||
before: {
|
||||
deviceId: drag.id, spaceId: drag.spaceId, placement: drag.before,
|
||||
},
|
||||
after: {
|
||||
deviceId: drag.id, spaceId: drag.spaceId, placement: after,
|
||||
},
|
||||
});
|
||||
})
|
||||
.catch((error: unknown) => {
|
||||
this._previewDevicePlacement(d.id, drag.before);
|
||||
this._showToast(this._t('toast.pos_save_failed', { err: this._errText(error) }));
|
||||
})
|
||||
.finally(() => {
|
||||
this._devicePositionBusy = false;
|
||||
this.requestUpdate();
|
||||
});
|
||||
}
|
||||
|
||||
private _pointerCancel(ev: PointerEvent, d: DevItem): void {
|
||||
if (this._deviceDrag?.id !== d.id || this._deviceDrag.pointerId !== ev.pointerId) return;
|
||||
this._cancelDeviceDrag();
|
||||
}
|
||||
|
||||
private _showToast(msg: string): void {
|
||||
@@ -6576,7 +6802,7 @@ export class HouseplanCard extends LitElement {
|
||||
): void {
|
||||
this._notePointer(ev);
|
||||
if (!this._pointerModality.hoverEnabled) return;
|
||||
if (this._drag) return;
|
||||
if (this._drag || this._deviceDrag) return;
|
||||
this._tip = { x: ev.clientX, y: ev.clientY, title, meta, lqi, temp, hum };
|
||||
}
|
||||
|
||||
@@ -11804,7 +12030,8 @@ export class HouseplanCard extends LitElement {
|
||||
this._showTip(e, d.name, presentation.haDisabled ? ghostLabel : metrics);
|
||||
}}
|
||||
@pointerup=${(e: PointerEvent) => this._pointerUp(e, d)}
|
||||
@pointercancel=${(e: PointerEvent) => this._pointerUp(e, d)}
|
||||
@pointercancel=${(e: PointerEvent) => this._pointerCancel(e, d)}
|
||||
@lostpointercapture=${(e: PointerEvent) => this._pointerCancel(e, d)}
|
||||
>
|
||||
${renderDeviceFace(presentation, {
|
||||
surface: 'interactive-plan',
|
||||
@@ -12127,8 +12354,8 @@ export class HouseplanCard extends LitElement {
|
||||
}
|
||||
return null;
|
||||
}
|
||||
if (this._mode === 'devices' && this._drag?.moved) {
|
||||
const d = this._devices.find((x) => x.id === this._drag!.id);
|
||||
if (this._mode === 'devices' && this._deviceDrag?.moved) {
|
||||
const d = this._devices.find((x) => x.id === this._deviceDrag!.id);
|
||||
return d ? (() => { const p = this._pos(d); return [p.x, p.y]; })() : null;
|
||||
}
|
||||
if (this._mode === 'decor') {
|
||||
|
||||
@@ -245,6 +245,7 @@ import { enqueueSerializedWrite } from './serialized-write-queue';
|
||||
import { hasTranslation, langOf, t, type I18nKey } from './i18n';
|
||||
import { classifyPlanFile, encodePlanFile, renderBackdropGuard } from './backdrop-pick';
|
||||
import { CommandStack } from './command-stack';
|
||||
import type { DeviceLayout, DevicePositionState } from './device-position-history';
|
||||
import { resolvedSvgScreenBlend, svgScreenBlendSupported } from './glow-blend';
|
||||
import {
|
||||
CONTINUITY_LONG_HIDDEN_MS,
|
||||
@@ -854,6 +855,7 @@ export interface HouseplanEditorHostPort {
|
||||
_canEdit: boolean;
|
||||
_canOptimizeUndo: boolean;
|
||||
_cancelDevicePressFeedback: () => void;
|
||||
_cancelDeviceDrag: () => boolean;
|
||||
_cancelModeTransition: (commitTarget?: boolean) => void;
|
||||
_candidateDeviceSnapshot: RenderDeviceSnapshot | null;
|
||||
_capturedSnapshotConfigEpoch: number;
|
||||
@@ -898,6 +900,8 @@ export interface HouseplanEditorHostPort {
|
||||
_deviceInboxMemo: { key: string; rows: DeviceInboxRow[]; } | null;
|
||||
_deviceInboxReturn: DeviceInboxDialogState | null;
|
||||
_devicePresentation: (d: DevItem, showLqi?: boolean, designPreview?: boolean) => ResolvedDevicePresentation;
|
||||
_devicePositionBusy: boolean;
|
||||
_devicePositionHistory: CommandStack<DevicePositionState>;
|
||||
_devices: DevItem[];
|
||||
_dirtyPos: Set<string>;
|
||||
_display: (url: string | null | undefined) => string;
|
||||
@@ -963,7 +967,7 @@ export interface HouseplanEditorHostPort {
|
||||
_kioskScale: { icon: number; font: number; };
|
||||
_labsIso: boolean;
|
||||
_lastValidStageSize: [number, number] | null;
|
||||
_layout: Record<string, { x: number; y: number; s?: string; k?: number; }>;
|
||||
_layout: DeviceLayout;
|
||||
_layoutRev: number;
|
||||
_logicalViewCenter: (projection: "flat" | "iso") => { x: number; y: number; } | null;
|
||||
_markerDialog: { devId?: string; uploadId?: string; name: string; binding: string; bindingMode: "virtual" | "ha"; bindingOpen: boolean; showEntities: boolean; bindingFilter: string; icon: string; autoIcon: string; display: DeviceDisplayMode; rippleColor: string; rippleSize: number; size: number; angle: number; tapAction: string; tapActionTouched: boolean; originalHasTapAction: boolean; originalTapAction: string | null | undefined; tapHintAnnouncement: string; toggleEntity: string; toggleEntityTouched: boolean; originalHasToggleEntity: boolean; originalToggleEntity: string | null | undefined; tapTarget: string; tapConfirm: boolean; runFilter: string; controls: string[]; controlsFilter: string; glowRadius: string; lightRole: "auto" | "always" | "never"; lightRoleTouched: boolean; originalHasIsLight: boolean; originalIsLight: boolean | null | undefined; lightEntity: string; lightEntityTouched: boolean; originalHasLightEntity: boolean; originalLightEntity: string | null | undefined; glowMode: "auto" | "color" | "fixed"; glowColor: string; glowBrightness: number; glowColorDrafted: boolean; glowBrightnessDrafted: boolean; glowTouched: boolean; originalHasGlowColor: boolean; originalGlowColor: { c: string; bri?: number | null; } | null | undefined; valueBadgeEnabled: boolean; valueBadgeSource: ValueBadgeSource | null; valueBadgePosition: ValueBadgePosition; valueBadgeTouched: boolean; originalHasValueBadge: boolean; originalValueBadge: MarkerValueBadge | null | undefined; valueSource: ValueBadgeSource | null; valueSourceTouched: boolean; originalHasValueSource: boolean; originalValueSource: ValueBadgeSource | null | undefined; useClimateTemp: boolean; model: string; link: string; description: string; pdfs: PdfRef[]; room: string; hideFromPlan: boolean; busy: boolean; } | null;
|
||||
@@ -1052,6 +1056,7 @@ export interface HouseplanEditorHostPort {
|
||||
ResizePreview, ResizeLiveLabel[], SpaceGeometryState, ResizeWallUnion, ResizeWallArtifact
|
||||
>;
|
||||
_restoreZoom: () => void;
|
||||
_redoDevicePosition: () => void;
|
||||
_resumeDraftBySpace: Record<string, string>;
|
||||
_rlResize: { id: string; space: string; k0: number; cx: number; cy: number; d0: number; } | null;
|
||||
_roomCenter: (r: RoomCfg) => number[];
|
||||
@@ -1075,7 +1080,7 @@ export interface HouseplanEditorHostPort {
|
||||
_screenToVb: (sx: number, sy: number) => number[];
|
||||
_segments: number[][];
|
||||
_selId: string | null;
|
||||
_sentPos: Map<string, { s?: string; x: number; y: number; }>;
|
||||
_sentPos: Map<string, DeviceLayout[string] | null>;
|
||||
_serverCfg: ServerConfig | null;
|
||||
_serverStorage: boolean;
|
||||
_settings: { exclude_integrations?: string[]; group_lights?: boolean; show_all?: boolean; filter_seeded?: boolean; icon_rules?: { pattern: string; icon: string; }[]; };
|
||||
@@ -1097,6 +1102,7 @@ export interface HouseplanEditorHostPort {
|
||||
_suppressClick: boolean;
|
||||
_swipeStart: { x: number; y: number; id: number; } | null;
|
||||
_t: (key: I18nKey, vars?: Record<string, string | number>) => string;
|
||||
_undoDevicePosition: () => void;
|
||||
_thickWallCuts: () => number[][];
|
||||
_tip: { x: number; y: number; title: string; meta: string; lqi?: number | null; temp?: number | null; hum?: number | null; } | null;
|
||||
_toggleConfirmationLines: (intent: ResolvedToggleIntent) => string[];
|
||||
@@ -1212,6 +1218,7 @@ public _setMode(mode: 'view' | 'plan' | 'devices' | 'decor', animate = true): vo
|
||||
}
|
||||
return;
|
||||
}
|
||||
if (this.host._mode === 'devices') this.host._cancelDeviceDrag();
|
||||
this.host._bootSoftCancel(); // navigation owns its own short, bounded transition
|
||||
if ((mode === 'plan' || mode === 'decor') && !this.host._norm) {
|
||||
this.host._showToast(this.host._t('toast.markup_needs_server'));
|
||||
@@ -8315,6 +8322,8 @@ public async _saveMarker(): Promise<void> {
|
||||
.catch(() => undefined); // leftovers are harmless; broken links are not
|
||||
}
|
||||
this._closeMarkerDialog();
|
||||
this.host._cancelDeviceDrag();
|
||||
this.host._devicePositionHistory.clear();
|
||||
this.host._regSignature = '';
|
||||
this.host._maybeRebuildDevices();
|
||||
this.host._showToast(this.host._t('toast.marker_saved'));
|
||||
@@ -8377,6 +8386,8 @@ public async _deleteMarker(): Promise<void> {
|
||||
this.host._deviceInboxReturn = { ...this.host._deviceInboxReturn, tab: 'readd', anchor: binding };
|
||||
}
|
||||
this._closeMarkerDialog();
|
||||
this.host._cancelDeviceDrag();
|
||||
this.host._devicePositionHistory.clear();
|
||||
if (this.host._infoCard?.id === targetId) this.host._closeInfoCard();
|
||||
if (this.host._selId === targetId) this.host._selId = null;
|
||||
if (this.host._drag?.id === targetId) this.host._drag = null;
|
||||
@@ -9127,6 +9138,8 @@ public async _runAlignToGrid(): Promise<void> {
|
||||
this.host._serverCfg = d.config;
|
||||
this.host._layout = d.layout;
|
||||
this.host._geometryHistory.clear();
|
||||
this.host._cancelDeviceDrag();
|
||||
this.host._devicePositionHistory.clear();
|
||||
this.host._cfgRev = resp?.config_rev ?? this.host._cfgRev + 1;
|
||||
this.host._layoutRev = resp?.layout_rev ?? this.host._layoutRev + 1;
|
||||
this.host._canOptimizeUndo = !!resp?.can_undo;
|
||||
@@ -9187,6 +9200,8 @@ public async _undoPlanOptimization(): Promise<void> {
|
||||
this.host._cfgRev = cfgResp?.rev ?? this.host._cfgRev;
|
||||
this.host._layout = layResp?.layout || this.host._layout;
|
||||
this.host._geometryHistory.clear();
|
||||
this.host._cancelDeviceDrag();
|
||||
this.host._devicePositionHistory.clear();
|
||||
this.host._layoutRev = layResp?.rev ?? this.host._layoutRev;
|
||||
this.host._canOptimizeUndo = false;
|
||||
this.host._undoKind = null;
|
||||
@@ -11741,6 +11756,14 @@ public _renderMarkupBar(): TemplateResult {
|
||||
}
|
||||
|
||||
public _renderDevicesBar(): TemplateResult {
|
||||
const undoName = this.host._devicePositionHistory.undoName;
|
||||
const redoName = this.host._devicePositionHistory.redoName;
|
||||
const undoTitle = undoName
|
||||
? this.host._t('history.undo_named', { name: undoName })
|
||||
: this.host._t('history.undo_empty');
|
||||
const redoTitle = redoName
|
||||
? this.host._t('history.redo_named', { name: redoName })
|
||||
: this.host._t('history.redo_empty');
|
||||
return html`<div class="editbar devbar">
|
||||
<div class="editbar-tools" tabindex="-1" ?inert=${this.host._modeTransitionBusy}>
|
||||
<ha-icon icon="mdi:tune-variant" class="warn"></ha-icon>
|
||||
@@ -11758,6 +11781,18 @@ public _renderDevicesBar(): TemplateResult {
|
||||
${this.host._editorToolbarGroups.map((group) => this._renderEditorGroupLauncher(group))}
|
||||
</div>
|
||||
<div class="editbar-end">
|
||||
<button class="btn ghost" data-device-position-history="undo"
|
||||
@click=${() => this.host._undoDevicePosition()}
|
||||
?disabled=${this.host._devicePositionBusy || !undoName}
|
||||
title=${undoTitle} aria-label=${undoTitle}>
|
||||
<ha-icon icon="mdi:undo-variant" aria-hidden="true"></ha-icon>
|
||||
</button>
|
||||
<button class="btn ghost" data-device-position-history="redo"
|
||||
@click=${() => this.host._redoDevicePosition()}
|
||||
?disabled=${this.host._devicePositionBusy || !redoName}
|
||||
title=${redoTitle} aria-label=${redoTitle}>
|
||||
<ha-icon icon="mdi:redo-variant" aria-hidden="true"></ha-icon>
|
||||
</button>
|
||||
<button class="btn barclose" title=${this.host._t('title.close_editor')}
|
||||
data-editor-navigation="view"
|
||||
@click=${() => this._setMode('view')}>
|
||||
|
||||
@@ -126,6 +126,8 @@
|
||||
"history.decor_transform": "Dekorobjekt transformieren",
|
||||
"history.decor_delete": "Dekorobjekt löschen",
|
||||
"history.backdrop_transform": "Plan-Hintergrund transformieren",
|
||||
"history.device_move": "{name} verschieben",
|
||||
"history.device_stale": "Der Positionsverlauf ist nicht mehr anwendbar und wurde gelöscht",
|
||||
"opening.new": "Neue Öffnung",
|
||||
"opening.edit": "Öffnung",
|
||||
"opening.door": "Tür",
|
||||
|
||||
@@ -126,6 +126,8 @@
|
||||
"history.decor_transform": "Transform decor object",
|
||||
"history.decor_delete": "Delete decor object",
|
||||
"history.backdrop_transform": "Transform plan backdrop",
|
||||
"history.device_move": "Move {name}",
|
||||
"history.device_stale": "Device position history is no longer applicable and was cleared",
|
||||
"opening.new": "New opening",
|
||||
"opening.edit": "Opening",
|
||||
"opening.door": "Door",
|
||||
|
||||
@@ -126,6 +126,8 @@
|
||||
"history.decor_transform": "Transformer l’objet décoratif",
|
||||
"history.decor_delete": "Supprimer l’objet décoratif",
|
||||
"history.backdrop_transform": "Transformer l’arrière-plan du plan",
|
||||
"history.device_move": "Déplacer {name}",
|
||||
"history.device_stale": "L’historique des positions n’est plus applicable et a été effacé",
|
||||
"opening.new": "Nouvelle ouverture",
|
||||
"opening.edit": "Ouverture",
|
||||
"opening.door": "Porte",
|
||||
|
||||
@@ -126,6 +126,8 @@
|
||||
"history.decor_transform": "Трансформация объекта декора",
|
||||
"history.decor_delete": "Удаление объекта декора",
|
||||
"history.backdrop_transform": "Трансформация подложки плана",
|
||||
"history.device_move": "Перемещение: {name}",
|
||||
"history.device_stale": "История позиций больше неприменима и была очищена",
|
||||
"opening.new": "Новый проём",
|
||||
"opening.edit": "Проём",
|
||||
"opening.door": "Дверь",
|
||||
|
||||
Reference in New Issue
Block a user