fix(touch): separate pan, edge swipe and double fit (#691)

Issue: #691
User-Visible: yes
This commit is contained in:
Matysh
2026-09-28 16:27:45 +03:00
parent 3f17a1497d
commit 37fbb9824d
17 changed files with 665 additions and 150 deletions
+51 -42
View File
@@ -26,13 +26,16 @@ import { compileIconRules, iconFor, type IconRule, type CompiledIconRule } from
import {
snapToGrid, samePoint, pointInPolygon, segmentCm, formatLength, roomEdges, roomPoly,
paperRoomShapes, islandsOf, distToSegment, outlineWithout, segmentAngle, is45, isExact45Vector,
swipeTarget, clampScale, roomFillModeOf, roomGlowOf, openingAmount, openingEntityReferences,
beginSpaceSwipePointer, classifySpaceDrag, spaceSwipeTargetForOwner, clampScale,
roomFillModeOf, roomGlowOf,
openingAmount, openingEntityReferences,
averageLqi, fitView, declump, safeUrl, floorsOf, type FloorInfo, stateIcon, diffNewDevices,
isControllable, spaceDisplayOf, resolveEffectiveRoomFill, fillColorsOf, customFillOf,
roomCustomFillOf, DEFAULT_CUSTOM_FILL, type FillColors, type FillColorEntry,
type ResolvedRoomFill, runServiceFor, stageBgOf, showRoomTooltipOf, volumetricViewOf, type SpaceDisplay,
referencedContentUrls, normalizeDeviceDisplay, isAlarmCapable, displayIsNeutral,
type DeviceDisplayMode, liveText, liveTextReference, hassValue, decorTextScale, decorTextLines,
type DeviceDisplayMode, type SpaceSwipePointerStart, liveText, liveTextReference, hassValue,
decorTextScale, decorTextLines,
DECOR_TEXT_BASE,
} from './logic';
import { type SafeResizePlan, type SafeResizeResolution } from './resize';
@@ -224,8 +227,7 @@ import {
type CameraState, type CameraTransitionReason, type CameraTransitionState,
} from './viewport-transition'; import { measuredCardHeaderHeight, settleSoftStageLayout } from './boot-soft-layout';
import {
acceptedRoomFitGesture, DoubleFitGestureRecognizer, roomFitCameraTarget,
roomFitClampFrame,
PlanTapGestureController, roomFitCameraTarget, roomFitClampFrame,
roomFitGeometryBounds, roomFitOwnerFromPath, STAGE_TAP_DISTANCE_PX,
type RoomFitGestureCandidate,
} from './room-fit';
@@ -1899,15 +1901,21 @@ export class HouseplanCard extends LitElement {
/** Session-only View intent. It is deliberately absent from warm/LS/config state. */
private _roomFocus: { spaceId: string; roomId: string } | null = null;
/** Pointer owner captured from the actually painted event path. */
private _roomPointer: RoomFitGestureCandidate | null = null; private readonly _doubleFit = new DoubleFitGestureRecognizer();
private _roomPointer: RoomFitGestureCandidate | null = null;
private readonly _planTaps = new PlanTapGestureController({
current: () => ({ spaceId: this._space, enabled: this._doubleFitEnabled }),
fitAll: () => this._fitAll('double-tap'),
fitRoom: (roomId) => this._fitRoom(roomId),
});
private readonly _deviceHits = new DeviceHitController(); private _deviceHitScrollUnsub?: () => void;
private _pointers = new Map<number, { x: number; y: number }>();
private _panStart: { sx: number; sy: number; vx: number; vy: number } | null = null;
/**
* What the current one-finger drag turned out to be, decided ONCE on the
* first real movement and held until the finger lifts (see
* `_stagePointerMove`). Only the kiosk has two candidates — a horizontal
* drag there is the floor swipe; everywhere else a drag always pans.
* `_stagePointerMove`). Only the kiosk has two candidates — an inward,
* sufficiently horizontal drag that starts inside the active 48 px edge
* strip is a floor swipe; every other drag pans.
*/
private _panLock: 'pan' | 'swipe' | null = null;
private _pinchStart: { dist: number; zoom: number } | null = null;
@@ -2290,7 +2298,7 @@ export class HouseplanCard extends LitElement {
private _kioskHoldTimer?: number;
private _cycleTimer?: number;
private _cyclePausedUntil = 0;
private _swipeStart: { x: number; y: number; id: number } | null = null;
private _swipeStart: SpaceSwipePointerStart | null = null;
/** Live tab reorder: which tab is held, where it started, where it would land. */
private _tabDrag: {
@@ -5528,7 +5536,7 @@ export class HouseplanCard extends LitElement {
this._pinchStart = null;
this._swipeStart = null;
this._roomPointer = null;
this._doubleFit.clear();
this._clearPlanTapSequence();
this._deviceHits.clearPointers();
}
@@ -6114,8 +6122,12 @@ export class HouseplanCard extends LitElement {
this._resetZoom();
}
private _clearPlanTapSequence(): void {
this._planTaps.clear();
}
private _clearRoomFocus(pointer = false): void {
this._doubleFit.clear();
this._clearPlanTapSequence();
this._roomFocus = null;
if (pointer) this._roomPointer = null;
}
@@ -6160,7 +6172,7 @@ export class HouseplanCard extends LitElement {
/** Apply or retarget one room command without introducing a second RAF owner. */
private _fitRoom(roomId: string, animate = true): boolean {
if (this._mode !== 'view') return false;
this._doubleFit.clear();
this._clearPlanTapSequence();
const space = this._spaceModel();
const room = space?.rooms.find((item) => item.id === roomId);
if (!space || !room) {
@@ -6658,11 +6670,13 @@ export class HouseplanCard extends LitElement {
}
private _stagePointerDown(ev: PointerEvent): void {
this._bootSoftCancel(); const roomId = ev.isPrimary && ev.button === 0 && this._mode === 'view' ? roomFitOwnerFromPath(ev.composedPath()) : null;
this._bootSoftCancel();
const roomId = ev.isPrimary && ev.button === 0 && this._mode === 'view'
? roomFitOwnerFromPath(ev.composedPath()) : null;
this._roomPointer = roomId
? { pointerId: ev.pointerId, spaceId: this._space, roomId }
: null;
this._doubleFit.pointerDown(ev, this._space, this._doubleFitEnabled);
this._planTaps.pointerDown(ev, this._space, this._doubleFitEnabled);
// The gesture that starts here freezes the animated frame and keeps it on
// screen — so the shown zoom becomes the saved one (#396 AC1).
this._cancelCameraTransition(false, true);
@@ -6674,13 +6688,17 @@ export class HouseplanCard extends LitElement {
if (this._kiosk) {
this._cyclePausedUntil = Date.now() + 60000;
if (this._pointers.size === 0) {
this._swipeStart = { x: ev.clientX, y: ev.clientY, id: ev.pointerId };
const rect = this._stageEl?.getBoundingClientRect();
this._swipeStart = rect ? beginSpaceSwipePointer(
this._swipeZone, ev.clientX, ev.clientY, ev.pointerId, rect.left, rect.width,
this._model.map((space) => space.id), this._space,
) : null;
// long-press on EMPTY stage opens the per-screen size popover
if (!(ev.target as HTMLElement).closest?.('.dev, .roomlabel, .oplock')) {
clearTimeout(this._kioskHoldTimer);
this._kioskHoldTimer = window.setTimeout(() => {
this._roomPointer = null;
this._doubleFit.clear();
this._clearPlanTapSequence();
this._kioskDialog = true;
this._swipeStart = null;
}, 3000);
@@ -6812,7 +6830,7 @@ export class HouseplanCard extends LitElement {
const ddy = ev.clientY - this._panStart.sy;
if (Math.abs(ddx) + Math.abs(ddy) > 4) {
this._roomPointer = null;
this._doubleFit.clear();
this._clearPlanTapSequence();
this._suppressClick = true;
clearTimeout(this._holdTimer);
if (this._tool === 'opening') {
@@ -6830,7 +6848,9 @@ export class HouseplanCard extends LitElement {
// make a drag meaningless — `_clampView` alone decides how far you may
// walk, at 400% and at 33% alike.
if (this._panLock === null && Math.abs(ddx) + Math.abs(ddy) > STAGE_TAP_DISTANCE_PX) {
this._panLock = this._swipeZone && Math.abs(ddx) > Math.abs(ddy) * 1.5 ? 'swipe' : 'pan';
const edge = this._swipeStart?.id === ev.pointerId ? this._swipeStart.edge : null;
const owner = classifySpaceDrag(edge, ddx, ddy, STAGE_TAP_DISTANCE_PX);
this._panLock = owner === 'swipe' ? 'swipe' : 'pan';
if (this._panLock === 'pan') { this._activateSafeDayCycleOutline(); this._clearRoomFocus(); }
}
const stage = this._stageEl;
@@ -6869,17 +6889,12 @@ export class HouseplanCard extends LitElement {
this._deviceHits.release(ev.pointerId);
this._flushHa();
this._editorRuntime?._cancelPointerMove('markup-hover');
const acceptedRoom = acceptedRoomFitGesture(
this._roomPointer,
ev.pointerId,
this._space,
this._roomPointer && this._mode === 'view' ? roomFitOwnerFromPath(ev.composedPath()) : null,
this._suppressClick || !!this._pinchStart || this._panLock !== null
|| this._holdFired || this._touchSequenceMultitouch,
const blockedRoomTap = this._suppressClick || !!this._pinchStart || this._panLock !== null
|| this._holdFired || this._touchSequenceMultitouch;
const acceptedRoom = this._planTaps.pointerUp(
ev, this._roomPointer, this._space, this._doubleFitEnabled, blockedRoomTap,
);
if (this._roomPointer?.pointerId === ev.pointerId) this._roomPointer = null;
const doubleFit = this._doubleFit.pointerUp(ev, this._space, this._doubleFitEnabled, this._suppressClick || !!this._pinchStart || this._panLock !== null || this._holdFired);
if (doubleFit) this._fitAll('double-tap');
if (this._kiosk) {
clearTimeout(this._kioskHoldTimer);
const ss = this._swipeStart;
@@ -6887,19 +6902,11 @@ export class HouseplanCard extends LitElement {
if (!acceptedRoom && ss && ss.id === ev.pointerId) {
const dx = ev.clientX - ss.x;
const dy = ev.clientY - ss.y;
// The lock is FINAL (audit DEV-1DA1-02). `_stagePointerMove` decided
// once, on the first movement worth the name, whether this gesture is
// a swipe or a pan — and the release may not overturn it. Until this
// the release asked `swipeTarget()` again from the raw start→end
// vector, so a CURVED gesture (a small vertical lead-in that locks
// 'pan', then a long horizontal sweep) dragged the plan under the
// finger and still landed on another storey when it lifted. A pan is
// a pan to the end: no floor change, whatever the overall vector
// happens to look like. A motionless tap never locks anything, so it
// remains eligible for the shared free-background double-fit recognizer.
const target = this._panLock === 'pan'
? null
: swipeTarget(dx, dy, this._zoom, this._model.map((m) => m.id), this._space);
// Release obeys the first-movement owner: a curved pan never becomes a
// floor change merely because its final vector looks horizontal.
const target = spaceSwipeTargetForOwner(
this._panLock, dx, dy, this._zoom, this._model.map((m) => m.id), this._space,
);
if (target) {
// the plan follows the finger: swiping left brings the next one in
// from the right, so the current one leaves to the left
@@ -6948,7 +6955,6 @@ export class HouseplanCard extends LitElement {
if (this._suppressClick) setTimeout(() => (this._suppressClick = false), 0);
}
if (this._pointers.size === 0 && !acceptedRoom) this._finishViewportGesture();
if (acceptedRoom) this._fitRoom(acceptedRoom);
}
private _clickRoom(r: RoomCfg): void {
@@ -7171,7 +7177,9 @@ export class HouseplanCard extends LitElement {
if (this._touchClickGuard.handleActivation(ev, this._suppressClick)) return;
const pointer = ev as PointerEvent;
this._notePointer(pointer);
if (ev.type === 'pointercancel' || ev.type === 'lostpointercapture') this._doubleFit.clear(); else if (ev.type === 'pointerdown') this._doubleFit.clearOutside(pointer);
if (ev.type === 'pointercancel' || ev.type === 'lostpointercapture') {
this._clearPlanTapSequence();
} else if (ev.type === 'pointerdown') this._planTaps.clearNonPlan(pointer);
if (ev.type === 'pointerdown') this._touchClickGuard.pointerDown(
pointer.pointerId, pointer.pointerType,
);
@@ -7721,7 +7729,8 @@ export class HouseplanCard extends LitElement {
this._deviceHits.cancel(ev.pointerId);
this._flushHa();
this._editorRuntime?._cancelPointerMove('markup-hover');
if (this._roomPointer?.pointerId === ev.pointerId) this._roomPointer = null; this._doubleFit.clear();
if (this._roomPointer?.pointerId === ev.pointerId) this._roomPointer = null;
this._clearPlanTapSequence();
this._stairsView.pointerCancel(ev);
if (this._editorRuntime?.stairs.pointerCancel(ev)) return;
if (this._editorRuntime) return this._editorRuntime._stagePointerCancel(ev);
+83
View File
@@ -1998,6 +1998,89 @@ export function migratePdfUrls<T extends { url: string }>(
// ---------------- kiosk gestures ----------------
export const SPACE_SWIPE_EDGE_PX = 48;
export type SpaceSwipeEdge = 'previous' | 'next';
export type SpaceDragOwner = 'pending' | 'pan' | 'swipe';
export interface SpaceSwipePointerStart {
x: number;
y: number;
id: number;
edge: SpaceSwipeEdge;
}
/**
* The only places where a kiosk drag may become a space swipe. A missing
* neighbour deliberately leaves the edge to ordinary pan, so the first and
* last spaces never acquire a dead strip. Screen coordinates keep this
* independent from plan zoom, DPR and viewBox geometry.
*/
export function spaceSwipeEdgeAt(
clientX: number,
stageLeft: number,
stageWidth: number,
spaceIds: readonly string[],
current: string,
edgePx = SPACE_SWIPE_EDGE_PX,
): SpaceSwipeEdge | null {
if (!Number.isFinite(clientX) || !Number.isFinite(stageLeft)
|| !Number.isFinite(stageWidth) || stageWidth <= 0
|| !Number.isFinite(edgePx) || edgePx <= 0) return null;
const offset = clientX - stageLeft;
if (offset < 0 || offset > stageWidth) return null;
const index = spaceIds.indexOf(current);
if (index < 0) return null;
const leftDistance = offset;
const rightDistance = stageWidth - offset;
const previous = index > 0 && leftDistance <= edgePx;
const next = index < spaceIds.length - 1 && rightDistance <= edgePx;
if (previous && next) return leftDistance <= rightDistance ? 'previous' : 'next';
if (previous) return 'previous';
if (next) return 'next';
return null;
}
export function beginSpaceSwipePointer(
enabled: boolean,
clientX: number,
clientY: number,
pointerId: number,
stageLeft: number,
stageWidth: number,
spaceIds: readonly string[],
current: string,
): SpaceSwipePointerStart | null {
if (!enabled) return null;
const edge = spaceSwipeEdgeAt(clientX, stageLeft, stageWidth, spaceIds, current);
return edge ? { x: clientX, y: clientY, id: pointerId, edge } : null;
}
/** One irreversible classification after the existing eight-pixel boundary. */
export function classifySpaceDrag(
edge: SpaceSwipeEdge | null,
dx: number,
dy: number,
minPx = 8,
): SpaceDragOwner {
if (!Number.isFinite(dx) || !Number.isFinite(dy)
|| !Number.isFinite(minPx) || minPx < 0) return 'pan';
if (Math.abs(dx) + Math.abs(dy) <= minPx) return 'pending';
const inward = edge === 'previous' ? dx > 0 : edge === 'next' && dx < 0;
return inward && Math.abs(dx) > Math.abs(dy) * 1.5 ? 'swipe' : 'pan';
}
/** Release may consult the vector only after the first-movement owner is final. */
export function spaceSwipeTargetForOwner(
owner: 'pan' | 'swipe' | null,
dx: number,
dy: number,
zoom: number,
spaceIds: string[],
current: string,
): string | null {
if (owner !== 'swipe') return null;
return swipeTarget(dx, dy, zoom, spaceIds, current);
}
/**
* Kiosk swipe: which neighbouring space a horizontal gesture selects.
* Only fires at 1:1 zoom (owner's decision — when zoomed the gesture pans),
+109 -9
View File
@@ -36,6 +36,7 @@ export interface DoubleFitPointerCandidate {
modality: DoubleFitPointerModality;
x: number;
y: number;
roomId?: string;
}
export interface DoubleFitTapSequence {
@@ -77,6 +78,7 @@ export interface DoubleFitPointerUpInput {
export interface DoubleFitResult {
sequence: DoubleFitTapSequence | null;
trigger: boolean;
roomId?: string;
}
interface DoubleFitPointerEventLike {
@@ -114,9 +116,9 @@ const pathMatches = (node: RoomFitPathNode, selector: string): boolean => {
};
/**
* One browser-path authority for room fit and the free-background shortcut.
* The first independently interactive owner wins; only a path which reaches
* this card's stage without one is free background.
* One browser-path authority for room fit and the plan-surface shortcut.
* The first independently interactive owner wins; otherwise a room surface or
* the stage background may contribute one clean tap.
*/
export function planGestureOwnerFromPath(path: readonly unknown[]): PlanGestureOwner {
for (const raw of path) {
@@ -152,7 +154,8 @@ export function beginDoubleFitPointer(
input: DoubleFitPointerDownInput,
): DoubleFitPointerCandidate | null {
const modality = pointerModality(input.pointerType);
if (!modality || input.mode !== 'view' || input.owner.kind !== 'background'
if (!modality || input.mode !== 'view'
|| (input.owner.kind !== 'background' && input.owner.kind !== 'room')
|| input.blocked || !input.isPrimary || input.button !== 0
|| !Number.isFinite(input.x) || !Number.isFinite(input.y)) return null;
return {
@@ -161,6 +164,7 @@ export function beginDoubleFitPointer(
modality,
x: input.x,
y: input.y,
...(input.owner.kind === 'room' ? { roomId: input.owner.roomId } : {}),
};
}
@@ -170,7 +174,10 @@ export function completeDoubleFitPointer(
candidate: DoubleFitPointerCandidate | null,
input: DoubleFitPointerUpInput,
): DoubleFitResult {
if (!candidate || input.mode !== 'view' || input.owner.kind !== 'background'
const sameOwner = !!candidate && (candidate.roomId
? input.owner.kind === 'room' && input.owner.roomId === candidate.roomId
: input.owner.kind === 'background');
if (!candidate || input.mode !== 'view' || !sameOwner
|| input.blocked || candidate.pointerId !== input.pointerId
|| candidate.spaceId !== input.spaceId
|| !Number.isFinite(input.x) || !Number.isFinite(input.y)
@@ -189,6 +196,7 @@ export function completeDoubleFitPointer(
return {
sequence: { at: input.now, spaceId: input.spaceId, modality: candidate.modality },
trigger: false,
...(candidate.roomId ? { roomId: candidate.roomId } : {}),
};
}
@@ -202,10 +210,14 @@ export class DoubleFitGestureRecognizer {
this.sequence = null;
}
clearOutside(event: DoubleFitPointerEventLike): void {
clearNonPlan(event: DoubleFitPointerEventLike): boolean {
let owner: PlanGestureOwner = { kind: 'outside' };
try { owner = planGestureOwnerFromPath(event.composedPath()); } catch { /* fail closed */ }
if (owner.kind === 'outside') this.clear();
if (owner.kind !== 'background' && owner.kind !== 'room') {
this.clear();
return true;
}
return false;
}
pointerDown(event: DoubleFitPointerEventLike, spaceId: string, enabled: boolean): void {
@@ -226,7 +238,7 @@ export class DoubleFitGestureRecognizer {
enabled: boolean,
blocked: boolean,
now = Date.now(),
): boolean {
): DoubleFitResult {
let owner: PlanGestureOwner = { kind: 'outside' };
try { owner = planGestureOwnerFromPath(event.composedPath()); } catch { /* fail closed */ }
const result = completeDoubleFitPointer(this.sequence, this.pointer, {
@@ -235,7 +247,95 @@ export class DoubleFitGestureRecognizer {
});
this.pointer = null;
this.sequence = result.sequence;
return result.trigger;
return result;
}
}
export interface PlanTapTimerPort {
set(callback: () => void, delayMs: number): unknown;
clear(handle: unknown): void;
}
export interface PlanTapGestureActions {
current(): { spaceId: string; enabled: boolean };
fitAll(): void;
fitRoom(roomId: string): void;
}
const browserPlanTapTimers: PlanTapTimerPort = {
set: (callback, delayMs) => globalThis.setTimeout(callback, delayMs),
clear: (handle) => globalThis.clearTimeout(handle as number),
};
/** One owner for the double-fit recognizer and its sole delayed room command. */
export class PlanTapGestureController {
private readonly recognizer = new DoubleFitGestureRecognizer();
private pending: { spaceId: string; roomId: string } | null = null;
private timer: unknown;
constructor(
private readonly actions: PlanTapGestureActions,
private readonly timers: PlanTapTimerPort = browserPlanTapTimers,
) {}
cancelPending(): void {
if (this.timer !== undefined) this.timers.clear(this.timer);
this.timer = undefined;
this.pending = null;
}
clear(): void {
this.cancelPending();
this.recognizer.clear();
}
clearNonPlan(event: DoubleFitPointerEventLike): boolean {
if (!this.recognizer.clearNonPlan(event)) return false;
this.cancelPending();
return true;
}
pointerDown(event: DoubleFitPointerEventLike, spaceId: string, enabled: boolean): void {
this.cancelPending();
this.recognizer.pointerDown(event, spaceId, enabled);
}
pointerUp(
event: DoubleFitPointerEventLike,
roomCandidate: RoomFitGestureCandidate | null,
spaceId: string,
enabled: boolean,
blocked: boolean,
now = Date.now(),
): string | null {
const acceptedRoom = acceptedRoomFitGesture(
roomCandidate, event.pointerId, spaceId,
roomCandidate && enabled ? roomFitOwnerFromPath(event.composedPath()) : null,
blocked,
);
const result = this.recognizer.pointerUp(event, spaceId, enabled, blocked, now);
if (result.trigger) this.actions.fitAll();
else if (result.roomId && acceptedRoom === result.roomId) {
this.schedule(spaceId, result.roomId);
} else if (acceptedRoom) this.actions.fitRoom(acceptedRoom);
return acceptedRoom;
}
private schedule(spaceId: string, roomId: string): void {
this.cancelPending();
const pending = { spaceId, roomId };
this.pending = pending;
this.timer = this.timers.set(() => {
this.timer = undefined;
if (this.pending !== pending) return;
this.pending = null;
const current = this.actions.current();
if (current.spaceId !== pending.spaceId || !current.enabled) {
this.recognizer.clear();
return;
}
this.actions.fitRoom(pending.roomId);
}, DOUBLE_FIT_WINDOW_MS);
}
}