wenay-react2 1.0.32 → 1.0.34
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/lib/common/api.d.ts +1 -0
- package/lib/common/api.js +3 -0
- package/lib/common/src/components/Toolbar/Toolbar.d.ts +105 -0
- package/lib/common/src/components/Toolbar/Toolbar.js +210 -0
- package/lib/common/src/components/Toolbar/index.d.ts +1 -0
- package/lib/common/src/components/Toolbar/index.js +1 -0
- package/lib/common/src/hooks/index.d.ts +2 -0
- package/lib/common/src/hooks/index.js +2 -0
- package/lib/common/src/hooks/useReorder.d.ts +49 -0
- package/lib/common/src/hooks/useReorder.js +134 -0
- package/lib/common/src/hooks/useReorderBoard.d.ts +61 -0
- package/lib/common/src/hooks/useReorderBoard.js +232 -0
- package/lib/common/src/hooks/useReplay.d.ts +81 -0
- package/lib/common/src/hooks/useReplay.js +175 -16
- package/lib/common/src/styles/tokens.d.ts +17 -0
- package/lib/common/src/styles/tokens.js +17 -0
- package/lib/style/style.css +128 -0
- package/lib/style/tokens.css +14 -0
- package/package.json +2 -2
|
@@ -0,0 +1,232 @@
|
|
|
1
|
+
import { useEffect, useRef, useState } from 'react';
|
|
2
|
+
import { useDraggableApi } from './useDraggable';
|
|
3
|
+
export function useReorderBoard(o) {
|
|
4
|
+
const oRef = useRef(o);
|
|
5
|
+
oRef.current = o;
|
|
6
|
+
const colEls = useRef(new Map());
|
|
7
|
+
const refCbs = useRef(new Map());
|
|
8
|
+
const [dragKey, setDragKey] = useState(null);
|
|
9
|
+
const geom = useRef(null);
|
|
10
|
+
const measureCache = useRef(null);
|
|
11
|
+
/** stable callback ref for a column div; columns can be added at runtime */
|
|
12
|
+
function columnRef(col) {
|
|
13
|
+
let cb = refCbs.current.get(col);
|
|
14
|
+
if (!cb) {
|
|
15
|
+
cb = el => { el ? colEls.current.set(col, el) : colEls.current.delete(col); };
|
|
16
|
+
refCbs.current.set(col, cb);
|
|
17
|
+
}
|
|
18
|
+
return cb;
|
|
19
|
+
}
|
|
20
|
+
const local = (v) => v / (geom.current?.scale ?? 1);
|
|
21
|
+
/** Nearest column by clamped distance to its rect, then the insertion index
|
|
22
|
+
* = how many of that column's items (start centers, excluding the dragged
|
|
23
|
+
* one) sit above the dragged center. Targeting always runs against the
|
|
24
|
+
* geometry measured at drag START (anti-oscillation, same as useReorder). */
|
|
25
|
+
function dragTarget(dx, dy) {
|
|
26
|
+
const g = geom.current;
|
|
27
|
+
if (!g)
|
|
28
|
+
return { col: '', index: 0 };
|
|
29
|
+
const cx = g.draggedCenter.x + dx, cy = g.draggedCenter.y + dy;
|
|
30
|
+
let best = null, bestD = Infinity;
|
|
31
|
+
for (const [col, r] of g.colRect) {
|
|
32
|
+
const px = Math.max(r.x, Math.min(r.x + r.w, cx));
|
|
33
|
+
const py = Math.max(r.y, Math.min(r.y + r.h, cy));
|
|
34
|
+
const d = (px - cx) ** 2 + (py - cy) ** 2;
|
|
35
|
+
if (d < bestD) {
|
|
36
|
+
bestD = d;
|
|
37
|
+
best = col;
|
|
38
|
+
}
|
|
39
|
+
}
|
|
40
|
+
if (best == null)
|
|
41
|
+
return g.from;
|
|
42
|
+
let index = 0;
|
|
43
|
+
for (const c of geom.current.centers.get(best) ?? [])
|
|
44
|
+
if (c.key != dragKey && c.y < cy)
|
|
45
|
+
index++;
|
|
46
|
+
return { col: best, index };
|
|
47
|
+
}
|
|
48
|
+
/** Simulated commit: the dragged key removed from its column and inserted at
|
|
49
|
+
* over - preview and drop share this, so they agree by construction. */
|
|
50
|
+
function movedColumns(key, over) {
|
|
51
|
+
return oRef.current.columns.map(c => {
|
|
52
|
+
let items = c.items.filter(k => k != key);
|
|
53
|
+
if (c.key == over.col) {
|
|
54
|
+
const i = Math.max(0, Math.min(items.length, over.index));
|
|
55
|
+
items = items.slice(0, i).concat(key, items.slice(i));
|
|
56
|
+
}
|
|
57
|
+
return { key: c.key, items };
|
|
58
|
+
});
|
|
59
|
+
}
|
|
60
|
+
/** FLIP across columns, all offset-based (transform/transition-immune, see
|
|
61
|
+
* useReorder): the dragged block leaves flow via display:none, survivors get
|
|
62
|
+
* their preview CSS `order`, and the landing slot becomes a real margin gap
|
|
63
|
+
* (draggedHeight + row-gap) on the neighbour - so the column's own CSS
|
|
64
|
+
* gravity decides who moves aside (flex-end pushes the blocks ABOVE up).
|
|
65
|
+
* One synchronous apply-read-revert, cached per target slot. */
|
|
66
|
+
function measured(key, over) {
|
|
67
|
+
const cacheKey = over.col + '#' + over.index;
|
|
68
|
+
if (measureCache.current?.key == cacheKey)
|
|
69
|
+
return measureCache.current.pos;
|
|
70
|
+
const g = geom.current;
|
|
71
|
+
const keyToEl = new Map();
|
|
72
|
+
for (const c of oRef.current.columns) {
|
|
73
|
+
const el = colEls.current.get(c.key);
|
|
74
|
+
if (!el)
|
|
75
|
+
continue;
|
|
76
|
+
const kids = el.children;
|
|
77
|
+
c.items.forEach((k, i) => { const kid = kids[i]; if (kid)
|
|
78
|
+
keyToEl.set(k, kid); });
|
|
79
|
+
}
|
|
80
|
+
const preview = movedColumns(key, over);
|
|
81
|
+
const saved = [];
|
|
82
|
+
const save = (el) => saved.push({ el, order: el.style.order, display: el.style.display, mt: el.style.marginTop, mb: el.style.marginBottom });
|
|
83
|
+
for (const pc of preview) {
|
|
84
|
+
pc.items.forEach((k, i) => {
|
|
85
|
+
const el = keyToEl.get(k);
|
|
86
|
+
if (!el)
|
|
87
|
+
return;
|
|
88
|
+
save(el);
|
|
89
|
+
if (k == key)
|
|
90
|
+
el.style.display = 'none';
|
|
91
|
+
else
|
|
92
|
+
el.style.order = String(i);
|
|
93
|
+
});
|
|
94
|
+
}
|
|
95
|
+
const target = preview.find(c => c.key == over.col);
|
|
96
|
+
if (target) {
|
|
97
|
+
const gap = g.draggedSize.h + (g.rowGap.get(over.col) ?? 0);
|
|
98
|
+
const afterEl = keyToEl.get(target.items[over.index + 1] ?? '');
|
|
99
|
+
const beforeEl = keyToEl.get(target.items[over.index - 1] ?? '');
|
|
100
|
+
if (afterEl)
|
|
101
|
+
afterEl.style.marginTop = gap + 'px';
|
|
102
|
+
else if (beforeEl)
|
|
103
|
+
beforeEl.style.marginBottom = gap + 'px';
|
|
104
|
+
}
|
|
105
|
+
const pos = new Map();
|
|
106
|
+
for (const c of oRef.current.columns)
|
|
107
|
+
for (const k of c.items) {
|
|
108
|
+
if (k == key)
|
|
109
|
+
continue;
|
|
110
|
+
const el = keyToEl.get(k);
|
|
111
|
+
if (el)
|
|
112
|
+
pos.set(k, { x: el.offsetLeft, y: el.offsetTop });
|
|
113
|
+
}
|
|
114
|
+
saved.reverse().forEach(s => { s.el.style.order = s.order; s.el.style.display = s.display; s.el.style.marginTop = s.mt; s.el.style.marginBottom = s.mb; });
|
|
115
|
+
measureCache.current = { key: cacheKey, pos };
|
|
116
|
+
return pos;
|
|
117
|
+
}
|
|
118
|
+
const drag = useDraggableApi({ holdMs: o.holdMs ?? 0, onDragEnd: function commitBoard(final) {
|
|
119
|
+
const key = dragKey;
|
|
120
|
+
setDragKey(null);
|
|
121
|
+
measureCache.current = null;
|
|
122
|
+
const g = geom.current;
|
|
123
|
+
geom.current = null;
|
|
124
|
+
if (key == null || !g)
|
|
125
|
+
return;
|
|
126
|
+
geom.current = g; // dragTarget/local need it for this last computation
|
|
127
|
+
const over = dragTarget(local(final.x), local(final.y));
|
|
128
|
+
const next = movedColumns(key, over);
|
|
129
|
+
const cur = oRef.current.columns;
|
|
130
|
+
const committed = next.some((c, i) => c.items.length != cur[i].items.length || c.items.some((k, j) => k != cur[i].items[j]));
|
|
131
|
+
geom.current = null;
|
|
132
|
+
if (committed)
|
|
133
|
+
oRef.current.commit(next);
|
|
134
|
+
oRef.current.onDragEnd?.({ key, from: g.from, over, committed });
|
|
135
|
+
} });
|
|
136
|
+
function beginDrag(key, e) {
|
|
137
|
+
if (oRef.current.canDrag && !oRef.current.canDrag(key))
|
|
138
|
+
return false;
|
|
139
|
+
if (e.target.closest('input, button, select, textarea, a'))
|
|
140
|
+
return false;
|
|
141
|
+
const cols = oRef.current.columns;
|
|
142
|
+
let from = null;
|
|
143
|
+
for (const c of cols) {
|
|
144
|
+
const i = c.items.indexOf(key);
|
|
145
|
+
if (i != -1) {
|
|
146
|
+
from = { col: c.key, index: i };
|
|
147
|
+
break;
|
|
148
|
+
}
|
|
149
|
+
}
|
|
150
|
+
const fromEl = from && colEls.current.get(from.col);
|
|
151
|
+
if (!from || !fromEl)
|
|
152
|
+
return false;
|
|
153
|
+
const scale = fromEl.offsetWidth ? fromEl.getBoundingClientRect().width / fromEl.offsetWidth : 1;
|
|
154
|
+
const g = { scale, from, colRect: new Map(), centers: new Map(), startOffset: new Map(), rowGap: new Map(), draggedCenter: { x: 0, y: 0 }, draggedSize: { w: 0, h: 0 } };
|
|
155
|
+
for (const c of cols) {
|
|
156
|
+
const el = colEls.current.get(c.key);
|
|
157
|
+
if (!el)
|
|
158
|
+
continue;
|
|
159
|
+
const r = el.getBoundingClientRect();
|
|
160
|
+
g.colRect.set(c.key, { x: r.x / scale, y: r.y / scale, w: r.width / scale, h: r.height / scale });
|
|
161
|
+
g.rowGap.set(c.key, parseFloat(getComputedStyle(el).rowGap) || 0);
|
|
162
|
+
const kids = el.children;
|
|
163
|
+
const cs = [];
|
|
164
|
+
c.items.forEach((k, i) => {
|
|
165
|
+
const kid = kids[i];
|
|
166
|
+
if (!kid)
|
|
167
|
+
return;
|
|
168
|
+
const kr = kid.getBoundingClientRect();
|
|
169
|
+
cs.push({ key: k, y: (kr.y + kr.height / 2) / scale });
|
|
170
|
+
g.startOffset.set(k, { x: kid.offsetLeft, y: kid.offsetTop });
|
|
171
|
+
if (k == key) {
|
|
172
|
+
g.draggedCenter = { x: (kr.x + kr.width / 2) / scale, y: (kr.y + kr.height / 2) / scale };
|
|
173
|
+
g.draggedSize = { w: kid.offsetWidth, h: kid.offsetHeight };
|
|
174
|
+
}
|
|
175
|
+
});
|
|
176
|
+
g.centers.set(c.key, cs);
|
|
177
|
+
}
|
|
178
|
+
geom.current = g;
|
|
179
|
+
measureCache.current = null;
|
|
180
|
+
setDragKey(key);
|
|
181
|
+
oRef.current.onDragStart?.({ key, from });
|
|
182
|
+
return true;
|
|
183
|
+
}
|
|
184
|
+
const over = dragKey != null && geom.current
|
|
185
|
+
? dragTarget(local(drag.position.x), local(drag.position.y)) : null;
|
|
186
|
+
// callbacks ride effects (post-render), reading fresh options via oRef
|
|
187
|
+
const prevOverRef = useRef(null);
|
|
188
|
+
useEffect(() => {
|
|
189
|
+
if (dragKey == null || !over) {
|
|
190
|
+
prevOverRef.current = null;
|
|
191
|
+
return;
|
|
192
|
+
}
|
|
193
|
+
const prev = prevOverRef.current;
|
|
194
|
+
if (!prev || prev.col != over.col || prev.index != over.index) {
|
|
195
|
+
oRef.current.onOverChange?.({ key: dragKey, over, prev });
|
|
196
|
+
prevOverRef.current = { ...over };
|
|
197
|
+
}
|
|
198
|
+
});
|
|
199
|
+
const px = dragKey != null ? drag.position.x : 0, py = dragKey != null ? drag.position.y : 0;
|
|
200
|
+
useEffect(() => {
|
|
201
|
+
if (dragKey != null && over)
|
|
202
|
+
oRef.current.onDragMove?.({ key: dragKey, over, position: { x: px, y: py } });
|
|
203
|
+
}, [dragKey, px, py]); // eslint-disable-line react-hooks/exhaustive-deps
|
|
204
|
+
function item(key) {
|
|
205
|
+
const active = dragKey != null;
|
|
206
|
+
const dragging = key == dragKey;
|
|
207
|
+
let style;
|
|
208
|
+
if (active && geom.current && over) {
|
|
209
|
+
if (dragging) {
|
|
210
|
+
style = { transform: `translate(${local(drag.position.x)}px, ${local(drag.position.y)}px)` };
|
|
211
|
+
}
|
|
212
|
+
else {
|
|
213
|
+
const pos = measured(dragKey, over);
|
|
214
|
+
const a = geom.current.startOffset.get(key), b = pos.get(key);
|
|
215
|
+
if (a && b && (a.x != b.x || a.y != b.y))
|
|
216
|
+
style = { transform: `translate(${b.x - a.x}px, ${b.y - a.y}px)` };
|
|
217
|
+
}
|
|
218
|
+
}
|
|
219
|
+
return {
|
|
220
|
+
props: {
|
|
221
|
+
onMouseDown(e) { if (e.button == 0 && beginDrag(key, e))
|
|
222
|
+
drag.props.onMouseDown(e); },
|
|
223
|
+
onTouchStart(e) { if (beginDrag(key, e))
|
|
224
|
+
drag.props.onTouchStart(e); },
|
|
225
|
+
},
|
|
226
|
+
style,
|
|
227
|
+
dragging,
|
|
228
|
+
active,
|
|
229
|
+
};
|
|
230
|
+
}
|
|
231
|
+
return { columnRef, item, dragKey, over };
|
|
232
|
+
}
|
|
@@ -2,6 +2,7 @@ import { ObserveAll2, Replay } from "wenay-common2";
|
|
|
2
2
|
type StorePatch = ObserveAll2.StorePatch;
|
|
3
3
|
type ReplayEvent<Z extends any[]> = Replay.ReplayEvent<Z>;
|
|
4
4
|
type ReplayRemote<Z extends any[]> = Replay.ReplayRemote<Z>;
|
|
5
|
+
type StaleInfo = Replay.StaleInfo;
|
|
5
6
|
export type UseReplaySubscribeOptions = {
|
|
6
7
|
/** Start position for the first subscription: journal tail after this seq; omit = keyframe. */
|
|
7
8
|
since?: number;
|
|
@@ -11,13 +12,44 @@ export type UseReplaySubscribeOptions = {
|
|
|
11
12
|
enabled?: boolean;
|
|
12
13
|
onSeq?: (seq: number) => void;
|
|
13
14
|
onError?: (e: unknown) => void;
|
|
15
|
+
/**
|
|
16
|
+
* Producer-freshness watchdog (detection lives in wenay-common2): no event with a fresh
|
|
17
|
+
* producer ts for staleMs -> `stale` flips true, flips back on resume. Omitted = zero cost
|
|
18
|
+
* (no watchdog, no extra state updates). Changing staleMs resubscribes (reconnects by tail
|
|
19
|
+
* under keepSeq, so it is cheap).
|
|
20
|
+
*/
|
|
21
|
+
staleMs?: number;
|
|
22
|
+
/** Edge-triggered mirror of common2's onStale (fresh<->stale transitions only). Goes through a ref — a new identity does not resubscribe. */
|
|
23
|
+
onStale?: (info: StaleInfo) => void;
|
|
24
|
+
/**
|
|
25
|
+
* Lag policy of the wire subscription (frame model): 'queue' (default) — the socket buffers
|
|
26
|
+
* everything, nothing is ever skipped; 'frame' — rides the server's frameLine when the remote
|
|
27
|
+
* has one: on lag the server DROPS events for this client and recovers with a mini-frame.
|
|
28
|
+
* Without a frameLine (old server, in-proc line) 'frame' degrades to 'queue' in common2.
|
|
29
|
+
* This picks the wire surface, so changing it resubscribes (reconnects by tail under keepSeq).
|
|
30
|
+
*/
|
|
31
|
+
policy?: 'queue' | 'frame';
|
|
32
|
+
/**
|
|
33
|
+
* Opaque pass-through for the line's `frame` condenser on catch-up (which condensation rule
|
|
34
|
+
* this client wants). Captured at subscribe time through a ref — a new identity does not
|
|
35
|
+
* resubscribe; the next (re)subscribe reads the latest value.
|
|
36
|
+
*/
|
|
37
|
+
hint?: unknown;
|
|
14
38
|
};
|
|
15
39
|
export type ReplaySubscribeController = {
|
|
16
40
|
/** Keyframe/tail catch-up finished, events are live from here. */
|
|
17
41
|
readonly ready: boolean;
|
|
18
42
|
readonly error: unknown;
|
|
43
|
+
/**
|
|
44
|
+
* Line is stale by the staleMs watchdog. React state, but it updates ONLY on fresh<->stale
|
|
45
|
+
* transitions — a high-frequency line causes zero extra renders while fresh.
|
|
46
|
+
* Always false without staleMs.
|
|
47
|
+
*/
|
|
48
|
+
readonly stale: boolean;
|
|
19
49
|
/** Last seen seq (reconnect point). Getter — reading it does not re-render. */
|
|
20
50
|
seq(): number;
|
|
51
|
+
/** Producer ts of the last delivered event (0 before the first delivery). Getter — reading it does not re-render. */
|
|
52
|
+
lastTs(): number;
|
|
21
53
|
/** Drop and re-create the subscription. since omitted = continue by keepSeq rules; a number = explicit position. */
|
|
22
54
|
restart(since?: number): void;
|
|
23
55
|
};
|
|
@@ -46,6 +78,55 @@ export type StoreReplayMirrorController<T extends object> = StoreReplaySyncContr
|
|
|
46
78
|
* so reconnect is a journal tail, not a keyframe. A new `remote` recreates the store.
|
|
47
79
|
*/
|
|
48
80
|
export declare function useStoreReplayMirror<T extends object>(remote: ReplayRemote<[StorePatch]> | null | undefined, initial: T, options?: UseStoreReplaySyncOptions): StoreReplayMirrorController<T>;
|
|
81
|
+
export type UseReplayFrameOptions = {
|
|
82
|
+
/** Pull period, ms. Default 300. */
|
|
83
|
+
intervalMs?: number;
|
|
84
|
+
/**
|
|
85
|
+
* Start position: frame() brings the consumer from here to head. Omit = keyframe start:
|
|
86
|
+
* keyframe() is polled until the line has one (an empty line is "nothing yet", not an error).
|
|
87
|
+
* A sacred line (no keyframe) NEEDS an explicit since (0 = full tail) — omitted, it waits forever.
|
|
88
|
+
*/
|
|
89
|
+
since?: number;
|
|
90
|
+
/** Keep the last folded seq across remote-identity-stable resubscribes. Default true. */
|
|
91
|
+
keepSeq?: boolean;
|
|
92
|
+
/** false = stop pulling (and drop the timer). Default true. */
|
|
93
|
+
enabled?: boolean;
|
|
94
|
+
/**
|
|
95
|
+
* Opaque pass-through for the line's `frame` condenser (which condensation rule this client
|
|
96
|
+
* wants). Read through a ref on EVERY pull — a new identity neither resubscribes nor is missed.
|
|
97
|
+
*/
|
|
98
|
+
hint?: unknown;
|
|
99
|
+
onSeq?: (seq: number) => void;
|
|
100
|
+
/** frame() failed (network, or a sacred line evicted past our seq — loud by design). Pulling STOPS until restart(). */
|
|
101
|
+
onError?: (e: unknown) => void;
|
|
102
|
+
};
|
|
103
|
+
export type ReplayFrameController = {
|
|
104
|
+
/** First successful pull finished (like replaySubscribe's ready — even if the line was still empty). */
|
|
105
|
+
readonly ready: boolean;
|
|
106
|
+
/** Last pull error; pulling is stopped while set (restart() clears and re-arms). */
|
|
107
|
+
readonly error: unknown;
|
|
108
|
+
/** Last folded seq (reconnect point). Getter — reading it does not re-render. */
|
|
109
|
+
seq(): number;
|
|
110
|
+
/** Pull out of schedule now; resolves after folding. hint omitted = the latest options.hint. */
|
|
111
|
+
pull(hint?: unknown): Promise<void>;
|
|
112
|
+
/** Re-arm after an error / jump: since omitted = continue from the current seq. */
|
|
113
|
+
restart(since?: number): void;
|
|
114
|
+
};
|
|
115
|
+
/**
|
|
116
|
+
* Pull a replay line at YOUR pace (the frame model of common2): a timer around
|
|
117
|
+
* `remote.frame(seq, hint)` — the server condenses the tail via the line's `frame` lambda
|
|
118
|
+
* (mini-frame), so a slow consumer never accumulates a backlog and never holds a live socket
|
|
119
|
+
* subscription. Complements useReplaySubscribe (push): use pull when the consumer wants its own
|
|
120
|
+
* cadence (e.g. 500ms UI refresh over a fast line) or a client-picked condensation rule (hint).
|
|
121
|
+
*
|
|
122
|
+
* Folding contract mirrors the push path: envelopes arrive seq-ascending, already-seen seq are
|
|
123
|
+
* skipped, a keyframe recovery is just an envelope of the same event type. Fresh start (no since)
|
|
124
|
+
* = keyframe start like replaySubscribe: keyframe() is polled until the line has one; a sacred
|
|
125
|
+
* line needs an explicit since. Overlapping pulls are never issued (a slow frame() call skips
|
|
126
|
+
* timer ticks). A remote without frame() (old server) fails loudly via onError — there is
|
|
127
|
+
* deliberately no tail fallback here (that is replaySubscribe's job).
|
|
128
|
+
*/
|
|
129
|
+
export declare function useReplayFrame<Z extends any[]>(remote: ReplayRemote<Z> | null | undefined, cb: (...event: Z) => void, options?: UseReplayFrameOptions): ReplayFrameController;
|
|
49
130
|
export type ReplayHistoryLike<Z extends any[]> = {
|
|
50
131
|
at(where?: {
|
|
51
132
|
seq?: number;
|
|
@@ -11,10 +11,16 @@ import { ObserveAll2, Replay } from "wenay-common2";
|
|
|
11
11
|
* (ref/store/canvas). If the consumer state resets on resubscribe, pass {keepSeq: false};
|
|
12
12
|
* - a FULL unmount loses the hook's refs: to reconnect by tail after a remount, keep the position
|
|
13
13
|
* outside via onSeq and pass it back as `since` (see the QA card for the pattern);
|
|
14
|
-
* - a different `remote` identity always starts from scratch (seq from another line is meaningless)
|
|
14
|
+
* - a different `remote` identity always starts from scratch (seq from another line is meaningless);
|
|
15
|
+
* - freshness (staleMs/onStale, controller.stale/lastTs()) is detected by wenay-common2;
|
|
16
|
+
* the hooks only mirror the fresh<->stale edges into React state;
|
|
17
|
+
* - lag policy is the consumer's pick per subscription (frame model of common2 rev2):
|
|
18
|
+
* {policy: 'frame'} rides the server's frameLine when the remote has one (on lag the server
|
|
19
|
+
* drops and recovers with a mini-frame; old servers/in-proc lines degrade to 'queue');
|
|
20
|
+
* pull-at-own-pace is a separate hook (useReplayFrame) — a timer around remote.frame().
|
|
15
21
|
*
|
|
16
|
-
* Server-side parts of the stack (conflateReplay, archiveReplay
|
|
17
|
-
* and intentionally have no hooks here.
|
|
22
|
+
* Server-side parts of the stack (conflateReplay, archiveReplay, createRpcServerAuto replayOpts)
|
|
23
|
+
* are per-connection/per-process and intentionally have no hooks here.
|
|
18
24
|
*/
|
|
19
25
|
function useLatestRef(value) {
|
|
20
26
|
const ref = useRef(value);
|
|
@@ -28,14 +34,16 @@ function useLatestRef(value) {
|
|
|
28
34
|
* cb goes through a ref — a new cb identity does not resubscribe.
|
|
29
35
|
*/
|
|
30
36
|
export function useReplaySubscribe(remote, cb, options = {}) {
|
|
31
|
-
const { since, keepSeq = true, enabled = true, onSeq, onError } = options;
|
|
37
|
+
const { since, keepSeq = true, enabled = true, onSeq, onError, staleMs, onStale, policy, hint } = options;
|
|
32
38
|
const cbRef = useLatestRef(cb);
|
|
33
|
-
const hooksRef = useLatestRef({ onSeq, onError });
|
|
39
|
+
const hooksRef = useLatestRef({ onSeq, onError, onStale });
|
|
40
|
+
const hintRef = useLatestRef(hint);
|
|
34
41
|
const seqRef = useRef(since);
|
|
35
42
|
const subRef = useRef(null);
|
|
36
43
|
const lastRemoteRef = useRef(undefined);
|
|
37
44
|
const [ready, setReady] = useState(false);
|
|
38
45
|
const [error, setError] = useState(null);
|
|
46
|
+
const [stale, setStale] = useState(false);
|
|
39
47
|
const [epoch, setEpoch] = useState(0);
|
|
40
48
|
useEffect(() => {
|
|
41
49
|
if (!remote || !enabled)
|
|
@@ -46,8 +54,14 @@ export function useReplaySubscribe(remote, cb, options = {}) {
|
|
|
46
54
|
let alive = true;
|
|
47
55
|
setReady(false);
|
|
48
56
|
setError(null);
|
|
57
|
+
// stale is NOT reset here: it re-syncs from common2 after the first delivery (a stale
|
|
58
|
+
// keyframe must show stale from the start, not flicker through false on resubscribe)
|
|
59
|
+
if (staleMs === undefined)
|
|
60
|
+
setStale(false);
|
|
49
61
|
const off = Replay.replaySubscribe(remote, (...event) => cbRef.current(...event), {
|
|
50
62
|
since: seqRef.current,
|
|
63
|
+
policy,
|
|
64
|
+
hint: hintRef.current,
|
|
51
65
|
onSeq: seq => {
|
|
52
66
|
seqRef.current = seq;
|
|
53
67
|
hooksRef.current.onSeq?.(seq);
|
|
@@ -57,10 +71,23 @@ export function useReplaySubscribe(remote, cb, options = {}) {
|
|
|
57
71
|
setError(e);
|
|
58
72
|
hooksRef.current.onError?.(e);
|
|
59
73
|
},
|
|
74
|
+
...(staleMs !== undefined ? {
|
|
75
|
+
staleMs,
|
|
76
|
+
onStale: (info) => {
|
|
77
|
+
if (alive)
|
|
78
|
+
setStale(info.stale);
|
|
79
|
+
hooksRef.current.onStale?.(info);
|
|
80
|
+
},
|
|
81
|
+
} : null),
|
|
60
82
|
});
|
|
61
83
|
subRef.current = off;
|
|
62
|
-
off.ready.then(() => {
|
|
63
|
-
|
|
84
|
+
off.ready.then(() => {
|
|
85
|
+
if (!alive)
|
|
86
|
+
return;
|
|
87
|
+
setReady(true);
|
|
88
|
+
if (staleMs !== undefined)
|
|
89
|
+
setStale(off.isStale()); // fresh line after a stale one: no edge from common2, sync by hand
|
|
90
|
+
}, e => { if (alive)
|
|
64
91
|
setError(e); });
|
|
65
92
|
return () => {
|
|
66
93
|
alive = false;
|
|
@@ -69,16 +96,19 @@ export function useReplaySubscribe(remote, cb, options = {}) {
|
|
|
69
96
|
if (!keepSeq)
|
|
70
97
|
seqRef.current = since;
|
|
71
98
|
};
|
|
72
|
-
// keepSeq/since are start-position config, not subscription identity — no resubscribe on change
|
|
99
|
+
// keepSeq/since are start-position config, not subscription identity — no resubscribe on change;
|
|
100
|
+
// staleMs is subscribe-time config in common2, so changing it resubscribes;
|
|
101
|
+
// policy picks the wire surface (line vs frameLine), so it resubscribes too; hint rides a ref
|
|
73
102
|
// eslint-disable-next-line react-hooks/exhaustive-deps
|
|
74
|
-
}, [remote, enabled, epoch]);
|
|
103
|
+
}, [remote, enabled, epoch, staleMs, policy]);
|
|
75
104
|
const seq = useCallback(() => subRef.current?.seq() ?? seqRef.current ?? -1, []);
|
|
105
|
+
const lastTs = useCallback(() => subRef.current?.lastTs() ?? 0, []);
|
|
76
106
|
const restart = useCallback((at) => {
|
|
77
107
|
if (at !== undefined)
|
|
78
108
|
seqRef.current = at;
|
|
79
109
|
setEpoch(v => v + 1);
|
|
80
110
|
}, []);
|
|
81
|
-
return useMemo(() => ({ ready, error, seq, restart }), [ready, error, seq, restart]);
|
|
111
|
+
return useMemo(() => ({ ready, error, stale, seq, lastTs, restart }), [ready, error, stale, seq, lastTs, restart]);
|
|
82
112
|
}
|
|
83
113
|
/**
|
|
84
114
|
* Sync an existing mirror store from a store replay line (`ObserveAll2.exposeStoreReplay(...).api.replay`).
|
|
@@ -87,13 +117,15 @@ export function useReplaySubscribe(remote, cb, options = {}) {
|
|
|
87
117
|
* (useStoreNode/useStoreSelect).
|
|
88
118
|
*/
|
|
89
119
|
export function useStoreReplaySync(store, remote, options = {}) {
|
|
90
|
-
const { since, keepSeq = true, enabled = true, onSeq, onError } = options;
|
|
91
|
-
const hooksRef = useLatestRef({ onSeq, onError });
|
|
120
|
+
const { since, keepSeq = true, enabled = true, onSeq, onError, staleMs, onStale, policy, hint } = options;
|
|
121
|
+
const hooksRef = useLatestRef({ onSeq, onError, onStale });
|
|
122
|
+
const hintRef = useLatestRef(hint);
|
|
92
123
|
const seqRef = useRef(since);
|
|
93
124
|
const subRef = useRef(null);
|
|
94
125
|
const lastRemoteRef = useRef(undefined);
|
|
95
126
|
const [ready, setReady] = useState(false);
|
|
96
127
|
const [error, setError] = useState(null);
|
|
128
|
+
const [stale, setStale] = useState(false);
|
|
97
129
|
const [epoch, setEpoch] = useState(0);
|
|
98
130
|
useEffect(() => {
|
|
99
131
|
if (!store || !remote || !enabled)
|
|
@@ -104,8 +136,12 @@ export function useStoreReplaySync(store, remote, options = {}) {
|
|
|
104
136
|
let alive = true;
|
|
105
137
|
setReady(false);
|
|
106
138
|
setError(null);
|
|
139
|
+
if (staleMs === undefined)
|
|
140
|
+
setStale(false); // see useReplaySubscribe: stale re-syncs from common2, no reset-to-false flicker
|
|
107
141
|
const off = ObserveAll2.syncStoreReplay(store, remote, {
|
|
108
142
|
since: seqRef.current,
|
|
143
|
+
policy,
|
|
144
|
+
hint: hintRef.current,
|
|
109
145
|
onSeq: seq => {
|
|
110
146
|
seqRef.current = seq;
|
|
111
147
|
hooksRef.current.onSeq?.(seq);
|
|
@@ -115,10 +151,23 @@ export function useStoreReplaySync(store, remote, options = {}) {
|
|
|
115
151
|
setError(e);
|
|
116
152
|
hooksRef.current.onError?.(e);
|
|
117
153
|
},
|
|
154
|
+
...(staleMs !== undefined ? {
|
|
155
|
+
staleMs,
|
|
156
|
+
onStale: (info) => {
|
|
157
|
+
if (alive)
|
|
158
|
+
setStale(info.stale);
|
|
159
|
+
hooksRef.current.onStale?.(info);
|
|
160
|
+
},
|
|
161
|
+
} : null),
|
|
118
162
|
});
|
|
119
163
|
subRef.current = off;
|
|
120
|
-
off.ready.then(() => {
|
|
121
|
-
|
|
164
|
+
off.ready.then(() => {
|
|
165
|
+
if (!alive)
|
|
166
|
+
return;
|
|
167
|
+
setReady(true);
|
|
168
|
+
if (staleMs !== undefined)
|
|
169
|
+
setStale(off.isStale());
|
|
170
|
+
}, e => { if (alive)
|
|
122
171
|
setError(e); });
|
|
123
172
|
return () => {
|
|
124
173
|
alive = false;
|
|
@@ -128,14 +177,15 @@ export function useStoreReplaySync(store, remote, options = {}) {
|
|
|
128
177
|
seqRef.current = since;
|
|
129
178
|
};
|
|
130
179
|
// eslint-disable-next-line react-hooks/exhaustive-deps
|
|
131
|
-
}, [store, remote, enabled, epoch]);
|
|
180
|
+
}, [store, remote, enabled, epoch, staleMs, policy]);
|
|
132
181
|
const seq = useCallback(() => subRef.current?.seq() ?? seqRef.current ?? -1, []);
|
|
182
|
+
const lastTs = useCallback(() => subRef.current?.lastTs() ?? 0, []);
|
|
133
183
|
const restart = useCallback((at) => {
|
|
134
184
|
if (at !== undefined)
|
|
135
185
|
seqRef.current = at;
|
|
136
186
|
setEpoch(v => v + 1);
|
|
137
187
|
}, []);
|
|
138
|
-
return useMemo(() => ({ ready, error, seq, restart }), [ready, error, seq, restart]);
|
|
188
|
+
return useMemo(() => ({ ready, error, stale, seq, lastTs, restart }), [ready, error, stale, seq, lastTs, restart]);
|
|
139
189
|
}
|
|
140
190
|
/**
|
|
141
191
|
* Convenience: create a local mirror store and keep it synced from a store replay line.
|
|
@@ -151,6 +201,115 @@ export function useStoreReplayMirror(remote, initial, options = {}) {
|
|
|
151
201
|
const sync = useStoreReplaySync(store, remote, options);
|
|
152
202
|
return useMemo(() => ({ ...sync, store }), [sync, store]);
|
|
153
203
|
}
|
|
204
|
+
/**
|
|
205
|
+
* Pull a replay line at YOUR pace (the frame model of common2): a timer around
|
|
206
|
+
* `remote.frame(seq, hint)` — the server condenses the tail via the line's `frame` lambda
|
|
207
|
+
* (mini-frame), so a slow consumer never accumulates a backlog and never holds a live socket
|
|
208
|
+
* subscription. Complements useReplaySubscribe (push): use pull when the consumer wants its own
|
|
209
|
+
* cadence (e.g. 500ms UI refresh over a fast line) or a client-picked condensation rule (hint).
|
|
210
|
+
*
|
|
211
|
+
* Folding contract mirrors the push path: envelopes arrive seq-ascending, already-seen seq are
|
|
212
|
+
* skipped, a keyframe recovery is just an envelope of the same event type. Fresh start (no since)
|
|
213
|
+
* = keyframe start like replaySubscribe: keyframe() is polled until the line has one; a sacred
|
|
214
|
+
* line needs an explicit since. Overlapping pulls are never issued (a slow frame() call skips
|
|
215
|
+
* timer ticks). A remote without frame() (old server) fails loudly via onError — there is
|
|
216
|
+
* deliberately no tail fallback here (that is replaySubscribe's job).
|
|
217
|
+
*/
|
|
218
|
+
export function useReplayFrame(remote, cb, options = {}) {
|
|
219
|
+
const { intervalMs = 300, since, keepSeq = true, enabled = true, hint, onSeq, onError } = options;
|
|
220
|
+
const cbRef = useLatestRef(cb);
|
|
221
|
+
const hooksRef = useLatestRef({ onSeq, onError });
|
|
222
|
+
const hintRef = useLatestRef(hint);
|
|
223
|
+
const seqRef = useRef(since ?? -1);
|
|
224
|
+
const pullRef = useRef(null);
|
|
225
|
+
const lastRemoteRef = useRef(undefined);
|
|
226
|
+
const [ready, setReady] = useState(false);
|
|
227
|
+
const [error, setError] = useState(null);
|
|
228
|
+
const [epoch, setEpoch] = useState(0);
|
|
229
|
+
useEffect(() => {
|
|
230
|
+
if (!remote || !enabled)
|
|
231
|
+
return;
|
|
232
|
+
if (lastRemoteRef.current !== undefined && lastRemoteRef.current !== remote)
|
|
233
|
+
seqRef.current = since ?? -1; // a different line — old seq is meaningless
|
|
234
|
+
lastRemoteRef.current = remote;
|
|
235
|
+
let alive = true;
|
|
236
|
+
let stopped = false;
|
|
237
|
+
let timer = null;
|
|
238
|
+
let inflight = null;
|
|
239
|
+
setReady(false);
|
|
240
|
+
setError(null);
|
|
241
|
+
const fail = (e) => {
|
|
242
|
+
stopped = true;
|
|
243
|
+
if (timer) {
|
|
244
|
+
clearInterval(timer);
|
|
245
|
+
timer = null;
|
|
246
|
+
}
|
|
247
|
+
setError(e);
|
|
248
|
+
hooksRef.current.onError?.(e);
|
|
249
|
+
};
|
|
250
|
+
const pull = (hintArg) => {
|
|
251
|
+
if (inflight)
|
|
252
|
+
return inflight; // a slow frame() skips ticks, pulls never overlap
|
|
253
|
+
if (stopped || !alive)
|
|
254
|
+
return Promise.resolve();
|
|
255
|
+
const frame = remote.frame;
|
|
256
|
+
if (!frame) {
|
|
257
|
+
fail(new Error("useReplayFrame: remote has no frame() (old server) — use useReplaySubscribe"));
|
|
258
|
+
return Promise.resolve();
|
|
259
|
+
}
|
|
260
|
+
inflight = (async () => {
|
|
261
|
+
try {
|
|
262
|
+
// no position yet -> keyframe start, mirroring replaySubscribe's catch-up for since<0:
|
|
263
|
+
// frame(-1) has no tail to return and THROWS on a still-empty line, which is not an
|
|
264
|
+
// error here — poll keyframe() until the line has one, then switch to frame(seq)
|
|
265
|
+
const envs = seqRef.current < 0
|
|
266
|
+
? await Promise.resolve(remote.keyframe()).then(kf => kf ? [kf] : null)
|
|
267
|
+
: await frame(seqRef.current, hintArg !== undefined ? hintArg : hintRef.current);
|
|
268
|
+
if (!alive)
|
|
269
|
+
return;
|
|
270
|
+
for (const ev of envs ?? []) {
|
|
271
|
+
if (ev.seq <= seqRef.current)
|
|
272
|
+
continue;
|
|
273
|
+
seqRef.current = ev.seq;
|
|
274
|
+
cbRef.current(...ev.event);
|
|
275
|
+
hooksRef.current.onSeq?.(ev.seq);
|
|
276
|
+
}
|
|
277
|
+
setReady(true);
|
|
278
|
+
}
|
|
279
|
+
catch (e) {
|
|
280
|
+
if (alive)
|
|
281
|
+
fail(e);
|
|
282
|
+
}
|
|
283
|
+
finally {
|
|
284
|
+
inflight = null;
|
|
285
|
+
}
|
|
286
|
+
})();
|
|
287
|
+
return inflight;
|
|
288
|
+
};
|
|
289
|
+
pullRef.current = pull;
|
|
290
|
+
void pull();
|
|
291
|
+
if (!stopped)
|
|
292
|
+
timer = setInterval(() => { void pull(); }, intervalMs); // a sync throw in the first pull may already have stopped us
|
|
293
|
+
return () => {
|
|
294
|
+
alive = false;
|
|
295
|
+
pullRef.current = null;
|
|
296
|
+
if (timer)
|
|
297
|
+
clearInterval(timer);
|
|
298
|
+
if (!keepSeq)
|
|
299
|
+
seqRef.current = since ?? -1;
|
|
300
|
+
};
|
|
301
|
+
// since/keepSeq are start-position config, hint rides a ref — only the pace and identity resubscribe
|
|
302
|
+
// eslint-disable-next-line react-hooks/exhaustive-deps
|
|
303
|
+
}, [remote, enabled, epoch, intervalMs]);
|
|
304
|
+
const seq = useCallback(() => seqRef.current, []);
|
|
305
|
+
const pull = useCallback((hintArg) => pullRef.current?.(hintArg) ?? Promise.resolve(), []);
|
|
306
|
+
const restart = useCallback((at) => {
|
|
307
|
+
if (at !== undefined)
|
|
308
|
+
seqRef.current = at;
|
|
309
|
+
setEpoch(v => v + 1);
|
|
310
|
+
}, []);
|
|
311
|
+
return useMemo(() => ({ ready, error, seq, pull, restart }), [ready, error, seq, pull, restart]);
|
|
312
|
+
}
|
|
154
313
|
/**
|
|
155
314
|
* Time machine over `Replay.openHistory(storage, live?)`: scrubber/pause/resume for any replay line.
|
|
156
315
|
* `apply` folds ONE event into the consumer state (draw a frame, apply a store patch, ...) —
|
|
@@ -57,6 +57,23 @@ export declare const tokens: {
|
|
|
57
57
|
readonly navBg: "#17202e";
|
|
58
58
|
readonly navWidth: "168px";
|
|
59
59
|
};
|
|
60
|
+
/** Toolbar (createToolbar) chrome (--tb-*). Dark defaults; apps re-skin via :root[data-theme]. */
|
|
61
|
+
readonly tb: {
|
|
62
|
+
readonly bg: "transparent";
|
|
63
|
+
readonly border: "none";
|
|
64
|
+
readonly radius: "6px";
|
|
65
|
+
readonly gap: "2px";
|
|
66
|
+
/** var(--color-text-base) in CSS */
|
|
67
|
+
readonly itemColor: "#c4c4c4";
|
|
68
|
+
readonly itemHoverBg: "rgba(255, 255, 255, 0.08)";
|
|
69
|
+
readonly itemRadius: "6px";
|
|
70
|
+
/** var(--color-bg-dark) in CSS */
|
|
71
|
+
readonly popBg: "#131821";
|
|
72
|
+
/** var(--color-border-common) in CSS */
|
|
73
|
+
readonly popBorder: "1px solid rgb(50, 62, 71)";
|
|
74
|
+
readonly popRadius: "8px";
|
|
75
|
+
readonly popShadow: "0 8px 28px rgba(0, 0, 0, 0.5)";
|
|
76
|
+
};
|
|
60
77
|
readonly font: {
|
|
61
78
|
readonly family: "Roboto";
|
|
62
79
|
readonly sizeBase: "12px";
|
|
@@ -59,6 +59,23 @@ export const tokens = {
|
|
|
59
59
|
navBg: '#17202e',
|
|
60
60
|
navWidth: '168px',
|
|
61
61
|
},
|
|
62
|
+
/** Toolbar (createToolbar) chrome (--tb-*). Dark defaults; apps re-skin via :root[data-theme]. */
|
|
63
|
+
tb: {
|
|
64
|
+
bg: 'transparent',
|
|
65
|
+
border: 'none',
|
|
66
|
+
radius: '6px',
|
|
67
|
+
gap: '2px',
|
|
68
|
+
/** var(--color-text-base) in CSS */
|
|
69
|
+
itemColor: '#c4c4c4',
|
|
70
|
+
itemHoverBg: 'rgba(255, 255, 255, 0.08)',
|
|
71
|
+
itemRadius: '6px',
|
|
72
|
+
/** var(--color-bg-dark) in CSS */
|
|
73
|
+
popBg: '#131821',
|
|
74
|
+
/** var(--color-border-common) in CSS */
|
|
75
|
+
popBorder: '1px solid rgb(50, 62, 71)',
|
|
76
|
+
popRadius: '8px',
|
|
77
|
+
popShadow: '0 8px 28px rgba(0, 0, 0, 0.5)',
|
|
78
|
+
},
|
|
62
79
|
font: {
|
|
63
80
|
family: 'Roboto',
|
|
64
81
|
sizeBase: '12px',
|