editor: gestures work on touch — pinch zooms, a moving finger pans

The stage pointerdown bailed out whenever _markup was set, so in the plan
editor no pointer was ever tracked: no pinch, no pan — on a phone the plan
could not be zoomed or moved at all (owner's report). But drawing is
CLICK-based, so the two coexist: a finger that moves pans (and suppresses
the synthesized click so the release feeds no tool), two fingers pinch, a
clean tap still draws. Pointers that start on labels, handles, markers or
buttons stay out — those run their own drags. The tool preview keeps
following the tracked finger.

New smoke: smoke_editor_gestures (pinch in plan mode, pan without drawing,
tap still draws). Inventory: 140 / 51 / 43 / 66.
This commit is contained in:
Matysh
2026-07-29 10:00:44 +03:00
parent 14f7c06bf4
commit 098a147f87
5 changed files with 111 additions and 30 deletions
+65
View File
@@ -0,0 +1,65 @@
// На телефоне в редакторах не работали зум и навигация жестами: pointerdown
// сцены выходил сразу при _markup, так что пинч и пан не начинались вовсе.
// Рисование кликается, жесты двигаются — они совместимы; палец с движением
// панорамирует, два пальца зумируют, отпускание после жеста не рисует точку.
import { launch, checkAll, finish } from './serve.mjs';
const { page, browser } = await launch();
const out = {};
const pd = (c, id, x, y) => c._stagePointerDown({ pointerId: id, clientX: x, clientY: y, target: c._stageEl, preventDefault() {} });
const pm = (c, id, x, y) => c._stagePointerMove({ pointerId: id, clientX: x, clientY: y });
const pu = (c, id, x, y) => c._stagePointerUp({ pointerId: id, clientX: x, clientY: y });
// --- пинч-зум в редакторе плана -----------------------------------------
out.pinchZoomsInPlanEditor = await page.evaluate(() => {
const c = window.__card;
c._setMode('plan');
const pd = (id, x, y) => c._stagePointerDown({ pointerId: id, clientX: x, clientY: y, target: c._stageEl, preventDefault() {} });
const pm = (id, x, y) => c._stagePointerMove({ pointerId: id, clientX: x, clientY: y });
const pu = (id, x, y) => c._stagePointerUp({ pointerId: id, clientX: x, clientY: y });
c._resetZoom();
const z0 = c._zoom;
pd(1, 300, 300); pd(2, 400, 300); // два пальца
pm(1, 250, 300); pm(2, 450, 300); // разводим
const zoomed = c._zoom > z0 * 1.5;
pu(1, 250, 300); pu(2, 450, 300);
return zoomed && c._path.length === 0;
});
// --- пан одним пальцем в редакторе, точка не рисуется --------------------
out.panWorksAndDoesNotDraw = await page.evaluate(async () => {
const c = window.__card;
const pd = (id, x, y) => c._stagePointerDown({ pointerId: id, clientX: x, clientY: y, target: c._stageEl, preventDefault() {} });
const pm = (id, x, y) => c._stagePointerMove({ pointerId: id, clientX: x, clientY: y });
const pu = (id, x, y) => c._stagePointerUp({ pointerId: id, clientX: x, clientY: y });
const st = c._stageEl;
c._zoomAt(st.clientWidth / 2, st.clientHeight / 2, 3); // есть куда панорамировать
const v0 = { ...c._view };
pd(3, 300, 300);
pm(3, 380, 340); pm(3, 420, 360);
const panned = Math.abs(c._view.x - v0.x) > 1 || Math.abs(c._view.y - v0.y) > 1;
const suppressed = c._suppressClick === true;
c._markupClick({ composedPath: () => [], clientX: 420, clientY: 360 }); // синтезированный click после пана
const noDot = c._path.length === 0;
pu(3, 420, 360);
await new Promise((r) => setTimeout(r, 10));
return panned && suppressed && noDot;
});
// --- обычный клик без движения по-прежнему рисует ------------------------
out.tapStillDraws = await page.evaluate(() => {
const c = window.__card;
const pd = (id, x, y) => c._stagePointerDown({ pointerId: id, clientX: x, clientY: y, target: c._stageEl, preventDefault() {} });
const pu = (id, x, y) => c._stagePointerUp({ pointerId: id, clientX: x, clientY: y });
c._resetZoom();
c._tool = 'draw';
const before = c._path.length;
pd(4, 300, 300); pu(4, 300, 300);
const st = c._stageEl.getBoundingClientRect();
c._markupClick({ composedPath: () => [], clientX: st.left + 200, clientY: st.top + 200 });
const drew = c._path.length > before;
c._path = []; c._setMode('view');
return drew;
});
await finish(browser, checkAll(out));
File diff suppressed because one or more lines are too long
+14 -14
View File
File diff suppressed because one or more lines are too long
+3
View File
@@ -239,6 +239,9 @@ Run the *core flows* (marked ★ below) in each environment at least once per mi
on the plan after a reload. Same for each tap action and each fill mode
[auto: backend test_every_display_mode_the_editor_offers_is_accepted and
neighbours, test_a_marker_showing_its_value_can_be_saved]
- [ ] Editor gestures on touch (dev): in the plan editor on a phone, pinch
zooms and a moving finger pans; releasing after a gesture does not draw
a point, a clean tap still does [auto: smoke_editor_gestures]
- [ ] Legacy geometry parity (v1.50.4, HP-1503-01): a store with a zero
viewport and a negative rect renders identically sane in BOTH cards —
full canvas fallback, normalised rectangle [auto: smoke_legacy_geometry]
+15 -2
View File
@@ -1416,8 +1416,17 @@ class HouseplanCard extends LitElement {
clearTimeout(this._kioskHoldTimer);
}
}
// do not interfere with icon dragging and markup drawing
if (this._drag || this._markup) return;
// do not interfere with icon and label dragging
if (this._drag) return;
if (this._markup) {
// Drawing is CLICK-based, so gestures coexist with it: a finger that
// MOVES pans, two fingers pinch, and _suppressClick keeps the release
// from feeding the active tool. The editor used to opt out of gestures
// entirely, which left a phone with no way to zoom or pan the plan
// (owner's report). Pointers that begin on interactive children still
// stay out — labels and handles run their own drags.
if ((ev.target as HTMLElement).closest?.('.roomlabel, .rlhandle, .dev, .oplock, .op-hit, button')) return;
}
if (this._mode === 'devices' && (ev.target as HTMLElement).closest('.dev')) return;
if (this._mode === 'decor' && this._decorPointerDown(ev)) return;
this._pointers.set(ev.pointerId, { x: ev.clientX, y: ev.clientY });
@@ -1447,6 +1456,8 @@ class HouseplanCard extends LitElement {
return;
}
this._pointers.set(ev.pointerId, { x: ev.clientX, y: ev.clientY });
// the tool preview (snap dot, measure label) keeps following the finger
if (this._markup && this._pointers.size === 1) this._markupMove(ev);
if (this._pinchStart && this._pointers.size >= 2) {
const pts = [...this._pointers.values()];
const dist = Math.hypot(pts[0].x - pts[1].x, pts[0].y - pts[1].y);
@@ -1822,6 +1833,8 @@ class HouseplanCard extends LitElement {
private _markupClick(ev: MouseEvent): void {
if (!this._markup) return;
// a pan or pinch just happened — the synthesized click is not a draw
if (this._suppressClick) return;
// Room cards swallow markup clicks: dragging, resizing or just clicking a
// card must not feed the active tool (draw point, delete room, merge/split
// pick, opening placement). The drag itself already stops pointer events,