Files
houseplan-card/demo/smoke_pan_any_zoom.mjs
2026-09-04 22:04:17 +03:00

256 lines
12 KiB
JavaScript

// Panning at ANY zoom (owner's report 2026-08-04: «добавь возможность таскать
// план при любом масштабе, а не только при более 100%»).
//
// Until this fix `_stagePointerMove` moved the view only while `_zoom > 1`:
// the rule was inherited from the old bounded canvas, where a plan smaller
// than the scene had nowhere to go. The infinite canvas removed that edge —
// there is always somewhere to go, and an editor extending a plan outwards
// needs exactly that. So a drag on empty scene now pans at 100 %, at 50 % and
// at 800 %, in view mode and in every editor; how far you may walk is decided
// by `_clampView` (PAN_SLACK: about a screen past the content) and not by the
// zoom. The tools keep the pointer they already owned — a resize handle, a
// device badge, an opening — and the kiosk keeps its floor swipe.
import { launch, check, checkAll, finish } from './serve.mjs';
const { page, browser } = await launch();
const out = {};
// ---- a drag on empty scene pans, at every zoom and in every mode ---------
Object.assign(out, await page.evaluate(async () => {
const c = window.__card;
const o = {};
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 });
let pid = 100;
/** Drag 80x60 px across the middle of the scene; did the view follow? */
const drag = async (zoom) => {
// always start from the plan in the middle: the clamp is a real limit, and
// a dozen drags in a row would otherwise walk into it
const vb = c._baseVb();
c._applyView(zoom, vb[0] + vb[2] / 2, vb[1] + vb[3] / 2);
await c.updateComplete;
const v0 = { ...c._viewOr(c._baseVb()) };
const id = ++pid;
pd(id, 300, 300);
pm(id, 340, 330);
pm(id, 380, 360);
const v1 = { ...c._viewOr(c._baseVb()) };
pu(id, 380, 360);
// dragging down-right pulls the viewport up-left, and the zoom is untouched
return Math.abs(v1.x - v0.x) > 1 && Math.abs(v1.y - v0.y) > 1
&& v1.x < v0.x && v1.y < v0.y && Math.abs(c._zoom - zoom) < 1e-9;
};
const inMode = async (name, setup) => {
await setup();
await c.updateComplete;
o['pan_' + name + '_at100'] = await drag(1);
o['pan_' + name + '_at50'] = await drag(0.5);
o['pan_' + name + '_at33'] = await drag(1 / 3);
};
await inMode('view', async () => { c._setMode('view'); });
for (const tool of ['draw', 'merge', 'split', 'resize', 'opening', 'wallthick', 'delroom']) {
await inMode('plan_' + tool, async () => { c._setMode('plan'); c._tool = tool; });
}
await inMode('devices', async () => { c._setMode('devices'); });
await inMode('decor', async () => { c._setMode('decor'); c._decorTool = 'select'; });
c._setMode('view');
c._applyView(1);
await c.updateComplete;
return o;
}));
// ---- 400 % still pans (the behaviour that already worked) ----------------
out.panStillWorksZoomedIn = await page.evaluate(async () => {
const c = window.__card;
c._setMode('view'); c._applyView(4); await c.updateComplete;
const v0 = { ...c._view };
c._stagePointerDown({ pointerId: 7, clientX: 300, clientY: 300, target: c._stageEl, preventDefault() {} });
c._stagePointerMove({ pointerId: 7, clientX: 380, clientY: 360 });
const moved = Math.abs(c._view.x - v0.x) > 1;
c._stagePointerUp({ pointerId: 7, clientX: 380, clientY: 360 });
c._applyView(1);
return moved;
});
// ---- the clamp: you may walk about a screen off, never further ------------
Object.assign(out, await page.evaluate(async () => {
const c = window.__card;
const sr = c.shadowRoot || c.renderRoot;
const o = {};
c._setMode('view');
const vb = c._baseVb();
c._applyView(1, vb[0] + vb[2] / 2, vb[1] + vb[3] / 2); // at 100 % the view IS fitView
await c.updateComplete;
const fit = { ...c._view };
c._stagePointerDown({ pointerId: 8, clientX: 700, clientY: 400, target: c._stageEl, preventDefault() {} });
c._stagePointerMove({ pointerId: 8, clientX: -40000, clientY: 400 }); // shove it into the void
const far = { ...c._view };
c._stagePointerUp({ pointerId: 8, clientX: -40000, clientY: 400 });
o.panLeavesTheContent = far.x > fit.x + fit.w * 0.5;
o.panStopsAtTheSlack = far.x <= fit.x + fit.w + 1;
c.requestUpdate(); await c.updateComplete;
o.homeArrowOffersTheWayBack = !!sr.querySelector('.homearrow');
c._applyView(1, vb[0] + vb[2] / 2, vb[1] + vb[3] / 2);
c.requestUpdate(); await c.updateComplete;
// the same at 50 %: the screen holds more, the walk is still finite
const fit2 = { ...c._view };
c._applyView(0.5, vb[0] + vb[2] / 2, vb[1] + vb[3] / 2); await c.updateComplete;
c._stagePointerDown({ pointerId: 9, clientX: 700, clientY: 400, target: c._stageEl, preventDefault() {} });
c._stagePointerMove({ pointerId: 9, clientX: -40000, clientY: 400 });
const far2 = { ...c._view };
c._stagePointerUp({ pointerId: 9, clientX: -40000, clientY: 400 });
o.panStopsAtTheSlackZoomedOut = far2.x <= fit2.x + fit2.w + 1 && far2.x > fit2.x;
c._applyView(1, vb[0] + vb[2] / 2, vb[1] + vb[3] / 2); await c.updateComplete;
return o;
}));
// ---- the tools still own their pointer ------------------------------------
const ev = (type, id, x, y) => ({ type, id, x, y });
Object.assign(out, await page.evaluate(async () => {
const c = window.__card;
const sr = c.shadowRoot || c.renderRoot;
const o = {};
const fire = (el, type, id, x, y) => el.dispatchEvent(new PointerEvent(type, {
bubbles: true, composed: true, cancelable: true, pointerId: id, clientX: x, clientY: y,
}));
// a) resize handle: the wall moves, the view does not
c._setMode('plan'); c._tool = 'resize'; c._applyView(1); await c.updateComplete;
const h = sr.querySelector('.rszhandle[aria-disabled="false"]');
o.resizeHandlePresent = !!h;
if (h) {
const v0 = { ...c._viewOr(c._baseVb()) };
const r = h.getBoundingClientRect();
fire(h, 'pointerdown', 21, r.left + r.width / 2, r.top + r.height / 2);
o.resizeHandleTakesThePointer = c._resize?.dragging === true && c._panStart === null;
fire(h, 'pointermove', 21, r.left + r.width / 2 + 40, r.top + r.height / 2 + 40);
await Promise.resolve();
const v1 = { ...c._viewOr(c._baseVb()) };
o.resizeHandleDoesNotPan = Math.abs(v1.x - v0.x) < 0.5 && Math.abs(v1.y - v0.y) < 0.5;
o.resizeHandleResizes = c._resize?.dragging === true && c._resize.moved === true;
c._rszCancelDrag?.();
fire(h, 'pointerup', 21, r.left + r.width / 2 + 40, r.top + r.height / 2 + 40);
}
c._resize?.selectRoom(null);
// b) a device badge in the devices editor drags the DEVICE
c._setMode('devices'); c._applyView(1); await c.updateComplete;
const dev = sr.querySelector('.devlayer .dev');
o.devicePresent = !!dev;
if (dev) {
const v0 = { ...c._viewOr(c._baseVb()) };
const r = dev.getBoundingClientRect();
fire(dev, 'pointerdown', 22, r.left + r.width / 2, r.top + r.height / 2);
o.deviceTakesThePointer = !!c._deviceDrag && c._panStart === null;
fire(dev, 'pointermove', 22, r.left + r.width / 2 + 40, r.top + r.height / 2 + 30);
await Promise.resolve();
c._stagePointerMove({ pointerId: 22, clientX: r.left + 60, clientY: r.top + 50 });
const v1 = { ...c._viewOr(c._baseVb()) };
o.deviceDragDoesNotPan = Math.abs(v1.x - v0.x) < 0.5 && Math.abs(v1.y - v0.y) < 0.5;
o.deviceActuallyMoved = !!c._deviceDrag && c._deviceDrag.moved === true;
fire(dev, 'pointerup', 22, r.left + r.width / 2 + 40, r.top + r.height / 2 + 30);
// _pointerUp releases the device drag before its serial final write, and _stagePointerDown
// refuses to start a gesture while a device drag is live
await new Promise((r2) => setTimeout(r2, 20));
o.deviceDragReleased = c._deviceDrag === null;
await c.updateComplete;
}
// c) an opening drags along its wall, the view stays put
const cfg = JSON.parse(JSON.stringify(c._serverCfg));
cfg.spaces[0].openings = [{ id: 'o_pan', type: 'window', x: 0.3, y: 0.14, angle: 0, length: 0.1 }];
c._serverCfg = cfg; c._modelCache = null; c._frame = null;
c._setMode('plan'); c._tool = 'draw'; c._applyView(1);
c.requestUpdate(); await c.updateComplete;
const op = sr.querySelector('.op-hit');
o.openingPresent = !!op;
if (op) {
const v0 = { ...c._viewOr(c._baseVb()) };
const r = op.getBoundingClientRect();
fire(op, 'pointerdown', 23, r.left + r.width / 2, r.top + r.height / 2);
o.openingTakesThePointer = !!c._opDrag && c._panStart === null;
fire(op, 'pointermove', 23, r.left + r.width / 2 + 50, r.top + r.height / 2);
const v1 = { ...c._viewOr(c._baseVb()) };
o.openingDragDoesNotPan = Math.abs(v1.x - v0.x) < 0.5 && Math.abs(v1.y - v0.y) < 0.5;
fire(op, 'pointerup', 23, r.left + r.width / 2 + 50, r.top + r.height / 2);
await c.updateComplete;
}
// d) a drawing tool is still a drawing tool: two fingers pinch, they do not pan
c._setMode('plan'); c._tool = 'draw'; c._applyView(1); await c.updateComplete;
const z0 = c._zoom;
c._stagePointerDown({ pointerId: 31, clientX: 300, clientY: 300, target: c._stageEl, preventDefault() {} });
c._stagePointerDown({ pointerId: 32, clientX: 400, clientY: 300, target: c._stageEl, preventDefault() {} });
c._stagePointerMove({ pointerId: 31, clientX: 250, clientY: 300 });
c._stagePointerMove({ pointerId: 32, clientX: 450, clientY: 300 });
o.pinchStillZooms = c._zoom > z0 * 1.5;
c._stagePointerUp({ pointerId: 31, clientX: 250, clientY: 300 });
c._stagePointerUp({ pointerId: 32, clientX: 450, clientY: 300 });
c._path = [];
c._setMode('view'); c._applyView(1); await c.updateComplete;
return o;
}));
// ---- the kiosk keeps its floor swipe ---------------------------------------
Object.assign(out, await page.evaluate(async () => {
const c0 = window.__card;
const o = {};
const k = document.createElement('houseplan-card');
k.setConfig({ type: 'custom:houseplan-card', kiosk: true, cycle: 0 });
k.hass = c0.hass;
document.body.appendChild(k);
k.style.cssText = 'position:fixed;left:0;top:0;width:900px;height:700px;z-index:99';
await new Promise((r) => setTimeout(r, 500));
k.hass = { ...c0.hass };
await k.updateComplete;
const sr = k.shadowRoot || k.renderRoot;
const stage = sr.querySelector('.stage');
const fire = (type, id, x, y) => stage.dispatchEvent(new PointerEvent(type, {
bubbles: true, composed: true, cancelable: true, pointerId: id, clientX: x, clientY: y,
}));
k._applyView(1); await k.updateComplete;
const s0 = k._space;
const v0 = { ...k._viewOr(k._baseVb()) };
// a REAL swipe: down, several moves, up — the moves are what used to be
// impossible to add to smoke_kiosk without hijacking the gesture
fire('pointerdown', 41, 600, 300);
fire('pointermove', 41, 540, 302);
fire('pointermove', 41, 480, 305);
fire('pointermove', 41, 450, 305);
const midPan = Math.abs(k._viewOr(k._baseVb()).x - v0.x);
fire('pointerup', 41, 450, 305);
await k.updateComplete;
o.kioskSwipeStillSwitchesFloors = k._space !== s0;
o.kioskHorizontalDragIsNotAPan = midPan < 1;
// …and the plan is not left hanging half-panned after the switch
o.kioskViewCenteredAfterSwipe = Math.abs(k._viewOr(k._baseVb()).x - v0.x) < 2000;
// a VERTICAL drag is nobody's swipe, so it pans like everywhere else
const s1 = k._space;
const v1 = { ...k._viewOr(k._baseVb()) };
fire('pointerdown', 42, 450, 200);
fire('pointermove', 42, 452, 260);
fire('pointermove', 42, 454, 330);
const panned = Math.abs(k._viewOr(k._baseVb()).y - v1.y) > 1;
fire('pointerup', 42, 454, 330);
await k.updateComplete;
o.kioskVerticalDragPans = panned;
o.kioskVerticalDragKeepsFloor = k._space === s1;
// zoomed in, a horizontal drag pans (no swipe up there — unchanged rule)
k._applyView(2); await k.updateComplete;
const v2 = { ...k._view };
const s2 = k._space;
fire('pointerdown', 43, 600, 300);
fire('pointermove', 43, 540, 302);
fire('pointermove', 43, 480, 305);
const pannedZoomed = Math.abs(k._view.x - v2.x) > 1;
fire('pointerup', 43, 480, 305);
await k.updateComplete;
o.kioskZoomedHorizontalPans = pannedZoomed;
o.kioskZoomedDoesNotSwipe = k._space === s2;
k.remove();
return o;
}));
await finish(browser, checkAll(out));