wenay-react2 1.0.40 → 1.0.41
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/doc/EXAMPLE_USAGE.md +915 -0
- package/doc/PROJECT_FUNCTIONALITY.md +401 -0
- package/doc/PROJECT_RULES.md +80 -0
- package/doc/WENAY_REACT2_RENAMES.md +177 -0
- package/doc/changes/v1.0.35.md +26 -0
- package/doc/changes/v1.0.37.md +13 -0
- package/doc/changes/v1.0.38.md +48 -0
- package/doc/changes/v1.0.39.md +35 -0
- package/doc/changes/v1.0.40.md +15 -0
- package/doc/changes/v1.0.41.md +13 -0
- package/doc/progress/README.md +14 -0
- package/doc/progress/hook-controller-opportunities.md +367 -0
- package/doc/progress/hook-extraction-audit.md +195 -0
- package/doc/progress/public-surface-normalization.md +368 -0
- package/doc/progress/style-system-normalization.md +121 -0
- package/doc/target/README.md +33 -0
- package/doc/target/my.md +109 -0
- package/doc/wenay-react2-1.0.8.tgz +0 -0
- package/doc/wenay-react2-rare.md +702 -0
- package/doc/wenay-react2.md +475 -0
- package/lib/common/src/logs/logs.d.ts +27 -0
- package/lib/common/src/logs/logs.js +76 -27
- package/lib/common/src/styles/tokens.d.ts +7 -6
- package/lib/common/src/styles/tokens.js +8 -5
- package/lib/style/menuRight.css +29 -23
- package/lib/style/style.css +15 -9
- package/lib/style/tokens.css +8 -4
- package/package.json +2 -1
|
@@ -0,0 +1,475 @@
|
|
|
1
|
+
# wenay-react2 - BRIEF cheat sheet (canonical UI/controller API)
|
|
2
|
+
|
|
3
|
+
> Root import: `import { ... } from "wenay-react2"`.
|
|
4
|
+
> Notation: `name(args) -> ret`. JSX examples show the intended public path, not every prop.
|
|
5
|
+
> Short controller-style names are canonical. Removed names are recorded in
|
|
6
|
+
> **WENAY_REACT2_RENAMES.md** for migration only; old aliases are not exported.
|
|
7
|
+
|
|
8
|
+
## Standard
|
|
9
|
+
```
|
|
10
|
+
Hooks that own lifecycle/state return a small controller:
|
|
11
|
+
api.props / api.bind // props to spread on an element
|
|
12
|
+
api.open() / close() / set() // modal/menu-like state
|
|
13
|
+
api.update() / remove() / clean() // data buffers
|
|
14
|
+
api.fit() / flush() / sync() // ag-grid lifecycle
|
|
15
|
+
|
|
16
|
+
Components receive controllers when state must outlive the component.
|
|
17
|
+
Library primitives never know app-specific terms, domain objects, or group policy.
|
|
18
|
+
Put app-specific layout/build rules in an app wrapper above the primitive.
|
|
19
|
+
```
|
|
20
|
+
|
|
21
|
+
## Render Memory
|
|
22
|
+
```
|
|
23
|
+
updateBy(obj) / useUpdateBy(obj) // subscribe current component to renderBy(obj)
|
|
24
|
+
renderBy(obj, ms?) // emit render for subscribers
|
|
25
|
+
renderByRevers(obj, ms?, reverse?=true)
|
|
26
|
+
renderByLast(obj, ms?)
|
|
27
|
+
|
|
28
|
+
const state = { count: 0 }
|
|
29
|
+
const api = createUpdateApi(state)
|
|
30
|
+
api.use() // hook subscription
|
|
31
|
+
api.emit(ms?) // renderBy(state)
|
|
32
|
+
api.on(obj => {}) -> off
|
|
33
|
+
|
|
34
|
+
const api2 = useUpdateByApi(state) // hook + controller in one call
|
|
35
|
+
```
|
|
36
|
+
|
|
37
|
+
Persistent process memory:
|
|
38
|
+
```
|
|
39
|
+
memoryGetOrCreate(key, def, {abs?, deepAutoMerge?, reversDeep?}) -> def-or-stored
|
|
40
|
+
memoryGetById(key, def, id) -> stored only while id is the same
|
|
41
|
+
memorySet(key, data)
|
|
42
|
+
memoryGet(key)
|
|
43
|
+
memoryUpdate(key, mutate) -> cur? // mutate + rerender + announce in one call
|
|
44
|
+
memoryMarkDirty(key) // announce an in-place mutation of a memoryGetOrCreate object
|
|
45
|
+
createSearchHistory({key, max?}) // reusable small persisted search history controller
|
|
46
|
+
memoryMaps // rnd / resize / other maps
|
|
47
|
+
```
|
|
48
|
+
|
|
49
|
+
Persistence contract (memoryCache): the library NEVER writes storage by itself. The persisted maps
|
|
50
|
+
(floatingWindowMap, mapResiReact, mapRightMenu, memoryProps) are `ObservableMap`s - set/delete/clear
|
|
51
|
+
announce themselves, in-place mutations are announced at the commit points (drag/resize stop,
|
|
52
|
+
menu drag end, setPlace). memoryCache observes the maps it owns; the app owns the write policy:
|
|
53
|
+
```
|
|
54
|
+
memoryCache.load() // once on start; remembers the saved snapshot
|
|
55
|
+
memoryCache.onDirty((scope?, key?) => ...) -> off // dirty channel (coalesced, async)
|
|
56
|
+
memoryCache.saveDebounced(ms?) / save() / flush() // write only payloads that differ from the snapshot
|
|
57
|
+
memoryCache.isDirty() // cheap hint, e.g. a beforeunload guard
|
|
58
|
+
|
|
59
|
+
memoryCache.load()
|
|
60
|
+
const off = memoryCache.onDirty(() => memoryCache.saveDebounced(800))
|
|
61
|
+
document.addEventListener("visibilitychange",
|
|
62
|
+
() => { if (document.visibilityState == "hidden") void memoryCache.flush() })
|
|
63
|
+
window.addEventListener("pagehide", () => { void memoryCache.flush() }) // backstop: flush is async
|
|
64
|
+
```
|
|
65
|
+
|
|
66
|
+
## Outside Click / Buttons
|
|
67
|
+
```
|
|
68
|
+
const outside = useOutside({onOutside, enabled?})
|
|
69
|
+
<div {...outside.props} />
|
|
70
|
+
outside.enable()
|
|
71
|
+
outside.disable()
|
|
72
|
+
outside.contains(event.target)
|
|
73
|
+
|
|
74
|
+
<OutsideClickArea outsideClick={close} status={open}>...</OutsideClickArea>
|
|
75
|
+
|
|
76
|
+
<Button button={<button>Open</button>} outClick>{...}</Button>
|
|
77
|
+
<OutsideButton button={...}>{...}</OutsideButton>
|
|
78
|
+
<HoverButton button={...}>{...}</HoverButton>
|
|
79
|
+
<AbsoluteButton button={...}>{...}</AbsoluteButton>
|
|
80
|
+
```
|
|
81
|
+
|
|
82
|
+
## Drag / Floating Windows
|
|
83
|
+
```
|
|
84
|
+
const drag = useDraggableApi({initialPosition, holdMs?, onDragStart?, onDragEnd?})
|
|
85
|
+
<div {...drag.bind} style={{transform: `translate(${drag.position.x}px, ${drag.position.y}px)`}} />
|
|
86
|
+
drag.setPosition({x, y})
|
|
87
|
+
drag.resetPosition()
|
|
88
|
+
drag.cancelDrag()
|
|
89
|
+
|
|
90
|
+
const r = useReorder({order, commit, move?, canDrag?, preview?, holdMs?}) // mini reorder-by-drag
|
|
91
|
+
<div ref={r.listRef}>{order.map(k => { // children 1:1 with order
|
|
92
|
+
const it = r.item(k) // it.dragging / it.active
|
|
93
|
+
return <div key={k} {...it.props} style={{...own, ...it.style}} /> // ONE commit(next) on drop
|
|
94
|
+
})}</div>
|
|
95
|
+
|
|
96
|
+
const b = useReorderBoard({columns: [{key, items}], commit, // drag between vertical columns
|
|
97
|
+
canDrag?, holdMs?, onDragStart?, onDragMove?, onOverChange?, onDragEnd?})
|
|
98
|
+
<div ref={b.columnRef('todo')}>{items.map(k => ...b.item(k)...)}</div> // one div per column, any count
|
|
99
|
+
b.over // {col, index} | null - live target
|
|
100
|
+
// column gravity is YOUR CSS: justify-content flex-start packs up, flex-end packs down
|
|
101
|
+
|
|
102
|
+
<FloatingWindow keyForSave="tool" size={{width: 320, height: 240}} header={<div>Tool</div>}>
|
|
103
|
+
<Panel />
|
|
104
|
+
</FloatingWindow>
|
|
105
|
+
|
|
106
|
+
const wnd = useFloatingWindowController({keyForSave: "custom-tool", size: {width: 320, height: 240}})
|
|
107
|
+
wnd.position // {x,y}; bind wnd.onHeaderMouseDown/onHeaderTouchStart for custom chrome
|
|
108
|
+
```
|
|
109
|
+
|
|
110
|
+
## Modal / Input
|
|
111
|
+
```
|
|
112
|
+
<ModalProvider>
|
|
113
|
+
<App />
|
|
114
|
+
</ModalProvider>
|
|
115
|
+
|
|
116
|
+
const modal = useModal()
|
|
117
|
+
modal.open(<Dialog />)
|
|
118
|
+
modal.set(<Dialog />)
|
|
119
|
+
modal.close()
|
|
120
|
+
modal(null) // callable shortcut for clearing
|
|
121
|
+
```
|
|
122
|
+
|
|
123
|
+
Input helpers:
|
|
124
|
+
```
|
|
125
|
+
const text = useTextInputPanel({callback: txt => {}, txt: ""}) // headless value + submit API
|
|
126
|
+
const file = useFileInputPanel({callback: file => {}}) // headless file + submit API
|
|
127
|
+
<TextInputPanel callback={txt => {}} name="Name" txt="" /> // thin visual wrapper over hook
|
|
128
|
+
<FileInputPanel callback={file => {}} name="File" /> // thin visual wrapper over hook
|
|
129
|
+
<TextInputModal callback={...} outClick={modal.close} />
|
|
130
|
+
<FileInputModal callback={...} outClick={modal.close} />
|
|
131
|
+
<FreeModal outClick={modal.close} size={{width: 400, height: 260}}>...</FreeModal>
|
|
132
|
+
```
|
|
133
|
+
|
|
134
|
+
## Settings Dialog
|
|
135
|
+
```
|
|
136
|
+
<SettingsDialog trigger={<span>settings</span>} sections={[{key, name, render, children?, parentKey?, searchText?, keywords?}]} defaultSection? /> // searchable tree + persisted search history; history closes when search focus leaves
|
|
137
|
+
const settings = useSettingsDialogController({sections, defaultSection?}) // open/search/tree/history/resize actions for custom chrome
|
|
138
|
+
registerSettingsSection({key, name, render, parentKey?, searchText?, keywords?}) -> unregister
|
|
139
|
+
```
|
|
140
|
+
|
|
141
|
+
## UI Slot
|
|
142
|
+
```
|
|
143
|
+
const slot = createUiSlot({key, places: {top: "Top bar", side: "Sidebar"}, def: "top"})
|
|
144
|
+
|
|
145
|
+
<slot.Slot place="top">{content}</slot.Slot> // each mount point decides by itself
|
|
146
|
+
<slot.PlacementSetting /> // segmented place switcher; setPlace marks memoryCache dirty
|
|
147
|
+
```
|
|
148
|
+
|
|
149
|
+
## Toolbar
|
|
150
|
+
```
|
|
151
|
+
const tb = createToolbar({key, items: [{key, title, icon?, short?, render?, onClick?, defaultVisible?, fixed?}], def?, settingsItem?, resetItem?, source?, sourceMode?})
|
|
152
|
+
|
|
153
|
+
<tb.Bar settings reset? /> // the live bar; settings adds a gear; reset is hidden by default; item moves animate
|
|
154
|
+
<tb.Settings /> // the PURE config editor - same element drops into a settings section
|
|
155
|
+
tb.api.useConfig() / getConfig() / setConfig(next) / setOrder(order) / show(key,on) / setDensity(key) / reset()
|
|
156
|
+
tb.api.showSettings(on) / showReset(on) // pseudo-controls visibility
|
|
157
|
+
tb.api.useItems() // headless bar: ordered visible [{item, density, content}] - custom markup
|
|
158
|
+
tb.api.onChange.on(cfg => ...) -> off // fires on every edit
|
|
159
|
+
|
|
160
|
+
registerToolbarDensity({key, name, renderItem?}) -> unregister // built-ins: 'icon', 'label'
|
|
161
|
+
toolbarItemIcon(item) // the item's icon, or its first letters as a text pseudo-icon
|
|
162
|
+
```
|
|
163
|
+
Config `{order, visible, density}` is serializable and persisted like createUiSlot
|
|
164
|
+
(memoryGetOrCreate -> memoryCache, the app owns the write policy). Items added in an app update are merged
|
|
165
|
+
into a stale persisted config (appended, default-visible), removed ones are ignored. `icon` is
|
|
166
|
+
optional: in icon density an icon-less item renders the first letters of its short/title.
|
|
167
|
+
The gear and reset button are pseudo-items: `settingsItem: {title?, icon?}` and `resetItem` re-skin them,
|
|
168
|
+
and the editor has separated toggle rows for them (`visible['__settings']` / `visible['__reset']`, no order slots - they always sit at the bar edge). Settings is visible by default; reset is hidden by default unless `resetItem.defaultVisible: true` or `api.showReset(true)` enables it. `resetItem: false` removes the reset feature.
|
|
169
|
+
|
|
170
|
+
`source?` makes the toolbar a VIEW over an external `UiListSource`. Default `sourceMode:"orderVisible"`
|
|
171
|
+
means the source owns item order and item visibility, so one config can drive a grid, its icon menu
|
|
172
|
+
AND the toolbar together (`columnState.api.listSource`, QA card 31). `sourceMode:"order"` shares only
|
|
173
|
+
the source-key order; item membership, density, pseudo-controls, and extra non-source item positions
|
|
174
|
+
stay in the toolbar store (QA card 30). Without `source` the toolbar keeps its own store (standalone mode).
|
|
175
|
+
|
|
176
|
+
## Callback Hub
|
|
177
|
+
```
|
|
178
|
+
const hub = createCallbackHub<[Args]>(emit => api.onX(emit)) // one slot -> many subscribers
|
|
179
|
+
hub.on(cb) -> off
|
|
180
|
+
```
|
|
181
|
+
|
|
182
|
+
## Params
|
|
183
|
+
```
|
|
184
|
+
<ParamsEditor
|
|
185
|
+
params={params}
|
|
186
|
+
onChange={next => setParams(next)}
|
|
187
|
+
onExpand={next => setParams(next)}
|
|
188
|
+
expandStatus?
|
|
189
|
+
expandStatusLvl?
|
|
190
|
+
/>
|
|
191
|
+
|
|
192
|
+
const controller = useParamsEditorController({params, onChange, onExpand?})
|
|
193
|
+
|
|
194
|
+
<ParamsEdit params={params} onSave={next => save(next)} />
|
|
195
|
+
<ParamRow name="Name">...</ParamRow>
|
|
196
|
+
```
|
|
197
|
+
|
|
198
|
+
## Menu
|
|
199
|
+
```
|
|
200
|
+
type MenuItem = { name, actionKey?, onClick?, next?, func?, status? } | false | null | undefined
|
|
201
|
+
|
|
202
|
+
<Menu data={items} coordinate={{x: 0, y: 0}} />
|
|
203
|
+
|
|
204
|
+
<contextMenu.Layer>{children}</contextMenu.Layer>
|
|
205
|
+
contextMenu.openAt(event, [{name: "Copy", actionKey: "grid.copy", onClick: copy}], {source: "grid"})
|
|
206
|
+
contextMenu.stats.getSnapshot() // local counters: opens, source/layer usage, actionTotals, keyed actions
|
|
207
|
+
contextMenu.close()
|
|
208
|
+
```
|
|
209
|
+
|
|
210
|
+
Use `contextMenu.openAt(e, items, {source?})` for new right-click integrations. `contextMenu.map` still exists as a legacy Layer queue, but it is not the recommended path for new code. `contextMenu.stats` is an in-memory diagnostics API; it never persists data, never reports over the network, and records per-action counters only for explicit `actionKey` values. Unkeyed items are counted in aggregate totals without storing labels.
|
|
211
|
+
|
|
212
|
+
`Menu` keeps the hovered/open item in internal React state. `status` is only an initial open hint and a compatibility field passed to custom renderers; menu objects are not mutated.
|
|
213
|
+
|
|
214
|
+
Floating right menu:
|
|
215
|
+
```
|
|
216
|
+
<DropdownMenu elements={items} trigger={iconOrRender} classNames={...} styles={...} />
|
|
217
|
+
const menu = useRightMenuController({elements, keyForSave?})
|
|
218
|
+
```
|
|
219
|
+
|
|
220
|
+
`DropdownMenu` is a floating action menu with caller-owned trigger/content styling, not the main context-menu primitive. `useRightMenuController` exposes the same open/fixed/select/submenu/drag state for custom views; `DropdownMenu` remains the compatible visual wrapper.
|
|
221
|
+
|
|
222
|
+
## agGrid4
|
|
223
|
+
Plain declarative rows:
|
|
224
|
+
```
|
|
225
|
+
<AgGridTable<Row> rowData={rows} columnDefs={cols} />
|
|
226
|
+
```
|
|
227
|
+
|
|
228
|
+
Buffered mirror table:
|
|
229
|
+
```
|
|
230
|
+
const grid = useAgGrid<Row>({getId: row => row.id})
|
|
231
|
+
<AgGridTable<Row> controller={grid} columnDefs={cols} />
|
|
232
|
+
|
|
233
|
+
grid.update({newData: rows})
|
|
234
|
+
grid.remove([{id}])
|
|
235
|
+
grid.clean()
|
|
236
|
+
grid.fit()
|
|
237
|
+
grid.flush()
|
|
238
|
+
```
|
|
239
|
+
|
|
240
|
+
Shared module store:
|
|
241
|
+
```
|
|
242
|
+
export const mainTable = createGridBuffer<Row>({getId, externalBuffer})
|
|
243
|
+
|
|
244
|
+
const grid = useAgGrid({core: mainTable})
|
|
245
|
+
<AgGridTable<Row> controller={grid} columnDefs={cols} />
|
|
246
|
+
```
|
|
247
|
+
|
|
248
|
+
Overlay patches over React-owned `rowData`:
|
|
249
|
+
```
|
|
250
|
+
const core = createGridBuffer<Row>({getId, mode: "overlay", pushDefaults: {add: false}})
|
|
251
|
+
const grid = useAgGrid({core})
|
|
252
|
+
|
|
253
|
+
<AgGridTable<Row> controller={grid} rowData={rows} columnDefs={cols} getRowId={p => getId(p.data)} />
|
|
254
|
+
```
|
|
255
|
+
|
|
256
|
+
Dynamic columns are pure names + lifecycle replay:
|
|
257
|
+
```
|
|
258
|
+
const columns = createColumnBuffer<Row>()
|
|
259
|
+
|
|
260
|
+
columns.api.setNames(["a", "b"])
|
|
261
|
+
columns.control.attach(api, {
|
|
262
|
+
apply: ({api, names}) => api.setGridOption("columnDefs", buildColumnDefs(names)),
|
|
263
|
+
})
|
|
264
|
+
columns.api.apply()
|
|
265
|
+
columns.control.detach()
|
|
266
|
+
```
|
|
267
|
+
|
|
268
|
+
`createColumnBuffer()` knows nothing about groups, `columnDefs`, business names, or where columns live.
|
|
269
|
+
|
|
270
|
+
|
|
271
|
+
## columnState
|
|
272
|
+
A persisted column layer over a keyed column set - order / visibility / width / sort / filter in a
|
|
273
|
+
standalone config store, with an OPTIONAL two-way ag-grid adapter. Mobile card views consume the
|
|
274
|
+
SAME config with no ag-grid at all. agGrid4 wrappers are never modified: this is the app-level
|
|
275
|
+
column wrapper `WRAPPER.md` postulates, packaged as a primitive.
|
|
276
|
+
```
|
|
277
|
+
const cs = createColumnState({key, columns: [{key, title, short?, icon?, group?, fixed?, defaultVisible?, cardRole?}], def?, saveMs?})
|
|
278
|
+
|
|
279
|
+
cs.columns // the descriptors (UI renders from these + config)
|
|
280
|
+
cs.api.useConfig() / getConfig() / setConfig(next) / reset()
|
|
281
|
+
cs.api.show(key, on) / move(order) / setSort({key, dir} | null) / toggleSort(key) // asc->desc->off
|
|
282
|
+
cs.api.visibleKeys() // keys to render, in order (group-gated)
|
|
283
|
+
cs.api.onChange.on(cfg => ...) -> off
|
|
284
|
+
cs.api.usePresent() / isPresent(key) / setPresent(keys | null) // live-grid presence
|
|
285
|
+
cs.api.getPresentGate() / setPresentGate(keys | null) // app runtime availability gate, not persisted
|
|
286
|
+
cs.api.listSource // {order, visible} slice as a Toolbar `source`
|
|
287
|
+
```
|
|
288
|
+
Config `{v, order, visible, width, sort, filter, groups}` is serializable and persisted like
|
|
289
|
+
createToolbar (memoryGetOrCreate(key) -> memoryCache, the app owns the write policy); a stale config migrates
|
|
290
|
+
softly (unknown keys dropped, new columns appended default-visible, `fixed` pinned). `sort` is
|
|
291
|
+
STICKY: independent of visibility/selection, may point at a hidden or grid-absent column, changes
|
|
292
|
+
only by an explicit toggle/header click.
|
|
293
|
+
|
|
294
|
+
Two-way ag-grid adapter (opt-in, agGrid4 untouched):
|
|
295
|
+
```
|
|
296
|
+
<AgGridTable columnDefs={cols} autoSizeColumns={false}
|
|
297
|
+
onGridReady={e => cs.grid.attach(e.api)} // restore saved layout, then watch the grid
|
|
298
|
+
onGridPreDestroyed={() => cs.grid.detach()} /> // config survives remounts
|
|
299
|
+
```
|
|
300
|
+
Grid drag/resize/hide/sort/filter fold back into the config; UI edits apply to the grid; a column
|
|
301
|
+
removed from the live grid (dynamic defs) keeps its config entry and reads `isPresent==false`.
|
|
302
|
+
|
|
303
|
+
Icon menu - a 1:1 grid mirror (reorder in the grid <-> reorder the buttons):
|
|
304
|
+
```
|
|
305
|
+
<ColumnsMenu state={cs} compact? marks? tail? onItem? onTail? reorder? /> // default click = toggle visibility
|
|
306
|
+
<MenuStrip items={[{key, title, state:'on'|'off'|'disabled', marks?, fixed?}]} onItem? onMove? tail? compact? />
|
|
307
|
+
```
|
|
308
|
+
`ColumnsMenu` binds `MenuStrip` - a presentation-only layer that reports clicks/drags but never
|
|
309
|
+
interprets them - to the config. `compact` = icon-only buttons; an item with no icon shows its
|
|
310
|
+
first letters. `tail` = trailing non-column buttons (a table-standards cycler etc.). For new
|
|
311
|
+
settings-integrated toolbar/grid surfaces prefer `createToolbar({source: cs.api.listSource})`;
|
|
312
|
+
keep `ColumnsMenu` for compact button strips or custom presentation-only menus.
|
|
313
|
+
|
|
314
|
+
Mobile (no ag-grid): dots selector + card rows over the same config:
|
|
315
|
+
```
|
|
316
|
+
<ColumnDots state={cs} max?=8 /> // track of marks; dots = shown columns; tap empty=add,
|
|
317
|
+
// drag=replace, swipe up=remove, tap dot=select, sort button cycles
|
|
318
|
+
<CardList<Row> state={cs} data={rows} getId? renderValue? /> // visible cols -> card fields; cardRole:'title'/'accent'
|
|
319
|
+
```
|
|
320
|
+
QA cards 28 (grid layer + F5 restore), 29 (mobile dots + cards), 30 (toolbar icon menu + grouped sub-column mode button), 31 (Toolbar over the same config).
|
|
321
|
+
|
|
322
|
+
|
|
323
|
+
## Observe React Adapter
|
|
324
|
+
`wenay-common2` owns the store/listen/RPC primitives. `wenay-react2` owns React lifecycle hooks around them.
|
|
325
|
+
|
|
326
|
+
```ts
|
|
327
|
+
import { Observe } from "wenay-common2"
|
|
328
|
+
import { useStoreMirror, useStoreNode, useStoreKeys, useStoreSelect, useStoreChangedPaths, useListenEffect, useListenValue } from "wenay-react2"
|
|
329
|
+
```
|
|
330
|
+
|
|
331
|
+
Node subscription:
|
|
332
|
+
```
|
|
333
|
+
const price = useStoreNode(store.node.data.BTC)
|
|
334
|
+
price.value
|
|
335
|
+
price.exists
|
|
336
|
+
price.replace(123)
|
|
337
|
+
price.refresh()
|
|
338
|
+
```
|
|
339
|
+
|
|
340
|
+
Selection subscription:
|
|
341
|
+
```
|
|
342
|
+
const sel = useStoreSelect(store.update({data: {BTC: true}, meta: {status: true}}))
|
|
343
|
+
sel.value
|
|
344
|
+
sel.get()
|
|
345
|
+
```
|
|
346
|
+
|
|
347
|
+
Network mirror is explicit:
|
|
348
|
+
```
|
|
349
|
+
const mirror = useStoreMirror(remoteStore, {data: {}, meta: {}}, {
|
|
350
|
+
mask: {data: {BTC: true}, meta: {status: true}},
|
|
351
|
+
current: true,
|
|
352
|
+
drain: 250,
|
|
353
|
+
})
|
|
354
|
+
|
|
355
|
+
mirror.value
|
|
356
|
+
mirror.ready
|
|
357
|
+
mirror.sync()
|
|
358
|
+
mirror.stop()
|
|
359
|
+
```
|
|
360
|
+
|
|
361
|
+
Per-key feed (`store.each()` of common2 — one cb per CHANGED top-level key):
|
|
362
|
+
```
|
|
363
|
+
useStoreEach(store, (key, value, ctx) => {
|
|
364
|
+
value === undefined ? removeRow(key) : upsertRow(key, value)
|
|
365
|
+
}, {enabled?})
|
|
366
|
+
```
|
|
367
|
+
`undefined` = key deleted; a root replace (store.replace / mirror keyframe) expands into one call per key. The fold target should live outside React state (ref/Map/grid api) — the hook renders nothing itself. The wire counterpart is `useStoreReplayEach` below.
|
|
368
|
+
|
|
369
|
+
Listen helpers:
|
|
370
|
+
```
|
|
371
|
+
useListenEffect(listen, (...args) => {})
|
|
372
|
+
const value = useListenValue(listen, {initial})
|
|
373
|
+
const args = useListenArgs(listen)
|
|
374
|
+
```
|
|
375
|
+
|
|
376
|
+
The hook does not choose transport. `remoteStore` needs `{ get(mask?), changed }` and may also provide `changedPaths`; apps may implement it with RPC, WebSocket, SSE, or test HTTP. `changedPaths` is used as a transport optimization: mirror pulls `mask ∩ paths` instead of the whole mask. `initial` is only the local mirror seed; changing it does not reset an existing mirror. `mask` is treated as a small declarative StoreMask, so structurally equal inline masks do not resubscribe. For add/delete keys, subscribe to the parent object node with `useStoreKeys(node)`; this also covers deep objects. If a state key conflicts with StoreNode methods such as `count`, use `store.node.at("count")`.
|
|
377
|
+
|
|
378
|
+
## Replay React Adapter
|
|
379
|
+
Client-side hooks over the wenay-common2 Replay stack (snapshot + sequenced delta line). Server parts (`conflateReplay`, `archiveReplay`, `createRpcServerAuto` replayOpts) are per-connection and stay hook-free by design.
|
|
380
|
+
|
|
381
|
+
```ts
|
|
382
|
+
import { useReplaySubscribe, useReplayRouteSubscribe, useStoreReplaySync, useStoreReplayMirror, useStoreReplayRouteSync, useStoreReplayRouteMirror, useStoreReplayEach, useReplayFrame, useReplayHistory } from "wenay-react2"
|
|
383
|
+
|
|
384
|
+
// any replay line ({line, since, keyframe, frame?, frameLine?} remote)
|
|
385
|
+
const sub = useReplaySubscribe(remote, (frame) => draw(frame), {onSeq?, onError?, since?, enabled?, staleMs?, onStale?, policy?, hint?})
|
|
386
|
+
sub.ready; sub.error; sub.seq(); sub.restart()
|
|
387
|
+
sub.stale; sub.lastTs() // freshness (needs staleMs): stale re-renders on fresh<->stale transitions ONLY; lastTs() is a getter like seq()
|
|
388
|
+
// policy: 'queue' (default, nothing skipped) | 'frame' (rides the server frameLine when present: on lag the
|
|
389
|
+
// server drops and recovers with a mini-frame; old servers/in-proc degrade to 'queue'). hint -> the line's frame condenser.
|
|
390
|
+
|
|
391
|
+
// explicit route hand-off over the same logical replay line (relay <-> direct, etc.)
|
|
392
|
+
const routed = useReplayRouteSubscribe(relayRemote, (frame) => draw(frame), {label: "relay", onRoute?})
|
|
393
|
+
await routed.switchRoute(directRemote, {label: "direct"})
|
|
394
|
+
routed.ready; routed.switching; routed.route; routed.seq(); routed.label(); routed.active()
|
|
395
|
+
// route hooks do not expose stale/lastTs yet; use non-route hooks when freshness is required.
|
|
396
|
+
// store patch line (Observe.exposeStoreReplay(...).api.replay)
|
|
397
|
+
const mirror = useStoreReplayMirror(remote, initial, {enabled?}) // creates the mirror store; same staleMs/stale/policy surface
|
|
398
|
+
mirror.store; mirror.ready; mirror.seq(); mirror.restart()
|
|
399
|
+
const sync = useStoreReplaySync(existingStore, remote) // same, store supplied by the app
|
|
400
|
+
const routedSync = useStoreReplayRouteSync(existingStore, relayRemote, {label: "relay"}) // same store, switchRoute() replaces the route after catch-up
|
|
401
|
+
const routedMirror = useStoreReplayRouteMirror(relayRemote, initial, {label: "relay"}) // mirror store + route hand-off controller
|
|
402
|
+
|
|
403
|
+
// per-key fold over the same line (Observe.syncStoreReplayEach counterpart): cb per CHANGED top-level key,
|
|
404
|
+
// first delivery = keyframe expanded per key, (key, undefined) = deleted — grid rows without special cases
|
|
405
|
+
const feed = useStoreReplayEach<Rows>(remote, (key, row) => row === undefined ? removeRow(key) : upsertRow(key, row), {drain?, initial?})
|
|
406
|
+
feed.store; feed.ready; feed.stale; feed.seq(); feed.restart() // same controller surface as the mirror
|
|
407
|
+
// the mirror store lives in a ref: in-mount resubscribes (StrictMode/restart/enabled) reconnect by tail ON TOP of
|
|
408
|
+
// kept state — the consumer sees only the diff. After a full unmount: {since: prev.seq(), initial: prev.store.snapshot()}
|
|
409
|
+
|
|
410
|
+
// pull at YOUR pace (frame model): timer around remote.frame(seq, hint) — server condenses, no backlog, no live socket sub
|
|
411
|
+
const pf = useReplayFrame(remote, (quote) => apply(quote), {intervalMs: 500, hint?})
|
|
412
|
+
pf.ready; pf.error; pf.seq(); pf.pull(); pf.restart() // error (e.g. sacred line evicted) STOPS pulling until restart()
|
|
413
|
+
|
|
414
|
+
// time machine over Replay.openHistory(storage, live?)
|
|
415
|
+
const tt = useReplayHistory(history, (frame) => draw(frame), {head: () => replay.head()})
|
|
416
|
+
tt.live; tt.seq; tt.head; tt.pause(); tt.play(); tt.seek({seq})
|
|
417
|
+
```
|
|
418
|
+
|
|
419
|
+
Contract: `off()` on unmount, StrictMode-safe; seq survives resubscribes inside one mount (keepSeq, default on) — a resubscribe reconnects with `{since}` and gets the journal tail, not a keyframe. Across a FULL unmount/remount keep the position outside via `onSeq` and pass it back as `since`. `seq()` is a getter — high-frequency lines (video frames, ticks) do not re-render per event; draw to canvas via ref, bypassing VDOM. Freshness: detection lives in wenay-common2 (`staleMs` watchdog); the non-route hooks mirror its edge-triggered `onStale` into `stale`, so a fresh 100 ev/s line causes zero extra renders. Route hand-off is explicit through `switchRoute(nextRemote, {label?, since?, reset?, policy?, hint?})`: old route stays live while the replacement catches up by `seq`, then closes. `useReplayHistory` is archive playback — staleness does not apply. QA cards 23 (video line + conflation + time travel + freshness), 24 (store sync), 25 (per-key feed), and 26 (route hand-off) are the live examples.
|
|
420
|
+
|
|
421
|
+
## Logs
|
|
422
|
+
```
|
|
423
|
+
logsApi.addLogs({id: "api", time: new Date(), txt: "done", var: 1})
|
|
424
|
+
<logsApi.React.Message />
|
|
425
|
+
<logsApi.React.PageLogs />
|
|
426
|
+
<logsApi.React.Setting />
|
|
427
|
+
|
|
428
|
+
const customLogs = getLogsApi<MyFields>({limitPer: 500, limit?: 50, varMin?: 0})
|
|
429
|
+
const headless = createLogsController<MyFields>({options: {limitPer: 500, limit: 50}})
|
|
430
|
+
|
|
431
|
+
const messageNotifications = useMessageEventLogsController({maxVisible: 4})
|
|
432
|
+
<MessageEventLogsView controller={messageNotifications} zIndex={80} />
|
|
433
|
+
|
|
434
|
+
const contextTable = useLogsTableController()
|
|
435
|
+
const contextNotifications = useLogsNotificationsController()
|
|
436
|
+
|
|
437
|
+
const mini = useMiniLogsTable({data: rows, onClick: e => console.log(e.data)})
|
|
438
|
+
<AgGridTable {...mini.props} />
|
|
439
|
+
<MiniLogsTable data={rows} />
|
|
440
|
+
```
|
|
441
|
+
|
|
442
|
+
Logger notification/tabs chrome uses `--logs-*` CSS variables; apps can theme it without forking logger components. `createLogsController` owns the headless append/limit/settings state for custom integrations. `useMessageEventLogsController` owns the global notification queue/timers/settings, `MessageEventLogsView` draws it, and `MessageEventLogs` / `logsApi.React.Message` remain compatibility wrappers. `MiniLogs` remains the compatibility wrapper; new compact-table code can use `useMiniLogsTable` or `MiniLogsTable`.
|
|
443
|
+
|
|
444
|
+
## Styles / Theme
|
|
445
|
+
```
|
|
446
|
+
tokens.color.bgDark
|
|
447
|
+
tokens.menu.outlineColor
|
|
448
|
+
tokens.logs.notificationAccent
|
|
449
|
+
tokens.zIndex.modal
|
|
450
|
+
|
|
451
|
+
GridStyleDefault() // inject legacy grid CSS vars/classes
|
|
452
|
+
StyleGridDefault // common ag-grid style object
|
|
453
|
+
buildAgTheme("dark" | "light")
|
|
454
|
+
useAgGridTheme("dark" | "light")
|
|
455
|
+
colDefCentered / colDefWrap / numericComparator
|
|
456
|
+
```
|
|
457
|
+
|
|
458
|
+
Shared CSS variables include `--menu-outline-color`, `--logs-*`, `--dlg-*`, `--wnd-*`, `--tb-*`, `--cols-menu-*`, `--cols-dots-*`, and `--cols-card-*`.
|
|
459
|
+
|
|
460
|
+
## Charts
|
|
461
|
+
```
|
|
462
|
+
<MyChartEngine style={{height: 400}} />
|
|
463
|
+
createChartCanvas(config) -> canvas controller
|
|
464
|
+
```
|
|
465
|
+
|
|
466
|
+
The chart engine surface is still mostly low-level. Keep product-specific chart wrappers in the app.
|
|
467
|
+
|
|
468
|
+
## QA Stand
|
|
469
|
+
```
|
|
470
|
+
npm run testReact // http://localhost:3010/
|
|
471
|
+
```
|
|
472
|
+
|
|
473
|
+
The stand lives in `src/common/testUseReact/qa.tsx`. Use it for visual checks; agGrid4 overlay/dynamic-column demos are dedicated QA cards.
|
|
474
|
+
Two tabs: Active checks (current work) and Verified archive (`#archive`) - verified/fixed cards move
|
|
475
|
+
there and stay runnable for regression re-checks.
|
|
@@ -1,3 +1,4 @@
|
|
|
1
|
+
import React from "react";
|
|
1
2
|
import { getSettingLogs, type LogEntry, type LogsApiOptions } from "./logsController";
|
|
2
3
|
export { createLogsController, createLogsControllerState, getSettingLogs, } from "./logsController";
|
|
3
4
|
export type { CreateLogsControllerOptions, LogsApiOptions, LogsController, LogsControllerState, LogsFullState, LogsMiniState, } from "./logsController";
|
|
@@ -38,6 +39,32 @@ declare function InputSettingLogs({}: {
|
|
|
38
39
|
export declare function PageLogs({ update }: {
|
|
39
40
|
update?: number;
|
|
40
41
|
}): import("react/jsx-runtime").JSX.Element;
|
|
42
|
+
export type MessageEventLogsItem = {
|
|
43
|
+
key: string;
|
|
44
|
+
logs: LogEntry;
|
|
45
|
+
};
|
|
46
|
+
export type UseMessageEventLogsControllerOptions = {
|
|
47
|
+
maxVisible?: number;
|
|
48
|
+
};
|
|
49
|
+
export type MessageEventLogsController = {
|
|
50
|
+
show: boolean;
|
|
51
|
+
setShow(value: boolean): void;
|
|
52
|
+
toggleShow(): void;
|
|
53
|
+
notifications: MessageEventLogsItem[];
|
|
54
|
+
visibleNotifications: MessageEventLogsItem[];
|
|
55
|
+
maxVisible: number;
|
|
56
|
+
};
|
|
57
|
+
export type MessageEventLogsViewProps = {
|
|
58
|
+
controller: MessageEventLogsController;
|
|
59
|
+
zIndex?: number;
|
|
60
|
+
className?: string;
|
|
61
|
+
style?: React.CSSProperties;
|
|
62
|
+
};
|
|
63
|
+
export declare function MessageEventLogCard({ logs }: {
|
|
64
|
+
logs: LogEntry;
|
|
65
|
+
}): import("react/jsx-runtime").JSX.Element;
|
|
66
|
+
export declare function useMessageEventLogsController(options?: UseMessageEventLogsControllerOptions): MessageEventLogsController;
|
|
67
|
+
export declare function MessageEventLogsView({ controller, zIndex, className, style }: MessageEventLogsViewProps): import("react/jsx-runtime").JSX.Element;
|
|
41
68
|
export declare function MessageEventLogs({ zIndex }: {
|
|
42
69
|
zIndex?: number;
|
|
43
70
|
}): import("react/jsx-runtime").JSX.Element;
|
|
@@ -1,5 +1,5 @@
|
|
|
1
1
|
import { jsx as _jsx, jsxs as _jsxs } from "react/jsx-runtime";
|
|
2
|
-
import { useCallback, useEffect, useRef } from "react";
|
|
2
|
+
import { useCallback, useEffect, useMemo, useRef, useState } from "react";
|
|
3
3
|
import { copyToClipboard, Params, timeLocalToStr_hhmmss } from "wenay-common2";
|
|
4
4
|
import { renderBy, updateBy } from "../../updateBy";
|
|
5
5
|
import { contextMenu } from "../menu/menuMouse";
|
|
@@ -153,7 +153,7 @@ export function PageLogs({ update }) {
|
|
|
153
153
|
}, [true]);
|
|
154
154
|
return _jsx(Main, {});
|
|
155
155
|
}
|
|
156
|
-
function
|
|
156
|
+
export function MessageEventLogCard({ logs }) {
|
|
157
157
|
return _jsxs("div", { className: "testAnime", style: { width: "200px", color: logStyleTokens.text, height: "auto", marginTop: "10px", borderRight: `5px solid ${logStyleTokens.accent}`, background: logSeverityBackground(logs.var) }, children: [_jsx("p", { style: { textAlign: "center", fontSize: "10px", marginBottom: "1px" }, children: "notification" }), _jsx("hr", { style: {
|
|
158
158
|
backgroundImage: logDividerGradient(),
|
|
159
159
|
border: 0,
|
|
@@ -161,35 +161,84 @@ function Message({ logs }) {
|
|
|
161
161
|
margin: "0 0 0 0",
|
|
162
162
|
boxSizing: "content-box",
|
|
163
163
|
display: "block"
|
|
164
|
-
} }), _jsx("div", { style: { textAlign: "right", marginRight: "10px", height: "auto", overflowWrap: "break-word", textOverflow: "ellipsis" }, children: typeof logs.txt == "object" ? JSON.stringify(logs.txt) : logs.txt }), _jsx("p", { style: { float: "inline-end", textAlign: "right", marginRight: "10px" }, children: (new Date()).toLocaleDateString() })] });
|
|
164
|
+
} }), _jsx("div", { style: { textAlign: "right", marginRight: "10px", height: "auto", overflowWrap: "break-word", textOverflow: "ellipsis" }, children: typeof logs.txt == "object" ? JSON.stringify(logs.txt) : logs.txt }), _jsx("p", { style: { float: "inline-end", textAlign: "right", marginRight: "10px" }, children: (new Date(logs.time)).toLocaleDateString() })] });
|
|
165
165
|
}
|
|
166
|
-
|
|
167
|
-
let r = 0;
|
|
168
|
-
export function MessageEventLogs({ zIndex }) {
|
|
166
|
+
export function useMessageEventLogsController(options = {}) {
|
|
169
167
|
const setting = memoryGetOrCreate("settingLogs", settingLogs);
|
|
170
|
-
|
|
171
|
-
|
|
172
|
-
|
|
173
|
-
|
|
168
|
+
const maxVisible = Math.max(1, options.maxVisible ?? 10);
|
|
169
|
+
const [notifications, setNotifications] = useState([]);
|
|
170
|
+
const counterRef = useRef(0);
|
|
171
|
+
const lastLogRef = useRef(null);
|
|
172
|
+
const timersRef = useRef(new Map());
|
|
173
|
+
updateBy(setting);
|
|
174
|
+
const addNotification = useCallback((last) => {
|
|
175
|
+
if ((last.var ?? 0) < (setting.params.minVarMessage ?? 0))
|
|
174
176
|
return;
|
|
175
|
-
|
|
177
|
+
const key = String(counterRef.current++);
|
|
178
|
+
const item = { key, logs: last };
|
|
179
|
+
const displayMs = (setting.params.timeShow ? setting.params.timeShow : 2) * 1000;
|
|
180
|
+
setNotifications(prev => [item, ...prev]);
|
|
181
|
+
const timer = setTimeout(() => {
|
|
182
|
+
timersRef.current.delete(key);
|
|
183
|
+
setNotifications(prev => prev.filter(e => e.key !== key));
|
|
184
|
+
}, displayMs);
|
|
185
|
+
timersRef.current.set(key, timer);
|
|
186
|
+
}, [setting]);
|
|
187
|
+
const onMiniChange = useCallback(() => {
|
|
188
|
+
const last = datumMiniConst.last[0];
|
|
189
|
+
if (!last || last === lastLogRef.current)
|
|
176
190
|
return;
|
|
177
|
-
|
|
178
|
-
|
|
179
|
-
|
|
180
|
-
|
|
181
|
-
|
|
182
|
-
|
|
183
|
-
|
|
184
|
-
|
|
185
|
-
|
|
186
|
-
|
|
187
|
-
|
|
188
|
-
|
|
189
|
-
|
|
190
|
-
|
|
191
|
-
|
|
192
|
-
|
|
191
|
+
lastLogRef.current = last;
|
|
192
|
+
addNotification(last);
|
|
193
|
+
}, [addNotification]);
|
|
194
|
+
updateBy(datumMiniConst, onMiniChange);
|
|
195
|
+
useEffect(() => () => {
|
|
196
|
+
timersRef.current.forEach(clearTimeout);
|
|
197
|
+
timersRef.current.clear();
|
|
198
|
+
}, []);
|
|
199
|
+
const setShow = useCallback((value) => {
|
|
200
|
+
setting.params.show = value;
|
|
201
|
+
renderBy(setting);
|
|
202
|
+
}, [setting]);
|
|
203
|
+
const toggleShow = useCallback(() => {
|
|
204
|
+
setShow(!setting.params.show);
|
|
205
|
+
}, [setShow, setting]);
|
|
206
|
+
return useMemo(() => ({
|
|
207
|
+
show: Boolean(setting.params.show),
|
|
208
|
+
setShow,
|
|
209
|
+
toggleShow,
|
|
210
|
+
notifications,
|
|
211
|
+
visibleNotifications: notifications.slice(0, maxVisible),
|
|
212
|
+
maxVisible,
|
|
213
|
+
}), [maxVisible, notifications, setShow, setting.params.show, toggleShow]);
|
|
214
|
+
}
|
|
215
|
+
export function MessageEventLogsView({ controller, zIndex, className, style }) {
|
|
216
|
+
const toggleTitle = controller.show ? "Hide log notifications" : "Show log notifications";
|
|
217
|
+
const toggleStyle = {
|
|
218
|
+
position: "absolute",
|
|
219
|
+
right: 0,
|
|
220
|
+
top: 0,
|
|
221
|
+
zIndex: 120,
|
|
222
|
+
minWidth: controller.show ? 24 : 42,
|
|
223
|
+
height: 24,
|
|
224
|
+
padding: controller.show ? 0 : "0 8px",
|
|
225
|
+
border: `1px solid ${logStyleTokens.accent}`,
|
|
226
|
+
borderRadius: 999,
|
|
227
|
+
background: controller.show ? logStyleTokens.toggleBg : logStyleTokens.toggleOffBg,
|
|
228
|
+
color: logStyleTokens.text,
|
|
229
|
+
cursor: "pointer",
|
|
230
|
+
display: "inline-flex",
|
|
231
|
+
alignItems: "center",
|
|
232
|
+
justifyContent: "center",
|
|
233
|
+
fontSize: controller.show ? 18 : 12,
|
|
234
|
+
lineHeight: 1,
|
|
235
|
+
boxShadow: "0 2px 8px rgba(0, 0, 0, 0.35)",
|
|
236
|
+
};
|
|
237
|
+
return _jsxs("div", { className: className, style: { maxHeight: "50vh", position: "absolute", right: "1px", zIndex, ...style }, children: [_jsx("button", { type: "button", "aria-label": toggleTitle, title: toggleTitle, onClick: controller.toggleShow, style: toggleStyle, children: controller.show ? "\u00d7" : "log" }), _jsx("div", { children: controller.show ? controller.visibleNotifications.map(e => (_jsx("div", { className: "example-exit", children: _jsx(MessageEventLogCard, { logs: e.logs }) }, e.key))) : null })] });
|
|
238
|
+
}
|
|
239
|
+
export function MessageEventLogs({ zIndex }) {
|
|
240
|
+
const controller = useMessageEventLogsController();
|
|
241
|
+
return _jsx(MessageEventLogsView, { controller: controller, zIndex: zIndex });
|
|
193
242
|
}
|
|
194
243
|
const defPageBase = {
|
|
195
244
|
keyPage: "PageLogs"
|