mirror of
https://github.com/Matysh/houseplan-card
synced 2026-10-02 12:49:56 +00:00
feat: reorder space tabs by dragging one to a new position
The order of config.spaces used to be whatever order the spaces were created in, and there was no way back other than deleting a space and drawing it again. The gesture is deliberately narrow — mouse, editors only. The same tabs are the primary way to switch spaces in View, where touch is first class, so a drag there would compete with the tap that switches. Recorded in the spec as "Touch editor: not exposed". The part that needed care is not the drag. Position in the array feeds three things: the marker placement fallback, the swipe neighbour and a positional `floor`. So the write that stores the new order also writes down the placement that used to depend on it: a marker with neither an explicit space nor an area that names one gets the space it has right now. Both changes go in one save; splitting them would leave a window in which markers move on their own. The positional `floor` cannot be fixed from here, so the card says so once. Issue: #220 User-Visible: yes
This commit is contained in:
File diff suppressed because one or more lines are too long
@@ -0,0 +1,149 @@
|
||||
// Issue #220: the order of the space tabs is changed by dragging one of them.
|
||||
//
|
||||
// The demo fixture has two spaces, which is all the panel needs to prove the
|
||||
// contract. What this smoke protects is not the animation but the three
|
||||
// promises around it: the
|
||||
// new order survives a save, an ordinary click still switches the space, and
|
||||
// nothing of the sort exists in View, where the same tabs are a touch-first
|
||||
// navigation control.
|
||||
import { launch, checkAll, finish } from './serve.mjs';
|
||||
const { page, browser } = await launch({ width: 1100, height: 900 }, 1);
|
||||
const res = await page.evaluate(async () => {
|
||||
const out = {};
|
||||
const c = window.__card;
|
||||
const sr = () => c.shadowRoot || c.renderRoot;
|
||||
const tabs = () => [...sr().querySelectorAll('[data-hp="space-tab"]')];
|
||||
const ids = () => tabs().map((tab) => tab.dataset.id);
|
||||
const settle = async () => {
|
||||
const started = performance.now();
|
||||
do { await new Promise((r) => requestAnimationFrame(r)); }
|
||||
while (c._modeTransitionBusy && performance.now() - started < 1500);
|
||||
await c.updateComplete;
|
||||
};
|
||||
|
||||
// A save must reach the server exactly once per drop, and carry the order.
|
||||
const writes = [];
|
||||
const realWrite = c._writeConfig.bind(c);
|
||||
c._writeConfig = () => {
|
||||
writes.push((c._serverCfg.spaces || []).map((space) => space.id));
|
||||
return Promise.resolve();
|
||||
};
|
||||
|
||||
const drag = async (fromId, toId, { travel = 40 } = {}) => {
|
||||
const from = tabs().find((tab) => tab.dataset.id === fromId);
|
||||
const to = tabs().find((tab) => tab.dataset.id === toId);
|
||||
const a = from.getBoundingClientRect();
|
||||
const b = to.getBoundingClientRect();
|
||||
const event = (type, x, y, target) => target.dispatchEvent(new PointerEvent(type, {
|
||||
pointerId: 7, pointerType: 'mouse', clientX: x, clientY: y, bubbles: true,
|
||||
}));
|
||||
event('pointerdown', a.x + a.width / 2, a.y + a.height / 2, from);
|
||||
// one intermediate move on the source keeps the gesture honest: the drag
|
||||
// must begin from travel, not from merely touching a second tab
|
||||
event('pointermove', a.x + a.width / 2 + travel, a.y + a.height / 2, from);
|
||||
event('pointermove', b.x + b.width / 2, b.y + b.height / 2, to);
|
||||
event('pointerup', b.x + b.width / 2, b.y + b.height / 2, to);
|
||||
await settle();
|
||||
// The write is debounced (~500 ms). Waiting for it is the point: a smoke
|
||||
// that checks the panel and leaves proves the DOM, not the save.
|
||||
const deadline = performance.now() + 1500;
|
||||
const seen = writes.length;
|
||||
while (writes.length === seen && performance.now() < deadline) {
|
||||
await new Promise((r) => setTimeout(r, 25));
|
||||
}
|
||||
};
|
||||
|
||||
await settle();
|
||||
c._mode = 'plan';
|
||||
c.requestUpdate();
|
||||
await settle();
|
||||
|
||||
// A marker with neither an explicit space nor an area that names one is the
|
||||
// whole reason this feature has to be careful: today it renders in whichever
|
||||
// space sits first, so a reorder would hand it to another one. The fixture
|
||||
// has no such marker, so the smoke plants it — otherwise the guarantee would
|
||||
// be tested only as a pure function, never as applied behaviour.
|
||||
const firstBefore = c._model[0].id;
|
||||
c._serverCfg.markers = [
|
||||
...(c._serverCfg.markers || []),
|
||||
{ id: 'smoke-dangling', binding: 'virtual', name: 'dangling' },
|
||||
];
|
||||
const dangling = () => (c._serverCfg.markers || [])
|
||||
.find((marker) => marker.id === 'smoke-dangling');
|
||||
out.plantedMarkerStartsWithoutSpace = !dangling().space;
|
||||
|
||||
const before = ids();
|
||||
out.enoughTabsToReorder = before.length >= 2;
|
||||
out.reorderableInEditor = tabs()[0].hasAttribute('data-reorderable');
|
||||
|
||||
// --- AC1: the drop changes the order and asks for a save -------------------
|
||||
const moved = before[before.length - 1];
|
||||
const target = before[0];
|
||||
const active = c._space;
|
||||
await drag(moved, target);
|
||||
const after = ids();
|
||||
out.tabMovedToTheFront = after[0] === moved;
|
||||
out.otherTabsKeptOrder = JSON.stringify(after.filter((id) => id !== moved))
|
||||
=== JSON.stringify(before.filter((id) => id !== moved));
|
||||
out.orderReachedTheServer = writes.length >= 1;
|
||||
out.savedOrderMatchesPanel = JSON.stringify(writes[writes.length - 1])
|
||||
=== JSON.stringify(after);
|
||||
out.activeSpaceUnchanged = c._space === active;
|
||||
// AC3: the order-dependent marker keeps the space it had, written down.
|
||||
out.danglingMarkerPinnedToItsOldSpace = dangling().space === firstBefore;
|
||||
out.danglingMarkerDidNotFollowTheOrder = dangling().space !== ids()[0]
|
||||
|| firstBefore === ids()[0];
|
||||
|
||||
// --- AC7: the positional-floor warning is said once ------------------------
|
||||
out.warnedAboutPositionalFloor = typeof c._toast === 'string' && c._toast.length > 0;
|
||||
c._toast = '';
|
||||
const second = ids();
|
||||
await drag(second[second.length - 1], second[0]);
|
||||
out.secondDropAlsoReordered = ids()[0] === second[second.length - 1];
|
||||
out.warningNotRepeated = !c._toast;
|
||||
|
||||
// --- AC2: a click without travel still switches the space ------------------
|
||||
const other = ids().find((id) => id !== c._space);
|
||||
const writesBeforeClick = writes.length;
|
||||
await new Promise((r) => setTimeout(r, 700)); // let any pending debounce land
|
||||
const tab = tabs().find((t) => t.dataset.id === other);
|
||||
const rect = tab.getBoundingClientRect();
|
||||
const at = (type) => tab.dispatchEvent(new PointerEvent(type, {
|
||||
pointerId: 8, pointerType: 'mouse',
|
||||
clientX: rect.x + rect.width / 2, clientY: rect.y + rect.height / 2, bubbles: true,
|
||||
}));
|
||||
at('pointerdown'); at('pointermove'); at('pointerup');
|
||||
tab.click();
|
||||
await settle();
|
||||
out.clickStillSwitchesSpace = c._space === other;
|
||||
await new Promise((r) => setTimeout(r, 700));
|
||||
out.clickDidNotReorder = writes.length === writesBeforeClick;
|
||||
|
||||
// --- AC5: touch and View never start a drag -------------------------------
|
||||
const touchOrder = ids();
|
||||
const src = tabs()[tabs().length - 1];
|
||||
const dst = tabs()[0];
|
||||
const ra = src.getBoundingClientRect();
|
||||
const rb = dst.getBoundingClientRect();
|
||||
const touch = (type, x, y, target) => target.dispatchEvent(new PointerEvent(type, {
|
||||
pointerId: 9, pointerType: 'touch', clientX: x, clientY: y, bubbles: true,
|
||||
}));
|
||||
touch('pointerdown', ra.x + ra.width / 2, ra.y + ra.height / 2, src);
|
||||
touch('pointermove', rb.x + rb.width / 2, rb.y + rb.height / 2, dst);
|
||||
touch('pointerup', rb.x + rb.width / 2, rb.y + rb.height / 2, dst);
|
||||
await settle();
|
||||
out.touchDidNotReorder = JSON.stringify(ids()) === JSON.stringify(touchOrder);
|
||||
|
||||
c._mode = 'view';
|
||||
c.requestUpdate();
|
||||
await settle();
|
||||
out.notReorderableInView = !tabs()[0].hasAttribute('data-reorderable');
|
||||
const viewOrder = ids();
|
||||
await drag(viewOrder[viewOrder.length - 1], viewOrder[0]);
|
||||
out.viewDidNotReorder = JSON.stringify(ids()) === JSON.stringify(viewOrder);
|
||||
|
||||
c._writeConfig = realWrite;
|
||||
return out;
|
||||
});
|
||||
checkAll(res);
|
||||
await finish(browser, res);
|
||||
File diff suppressed because one or more lines are too long
Vendored
+51
-42
File diff suppressed because one or more lines are too long
@@ -2,6 +2,11 @@
|
||||
|
||||
## Unreleased
|
||||
|
||||
- Space tabs can be reordered: grab a tab with the mouse in any editor mode and
|
||||
drop it where it belongs. The order is saved, and swipe and carousel
|
||||
navigation follow it. Markers stay exactly where they were
|
||||
([#220](https://github.com/Matysh/houseplan-card/issues/220)).
|
||||
|
||||
- “Optimize plans” now removes microscopic floating-point noise from stored
|
||||
grid coordinates even when nothing visibly moves. Its preview separately
|
||||
reports updated spaces and cleaned coordinate values, and a second run is an
|
||||
|
||||
@@ -8,6 +8,11 @@
|
||||
|
||||
## Не выпущено
|
||||
|
||||
- Порядок вкладок пространств теперь можно менять: в режиме редактора возьмите
|
||||
вкладку мышью и перетащите на нужное место. Порядок сохраняется, свайп и
|
||||
карусель следуют ему. Устройства при этом остаются на своих местах
|
||||
([#220](https://github.com/Matysh/houseplan-card/issues/220)).
|
||||
|
||||
- «Оптимизировать планы» теперь устраняет микроскопический floating-point шум
|
||||
сохранённых координат сетки, даже если визуально ничего не сдвигается. В
|
||||
предпросмотре отдельно показаны обновлённые пространства и очищенные
|
||||
|
||||
@@ -235,6 +235,23 @@ The plan image keeps its proportions initially. Background can later move,
|
||||
scale or rotate it. Detaching a plan never deletes its server file; deletion
|
||||
requires an explicit user action.
|
||||
|
||||
### Tab order
|
||||
|
||||
Space tabs follow the order in which the spaces were created, and that order can
|
||||
be changed: in any editor mode, grab a tab with the mouse and drag it to a new
|
||||
position. The new order is saved immediately and applies everywhere — the tabs,
|
||||
the kiosk swipe between floors and the carousel arrows.
|
||||
|
||||
Dragging works **with a mouse and in the editors only**. In ordinary View and on
|
||||
touch screens a tab still does one thing: it switches the space. There it is the
|
||||
primary way to navigate, and a gesture must not compete with a plain tap. Order
|
||||
is changed on a computer, like the rest of the plan work.
|
||||
|
||||
If a card anywhere pins its floor **by number** (`floor: 0`), remember that the
|
||||
number means a position: after a reorder such a card shows a different floor.
|
||||
The card warns about this once. Pin the floor by space id instead of a number to
|
||||
avoid it entirely.
|
||||
|
||||
### Display settings
|
||||
|
||||
A space can show/hide room borders, names, LQI, Background and openings. It can
|
||||
|
||||
@@ -269,6 +269,24 @@ desktop: для точного рисования, Resize, модификато
|
||||
них, последующая смена источника больше не сбрасывает ни один выбор. В мастере
|
||||
этажей каждый следующий этаж получает собственные чистые defaults.
|
||||
|
||||
### Порядок вкладок
|
||||
|
||||
Вкладки пространств стоят в том порядке, в котором пространства заведены, и
|
||||
этот порядок можно изменить: в любом режиме редактора возьмите вкладку мышью и
|
||||
перетащите на новое место. Порядок сохраняется сразу и действует везде —
|
||||
вкладки, свайп между этажами в киоске, стрелки карусели.
|
||||
|
||||
Перетаскивание работает **только мышью и только в редакторах**. В обычном
|
||||
просмотре и на сенсорных экранах вкладка по-прежнему только переключает
|
||||
пространство: там это основной способ навигации, и жест не должен мешать
|
||||
обычному нажатию. Порядок меняется на компьютере — как и остальная работа с
|
||||
планом.
|
||||
|
||||
Если где-то карточка закреплена за этажом **по номеру** (`floor: 0`), помните,
|
||||
что номер означает позицию: после перестановки такая карточка покажет другой
|
||||
этаж. Карточка предупредит об этом один раз. Чтобы этого не случалось, задавайте
|
||||
этаж идентификатором пространства, а не номером.
|
||||
|
||||
### Настройки пространства
|
||||
|
||||
| Раздел | Настройка | Результат |
|
||||
|
||||
@@ -717,6 +717,50 @@ export const MUTANTS = [
|
||||
replace: ' .dev:not(.unavail):hover {',
|
||||
}],
|
||||
},
|
||||
{
|
||||
id: 'tab-reorder-not-persisted',
|
||||
guard: 'node demo/smoke_space_tab_reorder.mjs',
|
||||
because: 'перестановка вкладок, оставшаяся только в памяти, выглядит рабочей ровно до '
|
||||
+ 'перезагрузки страницы — смок обязан требовать запись на сервер',
|
||||
patches: [{
|
||||
file: 'src/houseplan-card.ts',
|
||||
find: ' cfg.spaces = applySpaceOrder(cfg.spaces || [], order);\n this._saveConfig();',
|
||||
replace: ' cfg.spaces = applySpaceOrder(cfg.spaces || [], order);',
|
||||
}],
|
||||
},
|
||||
{
|
||||
id: 'reorder-skips-materialization',
|
||||
guard: 'node demo/smoke_space_tab_reorder.mjs',
|
||||
because: 'без материализации привязки маркер без space и area уезжает вслед за порядком: '
|
||||
+ 'его пространство решает firstSpaceId, а тот после перестановки другой (#220 §8.3)',
|
||||
patches: [{
|
||||
file: 'src/houseplan-card.ts',
|
||||
find: ' const byId = new Map(pinned.map((entry) => [entry.id, entry.space]));',
|
||||
replace: ' const byId = new Map();',
|
||||
}],
|
||||
},
|
||||
{
|
||||
id: 'tab-reorder-eats-click',
|
||||
guard: 'node --test --test-name-pattern="issue 220" test/space-order.test.mjs',
|
||||
because: 'нулевой порог превращает обычный клик по вкладке в перетаскивание, и '
|
||||
+ 'переключение пространств — основное действие панели — перестаёт работать',
|
||||
patches: [{
|
||||
file: 'src/space-order.ts',
|
||||
find: 'export const TAB_DRAG_THRESHOLD_PX = 4;',
|
||||
replace: 'export const TAB_DRAG_THRESHOLD_PX = 0;',
|
||||
}],
|
||||
},
|
||||
{
|
||||
id: 'tab-reorder-ignores-pointer-type',
|
||||
guard: 'node --test --test-name-pattern="issue 220" test/space-order.test.mjs',
|
||||
because: 'на тач-устройстве вкладки — это навигация View, где тап обязан оставаться тапом; '
|
||||
+ 'решение владельца «Touch editor: not exposed» держится именно этой проверкой',
|
||||
patches: [{
|
||||
file: 'src/space-order.ts',
|
||||
find: " if (ctx.pointerType !== 'mouse') return false;",
|
||||
replace: ' if (false) return false;',
|
||||
}],
|
||||
},
|
||||
{
|
||||
id: 'internal-path-ignores-query',
|
||||
guard: 'node scripts/backend-test-guard.mjs issue_225',
|
||||
|
||||
+116
-2
@@ -261,6 +261,10 @@ import {
|
||||
type OpeningPlacementCore, type OpeningPlacementPreset, type OpeningPlacementType,
|
||||
} from './opening-placement';
|
||||
import { safeStoredColor } from './color';
|
||||
import {
|
||||
applySpaceOrder, canStartTabDrag, markersNeedingPlacement, passedDragThreshold,
|
||||
reorderSpaceIds,
|
||||
} from './space-order';
|
||||
|
||||
const CARD_VERSION = '1.66.0';
|
||||
const DISPLAY_LABEL_KEYS: Record<DeviceDisplayMode, I18nKey> = {
|
||||
@@ -1238,6 +1242,99 @@ class HouseplanCard extends LitElement {
|
||||
}
|
||||
|
||||
/** Direct space tabs use the same motion as swipe/carousel navigation. */
|
||||
// ---- reordering the space tabs (issue #220) ------------------------------
|
||||
//
|
||||
// Mouse only, editors only: the same tabs switch spaces in View, where touch
|
||||
// is a first-class citizen, so a gesture here would compete with that tap.
|
||||
// docs/specs/220-space-tab-reorder.md §4.1, "Touch editor: not exposed".
|
||||
|
||||
private get _canReorderTabs(): boolean {
|
||||
return canStartTabDrag({
|
||||
canEdit: this._canEdit,
|
||||
kiosk: this._kiosk,
|
||||
mode: this._mode,
|
||||
pointerType: 'mouse',
|
||||
spaceCount: this._model.length,
|
||||
fixedFloor: this._hasFixedFloor,
|
||||
});
|
||||
}
|
||||
|
||||
private _tabPointerDown(event: PointerEvent, id: string): void {
|
||||
if (!canStartTabDrag({
|
||||
canEdit: this._canEdit,
|
||||
kiosk: this._kiosk,
|
||||
mode: this._mode,
|
||||
pointerType: event.pointerType,
|
||||
spaceCount: this._model.length,
|
||||
fixedFloor: this._hasFixedFloor,
|
||||
})) return;
|
||||
this._tabDrag = {
|
||||
id, pointerId: event.pointerId, x: event.clientX, y: event.clientY,
|
||||
moved: false, overId: id,
|
||||
};
|
||||
}
|
||||
|
||||
private _tabPointerMove(event: PointerEvent, overId: string): void {
|
||||
const drag = this._tabDrag;
|
||||
if (!drag || drag.pointerId !== event.pointerId) return;
|
||||
if (!drag.moved
|
||||
&& !passedDragThreshold(event.clientX - drag.x, event.clientY - drag.y)) return;
|
||||
// Past the threshold the gesture is a drag: the click that would otherwise
|
||||
// follow is suppressed in _tabClick, and the panel shows where it lands.
|
||||
if (drag.moved && drag.overId === overId) return;
|
||||
this._tabDrag = { ...drag, moved: true, overId };
|
||||
}
|
||||
|
||||
private _tabPointerUp(event: PointerEvent): void {
|
||||
const drag = this._tabDrag;
|
||||
this._tabDrag = null;
|
||||
if (!drag || drag.pointerId !== event.pointerId || !drag.moved) return;
|
||||
this._commitTabOrder(drag.id, drag.overId);
|
||||
}
|
||||
|
||||
/** A click that followed a real drag must not also switch the space. */
|
||||
private _tabClick(id: string): void {
|
||||
if (this._tabDrag?.moved) return;
|
||||
this._pickSpace(id);
|
||||
}
|
||||
|
||||
/**
|
||||
* Write the new order — and, in the same write, the placement that used to
|
||||
* depend on it.
|
||||
*
|
||||
* A marker with neither an explicit space nor an area that names one renders
|
||||
* in whatever space sits first. Reordering would silently hand it to another
|
||||
* space, so the answer it has right now is written down first. This is the
|
||||
* whole reason the two changes may not be split into two saves.
|
||||
*/
|
||||
private _commitTabOrder(movedId: string, targetId: string): void {
|
||||
const cfg = this._serverCfg;
|
||||
if (!cfg || !this._canReorderTabs) return;
|
||||
const ids = this._model.map((space) => space.id);
|
||||
const order = reorderSpaceIds(ids, movedId, targetId);
|
||||
if (order === ids) return;
|
||||
const pinned = markersNeedingPlacement(
|
||||
cfg.markers || [],
|
||||
Object.fromEntries(
|
||||
Object.entries(this._areaToSpace).map(([area, value]) => [area, value.space]),
|
||||
),
|
||||
ids[0] || '',
|
||||
);
|
||||
if (pinned.length) {
|
||||
const byId = new Map(pinned.map((entry) => [entry.id, entry.space]));
|
||||
for (const marker of cfg.markers || []) {
|
||||
const space = byId.get(String((marker as any).id));
|
||||
if (space) (marker as any).space = space;
|
||||
}
|
||||
}
|
||||
cfg.spaces = applySpaceOrder(cfg.spaces || [], order);
|
||||
this._saveConfig();
|
||||
if (!this._tabOrderWarned) {
|
||||
this._tabOrderWarned = true;
|
||||
this._showToast(this._t('toast.space_order_changed'));
|
||||
}
|
||||
}
|
||||
|
||||
private _pickSpace(id: string): void {
|
||||
if (id === this._space) return;
|
||||
const ids = this._model.map((sp) => sp.id);
|
||||
@@ -1827,6 +1924,14 @@ class HouseplanCard extends LitElement {
|
||||
private _cycleTimer?: number;
|
||||
private _cyclePausedUntil = 0;
|
||||
private _swipeStart: { x: number; y: number; id: number } | null = null;
|
||||
|
||||
/** Live tab reorder: which tab is held, where it started, where it would land. */
|
||||
private _tabDrag: {
|
||||
id: string; pointerId: number; x: number; y: number; moved: boolean; overId: string;
|
||||
} | null = null;
|
||||
|
||||
/** The positional-`floor` warning is worth saying once, not on every drop. */
|
||||
private _tabOrderWarned = false;
|
||||
private _lastTap = 0;
|
||||
private get _labsIso(): boolean {
|
||||
return this._labs.active.includes('iso');
|
||||
@@ -1916,6 +2021,7 @@ class HouseplanCard extends LitElement {
|
||||
private _holdFired = false;
|
||||
|
||||
static properties = {
|
||||
_tabDrag: { state: true },
|
||||
_hdrH: { state: true },
|
||||
_booting: { state: true },
|
||||
_bootFading: { state: true },
|
||||
@@ -15746,8 +15852,16 @@ class HouseplanCard extends LitElement {
|
||||
${navigationSpaces.map(
|
||||
(s) => html`<button
|
||||
data-hp="space-tab" data-id="${s.id}"
|
||||
class="tab ${this._space === s.id ? 'active' : ''}"
|
||||
@click=${() => this._pickSpace(s.id)}
|
||||
class="tab ${this._space === s.id ? 'active' : ''}${
|
||||
this._tabDrag?.moved && this._tabDrag.id === s.id ? ' dragging' : ''}${
|
||||
this._tabDrag?.moved && this._tabDrag.overId === s.id
|
||||
&& this._tabDrag.id !== s.id ? ' droptarget' : ''}"
|
||||
?data-reorderable=${this._canReorderTabs}
|
||||
@pointerdown=${(e: PointerEvent) => this._tabPointerDown(e, s.id)}
|
||||
@pointermove=${(e: PointerEvent) => this._tabPointerMove(e, s.id)}
|
||||
@pointerup=${(e: PointerEvent) => this._tabPointerUp(e)}
|
||||
@pointercancel=${() => { this._tabDrag = null; }}
|
||||
@click=${() => this._tabClick(s.id)}
|
||||
>
|
||||
${s.title}${this._norm && this._canEdit
|
||||
? html`<ha-icon class="tabedit" icon="mdi:cog-outline"
|
||||
|
||||
@@ -287,6 +287,7 @@
|
||||
"toast.ha_disabled_add": "A disabled Home Assistant object cannot be added to the plan. Enable it in Home Assistant first.",
|
||||
"toast.ha_binding_unverified": "The object status could not be verified through the Home Assistant registry. Display and actions are temporarily unavailable.",
|
||||
"toast.markup_needs_server": "Markup is available after the config is moved to the server",
|
||||
"toast.space_order_changed": "Order changed. If any card pins its floor by number, check those panels.",
|
||||
"toast.conflict": "Config was changed in another window — data refreshed, repeat your last action",
|
||||
"toast.cfg_save_failed": "Failed to save config: {err}",
|
||||
"toast.room_overlap": "The outline overlaps room “{name}” — rooms must not overlap",
|
||||
|
||||
@@ -287,6 +287,7 @@
|
||||
"toast.ha_disabled_add": "Деактивированный объект Home Assistant нельзя добавить на план. Сначала активируйте его в Home Assistant.",
|
||||
"toast.ha_binding_unverified": "Статус объекта не удалось подтвердить по реестру Home Assistant. Отображение и действия временно недоступны.",
|
||||
"toast.markup_needs_server": "Разметка доступна после переноса конфига на сервер",
|
||||
"toast.space_order_changed": "Порядок изменён. Если где-то этаж карточки задан номером, проверьте такие панели.",
|
||||
"toast.conflict": "Конфиг изменён в другом окне — данные обновлены, повторите последнее действие",
|
||||
"toast.cfg_save_failed": "Не удалось сохранить конфиг: {err}",
|
||||
"toast.room_overlap": "Контур накладывается на комнату «{name}» — комнаты не должны накладываться",
|
||||
|
||||
@@ -0,0 +1,119 @@
|
||||
/**
|
||||
* Reordering the space tabs.
|
||||
*
|
||||
* The order of `config.spaces` is not decoration: it feeds the marker
|
||||
* placement fallback (`firstSpaceId`), the swipe neighbour and the positional
|
||||
* `floor` of a fixed-floor card. Moving a tab must therefore move nothing else
|
||||
* — see docs/specs/220-space-tab-reorder.md §8.
|
||||
*
|
||||
* Everything here is pure so the rules can be tested without a browser.
|
||||
*/
|
||||
|
||||
/** How far the pointer must travel before a click becomes a drag. */
|
||||
export const TAB_DRAG_THRESHOLD_PX = 4;
|
||||
|
||||
export interface TabDragContext {
|
||||
/** The card allows editing at all. */
|
||||
canEdit: boolean;
|
||||
/** A wall panel never reorders anything. */
|
||||
kiosk: boolean;
|
||||
/** Current mode; reordering lives in the editors only (owner, 2026-08-20). */
|
||||
mode: 'view' | 'plan' | 'devices' | 'decor';
|
||||
/** Pointer that started the gesture. */
|
||||
pointerType: string;
|
||||
/** How many spaces the panel shows right now. */
|
||||
spaceCount: number;
|
||||
/** A card pinned to one floor shows a single tab and must not reorder. */
|
||||
fixedFloor: boolean;
|
||||
}
|
||||
|
||||
/**
|
||||
* Whether a pointerdown on a tab may begin a reorder.
|
||||
*
|
||||
* Touch is deliberately excluded rather than degraded: the tabs live in View
|
||||
* as well, where switching spaces is a fully supported touch interaction, and
|
||||
* any gesture on a tab would compete with the tap that switches. The product
|
||||
* decision is recorded as `Touch editor: not exposed`.
|
||||
*/
|
||||
export function canStartTabDrag(ctx: TabDragContext): boolean {
|
||||
if (!ctx.canEdit || ctx.kiosk || ctx.fixedFloor) return false;
|
||||
if (ctx.mode === 'view') return false;
|
||||
if (ctx.pointerType !== 'mouse') return false;
|
||||
return ctx.spaceCount > 1;
|
||||
}
|
||||
|
||||
/** Has the pointer moved far enough to mean "drag" rather than "click"? */
|
||||
export function passedDragThreshold(dx: number, dy: number): boolean {
|
||||
return Math.hypot(dx, dy) >= TAB_DRAG_THRESHOLD_PX;
|
||||
}
|
||||
|
||||
/**
|
||||
* Move `movedId` so that it sits where `targetId` is, keeping every other id
|
||||
* in its relative order. Returns the same array instance when nothing moves,
|
||||
* so a caller can skip the write without comparing element by element.
|
||||
*/
|
||||
export function reorderSpaceIds(
|
||||
ids: readonly string[], movedId: string, targetId: string,
|
||||
): string[] {
|
||||
const from = ids.indexOf(movedId);
|
||||
const to = ids.indexOf(targetId);
|
||||
if (from < 0 || to < 0 || from === to) return ids as string[];
|
||||
const next = ids.slice();
|
||||
next.splice(from, 1);
|
||||
next.splice(to, 0, movedId);
|
||||
return next;
|
||||
}
|
||||
|
||||
/** Reorder the stored spaces to match `order`; ids missing from it keep their tail. */
|
||||
export function applySpaceOrder<T extends { id?: unknown }>(
|
||||
spaces: readonly T[], order: readonly string[],
|
||||
): T[] {
|
||||
const rank = new Map(order.map((id, index) => [id, index]));
|
||||
// A stable sort keeps unknown ids (there should be none) in their old order
|
||||
// instead of shuffling them by an accidental comparison result.
|
||||
return spaces
|
||||
.map((space, index) => ({ space, index }))
|
||||
.sort((a, b) => {
|
||||
const ra = rank.get(String(a.space?.id)) ?? Number.MAX_SAFE_INTEGER;
|
||||
const rb = rank.get(String(b.space?.id)) ?? Number.MAX_SAFE_INTEGER;
|
||||
return ra - rb || a.index - b.index;
|
||||
})
|
||||
.map((entry) => entry.space);
|
||||
}
|
||||
|
||||
export interface PlacementMarker {
|
||||
id?: unknown;
|
||||
space?: unknown;
|
||||
area?: unknown;
|
||||
removed?: unknown;
|
||||
}
|
||||
|
||||
/**
|
||||
* Markers whose space is decided by the "first space" fallback, and where that
|
||||
* fallback currently lands.
|
||||
*
|
||||
* Such a marker has neither an explicit `space` nor an `area` that names a
|
||||
* space. Today it renders in whichever space happens to sit first; after a
|
||||
* reorder that would be a different one — the marker would move on its own,
|
||||
* which is the one thing a reorder may never do. Writing the answer it has
|
||||
* right now makes the placement explicit and independent of order for good.
|
||||
*/
|
||||
export function markersNeedingPlacement(
|
||||
markers: readonly PlacementMarker[],
|
||||
areaToSpace: Readonly<Record<string, string>>,
|
||||
firstSpaceId: string,
|
||||
): { id: string; space: string }[] {
|
||||
if (!firstSpaceId) return [];
|
||||
const out: { id: string; space: string }[] = [];
|
||||
for (const marker of markers) {
|
||||
if (!marker || marker.removed === true) continue;
|
||||
const id = typeof marker.id === 'string' ? marker.id : '';
|
||||
if (!id) continue;
|
||||
const explicit = typeof marker.space === 'string' ? marker.space : '';
|
||||
if (explicit) continue;
|
||||
const area = typeof marker.area === 'string' ? marker.area : '';
|
||||
if (area && areaToSpace[area]) continue;
|
||||
out.push({ id, space: firstSpaceId });
|
||||
}
|
||||
return out;
|
||||
}
|
||||
@@ -1692,6 +1692,10 @@ export const cardStyles = css`
|
||||
}
|
||||
.modetab:active { transform: scale(0.97); }
|
||||
.modetab ha-icon { --mdc-icon-size: 15px; }
|
||||
/* issue #220: a tab can be dragged to a new position in the editors */
|
||||
.tab[data-reorderable] { cursor: grab; }
|
||||
.tab.dragging { cursor: grabbing; opacity: 0.55; }
|
||||
.tab.droptarget { box-shadow: inset 2px 0 0 0 var(--primary-color, #03a9f4); }
|
||||
.modetab .closex {
|
||||
--mdc-icon-size: 13px;
|
||||
box-sizing: border-box;
|
||||
|
||||
@@ -0,0 +1,85 @@
|
||||
import test from 'node:test';
|
||||
import assert from 'node:assert/strict';
|
||||
import {
|
||||
TAB_DRAG_THRESHOLD_PX, applySpaceOrder, canStartTabDrag, markersNeedingPlacement,
|
||||
passedDragThreshold, reorderSpaceIds,
|
||||
} from '../test-build/space-order.js';
|
||||
|
||||
const ctx = (over = {}) => ({
|
||||
canEdit: true, kiosk: false, mode: 'plan', pointerType: 'mouse',
|
||||
spaceCount: 3, fixedFloor: false, ...over,
|
||||
});
|
||||
|
||||
// --- AC5: где перетаскивание вообще включается -------------------------------
|
||||
|
||||
test('issue 220 tab drag is available to an editor with a mouse', () => {
|
||||
assert.equal(canStartTabDrag(ctx()), true);
|
||||
assert.equal(canStartTabDrag(ctx({ mode: 'devices' })), true);
|
||||
assert.equal(canStartTabDrag(ctx({ mode: 'decor' })), true);
|
||||
});
|
||||
|
||||
test('issue 220 tab drag is not exposed to touch, View, kiosk or a fixed floor', () => {
|
||||
// Touch is excluded by product decision, not by omission: the same tabs
|
||||
// switch spaces in View, where a tap must stay a tap.
|
||||
assert.equal(canStartTabDrag(ctx({ pointerType: 'touch' })), false);
|
||||
assert.equal(canStartTabDrag(ctx({ pointerType: 'pen' })), false);
|
||||
assert.equal(canStartTabDrag(ctx({ mode: 'view' })), false);
|
||||
assert.equal(canStartTabDrag(ctx({ kiosk: true })), false);
|
||||
assert.equal(canStartTabDrag(ctx({ canEdit: false })), false);
|
||||
assert.equal(canStartTabDrag(ctx({ fixedFloor: true })), false);
|
||||
assert.equal(canStartTabDrag(ctx({ spaceCount: 1 })), false);
|
||||
});
|
||||
|
||||
test('issue 220 a click stays a click until the pointer really travels', () => {
|
||||
assert.equal(passedDragThreshold(0, 0), false);
|
||||
assert.equal(passedDragThreshold(TAB_DRAG_THRESHOLD_PX - 1, 0), false);
|
||||
assert.equal(passedDragThreshold(TAB_DRAG_THRESHOLD_PX, 0), true);
|
||||
assert.equal(passedDragThreshold(0, TAB_DRAG_THRESHOLD_PX), true);
|
||||
});
|
||||
|
||||
// --- AC1: сама перестановка --------------------------------------------------
|
||||
|
||||
test('issue 220 a tab lands where it was dropped and the rest keep their order', () => {
|
||||
assert.deepEqual(reorderSpaceIds(['a', 'b', 'c'], 'c', 'a'), ['c', 'a', 'b']);
|
||||
assert.deepEqual(reorderSpaceIds(['a', 'b', 'c'], 'a', 'c'), ['b', 'c', 'a']);
|
||||
assert.deepEqual(reorderSpaceIds(['a', 'b', 'c'], 'b', 'b'), ['a', 'b', 'c']);
|
||||
assert.deepEqual(reorderSpaceIds(['a', 'b', 'c'], 'b', 'zz'), ['a', 'b', 'c']);
|
||||
});
|
||||
|
||||
test('issue 220 stored spaces follow the new order', () => {
|
||||
const spaces = [{ id: 'a' }, { id: 'b' }, { id: 'c' }];
|
||||
assert.deepEqual(applySpaceOrder(spaces, ['c', 'a', 'b']).map((s) => s.id), ['c', 'a', 'b']);
|
||||
// an id the order does not mention keeps its tail position instead of moving
|
||||
assert.deepEqual(
|
||||
applySpaceOrder([...spaces, { id: 'd' }], ['c', 'a', 'b']).map((s) => s.id),
|
||||
['c', 'a', 'b', 'd'],
|
||||
);
|
||||
});
|
||||
|
||||
// --- AC3: маркеры не двигаются ----------------------------------------------
|
||||
|
||||
test('issue 220 only order-dependent markers are pinned, and to where they are now', () => {
|
||||
const markers = [
|
||||
{ id: 'dangling' }, // no space, no area
|
||||
{ id: 'by-area', area: 'kitchen' }, // area names a space
|
||||
{ id: 'explicit', space: 'f2' }, // already explicit
|
||||
{ id: 'area-unknown', area: 'nowhere' }, // area names nothing
|
||||
{ id: 'gone', removed: true }, // tombstone
|
||||
];
|
||||
const pinned = markersNeedingPlacement(markers, { kitchen: 'f1' }, 'f1');
|
||||
assert.deepEqual(pinned, [
|
||||
{ id: 'dangling', space: 'f1' },
|
||||
{ id: 'area-unknown', space: 'f1' },
|
||||
]);
|
||||
});
|
||||
|
||||
test('issue 220 pinning writes the space the marker has before the reorder', () => {
|
||||
// The fallback is the FIRST space of the current order. If the write used
|
||||
// the order after the move, the marker would follow the reorder — the very
|
||||
// thing this pinning exists to prevent.
|
||||
const markers = [{ id: 'dangling' }];
|
||||
assert.deepEqual(markersNeedingPlacement(markers, {}, 'garden'), [
|
||||
{ id: 'dangling', space: 'garden' },
|
||||
]);
|
||||
assert.deepEqual(markersNeedingPlacement(markers, {}, ''), []);
|
||||
});
|
||||
@@ -20,6 +20,7 @@
|
||||
"src/virtual-light-state.ts",
|
||||
"src/types.ts",
|
||||
"src/space-geometry.ts",
|
||||
"src/space-order.ts",
|
||||
"src/signing.ts", "src/initial-load.ts", "src/space-model-selection.ts", "src/space-dialog.ts",
|
||||
"src/visual-continuity.ts", "src/mode-transition.ts", "src/pointer-modality.ts",
|
||||
"src/render-device-snapshot.ts",
|
||||
|
||||
Reference in New Issue
Block a user