Edit a control measure ​
edit(measure, options?) starts an interactive edit for one measure. It resolves with a GraphicSnapshot<ControlMeasure> of the working state on session.close(), and rejects on abort paths.
Draw an ambush below, then click it to edit. Toggle modes to see the handle sets change — drag a vertex (reshape), or drag a box corner, the rotate grip, or anywhere inside the box (transform). Click away to commit.
Loading map…
const { graphic: next } = await td.edit(measure, {
modes: ["reshape", "transform"],
});onCommit / onSettled ​
session.onCommit(handler) delivers the committed snapshot exactly once, synchronously from the producing call (close(), a click-away close, or a click on another measure), with the same reference close() returns and the edit() promise resolves with — never on abort. session.onSettled(handler) is the lifecycle complement: exactly once, on any settle path, after onCommit and after td.activeSession is already null; unlike onChange / onCommit, registering after settle still delivers, asynchronously. Same contract as TransformSession's pair below, singular here — see the reference table for the full row.
Modes ​
| Mode | Behaviour |
|---|---|
"reshape" (default) | Vertex handle at every control point; midpoint handles on editable segments (drag to insert). On variable-length measures, Alt/Option+click a vertex to delete it (the GIS-standard gesture, matching OpenLayers Modify). |
"transform" (default) | An oriented bounding box fitted around the rendered geometry — four corner handles, a rotate grip above the top edge, and drag-anywhere-inside-the-box translate. See The transform box. |
"delete" | Exclusive — passing it collapses the active set to ["delete"]. A modifier-free, host-toggled path: vertex handles only, tap to remove. Refused for trailing fixed slots and below the kind's minimum. |
reshape and transform combine freely — the default set shows both at once, so a host can drag a vertex and grab a box handle in the same session.
The transform box ​
"transform" mode renders an Excalidraw/Felt-style selection box around the measure's rendered geometry instead of separate translate and rotate grips:
- Corner handles (4) — drag one to scale the geometry uniformly about the opposite corner.
- Rotate grip — floats 28px above the box's top edge; drag it to rotate the box, and the geometry with it, about the box center.
- Drag anywhere inside the box — press away from a corner or the rotate grip (a 3px slop keeps an accidental micro-drag from reading as a click) to translate the whole measure.
The box always starts axis-aligned when an edit begins. Its angle is session state, not persisted geometry: rotate gestures accumulate for the life of the edit, but nothing records "this box is tilted" on the measure — only the rotated control points are written back. The box also re-fits on every view change (pan/zoom), so it keeps tracking the current screen projection of the geometry. Box gestures never run a kind's draw-rule transform — rule-driven reshaping only happens on reshape-mode vertex/midpoint drags.
By default the box hugs the rendered geometry tightly. Set interactionStyle.boxPadding (pixels, default 0) to inflate it on every side — useful when the corner/rotate handles would otherwise sit flush against (or overlap) the geometry itself.
EditOptions ​
| Field | Default | What it does |
|---|---|---|
modes | ["reshape", "transform"] | Active edit modes (see above). |
closeOnClickAway | true | Clicking outside preview/handles closes the edit. |
onSession | — | Called once when the edit starts, before pointer events flow. The same session is available as td.activeSession. |
signal | — | Abort signal; rejects with reason "signal". |
guide | true | Opt out of edit guide emission. Auto-suppressed for point kinds, and for fixed-length kinds unless the kind's draw rule opts back in via showGuide. |
interactionStyle | ctor default | Per-call style override (guide + handle slots), merged per-slot, per-property. |
sizeAnchor | "ground" | Where the resolved snapshot's sizes are anchored — geometry pixel sizes and label sizes alike. Editing a screen-anchored graphic as "ground" bakes/freezes its pixel sizes to meters; as "screen" it keeps them. The in-flight preview stays screen-anchored throughout. Derive a control measure's current anchor with the exported getSizeAnchor(measure) to preserve it on re-edit, and flip it live via session.setSizeAnchor(). |
See Validate edits and Drive style & options from UI controls for common edit-session integrations.
Multi-select ​
editMany(measures, options?) starts a group transform over several measures at once — think shift-clicking multiple graphics and dragging them as a unit. There's no reshape; a group is always the box.
const next = await td.editMany([measureA, measureB], {
onSession: (session) => {
groupSession = session;
},
});
// Key snapshots by measure id, not by input position.
next.forEach((snapshot) => upsert(snapshot.graphic));It resolves with GraphicSnapshot<ControlMeasure>[] and rejects with TacticalDrawAbortError on the same abort paths as edit. Because the member set is mutable (see below), key the resolved snapshots by snapshot.graphic.id rather than by input position — the promise resolves with the members in the set at close time.
Session-local undo and redo ​
Every live edit or transform session exposes session.history. Each completed vertex, label, move, rotate, or scale gesture is one step; pointer-move preview frames are not recorded. Edit sessions also record authored updateGraphic, options, style, text, label-placement, and size-anchor changes. Interaction configuration such as setModes() is not authored history.
const unsubscribe = session.history.subscribe((state) => {
undoButton.disabled = !state.canUndo;
redoButton.disabled = !state.canRedo;
});
undoButton.onclick = () => session.history.undo();
redoButton.onclick = () => session.history.redo();Undo and redo repaint the live preview and handles without settling the session. dirty becomes false again at the edit-start state. A new mutation after undo clears redo. close() commits only the currently selected state; abort() discards it all. After settlement, both methods return false. Each live session retains at most 100 completed history steps.
Treat an active session as the exclusive history scope even when its stack is empty: document undo beneath a live working copy is unsafe. For mutable group transforms, setGraphics() establishes a new baseline and clears both stacks; membership changes and already-emitted member exit snapshots are not locally undoable.
Mutable membership ​
session.setGraphics(measures) replaces the member set of a live session in place — the primitive behind shift-click toggle, marquee replace, and folding a single edit into a group, without tearing the session down or flickering the box:
// Add or remove members without a session boundary.
const removed = groupSession.setGraphics(nextSelection);
// `removed` are the exit snapshots of members that just left the group — fold
// them into your document exactly like close()'s snapshots, then td.render(...).
removed.forEach((snapshot) => upsert(snapshot.graphic));Retained members carry their current working state forward; removed members are committed at their current working state; added members join at their input, lifted off the graphics layer if currently on it. setGraphics([]) is equivalent to close(). Removed members are not written back to the graphics layer for you — fold the returned snapshots into your document and call td.render(), the same contract as close().
The resulting member set becomes a fresh session-history baseline. This clears undo and redo while retaining the members' current working geometry.
close() itself returns the committed snapshots synchronously (the same reference the promise resolves with), so a "commit then start the next interaction" host flow needs no promise handoff.
onCommit — one fold path for exit state ​
A removed member's exit snapshot is delivered two ways: setGraphics's return value, and — on close — the resolved editMany promise. Folding both sites into a host document is easy to get half-right (miss the return value and a removed member vanishes; miss the promise and the last members never land). session.onCommit(handler) is the single channel for both: it fires synchronously, once per committed batch, with the same arrays those two paths already carry.
// The promise still resolves with the close-time snapshots — a host using
// onCommit simply ignores the resolution value.
await td.editMany([measureA, measureB], {
onSession: (session) => {
session.onCommit((snapshots) => {
snapshots.forEach((snapshot) => upsert(snapshot.graphic));
td.render(documentMeasures());
});
},
});Subscribe once in onSession and every exit snapshot the session ever commits — a setGraphics removal, close(), setGraphics([]), or a click-away close — arrives here exactly once, never on abort. Calling td.render() from inside the handler is safe in both cases: a close batch is emitted after the interaction core has settled (the members are no longer excluded from reconcile), and a setGraphics removal batch is emitted after the removed members have already left the live membership. This is the recommended channel; the setGraphics return value and the promise resolution keep working unchanged.
session.onSettled(handler) is the lifecycle complement: it fires exactly once, whenever the session settles by any path — a commit onCommit already told you about, or an abort (session.abort(), Escape, preempt, destroy(), signal) that onCommit never sees. It fires after the close batch's onCommit and after td.activeTransformSession is already null, so a host that clears selection state or checks facade liveness from inside the handler sees it post-settle. Registering onSettled after the session has already settled still delivers — asynchronously, once — unlike onChange/onCommit.
The box itself is drawn dashed to read as a group selection rather than a single measure's, fitted around every member's combined rendered geometry. Each member also keeps its own thin outline underneath so you can see which graphics are included. Corner scale, the rotate grip, and body drag all apply rigidly to every member at once — there's no per-member reshaping while a group transform is live.
EditManyOptions is a leaner sibling of EditOptions: no modes (always the box), no guide (no rubber-band content for a group), and sizeAnchor applies group-wide rather than per-measure. onSession hands you a TransformSession — the group counterpart of EditSession, with dirty, close(), abort(), onChange / onCommit / onSettled (snapshot arrays keyed by measure.id), setGraphics(), and setSizeAnchor() in place of setOptions / setStyle (a group transform never touches per-kind options or style). The live session is also available as td.activeTransformSession.
A single-element array behaves like edit(measure, { modes: ["transform"] }) — no reshape handles, just the box.
syncTransformGraphics — one call for the whole sync path ​
A selection that changes over time (shift-click toggle, marquee replace) needs a host to branch on whether a transform is already live: setGraphics on the existing session, or a fresh editMany when idle. td.syncTransformGraphics collapses that branch, plus the two-site exit-snapshot fold, into one call and one subscription:
function setSelection(measures: ControlMeasure[]) {
td.syncTransformGraphics(measures, {
onSession: (session) => {
session.onCommit((snapshots) => {
snapshots.forEach((snapshot) => upsert(snapshot.graphic));
td.render(documentMeasures());
});
},
});
}
// Shift-click toggles just call setSelection with the next id set —
// no live-or-restart branch, no dual fold sites.
setSelection([alpha, bravo]);
setSelection([alpha]); // bravo's exit snapshot folds in via onCommit
setSelection([]); // closes the session; alpha's exit snapshot folds inAdd session.onSettled(...) in the same onSession callback to observe the session's lifecycle — including an Escape / preempt / destroy abort the host didn't itself trigger — without holding onto the editMany promise syncTransformGraphics already owns.
options (including onSession) is only consulted when syncTransformGraphics starts a fresh transform — on a live session it mutates membership in place, so session-scoped state (sizeAnchor, box angle, close-on-click-away policy) stays whatever the original editMany/syncTransformGraphics call set. Passing [] closes any live session and is a no-op while idle.