wenay-react2 1.0.29 → 1.0.31
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/README.md +3 -3
- package/lib/common/src/components/Dnd/RNDFunc3.d.ts +2 -1
- package/lib/common/src/components/Dnd/RNDFunc3.js +11 -2
- package/lib/common/src/components/Dnd/Resizable.d.ts +2 -1
- package/lib/common/src/components/Dnd/Resizable.js +6 -2
- package/lib/common/src/components/Menu/RightMenu.js +1 -0
- package/lib/common/src/components/Menu/RightMenuStore.d.ts +2 -1
- package/lib/common/src/components/Menu/RightMenuStore.js +3 -1
- package/lib/common/src/components/UiSlot/UiSlot.d.ts +3 -2
- package/lib/common/src/components/UiSlot/UiSlot.js +5 -3
- package/lib/common/src/hooks/index.d.ts +1 -0
- package/lib/common/src/hooks/index.js +1 -0
- package/lib/common/src/hooks/useReplay.d.ts +94 -0
- package/lib/common/src/hooks/useReplay.js +222 -0
- package/lib/common/src/utils/cache.d.ts +13 -0
- package/lib/common/src/utils/cache.js +0 -0
- package/lib/common/src/utils/index.d.ts +1 -0
- package/lib/common/src/utils/index.js +1 -0
- package/lib/common/src/utils/mapMemory.d.ts +15 -4
- package/lib/common/src/utils/mapMemory.js +21 -1
- package/lib/common/src/utils/observableMap.d.ts +17 -0
- package/lib/common/src/utils/observableMap.js +40 -0
- package/package.json +2 -2
package/README.md
CHANGED
|
@@ -4,8 +4,8 @@ Common React UI primitives for wenay apps.
|
|
|
4
4
|
|
|
5
5
|
Use the new concise API guide first:
|
|
6
6
|
|
|
7
|
-
- [wenay-react2.md](
|
|
8
|
-
- [wenay-react2-rare.md](
|
|
7
|
+
- [wenay-react2.md](doc/wenay-react2.md) - canonical everyday API.
|
|
8
|
+
- [wenay-react2-rare.md](doc/wenay-react2-rare.md) - old names, low-level exports, migration notes.
|
|
9
9
|
- [agGrid4 README](./src/common/src/grid/agGrid4/README.md) - table buffer/controller details.
|
|
10
10
|
- [agGrid4 wrapper boundary](./src/common/src/grid/agGrid4/WRAPPER.md) - where generic primitives end and app wrappers begin.
|
|
11
11
|
|
|
@@ -55,4 +55,4 @@ The stand source is `src/common/testUseReact/qa.tsx`. Use it for visual checks a
|
|
|
55
55
|
|
|
56
56
|
```sh
|
|
57
57
|
npm run build
|
|
58
|
-
```
|
|
58
|
+
```
|
|
@@ -1,4 +1,5 @@
|
|
|
1
1
|
import React, { ReactNode } from "react";
|
|
2
|
+
import { ObservableMap } from "../../utils/observableMap";
|
|
2
3
|
type tPosition = {
|
|
3
4
|
x: number;
|
|
4
5
|
y: number;
|
|
@@ -46,7 +47,7 @@ type tDivRndBase = {
|
|
|
46
47
|
children: React.ReactElement | ((update: number) => React.ReactElement);
|
|
47
48
|
className?: string;
|
|
48
49
|
};
|
|
49
|
-
export declare const ExRNDMap3:
|
|
50
|
+
export declare const ExRNDMap3: ObservableMap<string, tRND>;
|
|
50
51
|
export declare const DivRnd3: typeof DivRndBase3;
|
|
51
52
|
/**
|
|
52
53
|
* Wrapper component around react-rnd.
|
|
@@ -2,8 +2,9 @@ import { jsx as _jsx, jsxs as _jsxs } from "react/jsx-runtime";
|
|
|
2
2
|
import React, { useEffect, useLayoutEffect, useRef, useState } from "react";
|
|
3
3
|
import { Rnd } from "react-rnd";
|
|
4
4
|
import { renderBy, updateBy } from "../../../updateBy";
|
|
5
|
-
|
|
6
|
-
|
|
5
|
+
import { ObservableMap } from "../../utils/observableMap";
|
|
6
|
+
// Map of all popup window sizes; observable - Cash marks itself dirty on its mutations
|
|
7
|
+
export const ExRNDMap3 = new ObservableMap();
|
|
7
8
|
// limit={{x:{min:0}, y:{min:0}}}
|
|
8
9
|
let k = 0;
|
|
9
10
|
const openWindows = { ar: [] };
|
|
@@ -97,6 +98,10 @@ export function DivRndBase3({ children, keyForSave: ks, position, size, overflow
|
|
|
97
98
|
document.removeEventListener("mousemove", mouseMoveHandler);
|
|
98
99
|
lastC.current = null;
|
|
99
100
|
setA(false);
|
|
101
|
+
// drag end commits geometry mutated in place - invisible to the map, so announce
|
|
102
|
+
// it; a no-move click also lands here, the save-side diff turns that into a no-op
|
|
103
|
+
if (ks)
|
|
104
|
+
ExRNDMap3.touch(ks);
|
|
100
105
|
};
|
|
101
106
|
// Touch
|
|
102
107
|
const touchMoveHandler = (e) => {
|
|
@@ -141,6 +146,8 @@ export function DivRndBase3({ children, keyForSave: ks, position, size, overflow
|
|
|
141
146
|
document.removeEventListener("touchend", touchEndHandler);
|
|
142
147
|
document.removeEventListener("touchmove", touchMoveHandler);
|
|
143
148
|
setB(false);
|
|
149
|
+
if (ks)
|
|
150
|
+
ExRNDMap3.touch(ks);
|
|
144
151
|
}
|
|
145
152
|
};
|
|
146
153
|
// If mouse dragging mode is active
|
|
@@ -227,6 +234,8 @@ export function DivRndBase3({ children, keyForSave: ks, position, size, overflow
|
|
|
227
234
|
setHeight(elementRef.offsetHeight);
|
|
228
235
|
setWidth(elementRef.offsetWidth);
|
|
229
236
|
setUpdate(update + 1);
|
|
237
|
+
if (ks)
|
|
238
|
+
ExRNDMap3.touch(ks);
|
|
230
239
|
}, onResize: (e, dir, elementRef, delta, pos) => {
|
|
231
240
|
onUpdate?.({ e, dir, elementRef, delta, position: pos });
|
|
232
241
|
}, position: { x, y }, size: { width, height }, default: {
|
|
@@ -1,9 +1,10 @@
|
|
|
1
1
|
import { ResizableProps } from "re-resizable";
|
|
2
|
+
import { ObservableMap } from "../../utils/observableMap";
|
|
2
3
|
type tSaveMap = {
|
|
3
4
|
height?: number | string;
|
|
4
5
|
width?: number | string;
|
|
5
6
|
};
|
|
6
|
-
export declare const mapResiReact:
|
|
7
|
+
export declare const mapResiReact: ObservableMap<string, tSaveMap>;
|
|
7
8
|
type t3 = Pick<ResizableProps, "style" | "enable" | "onResize" | "children" | "size" | "maxWidth" | "maxHeight" | "minWidth" | "minHeight">;
|
|
8
9
|
export declare function FResizableReact({ style, onResize, enable, children, keyForSave, onResizeStop, size, minWidth, minHeight, maxWidth, maxHeight, moveWith, moveHeight }: t3 & {
|
|
9
10
|
keyForSave?: string;
|
|
@@ -1,7 +1,8 @@
|
|
|
1
1
|
import { jsx as _jsx } from "react/jsx-runtime";
|
|
2
2
|
import { Resizable } from "re-resizable";
|
|
3
|
-
|
|
4
|
-
|
|
3
|
+
import { ObservableMap } from "../../utils/observableMap";
|
|
4
|
+
// Memory for all column sizes; observable - Cash marks itself dirty on its mutations
|
|
5
|
+
export const mapResiReact = new ObservableMap();
|
|
5
6
|
export function FResizableReact({ style, onResize, enable, children, keyForSave, onResizeStop, size = { height: 50, width: 50 }, minWidth, minHeight, maxWidth = "100%", maxHeight = "100%", moveWith = true, moveHeight = true }) {
|
|
6
7
|
let obj = size;
|
|
7
8
|
if (keyForSave) {
|
|
@@ -30,6 +31,9 @@ export function FResizableReact({ style, onResize, enable, children, keyForSave,
|
|
|
30
31
|
obj.height = elementRef.style.height;
|
|
31
32
|
}
|
|
32
33
|
// onResize?.(size)
|
|
34
|
+
// obj is mutated in place - invisible to the map, so announce it
|
|
35
|
+
if (keyForSave)
|
|
36
|
+
mapResiReact.touch(keyForSave);
|
|
33
37
|
onResizeStop?.(obj);
|
|
34
38
|
// this.Refresh()
|
|
35
39
|
}, size: obj, defaultSize: obj, maxWidth: maxWidth, maxHeight: maxHeight, minWidth: minWidth, minHeight: minHeight, children: children });
|
|
@@ -67,6 +67,7 @@ export function DropdownMenu({ elements, style, position: initialPosition = 'rig
|
|
|
67
67
|
setPosition(nextPosition);
|
|
68
68
|
setIsTop(toTop);
|
|
69
69
|
if (keyForSave) {
|
|
70
|
+
// mapRightMenu is observable: set() itself marks the cache dirty
|
|
70
71
|
mapRightMenu.set(keyForSave, {
|
|
71
72
|
position: nextPosition,
|
|
72
73
|
position2: toTop ? 'top' : 'bottom',
|
|
@@ -8,4 +8,5 @@ export type MenuRightSavedState = {
|
|
|
8
8
|
y: number;
|
|
9
9
|
};
|
|
10
10
|
};
|
|
11
|
-
|
|
11
|
+
import { ObservableMap } from "../../utils/observableMap";
|
|
12
|
+
export declare const mapRightMenu: ObservableMap<string, MenuRightSavedState>;
|
|
@@ -1,7 +1,8 @@
|
|
|
1
1
|
import React from "react";
|
|
2
2
|
/** One UI block shown in exactly one of several mount points; the point is a persisted setting.
|
|
3
|
-
* Persistence rides the existing staticProps -> Cash mechanics:
|
|
4
|
-
*
|
|
3
|
+
* Persistence rides the existing staticProps -> Cash mechanics: staticProps is observable,
|
|
4
|
+
* setPlace announces its in-place mutation (staticMarkDirty), the app decides when to save
|
|
5
|
+
* via Cash.onDirty - same as window state (ExRNDMap3 etc.).
|
|
5
6
|
* Mount points render <Slot place="..."> themselves and decide nothing: the slot compares
|
|
6
7
|
* the persisted place with its own and renders children only on a match. */
|
|
7
8
|
export declare function createUiSlot<Places extends string>(opts: {
|
|
@@ -1,9 +1,10 @@
|
|
|
1
1
|
import { Fragment as _Fragment, jsx as _jsx } from "react/jsx-runtime";
|
|
2
2
|
import { renderBy, updateBy } from "../../../updateBy";
|
|
3
|
-
import { staticGetAdd } from "../../utils/mapMemory";
|
|
3
|
+
import { staticGetAdd, staticMarkDirty } from "../../utils/mapMemory";
|
|
4
4
|
/** One UI block shown in exactly one of several mount points; the point is a persisted setting.
|
|
5
|
-
* Persistence rides the existing staticProps -> Cash mechanics:
|
|
6
|
-
*
|
|
5
|
+
* Persistence rides the existing staticProps -> Cash mechanics: staticProps is observable,
|
|
6
|
+
* setPlace announces its in-place mutation (staticMarkDirty), the app decides when to save
|
|
7
|
+
* via Cash.onDirty - same as window state (ExRNDMap3 etc.).
|
|
7
8
|
* Mount points render <Slot place="..."> themselves and decide nothing: the slot compares
|
|
8
9
|
* the persisted place with its own and renders children only on a match. */
|
|
9
10
|
export function createUiSlot(opts) {
|
|
@@ -16,6 +17,7 @@ export function createUiSlot(opts) {
|
|
|
16
17
|
return;
|
|
17
18
|
st.place = p;
|
|
18
19
|
renderBy(st);
|
|
20
|
+
staticMarkDirty(opts.key);
|
|
19
21
|
};
|
|
20
22
|
function Slot(p) {
|
|
21
23
|
updateBy(st);
|
|
@@ -0,0 +1,94 @@
|
|
|
1
|
+
import { ObserveAll2, Replay } from "wenay-common2";
|
|
2
|
+
type StorePatch = ObserveAll2.StorePatch;
|
|
3
|
+
type ReplayEvent<Z extends any[]> = Replay.ReplayEvent<Z>;
|
|
4
|
+
type ReplayRemote<Z extends any[]> = Replay.ReplayRemote<Z>;
|
|
5
|
+
export type UseReplaySubscribeOptions = {
|
|
6
|
+
/** Start position for the first subscription: journal tail after this seq; omit = keyframe. */
|
|
7
|
+
since?: number;
|
|
8
|
+
/** Keep the last seen seq across resubscribes (remount/restart) and reconnect with the tail. Default true. */
|
|
9
|
+
keepSeq?: boolean;
|
|
10
|
+
/** false = do not subscribe (and drop the current subscription). Default true. */
|
|
11
|
+
enabled?: boolean;
|
|
12
|
+
onSeq?: (seq: number) => void;
|
|
13
|
+
onError?: (e: unknown) => void;
|
|
14
|
+
};
|
|
15
|
+
export type ReplaySubscribeController = {
|
|
16
|
+
/** Keyframe/tail catch-up finished, events are live from here. */
|
|
17
|
+
readonly ready: boolean;
|
|
18
|
+
readonly error: unknown;
|
|
19
|
+
/** Last seen seq (reconnect point). Getter — reading it does not re-render. */
|
|
20
|
+
seq(): number;
|
|
21
|
+
/** Drop and re-create the subscription. since omitted = continue by keepSeq rules; a number = explicit position. */
|
|
22
|
+
restart(since?: number): void;
|
|
23
|
+
};
|
|
24
|
+
/**
|
|
25
|
+
* Subscribe to a replay line (`Replay.exposeReplay` shape: {line, since, keyframe}).
|
|
26
|
+
* The delivery contract of the line is preserved as is: cb first receives the snapshot
|
|
27
|
+
* (keyframe as a normal event), then strictly-newer events, seq-ascending, deduped.
|
|
28
|
+
* cb goes through a ref — a new cb identity does not resubscribe.
|
|
29
|
+
*/
|
|
30
|
+
export declare function useReplaySubscribe<Z extends any[]>(remote: ReplayRemote<Z> | null | undefined, cb: (...event: Z) => void, options?: UseReplaySubscribeOptions): ReplaySubscribeController;
|
|
31
|
+
export type UseStoreReplaySyncOptions = UseReplaySubscribeOptions;
|
|
32
|
+
export type StoreReplaySyncController = ReplaySubscribeController;
|
|
33
|
+
/**
|
|
34
|
+
* Sync an existing mirror store from a store replay line (`ObserveAll2.exposeStoreReplay(...).api.replay`).
|
|
35
|
+
* Thin lifecycle wrapper over `ObserveAll2.syncStoreReplay`: the keyframe (root patch) and the
|
|
36
|
+
* patch tail are applied to the store by the library; UI subscribes to the store as usual
|
|
37
|
+
* (useStoreNode/useStoreSelect).
|
|
38
|
+
*/
|
|
39
|
+
export declare function useStoreReplaySync<T extends object>(store: ObserveAll2.Store<T> | null | undefined, remote: ReplayRemote<[StorePatch]> | null | undefined, options?: UseStoreReplaySyncOptions): StoreReplaySyncController;
|
|
40
|
+
export type StoreReplayMirrorController<T extends object> = StoreReplaySyncController & {
|
|
41
|
+
readonly store: ObserveAll2.Store<T>;
|
|
42
|
+
};
|
|
43
|
+
/**
|
|
44
|
+
* Convenience: create a local mirror store and keep it synced from a store replay line.
|
|
45
|
+
* The store lives in a ref — a component remount keeps both the state and the seq,
|
|
46
|
+
* so reconnect is a journal tail, not a keyframe. A new `remote` recreates the store.
|
|
47
|
+
*/
|
|
48
|
+
export declare function useStoreReplayMirror<T extends object>(remote: ReplayRemote<[StorePatch]> | null | undefined, initial: T, options?: UseStoreReplaySyncOptions): StoreReplayMirrorController<T>;
|
|
49
|
+
export type ReplayHistoryLike<Z extends any[]> = {
|
|
50
|
+
at(where?: {
|
|
51
|
+
seq?: number;
|
|
52
|
+
ts?: number;
|
|
53
|
+
}): ReplayEvent<Z>[] | undefined;
|
|
54
|
+
subscribe(cb: (...event: Z) => void, opts?: {
|
|
55
|
+
since?: number;
|
|
56
|
+
ts?: number;
|
|
57
|
+
onSeq?: (seq: number) => void;
|
|
58
|
+
}): (() => void) & {
|
|
59
|
+
seq: () => number;
|
|
60
|
+
};
|
|
61
|
+
};
|
|
62
|
+
export type UseReplayHistoryOptions = {
|
|
63
|
+
/** Live head seq getter (slider max), e.g. `() => replay.head()`. Without it head = max seen seq. */
|
|
64
|
+
head?: () => number;
|
|
65
|
+
/** Called before folding a seek (clear consumer state). Usually not needed: a keyframe fully redefines the state. */
|
|
66
|
+
reset?: () => void;
|
|
67
|
+
/** UI position/head refresh period while live, ms. Default 300. */
|
|
68
|
+
tickMs?: number;
|
|
69
|
+
/** Follow live on mount. Default true. */
|
|
70
|
+
autoPlay?: boolean;
|
|
71
|
+
};
|
|
72
|
+
export type ReplayHistoryController = {
|
|
73
|
+
/** true = following live (archive tail -> live handover), false = paused/seeked. */
|
|
74
|
+
readonly live: boolean;
|
|
75
|
+
/** Current playback position (UI state; refreshed every tickMs while live). */
|
|
76
|
+
readonly seq: number;
|
|
77
|
+
/** Last known head seq (slider max). */
|
|
78
|
+
readonly head: number;
|
|
79
|
+
pause(): void;
|
|
80
|
+
/** Resume from the current position: journal/archive tail, then live. */
|
|
81
|
+
play(): void;
|
|
82
|
+
/** Pause and jump: fold keyframe + events up to the position through `apply`. */
|
|
83
|
+
seek(where: {
|
|
84
|
+
seq?: number;
|
|
85
|
+
ts?: number;
|
|
86
|
+
}): void;
|
|
87
|
+
};
|
|
88
|
+
/**
|
|
89
|
+
* Time machine over `Replay.openHistory(storage, live?)`: scrubber/pause/resume for any replay line.
|
|
90
|
+
* `apply` folds ONE event into the consumer state (draw a frame, apply a store patch, ...) —
|
|
91
|
+
* the same fold works for live playback and for seeks, snapshot is not a special case.
|
|
92
|
+
*/
|
|
93
|
+
export declare function useReplayHistory<Z extends any[]>(history: ReplayHistoryLike<Z> | null | undefined, apply: (...event: Z) => void, options?: UseReplayHistoryOptions): ReplayHistoryController;
|
|
94
|
+
export {};
|
|
@@ -0,0 +1,222 @@
|
|
|
1
|
+
import { useCallback, useEffect, useMemo, useRef, useState } from "react";
|
|
2
|
+
import { ObserveAll2, Replay } from "wenay-common2";
|
|
3
|
+
/**
|
|
4
|
+
* React bridge over the Replay stack (snapshot + sequenced delta line) of wenay-common2.
|
|
5
|
+
*
|
|
6
|
+
* The lifecycle rules these hooks encapsulate:
|
|
7
|
+
* - off() on unmount, subscription never outlives the component (StrictMode double-effect safe);
|
|
8
|
+
* - seq survives resubscribes within a mounted component (StrictMode double-effect, restart(),
|
|
9
|
+
* enabled toggling): the resubscribe reconnects with {since: lastSeq} and receives the journal
|
|
10
|
+
* tail instead of a full keyframe — as long as the folded state itself lives outside the effect
|
|
11
|
+
* (ref/store/canvas). If the consumer state resets on resubscribe, pass {keepSeq: false};
|
|
12
|
+
* - a FULL unmount loses the hook's refs: to reconnect by tail after a remount, keep the position
|
|
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).
|
|
15
|
+
*
|
|
16
|
+
* Server-side parts of the stack (conflateReplay, archiveReplay) are per-connection/per-process
|
|
17
|
+
* and intentionally have no hooks here.
|
|
18
|
+
*/
|
|
19
|
+
function useLatestRef(value) {
|
|
20
|
+
const ref = useRef(value);
|
|
21
|
+
ref.current = value;
|
|
22
|
+
return ref;
|
|
23
|
+
}
|
|
24
|
+
/**
|
|
25
|
+
* Subscribe to a replay line (`Replay.exposeReplay` shape: {line, since, keyframe}).
|
|
26
|
+
* The delivery contract of the line is preserved as is: cb first receives the snapshot
|
|
27
|
+
* (keyframe as a normal event), then strictly-newer events, seq-ascending, deduped.
|
|
28
|
+
* cb goes through a ref — a new cb identity does not resubscribe.
|
|
29
|
+
*/
|
|
30
|
+
export function useReplaySubscribe(remote, cb, options = {}) {
|
|
31
|
+
const { since, keepSeq = true, enabled = true, onSeq, onError } = options;
|
|
32
|
+
const cbRef = useLatestRef(cb);
|
|
33
|
+
const hooksRef = useLatestRef({ onSeq, onError });
|
|
34
|
+
const seqRef = useRef(since);
|
|
35
|
+
const subRef = useRef(null);
|
|
36
|
+
const lastRemoteRef = useRef(undefined);
|
|
37
|
+
const [ready, setReady] = useState(false);
|
|
38
|
+
const [error, setError] = useState(null);
|
|
39
|
+
const [epoch, setEpoch] = useState(0);
|
|
40
|
+
useEffect(() => {
|
|
41
|
+
if (!remote || !enabled)
|
|
42
|
+
return;
|
|
43
|
+
if (lastRemoteRef.current !== undefined && lastRemoteRef.current !== remote)
|
|
44
|
+
seqRef.current = undefined; // a different line — old seq is meaningless
|
|
45
|
+
lastRemoteRef.current = remote;
|
|
46
|
+
let alive = true;
|
|
47
|
+
setReady(false);
|
|
48
|
+
setError(null);
|
|
49
|
+
const off = Replay.replaySubscribe(remote, (...event) => cbRef.current(...event), {
|
|
50
|
+
since: seqRef.current,
|
|
51
|
+
onSeq: seq => {
|
|
52
|
+
seqRef.current = seq;
|
|
53
|
+
hooksRef.current.onSeq?.(seq);
|
|
54
|
+
},
|
|
55
|
+
onError: e => {
|
|
56
|
+
if (alive)
|
|
57
|
+
setError(e);
|
|
58
|
+
hooksRef.current.onError?.(e);
|
|
59
|
+
},
|
|
60
|
+
});
|
|
61
|
+
subRef.current = off;
|
|
62
|
+
off.ready.then(() => { if (alive)
|
|
63
|
+
setReady(true); }, e => { if (alive)
|
|
64
|
+
setError(e); });
|
|
65
|
+
return () => {
|
|
66
|
+
alive = false;
|
|
67
|
+
subRef.current = null;
|
|
68
|
+
off();
|
|
69
|
+
if (!keepSeq)
|
|
70
|
+
seqRef.current = since;
|
|
71
|
+
};
|
|
72
|
+
// keepSeq/since are start-position config, not subscription identity — no resubscribe on change
|
|
73
|
+
// eslint-disable-next-line react-hooks/exhaustive-deps
|
|
74
|
+
}, [remote, enabled, epoch]);
|
|
75
|
+
const seq = useCallback(() => subRef.current?.seq() ?? seqRef.current ?? -1, []);
|
|
76
|
+
const restart = useCallback((at) => {
|
|
77
|
+
if (at !== undefined)
|
|
78
|
+
seqRef.current = at;
|
|
79
|
+
setEpoch(v => v + 1);
|
|
80
|
+
}, []);
|
|
81
|
+
return useMemo(() => ({ ready, error, seq, restart }), [ready, error, seq, restart]);
|
|
82
|
+
}
|
|
83
|
+
/**
|
|
84
|
+
* Sync an existing mirror store from a store replay line (`ObserveAll2.exposeStoreReplay(...).api.replay`).
|
|
85
|
+
* Thin lifecycle wrapper over `ObserveAll2.syncStoreReplay`: the keyframe (root patch) and the
|
|
86
|
+
* patch tail are applied to the store by the library; UI subscribes to the store as usual
|
|
87
|
+
* (useStoreNode/useStoreSelect).
|
|
88
|
+
*/
|
|
89
|
+
export function useStoreReplaySync(store, remote, options = {}) {
|
|
90
|
+
const { since, keepSeq = true, enabled = true, onSeq, onError } = options;
|
|
91
|
+
const hooksRef = useLatestRef({ onSeq, onError });
|
|
92
|
+
const seqRef = useRef(since);
|
|
93
|
+
const subRef = useRef(null);
|
|
94
|
+
const lastRemoteRef = useRef(undefined);
|
|
95
|
+
const [ready, setReady] = useState(false);
|
|
96
|
+
const [error, setError] = useState(null);
|
|
97
|
+
const [epoch, setEpoch] = useState(0);
|
|
98
|
+
useEffect(() => {
|
|
99
|
+
if (!store || !remote || !enabled)
|
|
100
|
+
return;
|
|
101
|
+
if (lastRemoteRef.current !== undefined && lastRemoteRef.current !== remote)
|
|
102
|
+
seqRef.current = undefined;
|
|
103
|
+
lastRemoteRef.current = remote;
|
|
104
|
+
let alive = true;
|
|
105
|
+
setReady(false);
|
|
106
|
+
setError(null);
|
|
107
|
+
const off = ObserveAll2.syncStoreReplay(store, remote, {
|
|
108
|
+
since: seqRef.current,
|
|
109
|
+
onSeq: seq => {
|
|
110
|
+
seqRef.current = seq;
|
|
111
|
+
hooksRef.current.onSeq?.(seq);
|
|
112
|
+
},
|
|
113
|
+
onError: e => {
|
|
114
|
+
if (alive)
|
|
115
|
+
setError(e);
|
|
116
|
+
hooksRef.current.onError?.(e);
|
|
117
|
+
},
|
|
118
|
+
});
|
|
119
|
+
subRef.current = off;
|
|
120
|
+
off.ready.then(() => { if (alive)
|
|
121
|
+
setReady(true); }, e => { if (alive)
|
|
122
|
+
setError(e); });
|
|
123
|
+
return () => {
|
|
124
|
+
alive = false;
|
|
125
|
+
subRef.current = null;
|
|
126
|
+
off();
|
|
127
|
+
if (!keepSeq)
|
|
128
|
+
seqRef.current = since;
|
|
129
|
+
};
|
|
130
|
+
// eslint-disable-next-line react-hooks/exhaustive-deps
|
|
131
|
+
}, [store, remote, enabled, epoch]);
|
|
132
|
+
const seq = useCallback(() => subRef.current?.seq() ?? seqRef.current ?? -1, []);
|
|
133
|
+
const restart = useCallback((at) => {
|
|
134
|
+
if (at !== undefined)
|
|
135
|
+
seqRef.current = at;
|
|
136
|
+
setEpoch(v => v + 1);
|
|
137
|
+
}, []);
|
|
138
|
+
return useMemo(() => ({ ready, error, seq, restart }), [ready, error, seq, restart]);
|
|
139
|
+
}
|
|
140
|
+
/**
|
|
141
|
+
* Convenience: create a local mirror store and keep it synced from a store replay line.
|
|
142
|
+
* The store lives in a ref — a component remount keeps both the state and the seq,
|
|
143
|
+
* so reconnect is a journal tail, not a keyframe. A new `remote` recreates the store.
|
|
144
|
+
*/
|
|
145
|
+
export function useStoreReplayMirror(remote, initial, options = {}) {
|
|
146
|
+
const storeRef = useRef(null);
|
|
147
|
+
if (!storeRef.current || storeRef.current.remote !== remote) {
|
|
148
|
+
storeRef.current = { remote, store: ObserveAll2.createStore(initial) };
|
|
149
|
+
}
|
|
150
|
+
const store = storeRef.current.store;
|
|
151
|
+
const sync = useStoreReplaySync(store, remote, options);
|
|
152
|
+
return useMemo(() => ({ ...sync, store }), [sync, store]);
|
|
153
|
+
}
|
|
154
|
+
/**
|
|
155
|
+
* Time machine over `Replay.openHistory(storage, live?)`: scrubber/pause/resume for any replay line.
|
|
156
|
+
* `apply` folds ONE event into the consumer state (draw a frame, apply a store patch, ...) —
|
|
157
|
+
* the same fold works for live playback and for seeks, snapshot is not a special case.
|
|
158
|
+
*/
|
|
159
|
+
export function useReplayHistory(history, apply, options = {}) {
|
|
160
|
+
const { head: headGetter, reset, tickMs = 300, autoPlay = true } = options;
|
|
161
|
+
const applyRef = useLatestRef(apply);
|
|
162
|
+
const resetRef = useLatestRef(reset);
|
|
163
|
+
const headRef = useLatestRef(headGetter);
|
|
164
|
+
const posRef = useRef(-1);
|
|
165
|
+
const killRef = useRef(null);
|
|
166
|
+
const [live, setLive] = useState(autoPlay);
|
|
167
|
+
const [pos, setPos] = useState(-1);
|
|
168
|
+
const [head, setHead] = useState(-1);
|
|
169
|
+
const readHead = useCallback(() => {
|
|
170
|
+
const h = headRef.current ? headRef.current() : -1;
|
|
171
|
+
return Math.max(h, posRef.current);
|
|
172
|
+
}, [headRef]);
|
|
173
|
+
useEffect(() => {
|
|
174
|
+
if (!history || !live)
|
|
175
|
+
return;
|
|
176
|
+
const off = history.subscribe((...event) => applyRef.current(...event), {
|
|
177
|
+
since: posRef.current >= 0 ? posRef.current : undefined,
|
|
178
|
+
onSeq: seq => { posRef.current = seq; },
|
|
179
|
+
});
|
|
180
|
+
let done = false;
|
|
181
|
+
killRef.current = () => {
|
|
182
|
+
if (done)
|
|
183
|
+
return;
|
|
184
|
+
done = true;
|
|
185
|
+
off();
|
|
186
|
+
};
|
|
187
|
+
const timer = setInterval(() => {
|
|
188
|
+
setPos(posRef.current);
|
|
189
|
+
setHead(readHead());
|
|
190
|
+
}, tickMs);
|
|
191
|
+
return () => {
|
|
192
|
+
clearInterval(timer);
|
|
193
|
+
killRef.current?.();
|
|
194
|
+
killRef.current = null;
|
|
195
|
+
};
|
|
196
|
+
}, [history, live, tickMs, applyRef, readHead]);
|
|
197
|
+
const pause = useCallback(() => {
|
|
198
|
+
killRef.current?.(); // stop deliveries immediately, not on the next render
|
|
199
|
+
killRef.current = null;
|
|
200
|
+
setLive(false);
|
|
201
|
+
setPos(posRef.current);
|
|
202
|
+
setHead(readHead());
|
|
203
|
+
}, [readHead]);
|
|
204
|
+
const play = useCallback(() => setLive(true), []);
|
|
205
|
+
const seek = useCallback((where) => {
|
|
206
|
+
if (!history)
|
|
207
|
+
return;
|
|
208
|
+
killRef.current?.();
|
|
209
|
+
killRef.current = null;
|
|
210
|
+
setLive(false);
|
|
211
|
+
const events = history.at(where);
|
|
212
|
+
if (events && events.length) {
|
|
213
|
+
resetRef.current?.();
|
|
214
|
+
for (const ev of events)
|
|
215
|
+
applyRef.current(...ev.event);
|
|
216
|
+
posRef.current = events[events.length - 1].seq;
|
|
217
|
+
}
|
|
218
|
+
setPos(posRef.current);
|
|
219
|
+
setHead(readHead());
|
|
220
|
+
}, [history, applyRef, resetRef, readHead]);
|
|
221
|
+
return useMemo(() => ({ live, seq: pos, head, pause, play, seek }), [live, pos, head, pause, play, seek]);
|
|
222
|
+
}
|
|
@@ -1,3 +1,4 @@
|
|
|
1
|
+
export type tDirtyListener = (scope?: string, key?: string) => void;
|
|
1
2
|
export interface IServerSaveBasePromise {
|
|
2
3
|
set(key: string, value: object): Promise<boolean>;
|
|
3
4
|
get<T extends (object)>(key: string): Promise<T | null>;
|
|
@@ -23,6 +24,12 @@ export declare function CacheFuncMapBase(arr: [k: string, v: Map<string, unknown
|
|
|
23
24
|
saveDebounced(delay?: number): void;
|
|
24
25
|
flush(): Promise<void>;
|
|
25
26
|
clear(): Promise<void>;
|
|
27
|
+
/** Manual announcement for state the observable maps cannot see (plain Map entries). */
|
|
28
|
+
markDirty: (scope?: string, key?: string) => void;
|
|
29
|
+
/** Subscribe to this instance's dirty events (coalesced, async); returns unsubscribe. */
|
|
30
|
+
onDirty(cb: tDirtyListener): () => void;
|
|
31
|
+
/** Cheap hint (e.g. a beforeunload guard); the save diff is the source of truth. */
|
|
32
|
+
isDirty(): boolean;
|
|
26
33
|
getArr: [k: string, v: Map<string, unknown>][];
|
|
27
34
|
};
|
|
28
35
|
export declare function CacheFuncMap(arr: [k: string, v: Map<string, unknown>][]): {
|
|
@@ -31,5 +38,11 @@ export declare function CacheFuncMap(arr: [k: string, v: Map<string, unknown>][]
|
|
|
31
38
|
saveDebounced(delay?: number): void;
|
|
32
39
|
flush(): Promise<void>;
|
|
33
40
|
clear(): Promise<void>;
|
|
41
|
+
/** Manual announcement for state the observable maps cannot see (plain Map entries). */
|
|
42
|
+
markDirty: (scope?: string, key?: string) => void;
|
|
43
|
+
/** Subscribe to this instance's dirty events (coalesced, async); returns unsubscribe. */
|
|
44
|
+
onDirty(cb: tDirtyListener): () => void;
|
|
45
|
+
/** Cheap hint (e.g. a beforeunload guard); the save diff is the source of truth. */
|
|
46
|
+
isDirty(): boolean;
|
|
34
47
|
getArr: [k: string, v: Map<string, unknown>][];
|
|
35
48
|
};
|
|
Binary file
|
|
@@ -1,3 +1,4 @@
|
|
|
1
|
+
import { ObservableMap } from "./observableMap";
|
|
1
2
|
export declare function staticSet(key: any, data: object): void;
|
|
2
3
|
export declare function staticGet(key: any): object | undefined;
|
|
3
4
|
export declare function deepMergeWithMap(target: any, source: any, visited?: Map<any, any>): any;
|
|
@@ -6,6 +7,13 @@ export declare function staticGetAdd<T extends object>(key: any, def: T, options
|
|
|
6
7
|
deepAutoMerge?: boolean;
|
|
7
8
|
reversDeep?: boolean;
|
|
8
9
|
}): T;
|
|
10
|
+
/** Announce a direct in-place mutation of an object taken from staticGetAdd - such
|
|
11
|
+
* mutations are invisible to map methods. staticSet/staticGetAdd need no announcement:
|
|
12
|
+
* their set() calls are observed by Cash automatically. */
|
|
13
|
+
export declare function staticMarkDirty(key: any): void;
|
|
14
|
+
/** App-facing change of a persisted staticProps entry in one call:
|
|
15
|
+
* mutate + rerender subscribers + mark the cache dirty. No-op if the key is absent. */
|
|
16
|
+
export declare function staticUpdate<T extends object>(key: any, mutate: (cur: T) => void): T | undefined;
|
|
9
17
|
export declare function staticGetById<T extends object>(key: any, def: T, id: string | number): T;
|
|
10
18
|
export declare const Cash: {
|
|
11
19
|
load(): Promise<void>;
|
|
@@ -13,10 +21,13 @@ export declare const Cash: {
|
|
|
13
21
|
saveDebounced(delay?: number): void;
|
|
14
22
|
flush(): Promise<void>;
|
|
15
23
|
clear(): Promise<void>;
|
|
24
|
+
markDirty: (scope?: string, key?: string) => void;
|
|
25
|
+
onDirty(cb: import("./cache").tDirtyListener): () => void;
|
|
26
|
+
isDirty(): boolean;
|
|
16
27
|
getArr: [k: string, v: Map<string, unknown>][];
|
|
17
28
|
};
|
|
18
29
|
export declare const MemoryMap: {
|
|
19
|
-
rnd:
|
|
30
|
+
rnd: ObservableMap<string, {
|
|
20
31
|
position: {
|
|
21
32
|
x: number;
|
|
22
33
|
y: number;
|
|
@@ -26,10 +37,10 @@ export declare const MemoryMap: {
|
|
|
26
37
|
width: number | string;
|
|
27
38
|
};
|
|
28
39
|
}>;
|
|
29
|
-
resize:
|
|
40
|
+
resize: ObservableMap<string, {
|
|
30
41
|
height?: number | string;
|
|
31
42
|
width?: number | string;
|
|
32
43
|
}>;
|
|
33
|
-
rightMenu:
|
|
34
|
-
other:
|
|
44
|
+
rightMenu: ObservableMap<string, import("../components/Menu/RightMenuStore").MenuRightSavedState>;
|
|
45
|
+
other: ObservableMap<string, object>;
|
|
35
46
|
};
|
|
@@ -1,9 +1,12 @@
|
|
|
1
1
|
import { deepClone } from "wenay-common2";
|
|
2
|
+
import { renderBy } from "../../updateBy";
|
|
2
3
|
import { ExRNDMap3 } from "../components/Dnd/RNDFunc3";
|
|
3
4
|
import { mapResiReact } from "../components/Dnd/Resizable";
|
|
4
5
|
import { mapRightMenu } from "../components/Menu/RightMenuStore";
|
|
5
6
|
import { CacheFuncMap } from "./cache";
|
|
6
|
-
|
|
7
|
+
import { ObservableMap } from "./observableMap";
|
|
8
|
+
// observable - Cash marks itself dirty on its mutations
|
|
9
|
+
const staticProps = new ObservableMap();
|
|
7
10
|
export function staticSet(key, data) {
|
|
8
11
|
if (!staticProps.has(key))
|
|
9
12
|
staticProps.set(key, data);
|
|
@@ -52,6 +55,23 @@ export function staticGetAdd(key, def, options = { reversDeep: false }) {
|
|
|
52
55
|
const t = (staticProps.has(key) ? staticProps.get(key) : staticProps.set(key, def).get(key));
|
|
53
56
|
return t; // Object.assign(def, t) // t //
|
|
54
57
|
}
|
|
58
|
+
/** Announce a direct in-place mutation of an object taken from staticGetAdd - such
|
|
59
|
+
* mutations are invisible to map methods. staticSet/staticGetAdd need no announcement:
|
|
60
|
+
* their set() calls are observed by Cash automatically. */
|
|
61
|
+
export function staticMarkDirty(key) {
|
|
62
|
+
staticProps.touch(typeof key == "string" ? key : undefined);
|
|
63
|
+
}
|
|
64
|
+
/** App-facing change of a persisted staticProps entry in one call:
|
|
65
|
+
* mutate + rerender subscribers + mark the cache dirty. No-op if the key is absent. */
|
|
66
|
+
export function staticUpdate(key, mutate) {
|
|
67
|
+
const cur = staticProps.get(key);
|
|
68
|
+
if (cur === undefined)
|
|
69
|
+
return undefined;
|
|
70
|
+
mutate(cur);
|
|
71
|
+
renderBy(cur);
|
|
72
|
+
staticMarkDirty(key);
|
|
73
|
+
return cur;
|
|
74
|
+
}
|
|
55
75
|
export function staticGetById(key, def, id) {
|
|
56
76
|
const stored = staticProps.get(key);
|
|
57
77
|
if (!stored || stored.__id !== id) {
|
|
@@ -0,0 +1,17 @@
|
|
|
1
|
+
/** Map that announces its own mutations. The persisted-state maps (ExRNDMap3, mapResiReact,
|
|
2
|
+
* mapRightMenu, staticProps) are ObservableMap instances, so the dirty signal originates in
|
|
3
|
+
* the data layer itself: set/delete/clear emit automatically, touch(key) announces an
|
|
4
|
+
* in-place mutation of a stored object (invisible to map methods). CacheFuncMapBase
|
|
5
|
+
* subscribes to the maps it owns - mutation sites never talk to the cache directly. */
|
|
6
|
+
export type tMapChangeListener<K> = (key?: K) => void;
|
|
7
|
+
export declare class ObservableMap<K, V> extends Map<K, V> {
|
|
8
|
+
private listeners?;
|
|
9
|
+
/** Subscribe to mutations (set/delete/clear/touch); returns unsubscribe. */
|
|
10
|
+
onChange(cb: tMapChangeListener<K>): () => void;
|
|
11
|
+
/** Announce an in-place mutation of a stored value. */
|
|
12
|
+
touch(key?: K): void;
|
|
13
|
+
private emit;
|
|
14
|
+
set(key: K, value: V): this;
|
|
15
|
+
delete(key: K): boolean;
|
|
16
|
+
clear(): void;
|
|
17
|
+
}
|
|
@@ -0,0 +1,40 @@
|
|
|
1
|
+
/** Map that announces its own mutations. The persisted-state maps (ExRNDMap3, mapResiReact,
|
|
2
|
+
* mapRightMenu, staticProps) are ObservableMap instances, so the dirty signal originates in
|
|
3
|
+
* the data layer itself: set/delete/clear emit automatically, touch(key) announces an
|
|
4
|
+
* in-place mutation of a stored object (invisible to map methods). CacheFuncMapBase
|
|
5
|
+
* subscribes to the maps it owns - mutation sites never talk to the cache directly. */
|
|
6
|
+
export class ObservableMap extends Map {
|
|
7
|
+
// no initializer: Map's constructor calls this.set() before class fields are assigned
|
|
8
|
+
listeners;
|
|
9
|
+
/** Subscribe to mutations (set/delete/clear/touch); returns unsubscribe. */
|
|
10
|
+
onChange(cb) {
|
|
11
|
+
(this.listeners ??= new Set()).add(cb);
|
|
12
|
+
return () => { this.listeners?.delete(cb); };
|
|
13
|
+
}
|
|
14
|
+
/** Announce an in-place mutation of a stored value. */
|
|
15
|
+
touch(key) { this.emit(key); }
|
|
16
|
+
emit(key) {
|
|
17
|
+
if (!this.listeners?.size)
|
|
18
|
+
return;
|
|
19
|
+
// copy before iterating: an unsubscribe from inside a callback must not skip the rest
|
|
20
|
+
for (const cb of [...this.listeners])
|
|
21
|
+
cb(key);
|
|
22
|
+
}
|
|
23
|
+
set(key, value) {
|
|
24
|
+
super.set(key, value);
|
|
25
|
+
this.emit(key);
|
|
26
|
+
return this;
|
|
27
|
+
}
|
|
28
|
+
delete(key) {
|
|
29
|
+
const existed = super.delete(key);
|
|
30
|
+
if (existed)
|
|
31
|
+
this.emit(key);
|
|
32
|
+
return existed;
|
|
33
|
+
}
|
|
34
|
+
clear() {
|
|
35
|
+
const hadEntries = this.size > 0;
|
|
36
|
+
super.clear();
|
|
37
|
+
if (hadEntries)
|
|
38
|
+
this.emit();
|
|
39
|
+
}
|
|
40
|
+
}
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "wenay-react2",
|
|
3
|
-
"version": "1.0.
|
|
3
|
+
"version": "1.0.31",
|
|
4
4
|
"description": "Common react",
|
|
5
5
|
"strict": true,
|
|
6
6
|
"main": "dist/index.js",
|
|
@@ -23,7 +23,7 @@
|
|
|
23
23
|
"react": "^19.2.0",
|
|
24
24
|
"react-dom": "^19.2.0",
|
|
25
25
|
"react-rnd": "^10.5.3",
|
|
26
|
-
"wenay-common2": "^1.0.
|
|
26
|
+
"wenay-common2": "^1.0.56"
|
|
27
27
|
},
|
|
28
28
|
"peerDependencies": {
|
|
29
29
|
"react": "^19.2.0",
|