From 37fbb9824d1d27e5244a3084eb3f5c52d1ffe696 Mon Sep 17 00:00:00 2001 From: Matysh Date: Mon, 28 Sep 2026 16:27:45 +0300 Subject: [PATCH] fix(touch): separate pan, edge swipe and double fit (#691) Issue: #691 User-Visible: yes --- demo/smoke_kiosk.mjs | 37 +++++++--- demo/smoke_kiosk_pan_lock.mjs | 73 +++++++++++++++----- demo/smoke_room_fit.mjs | 69 +++++++++++++++---- docs/CANVAS.md | 36 ++++++---- docs/CHANGELOG.md | 7 ++ docs/CHANGELOG.ru.md | 8 +++ docs/TOUCH-SUPPORT.md | 8 ++- docs/USER-GUIDE.md | 18 ++--- docs/UX-MODES.md | 7 +- docs/images/screenshots.json | 24 +++---- scripts/bundle-budget.mjs | 9 ++- scripts/mutation-registry.mjs | 64 ++++++++++++++---- src/houseplan-card.ts | 93 +++++++++++++------------ src/logic.ts | 83 +++++++++++++++++++++++ src/room-fit.ts | 118 +++++++++++++++++++++++++++++--- test/logic.test.mjs | 37 +++++++++- test/room-fit.test.mjs | 124 ++++++++++++++++++++++++++++++++-- 17 files changed, 665 insertions(+), 150 deletions(-) diff --git a/demo/smoke_kiosk.mjs b/demo/smoke_kiosk.mjs index 9d628cb1..3c228541 100644 --- a/demo/smoke_kiosk.mjs +++ b/demo/smoke_kiosk.mjs @@ -26,24 +26,43 @@ const res = await page.evaluate(async () => { // 2) редакторы заблокированы c._setMode('plan'); await c.updateComplete; out.editorsBlocked = c._mode === 'view'; - // 3) свайп при 1:1 переключает пространство (и точки показываются) + // 3) центральный drag остаётся pan, а свайп из внутренней 48 px зоны + // правого края при 1:1 переключает на соседнее пространство. const s0 = c._space; const stage = sr().querySelector('.stage'); - stage.dispatchEvent(new PointerEvent('pointerdown', { bubbles: true, composed: true, pointerId: 1, pointerType: 'touch', isPrimary: true, button: 0, clientX: 600, clientY: 300 })); - stage.dispatchEvent(new PointerEvent('pointerup', { bubbles: true, composed: true, pointerId: 1, pointerType: 'touch', isPrimary: true, button: 0, clientX: 450, clientY: 305 })); + const fire = (type, pointerId, clientX, clientY) => stage.dispatchEvent(new PointerEvent(type, { + bubbles: true, composed: true, cancelable: true, pointerId, pointerType: 'touch', + isPrimary: true, button: 0, buttons: type === 'pointerup' ? 0 : 1, clientX, clientY, + })); + const rect = stage.getBoundingClientRect(); + const centerX = rect.left + rect.width / 2; + const centerY = rect.top + rect.height / 2; + const centerView = JSON.stringify(c._viewOr(c._baseVb())); + fire('pointerdown', 1, centerX, centerY); + fire('pointermove', 1, centerX - 150, centerY + 5); + fire('pointerup', 1, centerX - 150, centerY + 5); await c.updateComplete; - out.swipeSwitches = c._space !== s0; + out.centralDragPansWithoutSwitching = c._space === s0 + && JSON.stringify(c._viewOr(c._baseVb())) !== centerView && c._panLock === null; + const rightEdgeX = rect.right - 20; + fire('pointerdown', 2, rightEdgeX, centerY); + fire('pointermove', 2, rightEdgeX - 80, centerY + 2); + fire('pointerup', 2, rightEdgeX - 120, centerY + 3); + await c.updateComplete; + out.edgeSwipeSwitches = c._space !== s0; out.dotsShown = !!sr().querySelector('.kioskdots'); // 4) при зуме свайп не переключает c._zoom = 2; const s1 = c._space; - stage.dispatchEvent(new PointerEvent('pointerdown', { bubbles: true, composed: true, pointerId: 2, pointerType: 'touch', isPrimary: true, button: 0, clientX: 600, clientY: 300 })); - stage.dispatchEvent(new PointerEvent('pointerup', { bubbles: true, composed: true, pointerId: 2, pointerType: 'touch', isPrimary: true, button: 0, clientX: 450, clientY: 305 })); + const leftEdgeX = rect.left + 20; + fire('pointerdown', 3, leftEdgeX, centerY); + fire('pointermove', 3, leftEdgeX + 80, centerY + 2); + fire('pointerup', 3, leftEdgeX + 120, centerY + 3); await c.updateComplete; out.noSwipeZoomed = c._space === s1; // 5) двойной тап сбрасывает зум - for (const pointerId of [3, 4]) { - stage.dispatchEvent(new PointerEvent('pointerdown', { bubbles: true, composed: true, pointerId, pointerType: 'touch', isPrimary: true, button: 0, clientX: 500, clientY: 300 })); - stage.dispatchEvent(new PointerEvent('pointerup', { bubbles: true, composed: true, pointerId, pointerType: 'touch', isPrimary: true, button: 0, clientX: 501, clientY: 300 })); + for (const pointerId of [4, 5]) { + fire('pointerdown', pointerId, centerX, centerY); + fire('pointerup', pointerId, centerX + 1, centerY); } const resetStarted = performance.now(); while (c._cameraTransition?.active) { diff --git a/demo/smoke_kiosk_pan_lock.mjs b/demo/smoke_kiosk_pan_lock.mjs index c73a0730..56f82ae2 100644 --- a/demo/smoke_kiosk_pan_lock.mjs +++ b/demo/smoke_kiosk_pan_lock.mjs @@ -2,8 +2,9 @@ // audit DEV-1DA1-02 (P2). // // `_stagePointerMove` locks the gesture on the first movement past 8 px -// (`_panLock`: 'swipe' if it is horizontal enough inside the swipe zone, 'pan' -// otherwise) and the plan then follows the finger. But `_stagePointerUp` used +// (`_panLock`: 'swipe' only for a sufficiently horizontal inward move that +// started inside the 48 px strip of an edge with a neighbour, 'pan' otherwise). +// A pan then follows the finger. But `_stagePointerUp` used // to ignore that lock and ask `swipeTarget()` again, from the raw start→end // vector alone. A CURVED gesture — a small vertical lead-in that locks 'pan', // then a long horizontal sweep — therefore panned under the finger and still @@ -12,7 +13,7 @@ // // The lock is now final: with `_panLock === 'pan'` the floor never changes, no // matter what the overall vector looks like. The two straight gestures keep -// their old behaviour, and so does the motionless double tap (no movement, +// their intended behaviour, and so does the motionless double tap (no movement, // no lock — the swipe path is never even reached by it). import { launch, checkAll, finish } from './serve.mjs'; const { page, browser } = await launch(); @@ -29,6 +30,10 @@ const out = await page.evaluate(async () => { await k.updateComplete; const sr = k.shadowRoot || k.renderRoot; const stage = sr.querySelector('.stage'); + const rect = stage.getBoundingClientRect(); + const centerX = rect.left + rect.width / 2; + const centerY = rect.top + rect.height / 2; + const rightEdgeX = rect.right - 20; const fire = (type, id, x, y) => stage.dispatchEvent(new PointerEvent(type, { bubbles: true, composed: true, cancelable: true, pointerId: id, pointerType: 'touch', isPrimary: true, button: 0, clientX: x, clientY: y, @@ -36,8 +41,10 @@ const out = await page.evaluate(async () => { o.kioskHasSeveralSpaces = k._model.length > 1; const home = async () => { + k._commitSpace(k._model[0].id, true); const vb = k._baseVb(); k._applyView(1, vb[0] + vb[2] / 2, vb[1] + vb[3] / 2); + k.requestUpdate(); await k.updateComplete; }; /** @@ -67,42 +74,76 @@ const out = await page.evaluate(async () => { // ---- 1. the auditor's curved pan: vertical lead-in, horizontal ending -- // (500,300) → (502,312) locks 'pan' → curve left to (350,304) → release. // dx = -150, dy = +4: swipeTarget() would happily call that a swipe. - const curvedPan = await play(51, [500, 300], [[502, 312], [460, 310], [400, 306], [350, 304]]); + const curvedPan = await play(51, [centerX, centerY], [ + [centerX + 2, centerY + 12], [centerX - 40, centerY + 10], + [centerX - 100, centerY + 6], [centerX - 150, centerY + 4], + ]); o.curvedPanLocksPan = curvedPan.lockAfterLeadIn === 'pan'; o.curvedPanKeepsTheLock = curvedPan.lockBeforeRelease === 'pan'; o.curvedPanActuallyPans = curvedPan.moved === true; o.curvedPanKeepsTheFloor = curvedPan.s1 === curvedPan.s0; // the same trajectory the other way round — a swipe to the right would have // been the previous floor, so the bug is symmetric and so is the fix - const curvedPanRight = await play(52, [400, 300], [[402, 312], [460, 308], [520, 305], [560, 304]]); + const curvedPanRight = await play(52, [centerX, centerY], [ + [centerX + 2, centerY + 12], [centerX + 60, centerY + 8], + [centerX + 120, centerY + 5], [centerX + 160, centerY + 4], + ]); o.curvedPanRightLocksPan = curvedPanRight.lockAfterLeadIn === 'pan'; o.curvedPanRightKeepsTheFloor = curvedPanRight.s1 === curvedPanRight.s0; // a long diagonal that ends up dominated by x, but started as a pan - const diagonalPan = await play(53, [500, 300], [[496, 316], [420, 340], [330, 350], [260, 352]]); + const diagonalPan = await play(53, [centerX, centerY], [ + [centerX - 4, centerY + 16], [centerX - 80, centerY + 40], + [centerX - 170, centerY + 50], [centerX - 240, centerY + 52], + ]); o.diagonalPanLocksPan = diagonalPan.lockAfterLeadIn === 'pan'; o.diagonalPanKeepsTheFloor = diagonalPan.s1 === diagonalPan.s0; + // Even when a drag starts in the active edge strip, a vertical first move + // owns it as pan forever; a later horizontal tail cannot reclassify release. + const edgeCurvedPan = await play(62, [rightEdgeX, centerY], [ + [rightEdgeX - 2, centerY + 14], [rightEdgeX - 80, centerY + 10], + [rightEdgeX - 170, centerY + 5], + ]); + o.edgeCurvedPanLocksPan = edgeCurvedPan.lockAfterLeadIn === 'pan'; + o.edgeCurvedPanActuallyPans = edgeCurvedPan.moved === true; + o.edgeCurvedPanKeepsTheFloor = edgeCurvedPan.s1 === edgeCurvedPan.s0; // ---- 2. a gesture locked as a SWIPE keeps its own semantics ------------ // horizontal lead-in locks 'swipe'; the plan must not slide under it, even // when the trajectory then bends vertically and the final vector no longer // qualifies — the floor simply stays, and nothing pans - const curvedSwipe = await play(54, [600, 300], [[540, 302], [536, 380], [545, 500]]); + const curvedSwipe = await play(54, [rightEdgeX, centerY], [ + [rightEdgeX - 60, centerY + 2], [rightEdgeX - 64, centerY + 80], + [rightEdgeX - 55, centerY + 200], + ]); o.curvedSwipeLocksSwipe = curvedSwipe.lockAfterLeadIn === 'swipe'; o.curvedSwipeNeverPans = curvedSwipe.moved === false; o.curvedSwipeThatDiesChangesNothing = curvedSwipe.s1 === curvedSwipe.s0; // a swipe that bends but still ends as a swipe does switch the floor - const bentSwipe = await play(55, [600, 300], [[540, 302], [470, 330], [420, 340]]); + const bentSwipe = await play(55, [rightEdgeX, centerY], [ + [rightEdgeX - 60, centerY + 2], [rightEdgeX - 130, centerY + 30], + [rightEdgeX - 180, centerY + 40], + ]); o.bentSwipeLocksSwipe = bentSwipe.lockAfterLeadIn === 'swipe'; o.bentSwipeNeverPans = bentSwipe.moved === false; o.bentSwipeStillSwitches = bentSwipe.s1 !== bentSwipe.s0; // ---- 3. the straight gestures are exactly as they were ----------------- - const straightSwipe = await play(56, [600, 300], [[540, 302], [480, 305], [450, 305]]); + const straightSwipe = await play(56, [rightEdgeX, centerY], [ + [rightEdgeX - 60, centerY + 2], [rightEdgeX - 120, centerY + 5], + [rightEdgeX - 150, centerY + 5], + ]); o.straightSwipeSwitches = straightSwipe.s1 !== straightSwipe.s0; o.straightSwipeDoesNotPan = straightSwipe.moved === false; - const straightPan = await play(57, [450, 200], [[452, 260], [454, 330]]); + const straightPan = await play(57, [centerX, centerY - 100], [ + [centerX + 2, centerY - 40], [centerX + 4, centerY + 30], + ]); o.straightPanPans = straightPan.moved === true; o.straightPanKeepsTheFloor = straightPan.s1 === straightPan.s0; + const centralHorizontalPan = await play(61, [centerX, centerY], [ + [centerX - 60, centerY + 2], [centerX - 150, centerY + 5], + ]); + o.centralHorizontalDragPans = centralHorizontalPan.moved === true; + o.centralHorizontalDragKeepsTheFloor = centralHorizontalPan.s1 === centralHorizontalPan.s0; // ---- 4. a motionless double tap still resets the zoom ------------------ // no movement means no lock at all, so nothing above can reach this path @@ -110,8 +151,8 @@ const out = await page.evaluate(async () => { k._applyView(2); await k.updateComplete; for (const id of [58, 60]) { - fire('pointerdown', id, 500, 300); - fire('pointerup', id, 501, 300); + fire('pointerdown', id, centerX, centerY); + fire('pointerup', id, centerX + 1, centerY); } const resetStarted = performance.now(); do { await new Promise((resolve) => requestAnimationFrame(resolve)); } @@ -125,11 +166,11 @@ const out = await page.evaluate(async () => { await k.updateComplete; const zoomed = await (async () => { const s0 = k._space; - fire('pointerdown', 59, 600, 300); - fire('pointermove', 59, 540, 302); + fire('pointerdown', 59, rightEdgeX, centerY); + fire('pointermove', 59, rightEdgeX - 60, centerY + 2); const lock = k._panLock; - fire('pointermove', 59, 480, 305); - fire('pointerup', 59, 480, 305); + fire('pointermove', 59, rightEdgeX - 120, centerY + 5); + fire('pointerup', 59, rightEdgeX - 120, centerY + 5); await k.updateComplete; return { lock, same: k._space === s0 }; })(); diff --git a/demo/smoke_room_fit.mjs b/demo/smoke_room_fit.mjs index 756946da..ee008dd8 100644 --- a/demo/smoke_room_fit.mjs +++ b/demo/smoke_room_fit.mjs @@ -45,6 +45,8 @@ const out = await page.evaluate(async () => { } await c.updateComplete; }; + const waitMs = (milliseconds) => new Promise((resolve) => setTimeout(resolve, milliseconds)); + const waitRoomDecision = () => waitMs(370); const pointer = (target, type, id, x = 500, y = 300, extra = {}) => target.dispatchEvent(new PointerEvent(type, { bubbles: true, composed: true, cancelable: true, pointerId: id, @@ -66,8 +68,17 @@ const out = await page.evaluate(async () => { }; localStorage.setItem('houseplan_card_zoom_v1', JSON.stringify({ 'fit-floor': 1 })); + const beforeDelayedRoomFit = JSON.stringify({ zoom: c._zoom, view: c._view }); pointer(roomNode(), 'pointerdown', 15201); pointer(roomNode(), 'pointerup', 15201); + result.singleRoomTapWaitsForSecondTap = !c._cameraTransition?.active + && c._roomFocus === null + && JSON.stringify({ zoom: c._zoom, view: c._view }) === beforeDelayedRoomFit; + await waitMs(120); + result.singleRoomTapStillWaitsInsideWindow = !c._cameraTransition?.active + && c._roomFocus === null + && JSON.stringify({ zoom: c._zoom, view: c._view }) === beforeDelayedRoomFit; + await waitMs(250); const tweenStarted = c._cameraTransition?.active === true; const syncFrames = []; if (tweenStarted) { @@ -108,6 +119,7 @@ const out = await page.evaluate(async () => { const settled = JSON.stringify(c._view); pointer(roomNode(), 'pointerdown', 15202); pointer(roomNode(), 'pointerup', 15202); + await waitRoomDecision(); await waitCamera(); result.repeatedFitIsNoOp = !c._cameraTransition.active && JSON.stringify(c._view) === settled; @@ -158,10 +170,27 @@ const out = await page.evaluate(async () => { result.areaLinkSuppressesRoomFit = JSON.stringify(c._view) === beforeLink && c._roomPointer === null && c._roomFocus === null; + c._clearRoomFocus(true); + pointer(roomNode(), 'pointerdown', 152041); + pointer(roomNode(), 'pointerup', 152041); + pointer(ownerProbe, 'pointerdown', 152042); + pointer(ownerProbe, 'pointerup', 152042); + await waitRoomDecision(); + result.interactiveSecondTapCancelsPendingRoomFit = c._roomFocus === null + && !c._cameraTransition.active; + + c._clearRoomFocus(true); + pointer(roomNode(), 'pointerdown', 152043); + pointer(roomNode(), 'pointerup', 152043); + c._clearRoomFocus(true); + await waitRoomDecision(); + result.lifecycleCleanupCancelsPendingRoomFit = c._roomFocus === null + && !c._cameraTransition.active; + const stage = root.querySelector('.stage'); const base = c._baseVb(); const moveAway = async () => { - c._doubleFit.clear(); + c._clearRoomFocus(true); c._applyView(2, base[0] + base[2] * 0.65, base[1] + base[3] * 0.4); await c.updateComplete; }; @@ -211,14 +240,14 @@ const out = await page.evaluate(async () => { result.cancelDisarmsThePreviousTap = c._zoom === 2 && !c._cameraTransition.active; c._mode = 'decor'; - c._doubleFit.clear(); + c._clearRoomFocus(true); pointer(stage, 'pointerdown', 44909, 400, 500); pointer(stage, 'pointerup', 44909, 400, 500); pointer(stage, 'pointerdown', 44910, 400, 500); pointer(stage, 'pointerup', 44910, 400, 500); result.editorBackgroundDoesNotFit = c._zoom === 2 && !c._cameraTransition.active; c._mode = 'view'; - c._doubleFit.clear(); + c._clearRoomFocus(true); pointer(roomNode(), 'pointerdown', 15205, 500, 300); pointer(roomNode(), 'pointermove', 15205, 520, 300); @@ -228,6 +257,7 @@ const out = await page.evaluate(async () => { pointer(roomNode(), 'pointerdown', 15206, 500, 300); pointer(roomNode(), 'pointerup', 15206, 500, 300); + await waitRoomDecision(); await waitCamera(); c.style.width = '760px'; c._lastValidStageSize = [1000, c._stageEl.clientHeight]; @@ -246,6 +276,7 @@ const out = await page.evaluate(async () => { await c.updateComplete; pointer(roomNode(), 'pointerdown', 15207); pointer(roomNode(), 'pointerup', 15207); + await waitRoomDecision(); await waitCamera(); const isoMargins = margins(); result.isoUsesProjectedBounds = c._effectiveProjection() === 'iso' @@ -267,19 +298,31 @@ const out = await page.evaluate(async () => { pointerType: 'touch', isPrimary: true, button: 0, buttons: type === 'pointerdown' ? 1 : 0, clientX: 450, clientY: 300, })); - for (const id of [15208, 15209]) { - kioskPointer(kioskRoom, 'pointerdown', id); - kioskPointer(kioskRoom, 'pointerup', id); - const started = performance.now(); - while (kiosk._cameraTransition.active && performance.now() - started < 1200) { - await new Promise((resolve) => requestAnimationFrame(resolve)); - } + kioskPointer(kioskRoom, 'pointerdown', 15208); + kioskPointer(kioskRoom, 'pointerup', 15208); + const kioskSingleWaits = kiosk._roomFocus === null && !kiosk._cameraTransition.active; + await waitRoomDecision(); + while (kiosk._cameraTransition.active) { + await new Promise((resolve) => requestAnimationFrame(resolve)); } - const beforeKioskBackground = JSON.stringify({ zoom: kiosk._zoom, view: kiosk._view }); + result.kioskSingleRoomTapFitsAfterDelay = kioskSingleWaits + && kiosk._roomFocus?.roomId === 'room-a'; + + kiosk._clearRoomFocus(true); + kiosk._applyView(2); + const beforeKioskPair = JSON.stringify({ zoom: kiosk._zoom, view: kiosk._view }); + kioskPointer(kioskRoom, 'pointerdown', 15209); + kioskPointer(kioskRoom, 'pointerup', 15209); + const kioskRoomPairHasNoIntermediateMotion = !kiosk._cameraTransition.active + && JSON.stringify({ zoom: kiosk._zoom, view: kiosk._view }) === beforeKioskPair; kioskPointer(kiosk._stageEl, 'pointerdown', 15210); kioskPointer(kiosk._stageEl, 'pointerup', 15210); - result.kioskRoomTapDoesNotEnterDoubleTap = kiosk._roomFocus?.roomId === 'room-a' - && JSON.stringify({ zoom: kiosk._zoom, view: kiosk._view }) === beforeKioskBackground; + result.kioskRoomThenBackgroundUsesDoubleFit = kioskRoomPairHasNoIntermediateMotion + && kiosk._cameraTransition.state?.reason === 'double-tap' + && kiosk._roomFocus === null; + while (kiosk._cameraTransition.active) { + await new Promise((resolve) => requestAnimationFrame(resolve)); + } kiosk._clearRoomFocus(true); kiosk._applyView(2); for (const id of [15211, 15212]) { diff --git a/docs/CANVAS.md b/docs/CANVAS.md index 0c2da9f5..acb2a07d 100644 --- a/docs/CANVAS.md +++ b/docs/CANVAS.md @@ -263,7 +263,7 @@ produce a zero-sized SVG `viewBox`. * **Zoom out** — `MIN_ZOOM = 1/3`: you can see three times the content frame and no further. Empty space beyond that is not information. * **Discrete camera motion** (#82) — wheel, `−`/`+`, Fit all, the home arrow - and the free-background double-click/tap in View or kiosk interpolate the + and the plan-surface double-click/tap in View or kiosk interpolate the existing exact camera target for 160–220 ms with `cubic-bezier(0.2, 0.7, 0.2, 1)`. Zoom is logarithmic and the world centre is linear; the final frame is the same clamped `viewBox` @@ -291,10 +291,12 @@ produce a zero-sized SVG `viewBox`. bails out on them), and a drawing tool that consumes the press — decor line/rect/ellipse/text — bails out too. Two fingers are always a pinch, never a pan. On a **kiosk** screen at swipe zoom (`≤ 1`, - more than one space) a *horizontal* drag belongs to the floor swipe: - the gesture is classified once, on the first movement past 8 px, and - keeps that role until the finger lifts (`_panLock`), so the plan - never slides under a swipe and a vertical drag still pans. + more than one space), a floor swipe may start only inside the inner + **48 CSS px** strip of the left/right stage edge that has a previous/next + space. The first movement past the 8 px Manhattan boundary becomes a swipe + only when it points inward and `|dx| > 1.5 × |dy|`; equality, the wrong + direction, a start outside the strip and an edge without a neighbour all + become ordinary pan. The decision remains final until release (`_panLock`). * **Room fit (#152).** A clean primary click/tap on the browser-painted room target fits that room's final floor plus its visible boundary wall body into the middle 80% of the stage. Devices, openings/actions and the HA Area link @@ -303,15 +305,19 @@ produce a zero-sized SVG `viewBox`. does not write the per-space zoom preference. Its session-only room intent is reapplied atomically on a stable stage resize, then cleared by manual camera input, Fit all/home, mode/space/projection changes, structural - adoption, hidden state or disconnect. In kiosk, room-owned taps never enter - the free-background double-tap sequence. -* **Free-background fit (#449).** Two clean primary clicks/taps within 350 ms - on the stage background invoke the same Fit all command in View and kiosk. - Room, device, vacuum, opening, link and control paths disarm the sequence; - so do pan, pinch, swipe, long press, cancellation, editor/mode/space/projection - changes and lifecycle adoption. The new recognizer adds no timer or render on - the first tap. Mouse, touch and pen sequences are separate; editors do not - expose this shortcut. + adoption, hidden state or disconnect. A pointer tap waits up to 350 ms before + applying room fit: this lets a second clean plan tap select Fit all without + first animating into the room. Keyboard activation remains immediate. +* **Plan-surface fit (#449, #691).** Two clean primary clicks/taps within 350 ms + on stage background, room fill or a non-interactive room label invoke the + same Fit all command in View and kiosk. The two taps may use different plan + surfaces or rooms, but must use the same pointer modality and space. A single + room tap still fits that room once its 350 ms decision window expires; a + single background tap stays passive. Device, vacuum, opening, stair, link and + control paths cancel both the sequence and any pending room fit; so do pan, + pinch, swipe, long press, cancellation, editor/mode/space/projection changes + and lifecycle adoption. Mouse, touch and pen share this contract but do not + pair with one another; editors do not expose the shortcut. * **The lock is final, at the release too** (audit DEV-1DA1-02). The release used to ask `swipeTarget()` again from the raw start→end vector, ignoring the lock — so a *curved* gesture (a short vertical @@ -321,7 +327,7 @@ produce a zero-sized SVG `viewBox`. 'pan'` now means no floor change, whatever the overall vector ends up looking like; only a gesture locked as `swipe` may reach `swipeTarget()`, and it never pans on the way. A motionless tap locks - nothing, so a clean free-background double-tap remains available. + nothing, so a clean plan-surface double-tap remains available. * **"Home is that way" arrow** — when the content frame is entirely outside the current view, a small pointer appears at the view edge in the frame's direction. Clicking it fits the content. Cheap diff --git a/docs/CHANGELOG.md b/docs/CHANGELOG.md index de24e024..266fcf9f 100644 --- a/docs/CHANGELOG.md +++ b/docs/CHANGELOG.md @@ -2,6 +2,13 @@ ## Unreleased +- Touch navigation now keeps ordinary one-finger drags as pan and switches + kiosk spaces only from a 48 px inner edge with an available neighbour. Two + clean taps on background, a room fill or a passive room label fit the whole + plan without first jumping into the room; a single room tap still fits after + the 350 ms decision window, consistently for touch, mouse and pen + ([#691](https://github.com/Matysh/houseplan-card/issues/691)). + - Stair outlines, treads, trapezoids and direction arrows now share one 3.6 cm physical line weight. It scales with the plan instead of staying fixed in screen pixels, remains uniform when a stair is resized or rotated, and uses diff --git a/docs/CHANGELOG.ru.md b/docs/CHANGELOG.ru.md index c5b4702d..72e22bc2 100755 --- a/docs/CHANGELOG.ru.md +++ b/docs/CHANGELOG.ru.md @@ -8,6 +8,14 @@ ## Не выпущено +- Навигация на сенсорных экранах теперь сохраняет обычный drag одним пальцем + для панорамирования, а пространство в киоске переключается только жестом из + внутренней 48-пиксельной зоны края, за которым есть соседнее пространство. + Два чистых тапа по фону, заливке комнаты или пассивной подписи вписывают весь + план без промежуточного прыжка в комнату; одиночный тап по комнате по-прежнему + вписывает её после окна выбора 350 мс — одинаково для touch, мыши и пера + ([#691](https://github.com/Matysh/houseplan-card/issues/691)). + - Контур, ступени, трапеция и стрелка направления лестницы теперь используют единую физическую толщину 3,6 см. Она масштабируется вместе с планом, а не остаётся постоянной в экранных пикселях, не искажается при изменении размера diff --git a/docs/TOUCH-SUPPORT.md b/docs/TOUCH-SUPPORT.md index 0bd9fba1..4a3d0f08 100644 --- a/docs/TOUCH-SUPPORT.md +++ b/docs/TOUCH-SUPPORT.md @@ -39,8 +39,12 @@ On phones, tablets, wall panels and HA Companion apps, the ordinary View must: - support convenient pan, pinch zoom and space switching; - treat a linked stair as a floor-navigation target only after a clean tap; pan, pinch, long press, swipe and cancellation tails must stay inert; -- fit the whole plan after two clean taps on free scene background, while one - tap on a room keeps the immediate room-fit action; +- reserve floor switching for an inward horizontal gesture that starts in the + 48 CSS px strip of an edge with a neighbouring space; a drag elsewhere and + an unavailable edge must pan the plan; +- fit the whole plan after two clean taps on background, room fill or a passive + room label. A single room tap fits only after the 350 ms second-tap window, + with no intermediate camera motion; keyboard room activation stays immediate; - provide a touch path for essential information that desktop exposes through hover; - open and close View dialogs without clipping their essential content or diff --git a/docs/USER-GUIDE.md b/docs/USER-GUIDE.md index dd0e2bd3..63c9ea61 100644 --- a/docs/USER-GUIDE.md +++ b/docs/USER-GUIDE.md @@ -433,9 +433,9 @@ precise drawing, Resize, keyboard modifiers and double-click properties. | Scenario | Mouse | Touch View | Touch editors | Keyboard | |---|---|---|---|---| -| Zoom and pan | Wheel; drag empty space; `−`/`+`; double-click free background to Fit all | Pinch; drag; double-tap free background to Fit all | Available but precision is not guaranteed; no Fit-all double-tap | — | -| Room | A clean click fits the room with 10% margins | One tap fits the room; repeated room taps never become Fit all | — | `Enter`/`Space` on a visible room label | -| Change space | Click a tab | Tap; kiosk swipe at 1:1 | Tap a tab | — | +| Zoom and pan | Wheel; drag empty space; `−`/`+`; double-click background/room to Fit all | Pinch; drag; double-tap background/room to Fit all | Available but precision is not guaranteed; no Fit-all double-tap | — | +| Room | One clean click fits the room after the 350 ms double-click window | One clean tap fits the room after the 350 ms double-tap window | — | Immediate `Enter`/`Space` on a visible room label | +| Change space | Click a tab | Tap; in kiosk at 1:1, swipe inward from the 48 px edge that has a neighbour | Tap a tab | — | | Device | Click/double-click per mode | Tap; safe actions equal desktop | Drag/properties are best effort | `Esc` closes the top surface | | Walls drawing or precise drag | Full contract | Not applicable | Best effort; use desktop for Resize and exact nodes | `Shift` changes magnet/angle; `Esc` finishes a Walls chain or cancels the current precise drag | | Editor history | Undo/Redo controls | Not applicable | Controls may work; no gesture guarantee | `Ctrl/Cmd+Z`, `Ctrl/Cmd+Shift+Z`, `Ctrl+Y` | @@ -443,7 +443,7 @@ precise drawing, Resize, keyboard modifiers and double-click properties. Important details: -- wheel, `−`/`+`, **Fit all**, the return arrow and a free-background +- wheel, `−`/`+`, **Fit all**, the return arrow and a plan-surface double-click/tap in View or kiosk use a short smooth camera transition; rapid wheel events update one destination instead of building a queue; - pinch and pan remain directly under the fingers; reduced motion makes every @@ -454,8 +454,10 @@ Important details: - each space keeps its own local View viewport. Editor pan/zoom is a working view and does not replace it; - a hint appears when objects lie far away from the main plan; -- above 1:1, a horizontal kiosk gesture pans instead of changing space. Any - manual kiosk operation pauses auto-cycle for 60 seconds; +- a kiosk space swipe starts only in the inner 48 CSS px of an edge with a + neighbouring space, points inward and stays strongly horizontal. Elsewhere, + at an unavailable edge and above 1:1 the gesture pans. Any manual kiosk + operation pauses auto-cycle for 60 seconds; - `Shift` keeps positional grid snapping. For room walls it locks the current segment to the nearest 45° direction; in Background it creates a square or circle, allows independent axes for ordinary decor, unlocks furniture @@ -1714,9 +1716,9 @@ cycle: 30 | Card header | Hidden | | Editors | Unavailable | | Height | `100dvh` | -| Swipe | Cycles spaces at 1:1 | +| Swipe | At 1:1, switches to an existing neighbour from the inner 48 px edge | | Pinch/pan | Zooms and moves the plan | -| Double-tap free background | Fits all content | +| Double-tap background or room | Fits all content without an intermediate room jump | | Hold empty space for 3 seconds | Opens per-display icon/text sizing | | `cycle` | Automatically advances; any interaction pauses it for 60 seconds | diff --git a/docs/UX-MODES.md b/docs/UX-MODES.md index 2d462690..b43786de 100644 --- a/docs/UX-MODES.md +++ b/docs/UX-MODES.md @@ -95,13 +95,14 @@ support. User documentation recommends desktop for creation and maintenance. ## View — display and device interaction only -Allowed: pan/zoom (wheel, pinch, buttons, double-click/tap on free background -to Fit all), switching spaces, device tap +Allowed: pan/zoom (wheel, pinch, buttons, double-click/tap on background, room +fill or passive room label to Fit all), switching spaces, device tap (info / more-info / toggle per settings), long-press → info card, lock-badge tap → door/lock info card (openings themselves are inert in View; the card carries an explicit Unlock/Lock button — the only way to operate a lock from the card; plan-icon taps never toggle locks), -room-card link icon → HA area, clean room click/tap → room fit, room hover +room-card link icon → HA area, clean single room click/tap → delayed room fit +(350 ms decision window; keyboard activation is immediate), room hover highlight, hover tooltips (name, clean-floor area, temperature, signal). Room, device, opening and shared-control hover is a mouse-only transient layer: diff --git a/docs/images/screenshots.json b/docs/images/screenshots.json index 77be263e..9a8896ba 100644 --- a/docs/images/screenshots.json +++ b/docs/images/screenshots.json @@ -3,7 +3,7 @@ "fixture": "synthetic-only", "chromium": "151.0.7922.34", "oxipng": null, - "sourceFingerprint": "fa82f6c7697f8d2ec7ab4fc2c255b9f6e5a16b3636f5823f85d93819a85d93d7", + "sourceFingerprint": "4955edd1dd8d30dad8ae5b4eb12ba23943124646e541ea491287d01550c94870", "captureScriptSha256": "e2d14d89de793011615662af7c83c433768d4761f6cec7f0b3860c797494299e", "command": "npm run build && node demo/docs/capture.mjs", "scenarios": { @@ -15,7 +15,7 @@ }, "theme": "dark", "language": "en", - "sourceSha256": "fa82f6c7697f8d2ec7ab4fc2c255b9f6e5a16b3636f5823f85d93819a85d93d7", + "sourceSha256": "4955edd1dd8d30dad8ae5b4eb12ba23943124646e541ea491287d01550c94870", "imageSha256": "de1ee062cfffa375f66ff74509ad9ddbf6aabc5c5440733fe1ff0ea5a40e96c7" }, "view-touch": { @@ -26,7 +26,7 @@ }, "theme": "dark", "language": "en", - "sourceSha256": "fa82f6c7697f8d2ec7ab4fc2c255b9f6e5a16b3636f5823f85d93819a85d93d7", + "sourceSha256": "4955edd1dd8d30dad8ae5b4eb12ba23943124646e541ea491287d01550c94870", "imageSha256": "f9dd5b54f86b814c00bc84149af84485f052b103cdcc9263b5c50bf5d32c69d6" }, "space-create": { @@ -37,7 +37,7 @@ }, "theme": "dark", "language": "en", - "sourceSha256": "fa82f6c7697f8d2ec7ab4fc2c255b9f6e5a16b3636f5823f85d93819a85d93d7", + "sourceSha256": "4955edd1dd8d30dad8ae5b4eb12ba23943124646e541ea491287d01550c94870", "imageSha256": "aab4811bba3afa6cc849cb9d3989d126b8c327b69a2f8f853e55fc077e0e9a1b" }, "room-contour-close": { @@ -48,7 +48,7 @@ }, "theme": "dark", "language": "en", - "sourceSha256": "fa82f6c7697f8d2ec7ab4fc2c255b9f6e5a16b3636f5823f85d93819a85d93d7", + "sourceSha256": "4955edd1dd8d30dad8ae5b4eb12ba23943124646e541ea491287d01550c94870", "imageSha256": "96a7e691ad8167103199a58b48b31ae2fc4f03e9616b65a5096dc8d0b9b51da0" }, "plan-context-tray": { @@ -59,7 +59,7 @@ }, "theme": "dark", "language": "en", - "sourceSha256": "fa82f6c7697f8d2ec7ab4fc2c255b9f6e5a16b3636f5823f85d93819a85d93d7", + "sourceSha256": "4955edd1dd8d30dad8ae5b4eb12ba23943124646e541ea491287d01550c94870", "imageSha256": "ebb89bacfecd16178534e1f02fed8bb7540072a691ec4d4b14407c0b8ec4455e" }, "device-editor": { @@ -70,7 +70,7 @@ }, "theme": "dark", "language": "en", - "sourceSha256": "fa82f6c7697f8d2ec7ab4fc2c255b9f6e5a16b3636f5823f85d93819a85d93d7", + "sourceSha256": "4955edd1dd8d30dad8ae5b4eb12ba23943124646e541ea491287d01550c94870", "imageSha256": "b40f843d7bcab902a6f697cc76735e6d3351a95443fe11ba761f0e39613120bc" }, "device-display-preview": { @@ -81,7 +81,7 @@ }, "theme": "dark", "language": "en", - "sourceSha256": "fa82f6c7697f8d2ec7ab4fc2c255b9f6e5a16b3636f5823f85d93819a85d93d7", + "sourceSha256": "4955edd1dd8d30dad8ae5b4eb12ba23943124646e541ea491287d01550c94870", "imageSha256": "835e9f1b9446072a2caa2421b06a669eac9e5c0e3e6cf94106dd6cc2dd0d8e2b" }, "background-editor": { @@ -92,7 +92,7 @@ }, "theme": "dark", "language": "en", - "sourceSha256": "fa82f6c7697f8d2ec7ab4fc2c255b9f6e5a16b3636f5823f85d93819a85d93d7", + "sourceSha256": "4955edd1dd8d30dad8ae5b4eb12ba23943124646e541ea491287d01550c94870", "imageSha256": "98a8c735165912b49b4254d2f975a61891e5737b0a9c16c627f08d60853fa8a7" }, "room-card": { @@ -103,7 +103,7 @@ }, "theme": "dark", "language": "en", - "sourceSha256": "fa82f6c7697f8d2ec7ab4fc2c255b9f6e5a16b3636f5823f85d93819a85d93d7", + "sourceSha256": "4955edd1dd8d30dad8ae5b4eb12ba23943124646e541ea491287d01550c94870", "imageSha256": "d47988b03e6a078508b27b5612b6eafe8ecc33c891b589ddda1cc7c71f43b7c8" }, "device-info": { @@ -114,7 +114,7 @@ }, "theme": "dark", "language": "en", - "sourceSha256": "fa82f6c7697f8d2ec7ab4fc2c255b9f6e5a16b3636f5823f85d93819a85d93d7", + "sourceSha256": "4955edd1dd8d30dad8ae5b4eb12ba23943124646e541ea491287d01550c94870", "imageSha256": "a10e6044376b087ba6800a31a229e4166a6797820104860f85a4659d89ee8045" }, "pdf-export": { @@ -125,7 +125,7 @@ }, "theme": "light", "language": "en", - "sourceSha256": "fa82f6c7697f8d2ec7ab4fc2c255b9f6e5a16b3636f5823f85d93819a85d93d7", + "sourceSha256": "4955edd1dd8d30dad8ae5b4eb12ba23943124646e541ea491287d01550c94870", "imageSha256": "788a1ff32d6ceb51c9fb47ea37922ab15aeddf3fdcf3686bfcb0709634c41f0e" } }, diff --git a/scripts/bundle-budget.mjs b/scripts/bundle-budget.mjs index 5bef65e2..5695cbe8 100644 --- a/scripts/bundle-budget.mjs +++ b/scripts/bundle-budget.mjs @@ -380,8 +380,15 @@ export const LOW_HEADROOM_WARNING_BYTES = 15_000; * eager View, иначе браузер растягивает кешированный pattern и оставляет стены * размытыми в статике. Новый центр оставляет 587 Б сверху и 1 413 Б до нижней * границы полосы; общий бюджет 301 066 Б не меняется. + * + * 2026-09-28, #691: 300 300 → 301 000 (замер 300 370). Арбитраж touch-pan + * против краевого переключения пространства и единый 350 мс plan-tap + * контроллер нужны непосредственно в View/kiosk до загрузки редактора. + * Таймер и recognizer вынесены из монолита карточки в room-fit.ts, а редакторы + * не получили нового кода. Центр оставляет 630 Б сверху и 1 370 Б до нижней + * границы полосы; общий бюджет 301 066 Б не меняется. */ -export const INITIAL_VIEW_GZIP_CEILING = 300_300; +export const INITIAL_VIEW_GZIP_CEILING = 301_000; export const INITIAL_VIEW_CEILING_BAND = 2_000; /** diff --git a/scripts/mutation-registry.mjs b/scripts/mutation-registry.mjs index ab3b370a..c1421691 100644 --- a/scripts/mutation-registry.mjs +++ b/scripts/mutation-registry.mjs @@ -3401,8 +3401,8 @@ const MUTANT_DEFINITIONS = [ + 'is released over the original room (#152 AC7)', patches: [{ file: 'src/houseplan-card.ts', - find: ' this._roomPointer = null;\n this._doubleFit.clear();\n this._suppressClick = true;', - replace: ' this._doubleFit.clear();\n this._suppressClick = true;', + find: ' this._roomPointer = null;\n this._clearPlanTapSequence();\n this._suppressClick = true;', + replace: ' this._clearPlanTapSequence();\n this._suppressClick = true;', }, { file: 'src/room-fit.ts', find: ' if (!candidate || blocked || candidate.pointerId !== pointerId', @@ -9405,14 +9405,14 @@ const MUTANT_DEFINITIONS = [ }], }, { - id: 'double-fit-free-background-owner-removed', - guard: 'node --test --test-name-pattern="#449 only" test/room-fit.test.mjs', - because: 'room, device and opening owners must never become a half of the free-background ' - + 'double-fit sequence (#449 AC4–AC5)', + id: 'double-fit-plan-owner-guard-removed', + guard: 'node --test --test-name-pattern="primary clean View press" test/room-fit.test.mjs', + because: 'background and room surfaces are the only valid halves of the plan double-fit; ' + + 'devices, openings and other interactive owners must remain excluded (#691 AC6)', patches: [{ file: 'src/room-fit.ts', - find: " if (!modality || input.mode !== 'view' || input.owner.kind !== 'background'", - replace: " if (!modality || input.mode !== 'view' || false", + find: " || (input.owner.kind !== 'background' && input.owner.kind !== 'room')", + replace: ' || false // mutant: interactive owners become plan taps', }], }, { @@ -9428,13 +9428,13 @@ const MUTANT_DEFINITIONS = [ }, { id: 'double-fit-editor-mode-enabled', - guard: 'node --test --test-name-pattern="#449 only" test/room-fit.test.mjs', + guard: 'node --test --test-name-pattern="primary clean View press" test/room-fit.test.mjs', because: 'the shortcut belongs only to View and kiosk; editor double-click contracts must ' + 'remain untouched (#449 AC7)', patches: [{ file: 'src/room-fit.ts', - find: "!modality || input.mode !== 'view' || input.owner.kind !== 'background'", - replace: "!modality || input.owner.kind !== 'background'", + find: " if (!modality || input.mode !== 'view'\n || (input.owner.kind !== 'background' && input.owner.kind !== 'room')", + replace: " if (!modality\n || (input.owner.kind !== 'background' && input.owner.kind !== 'room')", }], }, { @@ -9444,8 +9444,46 @@ const MUTANT_DEFINITIONS = [ + 'room-focus cleanup, rather than resetting only camera zoom (#449 AC1, AC9)', patches: [{ file: 'src/houseplan-card.ts', - find: " if (doubleFit) this._fitAll('double-tap');", - replace: " if (doubleFit) this._resetZoom('double-tap');", + find: " fitAll: () => this._fitAll('double-tap'),", + replace: " fitAll: () => this._resetZoom('double-tap'), // mutant: bypass canonical fit-all", + }], + }, + { + id: 'space-swipe-edge-origin-guard-removed', + guard: 'node --test --test-name-pattern="edge swipe is available" test/logic.test.mjs', + because: 'a central drag and an edge without a neighbour must pan; only the 48 px strip ' + + 'beside an existing ordered neighbour may own a kiosk space swipe (#691 AC1–AC3)', + patches: [{ + file: 'src/logic.ts', + find: ' const previous = index > 0 && leftDistance <= edgePx;\n' + + ' const next = index < spaceIds.length - 1 && rightDistance <= edgePx;', + replace: ' const previous = index > 0; // mutant: every x is the left edge\n' + + ' const next = index < spaceIds.length - 1;', + }], + }, + { + id: 'space-swipe-release-ignores-final-owner', + guard: 'node --test --test-name-pattern="release obeys the final" test/logic.test.mjs', + because: 'an edge drag classified as pan by its first meaningful movement must not switch ' + + 'spaces when its final vector later looks horizontal (#691 AC3)', + patches: [{ + file: 'src/logic.ts', + find: " if (owner !== 'swipe') return null;", + replace: " if (owner === 'swipe') return null; // mutant: pan vectors are reclassified", + }], + }, + { + id: 'double-fit-interactive-pending-cancel-removed', + guard: 'node --test --test-name-pattern="controller owns one fake-clock" test/room-fit.test.mjs', + because: 'an interactive pointerdown must report cancellation to the card so its pending ' + + '350 ms room-fit timer cannot fire underneath the independent action (#691 AC6, AC8)', + patches: [{ + file: 'src/room-fit.ts', + find: ' if (!this.recognizer.clearNonPlan(event)) return false;\n' + + ' this.cancelPending();\n' + + ' return true;', + replace: ' if (!this.recognizer.clearNonPlan(event)) return false;\n' + + ' return true; // mutant: interactive intent leaves the pending timer armed', }], }, { diff --git a/src/houseplan-card.ts b/src/houseplan-card.ts index 2b439da5..290d9d1c 100755 --- a/src/houseplan-card.ts +++ b/src/houseplan-card.ts @@ -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(); 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); diff --git a/src/logic.ts b/src/logic.ts index 8dfec543..18b720e2 100644 --- a/src/logic.ts +++ b/src/logic.ts @@ -1998,6 +1998,89 @@ export function migratePdfUrls( // ---------------- 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), diff --git a/src/room-fit.ts b/src/room-fit.ts index 255a9d5f..295043e9 100644 --- a/src/room-fit.ts +++ b/src/room-fit.ts @@ -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); } } diff --git a/test/logic.test.mjs b/test/logic.test.mjs index 1cd42931..a4403a43 100644 --- a/test/logic.test.mjs +++ b/test/logic.test.mjs @@ -10,7 +10,7 @@ import { sharedBoundary, distToSegment, outlineWithout, alignGuides, segmentAngle, is45, - swipeTarget, clampScale, + swipeTarget, spaceSwipeEdgeAt, classifySpaceDrag, spaceSwipeTargetForOwner, clampScale, migratePdfUrls, roomFillModeOf, roomGlowOf, customFillOf, roomCustomFillOf, roomTempRangeOf, contentUrl, chunk, referencedContentUrls, MAX_SIGN_PATHS, @@ -1042,6 +1042,41 @@ test('swipeTarget: kiosk swipe rules', () => { assert.equal(swipeTarget(-100, 0, 1, ['f1'], 'f1'), null); }); +test('#691 edge swipe is available only beside an existing ordered neighbour', () => { + const ids = ['f1', 'f2', 'garden']; + assert.equal(spaceSwipeEdgeAt(100, 100, 900, ids, 'f2'), 'previous'); + assert.equal(spaceSwipeEdgeAt(148, 100, 900, ids, 'f2'), 'previous'); + assert.equal(spaceSwipeEdgeAt(149, 100, 900, ids, 'f2'), null); + assert.equal(spaceSwipeEdgeAt(952, 100, 900, ids, 'f2'), 'next'); + assert.equal(spaceSwipeEdgeAt(951, 100, 900, ids, 'f2'), null); + assert.equal(spaceSwipeEdgeAt(100, 100, 900, ids, 'f1'), null); + assert.equal(spaceSwipeEdgeAt(952, 100, 900, ids, 'f1'), 'next'); + assert.equal(spaceSwipeEdgeAt(100, 100, 900, ids, 'garden'), 'previous'); + assert.equal(spaceSwipeEdgeAt(952, 100, 900, ids, 'garden'), null); + assert.equal(spaceSwipeEdgeAt(130, 100, 80, ids, 'f2'), 'previous'); + assert.equal(spaceSwipeEdgeAt(160, 100, 80, ids, 'f2'), 'next'); + assert.equal(spaceSwipeEdgeAt(NaN, 100, 900, ids, 'f2'), null); + assert.equal(spaceSwipeEdgeAt(99, 100, 900, ids, 'f2'), null); +}); + +test('#691 edge swipe keeps the 8 px and strict 1.5 direction boundaries', () => { + assert.equal(classifySpaceDrag('previous', 8, 0), 'pending'); + assert.equal(classifySpaceDrag('previous', 9, 6), 'pan'); + assert.equal(classifySpaceDrag('previous', 10, 6), 'swipe'); + assert.equal(classifySpaceDrag('next', -10, 6), 'swipe'); + assert.equal(classifySpaceDrag('next', 10, 0), 'pan'); + assert.equal(classifySpaceDrag(null, -100, 0), 'pan'); + assert.equal(classifySpaceDrag('next', NaN, 0), 'pan'); +}); + +test('#691 release obeys the final drag owner instead of reclassifying the vector', () => { + const ids = ['f1', 'f2', 'f3']; + assert.equal(spaceSwipeTargetForOwner('pan', -100, 0, 1, ids, 'f1'), null); + assert.equal(spaceSwipeTargetForOwner(null, -100, 0, 1, ids, 'f1'), null); + assert.equal(spaceSwipeTargetForOwner('swipe', -100, 0, 1, ids, 'f1'), 'f2'); + assert.equal(spaceSwipeTargetForOwner('swipe', 100, 0, 1, ids, 'f2'), 'f1'); +}); + test('clampScale', () => { assert.equal(clampScale(2), 2); assert.equal(clampScale(9), 3); diff --git a/test/room-fit.test.mjs b/test/room-fit.test.mjs index a26b0f64..9c42cccb 100644 --- a/test/room-fit.test.mjs +++ b/test/room-fit.test.mjs @@ -5,6 +5,7 @@ import { beginDoubleFitPointer, completeDoubleFitPointer, DoubleFitGestureRecognizer, + PlanTapGestureController, DOUBLE_FIT_WINDOW_MS, planGestureOwnerFromPath, roomFitCameraTarget, @@ -157,6 +158,7 @@ test('#152 release accepts only the same pointer, space and painted room', () => }); const background = { kind: 'background' }; +const roomOwner = { kind: 'room', roomId: 'room-a' }; const down = (overrides = {}) => beginDoubleFitPointer({ pointerId: 1, pointerType: 'mouse', @@ -186,10 +188,14 @@ const up = (sequence, candidate, overrides = {}) => completeDoubleFitPointer( }, ); -test('#449 only a primary clean View press on free stage background becomes a candidate', () => { +test('#691 only a primary clean View press on a plan surface becomes a candidate', () => { assert.deepEqual(down(), { pointerId: 1, spaceId: 'floor-a', modality: 'mouse', x: 100, y: 100, }); + assert.deepEqual(down({ owner: roomOwner }), { + pointerId: 1, spaceId: 'floor-a', modality: 'mouse', x: 100, y: 100, + roomId: 'room-a', + }); assert.equal(down({ pointerType: 'touch' }).modality, 'touch'); assert.equal(down({ pointerType: 'pen' }).modality, 'pen'); for (const rejected of [ @@ -197,7 +203,6 @@ test('#449 only a primary clean View press on free stage background becomes a ca { isPrimary: false }, { button: 2 }, { mode: 'plan' }, - { owner: { kind: 'room', roomId: 'room-a' } }, { owner: { kind: 'interactive' } }, { owner: { kind: 'outside' } }, { blocked: true }, @@ -216,6 +221,32 @@ test('#449 two clean taps within 350 ms trigger once and clear before the comman }); }); +test('#691 a first room tap arms delayed room-fit and any second plan tap fits all', () => { + const roomCandidate = down({ owner: roomOwner }); + const first = up(null, roomCandidate, { owner: roomOwner }); + assert.deepEqual(first, { + sequence: { at: 1_000, spaceId: 'floor-a', modality: 'mouse' }, + trigger: false, + roomId: 'room-a', + }); + const roomToBackground = up(first.sequence, down({ pointerId: 2 }), { + pointerId: 2, now: 1_200, + }); + assert.deepEqual(roomToBackground, { sequence: null, trigger: true }); + + const backgroundFirst = up(null, down(), { now: 2_000 }); + const backgroundToRoom = up( + backgroundFirst.sequence, + down({ pointerId: 3, owner: roomOwner }), + { pointerId: 3, owner: roomOwner, now: 2_100 }, + ); + assert.deepEqual(backgroundToRoom, { sequence: null, trigger: true }); + + assert.deepEqual(up(null, roomCandidate, { + owner: { kind: 'room', roomId: 'room-b' }, + }), { sequence: null, trigger: false }); +}); + test('#449 an expired tap becomes the new first tap and modalities never mix', () => { const old = { at: 1_000, spaceId: 'floor-a', modality: 'mouse' }; const expired = up(old, down(), { now: 1_000 + DOUBLE_FIT_WINDOW_MS + 1 }); @@ -260,12 +291,93 @@ test('#449 recognizer instances keep independent transient sequences', () => { const first = new DoubleFitGestureRecognizer(); const second = new DoubleFitGestureRecognizer(); first.pointerDown(event(1), 'floor-a', true); - assert.equal(first.pointerUp(event(1), 'floor-a', true, false, 1_000), false); + assert.equal(first.pointerUp(event(1), 'floor-a', true, false, 1_000).trigger, false); second.pointerDown(event(2), 'floor-a', true); - assert.equal(second.pointerUp(event(2), 'floor-a', true, false, 1_100), false); + assert.equal(second.pointerUp(event(2), 'floor-a', true, false, 1_100).trigger, false); first.pointerDown(event(3), 'floor-a', true); - assert.equal(first.pointerUp(event(3), 'floor-a', true, false, 1_200), true); + assert.equal(first.pointerUp(event(3), 'floor-a', true, false, 1_200).trigger, true); second.clear(); second.pointerDown(event(4), 'floor-a', true); - assert.equal(second.pointerUp(event(4), 'floor-a', true, false, 1_250), false); + assert.equal(second.pointerUp(event(4), 'floor-a', true, false, 1_250).trigger, false); +}); + +test('#691 an interactive or outside pointerdown disarms the plan sequence', () => { + const stage = pathNode(['.stage']); + const interactive = pathNode(['.dev']); + const event = (pointerId, path = [stage]) => ({ + pointerId, pointerType: 'touch', isPrimary: true, button: 0, + clientX: 10, clientY: 10, composedPath: () => path, + }); + const recognizer = new DoubleFitGestureRecognizer(); + recognizer.pointerDown(event(1), 'floor-a', true); + recognizer.pointerUp(event(1), 'floor-a', true, false, 1_000); + assert.equal(recognizer.clearNonPlan(event(2, [interactive, stage])), true); + recognizer.pointerDown(event(3), 'floor-a', true); + assert.equal(recognizer.pointerUp(event(3), 'floor-a', true, false, 1_100).trigger, false); + assert.equal(recognizer.clearNonPlan(event(4, [stage])), false); +}); + +test('#691 controller owns one fake-clock room timer and cancels it on second intent', () => { + const stage = pathNode(['.stage']); + const room = pathNode(['.roomlabel[data-id]'], 'room-a'); + const interactive = pathNode(['.dev']); + const pointer = (pointerId, path, pointerType = 'touch') => ({ + pointerId, pointerType, isPrimary: true, button: 0, + clientX: 20, clientY: 30, composedPath: () => path, + }); + let nextTimer = 1; + const timers = new Map(); + const delays = []; + const timerPort = { + set(callback, delayMs) { + const id = nextTimer++; + timers.set(id, callback); + delays.push(delayMs); + return id; + }, + clear(handle) { timers.delete(handle); }, + }; + const calls = []; + let current = { spaceId: 'floor-a', enabled: true }; + const controller = new PlanTapGestureController({ + current: () => current, + fitAll: () => calls.push('all'), + fitRoom: (roomId) => calls.push(`room:${roomId}`), + }, timerPort); + + controller.pointerDown(pointer(1, [room, stage]), 'floor-a', true); + assert.equal(controller.pointerUp( + pointer(1, [room, stage]), + { pointerId: 1, spaceId: 'floor-a', roomId: 'room-a' }, + 'floor-a', true, false, 1_000, + ), 'room-a'); + assert.deepEqual(calls, []); + assert.deepEqual(delays, [DOUBLE_FIT_WINDOW_MS]); + assert.equal(timers.size, 1); + + controller.pointerDown(pointer(2, [stage]), 'floor-a', true); + assert.equal(timers.size, 0, 'a second clean pointer cancels pending room motion'); + controller.pointerUp(pointer(2, [stage]), null, 'floor-a', true, false, 1_100); + assert.deepEqual(calls, ['all']); + assert.equal(timers.size, 0); + + calls.length = 0; + controller.pointerDown(pointer(3, [room, stage]), 'floor-a', true); + controller.pointerUp( + pointer(3, [room, stage]), + { pointerId: 3, spaceId: 'floor-a', roomId: 'room-a' }, + 'floor-a', true, false, 2_000, + ); + assert.equal(controller.clearNonPlan(pointer(4, [interactive, stage])), true); + assert.equal(timers.size, 0, 'interactive intent cancels the pending room command'); + + controller.pointerDown(pointer(5, [room, stage]), 'floor-a', true); + controller.pointerUp( + pointer(5, [room, stage]), + { pointerId: 5, spaceId: 'floor-a', roomId: 'room-a' }, + 'floor-a', true, false, 3_000, + ); + current = { spaceId: 'floor-b', enabled: true }; + for (const callback of [...timers.values()]) callback(); + assert.deepEqual(calls, [], 'a stale-space timer is inert'); });