@orbat-mapper/tactical-draw 0.2.0-alpha.20 → 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
@@ -2088,6 +2198,34 @@ function boxToHandles(box, fromPixel) {
2088
2198
  return handles;
2089
2199
  }
2090
2200
  /**
2201
+ * The group's outline style: the resolved `boxOutline` plus `[4, 4]` dash so
2202
+ * the group reads as dashed per the reference design — unless the host
2203
+ * explicitly set their own `strokeDash`, which wins. Shared by
2204
+ * `transform-controller.ts` and `graphic-transform-controller.ts` so their
2205
+ * group boxes can't drift apart (member outlines stay solid).
2206
+ */
2207
+ function groupOutlineStyle(base) {
2208
+ if (base?.strokeDash !== void 0) return base;
2209
+ return {
2210
+ ...base ?? {},
2211
+ strokeDash: [4, 4]
2212
+ };
2213
+ }
2214
+ /**
2215
+ * Build the per-member outline features for a group transform — one closed
2216
+ * `LineString` per `[memberId, box]` pair, id'd
2217
+ * `${handleIdPrefix}:member-outline:{memberId}` and styled with the plain
2218
+ * (solid) `boxOutline`. Shared by `transform-controller.ts` and
2219
+ * `graphic-transform-controller.ts` so member outlines can't drift between
2220
+ * the measures-only and mixed-graphic group transforms — only the group box
2221
+ * gets the dashed default (`groupOutlineStyle`).
2222
+ */
2223
+ function memberOutlineFeatures(members, fromPixel, handleIdPrefix, style) {
2224
+ const features = [];
2225
+ for (const [memberId, box] of members) features.push(boxOutlineFeature(box, fromPixel, `${handleIdPrefix}:member-outline:${memberId}`, style));
2226
+ return features;
2227
+ }
2228
+ /**
2091
2229
  * Build the closed `LineString` outline feature for `box`, styled from
2092
2230
  * `style` (delivered per-feature via `properties.style`, as the adapters
2093
2231
  * already honor for line features). `featureId` is the caller's stable id —
@@ -2358,6 +2496,28 @@ function runEdit(opts) {
2358
2496
  * persisted on the measure; a fresh edit always starts back at `0`.
2359
2497
  */
2360
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
+ }
2361
2521
  /**
2362
2522
  * The transform box fitted around the *rendered* working-copy geometry
2363
2523
  * (pixel space), or `null` when nothing projected this frame. Re-fitted
@@ -2584,12 +2744,13 @@ function runEdit(opts) {
2584
2744
  * - Reject restores the working state to the pre-gesture snapshot and
2585
2745
  * recomputes `dirty`. Close/abort settle the session as normal.
2586
2746
  */
