feat(editor): перетаскивание кнопки настроек комнаты (#645)

Кнопка сохраняет положение в пределах комнаты только на время сессии редактора,
корректно отменяется при pinch/cancel и учитывается подписями Resize.

Issue: #645
User-Visible: yes
This commit is contained in:
Sergey Matyunin
2026-09-25 08:33:07 +03:00
parent 27ba3f2ee0
commit 62d0f50593
82 changed files with 1360 additions and 658 deletions
+6 -6
View File
@@ -1912,7 +1912,7 @@ export class HouseplanCard extends LitElement {
* a synthetic tap on a device, room link, opening badge or editor control.
*/
private _touchContacts = new Map<number, { x: number; y: number; inStage: boolean }>();
private readonly _touchClickGuard = new TouchGestureClickGuard();
private readonly _touchClickGuard = new TouchGestureClickGuard(); private _roomGearTouchNavigation = false;
private get _touchSequenceMultitouch(): boolean {
return this._touchClickGuard.sequenceMultitouch;
}
@@ -2739,7 +2739,7 @@ export class HouseplanCard extends LitElement {
// #369(д) r2-H1: the Shift listeners hold the runtime (and the card) in
// their closure — a disconnected card must not stay pinned to window.
this._editorRuntime?._furnShiftDetach();
this._touchContacts.clear();
this._touchContacts.clear(); this._roomGearTouchNavigation = false;
this._touchClickGuard.reset();
this._resetDeviceHitState();
this._clearTransientHover(true);
@@ -7175,7 +7175,7 @@ export class HouseplanCard extends LitElement {
y: pointer.clientY,
inStage: !!(pointer.target as Element | null)?.closest?.('.stage'),
});
if (this._touchContacts.size >= 2) {
if (this._touchContacts.size >= 2) { if (this._editorRuntime?._cancelRoomGearForMultitouch()) this._roomGearTouchNavigation = true;
this._deviceHits.clearPointers();
this._clearRoomFocus(true);
this._clearTransientHover();
@@ -7185,7 +7185,7 @@ export class HouseplanCard extends LitElement {
// Viewing is the guaranteed touch surface. Seed its existing stage
// pinch pipeline even when a child swallowed the first pointerdown.
const contacts = [...this._touchContacts.values()];
if (this._mode === 'view' && !this._vacFit
if ((this._mode === 'view' || this._roomGearTouchNavigation) && !this._vacFit
&& contacts.every((contact) => contact.inStage)) {
this._pointers = new Map([...this._touchContacts].map(([id, contact]) => [
id, { x: contact.x, y: contact.y },
@@ -7208,7 +7208,7 @@ export class HouseplanCard extends LitElement {
if (!contact) return;
const next = { ...contact, x: pointer.clientX, y: pointer.clientY };
this._touchContacts.set(pointer.pointerId, next);
if (this._touchSequenceMultitouch && this._mode === 'view' && !this._vacFit && this._pinchStart
if (this._touchSequenceMultitouch && (this._mode === 'view' || this._roomGearTouchNavigation) && !this._vacFit && this._pinchStart
&& [...this._touchContacts.values()].every((item) => item.inStage)) {
this._activateSafeDayCycleOutline(); this._pointers.set(pointer.pointerId, { x: pointer.clientX, y: pointer.clientY });
const contacts = [...this._touchContacts.values()];
@@ -7230,7 +7230,7 @@ export class HouseplanCard extends LitElement {
pointer.pointerId, pointer.pointerType,
);
this._clearTransientHover();
this._touchContacts.delete(pointer.pointerId);
this._touchContacts.delete(pointer.pointerId); if (!this._touchContacts.size) this._roomGearTouchNavigation = false;
if (wasMultitouch) {
this._pointers.delete(pointer.pointerId);
if (!this._vacFit && this._pointers.size >= 2) {
+33 -15
View File
@@ -69,6 +69,7 @@ import {
placeResizeAreaLabel, resizeMeasuredEdges,
type ResizeAreaPlacement,
} from './resize-labels';
import { RoomGearDragController } from './room-gear-drag';
import { northDegOf, bgModeOf, sunRaysOn, sunRayOriginOf, type SunRayOrigin } from './sun';
import {
furnitureDefaultCm,
@@ -812,6 +813,7 @@ export class HouseplanEditorRuntime {
private _supportExpiryTimer?: number;
private _supportPreviewGeneration = 0;
private _decorAssetGuardReplace: boolean | null = null;
public readonly roomGear: RoomGearDragController;
private readonly _decorImages: DecorImageEditor<SpaceGeometryState | null>;
// #592: маркерный диалог уехал в src/editors/marker-dialog.ts и обращается
// к контроллеру оттуда. Остальной класс использует `public _x` — поле
@@ -820,6 +822,15 @@ export class HouseplanEditorRuntime {
/** #642: «Оптимизировать планы» — свой модуль с узким портом, не делегаты карточки. */
public readonly optimizePlans: OptimizePlansDialog;
public constructor(public readonly host: HouseplanEditorHostPort) {
const owner = host;
this.roomGear = new RoomGearDragController({
mode: () => owner._mode, spaceId: () => owner._space,
currentRoom: (roomId) => owner._spaceModel()?.rooms.find((room) => room.id === roomId),
planPoint: (event) => this._svgPoint(event),
queueMove: (run) => this._queuePointerMove('room-gear', run), flushMove: () => this._flushPointerMove('room-gear'),
cancelMove: () => this._cancelPointerMove('room-gear'), requestUpdate: () => owner.requestUpdate(),
openRoom: (room) => this._openRoomEdit(room),
});
this._decorImages = new DecorImageEditor(host, {
decorSnap: (raw, pointerType) => this._decorSnap(raw, pointerType),
geometrySnapshot: () => this._geometrySnapshot(),
@@ -876,12 +887,14 @@ export class HouseplanEditorRuntime {
>();
}
public _routeLiveEditorUpdate(name?: PropertyKey, oldValue?: unknown): boolean {
const gearSpace = this.host._mode === 'plan' ? this.host._spaceModel() : null;
if (gearSpace) this.roomGear.adoptPlan(gearSpace);
const live = routeHouseplanEditorUpdate(this.host, name, oldValue);
if (!live && this.host._resize.preview) this._resizeBaseFrameStable = false;
return live;
}
public _commitLiveEditor(): void { commitHouseplanEditor(this.host); }
public _disposeLiveEditor(): void { this._radarSetup.reset(); disposeHouseplanEditor(this.host); }
public _disposeLiveEditor(): void { this._radarSetup.reset(); this.roomGear.reset(); disposeHouseplanEditor(this.host); }
public _cancelRadarSetup(): void { this._radarSetup.interrupt(); }
public async _whenLiveEditorSettled(): Promise<void> {
// An already queued pointer calculation runs before this continuation.
@@ -996,6 +1009,7 @@ public _setMode(mode: 'view' | 'plan' | 'devices' | 'decor', animate = true): vo
}
}
this.host._mode = mode;
if (previousMode === 'plan' && mode !== 'plan') this.roomGear.reset();
if (previousMode === 'devices' && mode !== 'devices') {
this.host._showHidden = false;
this.host._deviceInbox = null;
@@ -3416,6 +3430,10 @@ public _rszEdgeLabels(
);
for (const id of ids) {
const poly = res.polys[id] || rooms.find((r) => r.id === id)!.poly;
const room = space?.rooms.find((candidate) => candidate.id === id);
const gearCenter = room
? (this.roomGear.center(room, poly, space!.id) || poleOfInaccessibility(poly))
: poleOfInaccessibility(poly);
// The preview is already the active render model. Reuse the same shared
// masonry union + contour cache that the following render consumes;
// rebuilding both independently here doubled one Resize frame.
@@ -3435,7 +3453,7 @@ public _rszEdgeLabels(
edge: plan.edgeByRoom[id],
text,
view,
gearCenter: poleOfInaccessibility(poly),
gearCenter,
gearWidthPx,
gearHeightPx,
});
@@ -10254,28 +10272,28 @@ public _rlResizeUp(): void {
this.host._persistLayout();
}
public _cancelRoomGearForMultitouch(): boolean { return this.roomGear.cancelForMultitouch(); }
public _renderRoomGear(
r: RoomCfg, space: SpaceModel, view: { x: number; y: number; w: number; h: number },
): TemplateResult | typeof nothing {
if (!r.id) return nothing;
let c: number[] | null = null;
if (r.poly) {
// the VISUAL centre (largest inscribed circle) — interiorPoint only
// promises "inside", which sat visibly off-centre on an L-shaped room.
// The model is memoized, so the poly array is a stable cache key.
c = this.host._gearPtCache.get(r.poly) || null;
if (!c) { c = poleOfInaccessibility(r.poly); this.host._gearPtCache.set(r.poly, c); }
} else if (r.x != null && r.y != null) {
c = [r.x + (r.w || 0) / 2, r.y + (r.h || 0) / 2];
}
this.roomGear.adoptPlan(space);
const c = this.roomGear.center(r, roomPoly(r), space.id);
if (!c) return nothing;
const left = ((c[0] - view.x) / view.w) * 100;
const top = ((c[1] - view.y) / view.h) * 100;
return html`<button class="rlgearbtn" data-hp="room-settings" data-room=${r.id}
const dragging = this.roomGear.dragging(this.roomGear.key(r.id, space.id));
return html`<button class="rlgearbtn ${dragging ? 'dragging' : ''}"
data-hp="room-settings" data-room=${r.id}
style="left:${left}%;top:${top}%"
title=${this.host._t('room.settings_title')}
@pointerdown=${(e: Event) => e.stopPropagation()}
@click=${(e: Event) => { e.stopPropagation(); this._openRoomEdit(r); }}>
@pointerdown=${(e: PointerEvent) => this.roomGear.pointerDown(e, r)}
@pointermove=${(e: PointerEvent) => this.roomGear.pointerMove(e)}
@pointerup=${(e: PointerEvent) => this.roomGear.pointerUp(e)}
@pointercancel=${(e: PointerEvent) => this.roomGear.pointerCancel(e)}
@lostpointercapture=${(e: PointerEvent) => this.roomGear.pointerCancel(e)}
@click=${(e: MouseEvent) => this.roomGear.click(e, r)}>
<ha-icon icon="mdi:cog-outline"></ha-icon>
<span class="rlgeartext">${this.host._t('room.settings_short')}</span>
</button>`;
+326
View File
@@ -0,0 +1,326 @@
import { pointInPolygon, pointOnBoundary, poleOfInaccessibility, roomPoly } from './logic';
import type { RoomCfg, SpaceModel } from './types';
export type RoomGearPoint = [number, number];
/** A click stays a click until the pointer travels more than three CSS pixels. */
export const ROOM_GEAR_DRAG_THRESHOLD_PX = 3;
const finitePoint = (point: readonly number[] | null | undefined): point is readonly [number, number] =>
!!point && point.length >= 2 && Number.isFinite(point[0]) && Number.isFinite(point[1]);
export function roomGearPointAllowed(
point: readonly number[] | null | undefined,
polygon: readonly number[][] | null | undefined,
): point is readonly [number, number] {
if (!finitePoint(point) || !polygon?.length) return false;
const candidate = [point[0], point[1]];
return pointInPolygon(candidate, polygon as number[][])
|| pointOnBoundary(candidate, polygon as number[][]);
}
export function roomGearAutoCenter(
room: RoomCfg,
polygonOverride?: number[][] | null,
): RoomGearPoint | null {
const polygon = polygonOverride ?? roomPoly(room);
if (!polygon?.length) return null;
const point = poleOfInaccessibility(polygon);
return finitePoint(point) ? [point[0], point[1]] : null;
}
export function resolveRoomGearCenter(
room: RoomCfg,
temporary: readonly number[] | null | undefined,
polygonOverride?: number[][] | null,
): { point: RoomGearPoint | null; usedTemporary: boolean } {
const polygon = polygonOverride ?? roomPoly(room);
if (roomGearPointAllowed(temporary, polygon)) {
return { point: [temporary[0], temporary[1]], usedTemporary: true };
}
return { point: roomGearAutoCenter(room, polygon), usedTemporary: false };
}
export function roomGearDragMoved(
startClient: readonly [number, number],
currentClient: readonly [number, number],
): boolean {
return Math.abs(currentClient[0] - startClient[0])
+ Math.abs(currentClient[1] - startClient[1]) > ROOM_GEAR_DRAG_THRESHOLD_PX;
}
const interpolate = (
start: readonly [number, number], target: readonly [number, number], t: number,
): RoomGearPoint => [
start[0] + (target[0] - start[0]) * t,
start[1] + (target[1] - start[1]) * t,
];
const parameterOnSegment = (
point: readonly number[], start: readonly [number, number], target: readonly [number, number],
): number => {
const dx = target[0] - start[0];
const dy = target[1] - start[1];
const denom = dx * dx + dy * dy;
if (denom <= 1e-18) return 0;
return ((point[0] - start[0]) * dx + (point[1] - start[1]) * dy) / denom;
};
/** Parameters where the drag segment meets one polygon edge. */
const segmentEdgeParameters = (
start: readonly [number, number], target: readonly [number, number],
a: readonly number[], b: readonly number[],
): number[] => {
const rx = target[0] - start[0];
const ry = target[1] - start[1];
const sx = b[0] - a[0];
const sy = b[1] - a[1];
const qpx = a[0] - start[0];
const qpy = a[1] - start[1];
const cross = rx * sy - ry * sx;
const qpr = qpx * ry - qpy * rx;
const eps = 1e-9;
if (Math.abs(cross) <= eps) {
if (Math.abs(qpr) > eps) return [];
return [parameterOnSegment(a, start, target), parameterOnSegment(b, start, target)]
.filter((t) => t >= -eps && t <= 1 + eps)
.map((t) => Math.max(0, Math.min(1, t)));
}
const t = (qpx * sy - qpy * sx) / cross;
const u = (qpx * ry - qpy * rx) / cross;
return t >= -eps && t <= 1 + eps && u >= -eps && u <= 1 + eps
? [Math.max(0, Math.min(1, t))]
: [];
};
/**
* Move along the pointer path only while it remains in the room.
*
* Testing the target alone is insufficient for a concave room: two allowed
* points may have a segment that crosses a cut-out. Polygon-edge parameters
* split the path into intervals, so the first outside interval is also the
* exact first boundary where the button must stop.
*/
export function clampRoomGearPointAlongPath(
start: readonly [number, number],
target: readonly [number, number],
polygon: readonly number[][],
): RoomGearPoint {
if (!finitePoint(start) || !finitePoint(target) || polygon.length < 3) return [start[0], start[1]];
if (start[0] === target[0] && start[1] === target[1]) return [start[0], start[1]];
const parameters = [0, 1];
for (let index = 0; index < polygon.length; index += 1) {
parameters.push(...segmentEdgeParameters(
start, target, polygon[index], polygon[(index + 1) % polygon.length],
));
}
parameters.sort((a, b) => a - b);
const unique = parameters.filter((value, index) =>
index === 0 || Math.abs(value - parameters[index - 1]) > 1e-8);
for (let index = 0; index < unique.length - 1; index += 1) {
const from = unique[index];
const to = unique[index + 1];
if (to - from <= 1e-8) continue;
const midpoint = interpolate(start, target, (from + to) / 2);
if (!roomGearPointAllowed(midpoint, polygon)) return interpolate(start, target, from);
}
return roomGearPointAllowed(target, polygon)
? [target[0], target[1]]
: [start[0], start[1]];
}
type RoomGearDragState = {
key: string;
roomId: string;
pointerId: number;
pointerType: string;
source: Element | null;
startClient: [number, number];
beforeTemporary: RoomGearPoint | null;
current: RoomGearPoint;
moved: boolean;
};
export interface RoomGearDragPort {
mode: () => string;
spaceId: () => string;
currentRoom: (roomId: string) => RoomCfg | undefined;
planPoint: (event: PointerEvent) => number[];
queueMove: (run: () => void) => void;
flushMove: () => void;
cancelMove: () => void;
requestUpdate: () => void;
openRoom: (room: RoomCfg) => void;
}
const capturePointer = (event: PointerEvent): void => {
try {
(event.currentTarget as Element | null)?.setPointerCapture?.(event.pointerId);
} catch {
/* an inactive synthetic pointer must not kill a drag */
}
};
/** Session-only owner of the draggable Room settings capsule (#645). */
export class RoomGearDragController {
public readonly positions = new Map<string, RoomGearPoint>();
public drag: RoomGearDragState | null = null;
private readonly planKeys = new Map<string, string>();
private suppressClickUntil = 0;
public constructor(private readonly input: RoomGearDragPort) {}
public key(roomId: string, spaceId = this.input.spaceId()): string {
return `${spaceId}\u0000${roomId}`;
}
public adoptPlan(space: SpaceModel): void {
const identity = `${space.bg?.href || ''}\u0000${space.vb.join(',')}`;
const previous = this.planKeys.get(space.id);
const prefix = `${space.id}\u0000`;
if (previous !== undefined && previous !== identity) {
for (const key of this.positions.keys()) if (key.startsWith(prefix)) this.positions.delete(key);
if (this.drag?.key.startsWith(prefix)) this.clearDrag(false);
}
this.planKeys.set(space.id, identity);
const roomIds = new Set(space.rooms.flatMap((room) => room.id ? [room.id] : []));
for (const key of this.positions.keys()) {
if (key.startsWith(prefix) && !roomIds.has(key.slice(prefix.length))) this.positions.delete(key);
}
}
public center(
room: RoomCfg, polygonOverride?: number[][] | null, spaceId = this.input.spaceId(),
): RoomGearPoint | null {
if (!room.id) return null;
const key = this.key(room.id, spaceId);
const temporary = this.positions.get(key);
const resolved = resolveRoomGearCenter(room, temporary, polygonOverride);
if (temporary && !resolved.usedTemporary) this.positions.delete(key);
return resolved.point;
}
public dragging(key: string): boolean { return this.drag?.key === key; }
public reset(): void {
this.finish(true);
this.positions.clear();
this.planKeys.clear();
this.suppressClickUntil = 0;
}
public cancelForMultitouch(): boolean {
if (this.drag?.pointerType !== 'touch') return false;
return this.finish(true);
}
public pointerDown(event: PointerEvent, room: RoomCfg): void {
if (this.input.mode() !== 'plan' || !room.id || event.button !== 0
|| (event.pointerType === 'touch' && event.isPrimary === false)) return;
event.stopPropagation();
this.suppressClickUntil = 0;
if (this.drag) this.finish(true);
const polygon = roomPoly(room);
const current = this.center(room, polygon);
if (!current || !polygon) return;
const key = this.key(room.id);
const before = this.positions.get(key);
this.drag = {
key, roomId: room.id, pointerId: event.pointerId, pointerType: event.pointerType,
source: event.currentTarget as Element | null,
startClient: [event.clientX, event.clientY],
beforeTemporary: before ? [before[0], before[1]] : null,
current: [current[0], current[1]], moved: false,
};
capturePointer(event);
this.input.requestUpdate();
}
public pointerMove(event: PointerEvent): void {
const drag = this.drag;
if (!drag || drag.pointerId !== event.pointerId) return;
event.stopPropagation();
event.preventDefault();
const client: [number, number] = [event.clientX, event.clientY];
const target = this.input.planPoint(event);
this.input.queueMove(() => {
const active = this.drag;
if (!active || active.pointerId !== event.pointerId) return;
if (!active.moved && !roomGearDragMoved(active.startClient, client)) return;
const room = this.input.currentRoom(active.roomId);
const polygon = room && roomPoly(room);
if (!room || !polygon || !roomGearPointAllowed(active.current, polygon)) {
this.finish(true);
return;
}
active.moved = true;
active.current = clampRoomGearPointAlongPath(
active.current, [target[0], target[1]], polygon,
);
this.positions.set(active.key, active.current);
this.input.requestUpdate();
});
}
public pointerUp(event: PointerEvent): void {
if (!this.drag || this.drag.pointerId !== event.pointerId) return;
event.stopPropagation();
this.input.flushMove();
this.finish(false);
}
public pointerCancel(event: PointerEvent): void {
if (!this.drag || this.drag.pointerId !== event.pointerId) return;
event.stopPropagation();
this.finish(true);
}
public click(event: MouseEvent, room: RoomCfg): void {
event.stopPropagation();
if (event.detail !== 0 && performance.now() <= this.suppressClickUntil) {
this.suppressClickUntil = 0;
event.preventDefault();
return;
}
this.suppressClickUntil = 0;
this.input.openRoom(room);
}
private finish(cancelled: boolean): boolean {
const drag = this.drag;
if (!drag) return false;
this.input.cancelMove();
if (cancelled) {
if (drag.beforeTemporary) this.positions.set(drag.key, drag.beforeTemporary);
else this.positions.delete(drag.key);
} else if (drag.moved) {
this.suppressClickUntil = performance.now() + 700;
}
this.drag = null;
this.releasePointer(drag);
this.input.requestUpdate();
return true;
}
private clearDrag(restore: boolean): void {
const drag = this.drag;
if (!drag) return;
this.input.cancelMove();
if (restore) {
if (drag.beforeTemporary) this.positions.set(drag.key, drag.beforeTemporary);
else this.positions.delete(drag.key);
}
this.drag = null;
this.releasePointer(drag);
}
private releasePointer(drag: RoomGearDragState): void {
try {
if (drag.source?.hasPointerCapture?.(drag.pointerId)) {
drag.source.releasePointerCapture(drag.pointerId);
}
} catch {
/* a browser-owned terminal pointer must not turn cancellation into an error */
}
}
}
+10 -2
View File
@@ -628,6 +628,7 @@ export const planStyles = css`
@media (prefers-reduced-motion: reduce) {
.zoomwrap.slide-left,
.zoomwrap.slide-right { animation: none; }
.rlgearbtn { transition: none !important; }
}
/* The name is the anchor: the label box is centred on the room point, so
anything that takes part in its layout SHIFTS THE NAME. The gear button
@@ -656,13 +657,20 @@ export const planStyles = css`
font-weight: 600;
line-height: 1;
white-space: nowrap;
cursor: pointer;
cursor: grab;
pointer-events: auto;
touch-action: none;
opacity: 0.92;
box-shadow: var(--shadow-1);
z-index: 2;
}
.rlgearbtn { transition: opacity 0.15s, filter 0.15s; }
.rlgearbtn { transition: opacity 0.15s, filter 0.15s, box-shadow 0.15s; }
.rlgearbtn.dragging {
cursor: grabbing;
opacity: 1;
filter: brightness(1.1);
box-shadow: var(--shadow-3, 0 8px 20px rgb(0 0 0 / 0.28));
}
:host([data-pointer-hover]) .rlgearbtn:hover { opacity: 1; filter: brightness(1.18); }
.rlgearbtn ha-icon { --mdc-icon-size: calc(var(--gear-h) * 0.55); display: inline-flex; }
.rlgear {