Files
houseplan-card/docs/specs/074-device-position-undo.md
T
2026-08-30 13:16:27 +00:00

21 KiB
Raw Blame History

Issue #74 — position-only Undo/Redo в редакторе устройств

  • Issue: https://github.com/Matysh/houseplan-card/issues/74
  • Статус документа: готово к ревью ТЗ (S4-spec-review после публикации)
  • Приоритет: P2
  • Тип: feature/polish, полный трек
  • Пользовательское изменение: да

1. Пользовательский результат

До

В редакторе устройств любое ошибочное перетаскивание сразу меняет серверный layout. Кнопок Undo/Redo нет, а Ctrl/Cmd+Z в этом редакторе ничего не делает. Во время одного drag текущая реализация вызывает _savePos() на каждом pointermove, поэтому жест не имеет явной транзакционной границы.

После

Одно завершённое перемещение одного устройства становится одной локальной командой. Пользователь может отменить и повторить до 50 перемещений кнопками в панели редактора или стандартными shortcuts. Undo меняет только положение маркера: настройки, привязки, жизненный цикл устройства и объекты других редакторов не затрагиваются.

Основной пользователь

Функция предназначена для администратора Home Assistant, который раскладывает устройства по плану в desktop-first редакторе. Гарантированный touch-контракт View не расширяется, но отменённый системой touch-жест не должен сохранять случайную позицию.

2. Актуальность на 2026-08-30

Задача полностью актуальна на текущем dev:

  • CommandStack используется для геометрии и декора, но отдельной истории позиций устройств нет;
  • Device toolbar не содержит Undo/Redo;
  • keyboard handler выходит до обработки history в режиме devices;
  • _pointerMove() вызывает _savePos() и планирует persist на каждом кадре;
  • pointercancel сейчас направлен в обычный pointerup, поэтому отменённый браузером жест фактически коммитится;
  • backend уже предоставляет точечные houseplan/layout/update и houseplan/layout/delete, менять API или схему не требуется.

3. Цели

  1. Один успешно завершённый drag — одна команда и одна финальная запись.
  2. Undo/Redo доступны только в Device editor кнопками и стандартными shortcuts.
  3. Первый drag auto-positioned устройства полностью обратим до отсутствующей explicit layout-записи.
  4. Preview, commit, abort и persist failure имеют однозначный контракт.
  5. Position-only merge сохраняет k, включая k: 0, и неизвестные future поля.
  6. Собственный server echo сохраняет историю, несовместимое внешнее изменение безопасно её инвалидирует.

4. Не входит в задачу

  • общий Undo настроек устройства;
  • отмена add/delete/hide/show/rebind и создания virtual device;
  • keyboard nudging стрелками — отдельный scope #41;
  • server-side история между reload/remount;
  • collaborative Undo между клиентами;
  • объединение с geometry/decor history;
  • изменение backend schema/API или миграция layout;
  • расширение гарантированного touch-редактирования за пределы safety contract.

5. UX панели и shortcuts

В основной панели Device editor перед постоянной кнопкой закрытия появляются постоянные icon-only кнопки Undo и Redo. Они не относятся к динамической context tray, не меняют высоту панели и не сдвигают рабочую область.

  • Undo: mdi:undo-variant; Redo: mdi:redo-variant.
  • Пустая история или выполняющаяся запись: кнопка disabled.
  • Доступное имя и tooltip используют существующие history.undo_named, history.redo_named, history.undo_empty, history.redo_empty.
  • Имя команды локализуется новым ключом history.device_move и включает имя устройства, например «Перемещение: Датчик движения».
  • После успеха используются history.undone / history.redone.
  • Ctrl/Cmd+Z — Undo.
  • Ctrl/Cmd+Shift+Z и Ctrl+Y — Redo.
  • В input, textarea, select и [contenteditable] сохраняется нативная история поля.
  • Shortcuts активны только при _mode === "devices"; View, Plan и Backdrop не меняют своё поведение.

Если Undo вызван во время активного drag, первый вызов только abort-ит preview. Следующий Undo применяет последнюю завершённую команду.

6. Модель команды

Вводится отдельный CommandStack<DevicePositionCommand>(50). Общий src/command-stack.ts переиспользуется без смешивания со _geometryHistory.

interface DevicePlacement {
  x: number;
  y: number;
  s?: string;
}

interface DevicePositionCommand {
  deviceId: string;
  spaceId: string;
  displayName: string;
  before: DevicePlacement | null;
  after: DevicePlacement;
}

null означает, что до ручного перемещения ключ устройства отсутствовал в layout и маркер использовал auto-position. Команда хранит только placement; текущие sibling-поля записи берутся в момент применения и сохраняются.

Координаты after — уже каноническая snapped wire-position для текущего normalized/legacy режима. Undo/Redo не пропускает сохранённые точки через snap повторно и не накапливает drift.

7. Чистые position helpers

