@driftengine/tools 4.0.0

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.
@@ -0,0 +1,79 @@
1
+ import type { UiNode } from '@driftengine/ui2d';
2
+ import type { Command } from './command.ts';
3
+ /**
4
+ * What a panel is asked to deal with.
5
+ *
6
+ * Positions are in the panel's own space — the caller has already subtracted the dock site's
7
+ * origin — so a panel never knows where on screen it is, and a panel moved to another dock behaves
8
+ * identically. `button` is 0 for the primary; a context menu is 2, as every platform spells it.
9
+ */
10
+ export type UiEvent = {
11
+ readonly kind: 'pointer';
12
+ readonly phase: 'down' | 'move' | 'up';
13
+ readonly x: number;
14
+ readonly y: number;
15
+ readonly button: number;
16
+ } | {
17
+ readonly kind: 'key';
18
+ readonly key: string;
19
+ readonly shift: boolean;
20
+ readonly ctrl: boolean;
21
+ } | {
22
+ readonly kind: 'wheel';
23
+ readonly x: number;
24
+ readonly y: number;
25
+ readonly dx: number;
26
+ readonly dy: number;
27
+ };
28
+ export declare function pointerEvent(phase: 'down' | 'move' | 'up', x: number, y: number, button?: number): UiEvent;
29
+ export declare function keyEvent(key: string, shift?: boolean, ctrl?: boolean): UiEvent;
30
+ export declare function wheelEvent(x: number, y: number, dx: number, dy: number): UiEvent;
31
+ export interface Panel<W, V> {
32
+ /** Stable for the life of the panel. What the dock layout stores. */
33
+ readonly id: string;
34
+ /** What the dock tab says. */
35
+ readonly title: string;
36
+ /** Fill `root` from the world and the view. Called whenever either may have changed. */
37
+ build(world: Readonly<W>, view: V, root: UiNode): void;
38
+ /**
39
+ * What this event asks of the *world*, or nothing.
40
+ *
41
+ * May write `view` — a drag has to be recorded somewhere, and so do a scroll and a hover. May
42
+ * not write `world`, which is what the command it returns is for.
43
+ */
44
+ route(world: Readonly<W>, view: V, event: UiEvent): Command | null;
45
+ }
46
+ /** The prefix a panel root's name carries, so the dock can recognise one. */
47
+ export declare const PANEL_PREFIX = "panel:";
48
+ /**
49
+ * The node a panel builds into.
50
+ *
51
+ * The identifier lives on the root rather than in a map beside the tree, so the dock reads it off
52
+ * whatever node it is holding and there is nothing to keep in step. It takes the identifier alone
53
+ * rather than a `Panel<S>`, so a caller need not name the state type to make a root. `clip`,
54
+ * because a panel's content is
55
+ * routinely taller than its site, and a panel drawing outside its dock is the first thing anybody
56
+ * notices.
57
+ */
58
+ export declare function createPanelRoot(panel: {
59
+ readonly id: string;
60
+ }): UiNode;
61
+ /** Which panel a node is the root of, or null. */
62
+ export declare function panelIdOf(node: UiNode | null): string | null;
63
+ /**
64
+ * Replace a panel's contents with one line saying why there is nothing.
65
+ *
66
+ * Not interactive, because a message is not a control and a person who clicks it and gets a
67
+ * selection has learnt something untrue about the panel.
68
+ */
69
+ export declare function emptyPanel(root: UiNode, message: string): void;
70
+ /**
71
+ * A tree as a comparable string: structure, text, geometry and name, but never identity.
72
+ *
73
+ * **What it deliberately leaves out is object identity**, because a panel that reuses its pooled
74
+ * rows and one that rebuilt them are the same panel as far as the contract is concerned — the
75
+ * whole point of pooling is that nobody downstream can tell. What it deliberately includes is
76
+ * `hidden`, because a pooled row that is not showing is exactly how a panel shrinks, and two trees
77
+ * differing only there are showing different things.
78
+ */
79
+ export declare function treeShape(node: UiNode, depth?: number): string;
package/dist/panel.js ADDED
@@ -0,0 +1,90 @@
1
+ /**
2
+ * The shape every panel shares.
3
+ *
4
+ * **`route` is handed the world read-only and returns a command or nothing.** That one signature is
5
+ * what makes "a panel mutates only through commands" enforceable rather than aspirational: a panel
6
+ * physically cannot change the world, because it is given no way to. The undo stack is only as good
7
+ * as its worst offender, and this is how there is no worst offender.
8
+ *
9
+ * **The world and the view are two parameters, and the plan had them as one.** Writing the scene
10
+ * tree found out why they cannot be: a drag in progress, which row is hovered, how far the list is
11
+ * scrolled and which branches are open are all things `route` must write, and none of them belongs
12
+ * to the world. With one state object, "never mutates state" is either false or the panel cannot
13
+ * record a drag. Split, the claim is exact — `Readonly<W>` for what the world owns, `V` for what
14
+ * the panel owns — and the frozen-state test below is meaningful rather than true by construction.
15
+ * `Readonly` is shallow and so is not a proof; it is a statement of intent in the place a reader
16
+ * will look, which is the same standing every other rule in this repository has.
17
+ *
18
+ * **`build` is a function of both.** Building twice over the same world and view reaches the same
19
+ * tree, so a panel can be rebuilt whenever anything changes without anybody reasoning about what it
20
+ * was showing before. `treeShape` is how a test says that, and every panel's test uses it.
21
+ *
22
+ * **Nothing to show is a state and not a failure.** A panel with no selection, an asset browser
23
+ * before the project has loaded and a profiler before the first frame all reach `emptyPanel`. An
24
+ * empty dock with an exception behind it is the least useful thing an editor can show.
25
+ */
26
+ import { addUiChild, createUiNode } from '@driftengine/ui2d';
27
+ export function pointerEvent(phase, x, y, button = 0) {
28
+ return { kind: 'pointer', phase, x, y, button };
29
+ }
30
+ export function keyEvent(key, shift = false, ctrl = false) {
31
+ return { kind: 'key', key, shift, ctrl };
32
+ }
33
+ export function wheelEvent(x, y, dx, dy) {
34
+ return { kind: 'wheel', x, y, dx, dy };
35
+ }
36
+ /** The prefix a panel root's name carries, so the dock can recognise one. */
37
+ export const PANEL_PREFIX = 'panel:';
38
+ /**
39
+ * The node a panel builds into.
40
+ *
41
+ * The identifier lives on the root rather than in a map beside the tree, so the dock reads it off
42
+ * whatever node it is holding and there is nothing to keep in step. It takes the identifier alone
43
+ * rather than a `Panel<S>`, so a caller need not name the state type to make a root. `clip`,
44
+ * because a panel's content is
45
+ * routinely taller than its site, and a panel drawing outside its dock is the first thing anybody
46
+ * notices.
47
+ */
48
+ export function createPanelRoot(panel) {
49
+ return createUiNode({
50
+ direction: 'column',
51
+ width: 'grow',
52
+ height: 'grow',
53
+ clip: true,
54
+ name: `${PANEL_PREFIX}${panel.id}`,
55
+ });
56
+ }
57
+ /** Which panel a node is the root of, or null. */
58
+ export function panelIdOf(node) {
59
+ if (node === null || !node.name.startsWith(PANEL_PREFIX))
60
+ return null;
61
+ const id = node.name.slice(PANEL_PREFIX.length);
62
+ return id.length > 0 ? id : null;
63
+ }
64
+ /**
65
+ * Replace a panel's contents with one line saying why there is nothing.
66
+ *
67
+ * Not interactive, because a message is not a control and a person who clicks it and gets a
68
+ * selection has learnt something untrue about the panel.
69
+ */
70
+ export function emptyPanel(root, message) {
71
+ root.children.length = 0;
72
+ addUiChild(root, createUiNode({ width: 'grow', height: 18, text: message, name: 'empty', interactive: false }));
73
+ }
74
+ /**
75
+ * A tree as a comparable string: structure, text, geometry and name, but never identity.
76
+ *
77
+ * **What it deliberately leaves out is object identity**, because a panel that reuses its pooled
78
+ * rows and one that rebuilt them are the same panel as far as the contract is concerned — the
79
+ * whole point of pooling is that nobody downstream can tell. What it deliberately includes is
80
+ * `hidden`, because a pooled row that is not showing is exactly how a panel shrinks, and two trees
81
+ * differing only there are showing different things.
82
+ */
83
+ export function treeShape(node, depth = 0) {
84
+ const pad = ' '.repeat(depth);
85
+ const size = `${String(node.width)}x${String(node.height)}`;
86
+ const flags = `${node.hidden ? 'h' : '-'}${node.interactive ? 'i' : '-'}${node.clip ? 'c' : '-'}`;
87
+ const own = `${pad}${node.name || '(unnamed)'} ${size} ${flags} ${node.layer} ${JSON.stringify(node.text)}`;
88
+ const children = node.children.map((child) => treeShape(child, depth + 1));
89
+ return [own, ...children].join('\n');
90
+ }
@@ -0,0 +1,56 @@
1
+ import type { UiNode } from '@driftengine/ui2d';
2
+ import { type PassTimings } from '@driftengine/core';
3
+ import { type ResidencyTable } from '@driftengine/texture';
4
+ import { type Panel } from './panel.ts';
5
+ export interface ProfilerRow {
6
+ label: string;
7
+ /** Milliseconds, or null where nothing measured this pass. Null is not zero. */
8
+ ms: number | null;
9
+ }
10
+ /** `—` for unmeasured. Two decimals, because a pass under a hundredth is not the one to look at. */
11
+ export declare function formatMs(ms: number | null): string;
12
+ /**
13
+ * One row per *labelled* pass, oldest index first, written into `out`.
14
+ *
15
+ * A pass with no label was never part of this frame's composition, so it is not a row — as against
16
+ * a pass that has a label and no sample, which is a row reading `—`.
17
+ */
18
+ export declare function profilerRows(timings: PassTimings, out: ProfilerRow[]): ProfilerRow[];
19
+ /**
20
+ * A fixed window of frame times, oldest first.
21
+ *
22
+ * A ring rather than a growing array, because this is pushed once a frame forever and the window
23
+ * is the only part anybody looks at.
24
+ */
25
+ export interface FrameHistory {
26
+ readonly ms: Float64Array;
27
+ /** Where the next frame goes. */
28
+ head: number;
29
+ /** How many of `ms` are live, up to its length. */
30
+ filled: number;
31
+ }
32
+ export declare function createFrameHistory(capacity: number): FrameHistory;
33
+ export declare function pushFrame(history: FrameHistory, ms: number): void;
34
+ /** The window, oldest first. Allocates, so it is for a reader rather than for a frame. */
35
+ export declare function historyValues(history: FrameHistory): number[];
36
+ /**
37
+ * How full the page cache is, or null where there is no cache to be full.
38
+ *
39
+ * **Null rather than zero for a capacity of nothing.** A cache that is not there is not an empty
40
+ * cache, and a bar reading 0% would say the streamer had failed rather than that it was absent.
41
+ */
42
+ export declare function occupancy(table: ResidencyTable | null): number | null;
43
+ export interface ProfilerWorld {
44
+ readonly timings: PassTimings;
45
+ readonly history: FrameHistory;
46
+ readonly residency: ResidencyTable | null;
47
+ }
48
+ export interface ProfilerView {
49
+ readonly root: UiNode;
50
+ rows: ProfilerRow[];
51
+ rowHeight: number;
52
+ }
53
+ export declare function createProfilerView(options: {
54
+ rowHeight?: number;
55
+ }): ProfilerView;
56
+ export declare const profilerPanel: Panel<ProfilerWorld, ProfilerView>;
@@ -0,0 +1,142 @@
1
+ /**
2
+ * Where the frame's milliseconds went, per pass.
3
+ *
4
+ * **Unmeasured is not zero, and this panel is the reason Wave 1A drew that line.** `passMs` returns
5
+ * null where nothing sampled a pass, and this renders it as `—`. A profiler that showed 0.00 there
6
+ * could not be told apart from a pass that genuinely ran in no time, and the person reading it
7
+ * would go and optimise a pass that never ran. A *measured* zero still reads as `0.00`, because
8
+ * that one is a fact.
9
+ *
10
+ * **A pass that did nothing this frame keeps its row.** That is what Wave 1A's labels persisting
11
+ * while its timings reset buys: the rows stop jumping about as quality settings toggle passes on
12
+ * and off, which is exactly when somebody is reading them.
13
+ *
14
+ * **Nothing here allocates on a rebuild.** This panel is rebuilt every frame by definition, so the
15
+ * rows and the nodes are both pooled and refilled; a profiler that allocated would be reporting on
16
+ * a frame it was making worse.
17
+ */
18
+ import { addUiChild, createUiNode } from '@driftengine/ui2d';
19
+ import { passLabel, passMs } from '@driftengine/core';
20
+ import { residentCount } from '@driftengine/texture';
21
+ import { createPanelRoot, emptyPanel } from './panel.js';
22
+ /** `—` for unmeasured. Two decimals, because a pass under a hundredth is not the one to look at. */
23
+ export function formatMs(ms) {
24
+ return ms === null ? '—' : ms.toFixed(2);
25
+ }
26
+ /**
27
+ * One row per *labelled* pass, oldest index first, written into `out`.
28
+ *
29
+ * A pass with no label was never part of this frame's composition, so it is not a row — as against
30
+ * a pass that has a label and no sample, which is a row reading `—`.
31
+ */
32
+ export function profilerRows(timings, out) {
33
+ let at = 0;
34
+ for (let pass = 0; pass < timings.labels.length; pass += 1) {
35
+ const label = passLabel(timings, pass);
36
+ if (label === '')
37
+ continue;
38
+ const row = out[at] ?? { label: '', ms: null };
39
+ row.label = label;
40
+ row.ms = passMs(timings, pass);
41
+ out[at] = row;
42
+ at += 1;
43
+ }
44
+ out.length = at;
45
+ return out;
46
+ }
47
+ export function createFrameHistory(capacity) {
48
+ return { ms: new Float64Array(Math.max(0, capacity)), head: 0, filled: 0 };
49
+ }
50
+ export function pushFrame(history, ms) {
51
+ const capacity = history.ms.length;
52
+ if (capacity === 0)
53
+ return;
54
+ history.ms[history.head] = ms;
55
+ history.head = (history.head + 1) % capacity;
56
+ if (history.filled < capacity)
57
+ history.filled += 1;
58
+ }
59
+ /** The window, oldest first. Allocates, so it is for a reader rather than for a frame. */
60
+ export function historyValues(history) {
61
+ const capacity = history.ms.length;
62
+ const out = [];
63
+ const first = history.filled < capacity ? 0 : history.head;
64
+ for (let at = 0; at < history.filled; at += 1) {
65
+ out.push(history.ms[(first + at) % capacity]);
66
+ }
67
+ return out;
68
+ }
69
+ /**
70
+ * How full the page cache is, or null where there is no cache to be full.
71
+ *
72
+ * **Null rather than zero for a capacity of nothing.** A cache that is not there is not an empty
73
+ * cache, and a bar reading 0% would say the streamer had failed rather than that it was absent.
74
+ */
75
+ export function occupancy(table) {
76
+ if (table === null || table.capacity <= 0)
77
+ return null;
78
+ return residentCount(table) / table.capacity;
79
+ }
80
+ export function createProfilerView(options) {
81
+ return {
82
+ root: createPanelRoot(profilerPanel),
83
+ rows: [],
84
+ rowHeight: options.rowHeight ?? 18,
85
+ };
86
+ }
87
+ /** A pooled row, shown; rows past the end are hidden rather than removed, so the pool survives. */
88
+ function lineAt(root, index, text, height) {
89
+ const existing = root.children[index];
90
+ if (existing !== undefined) {
91
+ existing.hidden = false;
92
+ existing.text = text;
93
+ return;
94
+ }
95
+ addUiChild(root, createUiNode({ width: 'grow', height, text, interactive: false, name: `line:${index}` }));
96
+ }
97
+ function hideFrom(root, index) {
98
+ for (let at = index; at < root.children.length; at += 1) {
99
+ root.children[at].hidden = true;
100
+ }
101
+ }
102
+ /** The mean of the window, which is the number worth showing beside the current frame. */
103
+ function meanOf(history) {
104
+ if (history.filled === 0)
105
+ return null;
106
+ let total = 0;
107
+ for (let at = 0; at < history.filled; at += 1)
108
+ total += history.ms[at];
109
+ return total / history.filled;
110
+ }
111
+ export const profilerPanel = {
112
+ id: 'profiler',
113
+ title: 'Profiler',
114
+ build(world, view, root) {
115
+ view.rows = profilerRows(world.timings, view.rows);
116
+ /*
117
+ * **Nothing at all, rather than no *passes*.** A host with no device still knows how long its
118
+ * frames took, and this returned before the frame line was ever added — so a panel holding
119
+ * sixty samples said `No timings yet`. Reporting nothing where there is something is the same
120
+ * fault as reporting a zero where there is nothing, which the row formatter exists to avoid.
121
+ */
122
+ if (view.rows.length === 0 && world.history.filled === 0) {
123
+ emptyPanel(root, 'No timings yet');
124
+ return;
125
+ }
126
+ let at = 0;
127
+ for (const row of view.rows) {
128
+ lineAt(root, at, `${row.label} ${formatMs(row.ms)}`, view.rowHeight);
129
+ at += 1;
130
+ }
131
+ lineAt(root, at, `frame ${formatMs(meanOf(world.history))} mean of ${world.history.filled}`, view.rowHeight);
132
+ at += 1;
133
+ const full = occupancy(world.residency);
134
+ lineAt(root, at, `residency ${full === null ? '—' : `${(full * 100).toFixed(0)}%`}`, view.rowHeight);
135
+ at += 1;
136
+ hideFrom(root, at);
137
+ },
138
+ route() {
139
+ /* A profiler reads. There is nothing here a person could ask the world to change. */
140
+ return null;
141
+ },
142
+ };
@@ -0,0 +1,29 @@
1
+ /**
2
+ * What is selected, which of it is primary, and in what order.
3
+ *
4
+ * **Order is kept, and that is not cosmetic.** A multi-entity gizmo pivots on the primary
5
+ * selection; if the order drifts between frames the pivot jumps, and a drag that started around one
6
+ * object finishes around another. Insertion order with the primary as the most recent addition is
7
+ * both what a person expects and what keeps the pivot still.
8
+ */
9
+ export interface Selection {
10
+ /** Selected entities, in the order they were added. */
11
+ entities: number[];
12
+ }
13
+ export declare function createSelection(): Selection;
14
+ export declare function selectOnly(selection: Selection, entity: number): void;
15
+ export declare function addToSelection(selection: Selection, entity: number): void;
16
+ export declare function toggleSelection(selection: Selection, entity: number): void;
17
+ /**
18
+ * Select everything between two entities in a caller-supplied order.
19
+ *
20
+ * The order is the caller's because only the caller knows it — a scene tree's order is its own
21
+ * flattening, and an asset browser's is its filter and sort. Inclusive at both ends, and the same
22
+ * set whichever end is given first.
23
+ */
24
+ export declare function selectRange(selection: Selection, from: number, to: number, order: readonly number[]): void;
25
+ export declare function clearSelection(selection: Selection): void;
26
+ export declare function selectedEntities(selection: Selection): readonly number[];
27
+ export declare function isSelected(selection: Selection, entity: number): boolean;
28
+ /** The most recently added, which is what an inspector shows and what a gizmo pivots on. */
29
+ export declare function primarySelection(selection: Selection): number | null;
@@ -0,0 +1,52 @@
1
+ export function createSelection() {
2
+ return { entities: [] };
3
+ }
4
+ export function selectOnly(selection, entity) {
5
+ selection.entities.length = 0;
6
+ selection.entities.push(entity);
7
+ }
8
+ export function addToSelection(selection, entity) {
9
+ if (selection.entities.includes(entity))
10
+ return;
11
+ selection.entities.push(entity);
12
+ }
13
+ export function toggleSelection(selection, entity) {
14
+ const at = selection.entities.indexOf(entity);
15
+ if (at === -1)
16
+ selection.entities.push(entity);
17
+ else
18
+ selection.entities.splice(at, 1);
19
+ }
20
+ /**
21
+ * Select everything between two entities in a caller-supplied order.
22
+ *
23
+ * The order is the caller's because only the caller knows it — a scene tree's order is its own
24
+ * flattening, and an asset browser's is its filter and sort. Inclusive at both ends, and the same
25
+ * set whichever end is given first.
26
+ */
27
+ export function selectRange(selection, from, to, order) {
28
+ const a = order.indexOf(from);
29
+ const b = order.indexOf(to);
30
+ if (a === -1 || b === -1)
31
+ return;
32
+ const lo = Math.min(a, b);
33
+ const hi = Math.max(a, b);
34
+ selection.entities.length = 0;
35
+ for (let i = lo; i <= hi; i += 1)
36
+ selection.entities.push(order[i]);
37
+ }
38
+ export function clearSelection(selection) {
39
+ selection.entities.length = 0;
40
+ }
41
+ export function selectedEntities(selection) {
42
+ return selection.entities;
43
+ }
44
+ export function isSelected(selection, entity) {
45
+ return selection.entities.includes(entity);
46
+ }
47
+ /** The most recently added, which is what an inspector shows and what a gizmo pivots on. */
48
+ export function primarySelection(selection) {
49
+ return selection.entities.length === 0
50
+ ? null
51
+ : selection.entities[selection.entities.length - 1];
52
+ }
package/package.json ADDED
@@ -0,0 +1,63 @@
1
+ {
2
+ "name": "@driftengine/tools",
3
+ "version": "4.0.0",
4
+ "description": "The editor panels a shipped game can carry, and the command stack that makes their edits undoable",
5
+ "license": "Apache-2.0",
6
+ "type": "module",
7
+ "main": "./dist/index.js",
8
+ "types": "./dist/index.d.ts",
9
+ "exports": {
10
+ ".": {
11
+ "drift-source": "./src/index.ts",
12
+ "types": "./dist/index.d.ts",
13
+ "default": "./dist/index.js"
14
+ },
15
+ "./package.json": "./package.json",
16
+ "./*": "./*"
17
+ },
18
+ "files": [
19
+ "dist",
20
+ "src",
21
+ "!src/**/*.test.ts",
22
+ "!src/**/*.test.mjs",
23
+ "!src/**/__snapshots__",
24
+ "README.md",
25
+ "LICENSE",
26
+ "NOTICE"
27
+ ],
28
+ "sideEffects": false,
29
+ "peerDependencies": {
30
+ "@driftengine/core": "4.0.0",
31
+ "@driftengine/editor": "4.0.0",
32
+ "@driftengine/network": "4.0.0",
33
+ "@driftengine/texture": "4.0.0",
34
+ "@driftengine/ui2d": "4.0.0"
35
+ },
36
+ "author": "Drift Technologies",
37
+ "repository": {
38
+ "type": "git",
39
+ "url": "git+https://github.com/drftrun/driftengine.git",
40
+ "directory": "packages/tools"
41
+ },
42
+ "homepage": "https://github.com/drftrun/driftengine#readme",
43
+ "bugs": "https://github.com/drftrun/driftengine/issues",
44
+ "keywords": [
45
+ "driftengine",
46
+ "3d",
47
+ "webgl",
48
+ "webgpu",
49
+ "typescript",
50
+ "editor",
51
+ "tools",
52
+ "debugging"
53
+ ],
54
+ "engines": {
55
+ "node": ">=22.12.0"
56
+ },
57
+ "publishConfig": {
58
+ "access": "public"
59
+ },
60
+ "scripts": {
61
+ "prepack": "node ../../scripts/build.mjs @driftengine/tools"
62
+ }
63
+ }
package/src/command.ts ADDED
@@ -0,0 +1,103 @@
1
+ /**
2
+ * Every change to the world, as an object that can be undone.
3
+ *
4
+ * **Built before any panel exists, and that order is the point.** An undo stack added after the
5
+ * panels is an undo stack with holes in it, and the holes are found by users rather than by tests:
6
+ * one panel mutated directly, once, and now a particular sequence of actions cannot be taken back.
7
+ * Built first, every panel is written against it.
8
+ *
9
+ * **A panel is never handed the world.** `Panel.route` returns a `Command` or nothing, so a panel
10
+ * physically cannot change anything — the rule is enforced by the signature rather than by review.
11
+ *
12
+ * **Merging is what makes a drag usable.** A gizmo emits a command per frame; sixty of them in the
13
+ * undo stack means sixty presses of undo to take back one movement. `merge` lets a command absorb
14
+ * the next of its kind, so a drag is one entry.
15
+ */
16
+ export interface Command {
17
+ /** Shown in the undo menu and in the palette. */
18
+ readonly label: string;
19
+ apply(): void;
20
+ revert(): void;
21
+ /**
22
+ * Absorb a following command of the same kind. Return true if it was absorbed.
23
+ *
24
+ * **Called after the stack has already applied `next`, and an implementation must not apply it
25
+ * again.** Apply happens exactly once, by the stack, for every command — the alternative splits
26
+ * that responsibility between the stack and each mergeable command, and the first implementation
27
+ * of this got it wrong in exactly that way: the change ran twice and a drag moved twice as far
28
+ * as the pointer.
29
+ *
30
+ * What absorbing means is that the *earlier* command's `revert` now takes back both.
31
+ */
32
+ merge?(next: Command): boolean;
33
+ }
34
+
35
+ export interface UndoStack {
36
+ push(command: Command): void;
37
+ undo(): boolean;
38
+ redo(): boolean;
39
+ canUndo(): boolean;
40
+ canRedo(): boolean;
41
+ undoLabel(): string | null;
42
+ redoLabel(): string | null;
43
+ clear(): void;
44
+ }
45
+
46
+ export function createUndoStack(limit: number): UndoStack {
47
+ /* Entries below `at` are done; entries from `at` up are the redo branch. */
48
+ let entries: Command[] = [];
49
+ let at = 0;
50
+
51
+ return {
52
+ push(command: Command): void {
53
+ command.apply();
54
+
55
+ const previous = at > 0 ? entries[at - 1] : undefined;
56
+ if (previous?.merge !== undefined && previous.merge(command)) {
57
+ /*
58
+ * Absorbed. `command` has already been applied above and must not be applied again; what
59
+ * changed is that `previous.revert` now takes back both. The redo branch still dies.
60
+ */
61
+ entries.length = at;
62
+ return;
63
+ }
64
+
65
+ /* A new command after an undo discards the redo branch, which is what every editor does. */
66
+ entries.length = at;
67
+ entries.push(command);
68
+ at += 1;
69
+
70
+ if (entries.length > limit) {
71
+ /*
72
+ * The oldest is forgotten, never reverted. Reverting it on the way out would undo a change
73
+ * the user made and kept, which is the ring-buffer bug this comment exists to prevent.
74
+ */
75
+ entries = entries.slice(entries.length - limit);
76
+ at = entries.length;
77
+ }
78
+ },
79
+
80
+ undo(): boolean {
81
+ if (at === 0) return false;
82
+ at -= 1;
83
+ (entries[at] as Command).revert();
84
+ return true;
85
+ },
86
+
87
+ redo(): boolean {
88
+ if (at >= entries.length) return false;
89
+ (entries[at] as Command).apply();
90
+ at += 1;
91
+ return true;
92
+ },
93
+
94
+ canUndo: () => at > 0,
95
+ canRedo: () => at < entries.length,
96
+ undoLabel: () => (at > 0 ? (entries[at - 1] as Command).label : null),
97
+ redoLabel: () => (at < entries.length ? (entries[at] as Command).label : null),
98
+ clear(): void {
99
+ entries = [];
100
+ at = 0;
101
+ },
102
+ };
103
+ }