@orbat-mapper/tactical-draw 0.2.0-alpha.8 → 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
@@ -648,13 +648,15 @@ interface TransformSession {
648
648
  * **Return value** — the committed exit snapshots of the members removed *by
649
649
  * this call*, built exactly like [[close]]'s snapshots (pixel size baked to
650
650
  * ground unless `sizeAnchor` is `"screen"`, then cloned and rendered); an
651
- * empty array when nothing was removed. This is the *only* delivery of a
652
- * removed member's exit state: the outer `editMany` promise resolves with the
653
- * snapshots of the members in the set **at close time only**. Removed members
654
- * are NOT written back to the graphics layer by the controller — the host
655
- * folds the returned snapshots into its document and calls `td.render()`, the
656
- * same contract as [[close]]. Returns `undefined` (a silent no-op) after the
657
- * session has settled.
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.
658
660
  *
659
661
  * `setMembers([])` on a live session delegates to [[close]]: it commits every
660
662
  * current member, resolves the promise with their snapshots, and returns that
@@ -667,8 +669,9 @@ interface TransformSession {
667
669
  * set at close time) and resolves the outer `editMany` promise with the same
668
670
  * value (the same reference — no double-bake of ground-anchored sizes). A
669
671
  * member removed mid-session via [[setMembers]] is not included here; its exit
670
- * snapshot was returned from that `setMembers` call. Returns `undefined` after
671
- * the session has settled (idempotent no-op).
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).
672
675
  */
673
676
  close(): ControlMeasureSnapshot[] | undefined;
674
677
  /** Reject the outer `editMany` promise with reason `"session"`. */
@@ -686,6 +689,33 @@ interface TransformSession {
686
689
  * have run — mirrors `EditSession.onChange` (issue #49).
687
690
  */
688
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;
689
719
  }
690
720
  /**
691
721
  * Delivered to `TransformSession.onChange` listeners once per completed group
@@ -1104,8 +1134,41 @@ declare class TacticalDraw {
1104
1134
  * `measures.length` must be >= 1; a single-element array behaves like an
1105
1135
  * `edit` with only `transform` mode. Throws `TypeError` synchronously (before
1106
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.
1107
1142
  */
1108
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;
1109
1172
  /**
1110
1173
  * Subscribe to picks on committed control measures.
1111
1174
  *
package/dist/index.mjs CHANGED
@@ -2737,6 +2737,7 @@ function runGroupTransform(opts) {
2737
2737
  /** Same click-away suppression role as `edit-controller.ts`'s flag of the same name. */
2738
2738
  let suppressClickAway = false;
2739
2739
  const changeListeners = /* @__PURE__ */ new Set();
2740
+ const commitListeners = /* @__PURE__ */ new Set();
2740
2741
  /** Session-scoped box rotation (ADR-0017 decision #2), shared by the whole group. */
2741
2742
  let boxAngle = 0;
2742
2743
  /** The group box fitted over every member's rendered geometry, or `null` when nothing projects this frame. */
@@ -2944,7 +2945,7 @@ function runGroupTransform(opts) {
2944
2945
  errors.push(err);
2945
2946
  }
2946
2947
  if (abortRequested) ctx.fail(new TacticalDrawAbortError("session"));
2947
- else if (closeRequested) ctx.commit(rebuildRenderedAll);
2948
+ else if (closeRequested) commitClose();
2948
2949
  else if (rejectRequested && !ctx.settled && members === membersAtEmission) {
2949
2950
  restorePointsByMember(previousPointsByMember);
2950
2951
  sizeAnchor = previousSizeAnchor;
@@ -2957,6 +2958,35 @@ function runGroupTransform(opts) {
2957
2958
  if (errors.length === 1) throw errors[0];
2958
2959
  if (errors.length > 1) throw new AggregateError(errors, "TransformSession.onChange listener errors");
2959
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
+ }
2960
2990
  function commitGesture(nextConcatPoints, preGestureOptionsOverrideByMember) {
2961
2991
  if (ctx.settled) return;
2962
2992
  const previousPointsByMember = clonePointsByMember();
@@ -3034,10 +3064,11 @@ function runGroupTransform(opts) {
3034
3064
  renderAllPreviews();
3035
3065
  renderHandlesLayer();
3036
3066
  recomputeDirty();
3067
+ if (removedSnapshots.length > 0) emitCommit(removedSnapshots);
3037
3068
  return removedSnapshots;
3038
3069
  }
3039
3070
  function close() {
3040
- return ctx.commit(rebuildRenderedAll);
3071
+ return commitClose();
3041
3072
  }
3042
3073
  const session = {
3043
3074
  get measures() {
@@ -3069,6 +3100,13 @@ function runGroupTransform(opts) {
3069
3100
  return () => {
3070
3101
  changeListeners.delete(listener);
3071
3102
  };
3103
+ },
3104
+ onCommit(listener) {
3105
+ if (ctx.settled) return () => {};
3106
+ commitListeners.add(listener);
3107
+ return () => {
3108
+ commitListeners.delete(listener);
3109
+ };
3072
3110
  }
3073
3111
  };
3074
3112
  let activeBoxDrag = null;
@@ -3173,11 +3211,11 @@ function runGroupTransform(opts) {
3173
3211
  if (localPreview || localHandles) return;
3174
3212
  const isMember = localGraphics ? members.some((m) => m.measure.id === localGraphics.id) : false;
3175
3213
  if (localGraphics && !isMember) {
3176
- ctx.commit(rebuildRenderedAll);
3214
+ commitClose();
3177
3215
  if (onClickAwayGraphicsPick) onClickAwayGraphicsPick(localGraphics);
3178
3216
  return;
3179
3217
  }
3180
- ctx.commit(rebuildRenderedAll);
3218
+ commitClose();
3181
3219
  };
3182
3220
  const core = runInteraction({
3183
3221
  adapter,
@@ -3665,6 +3703,11 @@ var TacticalDraw = class {
3665
3703
  * `measures.length` must be >= 1; a single-element array behaves like an
3666
3704
  * `edit` with only `transform` mode. Throws `TypeError` synchronously (before
3667
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.
3668
3711
  */
3669
3712
  editMany(measures, options) {
3670
3713
  this.assertAlive();
@@ -3713,6 +3756,42 @@ var TacticalDraw = class {
3713
3756
  }, extraSettle);
3714
3757
  }
3715
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
+ /**
3716
3795
  * Subscribe to picks on committed control measures.
3717
3796
  *
3718
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.8",
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",