@orbat-mapper/tactical-draw 0.2.0-alpha.21 → 0.2.0-alpha.22

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/README.md CHANGED
@@ -87,6 +87,22 @@ if (session && "canCommit" in session) {
87
87
  }
88
88
  ```
89
89
 
90
+ Live edit and transform sessions expose a session-local `history`. Completed
91
+ gestures and authored programmatic mutations are undoable without settling the
92
+ session; pointer-move preview frames are not history steps:
93
+
94
+ ```ts
95
+ const active = td.activeSession;
96
+ const history =
97
+ (active && "history" in active ? active.history : null) ?? td.activeTransformSession?.history;
98
+ if (history) history.undo();
99
+ ```
100
+
101
+ `history.subscribe(...)` reports `canUndo`, `canRedo`, and optional operation
102
+ labels. Committing folds only the selected working state into the host document
103
+ as one operation. Changing a transform session's members with `setGraphics()`
104
+ establishes a new local baseline and clears both stacks.
105
+
90
106
  ## Bring your own engine
91
107
 
92
108
  Implement `MapAdapter` (or extend `BaseMapAdapter`) against your map engine
package/dist/index.d.mts CHANGED
@@ -12,6 +12,11 @@ interface PointerEventInfo {
12
12
  * mouse paths populate it; touch leaves it `undefined`.
13
13
  */
14
14
  altKey?: boolean;
15
+ /**
16
+ * Whether Shift was held for this pointer event. Rotation gestures use the
17
+ * live value to constrain transform-box angles to 15-degree increments.
18
+ */
19
+ shiftKey?: boolean;
15
20
  /** Engine-native event, for drivers that need to call preventDefault etc. */
16
21
  native?: unknown;
17
22
  }
@@ -672,6 +677,22 @@ type EditMode = "reshape" | "transform" | "labeldrag" | "delete";
672
677
  */
673
678
  type SettleReason = "close" | "error" | TacticalDrawAbortReason;
674
679
  //#endregion
680
+ //#region src/tactical-draw/session-history.d.ts
681
+ type SessionChangeSource = "gesture" | "undo" | "redo" | "programmatic";
682
+ interface SessionHistoryState {
683
+ readonly canUndo: boolean;
684
+ readonly canRedo: boolean;
685
+ readonly undoLabel?: string;
686
+ readonly redoLabel?: string;
687
+ }
688
+ interface SessionHistory {
689
+ /** Current availability and optional labels. Each session retains at most 100 completed steps. */
690
+ readonly state: SessionHistoryState;
691
+ undo(): boolean;
692
+ redo(): boolean;
693
+ subscribe(listener: (state: SessionHistoryState) => void): () => void;
694
+ }
695
+ //#endregion
675
696
  //#region src/tactical-draw/edit-session.d.ts
676
697
  /**
677
698
  * Surface delivered via `td.edit(measure, opts).onSession` and synchronously
@@ -683,6 +704,8 @@ type SettleReason = "close" | "error" | TacticalDrawAbortReason;
683
704
  * asynchronously).
684
705
  */
685
706
  interface EditSession {
707
+ /** Undo/redo for completed mutations within this live session. */
708
+ readonly history: SessionHistory;
686
709
  /** Immutable edit-start input. */
687
710
  readonly graphic: ControlMeasure;
688
711
  /** Live complete working graphic. Copy on read. */
@@ -820,6 +843,8 @@ interface EditSession {
820
843
  * observe partial state. See PRD #40 user-stories 23–24.
821
844
  */
822
845
  interface EditChangeEvent {
846
+ /** How this working-copy transition was initiated. */
847
+ readonly source: SessionChangeSource;
823
848
  /** Working state after the completed gesture. */
824
849
  readonly graphic: GraphicSnapshot<ControlMeasure>;
825
850
  /** State immediately before this gesture. */
@@ -840,6 +865,7 @@ interface EditChangeEvent {
840
865
  //#endregion
841
866
  //#region src/tactical-draw/graphic-edit-session.d.ts
842
867
  interface GraphicEditChangeEvent<G extends Graphic = Graphic> {
868
+ readonly source: SessionChangeSource;
843
869
  readonly graphic: GraphicSnapshot<G>;
844
870
  readonly previous: GraphicSnapshot<G>;
845
871
  readonly session: GraphicEditSession<G>;
@@ -849,6 +875,7 @@ interface GraphicEditChangeEvent<G extends Graphic = Graphic> {
849
875
  }
850
876
  /** Common selection/editing contract shared by control measures and point symbols. */
851
877
  interface GraphicEditSession<G extends Graphic = Graphic> {
878
+ readonly history: SessionHistory;
852
879
  /** Immutable edit-start input. */
853
880
  readonly graphic: G;
854
881
  /** Live working graphic. Copy on read. */
@@ -885,6 +912,8 @@ interface GraphicEditSession<G extends Graphic = Graphic> {
885
912
  * delivers, asynchronously).
886
913
  */
887
914
  interface TransformSession {
915
+ /** Undo/redo for completed gestures within the current membership baseline. */
916
+ readonly history: SessionHistory;
888
917
  /**
889
918
  * Live view of the current members' join-time input graphics — a fresh array
890
919
  * on each read, in current membership order. Grows/shrinks as [[setGraphics]]
@@ -926,6 +955,11 @@ interface TransformSession {
926
955
  * current box angle. Session-scoped state (`sizeAnchor`, box angle,
927
956
  * non-geometry dirty) is unchanged.
928
957
  *
958
+ * Calling this establishes a new session-history baseline: both undo and
959
+ * redo are cleared after retained members carry their working state
960
+ * forward. Membership changes and already-emitted exit snapshots are not
961
+ * undoable within the live session.
962
+ *
929
963
  * **Return value** — the committed exit snapshots of the members removed *by
930
964
  * this call*, built exactly like [[close]]'s snapshots (pixel size baked to
931
965
  * ground unless `sizeAnchor` is `"screen"`, then cloned and rendered); an
@@ -1036,6 +1070,8 @@ interface TransformSession {
1036
1070
  * `EditChangeEvent` for the identical emission contract this mirrors.
1037
1071
  */
1038
1072
  interface TransformChangeEvent {
1073
+ /** How this working-copy transition was initiated. */
1074
+ readonly source: SessionChangeSource;
1039
1075
  /** Working state after the completed gesture, index-aligned with `session.graphics` as of this emission. */
1040
1076
  readonly graphics: GraphicSnapshot<ControlMeasure>[];
1041
1077
  /** State immediately before this gesture, index-aligned with `session.graphics` as of this emission. */
@@ -1058,6 +1094,7 @@ interface TransformChangeEvent {
1058
1094
  //#endregion
1059
1095
  //#region src/tactical-draw/graphic-transform-session.d.ts
1060
1096
  interface GraphicTransformChangeEvent {
1097
+ readonly source: SessionChangeSource;
1061
1098
  readonly graphics: GraphicSnapshot[];
1062
1099
  readonly previous: GraphicSnapshot[];
1063
1100
  readonly session: GraphicTransformSession;
@@ -1066,6 +1103,7 @@ interface GraphicTransformChangeEvent {
1066
1103
  abort(): void;
1067
1104
  }
1068
1105
  interface GraphicTransformSession {
1106
+ readonly history: SessionHistory;
1069
1107
  readonly graphics: readonly Graphic[];
1070
1108
  readonly workingGraphics: readonly Graphic[];
1071
1109
  readonly dirty: boolean;
@@ -1782,4 +1820,4 @@ declare function nearestWorldCopyLng(lng: number, refLng: number): number;
1782
1820
  /** `nearestWorldCopyLng` applied to a lon/lat position. */
1783
1821
  declare function nearestWorldCopyPosition(position: Position, refLng: number): Position;
1784
1822
  //#endregion
1785
- export { BaseMapAdapter, type CombinedAbort, DEFAULT_LABEL_SIZE_PIXELS, DEFAULT_TEXT_SIZE_MAX_PIXELS, DEFAULT_TEXT_SIZE_MIN_PIXELS, type DrawMeasureDraft, type DrawOptions, type DrawPointSymbolDraft, type DrawSession, type EditChangeEvent, type EditManyOptions, type EditMode, type EditOptions, type EditPointerDriver, type EditSession, type FeatureOps, type Graphic, type GraphicEditChangeEvent, type GraphicEditSession, type GraphicRender, type GraphicSnapshot, type GraphicTransformChangeEvent, type GraphicTransformSession, type GuideStyle, type HandleStyle, type IconResource, type InteractionHit, type InteractionStyle, type LayerId, type MapAdapter, type MapCursor, type MapEvent, type MapEventHandler, type NativeEventEntry, type PickEvent, type PickHandler, type PickOptions, type PixelCoordinate, type PointSymbol, type PointSymbolAnchor, type PointSymbolCapability, PointSymbolCapabilityRequiredError, type PointSymbolColors, type PointSymbolFootprint, type PointSymbolGraphicModifiers, type PointSymbolRender, type PointSymbolSize, type PointSymbolStyle, type PointerCallback, PointerCallbackHub, type PointerEventInfo, type SettleReason, type SizeAnchor, type StyleOptions, TOUCH_HIT_TOLERANCE_PX, TacticalDraw, TacticalDrawAbortError, type TacticalDrawAbortReason, TacticalDrawDestroyedError, type TacticalDrawLayerOptions, type TacticalDrawOptions, type TextStyleOptions, type ToPixel, type TransformBox, type TransformChangeEvent, type TransformSession, bindAbortable, clonePointSymbol, coerceHandleStyle, combineWithHostSignal, getSizeAnchor, hitTestFeature, hitTestIconFeature, ignoreAbort, isPointSymbol, isTacticalDrawAbortError, nearestWorldCopyLng, nearestWorldCopyPosition, pickHitTolerance, pointSymbolFootprint, resolveGroundTextSizePixels, tryControlMeasureIdFromFeature };
1823
+ export { BaseMapAdapter, type CombinedAbort, DEFAULT_LABEL_SIZE_PIXELS, DEFAULT_TEXT_SIZE_MAX_PIXELS, DEFAULT_TEXT_SIZE_MIN_PIXELS, type DrawMeasureDraft, type DrawOptions, type DrawPointSymbolDraft, type DrawSession, type EditChangeEvent, type EditManyOptions, type EditMode, type EditOptions, type EditPointerDriver, type EditSession, type FeatureOps, type Graphic, type GraphicEditChangeEvent, type GraphicEditSession, type GraphicRender, type GraphicSnapshot, type GraphicTransformChangeEvent, type GraphicTransformSession, type GuideStyle, type HandleStyle, type IconResource, type InteractionHit, type InteractionStyle, type LayerId, type MapAdapter, type MapCursor, type MapEvent, type MapEventHandler, type NativeEventEntry, type PickEvent, type PickHandler, type PickOptions, type PixelCoordinate, type PointSymbol, type PointSymbolAnchor, type PointSymbolCapability, PointSymbolCapabilityRequiredError, type PointSymbolColors, type PointSymbolFootprint, type PointSymbolGraphicModifiers, type PointSymbolRender, type PointSymbolSize, type PointSymbolStyle, type PointerCallback, PointerCallbackHub, type PointerEventInfo, type SessionChangeSource, type SessionHistory, type SessionHistoryState, type SettleReason, type SizeAnchor, type StyleOptions, TOUCH_HIT_TOLERANCE_PX, TacticalDraw, TacticalDrawAbortError, type TacticalDrawAbortReason, TacticalDrawDestroyedError, type TacticalDrawLayerOptions, type TacticalDrawOptions, type TextStyleOptions, type ToPixel, type TransformBox, type TransformChangeEvent, type TransformSession, bindAbortable, clonePointSymbol, coerceHandleStyle, combineWithHostSignal, getSizeAnchor, hitTestFeature, hitTestIconFeature, ignoreAbort, isPointSymbol, isTacticalDrawAbortError, nearestWorldCopyLng, nearestWorldCopyPosition, pickHitTolerance, pointSymbolFootprint, resolveGroundTextSizePixels, tryControlMeasureIdFromFeature };
package/dist/index.mjs CHANGED
@@ -1693,6 +1693,19 @@ function pickHandle(handles, pixel, tolerance, toPixel) {
1693
1693
  }
1694
1694
  return null;
1695
1695
  }
1696
+ //#endregion
1697
+ //#region src/tactical-draw/rotation.ts
1698
+ /** Excalidraw-style rotation increment used while Shift is held. */
1699
+ const CONSTRAINED_ROTATION_STEP = Math.PI / 12;
1700
+ /** Normalize radians to the canonical clockwise [0, 2π) range. */
1701
+ function normalizeRotation(angle) {
1702
+ const turn = Math.PI * 2;
1703
+ return (angle % turn + turn) % turn;
1704
+ }
1705
+ /** Snap an absolute rotation angle to the nearest 15-degree increment. */
1706
+ function constrainRotation(angle) {
1707
+ return Math.round(angle / CONSTRAINED_ROTATION_STEP) * CONSTRAINED_ROTATION_STEP;
1708
+ }
1696
1709
  function rotate(x, y, angle) {
1697
1710
  const cos = Math.cos(angle);
1698
1711
  const sin = Math.sin(angle);
@@ -1956,8 +1969,9 @@ function beginBoxDrag(args) {
1956
1969
  const pivotPixel = box.center;
1957
1970
  const pivot = fromPixel(pivotPixel);
1958
1971
  const startAngle = Math.atan2(downPixel[1] - pivotPixel[1], downPixel[0] - pivotPixel[0]);
1959
- const move = (pixel) => {
1960
- const angle = Math.atan2(pixel[1] - pivotPixel[1], pixel[0] - pivotPixel[0]) - startAngle;
1972
+ const move = (pixel, modifiers) => {
1973
+ const rawAngle = Math.atan2(pixel[1] - pivotPixel[1], pixel[0] - pivotPixel[0]) - startAngle;
1974
+ const angle = modifiers?.constrainRotation ? constrainRotation(box.angle + rawAngle) - box.angle : rawAngle;
1961
1975
  return {
1962
1976
  points: rotatePointsAround(preDragPoints, pivot, pivotPixel, angle, projection),
1963
1977
  box: {
@@ -1971,7 +1985,7 @@ function beginBoxDrag(args) {
1971
1985
  return {
1972
1986
  kind,
1973
1987
  move,
1974
- end: (pixel) => move(pixel).points
1988
+ end: (pixel, modifiers) => move(pixel, modifiers).points
1975
1989
  };
1976
1990
  }
1977
1991
  const origin = fromPixel(downPixel);
@@ -2035,6 +2049,102 @@ const BEGIN_DRAG_FOR_TEST = Symbol("EditSession.beginDragForTest");
2035
2049
  */
2036
2050
  const END_DRAG_FOR_TEST = Symbol("EditSession.endDragForTest");
2037
2051
  //#endregion
2052
+ //#region src/tactical-draw/session-history.ts
2053
+ /** @internal Controller-owned history stack. */
2054
+ function createSessionHistory(options) {
2055
+ const limit = Math.max(1, options.limit ?? 100);
2056
+ let entries = [{ value: options.clone(options.initial) }];
2057
+ let index = 0;
2058
+ let settled = false;
2059
+ const listeners = /* @__PURE__ */ new Set();
2060
+ function currentState() {
2061
+ if (settled) return {
2062
+ canUndo: false,
2063
+ canRedo: false
2064
+ };
2065
+ return {
2066
+ canUndo: index > 0,
2067
+ canRedo: index < entries.length - 1,
2068
+ ...index > 0 && entries[index]?.label ? { undoLabel: entries[index].label } : {},
2069
+ ...index < entries.length - 1 && entries[index + 1]?.label ? { redoLabel: entries[index + 1].label } : {}
2070
+ };
2071
+ }
2072
+ function notify() {
2073
+ const state = currentState();
2074
+ const errors = [];
2075
+ for (const listener of [...listeners]) try {
2076
+ listener(state);
2077
+ } catch (error) {
2078
+ errors.push(error);
2079
+ }
2080
+ if (errors.length === 1) throw errors[0];
2081
+ if (errors.length > 1) throw new AggregateError(errors, "SessionHistory subscriber errors");
2082
+ }
2083
+ function move(nextIndex, source) {
2084
+ if (settled || nextIndex < 0 || nextIndex >= entries.length) return false;
2085
+ const from = options.clone(entries[index].value);
2086
+ const to = options.clone(entries[nextIndex].value);
2087
+ try {
2088
+ if (!options.apply(from, to, source)) return false;
2089
+ } catch (error) {
2090
+ if (!options.isCurrent?.(to)) throw error;
2091
+ index = nextIndex;
2092
+ try {
2093
+ notify();
2094
+ } catch (notifyError) {
2095
+ throw new AggregateError([error, notifyError], "SessionHistory undo/redo errors");
2096
+ }
2097
+ throw error;
2098
+ }
2099
+ index = nextIndex;
2100
+ notify();
2101
+ return true;
2102
+ }
2103
+ return {
2104
+ get state() {
2105
+ return currentState();
2106
+ },
2107
+ undo() {
2108
+ return move(index - 1, "undo");
2109
+ },
2110
+ redo() {
2111
+ return move(index + 1, "redo");
2112
+ },
2113
+ subscribe(listener) {
2114
+ listener(currentState());
2115
+ if (settled) return () => {};
2116
+ listeners.add(listener);
2117
+ return () => listeners.delete(listener);
2118
+ },
2119
+ record(value, label) {
2120
+ if (settled || options.equals(entries[index].value, value)) return false;
2121
+ entries.splice(index + 1);
2122
+ entries.push({
2123
+ value,
2124
+ label
2125
+ });
2126
+ if (entries.length > limit + 1) entries.shift();
2127
+ index = entries.length - 1;
2128
+ notify();
2129
+ return true;
2130
+ },
2131
+ reset(value) {
2132
+ if (settled) return;
2133
+ entries = [{ value: options.clone(value) }];
2134
+ index = 0;
2135
+ notify();
2136
+ },
2137
+ settle() {
2138
+ if (settled) return;
2139
+ settled = true;
2140
+ entries = [];
2141
+ index = 0;
2142
+ notify();
2143
+ listeners.clear();
2144
+ }
2145
+ };
2146
+ }
2147
+ //#endregion
2038
2148
  //#region src/tactical-draw/box-features.ts
2039
2149
  /**
2040
2150
  * Rendering helpers shared by the single-measure edit controller
@@ -2386,6 +2496,28 @@ function runEdit(opts) {
2386
2496
  * persisted on the measure; a fresh edit always starts back at `0`.
2387
2497
  */
2388
2498
  let boxAngle = 0;
2499
+ function captureHistory() {
2500
+ return {
2501
+ points: workingPoints.map((point) => [...point]),
2502
+ options: { ...workingOptions ?? {} },
2503
+ style: currentStyle ? { ...currentStyle } : void 0,
2504
+ textAmplifiers: workingTextAmplifiers ? { ...workingTextAmplifiers } : void 0,
2505
+ amplifierPlacements: snapshotAmplifierPlacements(),
2506
+ sizeAnchor,
2507
+ nonGeometryDirty,
2508
+ boxAngle
2509
+ };
2510
+ }
2511
+ function restoreHistory(value) {
2512
+ replaceWorkingPoints(value.points);
2513
+ replaceWorkingOptions(value.options);
2514
+ currentStyle = value.style ? { ...value.style } : void 0;
2515
+ workingTextAmplifiers = value.textAmplifiers ? { ...value.textAmplifiers } : void 0;
2516
+ workingAmplifierPlacements = value.amplifierPlacements ? structuredClone(value.amplifierPlacements) : void 0;
2517
+ sizeAnchor = value.sizeAnchor;
2518
+ nonGeometryDirty = value.nonGeometryDirty;
2519
+ boxAngle = value.boxAngle;
2520
+ }
2389
2521
  /**
2390
2522
  * The transform box fitted around the *rendered* working-copy geometry
2391
2523
  * (pixel space), or `null` when nothing projected this frame. Re-fitted
@@ -2612,12 +2744,13 @@ function runEdit(opts) {
2612
2744
  * - Reject restores the working state to the pre-gesture snapshot and
2613
2745
  * recomputes `dirty`. Close/abort settle the session as normal.
2614
2746
  */
2615
- function emitChange(previousRendered, previousPoints, previousOptions, previousStyle, previousNonGeometryDirty, previousSizeAnchor, currentRendered, previousTextAmplifiers, previousAmplifierPlacements) {
2747
+ function emitChange(previousRendered, previousPoints, previousOptions, previousStyle, previousNonGeometryDirty, previousSizeAnchor, currentRendered, previousTextAmplifiers, previousAmplifierPlacements, source = "gesture", historyLabel = "Edit", previousBoxAngle = boxAngle) {
2616
2748
  if (ctx.settled) return;
2617
2749
  let rejectRequested = false;
2618
2750
  let closeRequested = false;
2619
2751
  let abortRequested = false;
2620
2752
  const event = {
2753
+ source,
2621
2754
  graphic: currentRendered,
2622
2755
  previous: previousRendered,
2623
2756
  session,
@@ -2646,11 +2779,13 @@ function runEdit(opts) {
2646
2779
  sizeAnchor = previousSizeAnchor;
2647
2780
  workingTextAmplifiers = previousTextAmplifiers;
2648
2781
  workingAmplifierPlacements = previousAmplifierPlacements;
2782
+ boxAngle = previousBoxAngle;
2649
2783
  nonGeometryDirty = previousNonGeometryDirty;
2650
2784
  renderPreview();
2651
2785
  renderHandlesLayer();
2652
2786
  recomputeDirty();
2653
2787
  }
2788
+ if (!ctx.settled && !rejectRequested && (source === "gesture" || source === "programmatic")) history.record(captureHistory(), historyLabel);
2654
2789
  if (errors.length === 1) throw errors[0];
2655
2790
  if (errors.length > 1) throw new AggregateError(errors, "EditSession.onChange listener errors");
2656
2791
  }
@@ -2664,7 +2799,7 @@ function runEdit(opts) {
2664
2799
  * queued during the drag are applied after the listener emission (so the
2665
2800
  * post-gesture render reflects the requested mode set).
2666
2801
  */
2667
- function commitGesture(nextPoints, preGestureOptions, nextAmplifierPlacements) {
2802
+ function commitGesture(nextPoints, preGestureOptions, nextAmplifierPlacements, preGestureBoxAngle = boxAngle) {
2668
2803
  if (ctx.settled) return;
2669
2804
  const previousPoints = workingPoints.map((p) => [...p]);
2670
2805
  const previousOptions = { ...preGestureOptions ?? workingOptions ?? {} };
@@ -2691,7 +2826,7 @@ function runEdit(opts) {
2691
2826
  recomputeDirty();
2692
2827
  dragInFlight = false;
2693
2828
  drainPendingModes();
2694
- emitChange(previousRendered, previousPoints, previousOptions, previousStyle, previousNonGeometryDirty, sizeAnchor, currentRendered, previousTextAmplifiers, previousAmplifierPlacements);
2829
+ emitChange(previousRendered, previousPoints, previousOptions, previousStyle, previousNonGeometryDirty, sizeAnchor, currentRendered, previousTextAmplifiers, previousAmplifierPlacements, "gesture", "Edit", preGestureBoxAngle);
2695
2830
  }
2696
2831
  function setOptions(partial) {
2697
2832
  if (ctx.settled) return;
@@ -2706,6 +2841,7 @@ function runEdit(opts) {
2706
2841
  ...workingOptions ?? {},
2707
2842
  ...partial ?? {}
2708
2843
  });
2844
+ if (JSON.stringify(workingOptions) === JSON.stringify(previousOptions)) return;
2709
2845
  let currentRendered;
2710
2846
  try {
2711
2847
  currentRendered = rebuildRendered();
@@ -2716,7 +2852,7 @@ function runEdit(opts) {
2716
2852
  renderPreview();
2717
2853
  nonGeometryDirty = true;
2718
2854
  dirty = true;
2719
- emitChange(previousRendered, previousPoints, previousOptions, previousStyle, previousNonGeometryDirty, sizeAnchor, currentRendered, previousTextAmplifiers, previousAmplifierPlacements);
2855
+ emitChange(previousRendered, previousPoints, previousOptions, previousStyle, previousNonGeometryDirty, sizeAnchor, currentRendered, previousTextAmplifiers, previousAmplifierPlacements, "programmatic", "Change options");
2720
2856
  }
2721
2857
  function setStyle(partial) {
2722
2858
  if (ctx.settled) return;
@@ -2731,6 +2867,7 @@ function runEdit(opts) {
2731
2867
  ...currentStyle ?? {},
2732
2868
  ...partial
2733
2869
  };
2870
+ if (JSON.stringify(currentStyle) === JSON.stringify(previousStyle)) return;
2734
2871
  let currentRendered;
2735
2872
  try {
2736
2873
  currentRendered = rebuildRendered();
@@ -2741,7 +2878,7 @@ function runEdit(opts) {
2741
2878
  renderPreview();
2742
2879
  nonGeometryDirty = true;
2743
2880
  dirty = true;
2744
- emitChange(previousRendered, previousPoints, previousOptions, previousStyle, previousNonGeometryDirty, sizeAnchor, currentRendered, previousTextAmplifiers, previousAmplifierPlacements);
2881
+ emitChange(previousRendered, previousPoints, previousOptions, previousStyle, previousNonGeometryDirty, sizeAnchor, currentRendered, previousTextAmplifiers, previousAmplifierPlacements, "programmatic", "Change style");
2745
2882
  }
2746
2883
  /**
2747
2884
  * Shallow-merge into the live working text amplifiers. Mirrors
@@ -2776,6 +2913,7 @@ function runEdit(opts) {
2776
2913
  if (value !== void 0 && value.trim() !== "") merged[canonicalKey ?? key] = value;
2777
2914
  }
2778
2915
  workingTextAmplifiers = Object.keys(merged).length > 0 ? merged : void 0;
2916
+ if (JSON.stringify(workingTextAmplifiers) === JSON.stringify(previousTextAmplifiers)) return;
2779
2917
  let currentRendered;
2780
2918
  try {
2781
2919
  currentRendered = rebuildRendered();
@@ -2786,9 +2924,9 @@ function runEdit(opts) {
2786
2924
  renderPreview();
2787
2925
  nonGeometryDirty = true;
2788
2926
  dirty = true;
2789
- emitChange(previousRendered, previousPoints, previousOptions, previousStyle, previousNonGeometryDirty, sizeAnchor, currentRendered, previousTextAmplifiers, previousAmplifierPlacements);
2927
+ emitChange(previousRendered, previousPoints, previousOptions, previousStyle, previousNonGeometryDirty, sizeAnchor, currentRendered, previousTextAmplifiers, previousAmplifierPlacements, "programmatic", "Change text");
2790
2928
  }
2791
- function setAmplifierPlacements(partial) {
2929
+ function setAmplifierPlacements(partial, source = "programmatic", historyLabel = "Move label") {
2792
2930
  if (ctx.settled) return;
2793
2931
  const previousPoints = workingPoints.map((p) => [...p]);
2794
2932
  const previousOptions = { ...workingOptions ?? {} };
@@ -2808,11 +2946,12 @@ function runEdit(opts) {
2808
2946
  else next[field] = merged;
2809
2947
  }
2810
2948
  workingAmplifierPlacements = Object.keys(next).length > 0 ? next : void 0;
2949
+ if (JSON.stringify(workingAmplifierPlacements) === JSON.stringify(previousAmplifierPlacements)) return;
2811
2950
  const currentRendered = rebuildRendered();
2812
2951
  renderPreview();
2813
2952
  nonGeometryDirty = true;
2814
2953
  dirty = true;
2815
- emitChange(previousRendered, previousPoints, previousOptions, previousStyle, previousNonGeometryDirty, sizeAnchor, currentRendered, previousTextAmplifiers, previousAmplifierPlacements);
2954
+ emitChange(previousRendered, previousPoints, previousOptions, previousStyle, previousNonGeometryDirty, sizeAnchor, currentRendered, previousTextAmplifiers, previousAmplifierPlacements, source, historyLabel);
2816
2955
  }
2817
2956
  /**
2818
2957
  * Flip the size anchor for the measure under edit (ADR-0020). Re-renders the
@@ -2835,7 +2974,7 @@ function runEdit(opts) {
2835
2974
  const currentRendered = rebuildRendered();
2836
2975
  nonGeometryDirty = true;
2837
2976
  dirty = true;
2838
- emitChange(previousRendered, previousPoints, previousOptions, previousStyle, previousNonGeometryDirty, previousSizeAnchor, currentRendered, previousTextAmplifiers, previousAmplifierPlacements);
2977
+ emitChange(previousRendered, previousPoints, previousOptions, previousStyle, previousNonGeometryDirty, previousSizeAnchor, currentRendered, previousTextAmplifiers, previousAmplifierPlacements, "programmatic", "Change size anchor");
2839
2978
  }
2840
2979
  /** Apply a translate gesture by shifting every working point by `delta`. */
2841
2980
  function commitTranslate(delta) {
@@ -2864,7 +3003,7 @@ function runEdit(opts) {
2864
3003
  const pivot = box ? driver.fromPixel(box.center) : computeBoundingBoxCenter(workingPoints);
2865
3004
  if (!pivot) return;
2866
3005
  boxAngle += angle;
2867
- commitGesture(rotatePointsAround(workingPoints, pivot, pivotPixel, angle, dragProjection), preFreezeOptions);
3006
+ commitGesture(rotatePointsAround(workingPoints, pivot, pivotPixel, angle, dragProjection), preFreezeOptions, void 0, boxAngle - angle);
2868
3007
  }
2869
3008
  function modesEqual(a, b) {
2870
3009
  if (a.length !== b.length) return false;
@@ -2883,7 +3022,32 @@ function runEdit(opts) {
2883
3022
  pendingModes = null;
2884
3023
  applyModes(next);
2885
3024
  }
3025
+ const history = createSessionHistory({
3026
+ initial: captureHistory(),
3027
+ clone: (value) => ({
3028
+ points: value.points.map((point) => [...point]),
3029
+ options: { ...value.options ?? {} },
3030
+ style: value.style ? { ...value.style } : void 0,
3031
+ textAmplifiers: value.textAmplifiers ? { ...value.textAmplifiers } : void 0,
3032
+ amplifierPlacements: value.amplifierPlacements ? structuredClone(value.amplifierPlacements) : void 0,
3033
+ sizeAnchor: value.sizeAnchor,
3034
+ nonGeometryDirty: value.nonGeometryDirty,
3035
+ boxAngle: value.boxAngle
3036
+ }),
3037
+ equals: (a, b) => JSON.stringify(a) === JSON.stringify(b),
3038
+ isCurrent: (value) => JSON.stringify(captureHistory()) === JSON.stringify(value),
3039
+ apply(from, to, source) {
3040
+ const previousRendered = rebuildRendered();
3041
+ restoreHistory(to);
3042
+ renderPreview();
3043
+ renderHandlesLayer();
3044
+ recomputeDirty();
3045
+ emitChange(previousRendered, from.points, from.options, from.style, from.nonGeometryDirty, from.sizeAnchor, rebuildRendered(), from.textAmplifiers, from.amplifierPlacements, source, "Edit", from.boxAngle);
3046
+ return !ctx.settled && JSON.stringify(captureHistory()) === JSON.stringify(to);
3047
+ }
3048
+ });
2886
3049
  const session = {
3050
+ history,
2887
3051
  graphic: measure,
2888
3052
  get workingGraphic() {
2889
3053
  return cloneControlMeasure({
@@ -2943,6 +3107,7 @@ function runEdit(opts) {
2943
3107
  const current = session.workingGraphic;
2944
3108
  const next = updater(current);
2945
3109
  if (next.id !== measure.id || next.kind !== measure.kind) throw new TypeError("updateGraphic must preserve control-measure id and kind");
3110
+ if (JSON.stringify(next) === JSON.stringify(current)) return;
2946
3111
  renderMeasureForAdapter(adapter, next, renderOpts);
2947
3112
  workingPoints.splice(0, workingPoints.length, ...next.controlPoints.map((point) => [...point]));
2948
3113
  workingOptions = { ...next.options ?? {} };
@@ -2953,7 +3118,7 @@ function runEdit(opts) {
2953
3118
  renderPreview();
2954
3119
  renderHandlesLayer();
2955
3120
  recomputeDirty();
2956
- emitChange(previous, previous.graphic.controlPoints.map((point) => [...point]), previous.graphic.options, previous.graphic.style, previousNonGeometryDirty, previousSizeAnchor, rebuildRendered(), previous.graphic.textAmplifiers, previous.graphic.amplifierPlacements);
3121
+ emitChange(previous, previous.graphic.controlPoints.map((point) => [...point]), previous.graphic.options, previous.graphic.style, previousNonGeometryDirty, previousSizeAnchor, rebuildRendered(), previous.graphic.textAmplifiers, previous.graphic.amplifierPlacements, "programmatic", "Update graphic");
2957
3122
  },
2958
3123
  setOptions(partial) {
2959
3124
  setOptions(partial);
@@ -3258,9 +3423,9 @@ function runEdit(opts) {
3258
3423
  renderHandlesLayer();
3259
3424
  recomputeDirty();
3260
3425
  }
3261
- function paintBoxDragSnapshot(pixel) {
3426
+ function paintBoxDragSnapshot(pixel, constrainRotation = false) {
3262
3427
  if (!activeBoxDrag) return;
3263
- const snapshot = activeBoxDrag.fsm.move(pixel);
3428
+ const snapshot = activeBoxDrag.fsm.move(pixel, { constrainRotation });
3264
3429
  activeBoxDrag.lastSnapshot = snapshot;
3265
3430
  workingPoints.splice(0, workingPoints.length, ...snapshot.points);
3266
3431
  workingAmplifierPlacements = transformAmplifierPlacements(activeBoxDrag.preDragAmplifierPlacements, activeBoxDrag.preDragBox, snapshot.box, driver);
@@ -3268,7 +3433,7 @@ function runEdit(opts) {
3268
3433
  renderHandlesLayer();
3269
3434
  recomputeDirty();
3270
3435
  }
3271
- const onDriverMove = (pixel) => {
3436
+ const onDriverMove = (pixel, info) => {
3272
3437
  if (ctx.settled) return;
3273
3438
  if (activeLabelRotateDrag) {
3274
3439
  const drag = activeLabelRotateDrag;
@@ -3304,7 +3469,7 @@ function runEdit(opts) {
3304
3469
  renderPreview();
3305
3470
  renderHandlesLayer();
3306
3471
  } else if (activeDrag) paintDragSnapshot(pixel);
3307
- else if (activeBoxDrag) paintBoxDragSnapshot(pixel);
3472
+ else if (activeBoxDrag) paintBoxDragSnapshot(pixel, info?.shiftKey);
3308
3473
  };
3309
3474
  /** Reset drag-in-flight bookkeeping shared by every "gesture ended" path. */
3310
3475
  function endDragBookkeeping() {
@@ -3312,7 +3477,7 @@ function runEdit(opts) {
3312
3477
  dragInFlight = false;
3313
3478
  drainPendingModes();
3314
3479
  }
3315
- const onDriverUp = (pixel) => {
3480
+ const onDriverUp = (pixel, info) => {
3316
3481
  if (activeLabelRotateDrag) {
3317
3482
  const drag = activeLabelRotateDrag;
3318
3483
  activeLabelRotateDrag = null;
@@ -3321,7 +3486,7 @@ function runEdit(opts) {
3321
3486
  if (Math.hypot(pixel[0] - drag.downPixel[0], pixel[1] - drag.downPixel[1]) > CLICK_SLOP_PX$3) {
3322
3487
  const delta = pointerAngleAround(pixel, drag.anchorPixel) - drag.startPointerAngle;
3323
3488
  const rotation = drag.startRotation - delta;
3324
- setAmplifierPlacements({ [drag.key]: { rotation } });
3489
+ setAmplifierPlacements({ [drag.key]: { rotation } }, "gesture", "Rotate label");
3325
3490
  } else {
3326
3491
  renderPreview();
3327
3492
  renderHandlesLayer();
@@ -3335,7 +3500,7 @@ function runEdit(opts) {
3335
3500
  const position = labelAnchorFromPointer(pixel, grabOffset);
3336
3501
  workingAmplifierPlacements = previous;
3337
3502
  endDragBookkeeping();
3338
- if (position && Math.hypot(pixel[0] - downPixel[0], pixel[1] - downPixel[1]) > CLICK_SLOP_PX$3) setAmplifierPlacements({ [field]: position });
3503
+ if (position && Math.hypot(pixel[0] - downPixel[0], pixel[1] - downPixel[1]) > CLICK_SLOP_PX$3) setAmplifierPlacements({ [field]: position }, "gesture", "Move label");
3339
3504
  else {
3340
3505
  selectedLabelKey = selectedLabelKey === field ? null : field;
3341
3506
  renderPreview();
@@ -3380,11 +3545,11 @@ function runEdit(opts) {
3380
3545
  bodyClickPending = true;
3381
3546
  return;
3382
3547
  }
3383
- const finalSnapshot = fsm.move(pixel);
3548
+ const finalSnapshot = fsm.move(pixel, { constrainRotation: info?.shiftKey });
3384
3549
  replaceWorkingPoints(preDragPoints);
3385
3550
  workingAmplifierPlacements = preDragAmplifierPlacements;
3386
3551
  if (fsm.kind === "rotate") boxAngle += finalSnapshot.box.angle - preDragBox.angle;
3387
- commitGesture(finalSnapshot.points, preDragOptions, transformAmplifierPlacements(preDragAmplifierPlacements, preDragBox, finalSnapshot.box, driver));
3552
+ commitGesture(finalSnapshot.points, preDragOptions, transformAmplifierPlacements(preDragAmplifierPlacements, preDragBox, finalSnapshot.box, driver), preDragBox.angle);
3388
3553
  return;
3389
3554
  }
3390
3555
  };
@@ -3435,8 +3600,12 @@ function runEdit(opts) {
3435
3600
  ],
3436
3601
  onViewChange: rescaleOnViewChange,
3437
3602
  onSettled: (settlement) => {
3438
- if (onSettled) onSettled();
3439
- settleFromInteraction(settlement, commitEmitter, settleEmitter);
3603
+ try {
3604
+ history.settle();
3605
+ } finally {
3606
+ if (onSettled) onSettled();
3607
+ settleFromInteraction(settlement, commitEmitter, settleEmitter);
3608
+ }
3440
3609
  },
3441
3610
  attach(c) {
3442
3611
  ctx = c;
@@ -3641,6 +3810,25 @@ function runGroupTransform(opts) {
3641
3810
  const settleEmitter = createSettleEmitter("TransformSession.onSettled listener errors");
3642
3811
  /** Session-scoped box rotation (ADR-0017 decision #2), shared by the whole group. */
3643
3812
  let boxAngle = 0;
3813
+ function captureHistory() {
3814
+ return {
3815
+ ids: members.map((member) => member.measure.id),
3816
+ points: clonePointsByMember(),
3817
+ amplifierPlacements: cloneAmplifierPlacementsByMember(),
3818
+ optionsOverrides: cloneOptionsOverrideByMember(),
3819
+ sizeAnchor,
3820
+ nonGeometryDirty,
3821
+ boxAngle
3822
+ };
3823
+ }
3824
+ function restoreHistory(value) {
3825
+ restorePointsByMember(value.points);
3826
+ applyAmplifierPlacementsByMember(value.amplifierPlacements.map((placements) => placements ? structuredClone(placements) : void 0));
3827
+ applyOptionsOverrides(value.optionsOverrides);
3828
+ sizeAnchor = value.sizeAnchor;
3829
+ nonGeometryDirty = value.nonGeometryDirty;
3830
+ boxAngle = value.boxAngle;
3831
+ }
3644
3832
  /** The group box fitted over every member's rendered geometry, or `null` when nothing projects this frame. */
3645
3833
  let groupBox = null;
3646
3834
  let ctx;
@@ -3817,12 +4005,13 @@ function runGroupTransform(opts) {
3817
4005
  * instance to all listeners in order, deferred `reject`/`close`/`abort`
3818
4006
  * (close/abort override reject), exception collection with `AggregateError`.
3819
4007
  */
3820
- function emitChange(previousRendered, previousPointsByMember, previousAmplifierPlacementsByMember, previousSizeAnchor, previousNonGeometryDirty, previousOptionsOverrideByMember, currentRendered) {
4008
+ function emitChange(previousRendered, previousPointsByMember, previousAmplifierPlacementsByMember, previousSizeAnchor, previousNonGeometryDirty, previousOptionsOverrideByMember, currentRendered, source = "gesture", historyLabel = "Transform", previousBoxAngle = boxAngle) {
3821
4009
  if (ctx.settled) return;
3822
4010
  let rejectRequested = false;
3823
4011
  let closeRequested = false;
3824
4012
  let abortRequested = false;
3825
4013
  const event = {
4014
+ source,
3826
4015
  graphics: currentRendered,
3827
4016
  previous: previousRendered,
3828
4017
  session,
@@ -3851,10 +4040,12 @@ function runGroupTransform(opts) {
3851
4040
  sizeAnchor = previousSizeAnchor;
3852
4041
  nonGeometryDirty = previousNonGeometryDirty;
3853
4042
  applyOptionsOverrides(previousOptionsOverrideByMember);
4043
+ boxAngle = previousBoxAngle;
3854
4044
  renderAllPreviews();
3855
4045
  renderHandlesLayer();
3856
4046
  recomputeDirty();
3857
4047
  }
4048
+ if (!ctx.settled && !rejectRequested && members === membersAtEmission && (source === "gesture" || source === "programmatic")) history.record(captureHistory(), historyLabel);
3858
4049
  if (errors.length === 1) throw errors[0];
3859
4050
  if (errors.length > 1) throw new AggregateError(errors, "TransformSession.onChange listener errors");
3860
4051
  }
@@ -3880,7 +4071,7 @@ function runGroupTransform(opts) {
3880
4071
  }
3881
4072
  return snapshots;
3882
4073
  }
3883
- function commitGesture(nextConcatPoints, preGestureOptionsOverrideByMember, nextAmplifierPlacementsByMember) {
4074
+ function commitGesture(nextConcatPoints, preGestureOptionsOverrideByMember, nextAmplifierPlacementsByMember, preGestureBoxAngle = boxAngle) {
3884
4075
  if (ctx.settled) return;
3885
4076
  const previousPointsByMember = clonePointsByMember();
3886
4077
  const previousAmplifierPlacementsByMember = cloneAmplifierPlacementsByMember();
@@ -3901,7 +4092,7 @@ function runGroupTransform(opts) {
3901
4092
  renderHandlesLayer();
3902
4093
  recomputeDirty();
3903
4094
  dragInFlight = false;
3904
- emitChange(previousRendered, previousPointsByMember, previousAmplifierPlacementsByMember, sizeAnchor, nonGeometryDirty, previousOptionsOverrideByMember, currentRendered);
4095
+ emitChange(previousRendered, previousPointsByMember, previousAmplifierPlacementsByMember, sizeAnchor, nonGeometryDirty, previousOptionsOverrideByMember, currentRendered, "gesture", "Transform", preGestureBoxAngle);
3905
4096
  }
3906
4097
  function setSizeAnchor(next) {
3907
4098
  if (ctx.settled || sizeAnchor === next) return;
@@ -3915,7 +4106,7 @@ function runGroupTransform(opts) {
3915
4106
  const currentRendered = rebuildRenderedAll();
3916
4107
  nonGeometryDirty = true;
3917
4108
  dirty = true;
3918
- emitChange(previousRendered, previousPointsByMember, previousAmplifierPlacementsByMember, previousSizeAnchor, previousNonGeometryDirty, previousOptionsOverrideByMember, currentRendered);
4109
+ emitChange(previousRendered, previousPointsByMember, previousAmplifierPlacementsByMember, previousSizeAnchor, previousNonGeometryDirty, previousOptionsOverrideByMember, currentRendered, "programmatic", "Change size anchor");
3919
4110
  }
3920
4111
  /**
3921
4112
  * Cancel an in-flight box drag without committing it: restore the pre-drag
@@ -3962,13 +4153,42 @@ function runGroupTransform(opts) {
3962
4153
  renderAllPreviews();
3963
4154
  renderHandlesLayer();
3964
4155
  recomputeDirty();
3965
- if (removedSnapshots.length > 0) commitEmitter.emit(removedSnapshots);
4156
+ const notificationErrors = [];
4157
+ try {
4158
+ history.reset(captureHistory());
4159
+ } catch (error) {
4160
+ notificationErrors.push(error);
4161
+ }
4162
+ if (removedSnapshots.length > 0) try {
4163
+ commitEmitter.emit(removedSnapshots);
4164
+ } catch (error) {
4165
+ notificationErrors.push(error);
4166
+ }
4167
+ if (notificationErrors.length === 1) throw notificationErrors[0];
4168
+ if (notificationErrors.length > 1) throw new AggregateError(notificationErrors, "TransformSession.setGraphics notification errors");
3966
4169
  return removedSnapshots;
3967
4170
  }
3968
4171
  function close() {
3969
4172
  return commitClose();
3970
4173
  }
4174
+ const history = createSessionHistory({
4175
+ initial: captureHistory(),
4176
+ clone: (value) => structuredClone(value),
4177
+ equals: (a, b) => JSON.stringify(a) === JSON.stringify(b),
4178
+ isCurrent: (value) => JSON.stringify(captureHistory()) === JSON.stringify(value),
4179
+ apply(from, to, source) {
4180
+ if (JSON.stringify(from.ids) !== JSON.stringify(to.ids)) return false;
4181
+ const previousRendered = rebuildRenderedAll();
4182
+ restoreHistory(to);
4183
+ renderAllPreviews();
4184
+ renderHandlesLayer();
4185
+ recomputeDirty();
4186
+ emitChange(previousRendered, from.points, from.amplifierPlacements, from.sizeAnchor, from.nonGeometryDirty, from.optionsOverrides, rebuildRenderedAll(), source, "Transform", from.boxAngle);
4187
+ return !ctx.settled && JSON.stringify(captureHistory()) === JSON.stringify(to);
4188
+ }
4189
+ });
3971
4190
  const session = {
4191
+ history,
3972
4192
  get graphics() {
3973
4193
  return members.map((m) => m.measure);
3974
4194
  },
@@ -4044,9 +4264,9 @@ function runGroupTransform(opts) {
4044
4264
  dragInFlight = true;
4045
4265
  driver.setDragPanEnabled(false);
4046
4266
  };
4047
- function paintBoxDragSnapshot(pixel) {
4267
+ function paintBoxDragSnapshot(pixel, constrainRotation = false) {
4048
4268
  if (!activeBoxDrag) return;
4049
- const snapshot = activeBoxDrag.fsm.move(pixel);
4269
+ const snapshot = activeBoxDrag.fsm.move(pixel, { constrainRotation });
4050
4270
  activeBoxDrag.lastSnapshot = snapshot;
4051
4271
  applyConcatenatedPoints(snapshot.points);
4052
4272
  applyAmplifierPlacementsByMember(activeBoxDrag.preDragAmplifierPlacementsByMember.map((placements) => transformAmplifierPlacements(placements, activeBoxDrag.preDragBox, snapshot.box, driver)));
@@ -4054,11 +4274,11 @@ function runGroupTransform(opts) {
4054
4274
  renderHandlesLayer();
4055
4275
  recomputeDirty();
4056
4276
  }
4057
- const onDriverMove = (pixel) => {
4277
+ const onDriverMove = (pixel, info) => {
4058
4278
  if (ctx.settled) return;
4059
- if (activeBoxDrag) paintBoxDragSnapshot(pixel);
4279
+ if (activeBoxDrag) paintBoxDragSnapshot(pixel, info?.shiftKey);
4060
4280
  };
4061
- const onDriverUp = (pixel) => {
4281
+ const onDriverUp = (pixel, info) => {
4062
4282
  if (!activeBoxDrag) return;
4063
4283
  if (ctx.settled) {
4064
4284
  activeBoxDrag = null;
@@ -4080,11 +4300,11 @@ function runGroupTransform(opts) {
4080
4300
  bodyClickPending = true;
4081
4301
  return;
4082
4302
  }
4083
- const finalSnapshot = fsm.move(pixel);
4303
+ const finalSnapshot = fsm.move(pixel, { constrainRotation: info?.shiftKey });
4084
4304
  applyConcatenatedPoints(preDragPoints);
4085
4305
  applyAmplifierPlacementsByMember(preDragAmplifierPlacementsByMember);
4086
4306
  if (fsm.kind === "rotate") boxAngle += finalSnapshot.box.angle - preDragBox.angle;
4087
- commitGesture(finalSnapshot.points, preDragOptionsOverrideByMember, preDragAmplifierPlacementsByMember.map((placements) => transformAmplifierPlacements(placements, preDragBox, finalSnapshot.box, driver)));
4307
+ commitGesture(finalSnapshot.points, preDragOptionsOverrideByMember, preDragAmplifierPlacementsByMember.map((placements) => transformAmplifierPlacements(placements, preDragBox, finalSnapshot.box, driver)), preDragBox.angle);
4088
4308
  };
4089
4309
  let pickPreview;
4090
4310
  let pickHandles;
@@ -4130,8 +4350,12 @@ function runGroupTransform(opts) {
4130
4350
  layers: [previewLayer, handlesLayer],
4131
4351
  onViewChange: rescaleOnViewChange,
4132
4352
  onSettled: (settlement) => {
4133
- if (onSettled) onSettled();
4134
- if (settlement.status === "rejected") settleEmitter.emit(settleReasonFromError(settlement.error));
4353
+ try {
4354
+ history.settle();
4355
+ } finally {
4356
+ if (onSettled) onSettled();
4357
+ if (settlement.status === "rejected") settleEmitter.emit(settleReasonFromError(settlement.error));
4358
+ }
4135
4359
  },
4136
4360
  attach(c) {
4137
4361
  ctx = c;
@@ -4245,13 +4469,6 @@ function pointSymbolFeature(symbol, render, resolution) {
4245
4469
  };
4246
4470
  }
4247
4471
  //#endregion
4248
- //#region src/tactical-draw/rotation.ts
4249
- /** Normalize radians to the canonical clockwise [0, 2π) range. */
4250
- function normalizeRotation(angle) {
4251
- const turn = Math.PI * 2;
4252
- return (angle % turn + turn) % turn;
4253
- }
4254
- //#endregion
4255
4472
  //#region src/tactical-draw/graphic-transform-controller.ts
4256
4473
  const HIT_TOLERANCE_PX$1 = 12;
4257
4474
  const CLICK_SLOP_PX$1 = 3;
@@ -4272,13 +4489,22 @@ function runGraphicTransform(opts) {
4272
4489
  const commitEmitter = createCommitEmitter("GraphicTransformSession.onCommit listener errors");
4273
4490
  const settleEmitter = createSettleEmitter("GraphicTransformSession.onSettled listener errors");
4274
4491
  const renderOpts = graphicsStyle ? { graphicsStyle } : void 0;
4275
- const snapshots = () => working.map((graphic) => isPointSymbol(graphic) ? {
4492
+ const snapshotsFor = (source) => source.map((graphic) => isPointSymbol(graphic) ? {
4276
4493
  graphic: clonePointSymbol(graphic),
4277
4494
  render: capability.render(graphic)
4278
4495
  } : {
4279
4496
  graphic: cloneControlMeasure(graphic),
4280
4497
  render: renderMeasureForAdapter(adapter, graphic, renderOpts)
4281
4498
  });
4499
+ const snapshots = () => snapshotsFor(working);
4500
+ const captureHistory = () => ({
4501
+ working: working.map(cloneGraphic),
4502
+ boxAngle
4503
+ });
4504
+ const cloneHistory = (value) => ({
4505
+ working: value.working.map(cloneGraphic),
4506
+ boxAngle: value.boxAngle
4507
+ });
4282
4508
  function renderAll() {
4283
4509
  if (ctx.settled) return;
4284
4510
  const features = [];
@@ -4345,12 +4571,14 @@ function runGraphicTransform(opts) {
4345
4571
  return next;
4346
4572
  });
4347
4573
  }
4348
- function emit(previous, previousGraphics) {
4574
+ function emit(previousState, source) {
4575
+ const previous = snapshotsFor(previousState.working);
4349
4576
  const current = snapshots();
4350
4577
  let reject = false;
4351
4578
  let close = false;
4352
4579
  let abort = false;
4353
4580
  const event = {
4581
+ source,
4354
4582
  graphics: current,
4355
4583
  previous,
4356
4584
  session,
@@ -4367,14 +4595,30 @@ function runGraphicTransform(opts) {
4367
4595
  if (abort) ctx.fail(new TacticalDrawAbortError("session"));
4368
4596
  else if (close) ctx.commit(snapshots);
4369
4597
  else if (reject && !ctx.settled) {
4370
- working = previousGraphics.map(cloneGraphic);
4598
+ working = previousState.working.map(cloneGraphic);
4599
+ boxAngle = previousState.boxAngle;
4371
4600
  dirty = JSON.stringify(working) !== JSON.stringify(original);
4372
4601
  renderAll();
4373
4602
  }
4374
4603
  if (errors.length === 1) throw errors[0];
4375
4604
  if (errors.length > 1) throw new AggregateError(errors, "GraphicTransformSession.onChange errors");
4376
- }
4605
+ return !ctx.settled && !reject;
4606
+ }
4607
+ const history = createSessionHistory({
4608
+ initial: captureHistory(),
4609
+ clone: cloneHistory,
4610
+ equals: (a, b) => a.boxAngle === b.boxAngle && JSON.stringify(a.working) === JSON.stringify(b.working),
4611
+ isCurrent: (value) => boxAngle === value.boxAngle && JSON.stringify(working) === JSON.stringify(value.working),
4612
+ apply(from, to, source) {
4613
+ working = to.working.map(cloneGraphic);
4614
+ boxAngle = to.boxAngle;
4615
+ dirty = JSON.stringify(working) !== JSON.stringify(original);
4616
+ renderAll();
4617
+ return emit(from, source);
4618
+ }
4619
+ });
4377
4620
  const session = {
4621
+ history,
4378
4622
  get graphics() {
4379
4623
  return original.map(cloneGraphic);
4380
4624
  },
@@ -4421,9 +4665,9 @@ function runGraphicTransform(opts) {
4421
4665
  };
4422
4666
  driver.setDragPanEnabled(false);
4423
4667
  };
4424
- const dragResult = (pixel) => {
4668
+ const dragResult = (pixel, constrainRotation = false) => {
4425
4669
  if (!drag) return null;
4426
- const result = drag.fsm.move(pixel);
4670
+ const result = drag.fsm.move(pixel, { constrainRotation });
4427
4671
  const scale = drag.fsm.kind === "scale" ? Math.max(result.box.halfWidth / drag.startBox.halfWidth, result.box.halfHeight / drag.startBox.halfHeight) : 1;
4428
4672
  const rotation = drag.fsm.kind === "rotate" ? result.box.angle - drag.startBox.angle : 0;
4429
4673
  return {
@@ -4431,18 +4675,18 @@ function runGraphicTransform(opts) {
4431
4675
  graphics: applyPoints(drag.start, result.points, scale, rotation)
4432
4676
  };
4433
4677
  };
4434
- const onMove = (pixel) => {
4678
+ const onMove = (pixel, info) => {
4435
4679
  const active = drag;
4436
- const next = dragResult(pixel);
4680
+ const next = dragResult(pixel, info?.shiftKey);
4437
4681
  if (!active || !next) return;
4438
4682
  working = next.graphics;
4439
4683
  active.liveBox = next.result.box;
4440
4684
  renderAll();
4441
4685
  };
4442
- const onUp = (pixel) => {
4686
+ const onUp = (pixel, info) => {
4443
4687
  if (!drag) return;
4444
4688
  const active = drag;
4445
- const next = dragResult(pixel);
4689
+ const next = dragResult(pixel, info?.shiftKey);
4446
4690
  drag = null;
4447
4691
  suppressClickAway = true;
4448
4692
  driver.setDragPanEnabled(true);
@@ -4452,13 +4696,15 @@ function runGraphicTransform(opts) {
4452
4696
  renderAll();
4453
4697
  return;
4454
4698
  }
4455
- const previous = snapshots();
4456
- const previousGraphics = working.map(cloneGraphic);
4699
+ const previousState = captureHistory();
4457
4700
  working = next.graphics;
4458
4701
  if (active.fsm.kind === "rotate") boxAngle += next.result.box.angle - active.startBox.angle;
4459
4702
  dirty = JSON.stringify(working) !== JSON.stringify(original);
4460
4703
  renderAll();
4461
- emit(previous, previousGraphics);
4704
+ if (emit(previousState, "gesture")) {
4705
+ const label = `${active.fsm.kind[0].toUpperCase()}${active.fsm.kind.slice(1)}`;
4706
+ history.record(captureHistory(), label);
4707
+ }
4462
4708
  };
4463
4709
  let previewPick;
4464
4710
  let handlesPick;
@@ -4481,8 +4727,12 @@ function runGraphicTransform(opts) {
4481
4727
  layers: [previewLayer, handlesLayer],
4482
4728
  onViewChange: renderAll,
4483
4729
  onSettled(settlement) {
4484
- onSettled?.();
4485
- settleFromInteraction(settlement, commitEmitter, settleEmitter);
4730
+ try {
4731
+ history.settle();
4732
+ } finally {
4733
+ onSettled?.();
4734
+ settleFromInteraction(settlement, commitEmitter, settleEmitter);
4735
+ }
4486
4736
  },
4487
4737
  attach(interaction) {
4488
4738
  ctx = interaction;
@@ -4632,6 +4882,8 @@ function runPointSymbolEdit(opts) {
4632
4882
  const listeners = /* @__PURE__ */ new Set();
4633
4883
  const commitEmitter = createCommitEmitter("GraphicEditSession.onCommit listener errors");
4634
4884
  const settleEmitter = createSettleEmitter("GraphicEditSession.onSettled listener errors");
4885
+ const captureHistory = () => ({ working: clonePointSymbol(working) });
4886
+ const cloneHistory = (value) => ({ working: clonePointSymbol(value.working) });
4635
4887
  const snapshot = () => ({
4636
4888
  graphic: clonePointSymbol(working),
4637
4889
  render: capability.render(working)
@@ -4649,15 +4901,17 @@ function runPointSymbolEdit(opts) {
4649
4901
  }
4650
4902
  ctx.writeLayer(handlesLayer, handles);
4651
4903
  };
4652
- const apply = (next, previous = snapshot()) => {
4653
- working = clonePointSymbol(next);
4654
- dirty = JSON.stringify(working) !== JSON.stringify(symbol);
4655
- render();
4904
+ const emitChange = (previousState, source) => {
4905
+ const previous = {
4906
+ graphic: clonePointSymbol(previousState.working),
4907
+ render: capability.render(previousState.working)
4908
+ };
4656
4909
  const current = snapshot();
4657
4910
  let reject = false;
4658
4911
  let close = false;
4659
4912
  let abort = false;
4660
4913
  const event = {
4914
+ source,
4661
4915
  graphic: current,
4662
4916
  previous,
4663
4917
  session,
@@ -4680,14 +4934,35 @@ function runPointSymbolEdit(opts) {
4680
4934
  if (abort) ctx.fail(new TacticalDrawAbortError("session"));
4681
4935
  else if (close) ctx.commit(snapshot);
4682
4936
  else if (reject && !ctx.settled) {
4683
- working = clonePointSymbol(previous.graphic);
4937
+ working = clonePointSymbol(previousState.working);
4684
4938
  dirty = JSON.stringify(working) !== JSON.stringify(symbol);
4685
4939
  render();
4686
4940
  }
4687
4941
  if (errors.length === 1) throw errors[0];
4688
4942
  if (errors.length > 1) throw new AggregateError(errors, "GraphicEditSession.onChange errors");
4943
+ return !ctx.settled && !reject;
4944
+ };
4945
+ const history = createSessionHistory({
4946
+ initial: captureHistory(),
4947
+ clone: cloneHistory,
4948
+ equals: (a, b) => JSON.stringify(a.working) === JSON.stringify(b.working),
4949
+ isCurrent: (value) => JSON.stringify(working) === JSON.stringify(value.working),
4950
+ apply(from, to, source) {
4951
+ working = clonePointSymbol(to.working);
4952
+ dirty = JSON.stringify(working) !== JSON.stringify(symbol);
4953
+ render();
4954
+ return emitChange(from, source);
4955
+ }
4956
+ });
4957
+ const apply = (next, source = "programmatic", label = "Update graphic") => {
4958
+ const previousState = captureHistory();
4959
+ working = clonePointSymbol(next);
4960
+ dirty = JSON.stringify(working) !== JSON.stringify(symbol);
4961
+ render();
4962
+ if (emitChange(previousState, source)) history.record(captureHistory(), label);
4689
4963
  };
4690
4964
  const session = {
4965
+ history,
4691
4966
  graphic: clonePointSymbol(symbol),
4692
4967
  get workingGraphic() {
4693
4968
  return clonePointSymbol(working);
@@ -4711,11 +4986,10 @@ function runPointSymbolEdit(opts) {
4711
4986
  },
4712
4987
  updateGraphic(updater) {
4713
4988
  if (ctx.settled) return;
4714
- const previous = snapshot();
4715
4989
  const next = updater(clonePointSymbol(working));
4716
4990
  if (next.kind !== "point-symbol" || next.id !== symbol.id) throw new TypeError("updateGraphic must preserve point-symbol kind and id");
4717
4991
  capability.render(next);
4718
- apply(next, previous);
4992
+ apply(next, "programmatic", "Update graphic");
4719
4993
  },
4720
4994
  onChange(listener) {
4721
4995
  if (ctx.settled) return () => {};
@@ -4747,27 +5021,29 @@ function runPointSymbolEdit(opts) {
4747
5021
  };
4748
5022
  driver.setDragPanEnabled(false);
4749
5023
  };
4750
- const transformed = (pixel) => {
5024
+ const transformed = (pixel, constrainAngle = false) => {
4751
5025
  if (!drag) return null;
4752
5026
  const next = clonePointSymbol(drag.start);
4753
5027
  if (drag.kind === "body") next.position = driver.fromPixel([drag.anchor[0] + pixel[0] - drag.down[0], drag.anchor[1] + pixel[1] - drag.down[1]]);
4754
- else if (drag.kind === "rotate") next.rotation = normalizeRotation(drag.start.rotation + pointerAngle(pixel, drag.anchor) - drag.startPointerAngle);
4755
- else {
5028
+ else if (drag.kind === "rotate") {
5029
+ const rotation = drag.start.rotation + pointerAngle(pixel, drag.anchor) - drag.startPointerAngle;
5030
+ next.rotation = normalizeRotation(constrainAngle ? constrainRotation(rotation) : rotation);
5031
+ } else {
4756
5032
  const distance = Math.hypot(pixel[0] - drag.anchor[0], pixel[1] - drag.anchor[1]);
4757
5033
  next.size.value = Math.max(.1, drag.start.size.value * (distance / drag.startDistance));
4758
5034
  }
4759
5035
  return next;
4760
5036
  };
4761
- const onMove = (pixel) => {
4762
- const next = transformed(pixel);
5037
+ const onMove = (pixel, info) => {
5038
+ const next = transformed(pixel, info?.shiftKey);
4763
5039
  if (!next) return;
4764
5040
  working = next;
4765
5041
  render();
4766
5042
  };
4767
- const onUp = (pixel) => {
5043
+ const onUp = (pixel, info) => {
4768
5044
  if (!drag) return;
4769
5045
  const currentDrag = drag;
4770
- const next = transformed(pixel);
5046
+ const next = transformed(pixel, info?.shiftKey);
4771
5047
  drag = null;
4772
5048
  suppressClickAway = true;
4773
5049
  driver.setDragPanEnabled(true);
@@ -4777,7 +5053,7 @@ function runPointSymbolEdit(opts) {
4777
5053
  render();
4778
5054
  return;
4779
5055
  }
4780
- apply(next);
5056
+ apply(next, "gesture", `${currentDrag.kind[0].toUpperCase()}${currentDrag.kind.slice(1)}`);
4781
5057
  };
4782
5058
  let previewPick;
4783
5059
  let handlesPick;
@@ -4803,8 +5079,12 @@ function runPointSymbolEdit(opts) {
4803
5079
  layers: [previewLayer, handlesLayer],
4804
5080
  onViewChange: render,
4805
5081
  onSettled(settlement) {
4806
- onSettled?.();
4807
- settleFromInteraction(settlement, commitEmitter, settleEmitter);
5082
+ try {
5083
+ history.settle();
5084
+ } finally {
5085
+ onSettled?.();
5086
+ settleFromInteraction(settlement, commitEmitter, settleEmitter);
5087
+ }
4808
5088
  },
4809
5089
  attach(interaction) {
4810
5090
  ctx = interaction;
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@orbat-mapper/tactical-draw",
3
- "version": "0.2.0-alpha.21",
3
+ "version": "0.2.0-alpha.22",
4
4
  "description": "Engine-agnostic draw/edit core for tactical control measures (MIL-STD-2525 / APP-6). The MapAdapter ABI and bring-your-own-engine entry point for the tactical-draw package family.",
5
5
  "license": "MIT",
6
6
  "author": "Orbat Mapper",