wenay-react2 1.0.39 → 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.
Files changed (54) hide show
  1. package/doc/EXAMPLE_USAGE.md +915 -0
  2. package/doc/PROJECT_FUNCTIONALITY.md +401 -0
  3. package/doc/PROJECT_RULES.md +80 -0
  4. package/doc/WENAY_REACT2_RENAMES.md +177 -0
  5. package/doc/changes/v1.0.35.md +26 -0
  6. package/doc/changes/v1.0.37.md +13 -0
  7. package/doc/changes/v1.0.38.md +48 -0
  8. package/doc/changes/v1.0.39.md +35 -0
  9. package/doc/changes/v1.0.40.md +15 -0
  10. package/doc/changes/v1.0.41.md +13 -0
  11. package/doc/progress/README.md +14 -0
  12. package/doc/progress/hook-controller-opportunities.md +367 -0
  13. package/doc/progress/hook-extraction-audit.md +195 -0
  14. package/doc/progress/public-surface-normalization.md +368 -0
  15. package/doc/progress/style-system-normalization.md +121 -0
  16. package/doc/target/README.md +33 -0
  17. package/doc/target/my.md +109 -0
  18. package/doc/wenay-react2-1.0.8.tgz +0 -0
  19. package/doc/wenay-react2-rare.md +702 -0
  20. package/doc/wenay-react2.md +475 -0
  21. package/lib/common/src/components/Dnd/FloatingWindow.d.ts +29 -12
  22. package/lib/common/src/components/Dnd/FloatingWindow.js +100 -91
  23. package/lib/common/src/components/Menu/RightMenu.d.ts +28 -1
  24. package/lib/common/src/components/Menu/RightMenu.js +62 -35
  25. package/lib/common/src/components/Modal/LeftModal.js +5 -4
  26. package/lib/common/src/components/Modal/Modal.js +8 -7
  27. package/lib/common/src/components/ParamsEditor.d.ts +15 -0
  28. package/lib/common/src/components/ParamsEditor.js +45 -18
  29. package/lib/common/src/components/Settings/SettingsDialog.d.ts +68 -6
  30. package/lib/common/src/components/Settings/SettingsDialog.js +61 -12
  31. package/lib/common/src/components/Toolbar/Toolbar.js +64 -19
  32. package/lib/common/src/components/UiSlot/UiSlot.js +5 -4
  33. package/lib/common/src/grid/columnState/columnState.js +8 -6
  34. package/lib/common/src/hooks/useKeyboard.js +4 -3
  35. package/lib/common/src/logs/logs.d.ts +40 -133
  36. package/lib/common/src/logs/logs.js +96 -66
  37. package/lib/common/src/logs/logsContext.d.ts +26 -0
  38. package/lib/common/src/logs/logsContext.js +25 -20
  39. package/lib/common/src/logs/logsController.d.ts +88 -0
  40. package/lib/common/src/logs/logsController.js +54 -0
  41. package/lib/common/src/logs/miniLogs.d.ts +69 -3
  42. package/lib/common/src/logs/miniLogs.js +70 -15
  43. package/lib/common/src/menu/menu.d.ts +15 -3
  44. package/lib/common/src/menu/menu.js +81 -17
  45. package/lib/common/src/menu/menuMouse.d.ts +18 -0
  46. package/lib/common/src/menu/menuMouse.js +43 -1
  47. package/lib/common/src/styles/tokens.d.ts +7 -6
  48. package/lib/common/src/styles/tokens.js +8 -5
  49. package/lib/common/src/utils/memoryStore.d.ts +1 -10
  50. package/lib/common/src/utils/searchHistory.js +4 -3
  51. package/lib/style/menuRight.css +29 -23
  52. package/lib/style/style.css +15 -9
  53. package/lib/style/tokens.css +8 -4
  54. package/package.json +50 -49
@@ -1,5 +1,5 @@
1
1
  import { jsx as _jsx, jsxs as _jsxs, Fragment as _Fragment } from "react/jsx-runtime";
2
- import { useEffect, useMemo, useRef, useState } from "react";
2
+ import { useCallback, useEffect, useMemo, useRef, useState } from "react";
3
3
  import { deepCloneMutable, isDate, TF, timeLocalToStr_yyyymmdd, timeLocalToStr_yyyymmdd_hhmm, timeLocalToStr_yyyymmdd_hhmmss, timeLocalToStr_yyyymmdd_hhmmss_ms } from "wenay-common2";
4
4
  import { setResizeableElement, removeResizeableElement } from "./MyResizeObserver";
5
5
  import { ParamRow, ParamToggleLabel } from "./Parameters";
@@ -163,6 +163,44 @@ function InputListArray(set, values, range, rangeLabels) {
163
163
  return _jsx("option", { value: String(a), label: rangeLabels?.at(i) ?? String(a), children: a }, String(a));
164
164
  }) });
165
165
  }