Добавляется отдельный pure-модуль (рекомендуемо src/device-position-history.ts) со следующими обязанностями:

  • снять DevicePlacement | null с layout по stable id;
  • сравнить две placement-позиции;
  • применить placement к копии layout;
  • при update сохранить все текущие неизвестные поля и k, включая k: 0;
  • при null удалить весь ключ устройства, поскольку валидная persisted position-запись обязательно содержит x/y;
  • не мутировать входной объект.

Room-label ids (rl_*) продолжают использовать свой текущий путь сохранения и никогда не попадают в device position history.

8. Транзакция drag

8.1 Begin

После существующих permission/disabled guards pointerdown фиксирует stable device id, spaceId, имя, pointer id и placement before. Пока выполняется предыдущий финальный update/delete, новый drag не начинается: это короткая serialisation boundary, исключающая гонку rollback с более новой командой.

8.2 Preview

pointermove применяет snap/clamp/conversion и меняет только локальный preview. Он может запросить render, но:

  • не добавляет id в _dirtyPos;
  • не запускает debounce/persist;
  • не создаёт history entry;
  • полностью обратим до before.

8.3 Commit

На pointerup конечная placement сравнивается с before.

  • Нет реального snapped-изменения: оставить/восстановить исходное состояние, не писать серверу, не добавлять command и не очищать Redo.
  • Есть изменение: выполнить одну optimistic final mutation и одну точечную houseplan/layout/update. После подтверждённого успеха добавить одну named command. До завершения persist Undo/Redo и новые drag disabled.
  • Ошибка update: вернуть before, не добавлять command и показать toast.pos_save_failed.

8.4 Abort

pointercancel, lostpointercapture, Escape, смена editor mode, disconnect, второй pointer, а также исчезновение/HA-disable устройства во время жеста:

  1. восстанавливают локальный before;
  2. освобождают capture/drag state;
  3. не создают command;
  4. не отправляют финальный update/delete.

9. Undo/Redo и persist

Undo извлекает команду и применяет before, Redo — after.

  • before === null: optimistic delete ключа и один houseplan/layout/delete.
  • Placement: position-only merge и один houseplan/layout/update.
  • Во время запроса history controls disabled, следующий жест не начинается.
  • При успехе направление stack остаётся изменённым и показывается named toast.
  • При ошибке optimistic state и направление stack восстанавливаются: failed Undo снова доступен как Undo, failed Redo — как Redo.
  • _sentPos либо эквивалентная pending-authority карта должна представлять как update, так и delete tombstone. Layout reload не может воскресить manual запись, удаляемую Undo, пока delete ещё in-flight.

Команда другого пространства сначала переключает карточку на spaceId, чтобы результат был виден, затем применяет изменение. Проверка выполняется по stable device id и ожидаемому space, а не по сохранённому display name.

10. Невалидные команды и revisions

History session-local и сохраняется при View ↔ Devices и обычном переключении пространств. Reload/remount её теряет.

  • Own echo с тем же фактическим layout content не очищает stack.
  • Reconnect с тем же content не очищает stack.
  • Любой принятый authoritative layout, отличающийся от текущего optimistic content, conflict/reload или whole-layout replacement очищает оба направления.
  • Authoritative config change, из-за которого command device удалён, сменил id, space или стал HA-disabled, делает команды неприменимыми.
  • Перед каждым Undo/Redo устройство повторно валидируется. Stale command не воскрешает объект: весь position stack очищается и показывается новый локализованный toast history.device_stale.
  • Новый успешный drag после Undo очищает Redo стандартным CommandStack.push().

Нельзя определять own echo только по timeout/wall-clock. Используются текущие revision/content contracts и сравнение канонического content.

11. Строгая граница истории

Position stack не получает команды при:

  • сохранении dialog и смене entity/binding/room/HA Area;
  • icon, size, rotation, display, tap action, Glow и controls;
  • hide/show/delete/re-add;
  • add virtual device, icon rules и Show hidden;
  • auto placement, registry rebuild, optimizer/migration;
  • room-label, decor, wall, opening и другой geometry drag;
  • принятии remote layout.

Удаление/rebind/HA-disable устройства очищает неприменимую историю, но не создаёт обратную команду.

12. Touch, accessibility и performance

  • Кнопки используют существующий размер toolbar target и focus-visible.
  • Tooltip и aria-label совпадают по смыслу и локализованы на EN/RU/DE/FR.
  • pointercancel, lost capture и multitouch выполняют безопасный abort.
  • Редактор остаётся desktop-first согласно docs/TOUCH-SUPPORT.md.
  • Snapshot и apply имеют O(1) стоимость; stack ограничен 50 командами.
  • Удаление persist/debounce из pointermove снижает объём работы во время drag.

13. Acceptance criteria и доказательства

