Skip to content

SPEC: Оптимизация Undo при Drag-and-Drop (Атомарность перетаскивания)

Ticket: BACKLOG-BUG-001 Priority: Высокий (UX-критический баг) Estimated Effort: ~2-3 часа


1. Objective

Сделать любое перетаскивание (drag) одним атомарным шагом в истории Undo.

Сейчас одно перетаскивание точки генерирует десятки записей в history stack — пользователю приходится нажимать Undo 10-30 раз, чтобы отменить одно действие. Это делает Undo бесполезным.

Целевые пользователи

Любой пользователь, строящий маршрут и использующий Ctrl+Z / кнопку Undo.


2. Root Cause Analysis

Источники проблемы (два независимых бага):

Bug A: Waypoint Marker Drag (🔴 Нет pause/resume вообще)

Файл: useWaypointMarkers.ts:133-146

Маркеры Start/Finish/промежуточные точки — draggable: true. При перетаскивании MapLibre вызывает dragendupdateWaypoint() + recalculateRoute(). Но нет temporal.pause() на dragstart и нет temporal.resume() на dragend.

Каждый промежуточный updateWaypoint (от MapLibre internal drag events) записывается в history stack.

Bug B: Route-Line Drag (🟡 Resume вызывается слишком рано)

Файл: useDragInteractions.ts:259-268

Route-line rubber-band drag правильно вызывает pause() на mousedown (строка 196). Но resume() вызывается на mouseup (строка 260-262) перед recalculateRoute() (строка 268).

Это означает:

  • resume() — undo recording is ON
  • recalculateRoute() → async → setRoute(newResult)записывается как отдельный шаг
  • Если пересчёт маршрута вызывает несколько set() (waypoints + route), каждый попадёт в историю

3. Solution Design

Принцип: "Snapshot-based undo"

Вместо записи каждого промежуточного set(), мы:

  1. Pause tracking на dragstart
  2. Выполняем все мутации (updateWaypoint, recalculateRoute)
  3. Resume tracking только после финального setRoute() от recalculateRoute()

Паттерн реализации (zundo API):

typescript
// На dragstart:
usePlannerStore.temporal.getState().pause()

// На dragend (после всех мутаций завершены):
usePlannerStore.temporal.getState().resume()

Ключевой момент: resume() должен вызываться после завершения recalculateRoute(), а не до.


4. Tasks

Task 1: Fix Waypoint Marker Drag (Bug A)

Файл: apps/web/src/widgets/map-canvas/hooks/useWaypointMarkers.ts

diff
marker.on('dragstart', () => {
  el.dataset.isDragging = 'true'
+ usePlannerStore.temporal.getState().pause()
})

marker.on('dragend', () => {
  delete el.dataset.isDragging
  const lngLat = marker.getLngLat()
  const idx = Number.parseInt(marker.getElement().dataset.originalIndex || '-1', 10)
  if (idx >= 0) {
    usePlannerStore.getState().updateWaypoint(idx, { lng: lngLat.lng, lat: lngLat.lat })
-   recalculateRoute()
+   recalculateRoute().finally(() => {
+     usePlannerStore.temporal.getState().resume()
+   })
  }
+ else {
+   usePlannerStore.temporal.getState().resume()
+ }
})

Acceptance Criteria:

  • [ ] Перетаскивание Start/Finish маркера = 1 запись в Undo
  • [ ] Перетаскивание промежуточной точки = 1 запись в Undo
  • [ ] При неудачном drag (idx < 0) resume всё равно вызывается

Task 2: Fix Route-Line Drag Resume Timing (Bug B)

Файл: apps/web/src/widgets/map-canvas/hooks/useDragInteractions.ts