166
+ export function useParamsEditorController({ params, onChange, onExpand }) {
167
+ const [, setUpdate] = useState(0);
168
+ const cloned = useMemo(() => deepCloneMutable(params), [params]);
169
+ const paramsRef = useRef(cloned);
170
+ if (paramsRef.current !== cloned)
171
+ paramsRef.current = cloned;
172
+ const callbacks = useRef({ onChange, onExpand });
173
+ callbacks.current = { onChange, onExpand };
174
+ const timeoutId = useRef(null);
175
+ useEffect(() => () => {
176
+ if (timeoutId.current != null)
177
+ clearTimeout(timeoutId.current);
178
+ }, []);
179
+ const refresh = useCallback(() => {
180
+ setUpdate(e => e + 1);
181
+ }, []);
182
+ const notifyChange = useCallback(() => {
183
+ callbacks.current.onChange(paramsRef.current);
184
+ }, []);
185
+ const notifyChangeDelayed = useCallback(() => {
186
+ if (timeoutId.current != null)
187
+ clearTimeout(timeoutId.current);
188
+ timeoutId.current = setTimeout(() => {
189
+ notifyChange();
190
+ }, 200);
191
+ }, [notifyChange]);
192
+ const notifyExpand = useCallback(() => {
193
+ callbacks.current.onExpand?.(paramsRef.current);
194
+ }, []);
195
+ return {
196
+ paramsRef,
197
+ params: paramsRef.current,
198
+ refresh,
199
+ notifyChange,
200
+ notifyChangeDelayed,
201
+ notifyExpand,
202
+ };
203
+ }
166
204
  export function ParamsEditor(data) {
167
205
  // callbacks live in a ref: useMemo below intentionally freezes the element until
168
206
  // params identity changes, and would otherwise freeze a stale onChange/onExpand with it
@@ -174,26 +212,15 @@ export function ParamsEditor(data) {
174
212
  return result;
175
213
  }
176
214
  function ParamsEditorBase(data) {
177
- const [_, setUpdate] = useState(0);
178
- function Refresh() { setUpdate(e => ++e); }
215
+ const controller = useParamsEditorController(data);
216
+ const myParams = controller.paramsRef;
217
+ function Refresh() { controller.refresh(); }
179
218
  const styleColorDisable = "rgb(146,146,146)";
180
- const p = useMemo(() => deepCloneMutable(data.params), [data.params]);
181
- const myParams = useRef(p);
182
- // sync during render: the effect+Refresh variant committed a frame of stale params first
183
- if (myParams.current !== p)
184
- myParams.current = p;
185
- const timeoutId = useRef(null);
186
- useEffect(() => () => { if (timeoutId.current != null)
187
- clearTimeout(timeoutId.current); }, []);
188
219
  function refreshIndicator() {
189
- data.onChange(myParams.current);
220
+ controller.notifyChange();
190
221
  }
191
222
  function refreshIndicatorDelayed() {
192
- if (timeoutId.current != null)
193
- clearTimeout(timeoutId.current);
194
- timeoutId.current = setTimeout(() => {
195
- refreshIndicator();
196
- }, 200);
223
+ controller.notifyChangeDelayed();
197
224
  }
198
225
  function Param(//<T extends boolean|number|string|const_Date>(
199
226
  set, val, type, range, labels) {
@@ -218,7 +245,7 @@ function ParamsEditorBase(data) {
218
245
  refreshIndicator(); // process without delay
219
246
  }
220
247
  function onExpandA(param, flag) {
221
- data.onExpand?.(myParams.current);
248
+ controller.notifyExpand();
222
249
  }
223
250
  function ListParams(obj, parentEnabled = true, expandLevel = 0) {
224
251
  return Object.entries(obj).map(([key, param]) => {
@@ -13,20 +13,82 @@ export type SettingsSection = {
13
13
  /** Semantic aliases: commands, domain terms, translated labels, etc. */
14
14
  keywords?: readonly string[];
15
15
  };
16
+ type SettingsTreeNode = {
17
+ section: SettingsSection;
18
+ children: SettingsTreeNode[];
19
+ parent?: SettingsTreeNode;
20
+ depth: number;
21
+ };
22
+ type SettingsTree = {
23
+ roots: SettingsTreeNode[];
24
+ ordered: SettingsTreeNode[];
25
+ byKey: Map<string, SettingsTreeNode>;
26
+ };
27
+ type SettingsTreeFilter = {
28
+ visibleKeys: Set<string>;
29
+ matchedKeys: Set<string>;
30
+ expandedKeys: Set<string>;
31
+ };
32
+ type SettingsSearchTerm = {
33
+ variants: string[];
34
+ };
16
35
  /** Register an external section. Re-register with the same key replaces the previous one.
17
36
  * The returned function removes exactly this registration (a no-op if it was replaced). */
18
37
  export declare function registerSettingsSection(s: SettingsSection): () => void;
19
38
  /** Current external sections (static props sections are not included). */
20
39
  export declare function getSettingsSections(): readonly SettingsSection[];
21
- /** Centered settings dialog with a searchable JetBrains-style settings tree on the left.
22
- * Sections = props.sections (first) + everything from registerSettingsSection.
23
- * Flat {key, name, render} sections remain valid; use children/parentKey for hierarchy.
24
- * Look is themed via --dlg-* CSS variables (dark defaults), same contract as --wnd-*. */
25
- export declare function SettingsDialog(props: {
40
+ export type SettingsDialogProps = {
26
41
  trigger?: React.ReactNode;
27
42
  sections?: SettingsSection[];
28
43
  defaultSection?: string;
29
44
  /** Section buttons: apps pass their own .chip / .chipActive; defaults are minimal library styles */
30
45
  sectionClassName?: string;
31
46
  sectionActiveClassName?: string;
32
- }): import("react/jsx-runtime").JSX.Element;
47
+ };
48
+ export type SettingsDialogTreeToolState = "collapsed" | "expanded" | "branch";
49
+ export type SettingsDialogController = {
50
+ open: boolean;
51
+ setOpen: React.Dispatch<React.SetStateAction<boolean>>;
52
+ active?: string;
53
+ setActive: React.Dispatch<React.SetStateAction<string | undefined>>;
54
+ search: string;
55
+ setSearch: React.Dispatch<React.SetStateAction<string>>;
56
+ expanded: Set<string>;
57
+ historyOpen: boolean;
58
+ setHistoryOpen: React.Dispatch<React.SetStateAction<boolean>>;
59
+ navWidth: number;
60
+ navResizing: boolean;
61
+ searchInputRef: React.RefObject<HTMLInputElement | null>;
62
+ searchBoxRef: React.RefObject<HTMLDivElement | null>;
63
+ tree: SettingsTree;
64
+ filtered: SettingsTreeFilter;
65
+ current?: SettingsSection;
66
+ currentNode?: SettingsTreeNode;
67
+ firstVisibleNode?: SettingsTreeNode;
68
+ searchTerms: SettingsSearchTerm[];
69
+ searchHistory: readonly string[];
70
+ base: string;
71
+ activeCls: string;
72
+ treeToolsVisible: boolean;
73
+ treeToolState: SettingsDialogTreeToolState;
74
+ treeToolTitle: string;
75
+ openDialog(): void;
76
+ closeDialog(): void;
77
+ onTriggerKeyDown(e: React.KeyboardEvent<HTMLSpanElement>): void;
78
+ toggleExpanded(key: string): void;
79
+ commitSearch(value?: string): void;
80
+ useHistoryItem(value: string): void;
81
+ clearHistory(): void;
82
+ commitNavWidth(value: number): void;
83
+ beginNavResize(e: React.PointerEvent<HTMLDivElement>): void;
84
+ onNavResizeKeyDown(e: React.KeyboardEvent<HTMLDivElement>): void;
85
+ closeHistoryWhenSearchFocusLeaves(e: React.FocusEvent<HTMLDivElement>): void;
86
+ cycleTreeTool(): void;
87
+ };
88
+ export declare function useSettingsDialogController(props: SettingsDialogProps): SettingsDialogController;
89
+ /** Centered settings dialog with a searchable JetBrains-style settings tree on the left.
90
+ * Sections = props.sections (first) + everything from registerSettingsSection.
91
+ * Flat {key, name, render} sections remain valid; use children/parentKey for hierarchy.
92
+ * Look is themed via --dlg-* CSS variables (dark defaults), same contract as --wnd-*. */
93
+ export declare function SettingsDialog(props: SettingsDialogProps): import("react/jsx-runtime").JSX.Element;
94
+ export {};
@@ -1,7 +1,7 @@
1
1
  import { jsx as _jsx, jsxs as _jsxs, Fragment as _Fragment } from "react/jsx-runtime";
2
2
  import React, { useEffect, useRef, useState } from "react";
3
3
  import { createPortal } from "react-dom";
4
- import { renderBy, updateBy } from "../../../updateBy";
4
+ import { createUpdateApi, renderBy } from "../../../updateBy";
5
5
  import { OutsideClickArea } from "../../hooks/useOutside";
6
6
  import { FloatingWindowBase } from "../Dnd/FloatingWindow";
7
7
  import { createSearchHistory } from "../../utils/searchHistory";
@@ -12,6 +12,7 @@ const settingsDialogNav = { min: 160, def: 220, max: 360 };
12
12
  // Module singleton (closure + updateBy subscription, no React context): any module
13
13
  // registers a section on mount and removes it on unmount; the dialog re-renders on changes.
14
14
  const registry = { list: [] };
15
+ const registryApi = createUpdateApi(registry);
15
16
  const settingsSearchHistory = createSearchHistory({ key: "SettingsDialog.searchHistory", max: 8 });
16
17
  const settingsDialogLayout = memoryGetOrCreate("SettingsDialog.layout", { navWidth: settingsDialogNav.def });
17
18
  /** Register an external section. Re-register with the same key replaces the previous one.
@@ -22,12 +23,12 @@ export function registerSettingsSection(s) {
22
23
  registry.list.push(s);
23
24
  else
24
25
  registry.list.splice(i, 1, s);
25
- renderBy(registry);
26
+ registryApi.render();
26
27
  return () => {
27
28
  const j = registry.list.indexOf(s);
28
29
  if (j != -1) {
29
30
  registry.list.splice(j, 1);
30
- renderBy(registry);
31
+ registryApi.render();
31
32
  }
32
33
  };
33
34
  }
@@ -220,11 +221,7 @@ function filterSettingsTree(roots, terms) {
220
221
  roots.forEach(visit);
221
222
  return { visibleKeys, matchedKeys, expandedKeys };
222
223
  }
223
- /** Centered settings dialog with a searchable JetBrains-style settings tree on the left.
224
- * Sections = props.sections (first) + everything from registerSettingsSection.
225
- * Flat {key, name, render} sections remain valid; use children/parentKey for hierarchy.
226
- * Look is themed via --dlg-* CSS variables (dark defaults), same contract as --wnd-*. */
227
- export function SettingsDialog(props) {
224
+ export function useSettingsDialogController(props) {
228
225
  const [open, setOpen] = useState(false);
229
226
  const [active, setActive] = useState(props.defaultSection);
230
227
  const [search, setSearch] = useState("");
@@ -236,7 +233,7 @@ export function SettingsDialog(props) {
236
233
  const searchBoxRef = useRef(null);
237
234
  const navResizeRef = useRef(null);
238
235
  const navWidthRef = useRef(navWidth);
239
- updateBy(registry);
236
+ registryApi.use();
240
237
  const searchHistory = settingsSearchHistory.use();
241
238
  const sections = [...(props.sections ?? []), ...registry.list];
242
239
  const tree = buildSettingsTree(sections);
@@ -310,6 +307,9 @@ export function SettingsDialog(props) {
310
307
  setHistoryOpen(false);
311
308
  setOpen(true);
312
309
  }
310
+ function closeDialog() {
311
+ setOpen(false);
312
+ }
313
313
  function onTriggerKeyDown(e) {
314
314
  if (e.key != "Enter" && e.key != " ")
315
315
  return;
@@ -380,6 +380,11 @@ export function SettingsDialog(props) {
380
380
  setHistoryOpen(false);
381
381
  searchInputRef.current?.focus();
382
382
  }
383
+ function clearHistory() {
384
+ settingsSearchHistory.clear();
385
+ setHistoryOpen(false);
386
+ searchInputRef.current?.focus();
387
+ }
383
388
  function closeHistoryWhenSearchFocusLeaves(e) {
384
389
  const next = e.relatedTarget;
385
390
  if (next && e.currentTarget.contains(next))
@@ -398,6 +403,52 @@ export function SettingsDialog(props) {
398
403
  else
399
404
  expandAll();
400
405
  }
406
+ return {
407
+ open,
408
+ setOpen,
409
+ active,
410
+ setActive,
411
+ search,
412
+ setSearch,
413
+ expanded,
414
+ historyOpen,
415
+ setHistoryOpen,
416
+ navWidth,
417
+ navResizing,
418
+ searchInputRef,
419
+ searchBoxRef,
420
+ tree,
421
+ filtered,
422
+ current,
423
+ currentNode,
424
+ firstVisibleNode,
425
+ searchTerms,
426
+ searchHistory,
427
+ base,
428
+ activeCls,
429
+ treeToolsVisible,
430
+ treeToolState,
431
+ treeToolTitle,
432
+ openDialog,
433
+ closeDialog,
434
+ onTriggerKeyDown,
435
+ toggleExpanded,
436
+ commitSearch,
437
+ useHistoryItem,
438
+ clearHistory,
439
+ commitNavWidth,
440
+ beginNavResize,
441
+ onNavResizeKeyDown,
442
+ closeHistoryWhenSearchFocusLeaves,
443
+ cycleTreeTool,
444
+ };
445
+ }
446
+ /** Centered settings dialog with a searchable JetBrains-style settings tree on the left.
447
+ * Sections = props.sections (first) + everything from registerSettingsSection.
448
+ * Flat {key, name, render} sections remain valid; use children/parentKey for hierarchy.
449
+ * Look is themed via --dlg-* CSS variables (dark defaults), same contract as --wnd-*. */
450
+ export function SettingsDialog(props) {
451
+ const { open, setOpen, setActive, search, setSearch, expanded, historyOpen, setHistoryOpen, navWidth, navResizing, searchInputRef, searchBoxRef, tree, filtered, current, firstVisibleNode, searchTerms, searchHistory, base, activeCls, treeToolsVisible, treeToolState, treeToolTitle, openDialog, onTriggerKeyDown, toggleExpanded, commitSearch, useHistoryItem, clearHistory, commitNavWidth, beginNavResize, onNavResizeKeyDown, closeHistoryWhenSearchFocusLeaves, cycleTreeTool, } = useSettingsDialogController(props);
401
452
  function renderTreeNode(node) {
402
453
  if (!filtered.visibleKeys.has(node.section.key))
403
454
  return null;
@@ -444,8 +495,6 @@ export function SettingsDialog(props) {
444
495
  setHistoryOpen(searchHistory.length != 0);
445
496
  searchInputRef.current?.focus();
446
497
  }, children: _jsx("svg", { width: "12", height: "12", viewBox: "0 0 12 12", "aria-hidden": "true", children: _jsx("path", { d: "M2 2 L10 10 M10 2 L2 10", stroke: "currentColor", strokeWidth: "1.6", strokeLinecap: "round" }) }) }), historyOpen && searchHistory.length != 0 && _jsxs("div", { className: "wenayDlgSearchHistory", role: "listbox", children: [searchHistory.map(item => _jsx("button", { type: "button", className: "wenayDlgSearchHistoryItem", onMouseDown: e => e.preventDefault(), onClick: () => useHistoryItem(item), children: item }, item)), _jsx("button", { type: "button", className: "wenayDlgSearchHistoryClear", onMouseDown: e => e.preventDefault(), onClick: () => {
447
- settingsSearchHistory.clear();
448
- setHistoryOpen(false);
449
- searchInputRef.current?.focus();
498
+ clearHistory();
450
499
  }, children: "Clear history" })] })] }), treeToolsVisible && _jsx("button", { className: classNames(["wenayDlgTreeTool", `wenayDlgTreeTool_${treeToolState}`]), type: "button", title: treeToolTitle, "aria-label": treeToolTitle, onClick: cycleTreeTool, children: _jsxs("span", { className: "wenayDlgTreeToolDots", "aria-hidden": "true", children: [_jsx("span", {}), _jsx("span", {}), _jsx("span", {})] }) })] }), _jsxs("div", { className: "wenayDlgTree", role: "tree", children: [tree.roots.map(renderTreeNode), firstVisibleNode == null && _jsx("div", { className: "wenayDlgNoResults", children: "No settings found" })] })] }), _jsx("div", { className: "wenayDlgDivider", role: "separator", "aria-orientation": "vertical", "aria-label": "Resize settings navigation", "aria-valuemin": settingsDialogNav.min, "aria-valuemax": settingsDialogNav.max, "aria-valuenow": navWidth, tabIndex: 0, title: "Drag to resize navigation; double-click to reset", onPointerDown: beginNavResize, onDoubleClick: () => commitNavWidth(settingsDialogNav.def), onKeyDown: onNavResizeKeyDown, children: _jsx("span", {}) }), _jsx("div", { className: "wenayDlgContent", children: current?.render() })] }) }) }) }), document.body)] });
