mirror of
https://github.com/Matysh/houseplan-card
synced 2026-09-29 03:09:36 +00:00
(1) Corner and edge handles carry the same hit radius (1.8 % of the view), so on furniture narrower than 4·hr — a 40 cm cabinet — the two circles overlap and whichever is painted last takes the tap. Edges were painted last. Corners are now, because a side handle scales one axis while a corner scales both, and the object is small exactly when proportional resize matters most. The visible beads are unchanged. The audit called this 'proportional resize becomes unavailable'; the measurement says otherwise and the spec records the correction: the corner centre lies outside the edge circle, so the corner was reachable — its area was halved, not lost. A polish, not a bug, and worth fixing because it is one line of ordering. (2) Alignment guides in the devices mode excluded the dragged marker by _drag, which has been null there since #74 moved device dragging into _deviceDrag. So the marker being moved was among its own candidates. Nothing looked wrong because a point always matches itself within tolerance — the guide was drawn from the marker to itself, visually identical to an honest one, and the smoke asserted only guides() >= 1. The smoke now compares the candidate lists with and without the drag and demands exactly one removed entry. (3) The 38 settings-help strings stay in the initial chunk, and that is now a recorded decision rather than an oversight: measured 2 654 B gzip, 0.9 % of the ceiling, against splitting a synchronous dictionary in two, a second request on first hint, and a second source for the key type derived from en.json (#391). docs/ARCHITECTURE.md says so, with the number that would justify revisiting it. Both mutants run by hand: reverting the paint order reddens the 40 cm probe while the 160 cm one stays green; restoring _drag reddens the guides smoke. User-Visible: yes Issue: #400
184 lines
9.1 KiB
JavaScript
184 lines
9.1 KiB
JavaScript
// #369(д)(е): the armed furniture preview follows Shift without mouse motion,
|
||
// its window listeners come and go with the palette, and only the primary
|
||
// mouse button places objects — a right-click with an armed tool is a no-op.
|
||
import { launch, checkAll, finish } from './serve.mjs';
|
||
const { page, browser } = await launch();
|
||
const out = await page.evaluate(async () => {
|
||
const out = {};
|
||
const c = window.__card;
|
||
const sr = () => c.shadowRoot || c.renderRoot;
|
||
const frame = () => new Promise((r) => requestAnimationFrame(() => requestAnimationFrame(r)));
|
||
const sleep = (ms) => new Promise((resolve) => setTimeout(resolve, ms));
|
||
const stageEl = () => sr().querySelector('.stage');
|
||
const toScreen = (x, y) => {
|
||
const r = stageEl().getBoundingClientRect();
|
||
const v = c._viewOr(c._baseVb());
|
||
return [r.left + ((x - v.x) / v.w) * r.width, r.top + ((y - v.y) / v.h) * r.width * (v.h / v.w) * (r.height / (r.width * (v.h / v.w)))];
|
||
};
|
||
// счётчики слушателей окна
|
||
const addReal = window.addEventListener.bind(window);
|
||
const removeReal = window.removeEventListener.bind(window);
|
||
let shiftAdds = 0; let shiftRemoves = 0;
|
||
window.addEventListener = (type, fn, opts) => {
|
||
if (type === 'keydown' || type === 'keyup') shiftAdds++;
|
||
return addReal(type, fn, opts);
|
||
};
|
||
window.removeEventListener = (type, fn, opts) => {
|
||
if (type === 'keydown' || type === 'keyup') shiftRemoves++;
|
||
return removeReal(type, fn, opts);
|
||
};
|
||
|
||
// в декор-режим, инструмент «мебель», выбрать символ
|
||
await c._requestMode('decor', false);
|
||
await c.updateComplete;
|
||
c._editorSecondary.openPalette?.();
|
||
c._decorTool = 'furniture';
|
||
c._furnPalette = null; c._furnCategory = null;
|
||
c.requestUpdate(); await c.updateComplete;
|
||
const pal = () => sr().querySelector('.furnpalette');
|
||
pal()?.querySelector('.furnitem[data-category="sofa"]')?.click();
|
||
await c.updateComplete;
|
||
pal()?.querySelector('.furnitem[data-symbol="sofa"]')?.click();
|
||
await c.updateComplete;
|
||
out.symbolArmed = !!c._furnPalette;
|
||
out.listenersAttached = shiftAdds >= 2;
|
||
|
||
// превью от движения мыши
|
||
const stage = stageEl();
|
||
const rect = stage.getBoundingClientRect();
|
||
const cx = rect.left + rect.width * 0.5;
|
||
const cy = rect.top + rect.height * 0.55;
|
||
stage.dispatchEvent(new PointerEvent('pointermove', {
|
||
bubbles: true, composed: true, pointerType: 'mouse', pointerId: 7,
|
||
clientX: cx, clientY: cy,
|
||
}));
|
||
await c.updateComplete;
|
||
out.previewVisible = !!c._furnPreviewInput;
|
||
out.previewStartsSnapped = c._furnPreviewInput?.free === false;
|
||
|
||
// (д): Shift БЕЗ движения мыши переводит превью в free и обратно
|
||
window.dispatchEvent(new KeyboardEvent('keydown', { key: 'Shift', bubbles: true }));
|
||
await c.updateComplete;
|
||
out.shiftDownGoesFree = c._furnPreviewInput?.free === true;
|
||
window.dispatchEvent(new KeyboardEvent('keyup', { key: 'Shift', bubbles: true }));
|
||
await c.updateComplete;
|
||
out.shiftUpBacksToSnap = c._furnPreviewInput?.free === false;
|
||
|
||
// (е): правый клик с armed-символом НЕ ставит мебель
|
||
const beforeCount = c._decorList.length;
|
||
stage.dispatchEvent(new PointerEvent('pointerdown', {
|
||
bubbles: true, composed: true, pointerType: 'mouse', pointerId: 8,
|
||
clientX: cx, clientY: cy, button: 2, buttons: 2,
|
||
}));
|
||
await c.updateComplete; await sleep(50);
|
||
out.rightClickPlacesNothing = c._decorList.length === beforeCount
|
||
&& !!c._furnPalette;
|
||
|
||
// левый клик — ставит (регресс)
|
||
stage.dispatchEvent(new PointerEvent('pointerdown', {
|
||
bubbles: true, composed: true, pointerType: 'mouse', pointerId: 9,
|
||
clientX: cx, clientY: cy, button: 0, buttons: 1,
|
||
}));
|
||
await c.updateComplete; await sleep(50);
|
||
out.leftClickPlaces = c._decorList.length === beforeCount + 1;
|
||
|
||
// (д): после установки палитра погашена — слушатели сняты симметрично
|
||
out.paletteDisarmed = !c._furnPalette;
|
||
out.listenersDetached = shiftRemoves >= 2 && shiftRemoves === shiftAdds;
|
||
|
||
// Shift после снятия — не трогает ничего (превью нет)
|
||
window.dispatchEvent(new KeyboardEvent('keydown', { key: 'Shift', bubbles: true }));
|
||
out.shiftAfterDetachIsInert = !c._furnPreviewInput;
|
||
window.dispatchEvent(new KeyboardEvent('keyup', { key: 'Shift', bubbles: true }));
|
||
|
||
// r2-H1: Escape с armed-символом тоже снимает слушатели (путь мимо place)
|
||
c._editorSecondary.openPalette?.();
|
||
c._decorTool = 'furniture';
|
||
c._furnPalette = null; c._furnCategory = null;
|
||
c.requestUpdate(); await c.updateComplete;
|
||
pal()?.querySelector('.furnitem[data-category="sofa"]')?.click();
|
||
await c.updateComplete;
|
||
pal()?.querySelector('.furnitem[data-symbol="sofa"]')?.click();
|
||
await c.updateComplete;
|
||
const addsBeforeEscape = shiftAdds;
|
||
out.rearmAttachesAgain = shiftAdds === shiftRemoves + 2;
|
||
window.dispatchEvent(new KeyboardEvent('keydown', { key: 'Escape', bubbles: true }));
|
||
c._onKey?.(new KeyboardEvent('keydown', { key: 'Escape' }));
|
||
await c.updateComplete;
|
||
out.escapeClosesPalette = !c._furnPalette && c._decorTool === 'select';
|
||
out.escapeDetachesListeners = shiftRemoves === addsBeforeEscape;
|
||
|
||
// #400 AC1/AC2: на мелкой мебели угловая и осевая ручки перекрываются —
|
||
// радиус хита один и тот же (1.8 % вида), и на объекте уже, чем 4·hr, круги
|
||
// пересекаются. Побеждает нарисованный последним. Угол обязан быть сверху:
|
||
// боком масштабируется одна ось, углом — обе, и мелкая мебель — как раз тот
|
||
// случай, где пропорциональное изменение нужнее всего.
|
||
//
|
||
// Проверяется настоящим pointerdown в геометрический угол рамки:
|
||
// elementFromPoint через shadow root тут бесполезен (возвращает саму
|
||
// карточку), а какой обработчик сработал — видно только по вызову.
|
||
const handleProbe = async (size) => {
|
||
c._setMode('decor'); await c.updateComplete;
|
||
c._decorTool = 'select';
|
||
c._curSpaceCfg.decor = [{ id: 'probe', kind: 'furniture', symbol: 'fridge',
|
||
x: 0.3, y: 0.3, w: size, h: size, angle: 0, color: '#ff00ff', opacity: 1, width_cm: 8 }];
|
||
c._cfgEpoch++; c._decorSel = 'probe';
|
||
c._editorRuntime?._syncDecorFrame?.();
|
||
c.requestUpdate(); await c.updateComplete; await frame();
|
||
const box = sr().querySelector('.dtframe');
|
||
if (!box) return { frame: false };
|
||
const handles = [...box.querySelectorAll('.dthandle')];
|
||
const corner = handles.find((el) => !el.classList.contains('dtrot')
|
||
&& !el.classList.contains('dtedge'));
|
||
if (!corner) return { frame: true, corner: false };
|
||
const r = corner.getBoundingClientRect();
|
||
const cx = r.left + r.width / 2;
|
||
const cy = r.top + r.height / 2;
|
||
// Кто получит событие в этой точке: считаем нажатия на каждой ручке.
|
||
let hitEdge = 0; let hitCorner = 0;
|
||
const mark = (el) => {
|
||
const isEdge = el.classList.contains('dtedge');
|
||
const listener = () => { if (isEdge) hitEdge++; else hitCorner++; };
|
||
el.addEventListener('pointerdown', listener, true);
|
||
return () => el.removeEventListener('pointerdown', listener, true);
|
||
};
|
||
const off = handles.filter((el) => !el.classList.contains('dtrot')).map(mark);
|
||
const top = sr().elementsFromPoint
|
||
? sr().elementsFromPoint(cx, cy)
|
||
: document.elementsFromPoint(cx, cy);
|
||
const target = top.find((el) => el.classList && el.classList.contains('dthandle'));
|
||
if (target) {
|
||
target.dispatchEvent(new PointerEvent('pointerdown', {
|
||
clientX: cx, clientY: cy, pointerId: 91, isPrimary: true, button: 0,
|
||
bubbles: true, composed: true, cancelable: true,
|
||
}));
|
||
}
|
||
off.forEach((fn) => fn());
|
||
c._dtDrag = null;
|
||
return { frame: true, corner: true, hitCorner, hitEdge,
|
||
targetIsCorner: !!target && !target.classList.contains('dtedge') };
|
||
};
|
||
const small = await handleProbe(0.04); // 40 см на 10-метровом плане
|
||
const big = await handleProbe(0.16); // 160 см
|
||
out.smallFurnitureCornerWinsTheHit = small.targetIsCorner === true && small.hitCorner === 1
|
||
&& small.hitEdge === 0;
|
||
out.largeFurnitureKeepsBothHandles = big.targetIsCorner === true && big.hitCorner === 1
|
||
&& big.hitEdge === 0;
|
||
out.handleProbeDiagnostics = { small, big };
|
||
c._curSpaceCfg.decor = [];
|
||
c._decorSel = null;
|
||
c._setMode('background'); await c.updateComplete;
|
||
|
||
window.addEventListener = addReal;
|
||
window.removeEventListener = removeReal;
|
||
// прибрать поставленный диван
|
||
const sp = c._curSpaceCfg;
|
||
sp.decor = sp.decor.slice(0, beforeCount);
|
||
c._decorSel = null;
|
||
c.requestUpdate(); await c.updateComplete;
|
||
return out;
|
||
});
|
||
const { handleProbeDiagnostics, ...checks } = out;
|
||
checkAll(checks);
|
||
await finish(browser, out);
|