@orbat-mapper/tactical-draw 0.2.0-alpha.7 → 0.2.0-alpha.9

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/dist/index.d.mts CHANGED
@@ -518,10 +518,12 @@ interface EditSession {
518
518
  */
519
519
  readonly sizeAnchor: SizeAnchor;
520
520
  /**
521
- * Resolve the outer `edit` promise with a `ControlMeasureSnapshot` of the
522
- * current working state.
521
+ * Commit: returns the `ControlMeasureSnapshot` of the current working state
522
+ * and resolves the outer `edit` promise with the same value (the same
523
+ * reference — no double-bake of ground-anchored sizes). Returns `undefined`
524
+ * after the session has settled (idempotent no-op).
523
525
  */
524
- close(): void;
526
+ close(): ControlMeasureSnapshot | undefined;
525
527
  /** Reject the outer `edit` promise with reason `"session"`. */
526
528
  abort(): void;
527
529
  /**
@@ -592,12 +594,22 @@ interface EditChangeEvent {
592
594
  * transform box's corner-scale / rotate / body-translate gestures, applied to
593
595
  * every member at once. See the Phase 3 ("Multi-select") slice of ADR-0017.
594
596
  *
595
- * `measures` is the immutable edit-start input, index-aligned with every
596
- * snapshot array this session or its change events produce. After the
597
- * session settles (`close` / `abort`), every method is a silent no-op.
597
+ * The member set is mutable via [[setMembers]] (#187): members can join or
598
+ * leave a live session without a session boundary. Because membership changes
599
+ * over the session's lifetime, snapshot arrays are keyed by **measure id**, not
600
+ * by index — a snapshot's identity is `snapshot.measure.id`. `measures` is a
601
+ * live view of the current members' join-time inputs; a `TransformChangeEvent`'s
602
+ * arrays are index-aligned with `session.measures` *as of that emission*. After
603
+ * the session settles (`close` / `abort` / an emptying `setMembers`), every
604
+ * method is a silent no-op.
598
605
  */
599
606
  interface TransformSession {
600
- /** Immutable edit-start inputs, index-aligned with every snapshot array. */
607
+ /**
608
+ * Live view of the current members' join-time input measures — a fresh array
609
+ * on each read, in current membership order. Grows/shrinks as [[setMembers]]
610
+ * adds or removes members. Not stable across membership changes: key
611
+ * snapshots by `snapshot.measure.id` rather than relying on index alignment.
612
+ */
601
613
  readonly measures: readonly ControlMeasure[];
602
614
  /**
603
615
  * Live working control points for the member whose id is `measureId`, or
@@ -615,10 +627,53 @@ interface TransformSession {
615
627
  /** Switch the group's size anchor (ADR-0020). Takes effect on the next emitted snapshot and on `close()`. */
616
628
  setSizeAnchor(anchor: SizeAnchor): void;
617
629
  /**
618
- * Resolve the outer `editMany` promise with index-aligned
619
- * `ControlMeasureSnapshot[]` of the current working state.
620
- */
621
- close(): void;
630
+ * Replace the member set in place — the primitive behind shift-click toggle,
631
+ * marquee replace, and single-edit → group folding, without tearing the
632
+ * session down (#187). Members are matched to the current set by measure id:
633
+ *
634
+ * - **Retained** members (id in both the current and next sets) carry their
635
+ * current working state forward; the passed measure object is ignored for
636
+ * them. Their `dirty` baseline stays their original join-time input, so a
637
+ * session that has already moved things stays dirty across the call.
638
+ * - **Removed** members (id in the current set, absent from the next) are
639
+ * committed at their current working state.
640
+ * - **Added** members (id only in the next set) join at their passed input
641
+ * state; if currently on the graphics layer they are lifted off it exactly
642
+ * like initial members.
643
+ *
644
+ * The transform box re-derives from the next member set at the session's
645
+ * current box angle. Session-scoped state (`sizeAnchor`, box angle,
646
+ * non-geometry dirty) is unchanged.
647
+ *
648
+ * **Return value** — the committed exit snapshots of the members removed *by
649
+ * this call*, built exactly like [[close]]'s snapshots (pixel size baked to
650
+ * ground unless `sizeAnchor` is `"screen"`, then cloned and rendered); an
651
+ * empty array when nothing was removed. The outer `editMany` promise resolves
652
+ * with the snapshots of the members in the set **at close time only**.
653
+ * Removed members are NOT written back to the graphics layer by the
654
+ * controller — the host folds the returned snapshots into its document and
655
+ * calls `td.render()`, the same contract as [[close]]. A removed member's
656
+ * exit snapshot is also delivered through [[onCommit]] — the recommended
657
+ * channel, since it covers this return value and the close-time promise
658
+ * resolution through a single subscription. Returns `undefined` (a silent
659
+ * no-op) after the session has settled.
660
+ *
661
+ * `setMembers([])` on a live session delegates to [[close]]: it commits every
662
+ * current member, resolves the promise with their snapshots, and returns that
663
+ * same array. Duplicate ids in `measures` throw `TypeError` synchronously,
664
+ * without mutating session state.
665
+ */
666
+ setMembers(measures: readonly ControlMeasure[]): ControlMeasureSnapshot[] | undefined;
667
+ /**
668
+ * Commit: returns the members' `ControlMeasureSnapshot[]` (the members in the
669
+ * set at close time) and resolves the outer `editMany` promise with the same
670
+ * value (the same reference — no double-bake of ground-anchored sizes). A
671
+ * member removed mid-session via [[setMembers]] is not included here; its exit
672
+ * snapshot was returned from that `setMembers` call (and delivered to
673
+ * [[onCommit]] at that time). Returns `undefined` after the session has
674
+ * settled (idempotent no-op).
675
+ */
676
+ close(): ControlMeasureSnapshot[] | undefined;
622
677
  /** Reject the outer `editMany` promise with reason `"session"`. */
623
678
  abort(): void;
624
679
  /**
@@ -634,17 +689,48 @@ interface TransformSession {
634
689
  * have run — mirrors `EditSession.onChange` (issue #49).
635
690
  */
636
691
  onChange(listener: (event: TransformChangeEvent) => void): () => void;
692
+ /**
693
+ * Subscribe to committed exit-snapshot batches — the single delivery channel
694
+ * for every `ControlMeasureSnapshot[]` this session ever commits (#191). Fires
695
+ * exactly once per batch:
696
+ *
697
+ * - The members [[setMembers]] removes, but only when that call actually
698
+ * removed at least one member.
699
+ * - The close-time members on any commit path: [[close]], `setMembers([])`,
700
+ * a click-away close, or `TransformChangeEvent.close()`.
701
+ *
702
+ * Never fires on an abort/reject path. Fires synchronously from within the
703
+ * `setMembers`/`close` call that produced the batch, before that call
704
+ * returns, and the array delivered here is the same reference that call
705
+ * returns (and, for the close batch, the same reference the `editMany`
706
+ * promise resolves with). This is the recommended single fold path for exit
707
+ * state — subscribe once in `onSession` and every committed snapshot, from
708
+ * either delivery site, arrives here exactly once; the `setMembers` return
709
+ * value and the promise resolution are unchanged and remain valid for
710
+ * backward compatibility.
711
+ *
712
+ * Listener exceptions are collected; after every listener has run, they are
713
+ * rethrown (one if single, `AggregateError` otherwise) — mirrors
714
+ * [[onChange]]. Returns an unsubscribe function; registering after the
715
+ * session has settled returns a no-op unsubscribe and the listener never
716
+ * fires.
717
+ */
718
+ onCommit(handler: (snapshots: ControlMeasureSnapshot[]) => void): () => void;
637
719
  }
638
720
  /**
639
721
  * Delivered to `TransformSession.onChange` listeners once per completed group
640
722
  * gesture. Both `measures` and `previous` are index-aligned with
641
- * `session.measures`. See `EditChangeEvent` for the identical emission
642
- * contract this mirrors.
723
+ * `session.measures` *as of this emission*. Key by `measure.id` rather than
724
+ * relying on index alignment across emissions. A `setMembers` call from within
725
+ * a listener takes effect immediately and supersedes this emission's
726
+ * `reject()` (the rollback baseline no longer matches the member set);
727
+ * `close()` and `abort()` still apply, against the new membership. See
728
+ * `EditChangeEvent` for the identical emission contract this mirrors.
643
729
  */
644
730
  interface TransformChangeEvent {
645
- /** Working state after the completed gesture, index-aligned with `session.measures`. */
731
+ /** Working state after the completed gesture, index-aligned with `session.measures` as of this emission. */
646
732
  readonly measures: ControlMeasureSnapshot[];
647
- /** State immediately before this gesture, index-aligned with `session.measures`. */
733
+ /** State immediately before this gesture, index-aligned with `session.measures` as of this emission. */
648
734
  readonly previous: ControlMeasureSnapshot[];
649
735
  /** The session the event belongs to. */
650
736
  readonly session: TransformSession;
@@ -652,7 +738,8 @@ interface TransformChangeEvent {
652
738
  * Roll every member's working preview back to `previous` after all
653
739
  * listeners have run. Multiple calls collapse to a single rollback.
654
740
  * Overridden by `close()` or `abort()` if either is also called during this
655
- * emission.
741
+ * emission, and dropped if a listener called `session.setMembers` (the
742
+ * rollback baseline no longer matches the member set).
656
743
  */
657
744
  reject(): void;
658
745
  /** Shortcut for `event.session.close()`, deferred until the emission ends. */
@@ -945,6 +1032,14 @@ declare class TacticalDraw {
945
1032
  * working copy. Empty while idle.
946
1033
  */
947
1034
  private excludedInteractionIds;
1035
+ /**
1036
+ * Live member ids of the active group transform, or `undefined` when none.
1037
+ * Falls back to the session ref because `onSession` fires from inside the
1038
+ * controller's attach, before `runWithSlot` has assigned `activeTransform` —
1039
+ * the session ref is registered first (`trackTransformSession`), so a
1040
+ * `render()` from inside `onSession` still sees live membership.
1041
+ */
1042
+ private transformMemberIds;
948
1043
  /**
949
1044
  * Reconcile the graphics layer against the authoritative list of measures.
950
1045
  * Idempotent. Throws synchronously on duplicate measure ids *before*
@@ -1028,15 +1123,52 @@ declare class TacticalDraw {
1028
1123
  * (ADR-0017 "Multi-select" slice). Every member is lifted onto the shared
1029
1124
  * preview layer; the box (corner scale, rotate grip, drag-anywhere body
1030
1125
  * translate) applies rigidly to all of them at once. Returns a Promise that
1031
- * resolves with index-aligned `ControlMeasureSnapshot[]` on
1032
- * `session.close()` and rejects with `TacticalDrawAbortError` on
1033
- * signal / Escape / destroy / preempt / `session.abort()`.
1126
+ * resolves with `ControlMeasureSnapshot[]` on `session.close()` and rejects
1127
+ * with `TacticalDrawAbortError` on signal / Escape / destroy / preempt /
1128
+ * `session.abort()`. Key the resolved snapshots by `snapshot.measure.id`:
1129
+ * the member set is mutable (`session.setMembers`, #187) so snapshot arrays
1130
+ * are id-keyed, not index-aligned with this input. The promise resolves with
1131
+ * the members in the set at close time; a member removed mid-session had its
1132
+ * exit snapshot returned from the `setMembers` call that removed it.
1034
1133
  *
1035
1134
  * `measures.length` must be >= 1; a single-element array behaves like an
1036
1135
  * `edit` with only `transform` mode. Throws `TypeError` synchronously (before
1037
1136
  * any preemption) for an empty array or duplicate ids.
1137
+ *
1138
+ * For a selection that changes over time (shift-click toggle, marquee
1139
+ * replace), prefer [[syncTransformMembers]] over hand-rolling the
1140
+ * live-session-vs-fresh-`editMany` branch, and subscribe to
1141
+ * `TransformSession.onCommit` as the single fold path for exit snapshots.
1038
1142
  */
1039
1143
  editMany(measures: ControlMeasure[], options?: EditManyOptions): Promise<ControlMeasureSnapshot[]>;
1144
+ /**
1145
+ * One-call selection-sync: mutate the live group transform's member set in
1146
+ * place, or start a fresh `editMany` when no transform is live (#191). This
1147
+ * owns the "live session → `setMembers`, else → `editMany`" branch that every
1148
+ * host otherwise hand-rolls against `activeTransformSession`.
1149
+ *
1150
+ * The facade knows session liveness synchronously — `activeTransformSessionRef`
1151
+ * is cleared inside the settle path, before control returns to host code — so
1152
+ * unlike a host's own `activeTransformSession ? setMembers : editMany` check,
1153
+ * this can never observe a stale ref pointing at an already-settled session.
1154
+ * The settled-race fall-through hosts had to hand-roll for that gap is owned
1155
+ * here instead: a `setMembers` call that (implausibly) still returns
1156
+ * `undefined` falls through to a fresh `editMany` rather than silently
1157
+ * dropping the selection.
1158
+ *
1159
+ * `measures: []` closes any live session (committing every member) and is a
1160
+ * no-op while idle. With a live session, `options` is ignored — session-scoped
1161
+ * state (`sizeAnchor`, box angle, close-on-click-away policy) is sticky per
1162
+ * `setMembers`'s contract; `options` applies only when this call starts a
1163
+ * fresh `editMany`. Exit snapshots are delivered via the session's
1164
+ * `onCommit` — subscribe once in `options.onSession`. The underlying
1165
+ * `editMany` promise is owned by the facade; its abort rejections are
1166
+ * swallowed here (non-abort errors surface as unhandled rejections). Throws
1167
+ * `TypeError` synchronously on duplicate ids, from either path. Starting a
1168
+ * fresh transform preempts any other in-flight draw/edit exactly like
1169
+ * `editMany`.
1170
+ */
1171
+ syncTransformMembers(measures: readonly ControlMeasure[], options?: EditManyOptions): void;
1040
1172
  /**
1041
1173
  * Subscribe to picks on committed control measures.
1042
1174
  *
package/dist/index.mjs CHANGED
@@ -699,7 +699,7 @@ function runInteraction(spec) {
699
699
  clearLayers();
700
700
  }
701
701
  function commit(produce) {
702
- if (settled) return;
702
+ if (settled) return void 0;
703
703
  let value;
704
704
  try {
705
705
  value = produce();
@@ -714,6 +714,7 @@ function runInteraction(spec) {
714
714
  teardown();
715
715
  resolveFn(value);
716
716
  if (onSettled) onSettled();
717
+ return value;
717
718
  }
718
719
  function fail(error) {
719
720
  if (settled) return;
@@ -2259,8 +2260,7 @@ function runEdit(opts) {
2259
2260
  return sizeAnchor;
2260
2261
  },
2261
2262
  close() {
2262
- if (ctx.settled) return;
2263
- ctx.commit(rebuildRendered);
2263
+ return ctx.commit(rebuildRendered);
2264
2264
  },
2265
2265
  abort() {
2266
2266
  if (ctx.settled) return;
@@ -2664,49 +2664,67 @@ const CLICK_SLOP_PX = 3;
2664
2664
  * thin outline (its own fit at the same angle)
2665
2665
  * - dirty tracking, close/abort/signal/preempt/destroy, click-away — same
2666
2666
  * contracts as `runEdit`, scoped to the group
2667
+ * - membership is mutable via `session.setMembers` (#187): members join/leave
2668
+ * a live session without a session boundary
2667
2669
  */
2668
2670
  function runGroupTransform(opts) {
2669
- const { adapter, measures, graphicsLayer, previewLayer, handlesLayer, graphicsStyle, signal, closeOnClickAway = true, interactionStyle, onSession, handleIdPrefix, restoreOriginalToGraphics, wasOnGraphicsLayer, onSettled, onClickAwayGraphicsPick } = opts;
2671
+ const { adapter, measures, graphicsLayer, previewLayer, handlesLayer, graphicsStyle, signal, closeOnClickAway = true, interactionStyle, onSession, handleIdPrefix, restoreOriginalToGraphics, isOnGraphicsLayer, onSettled, onClickAwayGraphicsPick } = opts;
2670
2672
  const renderOpts = graphicsStyle ? { graphicsStyle } : void 0;
2671
- const memberIds = new Set(measures.map((m) => m.id));
2672
2673
  let sizeAnchor = opts.sizeAnchor ?? "ground";
2673
- const editStartPointsByMember = measures.map((m) => m.controlPoints.map((p) => [...p]));
2674
- const workingPointsByMember = editStartPointsByMember.map((pts) => pts.map((p) => [...p]));
2675
- const counts = workingPointsByMember.map((pts) => pts.length);
2674
+ /** Build a fresh working record for `measure`, snapshotting its edit-start points. */
2675
+ function createMemberState(measure) {
2676
+ const editStartPoints = measure.controlPoints.map((p) => [...p]);
2677
+ return {
2678
+ measure,
2679
+ editStartPoints,
2680
+ workingPoints: editStartPoints.map((p) => [...p]),
2681
+ optionsOverride: null,
2682
+ box: null,
2683
+ lastPreviewFeatures: []
2684
+ };
2685
+ }
2686
+ /** The single mutable source of truth for the current member set. */
2687
+ let members = measures.map(createMemberState);
2676
2688
  /**
2677
- * Per-member options patch, index-aligned with `measures`, applied on top of
2678
- * the member's own `options` when building its working/rendered measure.
2679
- * This exists solely to freeze orientation-derived option defaults (e.g.
2680
- * Battle Position's echelon anchor) the moment a group rotate gesture
2681
- * starts (ADR-0023) — there is no public per-member `setOptions` here, and
2682
- * the "no per-measure setOptions" contract for the group session still
2683
- * holds; a host cannot reach this array.
2689
+ * `true` once any member has been lifted off the graphics layer (initial or a
2690
+ * mid-session `setMembers` add). Gates the abort-path restore so a session
2691
+ * that never touched the graphics layer doesn't rewrite it on abort.
2684
2692
  */
2685
- let optionsOverrideByMember = measures.map(() => null);
2686
- /** Effective options for member `i` against a given override array, merged onto its own options. */
2687
- function effectiveOptionsWith(i, overrides) {
2688
- const override = overrides[i];
2689
- if (!override) return measures[i].options;
2693
+ let anyLiftedFromGraphics = false;
2694
+ /** A member's own options merged with an override patch (a null override leaves them unchanged). */
2695
+ function mergeOptions(base, override) {
2696
+ if (!override) return base;
2690
2697
  return {
2691
- ...measures[i].options ?? {},
2698
+ ...base ?? {},
2692
2699
  ...override
2693
2700
  };
2694
2701
  }
2695
- /** Effective options for member `i`: its own options merged with the *live* override. */
2702
+ /** Effective options for member `i`: its own options merged with its *live* override. */
2696
2703
  function effectiveOptions(i) {
2697
- return effectiveOptionsWith(i, optionsOverrideByMember);
2704
+ const member = members[i];
2705
+ return mergeOptions(member.measure.options, member.optionsOverride);
2698
2706
  }
2699
- /** The working measure for member `i` against a given override array, with live working points. */
2700
- function workingMeasureForWith(i, overrides) {
2707
+ /** Build a member's committed snapshot with a given override, baking pixel size to ground unless "screen". */
2708
+ function buildMemberSnapshot(member, override) {
2709
+ const working = {
2710
+ ...member.measure,
2711
+ options: mergeOptions(member.measure.options, override),
2712
+ controlPoints: member.workingPoints.map((p) => [...p])
2713
+ };
2714
+ const committed = sizeAnchor === "screen" ? working : bakePixelSizeToGround(working, adapter);
2701
2715
  return {
2702
- ...measures[i],
2703
- options: effectiveOptionsWith(i, overrides),
2704
- controlPoints: workingPointsByMember[i].map((p) => [...p])
2716
+ measure: cloneControlMeasure(committed),
2717
+ render: renderMeasureForAdapter(adapter, committed, renderOpts)
2705
2718
  };
2706
2719
  }
2707
2720
  /** The working measure for member `i`: original measure, live effective options, live working points. */
2708
2721
  function workingMeasureFor(i) {
2709
- return workingMeasureForWith(i, optionsOverrideByMember);
2722
+ const member = members[i];
2723
+ return {
2724
+ ...member.measure,
2725
+ options: effectiveOptions(i),
2726
+ controlPoints: member.workingPoints.map((p) => [...p])
2727
+ };
2710
2728
  }
2711
2729
  let dirty = false;
2712
2730
  /**
@@ -2719,26 +2737,27 @@ function runGroupTransform(opts) {
2719
2737
  /** Same click-away suppression role as `edit-controller.ts`'s flag of the same name. */
2720
2738
  let suppressClickAway = false;
2721
2739
  const changeListeners = /* @__PURE__ */ new Set();
2740
+ const commitListeners = /* @__PURE__ */ new Set();
2722
2741
  /** Session-scoped box rotation (ADR-0017 decision #2), shared by the whole group. */
2723
2742
  let boxAngle = 0;
2724
2743
  /** The group box fitted over every member's rendered geometry, or `null` when nothing projects this frame. */
2725
2744
  let groupBox = null;
2726
- /** Each member's own fitted box (same angle as `groupBox`), index-aligned with `measures`. */
2727
- let memberBoxes = measures.map(() => null);
2728
- /** The features written to the preview layer for each member on the last paint. */
2729
- let lastPreviewFeaturesByMember = measures.map(() => []);
2730
2745
  let ctx;
2731
2746
  let driver;
2732
2747
  const dragProjection = {
2733
2748
  toPixel: (lonLat) => driver.toPixel(lonLat),
2734
2749
  fromPixel: (pixel) => driver.fromPixel(pixel)
2735
2750
  };
2736
- function liftOriginalsFromGraphics() {
2737
- measures.forEach((measure, i) => {
2738
- if (!wasOnGraphicsLayer[i]) return;
2739
- const ids = renderMeasureForAdapter(adapter, measure, renderOpts).features.map((f) => f.id).filter((id) => typeof id === "string");
2740
- if (ids.length > 0) adapter.removeFeatures(graphicsLayer, ids);
2741
- });
2751
+ /**
2752
+ * Lift `member` off the graphics layer when the host reports it currently
2753
+ * there, flipping `anyLiftedFromGraphics` so the abort path knows to restore.
2754
+ * Used for both initial and mid-session-added members.
2755
+ */
2756
+ function liftMemberFromGraphics(member) {
2757
+ if (!isOnGraphicsLayer(member.measure.id)) return;
2758
+ const ids = renderMeasureForAdapter(adapter, member.measure, renderOpts).features.map((f) => f.id).filter((id) => typeof id === "string");
2759
+ if (ids.length > 0) adapter.removeFeatures(graphicsLayer, ids);
2760
+ anyLiftedFromGraphics = true;
2742
2761
  }
2743
2762
  function pixelsFor(features) {
2744
2763
  const positions = [];
@@ -2755,7 +2774,7 @@ function runGroupTransform(opts) {
2755
2774
  return activeBoxDrag ? activeBoxDrag.lastSnapshot.box.angle : boxAngle;
2756
2775
  }
2757
2776
  /**
2758
- * Re-fit `groupBox` and every `memberBoxes[i]` from `lastPreviewFeaturesByMember`.
2777
+ * Re-fit `groupBox` and every member's `box` from their `lastPreviewFeatures`.
2759
2778
  * While a box gesture is in flight, `groupBox` itself is not touched here —
2760
2779
  * `renderHandlesLayer` paints the drag's own rigidly-transformed box instead
2761
2780
  * (matching `edit-controller.ts`'s `refitBox`) — but the member outlines are
@@ -2766,18 +2785,18 @@ function runGroupTransform(opts) {
2766
2785
  const angle = currentAngle();
2767
2786
  const padding = interactionStyle?.boxPadding ?? 0;
2768
2787
  const allPixels = [];
2769
- memberBoxes = lastPreviewFeaturesByMember.map((features) => {
2770
- const pixels = pixelsFor(features);
2788
+ for (const member of members) {
2789
+ const pixels = pixelsFor(member.lastPreviewFeatures);
2771
2790
  allPixels.push(...pixels);
2772
- return fitTransformBox(pixels, angle, padding);
2773
- });
2791
+ member.box = fitTransformBox(pixels, angle, padding);
2792
+ }
2774
2793
  if (activeBoxDrag) return;
2775
2794
  groupBox = fitTransformBox(allPixels, boxAngle, padding);
2776
2795
  }
2777
2796
  function renderAllPreviews() {
2778
2797
  if (ctx.settled) return;
2779
2798
  const allFeatures = [];
2780
- lastPreviewFeaturesByMember = measures.map((_measure, i) => {
2799
+ members.forEach((member, i) => {
2781
2800
  const working = workingMeasureFor(i);
2782
2801
  let features;
2783
2802
  try {
@@ -2785,17 +2804,17 @@ function runGroupTransform(opts) {
2785
2804
  } catch {
2786
2805
  features = [];
2787
2806
  }
2807
+ member.lastPreviewFeatures = features;
2788
2808
  allFeatures.push(...features);
2789
- return features;
2790
2809
  });
2791
2810
  ctx.writeLayer(previewLayer, allFeatures);
2792
2811
  refitBoxes();
2793
2812
  }
2794
2813
  function rescaleOnViewChange() {
2795
2814
  if (ctx.settled) return;
2796
- if (measures.some((m) => measureUsesAdapterResolution({
2797
- kind: m.kind,
2798
- options: m.options
2815
+ if (members.some((m) => measureUsesAdapterResolution({
2816
+ kind: m.measure.kind,
2817
+ options: m.measure.options
2799
2818
  }))) renderAllPreviews();
2800
2819
  else refitBoxes();
2801
2820
  renderHandlesLayer();
@@ -2821,59 +2840,62 @@ function runGroupTransform(opts) {
2821
2840
  features.push(boxOutlineFeature(liveGroupBox, dragProjection.fromPixel, `${handleIdPrefix}:box-outline`, groupOutlineStyle()));
2822
2841
  features.push(...toFeatures(boxToHandles(liveGroupBox, dragProjection.fromPixel), handleIdPrefix, interactionStyle));
2823
2842
  }
2824
- measures.forEach((measure, i) => {
2825
- const memberBox = memberBoxes[i];
2826
- if (!memberBox) return;
2827
- features.push(boxOutlineFeature(memberBox, dragProjection.fromPixel, `${handleIdPrefix}:member-outline:${measure.id}`, interactionStyle?.boxOutline));
2828
- });
2843
+ for (const member of members) {
2844
+ const memberBox = member.box;
2845
+ if (!memberBox) continue;
2846
+ features.push(boxOutlineFeature(memberBox, dragProjection.fromPixel, `${handleIdPrefix}:member-outline:${member.measure.id}`, interactionStyle?.boxOutline));
2847
+ }
2829
2848
  ctx.writeLayer(handlesLayer, features);
2830
2849
  }
2831
2850
  /**
2832
2851
  * Builds every member's committed snapshot against a given override array
2833
- * rather than the live `optionsOverrideByMember` — used to render a
2834
- * gesture's pre-freeze "previous" snapshot when a rotate has already
2835
- * mutated the live overrides by the time the commit runs.
2852
+ * rather than the live per-member override — used to render a gesture's
2853
+ * pre-freeze "previous" snapshot when a rotate has already mutated the live
2854
+ * overrides by the time the commit runs. Index-aligned with `members`, which
2855
+ * is sound because membership cannot change within a gesture emission.
2836
2856
  */
2837
2857
  function rebuildRenderedAllWith(overrides) {
2838
- return measures.map((_measure, i) => {
2839
- const working = workingMeasureForWith(i, overrides);
2840
- const committed = sizeAnchor === "screen" ? working : bakePixelSizeToGround(working, adapter);
2841
- return {
2842
- measure: cloneControlMeasure(committed),
2843
- render: renderMeasureForAdapter(adapter, committed, renderOpts)
2844
- };
2845
- });
2858
+ return members.map((member, i) => buildMemberSnapshot(member, overrides[i] ?? null));
2846
2859
  }
2847
2860
  function rebuildRenderedAll() {
2848
- return rebuildRenderedAllWith(optionsOverrideByMember);
2861
+ return members.map((member) => buildMemberSnapshot(member, member.optionsOverride));
2849
2862
  }
2850
2863
  function recomputeDirty() {
2851
- dirty = nonGeometryDirty || workingPointsByMember.some((pts, i) => !pointsEqualWithinTolerance(pts, editStartPointsByMember[i], DIRTY_EPSILON));
2864
+ dirty = nonGeometryDirty || members.some((m) => !pointsEqualWithinTolerance(m.workingPoints, m.editStartPoints, DIRTY_EPSILON));
2852
2865
  }
2853
- /** Split `points` (the concatenation of every member's control points) back per member and write them into `workingPointsByMember`. */
2866
+ /** Split `points` (the concatenation of every member's control points) back per member. */
2854
2867
  function applyConcatenatedPoints(points) {
2855
2868
  const rounded = roundControlPoints(points);
2856
2869
  let offset = 0;
2857
- for (let i = 0; i < counts.length; i++) {
2858
- const c = counts[i];
2870
+ for (const member of members) {
2871
+ const c = member.workingPoints.length;
2859
2872
  const slice = rounded.slice(offset, offset + c);
2860
- workingPointsByMember[i].splice(0, workingPointsByMember[i].length, ...slice);
2873
+ member.workingPoints.splice(0, member.workingPoints.length, ...slice);
2861
2874
  offset += c;
2862
2875
  }
2863
2876
  }
2864
2877
  function concatWorkingPoints() {
2865
2878
  const out = [];
2866
- for (const pts of workingPointsByMember) out.push(...pts.map((p) => [...p]));
2879
+ for (const member of members) out.push(...member.workingPoints.map((p) => [...p]));
2867
2880
  return out;
2868
2881
  }
2869
2882
  function clonePointsByMember() {
2870
- return workingPointsByMember.map((pts) => pts.map((p) => [...p]));
2883
+ return members.map((m) => m.workingPoints.map((p) => [...p]));
2871
2884
  }
2872
2885
  function restorePointsByMember(snapshot) {
2873
- for (let i = 0; i < workingPointsByMember.length; i++) workingPointsByMember[i].splice(0, workingPointsByMember[i].length, ...snapshot[i]);
2886
+ members.forEach((member, i) => {
2887
+ member.workingPoints.splice(0, member.workingPoints.length, ...snapshot[i]);
2888
+ });
2874
2889
  }
2875
2890
  function cloneOptionsOverrideByMember() {
2876
- return optionsOverrideByMember.map((o) => o ? { ...o } : null);
2891
+ return members.map((m) => m.optionsOverride ? { ...m.optionsOverride } : null);
2892
+ }
2893
+ /** Write a per-member override snapshot back onto the live members (index-aligned; gesture-scoped). */
2894
+ function applyOptionsOverrides(overrides) {
2895
+ members.forEach((member, i) => {
2896
+ const o = overrides[i];
2897
+ member.optionsOverride = o ? { ...o } : null;
2898
+ });
2877
2899
  }
2878
2900
  /**
2879
2901
  * Pin orientation-derived option defaults (e.g. Battle Position's echelon
@@ -2882,10 +2904,10 @@ function runGroupTransform(opts) {
2882
2904
  * per-member since the group has no single working-options set.
2883
2905
  */
2884
2906
  function freezeOrientationForRotate() {
2885
- measures.forEach((measure, i) => {
2886
- const patch = freezeOrientationOptions(measure.kind, workingPointsByMember[i].map((p) => [...p]), effectiveOptions(i));
2887
- if (patch) optionsOverrideByMember[i] = {
2888
- ...optionsOverrideByMember[i] ?? {},
2907
+ members.forEach((member, i) => {
2908
+ const patch = freezeOrientationOptions(member.measure.kind, member.workingPoints.map((p) => [...p]), effectiveOptions(i));
2909
+ if (patch) member.optionsOverride = {
2910
+ ...member.optionsOverride ?? {},
2889
2911
  ...patch
2890
2912
  };
2891
2913
  });
@@ -2915,6 +2937,7 @@ function runGroupTransform(opts) {
2915
2937
  abortRequested = true;
2916
2938
  }
2917
2939
  };
2940
+ const membersAtEmission = members;
2918
2941
  const errors = [];
2919
2942
  for (const listener of [...changeListeners]) try {
2920
2943
  listener(event);
@@ -2922,12 +2945,12 @@ function runGroupTransform(opts) {
2922
2945
  errors.push(err);
2923
2946
  }
2924
2947
  if (abortRequested) ctx.fail(new TacticalDrawAbortError("session"));
2925
- else if (closeRequested) ctx.commit(rebuildRenderedAll);
2926
- else if (rejectRequested && !ctx.settled) {
2948
+ else if (closeRequested) commitClose();
2949
+ else if (rejectRequested && !ctx.settled && members === membersAtEmission) {
2927
2950
  restorePointsByMember(previousPointsByMember);
2928
2951
  sizeAnchor = previousSizeAnchor;
2929
2952
  nonGeometryDirty = previousNonGeometryDirty;
2930
- optionsOverrideByMember = previousOptionsOverrideByMember.map((o) => o ? { ...o } : null);
2953
+ applyOptionsOverrides(previousOptionsOverrideByMember);
2931
2954
  renderAllPreviews();
2932
2955
  renderHandlesLayer();
2933
2956
  recomputeDirty();
@@ -2935,6 +2958,35 @@ function runGroupTransform(opts) {
2935
2958
  if (errors.length === 1) throw errors[0];
2936
2959
  if (errors.length > 1) throw new AggregateError(errors, "TransformSession.onChange listener errors");
2937
2960
  }
2961
+ /**
2962
+ * Deliver a committed exit-snapshot batch to every `onCommit` listener — the
2963
+ * single delivery channel for both the `setMembers`-removed batch and the
2964
+ * close-time batch (#191). Same exception-collection contract as `emitChange`.
2965
+ */
2966
+ function emitCommit(snapshots) {
2967
+ const errors = [];
2968
+ for (const listener of [...commitListeners]) try {
2969
+ listener(snapshots);
2970
+ } catch (err) {
2971
+ errors.push(err);
2972
+ }
2973
+ if (errors.length === 1) throw errors[0];
2974
+ if (errors.length > 1) throw new AggregateError(errors, "TransformSession.onCommit listener errors");
2975
+ }
2976
+ /**
2977
+ * Commit via `ctx.commit` and emit the close batch to `onCommit` listeners.
2978
+ * The emission happens after the interaction core has settled (teardown and
2979
+ * `onSettled` already ran inside `ctx.commit`), so a listener that calls
2980
+ * `td.render()` sees these members no longer excluded from reconcile — the
2981
+ * fold-then-render pattern works from inside the handler. `undefined` covers
2982
+ * both an already-settled session and a `produce` throw (the promise
2983
+ * rejects); neither emits.
2984
+ */
2985
+ function commitClose() {
2986
+ const snapshots = ctx.commit(rebuildRenderedAll);
2987
+ if (snapshots !== void 0) emitCommit(snapshots);
2988
+ return snapshots;
2989
+ }
2938
2990
  function commitGesture(nextConcatPoints, preGestureOptionsOverrideByMember) {
2939
2991
  if (ctx.settled) return;
2940
2992
  const previousPointsByMember = clonePointsByMember();
@@ -2968,12 +3020,64 @@ function runGroupTransform(opts) {
2968
3020
  dirty = true;
2969
3021
  emitChange(previousRendered, previousPointsByMember, previousSizeAnchor, previousNonGeometryDirty, previousOptionsOverrideByMember, currentRendered);
2970
3022
  }
3023
+ /**
3024
+ * Cancel an in-flight box drag without committing it: restore the pre-drag
3025
+ * geometry and option overrides, re-enable drag pan, and clear the drag. Used
3026
+ * by `setMembers` so a membership change never bakes a half-finished drag; the
3027
+ * caller is responsible for the follow-up repaint (`renderAllPreviews` /
3028
+ * `renderHandlesLayer` / `recomputeDirty`), so this leaves the paint to it
3029
+ * rather than rendering the outgoing member set only to redo it immediately.
3030
+ */
3031
+ function cancelActiveBoxDrag() {
3032
+ if (!activeBoxDrag) return;
3033
+ const { preDragPoints, preDragOptionsOverrideByMember } = activeBoxDrag;
3034
+ activeBoxDrag = null;
3035
+ driver.setDragPanEnabled(true);
3036
+ applyConcatenatedPoints(preDragPoints);
3037
+ applyOptionsOverrides(preDragOptionsOverrideByMember);
3038
+ dragInFlight = false;
3039
+ }
3040
+ function setMembers(next) {
3041
+ if (ctx.settled) return void 0;
3042
+ const seen = /* @__PURE__ */ new Set();
3043
+ for (const m of next) {
3044
+ if (seen.has(m.id)) throw new TypeError(`TransformSession.setMembers: duplicate control measure id "${m.id}"`);
3045
+ seen.add(m.id);
3046
+ }
3047
+ if (next.length === 0) return close();
3048
+ cancelActiveBoxDrag();
3049
+ const nextIds = new Set(next.map((m) => m.id));
3050
+ const currentById = new Map(members.map((m) => [m.measure.id, m]));
3051
+ const removedSnapshots = [];
3052
+ for (const member of members) if (!nextIds.has(member.measure.id)) removedSnapshots.push(buildMemberSnapshot(member, member.optionsOverride));
3053
+ const nextMembers = [];
3054
+ for (const measure of next) {
3055
+ const existing = currentById.get(measure.id);
3056
+ if (existing) nextMembers.push(existing);
3057
+ else {
3058
+ const member = createMemberState(measure);
3059
+ liftMemberFromGraphics(member);
3060
+ nextMembers.push(member);
3061
+ }
3062
+ }
3063
+ members = nextMembers;
3064
+ renderAllPreviews();
3065
+ renderHandlesLayer();
3066
+ recomputeDirty();
3067
+ if (removedSnapshots.length > 0) emitCommit(removedSnapshots);
3068
+ return removedSnapshots;
3069
+ }
3070
+ function close() {
3071
+ return commitClose();
3072
+ }
2971
3073
  const session = {
2972
- measures,
3074
+ get measures() {
3075
+ return members.map((m) => m.measure);
3076
+ },
2973
3077
  controlPointsFor(measureId) {
2974
- const index = measures.findIndex((m) => m.id === measureId);
2975
- if (index < 0) return void 0;
2976
- return workingPointsByMember[index].map((p) => [...p]);
3078
+ const member = members.find((m) => m.measure.id === measureId);
3079
+ if (!member) return void 0;
3080
+ return member.workingPoints.map((p) => [...p]);
2977
3081
  },
2978
3082
  get dirty() {
2979
3083
  return dirty;
@@ -2984,10 +3088,8 @@ function runGroupTransform(opts) {
2984
3088
  setSizeAnchor(next) {
2985
3089
  setSizeAnchor(next);
2986
3090
  },
2987
- close() {
2988
- if (ctx.settled) return;
2989
- ctx.commit(rebuildRenderedAll);
2990
- },
3091
+ setMembers,
3092
+ close,
2991
3093
  abort() {
2992
3094
  if (ctx.settled) return;
2993
3095
  ctx.fail(new TacticalDrawAbortError("session"));
@@ -2998,6 +3100,13 @@ function runGroupTransform(opts) {
2998
3100
  return () => {
2999
3101
  changeListeners.delete(listener);
3000
3102
  };
3103
+ },
3104
+ onCommit(listener) {
3105
+ if (ctx.settled) return () => {};
3106
+ commitListeners.add(listener);
3107
+ return () => {
3108
+ commitListeners.delete(listener);
3109
+ };
3001
3110
  }
3002
3111
  };
3003
3112
  let activeBoxDrag = null;
@@ -3100,12 +3209,13 @@ function runGroupTransform(opts) {
3100
3209
  return;
3101
3210
  }
3102
3211
  if (localPreview || localHandles) return;
3103
- if (localGraphics && !memberIds.has(localGraphics.id)) {
3104
- ctx.commit(rebuildRenderedAll);
3212
+ const isMember = localGraphics ? members.some((m) => m.measure.id === localGraphics.id) : false;
3213
+ if (localGraphics && !isMember) {
3214
+ commitClose();
3105
3215
  if (onClickAwayGraphicsPick) onClickAwayGraphicsPick(localGraphics);
3106
3216
  return;
3107
3217
  }
3108
- ctx.commit(rebuildRenderedAll);
3218
+ commitClose();
3109
3219
  };
3110
3220
  const core = runInteraction({
3111
3221
  adapter,
@@ -3116,7 +3226,7 @@ function runGroupTransform(opts) {
3116
3226
  attach(c) {
3117
3227
  ctx = c;
3118
3228
  driver = adapter.createEditPointerDriver();
3119
- liftOriginalsFromGraphics();
3229
+ for (const member of members) liftMemberFromGraphics(member);
3120
3230
  const unsubDown = driver.onPointerDown(onDriverDown);
3121
3231
  const unsubMove = driver.onPointerMove(onDriverMove);
3122
3232
  const unsubUp = driver.onPointerUp(onDriverUp);
@@ -3124,7 +3234,6 @@ function runGroupTransform(opts) {
3124
3234
  const unsubPickHandles = adapter.onPick(handlesLayer, handlePickHandles);
3125
3235
  const unsubPickGraphics = adapter.onPick(graphicsLayer, handlePickGraphics);
3126
3236
  adapter.on("click", handleClick);
3127
- const anyWasOnGraphicsLayer = wasOnGraphicsLayer.some(Boolean);
3128
3237
  c.onTeardown(() => {
3129
3238
  adapter.off("click", handleClick);
3130
3239
  unsubPickPreview();
@@ -3139,7 +3248,9 @@ function runGroupTransform(opts) {
3139
3248
  dragInFlight = false;
3140
3249
  }
3141
3250
  driver.dispose();
3142
- }, anyWasOnGraphicsLayer ? restoreOriginalToGraphics : void 0);
3251
+ }, () => {
3252
+ if (anyLiftedFromGraphics) restoreOriginalToGraphics();
3253
+ });
3143
3254
  renderAllPreviews();
3144
3255
  renderHandlesLayer();
3145
3256
  recomputeDirty();
@@ -3151,7 +3262,9 @@ function runGroupTransform(opts) {
3151
3262
  get settled() {
3152
3263
  return core.settled;
3153
3264
  },
3154
- measureIds: measures.map((m) => m.id),
3265
+ get measureIds() {
3266
+ return members.map((m) => m.measure.id);
3267
+ },
3155
3268
  abort(reason, cause) {
3156
3269
  core.abort(reason, cause);
3157
3270
  }
@@ -3297,10 +3410,21 @@ var TacticalDraw = class {
3297
3410
  excludedInteractionIds() {
3298
3411
  const ids = /* @__PURE__ */ new Set();
3299
3412
  if (this.activeEdit) ids.add(this.activeEdit.measureId);
3300
- if (this.activeTransform) for (const id of this.activeTransform.measureIds) ids.add(id);
3413
+ for (const id of this.transformMemberIds() ?? []) ids.add(id);
3301
3414
  return ids;
3302
3415
  }
3303
3416
  /**
3417
+ * Live member ids of the active group transform, or `undefined` when none.
3418
+ * Falls back to the session ref because `onSession` fires from inside the
3419
+ * controller's attach, before `runWithSlot` has assigned `activeTransform` —
3420
+ * the session ref is registered first (`trackTransformSession`), so a
3421
+ * `render()` from inside `onSession` still sees live membership.
3422
+ */
3423
+ transformMemberIds() {
3424
+ if (this.activeTransform) return this.activeTransform.measureIds;
3425
+ return this.activeTransformSessionRef?.measures.map((m) => m.id);
3426
+ }
3427
+ /**
3304
3428
  * Reconcile the graphics layer against the authoritative list of measures.
3305
3429
  * Idempotent. Throws synchronously on duplicate measure ids *before*
3306
3430
  * mutating any layer. An empty list before any prior render is a no-op.
@@ -3339,7 +3463,7 @@ var TacticalDraw = class {
3339
3463
  externalEdited = measures.find((m) => m.id === editedId);
3340
3464
  editedRemoved = externalEdited === void 0;
3341
3465
  }
3342
- const transformIds = this.activeTransform?.measureIds;
3466
+ const transformIds = this.transformMemberIds();
3343
3467
  const transformMemberRemoved = transformIds ? transformIds.some((id) => !seen.has(id)) : false;
3344
3468
  const excluded = this.excludedInteractionIds();
3345
3469
  const visible = excluded.size > 0 ? measures.filter((m) => !excluded.has(m.id)) : measures;
@@ -3355,10 +3479,14 @@ var TacticalDraw = class {
3355
3479
  editHandle.abort("removed");
3356
3480
  });
3357
3481
  }
3358
- if (transformMemberRemoved && this.activeTransform) {
3359
- const transformHandle = this.activeTransform;
3482
+ if (transformMemberRemoved) {
3483
+ const sessionRef = this.activeTransformSessionRef;
3360
3484
  queueMicrotask(() => {
3361
- transformHandle.abort("removed");
3485
+ const handle = this.activeTransform;
3486
+ if (!handle || handle.settled) return;
3487
+ if (this.activeTransformSessionRef !== sessionRef) return;
3488
+ const rendered = new Set(this.lastRenderedMeasures.map((m) => m.id));
3489
+ if (handle.measureIds.some((id) => !rendered.has(id))) handle.abort("removed");
3362
3490
  });
3363
3491
  }
3364
3492
  }
@@ -3564,13 +3692,22 @@ var TacticalDraw = class {
3564
3692
  * (ADR-0017 "Multi-select" slice). Every member is lifted onto the shared
3565
3693
  * preview layer; the box (corner scale, rotate grip, drag-anywhere body
3566
3694
  * translate) applies rigidly to all of them at once. Returns a Promise that
3567
- * resolves with index-aligned `ControlMeasureSnapshot[]` on
3568
- * `session.close()` and rejects with `TacticalDrawAbortError` on
3569
- * signal / Escape / destroy / preempt / `session.abort()`.
3695
+ * resolves with `ControlMeasureSnapshot[]` on `session.close()` and rejects
3696
+ * with `TacticalDrawAbortError` on signal / Escape / destroy / preempt /
3697
+ * `session.abort()`. Key the resolved snapshots by `snapshot.measure.id`:
3698
+ * the member set is mutable (`session.setMembers`, #187) so snapshot arrays
3699
+ * are id-keyed, not index-aligned with this input. The promise resolves with
3700
+ * the members in the set at close time; a member removed mid-session had its
3701
+ * exit snapshot returned from the `setMembers` call that removed it.
3570
3702
  *
3571
3703
  * `measures.length` must be >= 1; a single-element array behaves like an
3572
3704
  * `edit` with only `transform` mode. Throws `TypeError` synchronously (before
3573
3705
  * any preemption) for an empty array or duplicate ids.
3706
+ *
3707
+ * For a selection that changes over time (shift-click toggle, marquee
3708
+ * replace), prefer [[syncTransformMembers]] over hand-rolling the
3709
+ * live-session-vs-fresh-`editMany` branch, and subscribe to
3710
+ * `TransformSession.onCommit` as the single fold path for exit snapshots.
3574
3711
  */
3575
3712
  editMany(measures, options) {
3576
3713
  this.assertAlive();
@@ -3582,7 +3719,7 @@ var TacticalDraw = class {
3582
3719
  }
3583
3720
  const preempt = this.guardAndPreempt();
3584
3721
  if (preempt) return preempt;
3585
- const wasOnGraphicsLayer = measures.map((m) => this.lastRenderedMeasures.some((rendered) => rendered.id === m.id));
3722
+ const isOnGraphicsLayer = (id) => this.lastRenderedMeasures.some((m) => m.id === id);
3586
3723
  const restoreOriginalToGraphics = () => {
3587
3724
  this.renderMeasuresToGraphics(this.lastRenderedMeasures);
3588
3725
  };
@@ -3608,7 +3745,7 @@ var TacticalDraw = class {
3608
3745
  handleIdPrefix,
3609
3746
  sizeAnchor: options?.sizeAnchor,
3610
3747
  restoreOriginalToGraphics,
3611
- wasOnGraphicsLayer,
3748
+ isOnGraphicsLayer,
3612
3749
  onSettled,
3613
3750
  onClickAwayGraphicsPick
3614
3751
  }), (handle) => {
@@ -3619,6 +3756,42 @@ var TacticalDraw = class {
3619
3756
  }, extraSettle);
3620
3757
  }
3621
3758
  /**
3759
+ * One-call selection-sync: mutate the live group transform's member set in
3760
+ * place, or start a fresh `editMany` when no transform is live (#191). This
3761
+ * owns the "live session → `setMembers`, else → `editMany`" branch that every
3762
+ * host otherwise hand-rolls against `activeTransformSession`.
3763
+ *
3764
+ * The facade knows session liveness synchronously — `activeTransformSessionRef`
3765
+ * is cleared inside the settle path, before control returns to host code — so
3766
+ * unlike a host's own `activeTransformSession ? setMembers : editMany` check,
3767
+ * this can never observe a stale ref pointing at an already-settled session.
3768
+ * The settled-race fall-through hosts had to hand-roll for that gap is owned
3769
+ * here instead: a `setMembers` call that (implausibly) still returns
3770
+ * `undefined` falls through to a fresh `editMany` rather than silently
3771
+ * dropping the selection.
3772
+ *
3773
+ * `measures: []` closes any live session (committing every member) and is a
3774
+ * no-op while idle. With a live session, `options` is ignored — session-scoped
3775
+ * state (`sizeAnchor`, box angle, close-on-click-away policy) is sticky per
3776
+ * `setMembers`'s contract; `options` applies only when this call starts a
3777
+ * fresh `editMany`. Exit snapshots are delivered via the session's
3778
+ * `onCommit` — subscribe once in `options.onSession`. The underlying
3779
+ * `editMany` promise is owned by the facade; its abort rejections are
3780
+ * swallowed here (non-abort errors surface as unhandled rejections). Throws
3781
+ * `TypeError` synchronously on duplicate ids, from either path. Starting a
3782
+ * fresh transform preempts any other in-flight draw/edit exactly like
3783
+ * `editMany`.
3784
+ */
3785
+ syncTransformMembers(measures, options) {
3786
+ this.assertAlive();
3787
+ const session = this.activeTransformSessionRef;
3788
+ if (session) {
3789
+ if (session.setMembers(measures) !== void 0) return;
3790
+ }
3791
+ if (measures.length === 0) return;
3792
+ this.editMany([...measures], options).catch(ignoreAbort);
3793
+ }
3794
+ /**
3622
3795
  * Subscribe to picks on committed control measures.
3623
3796
  *
3624
3797
  * The graphics layer is pre-bound, and `event.id` is the originating control
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@orbat-mapper/tactical-draw",
3
- "version": "0.2.0-alpha.7",
3
+ "version": "0.2.0-alpha.9",
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",