2587
- 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) {
2588
2748
  if (ctx.settled) return;
2589
2749
  let rejectRequested = false;
2590
2750
  let closeRequested = false;
2591
2751
  let abortRequested = false;
2592
2752
  const event = {
2753
+ source,
2593
2754
  graphic: currentRendered,
2594
2755
  previous: previousRendered,
2595
2756
  session,
@@ -2618,11 +2779,13 @@ function runEdit(opts) {
2618
2779
  sizeAnchor = previousSizeAnchor;
2619
2780
  workingTextAmplifiers = previousTextAmplifiers;
2620
2781
  workingAmplifierPlacements = previousAmplifierPlacements;
2782
+ boxAngle = previousBoxAngle;
2621
2783
  nonGeometryDirty = previousNonGeometryDirty;
2622
2784
  renderPreview();
2623
2785
  renderHandlesLayer();
2624
2786
  recomputeDirty();
2625
2787
  }
2788
+ if (!ctx.settled && !rejectRequested && (source === "gesture" || source === "programmatic")) history.record(captureHistory(), historyLabel);
2626
2789
  if (errors.length === 1) throw errors[0];
2627
2790
  if (errors.length > 1) throw new AggregateError(errors, "EditSession.onChange listener errors");
2628
2791
  }
@@ -2636,7 +2799,7 @@ function runEdit(opts) {
2636
2799
  * queued during the drag are applied after the listener emission (so the
2637
2800
  * post-gesture render reflects the requested mode set).
2638
2801
  */
2639
- function commitGesture(nextPoints, preGestureOptions, nextAmplifierPlacements) {
2802
+ function commitGesture(nextPoints, preGestureOptions, nextAmplifierPlacements, preGestureBoxAngle = boxAngle) {
2640
2803
  if (ctx.settled) return;
2641
2804
  const previousPoints = workingPoints.map((p) => [...p]);
2642
2805
  const previousOptions = { ...preGestureOptions ?? workingOptions ?? {} };
@@ -2663,7 +2826,7 @@ function runEdit(opts) {
2663
2826
  recomputeDirty();
2664
2827
  dragInFlight = false;
2665
2828
  drainPendingModes();
2666
- emitChange(previousRendered, previousPoints, previousOptions, previousStyle, previousNonGeometryDirty, sizeAnchor, currentRendered, previousTextAmplifiers, previousAmplifierPlacements);
2829
+ emitChange(previousRendered, previousPoints, previousOptions, previousStyle, previousNonGeometryDirty, sizeAnchor, currentRendered, previousTextAmplifiers, previousAmplifierPlacements, "gesture", "Edit", preGestureBoxAngle);
2667
2830
  }
2668
2831
  function setOptions(partial) {
2669
2832
  if (ctx.settled) return;
@@ -2678,6 +2841,7 @@ function runEdit(opts) {
2678
2841
  ...workingOptions ?? {},
2679
2842
  ...partial ?? {}
2680
2843
  });
2844
+ if (JSON.stringify(workingOptions) === JSON.stringify(previousOptions)) return;
2681
2845
  let currentRendered;
2682
2846
  try {
2683
2847
  currentRendered = rebuildRendered();
@@ -2688,7 +2852,7 @@ function runEdit(opts) {
2688
2852
  renderPreview();
2689
2853
  nonGeometryDirty = true;
2690
2854
  dirty = true;
2691
- emitChange(previousRendered, previousPoints, previousOptions, previousStyle, previousNonGeometryDirty, sizeAnchor, currentRendered, previousTextAmplifiers, previousAmplifierPlacements);
2855
+ emitChange(previousRendered, previousPoints, previousOptions, previousStyle, previousNonGeometryDirty, sizeAnchor, currentRendered, previousTextAmplifiers, previousAmplifierPlacements, "programmatic", "Change options");
2692
2856
  }
2693
2857
  function setStyle(partial) {
2694
2858
  if (ctx.settled) return;
@@ -2703,6 +2867,7 @@ function runEdit(opts) {
2703
2867
  ...currentStyle ?? {},
2704
2868
  ...partial
2705
2869
  };
2870
+ if (JSON.stringify(currentStyle) === JSON.stringify(previousStyle)) return;
2706
2871
  let currentRendered;
2707
2872
  try {
2708
2873
  currentRendered = rebuildRendered();
@@ -2713,7 +2878,7 @@ function runEdit(opts) {
2713
2878
  renderPreview();
2714
2879
  nonGeometryDirty = true;
2715
2880
  dirty = true;
2716
- emitChange(previousRendered, previousPoints, previousOptions, previousStyle, previousNonGeometryDirty, sizeAnchor, currentRendered, previousTextAmplifiers, previousAmplifierPlacements);
2881
+ emitChange(previousRendered, previousPoints, previousOptions, previousStyle, previousNonGeometryDirty, sizeAnchor, currentRendered, previousTextAmplifiers, previousAmplifierPlacements, "programmatic", "Change style");
2717
2882
  }
2718
2883
  /**
2719
2884
  * Shallow-merge into the live working text amplifiers. Mirrors
@@ -2748,6 +2913,7 @@ function runEdit(opts) {
2748
2913
  if (value !== void 0 && value.trim() !== "") merged[canonicalKey ?? key] = value;
2749
2914
  }
2750
2915
  workingTextAmplifiers = Object.keys(merged).length > 0 ? merged : void 0;
2916
+ if (JSON.stringify(workingTextAmplifiers) === JSON.stringify(previousTextAmplifiers)) return;
2751
2917
  let currentRendered;
2752
2918
  try {
2753
2919
  currentRendered = rebuildRendered();
@@ -2758,9 +2924,9 @@ function runEdit(opts) {
2758
2924
  renderPreview();
2759
2925
  nonGeometryDirty = true;
2760
2926
  dirty = true;
2761
- emitChange(previousRendered, previousPoints, previousOptions, previousStyle, previousNonGeometryDirty, sizeAnchor, currentRendered, previousTextAmplifiers, previousAmplifierPlacements);
2927
+ emitChange(previousRendered, previousPoints, previousOptions, previousStyle, previousNonGeometryDirty, sizeAnchor, currentRendered, previousTextAmplifiers, previousAmplifierPlacements, "programmatic", "Change text");
2762
2928
  }
2763
- function setAmplifierPlacements(partial) {
2929
+ function setAmplifierPlacements(partial, source = "programmatic", historyLabel = "Move label") {
2764
2930
  if (ctx.settled) return;
2765
2931
  const previousPoints = workingPoints.map((p) => [...p]);
2766
2932
  const previousOptions = { ...workingOptions ?? {} };
@@ -2780,11 +2946,12 @@ function runEdit(opts) {
2780
2946
  else next[field] = merged;
2781
2947
  }
2782
2948
  workingAmplifierPlacements = Object.keys(next).length > 0 ? next : void 0;
2949
+ if (JSON.stringify(workingAmplifierPlacements) === JSON.stringify(previousAmplifierPlacements)) return;
2783
2950
  const currentRendered = rebuildRendered();
2784
2951
  renderPreview();
2785
2952
  nonGeometryDirty = true;
2786
2953
  dirty = true;
2787
- emitChange(previousRendered, previousPoints, previousOptions, previousStyle, previousNonGeometryDirty, sizeAnchor, currentRendered, previousTextAmplifiers, previousAmplifierPlacements);
2954
+ emitChange(previousRendered, previousPoints, previousOptions, previousStyle, previousNonGeometryDirty, sizeAnchor, currentRendered, previousTextAmplifiers, previousAmplifierPlacements, source, historyLabel);
2788
2955
  }
2789
2956
  /**
2790
2957
  * Flip the size anchor for the measure under edit (ADR-0020). Re-renders the
@@ -2807,7 +2974,7 @@ function runEdit(opts) {
2807
2974
  const currentRendered = rebuildRendered();
2808
2975
  nonGeometryDirty = true;
2809
2976
  dirty = true;
2810
- 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");
2811
2978
  }
2812
2979
  /** Apply a translate gesture by shifting every working point by `delta`. */
2813
2980
  function commitTranslate(delta) {
@@ -2836,7 +3003,7 @@ function runEdit(opts) {
2836
3003
  const pivot = box ? driver.fromPixel(box.center) : computeBoundingBoxCenter(workingPoints);
2837
3004
  if (!pivot) return;
2838
3005
  boxAngle += angle;
2839
- commitGesture(rotatePointsAround(workingPoints, pivot, pivotPixel, angle, dragProjection), preFreezeOptions);
3006
+ commitGesture(rotatePointsAround(workingPoints, pivot, pivotPixel, angle, dragProjection), preFreezeOptions, void 0, boxAngle - angle);
2840
3007
  }
2841
3008
  function modesEqual(a, b) {
2842
3009
  if (a.length !== b.length) return false;
@@ -2855,7 +3022,32 @@ function runEdit(opts) {
2855
3022
  pendingModes = null;
2856
3023
  applyModes(next);
2857
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
+ });
2858
3049
  const session = {
3050
+ history,
2859
3051
  graphic: measure,
2860
3052
  get workingGraphic() {
2861
3053
  return cloneControlMeasure({
@@ -2915,6 +3107,7 @@ function runEdit(opts) {
2915
3107
  const current = session.workingGraphic;
2916
3108
  const next = updater(current);
2917
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;
2918
3111
  renderMeasureForAdapter(adapter, next, renderOpts);
2919
3112
  workingPoints.splice(0, workingPoints.length, ...next.controlPoints.map((point) => [...point]));
2920
3113
  workingOptions = { ...next.options ?? {} };
@@ -2925,7 +3118,7 @@ function runEdit(opts) {
2925
3118
  renderPreview();
2926
3119
  renderHandlesLayer();
2927
3120
  recomputeDirty();
2928
- 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");
2929
3122
  },
2930
3123
  setOptions(partial) {
2931
3124
  setOptions(partial);
@@ -3230,9 +3423,9 @@ function runEdit(opts) {
3230
3423
  renderHandlesLayer();
3231
3424
  recomputeDirty();
3232
3425
  }
3233
- function paintBoxDragSnapshot(pixel) {
3426
+ function paintBoxDragSnapshot(pixel, constrainRotation = false) {
3234
3427
  if (!activeBoxDrag) return;
3235
- const snapshot = activeBoxDrag.fsm.move(pixel);
3428
+ const snapshot = activeBoxDrag.fsm.move(pixel, { constrainRotation });
3236
3429
  activeBoxDrag.lastSnapshot = snapshot;
3237
3430
  workingPoints.splice(0, workingPoints.length, ...snapshot.points);
3238
3431
  workingAmplifierPlacements = transformAmplifierPlacements(activeBoxDrag.preDragAmplifierPlacements, activeBoxDrag.preDragBox, snapshot.box, driver);
@@ -3240,7 +3433,7 @@ function runEdit(opts) {
3240
3433
  renderHandlesLayer();
3241
3434
  recomputeDirty();
3242
3435
  }
3243
- const onDriverMove = (pixel) => {
3436
+ const onDriverMove = (pixel, info) => {
3244
3437
  if (ctx.settled) return;
3245
3438
  if (activeLabelRotateDrag) {
3246
3439
  const drag = activeLabelRotateDrag;
@@ -3276,7 +3469,7 @@ function runEdit(opts) {
3276
3469
  renderPreview();
3277
3470
  renderHandlesLayer();
3278
3471
  } else if (activeDrag) paintDragSnapshot(pixel);
3279
- else if (activeBoxDrag) paintBoxDragSnapshot(pixel);
3472
+ else if (activeBoxDrag) paintBoxDragSnapshot(pixel, info?.shiftKey);
3280
3473
  };
3281
3474
  /** Reset drag-in-flight bookkeeping shared by every "gesture ended" path. */
3282
3475
  function endDragBookkeeping() {
@@ -3284,7 +3477,7 @@ function runEdit(opts) {
3284
3477
  dragInFlight = false;
3285
3478
  drainPendingModes();
3286
3479
  }
3287
- const onDriverUp = (pixel) => {
3480
+ const onDriverUp = (pixel, info) => {
3288
3481
  if (activeLabelRotateDrag) {
3289
3482
  const drag = activeLabelRotateDrag;
3290
3483
  activeLabelRotateDrag = null;
@@ -3293,7 +3486,7 @@ function runEdit(opts) {
3293
3486
  if (Math.hypot(pixel[0] - drag.downPixel[0], pixel[1] - drag.downPixel[1]) > CLICK_SLOP_PX$3) {
3294
3487
  const delta = pointerAngleAround(pixel, drag.anchorPixel) - drag.startPointerAngle;
3295
3488
  const rotation = drag.startRotation - delta;
3296
- setAmplifierPlacements({ [drag.key]: { rotation } });
3489
+ setAmplifierPlacements({ [drag.key]: { rotation } }, "gesture", "Rotate label");
3297
3490
  } else {
3298
3491
  renderPreview();
3299
3492
  renderHandlesLayer();
@@ -3307,7 +3500,7 @@ function runEdit(opts) {
3307
3500
  const position = labelAnchorFromPointer(pixel, grabOffset);
3308
3501
  workingAmplifierPlacements = previous;
3309
3502
  endDragBookkeeping();
3310
- 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");
3311
3504
  else {
3312
3505
  selectedLabelKey = selectedLabelKey === field ? null : field;
3313
3506
  renderPreview();
@@ -3352,11 +3545,11 @@ function runEdit(opts) {
3352
3545
  bodyClickPending = true;
3353
3546
  return;
3354
3547
  }
3355
- const finalSnapshot = fsm.move(pixel);
3548
+ const finalSnapshot = fsm.move(pixel, { constrainRotation: info?.shiftKey });
3356
3549
  replaceWorkingPoints(preDragPoints);
3357
3550
  workingAmplifierPlacements = preDragAmplifierPlacements;
3358
3551
  if (fsm.kind === "rotate") boxAngle += finalSnapshot.box.angle - preDragBox.angle;
3359
- commitGesture(finalSnapshot.points, preDragOptions, transformAmplifierPlacements(preDragAmplifierPlacements, preDragBox, finalSnapshot.box, driver));
3552
+ commitGesture(finalSnapshot.points, preDragOptions, transformAmplifierPlacements(preDragAmplifierPlacements, preDragBox, finalSnapshot.box, driver), preDragBox.angle);
3360
3553
  return;
3361
3554
  }
3362
3555
  };
@@ -3407,8 +3600,12 @@ function runEdit(opts) {
3407
3600
  ],
3408
3601
  onViewChange: rescaleOnViewChange,
3409
3602
  onSettled: (settlement) => {
3410
- if (onSettled) onSettled();
3411
- settleFromInteraction(settlement, commitEmitter, settleEmitter);
3603
+ try {
3604
+ history.settle();
3605
+ } finally {
3606
+ if (onSettled) onSettled();
3607
+ settleFromInteraction(settlement, commitEmitter, settleEmitter);
3608
+ }
3412
3609
  },
3413
3610
  attach(c) {
3414
3611
  ctx = c;
@@ -3613,6 +3810,25 @@ function runGroupTransform(opts) {
3613
3810
  const settleEmitter = createSettleEmitter("TransformSession.onSettled listener errors");
3614
3811
  /** Session-scoped box rotation (ADR-0017 decision #2), shared by the whole group. */
3615
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
+ }
3616
3832
  /** The group box fitted over every member's rendered geometry, or `null` when nothing projects this frame. */
3617
3833
  let groupBox = null;
3618
3834
  let ctx;
@@ -3692,32 +3908,15 @@ function runGroupTransform(opts) {
3692
3908
  else refitBoxes();
3693
3909
  renderHandlesLayer();
3694
3910
  }
3695
- /**
3696
- * The group's outline style: the resolved `boxOutline` plus `[4, 4]` dash
3697
- * so the group reads as dashed per the reference design — unless the host
3698
- * explicitly set their own `strokeDash`, which wins.
3699
- */
3700
- function groupOutlineStyle() {
3701
- const base = interactionStyle?.boxOutline;
3702
- if (base?.strokeDash !== void 0) return base;
3703
- return {
3704
- ...base ?? {},
3705
- strokeDash: [4, 4]
3706
- };
3707
- }
3708
3911
  function renderHandlesLayer() {
3709
3912
  if (ctx.settled) return;
3710
3913
  const features = [];
3711
3914
  const liveGroupBox = activeBoxDrag ? activeBoxDrag.lastSnapshot.box : groupBox;
3712
3915
  if (liveGroupBox) {
3713
- features.push(boxOutlineFeature(liveGroupBox, dragProjection.fromPixel, `${handleIdPrefix}:box-outline`, groupOutlineStyle()));
3916
+ features.push(boxOutlineFeature(liveGroupBox, dragProjection.fromPixel, `${handleIdPrefix}:box-outline`, groupOutlineStyle(interactionStyle?.boxOutline)));
3714
3917
  features.push(...toFeatures(boxToHandles(liveGroupBox, dragProjection.fromPixel), handleIdPrefix, interactionStyle));
3715
3918
  }
3716
- for (const member of members) {
3717
- const memberBox = member.box;
3718
- if (!memberBox) continue;
3719
- features.push(boxOutlineFeature(memberBox, dragProjection.fromPixel, `${handleIdPrefix}:member-outline:${member.measure.id}`, interactionStyle?.boxOutline));
3720
- }
3919
+ features.push(...memberOutlineFeatures(members.flatMap((member) => member.box ? [[member.measure.id, member.box]] : []), dragProjection.fromPixel, handleIdPrefix, interactionStyle?.boxOutline));
3721
3920
  ctx.writeLayer(handlesLayer, features);
3722
3921
  }
3723
3922
  /**
@@ -3806,12 +4005,13 @@ function runGroupTransform(opts) {
3806
4005
  * instance to all listeners in order, deferred `reject`/`close`/`abort`
3807
4006
  * (close/abort override reject), exception collection with `AggregateError`.
3808
4007
  */
3809
- 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) {
3810
4009
  if (ctx.settled) return;
3811
4010
  let rejectRequested = false;
3812
4011
  let closeRequested = false;
3813
4012
  let abortRequested = false;
3814
4013
  const event = {
4014
+ source,
3815
4015
  graphics: currentRendered,
3816
4016
  previous: previousRendered,
3817
4017
  session,
@@ -3840,10 +4040,12 @@ function runGroupTransform(opts) {
3840
4040
  sizeAnchor = previousSizeAnchor;
3841
4041
  nonGeometryDirty = previousNonGeometryDirty;
3842
4042
  applyOptionsOverrides(previousOptionsOverrideByMember);
4043
+ boxAngle = previousBoxAngle;
3843
4044
  renderAllPreviews();
3844
4045
  renderHandlesLayer();
3845
4046
  recomputeDirty();
3846
4047
  }
4048
+ if (!ctx.settled && !rejectRequested && members === membersAtEmission && (source === "gesture" || source === "programmatic")) history.record(captureHistory(), historyLabel);
3847
4049
  if (errors.length === 1) throw errors[0];
3848
4050
  if (errors.length > 1) throw new AggregateError(errors, "TransformSession.onChange listener errors");
3849
4051
  }
@@ -3869,7 +4071,7 @@ function runGroupTransform(opts) {
3869
4071
  }
3870
4072
  return snapshots;
3871
4073
  }
3872
- function commitGesture(nextConcatPoints, preGestureOptionsOverrideByMember, nextAmplifierPlacementsByMember) {
4074
+ function commitGesture(nextConcatPoints, preGestureOptionsOverrideByMember, nextAmplifierPlacementsByMember, preGestureBoxAngle = boxAngle) {
3873
4075
  if (ctx.settled) return;
3874
4076
  const previousPointsByMember = clonePointsByMember();
3875
4077
  const previousAmplifierPlacementsByMember = cloneAmplifierPlacementsByMember();
@@ -3890,7 +4092,7 @@ function runGroupTransform(opts) {
3890
4092
  renderHandlesLayer();
3891
4093
  recomputeDirty();
3892
4094
  dragInFlight = false;
3893
- emitChange(previousRendered, previousPointsByMember, previousAmplifierPlacementsByMember, sizeAnchor, nonGeometryDirty, previousOptionsOverrideByMember, currentRendered);
4095
+ emitChange(previousRendered, previousPointsByMember, previousAmplifierPlacementsByMember, sizeAnchor, nonGeometryDirty, previousOptionsOverrideByMember, currentRendered, "gesture", "Transform", preGestureBoxAngle);
3894
4096
  }
3895
4097
  function setSizeAnchor(next) {
3896
4098
  if (ctx.settled || sizeAnchor === next) return;
@@ -3904,7 +4106,7 @@ function runGroupTransform(opts) {
3904
4106
  const currentRendered = rebuildRenderedAll();
3905
4107
  nonGeometryDirty = true;
3906
4108
  dirty = true;
3907
- emitChange(previousRendered, previousPointsByMember, previousAmplifierPlacementsByMember, previousSizeAnchor, previousNonGeometryDirty, previousOptionsOverrideByMember, currentRendered);
4109
+ emitChange(previousRendered, previousPointsByMember, previousAmplifierPlacementsByMember, previousSizeAnchor, previousNonGeometryDirty, previousOptionsOverrideByMember, currentRendered, "programmatic", "Change size anchor");
3908
4110
  }
3909
4111
  /**
3910
4112
  * Cancel an in-flight box drag without committing it: restore the pre-drag
@@ -3951,13 +4153,42 @@ function runGroupTransform(opts) {
3951
4153
  renderAllPreviews();
3952
4154
  renderHandlesLayer();
3953
4155
  recomputeDirty();
3954
- 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");
3955
4169
  return removedSnapshots;
3956
4170
  }
3957
4171
  function close() {
3958
4172
  return commitClose();
3959
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
+ });
3960
4190
  const session = {
4191
+ history,
3961
4192
  get graphics() {
3962
4193
  return members.map((m) => m.measure);
3963
4194
  },
@@ -4033,9 +4264,9 @@ function runGroupTransform(opts) {
4033
4264
  dragInFlight = true;
4034
4265
  driver.setDragPanEnabled(false);
4035
4266
  };
4036
- function paintBoxDragSnapshot(pixel) {
4267
+ function paintBoxDragSnapshot(pixel, constrainRotation = false) {
4037
4268
  if (!activeBoxDrag) return;
4038
- const snapshot = activeBoxDrag.fsm.move(pixel);
4269
+ const snapshot = activeBoxDrag.fsm.move(pixel, { constrainRotation });
4039
4270
  activeBoxDrag.lastSnapshot = snapshot;
4040
4271
  applyConcatenatedPoints(snapshot.points);
4041
4272
  applyAmplifierPlacementsByMember(activeBoxDrag.preDragAmplifierPlacementsByMember.map((placements) => transformAmplifierPlacements(placements, activeBoxDrag.preDragBox, snapshot.box, driver)));
@@ -4043,11 +4274,11 @@ function runGroupTransform(opts) {
4043
4274
  renderHandlesLayer();
4044
4275
  recomputeDirty();
4045
4276
  }
4046
- const onDriverMove = (pixel) => {
4277
+ const onDriverMove = (pixel, info) => {
4047
4278
  if (ctx.settled) return;
4048
- if (activeBoxDrag) paintBoxDragSnapshot(pixel);
4279
+ if (activeBoxDrag) paintBoxDragSnapshot(pixel, info?.shiftKey);
4049
4280
  };
4050
- const onDriverUp = (pixel) => {
4281
+ const onDriverUp = (pixel, info) => {
4051
4282
  if (!activeBoxDrag) return;
4052
4283
  if (ctx.settled) {
4053
4284
  activeBoxDrag = null;
@@ -4069,11 +4300,11 @@ function runGroupTransform(opts) {
4069
4300
  bodyClickPending = true;
4070
4301
  return;
4071
4302
  }
4072
- const finalSnapshot = fsm.move(pixel);
4303
+ const finalSnapshot = fsm.move(pixel, { constrainRotation: info?.shiftKey });
4073
4304
  applyConcatenatedPoints(preDragPoints);
4074
4305
  applyAmplifierPlacementsByMember(preDragAmplifierPlacementsByMember);
4075
4306
  if (fsm.kind === "rotate") boxAngle += finalSnapshot.box.angle - preDragBox.angle;
4076
- 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);
4077
4308
  };
4078
4309
  let pickPreview;
4079
4310
  let pickHandles;
@@ -4119,8 +4350,12 @@ function runGroupTransform(opts) {
4119
4350
  layers: [previewLayer, handlesLayer],
4120
4351
  onViewChange: rescaleOnViewChange,
4121
4352
  onSettled: (settlement) => {
4122
- if (onSettled) onSettled();
4123
- 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
+ }
4124
4359
  },
4125
4360
  attach(c) {
4126
4361
  ctx = c;
@@ -4234,13 +4469,6 @@ function pointSymbolFeature(symbol, render, resolution) {
4234
4469
  };
4235
4470
  }
4236
4471
  //#endregion
4237
- //#region src/tactical-draw/rotation.ts
4238
- /** Normalize radians to the canonical clockwise [0, 2π) range. */
4239
- function normalizeRotation(angle) {
4240
- const turn = Math.PI * 2;
4241
- return (angle % turn + turn) % turn;
4242
- }
4243
- //#endregion
4244
4472
  //#region src/tactical-draw/graphic-transform-controller.ts
4245
4473
  const HIT_TOLERANCE_PX$1 = 12;
4246
4474
  const CLICK_SLOP_PX$1 = 3;
@@ -4261,35 +4489,52 @@ function runGraphicTransform(opts) {
4261
4489
  const commitEmitter = createCommitEmitter("GraphicTransformSession.onCommit listener errors");
4262
4490
  const settleEmitter = createSettleEmitter("GraphicTransformSession.onSettled listener errors");
4263
4491
  const renderOpts = graphicsStyle ? { graphicsStyle } : void 0;
4264
- const snapshots = () => working.map((graphic) => isPointSymbol(graphic) ? {
4492
+ const snapshotsFor = (source) => source.map((graphic) => isPointSymbol(graphic) ? {
4265
4493
  graphic: clonePointSymbol(graphic),
4266
4494
  render: capability.render(graphic)
4267
4495
  } : {
4268
4496
  graphic: cloneControlMeasure(graphic),
4269
4497
  render: renderMeasureForAdapter(adapter, graphic, renderOpts)
4270
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
+ });
4271
4508
  function renderAll() {
4272
4509
  if (ctx.settled) return;
4273
4510
  const features = [];
4274
4511
  const resources = /* @__PURE__ */ new Map();
4275
4512
  const pixels = [];
4276
- for (const graphic of working) if (isPointSymbol(graphic)) {
4277
- const plan = renderPointSymbolForAdapter(adapter, capability, graphic);
4278
- resources.set(plan.resource.id, plan.resource);
4279
- features.push(plan.feature);
4280
- const footprint = pointSymbolFootprint(graphic, plan.render, adapter);
4281
- if (footprint) pixels.push(...footprint.corners);
4282
- } else {
4283
- const rendered = renderMeasureForAdapter(adapter, graphic, renderOpts);
4284
- features.push(...rendered.features);
4285
- for (const feature of rendered.features) {
4286
- const positions = [];
4287
- if (feature.geometry) collectFeaturePositions(feature.geometry, positions);
4288
- for (const position of positions) {
4289
- const pixel = driver.toPixel(position);
4290
- if (pixel) pixels.push(pixel);
4513
+ const memberBoxes = [];
4514
+ const angle = drag ? drag.liveBox.angle : boxAngle;
4515
+ for (const graphic of working) {
4516
+ const graphicPixels = [];
4517
+ if (isPointSymbol(graphic)) {
4518
+ const plan = renderPointSymbolForAdapter(adapter, capability, graphic);
4519
+ resources.set(plan.resource.id, plan.resource);
4520
+ features.push(plan.feature);
4521
+ const footprint = pointSymbolFootprint(graphic, plan.render, adapter);
4522
+ if (footprint) graphicPixels.push(...footprint.corners);
4523
+ } else {
4524
+ const rendered = renderMeasureForAdapter(adapter, graphic, renderOpts);
4525
+ features.push(...rendered.features);
4526
+ for (const feature of rendered.features) {
4527
+ const positions = [];
4528
+ if (feature.geometry) collectFeaturePositions(feature.geometry, positions);
4529
+ for (const position of positions) {
4530
+ const pixel = driver.toPixel(position);
4531
+ if (pixel) graphicPixels.push(pixel);
4532
+ }
4291
4533
  }
4292
4534
  }
4535
+ pixels.push(...graphicPixels);
4536
+ const memberBox = fitTransformBox(graphicPixels, angle, interactionStyle?.boxPadding);
4537
+ if (memberBox) memberBoxes.push([graphic.id, memberBox]);
4293
4538
  }
4294
4539
  adapter.reconcileLayerIconResources(previewLayer, [...resources.values()]);
4295
4540
  ctx.writeLayer(previewLayer, features);
@@ -4297,9 +4542,10 @@ function runGraphicTransform(opts) {
4297
4542
  const liveBox = drag?.liveBox ?? box;
4298
4543
  const handles = [];
4299
4544
  if (liveBox) {
4300
- handles.push(boxOutlineFeature(liveBox, (pixel) => driver.fromPixel(pixel), `${handleIdPrefix}:box-outline`, interactionStyle?.boxOutline));
4545
+ handles.push(boxOutlineFeature(liveBox, (pixel) => driver.fromPixel(pixel), `${handleIdPrefix}:box-outline`, groupOutlineStyle(interactionStyle?.boxOutline)));
4301
4546
  handles.push(...toFeatures(boxToHandles(liveBox, (pixel) => driver.fromPixel(pixel)), handleIdPrefix, interactionStyle));
4302
4547
  }
4548
+ handles.push(...memberOutlineFeatures(memberBoxes, (pixel) => driver.fromPixel(pixel), handleIdPrefix, interactionStyle?.boxOutline));
4303
4549
  ctx.writeLayer(handlesLayer, handles);
4304
4550
  }
4305
4551
  function anchorPoints(source) {
@@ -4325,12 +4571,14 @@ function runGraphicTransform(opts) {
4325
4571
  return next;
4326
4572
  });
4327
4573
  }
4328
- function emit(previous, previousGraphics) {
4574
+ function emit(previousState, source) {
4575
+ const previous = snapshotsFor(previousState.working);
4329
4576
  const current = snapshots();
4330
4577
  let reject = false;
4331
4578
  let close = false;
4332
4579
  let abort = false;
4333
4580
  const event = {
4581
+ source,
4334
4582
  graphics: current,
4335
4583
  previous,
4336
4584
  session,
@@ -4347,14 +4595,30 @@ function runGraphicTransform(opts) {
4347
4595
  if (abort) ctx.fail(new TacticalDrawAbortError("session"));
4348
4596
  else if (close) ctx.commit(snapshots);
4349
4597
  else if (reject && !ctx.settled) {
4350
- working = previousGraphics.map(cloneGraphic);
4598
+ working = previousState.working.map(cloneGraphic);
4599
+ boxAngle = previousState.boxAngle;
4351
4600
  dirty = JSON.stringify(working) !== JSON.stringify(original);
4352
4601
  renderAll();
4353
4602
  }
4354
4603
  if (errors.length === 1) throw errors[0];
4355
4604
  if (errors.length > 1) throw new AggregateError(errors, "GraphicTransformSession.onChange errors");
4356
- }
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
+ });
4357
4620
  const session = {
4621
+ history,
4358
4622
  get graphics() {
4359
4623
  return original.map(cloneGraphic);
4360
4624
  },
@@ -4401,9 +4665,9 @@ function runGraphicTransform(opts) {
4401
4665
  };
4402
4666
  driver.setDragPanEnabled(false);
4403
4667
  };
4404
- const dragResult = (pixel) => {
4668
+ const dragResult = (pixel, constrainRotation = false) => {
4405
4669
  if (!drag) return null;
4406
- const result = drag.fsm.move(pixel);
4670
+ const result = drag.fsm.move(pixel, { constrainRotation });
4407
4671
  const scale = drag.fsm.kind === "scale" ? Math.max(result.box.halfWidth / drag.startBox.halfWidth, result.box.halfHeight / drag.startBox.halfHeight) : 1;
4408
4672
  const rotation = drag.fsm.kind === "rotate" ? result.box.angle - drag.startBox.angle : 0;
4409
4673
  return {
@@ -4411,18 +4675,18 @@ function runGraphicTransform(opts) {
4411
4675
  graphics: applyPoints(drag.start, result.points, scale, rotation)
4412
4676
  };
4413
4677
  };
4414
- const onMove = (pixel) => {
4678
+ const onMove = (pixel, info) => {
4415
4679
  const active = drag;
4416
- const next = dragResult(pixel);
4680
+ const next = dragResult(pixel, info?.shiftKey);
4417
4681
  if (!active || !next) return;
4418
4682
  working = next.graphics;
4419
4683
  active.liveBox = next.result.box;
4420
4684
  renderAll();
4421
4685
  };
4422
- const onUp = (pixel) => {
4686
+ const onUp = (pixel, info) => {
4423
4687
  if (!drag) return;
4424
4688
  const active = drag;
4425
- const next = dragResult(pixel);
4689
+ const next = dragResult(pixel, info?.shiftKey);
4426
4690
  drag = null;
4427
4691
  suppressClickAway = true;
4428
4692
  driver.setDragPanEnabled(true);
@@ -4432,13 +4696,15 @@ function runGraphicTransform(opts) {
4432
4696
  renderAll();
4433
4697
  return;
4434
4698
  }
4435
- const previous = snapshots();
4436
- const previousGraphics = working.map(cloneGraphic);
4699
+ const previousState = captureHistory();
4437
4700
  working = next.graphics;
4438
4701
  if (active.fsm.kind === "rotate") boxAngle += next.result.box.angle - active.startBox.angle;
4439
4702
  dirty = JSON.stringify(working) !== JSON.stringify(original);
4440
4703
  renderAll();
4441
- 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
+ }
4442
4708
  };
4443
4709
  let previewPick;
4444
4710
  let handlesPick;
@@ -4461,8 +4727,12 @@ function runGraphicTransform(opts) {
4461
4727
  layers: [previewLayer, handlesLayer],
4462
4728
  onViewChange: renderAll,
4463
4729
  onSettled(settlement) {
4464
- onSettled?.();
4465
- settleFromInteraction(settlement, commitEmitter, settleEmitter);
4730
+ try {
4731
+ history.settle();
4732
+ } finally {
4733
+ onSettled?.();
4734
+ settleFromInteraction(settlement, commitEmitter, settleEmitter);
4735
+ }
4466
4736
  },
4467
4737
  attach(interaction) {
4468
4738
  ctx = interaction;
@@ -4612,6 +4882,8 @@ function runPointSymbolEdit(opts) {
4612
4882
  const listeners = /* @__PURE__ */ new Set();
4613
4883
  const commitEmitter = createCommitEmitter("GraphicEditSession.onCommit listener errors");
4614
4884
  const settleEmitter = createSettleEmitter("GraphicEditSession.onSettled listener errors");
4885
+ const captureHistory = () => ({ working: clonePointSymbol(working) });
4886
+ const cloneHistory = (value) => ({ working: clonePointSymbol(value.working) });
4615
4887
  const snapshot = () => ({
4616
4888
  graphic: clonePointSymbol(working),
4617
4889
  render: capability.render(working)
@@ -4629,15 +4901,17 @@ function runPointSymbolEdit(opts) {
4629
4901
  }
4630
4902
  ctx.writeLayer(handlesLayer, handles);
4631
4903
  };
4632
- const apply = (next, previous = snapshot()) => {
4633
- working = clonePointSymbol(next);
4634
- dirty = JSON.stringify(working) !== JSON.stringify(symbol);
4635
- render();
4904
+ const emitChange = (previousState, source) => {
4905
+ const previous = {
4906
+ graphic: clonePointSymbol(previousState.working),
4907
+ render: capability.render(previousState.working)
4908
+ };
4636
4909
  const current = snapshot();
4637
4910
  let reject = false;
4638
4911
  let close = false;
4639
4912
  let abort = false;
4640
4913
  const event = {
4914
+ source,
4641
4915
  graphic: current,
4642
4916
  previous,
4643
4917
  session,
@@ -4660,14 +4934,35 @@ function runPointSymbolEdit(opts) {
4660
4934
  if (abort) ctx.fail(new TacticalDrawAbortError("session"));
4661
4935
  else if (close) ctx.commit(snapshot);
4662
4936
  else if (reject && !ctx.settled) {
4663
- working = clonePointSymbol(previous.graphic);
4937
+ working = clonePointSymbol(previousState.working);
4664
4938
  dirty = JSON.stringify(working) !== JSON.stringify(symbol);
4665
4939
  render();
4666
4940
  }
4667
4941
  if (errors.length === 1) throw errors[0];
4668
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);
4669
4963
  };
4670
4964
  const session = {
4965
+ history,
4671
4966
  graphic: clonePointSymbol(symbol),
4672
4967
  get workingGraphic() {
4673
4968
  return clonePointSymbol(working);
@@ -4691,11 +4986,10 @@ function runPointSymbolEdit(opts) {
4691
4986
  },
4692
4987
  updateGraphic(updater) {
4693
4988
  if (ctx.settled) return;
4694
- const previous = snapshot();
4695
4989
  const next = updater(clonePointSymbol(working));
4696
4990
  if (next.kind !== "point-symbol" || next.id !== symbol.id) throw new TypeError("updateGraphic must preserve point-symbol kind and id");
4697
4991
  capability.render(next);
4698
- apply(next, previous);
4992
+ apply(next, "programmatic", "Update graphic");
4699
4993
  },
4700
4994
  onChange(listener) {
4701
4995
  if (ctx.settled) return () => {};
@@ -4727,27 +5021,29 @@ function runPointSymbolEdit(opts) {
4727
5021
  };
4728
5022
  driver.setDragPanEnabled(false);
4729
5023
  };
4730
- const transformed = (pixel) => {
5024
+ const transformed = (pixel, constrainAngle = false) => {
4731
5025
  if (!drag) return null;
4732
5026
  const next = clonePointSymbol(drag.start);
4733
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]]);
4734
- else if (drag.kind === "rotate") next.rotation = normalizeRotation(drag.start.rotation + pointerAngle(pixel, drag.anchor) - drag.startPointerAngle);
4735
- 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 {
4736
5032
  const distance = Math.hypot(pixel[0] - drag.anchor[0], pixel[1] - drag.anchor[1]);
4737
5033
  next.size.value = Math.max(.1, drag.start.size.value * (distance / drag.startDistance));
4738
5034
  }
4739
5035
  return next;
4740
5036
  };
4741
- const onMove = (pixel) => {
4742
- const next = transformed(pixel);
5037
+ const onMove = (pixel, info) => {
5038
+ const next = transformed(pixel, info?.shiftKey);
4743
5039
  if (!next) return;
4744
5040
  working = next;
4745
5041
  render();
4746
5042
  };
4747
- const onUp = (pixel) => {
5043
+ const onUp = (pixel, info) => {
4748
5044
  if (!drag) return;
4749
5045
  const currentDrag = drag;
4750
- const next = transformed(pixel);
5046
+ const next = transformed(pixel, info?.shiftKey);
4751
5047
  drag = null;
4752
5048
  suppressClickAway = true;
4753
5049
  driver.setDragPanEnabled(true);
@@ -4757,7 +5053,7 @@ function runPointSymbolEdit(opts) {
4757
5053
  render();
4758
5054
  return;
4759
5055
  }
4760
- apply(next);
5056
+ apply(next, "gesture", `${currentDrag.kind[0].toUpperCase()}${currentDrag.kind.slice(1)}`);
4761
5057
  };
4762
5058
  let previewPick;
4763
5059
  let handlesPick;
@@ -4783,8 +5079,12 @@ function runPointSymbolEdit(opts) {
4783
5079
  layers: [previewLayer, handlesLayer],
4784
5080
  onViewChange: render,
4785
5081
  onSettled(settlement) {
4786
- onSettled?.();
4787
- settleFromInteraction(settlement, commitEmitter, settleEmitter);
5082
+ try {
5083
+ history.settle();
5084
+ } finally {
5085
+ onSettled?.();
5086
+ settleFromInteraction(settlement, commitEmitter, settleEmitter);
5087
+ }
4788
5088
  },
4789
5089
  attach(interaction) {
4790
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.20",
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",