diff
const onGlobalMouseUp = () => {
  if (draggedRoutePointIdxRef.current === null) return

  clearRubberband()
  dragStateRef.current = null
  draggedRoutePointIdxRef.current = null
  map.dragPan.enable()
  map.getCanvas().style.cursor = ''

- // Resume undo history
- usePlannerStore.temporal.getState().resume()

  ;(map as unknown as { _justFinishedRouteDrag?: boolean })._justFinishedRouteDrag = true

- recalculateRoute()
+ // Resume undo history AFTER recalculation completes
+ recalculateRoute().finally(() => {
+   usePlannerStore.temporal.getState().resume()
+ })
}

Acceptance Criteria:

  • [ ] Rubber-band drag + route recalculation = 1 запись в Undo
  • [ ] resume() вызывается даже если recalculateRoute() завершится с ошибкой (.finally())

Task 3: Verify recalculateRoute() returns Promise

Файл: apps/web/src/features/build-route/model/routeOrchestrator.ts

Проверить что recalculateRoute() возвращает Promise (или может быть обёрнут в .finally()).

Если функция синхронная или void — обернуть возврат:

typescript
export async function recalculateRoute(): Promise<void> {
  // ... existing logic
}

Acceptance Criteria:

  • [ ] recalculateRoute() возвращает Promise<void>
  • [ ] .finally() корректно вызывается при успехе и при ошибке

Task 4: Unit Tests

Файл: apps/web/src/features/undo-redo/ui/__tests__/undoAtomicity.test.ts (новый)

typescript
describe('Undo atomicity for drag operations', () => {
  it('waypoint marker drag creates exactly 1 undo entry', () => {
    // Setup: 2 waypoints + route
    // Action: simulate drag (updateWaypoint + setRoute)
    // Assert: temporal.getState().pastStates.length increased by 1
  })

  it('route-line drag creates exactly 1 undo entry', () => {
    // Setup: 2 waypoints + route
    // Action: simulate insertWaypoint + updateWaypoint + setRoute
    // Assert: temporal.getState().pastStates.length increased by 1
  })

  it('undo after drag restores exact previous state', () => {
    // Setup: 2 waypoints + route
    // Action: drag waypoint → setRoute
    // Action: temporal.undo()
    // Assert: state matches pre-drag snapshot
  })
})

Acceptance Criteria:

  • [ ] Тест проверяет что 1 drag = 1 undo entry
  • [ ] Тест проверяет что undo после drag восстанавливает предыдущее состояние

5. Testing Strategy

TypeWhatHow
UnitAtomicity guaranteeAssert pastStates.length increments by 1 per drag
UnitResume safetyAssert resume called even on error
ManualWaypoint dragDrag Start marker → Undo once → marker returns
ManualRoute dragRubberband reshape → Undo once → route restores
RegressionExisting undoVerify addWaypoint, removeWaypoint, setProfile still work

6. Boundaries

Always Do:

  • Call pause() synchronously on dragstart
  • Call resume() in .finally() to guarantee execution
  • Keep existing partialize and equality config unchanged

Ask First:

  • Whether to add wrapTracks: true to zundo config (records undo entries on resume())
  • Whether to implement smart viewport (Task 3.1 from use-case) in this ticket or defer

Never Do:

  • Don't replace zundo with custom history — existing integration is solid
  • Don't change the equality function — it correctly prevents duplicate entries
  • Don't add debounce to updateWaypoint — it would break rubber-band visual feedback
  • Don't modify recalculateRoute internals — only ensure it returns Promise

7. Risk Assessment

RiskLikelihoodImpactMitigation
resume() never called (drag interrupted)LowHigh (undo permanently broken).finally() guarantees call; add safety timeout
recalculateRoute doesn't return PromiseMediumMediumVerify in Task 3; wrap if needed
Existing tests breakLowLowOnly touching drag event handlers, not store logic

8. Out of Scope (Deferred)

These items from use-case #004 are not in this ticket:

  • ❌ Smart Viewport (камера при Undo) → separate ticket
  • ❌ Анимация метрик при Undo → cosmetic, defer
  • ❌ AbortController для pending routes → separate ticket
  • ❌ "Clear Route" в стек Undo → separate ticket

Фокус этого тикета: минимальный хирургический фикс — 2 файла, ~15 строк изменений.