feat: add device position undo history

Issue: #74
User-Visible: yes
This commit is contained in:
Matysh
2026-08-30 13:16:27 +00:00
committed by claude[bot]
parent 857537c620
commit bdf81fad58
55 changed files with 1091 additions and 281 deletions
+68
View File
@@ -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
View File
@@ -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') {
+37 -2
View File
@@ -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')}>
+2
View File
@@ -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",
+2
View File
@@ -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",
+2
View File
@@ -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",
+2
View File
@@ -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": "Дверь",