mirror of
https://github.com/Matysh/houseplan-card
synced 2026-10-07 06:59:46 +00:00
fix(touch): separate pan, edge swipe and double fit (#691)
Issue: #691 User-Visible: yes
This commit is contained in:
+51
-42
@@ -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);
|
||||
|
||||
@@ -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
@@ -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);
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
Reference in New Issue
Block a user