feat: fit all on double background tap

Issue: #449
User-Visible: yes
This commit is contained in:
Sergey Matyunin
2026-09-04 18:33:03 +03:00
parent b39f99b3a4
commit 05ef318133
28 changed files with 687 additions and 243 deletions
+25 -25
View File
@@ -316,8 +316,9 @@ import {
type CameraState, type CameraTransitionReason, type CameraTransitionState,
} from './viewport-transition';
import {
acceptedRoomFitGesture, roomFitCameraTarget, roomFitClampFrame,
roomFitGeometryBounds, roomFitOwnerFromPath,
acceptedRoomFitGesture, DoubleFitGestureRecognizer, roomFitCameraTarget,
roomFitClampFrame,
roomFitGeometryBounds, roomFitOwnerFromPath, STAGE_TAP_DISTANCE_PX,
type RoomFitGestureCandidate,
} from './room-fit';
import { EditorRuntimeLoader, lazyLoadFailureMessage, type EditorRuntimeLoaderState } from './editor-runtime-loader';
@@ -1092,6 +1093,8 @@ export class HouseplanCard extends LitElement {
return this._modeTransitionPreparing || this._modeTransition.active;
}
private get _doubleFitEnabled(): boolean { return this._mode === 'view' && !this._vacFit && !this._modeTransitionBusy && !this._suppressClick && !this._touchSequenceMultitouch && this._continuity.state === 'steady' && !this._continuity.overlayBlocksInteraction; }
private _cameraState(): CameraState {
const view = this._viewOr(this._baseVb());
return { zoom: this._zoom, viewBox: { ...view } };
@@ -1937,7 +1940,7 @@ 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 _roomPointer: RoomFitGestureCandidate | null = null; private readonly _doubleFit = new DoubleFitGestureRecognizer();
private _pointers = new Map<number, { x: number; y: number }>();
private _panStart: { sx: number; sy: number; vx: number; vy: number } | null = null;
/**
@@ -2376,7 +2379,6 @@ export class HouseplanCard extends LitElement {
/** The positional-`floor` warning is worth saying once, not on every drop. */
private _tabOrderWarned = false;
private _lastTap = 0;
private get _labsIso(): boolean {
return this._labs.active.includes('iso');
}
@@ -5663,6 +5665,7 @@ export class HouseplanCard extends LitElement {
this._pinchStart = null;
this._swipeStart = null;
this._roomPointer = null;
this._doubleFit.clear();
}
/** Close the device card and make stale long-press state non-observable. */
@@ -6214,16 +6217,11 @@ export class HouseplanCard extends LitElement {
}
private _clearRoomFocus(pointer = false): void {
this._doubleFit.clear();
this._roomFocus = null;
if (pointer) this._roomPointer = null;
}
/** The rendered SVG/label event path is the same authority as hover. */
private _roomOwner(ev: Event): string | null {
if (this._mode !== 'view') return null;
return roomFitOwnerFromPath(ev.composedPath());
}
/** Exact finite room geometry in the current camera coordinate system. */
private _roomFitBounds(room: RoomCfg, space: SpaceModel) {
const own = roomPoly(room);
@@ -6265,6 +6263,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();
const space = this._spaceModel();
const room = space?.rooms.find((item) => item.id === roomId);
if (!space || !room) {
@@ -6315,7 +6314,7 @@ export class HouseplanCard extends LitElement {
/** «Вписать всё» (docs/CANVAS.md §8) — the toolbar button and the "home is
* that way" arrow share it. It fits whatever the frame currently means:
* the main mass, or everything once the far-objects hint has been used. */
private _fitAll(reason: 'fit' | 'home' = 'fit'): void {
private _fitAll(reason: 'fit' | 'home' | 'double-tap' = 'fit'): void {
this._clearRoomFocus();
this._showFar = true;
this._frame = null;
@@ -6740,10 +6739,11 @@ export class HouseplanCard extends LitElement {
}
private _stagePointerDown(ev: PointerEvent): void {
const roomId = ev.isPrimary && ev.button === 0 ? this._roomOwner(ev) : null;
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);
// 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);
@@ -6761,6 +6761,7 @@ export class HouseplanCard extends LitElement {
clearTimeout(this._kioskHoldTimer);
this._kioskHoldTimer = window.setTimeout(() => {
this._roomPointer = null;
this._doubleFit.clear();
this._kioskDialog = true;
this._swipeStart = null;
}, 3000);
@@ -6889,6 +6890,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._suppressClick = true;
clearTimeout(this._holdTimer);
if (this._tool === 'opening') {
@@ -6905,7 +6907,7 @@ export class HouseplanCard extends LitElement {
// there is no "the content already covers the scene" state that would
// 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) > 8) {
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';
if (this._panLock === 'pan') this._clearRoomFocus();
}
@@ -6943,11 +6945,13 @@ export class HouseplanCard extends LitElement {
this._roomPointer,
ev.pointerId,
this._space,
this._roomPointer ? this._roomOwner(ev) : null,
this._roomPointer && this._mode === 'view' ? roomFitOwnerFromPath(ev.composedPath()) : null,
this._suppressClick || !!this._pinchStart || this._panLock !== null
|| this._holdFired || this._touchSequenceMultitouch,
);
if (this._roomPointer?.pointerId === ev.pointerId) this._roomPointer = null;
const doubleFit = this._doubleFit.pointerUp(ev, this._space, this._doubleFitEnabled, !!this._pinchStart || this._panLock !== null || this._holdFired);
if (doubleFit) this._fitAll('double-tap');
if (this._kiosk) {
clearTimeout(this._kioskHoldTimer);
const ss = this._swipeStart;
@@ -6955,12 +6959,6 @@ export class HouseplanCard extends LitElement {
if (!acceptedRoom && ss && ss.id === ev.pointerId) {
const dx = ev.clientX - ss.x;
const dy = ev.clientY - ss.y;
// double tap (no movement) resets the zoom to 1:1
if (Math.abs(dx) + Math.abs(dy) < 8) {
const now = Date.now();
if (now - this._lastTap < 350) this._resetZoom('double-tap');
this._lastTap = now;
}
// 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
@@ -6969,8 +6967,8 @@ export class HouseplanCard extends LitElement {
// '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 the
// double-tap zoom reset above is untouched.
// 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);
@@ -7015,14 +7013,14 @@ export class HouseplanCard extends LitElement {
return;
}
if (this._editorRuntime?._furnPointerUp(ev)) return;
const viewportGestureEnded = !!this._pinchStart || !!this._panStart;
const viewportGestureEnded = !!this._pinchStart || this._panLock === 'pan';
this._pointers.delete(ev.pointerId);
if (this._pointers.size < 2) this._pinchStart = null;
if (this._pointers.size === 0) {
this._panStart = null;
this._panLock = null;
// reset click suppression on the next tick (so that a click right after a pan does not fire)
setTimeout(() => (this._suppressClick = false), 0);
if (this._suppressClick) setTimeout(() => (this._suppressClick = false), 0);
}
if (viewportGestureEnded && this._pointers.size === 0 && !acceptedRoom) this.requestUpdate();
if (acceptedRoom) this._fitRoom(acceptedRoom);
@@ -7232,6 +7230,7 @@ export class HouseplanCard extends LitElement {
}
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 (pointer.pointerType !== 'touch') return;
if (ev.type === 'pointerdown') {
this._touchContacts.set(pointer.pointerId, {
@@ -7862,7 +7861,8 @@ export class HouseplanCard extends LitElement {
/** Browser/OS cancellation is an aborted transaction, never a commit. */
private _stagePointerCancel(ev: PointerEvent): void {
if (this._roomPointer?.pointerId === ev.pointerId) this._roomPointer = null; if (this._editorRuntime) return this._editorRuntime._stagePointerCancel(ev);
if (this._roomPointer?.pointerId === ev.pointerId) this._roomPointer = null; this._doubleFit.clear();
if (this._editorRuntime) return this._editorRuntime._stagePointerCancel(ev);
const viewportGestureEnded = !!this._pinchStart || !!this._panStart; this._pointers.delete(ev.pointerId);
if (this._pointers.size < 2) this._pinchStart = null;
if (this._pointers.size === 0) {
+192 -12
View File
@@ -28,6 +28,67 @@ export interface RoomFitGestureCandidate {
roomId: string;
}
export type DoubleFitPointerModality = 'mouse' | 'touch' | 'pen';
export interface DoubleFitPointerCandidate {
pointerId: number;
spaceId: string;
modality: DoubleFitPointerModality;
x: number;
y: number;
}
export interface DoubleFitTapSequence {
at: number;
spaceId: string;
modality: DoubleFitPointerModality;
}
export type PlanGestureOwner =
| { kind: 'background' }
| { kind: 'room'; roomId: string }
| { kind: 'interactive' }
| { kind: 'outside' };
export interface DoubleFitPointerDownInput {
pointerId: number;
pointerType: string;
isPrimary: boolean;
button: number;
spaceId: string;
mode: string;
owner: PlanGestureOwner;
blocked: boolean;
x: number;
y: number;
}
export interface DoubleFitPointerUpInput {
pointerId: number;
spaceId: string;
mode: string;
owner: PlanGestureOwner;
blocked: boolean;
x: number;
y: number;
now: number;
}
export interface DoubleFitResult {
sequence: DoubleFitTapSequence | null;
trigger: boolean;
}
interface DoubleFitPointerEventLike {
pointerId: number;
pointerType: string;
isPrimary: boolean;
button: number;
clientX: number;
clientY: number;
composedPath: () => readonly unknown[];
}
interface RoomFitPathNode {
matches?: (selector: string) => boolean;
getAttribute?: (name: string) => string | null;
@@ -36,27 +97,146 @@ interface RoomFitPathNode {
const ROOM_FIT_INTERACTIVE_OWNER = [
'.dev', '.vacpuck', '.oplock', '.op-hit', '.opening', '.rlgo',
'a', 'button', 'input', 'select', 'textarea',
'[contenteditable="true"]', '[data-room-fit-block]', '[role="link"]', '[role="button"]',
'[contenteditable]:not([contenteditable="false"])',
'[data-room-fit-block]', '[role="link"]', '[role="button"]',
].join(',');
export const DOUBLE_FIT_WINDOW_MS = 350;
/** Existing kiosk clean-tap / stage pan-lock boundary. */
export const STAGE_TAP_DISTANCE_PX = 8;
const pathMatches = (node: RoomFitPathNode, selector: string): boolean => {
try {
return typeof node?.matches === 'function' && node.matches(selector);
} catch {
return false;
}
};
/**
* 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.
*/
export function planGestureOwnerFromPath(path: readonly unknown[]): PlanGestureOwner {
for (const raw of path) {
const node = raw as RoomFitPathNode;
if (pathMatches(node, '.roomlabel[data-id]')) {
const roomId = node.getAttribute?.('data-id');
return roomId ? { kind: 'room', roomId } : { kind: 'interactive' };
}
if (pathMatches(node, ROOM_FIT_INTERACTIVE_OWNER)) return { kind: 'interactive' };
if (pathMatches(node, '[data-hp="room"][data-id]')) {
const roomId = node.getAttribute?.('data-id');
return roomId ? { kind: 'room', roomId } : { kind: 'interactive' };
}
if (pathMatches(node, '.stage')) return { kind: 'background' };
}
return { kind: 'outside' };
}
/**
* Resolve the browser-painted room owner. The first independently interactive
* node in the composed path wins; the room label itself is the one deliberate
* role=button exception because it maps back to the same room command.
*/
export function roomFitOwnerFromPath(path: readonly unknown[]): string | null {
for (const raw of path) {
const node = raw as RoomFitPathNode;
if (typeof node?.matches !== 'function') continue;
if (node.matches('.roomlabel[data-id]')) {
return node.getAttribute?.('data-id') || null;
}
if (node.matches(ROOM_FIT_INTERACTIVE_OWNER)) return null;
if (node.matches('[data-hp="room"][data-id]')) {
return node.getAttribute?.('data-id') || null;
}
const owner = planGestureOwnerFromPath(path);
return owner.kind === 'room' ? owner.roomId : null;
}
const pointerModality = (value: string): DoubleFitPointerModality | null =>
value === 'mouse' || value === 'touch' || value === 'pen' ? value : null;
export function beginDoubleFitPointer(
input: DoubleFitPointerDownInput,
): DoubleFitPointerCandidate | null {
const modality = pointerModality(input.pointerType);
if (!modality || input.mode !== 'view' || input.owner.kind !== 'background'
|| input.blocked || !input.isPrimary || input.button !== 0
|| !Number.isFinite(input.x) || !Number.isFinite(input.y)) return null;
return {
pointerId: input.pointerId,
spaceId: input.spaceId,
modality,
x: input.x,
y: input.y,
};
}
/** Pure release arbitration. Invalid input always disarms the previous tap. */
export function completeDoubleFitPointer(
sequence: DoubleFitTapSequence | null,
candidate: DoubleFitPointerCandidate | null,
input: DoubleFitPointerUpInput,
): DoubleFitResult {
if (!candidate || input.mode !== 'view' || input.owner.kind !== 'background'
|| input.blocked || candidate.pointerId !== input.pointerId
|| candidate.spaceId !== input.spaceId
|| !Number.isFinite(input.x) || !Number.isFinite(input.y)
|| !Number.isFinite(input.now)
|| Math.abs(input.x - candidate.x) + Math.abs(input.y - candidate.y)
>= STAGE_TAP_DISTANCE_PX) {
return { sequence: null, trigger: false };
}
const elapsed = sequence ? input.now - sequence.at : Number.POSITIVE_INFINITY;
if (sequence && sequence.spaceId === input.spaceId
&& sequence.modality === candidate.modality
&& elapsed >= 0 && elapsed <= DOUBLE_FIT_WINDOW_MS) {
return { sequence: null, trigger: true };
}
return {
sequence: { at: input.now, spaceId: input.spaceId, modality: candidate.modality },
trigger: false,
};
}
/** Per-card owner of the two transient halves; no timers, renders or writes. */
export class DoubleFitGestureRecognizer {
private pointer: DoubleFitPointerCandidate | null = null;
private sequence: DoubleFitTapSequence | null = null;
clear(): void {
this.pointer = null;
this.sequence = null;
}
clearOutside(event: DoubleFitPointerEventLike): void {
let owner: PlanGestureOwner = { kind: 'outside' };
try { owner = planGestureOwnerFromPath(event.composedPath()); } catch { /* fail closed */ }
if (owner.kind === 'outside') this.clear();
}
pointerDown(event: DoubleFitPointerEventLike, spaceId: string, enabled: boolean): void {
let owner: PlanGestureOwner = { kind: 'outside' };
try { owner = planGestureOwnerFromPath(event.composedPath()); } catch { /* fail closed */ }
this.pointer = beginDoubleFitPointer({
pointerId: event.pointerId, pointerType: event.pointerType,
isPrimary: event.isPrimary, button: event.button, spaceId,
mode: enabled ? 'view' : 'blocked', owner, blocked: !enabled,
x: event.clientX, y: event.clientY,
});
if (!this.pointer) this.sequence = null;
}
pointerUp(
event: DoubleFitPointerEventLike,
spaceId: string,
enabled: boolean,
blocked: boolean,
now = Date.now(),
): boolean {
let owner: PlanGestureOwner = { kind: 'outside' };
try { owner = planGestureOwnerFromPath(event.composedPath()); } catch { /* fail closed */ }
const result = completeDoubleFitPointer(this.sequence, this.pointer, {
pointerId: event.pointerId, spaceId, mode: enabled ? 'view' : 'blocked', owner,
blocked: blocked || !enabled, x: event.clientX, y: event.clientY, now,
});
this.pointer = null;
this.sequence = result.sequence;
return result.trigger;
}
return null;
}
export function acceptedRoomFitGesture(