AC Критерий Обязательное доказательство
AC1 Один drag даёт одну команду и одну финальную запись; десять pointermove не пишут серверу Browser smoke с fake WS/write counter
AC2 Undo/Redo возвращают exact before/after для двух устройств и нескольких drag одного устройства в LIFO-порядке Browser smoke
AC3 Auto → manual → Undo удаляет ключ через delete, Redo делает update Unit + browser smoke
AC4 No-op не создаёт команду и не очищает Redo Unit + browser smoke
AC5 Cancel/lost capture/Escape/mode switch/disconnect/second pointer восстанавливают preview и дают ноль final writes Browser smoke + source guard
AC6 Undo во время drag только abort-ит его Browser smoke
AC7 Persist failure откатывает position и корректно восстанавливает направление stack Browser smoke update/delete failure
AC8 k: 0 и неизвестные sibling-поля сохраняются; room-label не входит в history Unit
AC9 Кнопки и три shortcut работают только в Device editor; native field history не перехватывается Browser smoke
AC10 Own echo/reconnect same-content сохраняют stack, отличный remote content очищает Browser smoke sync fixture
AC11 Команда другого пространства переключает пространство и видимо применяется Browser smoke
AC12 Deleted/rebound/HA-disabled device не воскрешается, stack fail-closed очищается Browser smoke
AC13 Device toolbar сохраняет высоту и доступные имена на поддерживаемых ширинах Golden + DOM/a11y assertions
AC14 Geometry/decor Undo/Redo, pan/zoom и layout sync не регрессируют Targeted regression smoke

14. Тестовый план

Unit

Добавить test/device-position-history.test.mjs:

  • normalized и legacy snapshot/apply round-trip;
  • absent entry;
  • preservation k, k: 0 и future fields;
  • immutable input, equality/no-op;
  • stack branch/cap покрывается существующими command-stack tests.

Browser smoke

Добавить demo/smoke_device_position_history.mjs, который использует реальные pointer events Device editor и fake WS:

  • AC1–AC12, включая update/delete failures;
  • кнопки, Ctrl/Cmd+Z, Ctrl/Cmd+Shift+Z, Ctrl+Y и editable target;
  • проверка, что pointercancel больше не routed в commit-handler;
  • проверка двух пространств и внешней revision.

Регрессия и gates

  • npm run typecheck, npm test, production build и bundle sync;
  • node scripts/check-docs.mjs;
  • node scripts/no-new-any.mjs;
  • node scripts/smoke-select.mjs и выбранный targeted smoke;
  • минимум smoke_editor_tabs, smoke_layout_sync, smoke_grid_snap, smoke_pan_any_zoom и истории Plan/Backdrop;
  • целевой Device editor golden обязателен, потому что toolbar видимо меняется;
  • обновление golden/docs screenshots выполняется только каноническим Linux workflow с review diff и принятием через docs:accept.

15. План изменений

  • src/device-position-history.ts — pure snapshot/apply/equality contract;
  • src/houseplan-card.ts — stack, drag transaction, shortcuts, sync/persist;
  • src/houseplan-editor-runtime.ts — toolbar и mode-switch abort;
  • runtime host interface — типизированный history contract;
  • src/i18n/{en,ru,de,fr}.json — имя операции и stale-state сообщение;
  • unit/smoke/golden fixtures;
  • пользовательская документация и changelog;
  • сгенерированный custom_components/houseplan/www/houseplan-card.js синхронен с production build.

Backend и config/layout schema не изменяются.

16. Документация и release-артефакты

В одном user-visible implementation commit обязательны:

  • docs/CHANGELOG.md и docs/CHANGELOG.ru.md;
  • docs/USER-GUIDE.md и docs/USER-GUIDE.ru.md — кнопки, shortcuts, глубина 50 и session-local граница;
  • docs/UX-MODES.md — position-only history Device editor;
  • docs/TESTING.md — новый smoke/golden contract;
  • EN/RU/DE/FR i18n;
  • reviewed Device editor golden.

Security/performance report и HA config migration не требуются. Performance инвариант доказывается write counter: во время preview нет persist.

17. Риски и rollback

Риск Мера
Rollback гоняется с более новой записью Сериализация final position writes
Undo стирает future fields или k: 0 Pure position-only merge + unit tests
Delete resurrected layout reload Pending update/delete tombstone
Own echo очищает stack Revision + canonical content comparison
Cancel коммитит случайный touch drag Отдельный abort-handler и smoke
Stale command воскрешает device Валидация stable id/space/lifecycle, fail-closed clear
Toolbar дёргает stage Постоянные icon-only controls + golden

Rollback удаляет position stack, toolbar controls и preview transaction и возвращает прежний direct drag. Формат данных не меняется, data rollback и миграция не нужны.

18. Технические предположения, которые можно менять свободно

Без дополнительного продуктового согласования реализация может изменить:

  • имя и внутренний API pure-модуля src/device-position-history.ts;
  • форму внутренних TypeScript interfaces, если сохраняется position-only контракт и все AC;
  • способ композиции отдельного CommandStack с runtime host port;
  • способ представления update/delete tombstone в pending-authority карте;
  • момент внутреннего requestUpdate(), если preview и persist остаются визуально и транзакционно эквивалентны ТЗ.

Новый pure-модуль, две toolbar-кнопки и восемь коротких i18n-строк должны оставаться внутри действующего bundle:budget (256000 B gzip). Отдельный performance artifact не требуется, но общий budget gate обязателен.