mirror of
https://github.com/Matysh/houseplan-card
synced 2026-10-04 13:48:57 +00:00
fix: the corner handle wins on small furniture, guides exclude the real drag (#400)
(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
This commit is contained in:
@@ -41,8 +41,27 @@ const res = await page.evaluate(async () => {
|
||||
before: { s: b.space, x: pb.x / 1000, y: pb.y / 1000 },
|
||||
start: c._devicePlacementForCanvas(b, pb.x, pb.y),
|
||||
};
|
||||
const keptDrag = c._deviceDrag;
|
||||
c.requestUpdate(); await c.updateComplete;
|
||||
out.devGuide = guides() >= 1;
|
||||
// #400: направляющая обязана идти ОТ ДРУГОГО значка, а не от самого себя.
|
||||
// Прежняя проверка `guides() >= 1` этого не различала: точка всегда
|
||||
// совпадает сама с собой в пределах допуска, поэтому исключение
|
||||
// перетаскиваемого маркера могло не работать вовсе — и не работало,
|
||||
// потому что читало `_drag`, который в этом режиме всегда null.
|
||||
out.devGuideComesFromAnotherMarker = (() => {
|
||||
const key = (list) => list.map((point) => point.join(','));
|
||||
const withDrag = key(c._editorRuntime._alignCandidates());
|
||||
c._deviceDrag = null;
|
||||
const withoutDrag = key(c._editorRuntime._alignCandidates());
|
||||
c._deviceDrag = keptDrag;
|
||||
// Исключение снимает РОВНО один кандидат — перетаскиваемый маркер.
|
||||
// Сравнение по разнице списков, а не по заранее посчитанной точке:
|
||||
// позиция значка живёт вместе с перетаскиванием и к моменту проверки
|
||||
// уже другая.
|
||||
const removed = withoutDrag.filter((point) => !withDrag.includes(point));
|
||||
return withDrag.length === withoutDrag.length - 1 && removed.length === 1;
|
||||
})();
|
||||
c._deviceDrag = null; c.requestUpdate(); await c.updateComplete;
|
||||
out.devGuideGone = guides() === 0;
|
||||
// 4) подложка: рисование прямоугольника с углом на одном X с углом другой фигуры
|
||||
|
||||
@@ -7,6 +7,7 @@ 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) => {
|
||||
@@ -107,6 +108,67 @@ const out = await page.evaluate(async () => {
|
||||
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;
|
||||
// прибрать поставленный диван
|
||||
@@ -116,5 +178,6 @@ const out = await page.evaluate(async () => {
|
||||
c.requestUpdate(); await c.updateComplete;
|
||||
return out;
|
||||
});
|
||||
checkAll(out);
|
||||
const { handleProbeDiagnostics, ...checks } = out;
|
||||
checkAll(checks);
|
||||
await finish(browser, out);
|
||||
|
||||
@@ -1557,6 +1557,26 @@ diffs the real `seedHiddenBindings`/`buildDevices` outputs — there is no
|
||||
second copy of the filter logic to drift. The field registry (#33) carries
|
||||
their passports; `scripts/config-audit.mjs` treats both as `current`.
|
||||
|
||||
## The initial bundle carries English and Russian whole (#400, 2026-08-31)
|
||||
|
||||
`en` and `ru` are synchronous dictionaries in the initial chunk; `de` and `fr`
|
||||
load lazily (`src/i18n/registry.ts`). That includes strings only ever shown in
|
||||
the editor — the 38 settings-help entries of #86 among them — and the question
|
||||
of splitting them out was raised by the v1.70.0-beta.1 audit.
|
||||
|
||||
Measured before deciding: those 38 entries are 11 818 B of raw text but
|
||||
**2 654 B gzip** inside a bundle of 47 754 B of dictionaries — 0.9 % of the
|
||||
300 000 B ceiling. Splitting them would mean cutting a synchronous dictionary
|
||||
in two, merging the halves at runtime, a second network request the first time
|
||||
a hint is opened, and a second source of truth for the key type derived from
|
||||
`en.json` (#391). That is a lot of new machinery, in the area that #352–#355
|
||||
had to stabilise, for 2.6 KB.
|
||||
|
||||
So the decision is deliberate, not an oversight: **English and Russian ship
|
||||
whole**. Budget planning assumes it. If the editor's text ever grows by tens of
|
||||
kilobytes, revisit this — the number above is what makes it worth revisiting,
|
||||
not the feeling that "editor text should be lazy".
|
||||
|
||||
## Backend quality gates (#42, 2026-08-30)
|
||||
|
||||
- `tests_backend/requirements.txt` is the single source of backend CI
|
||||
|
||||
@@ -2,6 +2,11 @@
|
||||
|
||||
## Unreleased
|
||||
|
||||
- Small furniture is easier to resize proportionally: the corner handle now
|
||||
wins the tap over the side handle when the two overlap, which they do on
|
||||
anything narrower than about 80 cm
|
||||
([#400](https://github.com/Matysh/houseplan-card/issues/400)).
|
||||
|
||||
- Undo/Redo of marker positions no longer goes dark on its own: the card now
|
||||
keeps exactly what it sent to the server, so a reconnect or a second tab
|
||||
stops looking like someone else's edit
|
||||
|
||||
@@ -8,6 +8,10 @@
|
||||
|
||||
## Не выпущено
|
||||
|
||||
- Мелкую мебель проще тянуть за угол: угловая ручка теперь выигрывает нажатие
|
||||
у боковой там, где они перекрываются, — а перекрываются они на всём уже
|
||||
примерно 80 см ([#400](https://github.com/Matysh/houseplan-card/issues/400)).
|
||||
|
||||
- История Undo/Redo позиций маркеров больше не гаснет сама: карточка хранит
|
||||
ровно то, что отправила серверу, поэтому переподключение или вторая вкладка
|
||||
перестали выглядеть чужой правкой
|
||||
|
||||
@@ -746,6 +746,30 @@ const MUTANT_DEFINITIONS = [
|
||||
replace: " this._persistDecorStyle();\n }, 0);",
|
||||
}],
|
||||
},
|
||||
{
|
||||
id: 'furniture-edge-handles-steal-the-corner',
|
||||
guard: 'node demo/smoke_furniture_polish.mjs',
|
||||
because: 'both handles share one hit radius, so on furniture narrower than '
|
||||
+ '4·hr whichever is painted last takes the corner — and the corner is '
|
||||
+ 'the one that cannot be reached any other way (#400 AC1)',
|
||||
patches: [{
|
||||
file: 'src/houseplan-card.ts',
|
||||
find: ' ${/* #400: corners LAST.',
|
||||
replace: ' ${/* mutant: corners no longer last.',
|
||||
}],
|
||||
},
|
||||
{
|
||||
id: 'align-guides-exclude-dead-source',
|
||||
guard: 'node demo/smoke_align_guides.mjs',
|
||||
because: 'device dragging lives in _deviceDrag since #74; excluding by '
|
||||
+ '_drag excludes nothing, and a guide drawn from a marker to itself '
|
||||
+ 'looks exactly like an honest one (#400 AC4/AC5)',
|
||||
patches: [{
|
||||
file: 'src/houseplan-editor-runtime.ts',
|
||||
find: ' const draggedId = this.host._deviceDrag?.id ?? this.host._drag?.id;',
|
||||
replace: ' const draggedId = this.host._drag?.id;',
|
||||
}],
|
||||
},
|
||||
{
|
||||
id: 'workflow-scan-hardcodes-the-list',
|
||||
guard: 'node --test test/validate-workflow.test.mjs',
|
||||
|
||||
+11
-4
@@ -8464,10 +8464,6 @@ export class HouseplanCard extends LitElement {
|
||||
<circle class="dthandle dtrot" cx="${b.x + b.w / 2}" cy="${b.y - arm}" r="${hr.toFixed(1)}"
|
||||
@pointerdown=${(e: PointerEvent) => this._dtStart(e, 'rotate')}></circle>
|
||||
<circle class="dtknob" cx="${b.x + b.w / 2}" cy="${b.y - arm}" r="${kr.toFixed(2)}"></circle>
|
||||
${corners.map(([sx, sy, cur]) => svg`<circle class="dthandle dt-${cur}"
|
||||
cx="${sx < 0 ? b.x : b.x + b.w}" cy="${sy < 0 ? b.y : b.y + b.h}" r="${hr.toFixed(1)}"
|
||||
@pointerdown=${(e: PointerEvent) => this._dtStart(e, 'scale', [sx, sy])}></circle><circle class="dtknob"
|
||||
cx="${sx < 0 ? b.x : b.x + b.w}" cy="${sy < 0 ? b.y : b.y + b.h}" r="${kr.toFixed(2)}"></circle>`)}
|
||||
${sides.map(([sx, sy, cur]) => {
|
||||
const x = sx < 0 ? b.x : sx > 0 ? b.x + b.w : b.x + b.w / 2;
|
||||
const y = sy < 0 ? b.y : sy > 0 ? b.y + b.h : b.y + b.h / 2;
|
||||
@@ -8476,6 +8472,17 @@ export class HouseplanCard extends LitElement {
|
||||
this._dtStart(e, 'scale', [sx, sy])}></circle>
|
||||
<circle class="dtknob dtedgeknob" cx="${x}" cy="${y}" r="${kr.toFixed(2)}"></circle>`;
|
||||
})}
|
||||
${/* #400: corners LAST. Both 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 later takes the hit.
|
||||
The corner is the one that cannot be reached any other way: an edge
|
||||
handle scales one axis, a corner scales both, and the object is small
|
||||
precisely when proportional resize matters most. The bead order is
|
||||
unchanged visually — the knobs are drawn with their own handles. */
|
||||
corners.map(([sx, sy, cur]) => svg`<circle class="dthandle dt-${cur}"
|
||||
cx="${sx < 0 ? b.x : b.x + b.w}" cy="${sy < 0 ? b.y : b.y + b.h}" r="${hr.toFixed(1)}"
|
||||
@pointerdown=${(e: PointerEvent) => this._dtStart(e, 'scale', [sx, sy])}></circle><circle class="dtknob"
|
||||
cx="${sx < 0 ? b.x : b.x + b.w}" cy="${sy < 0 ? b.y : b.y + b.h}" r="${kr.toFixed(2)}"></circle>`)}
|
||||
</g>` as unknown as TemplateResult;
|
||||
}
|
||||
|
||||
|
||||
@@ -910,6 +910,10 @@ export interface HouseplanEditorHostPort {
|
||||
_display: (url: string | null | undefined) => string;
|
||||
_draftSegmentCms: number[];
|
||||
_drag: { id: string; sx: number; sy: number; ox: number; oy: number; moved: boolean; } | null;
|
||||
/** #400: device dragging has its own state since #74; `_drag` is null in
|
||||
* the devices mode, so anything excluding "the thing being dragged" there
|
||||
* must read this instead. Only the id is needed by the editor runtime. */
|
||||
_deviceDrag: { id: string } | null;
|
||||
_drawWallCm: number | null;
|
||||
_drawWallField: string | null;
|
||||
_drawWallFieldValue: string;
|
||||
@@ -10966,8 +10970,16 @@ public _alignCandidates(): number[][] {
|
||||
}
|
||||
if (this.host._mode === 'devices') {
|
||||
// other icons of this space only (owner's decision)
|
||||
//
|
||||
// #400: the dragged marker is excluded by `_deviceDrag`, not `_drag`.
|
||||
// Device dragging moved into its own state with #74, and `_drag` is
|
||||
// null in this mode — so the marker being moved was listed among its
|
||||
// own alignment candidates. Nothing looked wrong because a point always
|
||||
// matches itself within tolerance: the guide was drawn from the marker
|
||||
// to itself and is indistinguishable from an honest one.
|
||||
const draggedId = this.host._deviceDrag?.id ?? this.host._drag?.id;
|
||||
for (const d of this.host._devices) {
|
||||
if (d.space !== this.host._space || d.id === this.host._drag?.id
|
||||
if (d.space !== this.host._space || d.id === draggedId
|
||||
|| d.bindingStatus?.kind === 'ha_disabled') continue;
|
||||
const p = this.host._pos(d);
|
||||
out.push([p.x, p.y]);
|
||||
|
||||
Reference in New Issue
Block a user