mirror of
https://github.com/Matysh/houseplan-card
synced 2026-10-03 05:08:53 +00:00
fix(touch): separate pan, edge swipe and double fit (#691)
Issue: #691 User-Visible: yes
This commit is contained in:
+28
-9
@@ -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) {
|
||||
|
||||
@@ -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 };
|
||||
})();
|
||||
|
||||
+56
-13
@@ -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]) {
|
||||
|
||||
+21
-15
@@ -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
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -8,6 +8,14 @@
|
||||
|
||||
## Не выпущено
|
||||
|
||||
- Навигация на сенсорных экранах теперь сохраняет обычный drag одним пальцем
|
||||
для панорамирования, а пространство в киоске переключается только жестом из
|
||||
внутренней 48-пиксельной зоны края, за которым есть соседнее пространство.
|
||||
Два чистых тапа по фону, заливке комнаты или пассивной подписи вписывают весь
|
||||
план без промежуточного прыжка в комнату; одиночный тап по комнате по-прежнему
|
||||
вписывает её после окна выбора 350 мс — одинаково для touch, мыши и пера
|
||||
([#691](https://github.com/Matysh/houseplan-card/issues/691)).
|
||||
|
||||
- Контур, ступени, трапеция и стрелка направления лестницы теперь используют
|
||||
единую физическую толщину 3,6 см. Она масштабируется вместе с планом, а не
|
||||
остаётся постоянной в экранных пикселях, не искажается при изменении размера
|
||||
|
||||
@@ -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
|
||||
|
||||
+10
-8
@@ -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 |
|
||||
|
||||
|
||||
+4
-3
@@ -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:
|
||||
|
||||
@@ -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"
|
||||
}
|
||||
},
|
||||
|
||||
@@ -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;
|
||||
|
||||
/**
|
||||
|
||||
@@ -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',
|
||||
}],
|
||||
},
|
||||
{
|
||||
|
||||
+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);
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
+36
-1
@@ -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);
|
||||
|
||||
+118
-6
@@ -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');
|
||||
});
|
||||
|
||||
Reference in New Issue
Block a user