mirror of
https://github.com/Matysh/houseplan-card
synced 2026-09-29 03:09:36 +00:00
Drag the plan at any zoom, not only above 100%
Owner's report 2026-08-04: «добавь возможность таскать план при любом масштабе, а не только при более 100%, как сейчас (и в редакторах, и в просмотре)». _stagePointerMove moved the view only while `_zoom > 1`. That gate is older than the infinite canvas and made sense under the old rule — the content had to cover the scene, so at 100% or below there was literally nowhere to go and a drag could only jitter. The infinite canvas removed the edge and gave panning a slack of one screen past the content in every direction (CANVAS.md §5), and from that moment the gate was not a guard but a missing feature: at 100% you could see the arrow «home is that way» light up from a wheel-zoom, and still not drag the plan an inch. The zoom no longer takes part in the decision — `_clampView` alone says how far you may walk, at 400% and at 33% alike. The drag also stopped depending on `_view` being materialised: it reads `_viewOr(baseVb)`, so the very first drag on a freshly opened space pans instead of doing nothing. Gesture ownership is unchanged, and that is the point of most of the new smoke: `_stagePointerDown` still bails out on the room-resize handles, device badges, openings, room labels and decor shapes, and on a decor drawing tool that consumes the press; two fingers are still a pinch. The one place where a drag had a rival is the kiosk, where a horizontal swipe changes floors. It is now classified once per gesture, on the first movement past 8px (`_panLock`): horizontal in the swipe zone (kiosk, zoom <= 1, more than one space) = swipe and no pan, everything else = pan. So the plan never slides out from under a swipe, a vertical drag on a wall tablet pans as it does everywhere else, and zoomed in — where swipeTarget already refuses — a horizontal drag pans. demo/smoke_pan_any_zoom.mjs: a drag on empty scene moves the view at 100%, 50% and 1/3 in View and in every editor (all seven plan tools, Devices, Background), and at 400% as before; the walk stops at the PAN_SLACK limit and the home arrow appears; a resize handle resizes, a device badge moves the device and an opening slides along its wall, none of them panning a pixel; two fingers still zoom; the kiosk still swipes floors through a gesture that has real pointermove events in it (smoke_kiosk only ever sent down+up), a vertical drag there pans, and a zoomed-in horizontal drag pans without changing the floor. Before the fix 25 of its checks are red, including every editor at every zoom.
This commit is contained in:
File diff suppressed because one or more lines are too long
@@ -0,0 +1,123 @@
|
||||
// «Открыть/закрыть» on a curtain whose cover entity is NOT the primary one
|
||||
// (owner's report 2026-08-04: «в настройках "открыть\закрыть", а по нажатию
|
||||
// по-прежнему инфо-карточка»).
|
||||
//
|
||||
// The device below is his, entity for entity: an Aqara «Roller shade driver
|
||||
// E1» ships the `cover.*` HIDDEN by the integration and a perfectly visible
|
||||
// `switch.*_reverse_direction` beside it. `primaryEntity` ranks visible above
|
||||
// hidden and `switch` above `cover`, so the marker's primary was the service
|
||||
// switch — the tap then resolved on the domain `switch` and the action the
|
||||
// user had explicitly chosen degraded to the info card, while the dialog kept
|
||||
// OFFERING the action, because that check already looked at every entity of
|
||||
// the device. The tap now finds the cover the same way the climate
|
||||
// temperature finds the thermostat: among all of them.
|
||||
//
|
||||
// End to end: the action is chosen in the dialog and SAVED through the normal
|
||||
// path, and the click is done on the marker the card rebuilt from that config.
|
||||
import { launch, checkAll, finish } from './serve.mjs';
|
||||
const { page, browser } = await launch();
|
||||
const out = await page.evaluate(async () => {
|
||||
const o = {};
|
||||
const c = window.__card;
|
||||
const sr = () => c.shadowRoot || c.renderRoot;
|
||||
const calls = [];
|
||||
|
||||
// ---- the owner's curtain driver, joined to the demo house --------------
|
||||
const ENTS = {
|
||||
// hidden by the integration — exactly as z2m ships it
|
||||
'cover.office_curtain': { entity_id: 'cover.office_curtain', device_id: 'd_curtain', platform: 'demo', hidden: true },
|
||||
'sensor.office_curtain_battery': { entity_id: 'sensor.office_curtain_battery', device_id: 'd_curtain', platform: 'demo', entity_category: 'diagnostic' },
|
||||
'switch.office_curtain_reverse': { entity_id: 'switch.office_curtain_reverse', device_id: 'd_curtain', platform: 'demo' },
|
||||
'sensor.office_curtain_motor_state': { entity_id: 'sensor.office_curtain_motor_state', device_id: 'd_curtain', platform: 'demo' },
|
||||
};
|
||||
const coverState = (state, attrs = {}) => ({
|
||||
entity_id: 'cover.office_curtain', state,
|
||||
attributes: { friendly_name: 'Office curtain', ...attrs },
|
||||
});
|
||||
const push = async (state, attrs = {}) => {
|
||||
c.hass = {
|
||||
...c.hass,
|
||||
devices: { ...c.hass.devices,
|
||||
d_curtain: { id: 'd_curtain', name: 'Office curtain', model: 'Roller shade driver E1',
|
||||
area_id: 'bedroom', identifiers: [['demo', 'd_curtain']], entry_type: null, via_device_id: null } },
|
||||
entities: { ...c.hass.entities, ...ENTS },
|
||||
states: { ...c.hass.states,
|
||||
'cover.office_curtain': coverState(state, attrs),
|
||||
'sensor.office_curtain_battery': { entity_id: 'sensor.office_curtain_battery', state: '84', attributes: { device_class: 'battery', unit_of_measurement: '%' } },
|
||||
'switch.office_curtain_reverse': { entity_id: 'switch.office_curtain_reverse', state: 'off', attributes: { friendly_name: 'Reverse direction' } },
|
||||
'sensor.office_curtain_motor_state': { entity_id: 'sensor.office_curtain_motor_state', state: 'stopped', attributes: {} },
|
||||
},
|
||||
callService: async (dom, svc, data) => { calls.push([dom, svc, data]); return {}; },
|
||||
};
|
||||
c._regSignature = '';
|
||||
c._maybeRebuildDevices();
|
||||
c.requestUpdate();
|
||||
await c.updateComplete;
|
||||
};
|
||||
|
||||
await push('closed', { device_class: 'curtain' });
|
||||
const curtain = () => c._devices.find((x) => x.bindingRef === 'd_curtain');
|
||||
o.deviceIsOnThePlan = !!curtain();
|
||||
// the premise of the whole report: the cover is NOT the primary entity
|
||||
o.primaryIsNotTheCover = curtain()?.primary === 'switch.office_curtain_reverse';
|
||||
o.coverIsAmongTheEntities = (curtain()?.entities || []).includes('cover.office_curtain');
|
||||
|
||||
// ---- the dialog offers the action and SAVES it -------------------------
|
||||
c._setMode('devices'); await c.updateComplete;
|
||||
c._openMarkerDialog(curtain()); await c.updateComplete;
|
||||
const optionValues = () => [...sr().querySelectorAll('.dialog option')].map((e) => e.value);
|
||||
o.dialogOffersCover = optionValues().includes('cover');
|
||||
c._markerDialog = { ...c._markerDialog, tapAction: 'cover' };
|
||||
await c.updateComplete;
|
||||
await c._saveMarker();
|
||||
await c.updateComplete;
|
||||
const saved = (c._serverCfg.markers || []).find((m) => m.binding === 'device:d_curtain');
|
||||
o.markerSaved = saved?.tap_action === 'cover';
|
||||
|
||||
// ---- and the tap on THAT marker opens the curtain -----------------------
|
||||
c._setMode('view'); c._regSignature = ''; c._maybeRebuildDevices();
|
||||
c.requestUpdate(); await c.updateComplete;
|
||||
const tap = async () => {
|
||||
c._clickDevice({ stopPropagation() {} }, curtain());
|
||||
await c.updateComplete;
|
||||
await new Promise((r) => setTimeout(r, 10));
|
||||
};
|
||||
o.tapActionSurvivedTheRebuild = curtain()?.tapAction === 'cover';
|
||||
calls.length = 0;
|
||||
await tap();
|
||||
o.closedOpensTheCover = JSON.stringify(calls[calls.length - 1] || [])
|
||||
=== JSON.stringify(['cover', 'open_cover', { entity_id: 'cover.office_curtain' }]);
|
||||
o.noInfoCard = !c._infoCard;
|
||||
c._infoCard = null;
|
||||
|
||||
await push('open', { device_class: 'curtain' });
|
||||
calls.length = 0;
|
||||
await tap();
|
||||
o.openClosesTheCover = JSON.stringify(calls[calls.length - 1] || [])
|
||||
=== JSON.stringify(['cover', 'close_cover', { entity_id: 'cover.office_curtain' }]);
|
||||
|
||||
await push('opening', { device_class: 'curtain' });
|
||||
calls.length = 0;
|
||||
await tap();
|
||||
o.travellingStops = (calls[calls.length - 1] || [])[1] === 'stop_cover';
|
||||
|
||||
// the service switch, which used to be the whole story, is never touched
|
||||
o.serviceSwitchNeverCalled = !calls.some((x) => String(x[2]?.entity_id).startsWith('switch.'));
|
||||
|
||||
// ---- the guarded classes still degrade, read off the COVER --------------
|
||||
await push('closed', { device_class: 'garage' });
|
||||
calls.length = 0;
|
||||
await tap();
|
||||
o.garageCallsNothing = calls.length === 0;
|
||||
o.garageShowsInfo = !!c._infoCard;
|
||||
c._infoCard = null; await c.updateComplete;
|
||||
c._setMode('devices'); await c.updateComplete;
|
||||
c._openMarkerDialog(curtain()); await c.updateComplete;
|
||||
o.dialogHidesCoverForGarage = !optionValues().includes('cover');
|
||||
c._markerDialog = null;
|
||||
c._setMode('view'); await c.updateComplete;
|
||||
|
||||
return o;
|
||||
});
|
||||
checkAll(out);
|
||||
await finish(browser, out);
|
||||
@@ -0,0 +1,253 @@
|
||||
// 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', 'openwall', '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');
|
||||
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._rszDrag && c._panStart === null;
|
||||
fire(h, 'pointermove', 21, r.left + r.width / 2 + 40, r.top + r.height / 2 + 40);
|
||||
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._rszDrag && c._rszDrag.moved === true;
|
||||
c._rszCancelDrag?.();
|
||||
fire(h, 'pointerup', 21, r.left + r.width / 2 + 40, r.top + r.height / 2 + 40);
|
||||
}
|
||||
c._rszSel = 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._drag && c._panStart === null;
|
||||
fire(dev, 'pointermove', 22, r.left + r.width / 2 + 40, r.top + r.height / 2 + 30);
|
||||
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._drag && c._drag.moved === true;
|
||||
fire(dev, 'pointerup', 22, r.left + r.width / 2 + 40, r.top + r.height / 2 + 30);
|
||||
// _pointerUp releases _drag on the next macrotask, and _stagePointerDown
|
||||
// refuses to start a gesture while a device drag is live
|
||||
await new Promise((r2) => setTimeout(r2, 20));
|
||||
o.deviceDragReleased = c._drag === 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));
|
||||
File diff suppressed because one or more lines are too long
Vendored
+2
-2
File diff suppressed because one or more lines are too long
+20
-2
@@ -38,7 +38,7 @@ coordinate system.
|
||||
| `space.view_box` | the frame; everything was clamped into it | an OPTIONAL hint for the very first frame; used only when there is nothing to frame |
|
||||
| "fit" rectangle | `view_box` (or the content bbox in view mode) | always the **content frame** (§4) |
|
||||
| Zoom out floor | `ZOOM_MIN = 0.4` (fraction of `view_box`) | 3x the content frame (`MIN_ZOOM = 1/3`) |
|
||||
| Pan bounds | content must cover the scene | content frame + one screen of slack in each direction |
|
||||
| Pan bounds | content must cover the scene, and only above 100% zoom | content frame + one screen of slack in each direction, at any zoom |
|
||||
| Icon size | % of `view_box`, i.e. grew with zoom | % of `iconUnit` — still grows with zoom (§6) |
|
||||
| Validation range | `+/-4` | `+/-5000` (§3) |
|
||||
|
||||
@@ -154,9 +154,26 @@ was silently excluded from the frame).
|
||||
* **Zoom in** — unchanged, `ZOOM_MAX = 8`.
|
||||
* **Zoom out** — `MIN_ZOOM = 1/3`: you can see three times the content
|
||||
frame and no further. Empty space beyond that is not information.
|
||||
* **Pan** — bounded by the content frame inflated by
|
||||
* **Pan** — available at **every zoom**, in view mode and in every
|
||||
editor, and bounded by the content frame inflated by
|
||||
`PAN_SLACK = 1.0` of `max(view, frame)` on each side. You can walk
|
||||
off the plan (there is no edge), but not into infinity.
|
||||
Until 2026-08-04 a drag moved the view only while `zoom > 1`: on the
|
||||
old bounded canvas a plan smaller than the scene genuinely had
|
||||
nowhere to go, so the gate was harmless. With no edge left it was
|
||||
simply a missing feature, and the owner reported it as one
|
||||
(«таскать план при любом масштабе»). The zoom no longer takes part
|
||||
in the decision — `_clampView` alone says how far you may walk.
|
||||
* **Who owns the pointer.** A drag pans only when it starts on empty
|
||||
scene: the room-resize handles, device badges, openings, room labels
|
||||
and the decor shapes take the pointer first (`_stagePointerDown`
|
||||
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.
|
||||
* **"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
|
||||
@@ -259,6 +276,7 @@ fits `all`.
|
||||
| grid `<rect>` over `vb` | the grid ends with the square | rect follows the view (§7) |
|
||||
| grid pitch fixed | fine at 1 canvas wide | `gridLevels()` (§7) |
|
||||
| `_clampView` pinned content over the scene | you cannot pan past the edge | §5 pan slack |
|
||||
| `_stagePointerMove` panned only while `zoom > 1` | below 100% the content already covered the scene, so a drag had nowhere to go | **removed** — §5, panning at every zoom |
|
||||
| `ZOOM_MIN = 0.4` | fraction of the square | `MIN_ZOOM = 1/3` of the content frame (§5) |
|
||||
| `_decorMoveUpdate` clamp `-0.25 .. 1.25` | decor may hang a quarter past the edge | clamp widened to the sane range (`+/-CANVAS_LIMIT`) — corruption insurance, not a frame |
|
||||
| static card `aspect-ratio` + `viewBox` from `space.vb` | the static card frames the square | `spaceFrame()` — same content frame as the full card |
|
||||
|
||||
@@ -994,6 +994,13 @@ require hands on real hardware — they remain for the human pass.
|
||||
- [ ] **Pan has slack, not walls**: you can pan a full screen past the plan in
|
||||
every direction; when the plan is fully off screen a small arrow points
|
||||
home and one click fits it back
|
||||
- [ ] **Pan at ANY zoom** (owner's report 2026-08-04): dragging empty scene
|
||||
moves the view at 100 %, at 50 % and at the zoom-out floor — in View and
|
||||
in all three editors, with every plan tool selected. The tools keep the
|
||||
pointer they own (a resize handle resizes, a device badge in the Devices
|
||||
editor moves the device, an opening slides along its wall — none of them
|
||||
pan), two fingers still pinch, and on a kiosk screen a horizontal drag
|
||||
is still the floor swipe (a vertical one pans) [auto: smoke_pan_any_zoom]
|
||||
- [ ] **Adaptive grid** (§7): in the Plan editor zoomed far out the grid does
|
||||
not merge into a grey wash — fine dots thin out, every 5th/10th node
|
||||
stays bigger; zoomed in the grid is the usual one and snapping still
|
||||
|
||||
+37
-6
@@ -345,6 +345,13 @@ class HouseplanCard extends LitElement {
|
||||
private _viewModeSnap: { space: string; zoom: number; cx?: number; cy?: number } | null = null;
|
||||
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.
|
||||
*/
|
||||
private _panLock: 'pan' | 'swipe' | null = null;
|
||||
private _pinchStart: { dist: number; zoom: number } | null = null;
|
||||
private _suppressClick = false;
|
||||
private _roViewport?: ResizeObserver;
|
||||
@@ -2179,15 +2186,27 @@ class HouseplanCard extends LitElement {
|
||||
const v = this._viewOr(this._baseVb());
|
||||
if (this._pointers.size === 1) {
|
||||
this._panStart = { sx: ev.clientX, sy: ev.clientY, vx: v.x, vy: v.y };
|
||||
this._panLock = null; // undecided until the finger moves
|
||||
this._suppressClick = false;
|
||||
} else if (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);
|
||||
this._pinchStart = { dist, zoom: this._zoom };
|
||||
this._panStart = null;
|
||||
this._panLock = null;
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Is a horizontal drag reserved for the floor swipe right now? Only on a
|
||||
* kiosk screen, only at the zoom `swipeTarget` accepts, and only when there
|
||||
* is more than one space to swipe between — everywhere else nothing
|
||||
* competes with panning.
|
||||
*/
|
||||
private get _swipeZone(): boolean {
|
||||
return this._kiosk && this._zoom <= 1.001 && this._model.length > 1;
|
||||
}
|
||||
|
||||
private _stagePointerMove(ev: PointerEvent): void {
|
||||
if (this._decorDraft?.pid === ev.pointerId) {
|
||||
this._decorDraft = { ...this._decorDraft, b: this._snap(this._svgPoint(ev)) };
|
||||
@@ -2221,14 +2240,25 @@ class HouseplanCard extends LitElement {
|
||||
this._suppressClick = true;
|
||||
clearTimeout(this._holdTimer);
|
||||
}
|
||||
if (this._zoom > 1 && this._view) {
|
||||
const stage = this._stageEl!;
|
||||
const v = this._view;
|
||||
const fit = fitView(this._baseVb(), this._stageAspect());
|
||||
// Which gesture is this? Decided once, on the first movement worth the
|
||||
// name, and kept for the rest of the drag so the plan cannot flip-flop
|
||||
// under the finger. Panning is NOT gated on the zoom any more (owner,
|
||||
// 2026-08-04: «таскать план при любом масштабе»): on an infinite canvas
|
||||
// there is no "the content already covers the scene" state that would
|
||||
// make a drag meaningless — `_clampView` alone decides how far you may
|
||||
// walk, at 400% and at 33% alike.
|
||||
if (this._panLock === null && Math.abs(ddx) + Math.abs(ddy) > 8) {
|
||||
this._panLock = this._swipeZone && Math.abs(ddx) > Math.abs(ddy) * 1.5 ? 'swipe' : 'pan';
|
||||
}
|
||||
const stage = this._stageEl;
|
||||
if (this._panLock === 'pan' && stage) {
|
||||
const vb = this._baseVb();
|
||||
const v = this._viewOr(vb);
|
||||
const fit = fitView(vb, this._stageAspect());
|
||||
this._view = this._clampView(
|
||||
{
|
||||
x: this._panStart.vx - (ddx / stage.clientWidth) * v.w,
|
||||
y: this._panStart.vy - (ddy / stage.clientHeight) * v.h,
|
||||
x: this._panStart.vx - (ddx / (stage.clientWidth || 1)) * v.w,
|
||||
y: this._panStart.vy - (ddy / (stage.clientHeight || 1)) * v.h,
|
||||
w: v.w,
|
||||
h: v.h,
|
||||
},
|
||||
@@ -2277,6 +2307,7 @@ class HouseplanCard extends LitElement {
|
||||
if (this._pointers.size < 2) this._pinchStart = null;
|
||||
if (this._pointers.size === 0) {
|
||||
this._panStart = null;
|
||||
this._panLock = null;
|
||||
// reset click suppression on the next tick (so that a click right after a pan does not fire)
|
||||
setTimeout(() => (this._suppressClick = false), 0);
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user