mirror of
https://github.com/Matysh/houseplan-card
synced 2026-10-07 23:19:14 +00:00
feat(editor): перетаскивание кнопки настроек комнаты (#645)
Кнопка сохраняет положение в пределах комнаты только на время сессии редактора, корректно отменяется при pinch/cancel и учитывается подписями Resize. Issue: #645 User-Visible: yes
This commit is contained in:
@@ -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) {
|
||||
|
||||
@@ -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>`;
|
||||
|
||||
@@ -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 */
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -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 {
|
||||
|
||||
Reference in New Issue
Block a user