@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 +16 -0
- package/dist/index.d.mts +39 -1
- package/dist/index.mjs +413 -113
- package/package.json +1 -1
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
|
|
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
|
-
|
|
3411
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
4123
|
-
|
|
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
|
|
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
|
-
|
|
4277
|
-
|
|
4278
|
-
|
|
4279
|
-
|
|
4280
|
-
|
|
4281
|
-
|
|
4282
|
-
|
|
4283
|
-
|
|
4284
|
-
|
|
4285
|
-
|
|
4286
|
-
|
|
4287
|
-
|
|
4288
|
-
|
|
4289
|
-
|
|
4290
|
-
|
|
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(
|
|
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 =
|
|
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
|
|
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(
|
|
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
|
-
|
|
4465
|
-
|
|
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
|
|
4633
|
-
|
|
4634
|
-
|
|
4635
|
-
|
|
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(
|
|
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,
|
|
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")
|
|
4735
|
-
|
|
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
|
-
|
|
4787
|
-
|
|
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.
|
|
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",
|