4.2 KiB
command-merger
(no submodules)
Purpose
Implements CommandMerger<T>, a utility for coalescing high-frequency user actions — such as rapid keystroke edits, slider movements, or drag operations — into a single undoable step. Commands are accumulated in a pending buffer and flushed into a single composite command when a count or time window closes, or when two consecutive commands fail the merge predicate.
The injectable Clock makes time-window behavior deterministic in tests without needing real timers or setTimeout. The default merge predicate merges consecutive commands with identical labels; callers can supply any custom predicate.
Features
add(command)— adds to buffer; returns merged command if window closes, elseundefinedflush()— immediately flushes buffer; returns merged command orundefinedif emptyreset()— discards pending commands without returning themcanMerge(a, b)— checks predicate without mutating state- Getters:
hasPending,pendingCount maxCountwindow — flushes after accumulating N commandstimeWindowMswindow — flushes when elapsed time exceeds threshold- Default predicate: merge consecutive commands with the same
label - Injectable
Clockfor deterministic tests - Options validated with
CommandMergerOptionsSchema
Usage: Basic Example
import { CommandMerger } from "@suaveplan/undo/merge/command-merger";
import { createCommand } from "@suaveplan/undo/command/command";
import { UndoStack } from "@suaveplan/undo/stack/stack";
interface Counter { value: number }
const stack = new UndoStack<Counter>({ value: 0 });
const merger = new CommandMerger<Counter>({ maxCount: 3 });
for (const delta of [1, 1, 1, 2]) {
const cmd = createCommand("Inc", (s) => ({ value: s.value + delta }), (s) => ({ value: s.value - delta }));
const flushed = merger.add(cmd);
if (flushed) stack.push(flushed);
}
// Flush remaining
const last = merger.flush();
if (last) stack.push(last);
Usage: Advanced Example
import { CommandMerger } from "@suaveplan/undo/merge/command-merger";
import { createCommand } from "@suaveplan/undo/command/command";
interface Doc { text: string }
let time = 0;
const clock = () => ++time;
// Custom predicate: only merge "Type" commands
const merger = new CommandMerger<Doc>(
{ timeWindowMs: 500, clock },
(a, b) => a.label === "Type" && b.label === "Type"
);
const typeChar = (ch: string) =>
createCommand<Doc>("Type", (d) => ({ text: d.text + ch }), (d) => ({ text: d.text.slice(0, -1) }));
merger.add(typeChar("H")); // pending
merger.add(typeChar("i")); // pending
time = 600; // advance clock past window
const flushed = merger.add(typeChar("!")); // triggers time-window flush
// flushed = merged "Hi" command; "!" starts new pending
const remaining = merger.flush(); // flushes "!"
API Reference
new CommandMerger<T>(options?, predicate?)
| Param | Type | Description |
|---|---|---|
options |
CommandMergerOptions |
maxCount, timeWindowMs, clock |
predicate |
MergePredicate<T> |
(a, b) => boolean — default: same label |
add(command) → Command<T> | undefined
Returns a flushed composite when: time window closes, count limit reached, or predicate fails for the new command against the last pending. Returns undefined when the command is accumulated.
flush() → Command<T> | undefined
Returns the merged command for all pending items, or undefined if nothing is pending.
reset() → void
Discards all pending commands.
canMerge(a, b) → boolean
Calls the merge predicate. Non-mutating.
Getters
hasPending—truewhen there are accumulated commandspendingCount— number of commands in the buffer
Implementation Notes
The _doFlush method returns the single command directly (without wrapping in composeCommands) when the buffer contains only one entry, avoiding unnecessary composite overhead. The time window is measured from the first add call after a flush. Non-mergeable commands trigger an implicit flush of the existing buffer before the new command starts a fresh buffer — so add can return the old buffer's merged command in this case.