451
500
  }
@@ -1,7 +1,7 @@
1
1
  import { jsx as _jsx, Fragment as _Fragment, jsxs as _jsxs } from "react/jsx-runtime";
2
- import { useState } from 'react';
2
+ import { useLayoutEffect, useRef, useState } from 'react';
3
3
  import { listen as createListen } from 'wenay-common2';
4
- import { renderBy, updateBy } from '../../../updateBy';
4
+ import { createUpdateApi } from '../../../updateBy';
5
5
  import { memoryGetOrCreate, memoryMarkDirty } from '../../utils/memoryStore';
6
6
  import { OutsideClickArea } from '../../hooks/useOutside';
7
7
  import { useReorder } from '../../hooks/useReorder';
@@ -12,6 +12,7 @@ const densities = { list: [
12
12
  { key: 'icon', name: 'Icons', renderItem: itemIconOnly },
13
13
  { key: 'label', name: 'Icons + labels' },
14
14
  ] };
15
+ const densitiesApi = createUpdateApi(densities);
15
16
  /** Icon with a text fallback: no glyph = the first letters of short/title
16
17
  * (an "PR"-style pseudo-icon), so icon densities work for icon-less items. */
17
18
  export function toolbarItemIcon(item) {
@@ -30,12 +31,12 @@ export function registerToolbarDensity(d) {
30
31
  densities.list.push(d);
31
32
  else
32
33
  densities.list.splice(i, 1, d);
33
- renderBy(densities);
34
+ densitiesApi.render();
34
35
  return function offToolbarDensity() {
35
36
  const j = densities.list.indexOf(d);
36
37
  if (j != -1) {
37
38
  densities.list.splice(j, 1);
38
- renderBy(densities);
39
+ densitiesApi.render();
39
40
  }
40
41
  };
41
42
  }
@@ -55,6 +56,49 @@ function itemContent(item, densityKey) {
55
56
  * order (the gear always sits at the bar edge), but toggleable like an item. */
56
57
  const SETTINGS_KEY = '__settings';
57
58
  const RESET_KEY = '__reset';
59
+ function useToolbarFlip(layoutKey) {
60
+ const itemRefs = useRef(new Map());
61
+ const prevRects = useRef(new Map());
62
+ const rafs = useRef([]);
63
+ useLayoutEffect(() => {
64
+ const prev = prevRects.current;
65
+ const next = new Map();
66
+ rafs.current.forEach(cancelAnimationFrame);
67
+ rafs.current = [];
68
+ itemRefs.current.forEach((node, key) => {
69
+ const rect = node.getBoundingClientRect();
70
+ next.set(key, { left: rect.left, top: rect.top });
71
+ const old = prev.get(key);
72
+ if (old == null)
73
+ return;
74
+ const dx = old.left - rect.left;
75
+ const dy = old.top - rect.top;
76
+ if (Math.abs(dx) < 0.5 && Math.abs(dy) < 0.5)
77
+ return;
78
+ node.style.transition = 'none';
79
+ node.style.transform = `translate(${dx}px, ${dy}px)`;
80
+ node.getBoundingClientRect();
81
+ const raf = requestAnimationFrame(() => {
82
+ node.style.transition = 'transform 180ms ease';
83
+ node.style.transform = '';
84
+ });
85
+ rafs.current.push(raf);
86
+ });
87
+ prevRects.current = next;
88
+ return () => {
89
+ rafs.current.forEach(cancelAnimationFrame);
90
+ rafs.current = [];
91
+ };
92
+ }, [layoutKey]);
93
+ return function bind(key) {
94
+ return (node) => {
95
+ if (node)
96
+ itemRefs.current.set(key, node);
97
+ else
98
+ itemRefs.current.delete(key);
99
+ };
100
+ };
101
+ }
58
102
  export function createToolbar(opts) {
59
103
  const resetDefaultVisible = () => opts.resetItem !== false && opts.resetItem?.defaultVisible === true;
60
104
  const defConfig = () => ({
@@ -63,6 +107,7 @@ export function createToolbar(opts) {
63
107
  density: opts.def?.density ?? densities.list[0].key,
64
108
  });
65
109
  const st = memoryGetOrCreate(opts.key, defConfig());
110
+ const stApi = createUpdateApi(st);
66
111
  const [emitChange, onChange] = createListen();
67
112
  // ext is fixed for the controller's lifetime, so hook call order inside
68
113
  // useConfig/Bar/Settings never changes for a given toolbar instance
@@ -137,7 +182,7 @@ export function createToolbar(opts) {
137
182
  [RESET_KEY]: metaVisible(next, RESET_KEY, resetDefaultVisible()),
138
183
  };
139
184
  st.density = next.density;
140
- renderBy(st);
185
+ stApi.render();
141
186
  memoryMarkDirty(opts.key);
142
187
  ext.setConfig({ order: next.order.slice(), visible });
143
188
  // the source's own onChange already re-emitted; emit only when it can't
@@ -153,7 +198,7 @@ export function createToolbar(opts) {
153
198
  st.order = next.order.slice();
154
199
  st.visible = { ...next.visible };
155
200
  st.density = next.density;
156
- renderBy(st);
201
+ stApi.render();
157
202
  memoryMarkDirty(opts.key);
158
203
  const orderChanged = !sameOrder(curSourceOrder, nextSourceOrder);
159
204
  if (orderChanged)
@@ -168,7 +213,7 @@ export function createToolbar(opts) {
168
213
  st.order = next.order.slice();
169
214
  st.visible = { ...next.visible };
170
215
  st.density = next.density;
171
- renderBy(st);
216
+ stApi.render();
172
217
  memoryMarkDirty(opts.key);
173
218
  emitChange(normalize());
174
219
  }
@@ -176,7 +221,7 @@ export function createToolbar(opts) {
176
221
  const getConfig = () => normalize();
177
222
  /** subscription to everything the toolbar renders from (hook) */
178
223
  function useSubscribe() {
179
- updateBy(st);
224
+ stApi.use();
180
225
  ext?.useConfig(); // ext is per-instance constant - hook order is stable
181
226
  }
182
227
  function useConfig() {
@@ -191,7 +236,7 @@ export function createToolbar(opts) {
191
236
  * someone else's nodes.) */
192
237
  function useItems() {
193
238
  useSubscribe();
194
- updateBy(densities);
239
+ densitiesApi.use();
195
240
  const cfg = normalize();
196
241
  const byKey = new Map(opts.items.map(i => [i.key, i]));
197
242
  return cfg.order
@@ -238,18 +283,18 @@ export function createToolbar(opts) {
238
283
  * the popover sticks to - 'right' (default, for bars in a top-right corner)
239
284
  * or 'left'. */
240
285
  function Bar(p = {}) {
241
- useSubscribe();
242
- updateBy(densities);
286
+ const items = useItems();
243
287
  const cfg = normalize();
244
288
  const [open, setOpen] = useState(false);
245
- const byKey = new Map(opts.items.map(i => [i.key, i]));
246
289
  const resetOn = opts.resetItem !== false && (p.reset ?? p.settings ?? false) && cfg.visible[RESET_KEY] != false;
247
- return _jsxs("div", { className: p.className ?? 'wenayTb', children: [cfg.order.map(k => {
248
- const it = byKey.get(k);
249
- if (!it || cfg.visible[k] == false)
250
- return null;
251
- return _jsx("div", { className: 'wenayTbItem', title: cfg.density == 'icon' ? it.title : undefined, onClick: it.onClick, children: itemContent(it, cfg.density) }, k);
252
- }), resetOn && _jsx("div", { className: 'wenayTbItem', title: resetTitle(), onClick: () => reset(), children: resetIcon() }), p.settings && cfg.visible[SETTINGS_KEY] != false && _jsxs(OutsideClickArea, { className: 'wenayTbGear', status: open, outsideClick: () => setOpen(false), children: [_jsx("div", { className: 'wenayTbItem', title: settingsTitle(), onClick: () => setOpen(v => !v), children: settingsIcon() }), open && _jsx("div", { className: p.popAlign == 'left' ? 'wenayTbPop wenayTbPopLeft' : 'wenayTbPop', children: _jsx(Settings, {}) })] })] });
290
+ const settingsOn = !!p.settings && cfg.visible[SETTINGS_KEY] != false;
291
+ const layoutKey = [
292
+ ...items.map(x => `${x.item.key}:${x.density}`),
293
+ resetOn ? `${RESET_KEY}:on` : '',
294
+ settingsOn ? `${SETTINGS_KEY}:on` : '',
295
+ ].join('|');
296
+ const bindFlip = useToolbarFlip(layoutKey);
297
+ return _jsxs("div", { className: p.className ?? 'wenayTb', children: [items.map(x => _jsx("div", { ref: bindFlip(x.item.key), className: 'wenayTbItem', title: x.density == 'icon' ? x.item.title : undefined, onClick: x.item.onClick, children: x.content }, x.item.key)), resetOn && _jsx("div", { ref: bindFlip(RESET_KEY), className: 'wenayTbItem', title: resetTitle(), onClick: () => reset(), children: resetIcon() }), settingsOn && _jsxs(OutsideClickArea, { className: 'wenayTbGear', status: open, outsideClick: () => setOpen(false), children: [_jsx("div", { ref: bindFlip(SETTINGS_KEY), className: 'wenayTbItem', title: settingsTitle(), onClick: () => setOpen(v => !v), children: settingsIcon() }), open && _jsx("div", { className: p.popAlign == 'left' ? 'wenayTbPop wenayTbPopLeft' : 'wenayTbPop', children: _jsx(Settings, {}) })] })] });
253
298
  }
254
299
  /** The pure editor over config: density segments + one row per item
255
300
  * (visibility checkbox, icon preview, title, drag handle). Reorder rides the
@@ -260,7 +305,7 @@ export function createToolbar(opts) {
260
305
  * Plus arrow keys on the focused handle; fixed rows have neither. */
261
306
  function Settings(p = {}) {
262
307
  useSubscribe();
263
- updateBy(densities);
308
+ densitiesApi.use();
264
309
  const cfg = normalize();
265
310
  const base = p.className ?? 'wenaySegBtn';
266
311
  const activeCls = p.activeClassName ?? 'wenaySegBtnActive';
@@ -1,5 +1,5 @@
1
1
  import { Fragment as _Fragment, jsx as _jsx } from "react/jsx-runtime";
2
- import { renderBy, updateBy } from "../../../updateBy";
2
+ import { createUpdateApi } from "../../../updateBy";
3
3
  import { memoryGetOrCreate, memoryMarkDirty } from "../../utils/memoryStore";
4
4
  /** One UI block shown in exactly one of several mount points; the point is a persisted setting.
5
5
  * Persistence rides the existing memoryProps -> memoryCache mechanics: memoryProps is observable,
@@ -9,6 +9,7 @@ import { memoryGetOrCreate, memoryMarkDirty } from "../../utils/memoryStore";
9
9
  * the persisted place with its own and renders children only on a match. */
10
10
  export function createUiSlot(opts) {
11
11
  const st = memoryGetOrCreate(opts.key, { place: opts.def });
12
+ const stApi = createUpdateApi(st);
12
13
  // a stored place may no longer exist after an app update - fall back to def
13
14
  const isPlace = (p) => typeof p == "string" && p in opts.places;
14
15
  const getPlace = () => isPlace(st.place) ? st.place : opts.def;
@@ -16,16 +17,16 @@ export function createUiSlot(opts) {
16
17
  if (st.place == p)
17
18
  return;
18
19
  st.place = p;
19
- renderBy(st);
20
+ stApi.render();
20
21
  memoryMarkDirty(opts.key);
21
22
  };
22
23
  function Slot(p) {
23
- updateBy(st);
24
+ stApi.use();
24
25
  return getPlace() == p.place ? _jsx(_Fragment, { children: p.children }) : null;
25
26
  }
26
27
  /** Segmented row over opts.places; apps pass their own .chip / .chipActive classes */
27
28
  function PlacementSetting(p = {}) {
28
- updateBy(st);
29
+ stApi.use();
29
30
  const cur = getPlace();
30
31
  const base = p.className ?? "wenaySegBtn";
31
32
  const activeCls = p.activeClassName ?? "wenaySegBtnActive";
@@ -1,5 +1,5 @@
1
1
  import { listen as createListen } from 'wenay-common2';
2
- import { renderBy, updateBy } from '../../../updateBy';
2
+ import { createUpdateApi } from '../../../updateBy';
3
3
  import { memoryGetOrCreate, memoryMarkDirty } from '../../utils/memoryStore';
4
4
  const SCHEMA_V = 1;
5
5
  /** Grid events that mean "the user changed the column layout / sort / filter". */
@@ -17,6 +17,7 @@ export function createColumnState(opts) {
17
17
  groups: opts.def?.groups ? { ...opts.def.groups } : Object.fromEntries(groupKeys.map(g => [g, groupMembers(g)])),
18
18
  });
19
19
  const st = memoryGetOrCreate(opts.key, defConfig());
20
+ const stApi = createUpdateApi(st);
20
21
  const [emitChange, onChange] = createListen();
21
22
  /** Runtime-only, never persisted: actual = which keys the attached grid has;
22
23
  * gate = optional app-level availability over a stable grid schema (standards,
@@ -25,6 +26,7 @@ export function createColumnState(opts) {
25
26
  present: null,
26
27
  presentGate: null,
27
28
  };
29
+ const rtApi = createUpdateApi(rt);
28
30
  const keyMap = (keys) => keys ? Object.fromEntries(keys.map(k => [k, true])) : null;
29
31
  const sameMap = (a, b) => JSON.stringify(a) == JSON.stringify(b);
30
32
  function combinedPresent() {
@@ -42,14 +44,14 @@ export function createColumnState(opts) {
42
44
  if (sameMap(next, rt.present))
43
45
  return;
44
46
  rt.present = next;
45
- renderBy(rt);
47
+ rtApi.render();
46
48
  }
47
49
  function setPresentGate(keys) {
48
50
  const next = keyMap(keys);
49
51
  if (sameMap(next, rt.presentGate))
50
52
  return;
51
53
  rt.presentGate = next;
52
- renderBy(rt);
54
+ rtApi.render();
53
55
  applyToGrid();
54
56
  }
55
57
  const getPresent = combinedPresent;
@@ -60,7 +62,7 @@ export function createColumnState(opts) {
60
62
  };
61
63
  const passesPresentGate = (key) => !rt.presentGate || rt.presentGate[key] == true;
62
64
  function usePresent() {
63
- updateBy(rt);
65
+ rtApi.use();
64
66
  return combinedPresent();
65
67
  }
66
68
  /** The persisted state may be stale or partial (older app version, columns
@@ -116,7 +118,7 @@ export function createColumnState(opts) {
116
118
  st.sort = next.sort ? { ...next.sort } : null;
117
119
  st.filter = { ...next.filter };
118
120
  st.groups = Object.fromEntries(Object.entries(next.groups).map(([g, keys]) => [g, keys.slice()]));
119
- renderBy(st);
121
+ stApi.render();
120
122
  memoryMarkDirty(opts.key);
121
123
  if (!fromGrid)
122
124
  applyToGrid();
@@ -126,7 +128,7 @@ export function createColumnState(opts) {
126
128
  const setConfig = (next) => commit(next, false);
127
129
  const reset = () => commit(defConfig(), false);
128
130
  function useConfig() {
129
- updateBy(st);
131
+ stApi.use();
130
132
  return normalize();
131
133
  }
132
134
  function show(key, on) {
@@ -1,9 +1,10 @@
1
1
  import { useEffect, useRef } from "react";
2
- import { renderBy } from "../../updateBy";
2
+ import { createUpdateApi } from "../../updateBy";
3
3
  import { listen as createListen } from "wenay-common2";
4
4
  export const keyboardState = {
5
5
  key: ""
6
6
  };
7
+ const keyboardStateApi = createUpdateApi(keyboardState);
7
8
  const [emitKeyDown, keyboardListen] = createListen();
8
9
  export const keyboard = {
9
10
  get key() { return keyboardState.key; },
@@ -11,7 +12,7 @@ export const keyboard = {
11
12
  get() { return keyboardState.key; },
12
13
  clear() {
13
14
  keyboardState.key = "";
14
- renderBy(keyboardState);
15
+ keyboardStateApi.render();
15
16
  emitKeyDown("", undefined);
16
17
  },
17
18
  reset() {
@@ -38,7 +39,7 @@ export function useKeyboard(options = {}) {
38
39
  if (!(event instanceof KeyboardEvent))
39
40
  return;
40
41
  keyboardState.key = event.key;
41
- renderBy(keyboardState);
42
+ keyboardStateApi.render();
42
43
  emitKeyDown(event.key, event);
43
44
  onKeyDownRef.current?.(event.key, event);
44
45
  };