@treenity/react 3.0.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/dist/AclEditor.d.ts +11 -0
- package/dist/AclEditor.d.ts.map +1 -0
- package/dist/AclEditor.js +152 -0
- package/dist/AclEditor.js.map +1 -0
- package/dist/App.d.ts +2 -0
- package/dist/App.d.ts.map +1 -0
- package/dist/App.js +521 -0
- package/dist/App.js.map +1 -0
- package/dist/Inspector.d.ts +12 -0
- package/dist/Inspector.d.ts.map +1 -0
- package/dist/Inspector.js +360 -0
- package/dist/Inspector.js.map +1 -0
- package/dist/Tree.d.ts +16 -0
- package/dist/Tree.d.ts.map +1 -0
- package/dist/Tree.js +100 -0
- package/dist/Tree.js.map +1 -0
- package/dist/ViewPage.d.ts +5 -0
- package/dist/ViewPage.d.ts.map +1 -0
- package/dist/ViewPage.js +13 -0
- package/dist/ViewPage.js.map +1 -0
- package/dist/bind/computed.d.ts +9 -0
- package/dist/bind/computed.d.ts.map +1 -0
- package/dist/bind/computed.js +61 -0
- package/dist/bind/computed.js.map +1 -0
- package/dist/bind/engine.d.ts +3 -0
- package/dist/bind/engine.d.ts.map +1 -0
- package/dist/bind/engine.js +184 -0
- package/dist/bind/engine.js.map +1 -0
- package/dist/bind/eval.d.ts +13 -0
- package/dist/bind/eval.d.ts.map +1 -0
- package/dist/bind/eval.js +97 -0
- package/dist/bind/eval.js.map +1 -0
- package/dist/bind/hook.d.ts +8 -0
- package/dist/bind/hook.d.ts.map +1 -0
- package/dist/bind/hook.js +99 -0
- package/dist/bind/hook.js.map +1 -0
- package/dist/bind/parse.d.ts +19 -0
- package/dist/bind/parse.d.ts.map +1 -0
- package/dist/bind/parse.js +86 -0
- package/dist/bind/parse.js.map +1 -0
- package/dist/bind/pipes.d.ts +4 -0
- package/dist/bind/pipes.d.ts.map +1 -0
- package/dist/bind/pipes.js +43 -0
- package/dist/bind/pipes.js.map +1 -0
- package/dist/cache.d.ts +27 -0
- package/dist/cache.d.ts.map +1 -0
- package/dist/cache.js +236 -0
- package/dist/cache.js.map +1 -0
- package/dist/client-tree.d.ts +9 -0
- package/dist/client-tree.d.ts.map +1 -0
- package/dist/client-tree.js +14 -0
- package/dist/client-tree.js.map +1 -0
- package/dist/client.d.ts +2 -0
- package/dist/client.d.ts.map +1 -0
- package/dist/client.js +10 -0
- package/dist/client.js.map +1 -0
- package/dist/components/ui/accordion.d.ts +8 -0
- package/dist/components/ui/accordion.d.ts.map +1 -0
- package/dist/components/ui/accordion.js +18 -0
- package/dist/components/ui/accordion.js.map +1 -0
- package/dist/components/ui/badge.d.ts +10 -0
- package/dist/components/ui/badge.d.ts.map +1 -0
- package/dist/components/ui/badge.js +19 -0
- package/dist/components/ui/badge.js.map +1 -0
- package/dist/components/ui/button.d.ts +11 -0
- package/dist/components/ui/button.d.ts.map +1 -0
- package/dist/components/ui/button.js +31 -0
- package/dist/components/ui/button.js.map +1 -0
- package/dist/components/ui/checkbox.d.ts +4 -0
- package/dist/components/ui/checkbox.d.ts.map +1 -0
- package/dist/components/ui/checkbox.js +7 -0
- package/dist/components/ui/checkbox.js.map +1 -0
- package/dist/components/ui/dialog.d.ts +18 -0
- package/dist/components/ui/dialog.d.ts.map +1 -0
- package/dist/components/ui/dialog.js +37 -0
- package/dist/components/ui/dialog.js.map +1 -0
- package/dist/components/ui/drawer.d.ts +14 -0
- package/dist/components/ui/drawer.d.ts.map +1 -0
- package/dist/components/ui/drawer.js +35 -0
- package/dist/components/ui/drawer.js.map +1 -0
- package/dist/components/ui/input.d.ts +4 -0
- package/dist/components/ui/input.d.ts.map +1 -0
- package/dist/components/ui/input.js +7 -0
- package/dist/components/ui/input.js.map +1 -0
- package/dist/components/ui/label.d.ts +5 -0
- package/dist/components/ui/label.d.ts.map +1 -0
- package/dist/components/ui/label.js +8 -0
- package/dist/components/ui/label.js.map +1 -0
- package/dist/components/ui/popover.d.ts +11 -0
- package/dist/components/ui/popover.d.ts.map +1 -0
- package/dist/components/ui/popover.js +26 -0
- package/dist/components/ui/popover.js.map +1 -0
- package/dist/components/ui/progress.d.ts +5 -0
- package/dist/components/ui/progress.d.ts.map +1 -0
- package/dist/components/ui/progress.js +9 -0
- package/dist/components/ui/progress.js.map +1 -0
- package/dist/components/ui/select.d.ts +16 -0
- package/dist/components/ui/select.d.ts.map +1 -0
- package/dist/components/ui/select.js +39 -0
- package/dist/components/ui/select.js.map +1 -0
- package/dist/components/ui/slider.d.ts +5 -0
- package/dist/components/ui/slider.d.ts.map +1 -0
- package/dist/components/ui/slider.js +15 -0
- package/dist/components/ui/slider.js.map +1 -0
- package/dist/components/ui/sonner.d.ts +4 -0
- package/dist/components/ui/sonner.d.ts.map +1 -0
- package/dist/components/ui/sonner.js +21 -0
- package/dist/components/ui/sonner.js.map +1 -0
- package/dist/components/ui/switch.d.ts +7 -0
- package/dist/components/ui/switch.d.ts.map +1 -0
- package/dist/components/ui/switch.js +9 -0
- package/dist/components/ui/switch.js.map +1 -0
- package/dist/components/ui/textarea.d.ts +4 -0
- package/dist/components/ui/textarea.d.ts.map +1 -0
- package/dist/components/ui/textarea.js +7 -0
- package/dist/components/ui/textarea.js.map +1 -0
- package/dist/components/ui/tooltip.d.ts +8 -0
- package/dist/components/ui/tooltip.d.ts.map +1 -0
- package/dist/components/ui/tooltip.js +18 -0
- package/dist/components/ui/tooltip.js.map +1 -0
- package/dist/context/index.d.ts +31 -0
- package/dist/context/index.d.ts.map +1 -0
- package/dist/context/index.js +98 -0
- package/dist/context/index.js.map +1 -0
- package/dist/context.d.ts +2 -0
- package/dist/context.d.ts.map +1 -0
- package/dist/context.js +2 -0
- package/dist/context.js.map +1 -0
- package/dist/hooks.d.ts +21 -0
- package/dist/hooks.d.ts.map +1 -0
- package/dist/hooks.js +156 -0
- package/dist/hooks.js.map +1 -0
- package/dist/idb.d.ts +13 -0
- package/dist/idb.d.ts.map +1 -0
- package/dist/idb.js +67 -0
- package/dist/idb.js.map +1 -0
- package/dist/lib/minimd.d.ts +3 -0
- package/dist/lib/minimd.d.ts.map +1 -0
- package/dist/lib/minimd.js +97 -0
- package/dist/lib/minimd.js.map +1 -0
- package/dist/lib/utils.d.ts +3 -0
- package/dist/lib/utils.d.ts.map +1 -0
- package/dist/lib/utils.js +6 -0
- package/dist/lib/utils.js.map +1 -0
- package/dist/load-client.d.ts +2 -0
- package/dist/load-client.d.ts.map +1 -0
- package/dist/load-client.js +6 -0
- package/dist/load-client.js.map +1 -0
- package/dist/main.d.ts +4 -0
- package/dist/main.d.ts.map +1 -0
- package/dist/main.js +16 -0
- package/dist/main.js.map +1 -0
- package/dist/mods/editor-ui/client.d.ts +6 -0
- package/dist/mods/editor-ui/client.d.ts.map +1 -0
- package/dist/mods/editor-ui/client.js +8 -0
- package/dist/mods/editor-ui/client.js.map +1 -0
- package/dist/mods/editor-ui/default-view.d.ts +2 -0
- package/dist/mods/editor-ui/default-view.d.ts.map +1 -0
- package/dist/mods/editor-ui/default-view.js +71 -0
- package/dist/mods/editor-ui/default-view.js.map +1 -0
- package/dist/mods/editor-ui/dir-view.d.ts +2 -0
- package/dist/mods/editor-ui/dir-view.d.ts.map +1 -0
- package/dist/mods/editor-ui/dir-view.js +42 -0
- package/dist/mods/editor-ui/dir-view.js.map +1 -0
- package/dist/mods/editor-ui/form-fields.d.ts +6 -0
- package/dist/mods/editor-ui/form-fields.d.ts.map +1 -0
- package/dist/mods/editor-ui/form-fields.js +401 -0
- package/dist/mods/editor-ui/form-fields.js.map +1 -0
- package/dist/mods/editor-ui/layout-view.d.ts +2 -0
- package/dist/mods/editor-ui/layout-view.d.ts.map +1 -0
- package/dist/mods/editor-ui/layout-view.js +22 -0
- package/dist/mods/editor-ui/layout-view.js.map +1 -0
- package/dist/mods/editor-ui/list-items.d.ts +2 -0
- package/dist/mods/editor-ui/list-items.d.ts.map +1 -0
- package/dist/mods/editor-ui/list-items.js +38 -0
- package/dist/mods/editor-ui/list-items.js.map +1 -0
- package/dist/mods/editor-ui/node-utils.d.ts +10 -0
- package/dist/mods/editor-ui/node-utils.d.ts.map +1 -0
- package/dist/mods/editor-ui/node-utils.js +76 -0
- package/dist/mods/editor-ui/node-utils.js.map +1 -0
- package/dist/mods/editor-ui/user-view.d.ts +2 -0
- package/dist/mods/editor-ui/user-view.d.ts.map +1 -0
- package/dist/mods/editor-ui/user-view.js +47 -0
- package/dist/mods/editor-ui/user-view.js.map +1 -0
- package/dist/mods/treenity/client.d.ts +4 -0
- package/dist/mods/treenity/client.d.ts.map +1 -0
- package/dist/mods/treenity/client.js +6 -0
- package/dist/mods/treenity/client.js.map +1 -0
- package/dist/mods/treenity/groups/index.d.ts +2 -0
- package/dist/mods/treenity/groups/index.d.ts.map +1 -0
- package/dist/mods/treenity/groups/index.js +27 -0
- package/dist/mods/treenity/groups/index.js.map +1 -0
- package/dist/mods/treenity/preview.d.ts +6 -0
- package/dist/mods/treenity/preview.d.ts.map +1 -0
- package/dist/mods/treenity/preview.js +95 -0
- package/dist/mods/treenity/preview.js.map +1 -0
- package/dist/mods/treenity/ref-view.d.ts +2 -0
- package/dist/mods/treenity/ref-view.d.ts.map +1 -0
- package/dist/mods/treenity/ref-view.js +29 -0
- package/dist/mods/treenity/ref-view.js.map +1 -0
- package/dist/mods/treenity/schema-form.d.ts +2 -0
- package/dist/mods/treenity/schema-form.d.ts.map +1 -0
- package/dist/mods/treenity/schema-form.js +38 -0
- package/dist/mods/treenity/schema-form.js.map +1 -0
- package/dist/mods/treenity/seed.d.ts +2 -0
- package/dist/mods/treenity/seed.d.ts.map +1 -0
- package/dist/mods/treenity/seed.js +53 -0
- package/dist/mods/treenity/seed.js.map +1 -0
- package/dist/mods/treenity/server.d.ts +2 -0
- package/dist/mods/treenity/server.d.ts.map +1 -0
- package/dist/mods/treenity/server.js +2 -0
- package/dist/mods/treenity/server.js.map +1 -0
- package/dist/mods/treenity/type-view.d.ts +2 -0
- package/dist/mods/treenity/type-view.d.ts.map +1 -0
- package/dist/mods/treenity/type-view.js +36 -0
- package/dist/mods/treenity/type-view.js.map +1 -0
- package/dist/remote-tree.d.ts +6 -0
- package/dist/remote-tree.d.ts.map +1 -0
- package/dist/remote-tree.js +18 -0
- package/dist/remote-tree.js.map +1 -0
- package/dist/schema-loader.d.ts +19 -0
- package/dist/schema-loader.d.ts.map +1 -0
- package/dist/schema-loader.js +63 -0
- package/dist/schema-loader.js.map +1 -0
- package/dist/trpc.d.ts +187 -0
- package/dist/trpc.d.ts.map +1 -0
- package/dist/trpc.js +21 -0
- package/dist/trpc.js.map +1 -0
- package/package.json +88 -0
- package/src/AclEditor.tsx +330 -0
- package/src/App.tsx +775 -0
- package/src/CLAUDE.md +16 -0
- package/src/Inspector.tsx +857 -0
- package/src/Tree.tsx +237 -0
- package/src/ViewPage.tsx +45 -0
- package/src/bind/bind.test.ts +316 -0
- package/src/bind/computed.ts +64 -0
- package/src/bind/engine.ts +198 -0
- package/src/bind/eval.ts +108 -0
- package/src/bind/hook.ts +112 -0
- package/src/bind/parse.ts +104 -0
- package/src/bind/pipes.ts +71 -0
- package/src/cache.test.ts +139 -0
- package/src/cache.ts +244 -0
- package/src/client-tree.test.ts +116 -0
- package/src/client-tree.ts +24 -0
- package/src/client.ts +11 -0
- package/src/components/ui/accordion.tsx +63 -0
- package/src/components/ui/badge.tsx +27 -0
- package/src/components/ui/button.tsx +44 -0
- package/src/components/ui/checkbox.tsx +19 -0
- package/src/components/ui/dialog.tsx +156 -0
- package/src/components/ui/drawer.tsx +132 -0
- package/src/components/ui/input.tsx +19 -0
- package/src/components/ui/label.tsx +21 -0
- package/src/components/ui/popover.tsx +86 -0
- package/src/components/ui/progress.tsx +30 -0
- package/src/components/ui/select.tsx +189 -0
- package/src/components/ui/slider.tsx +62 -0
- package/src/components/ui/sonner.tsx +32 -0
- package/src/components/ui/switch.tsx +34 -0
- package/src/components/ui/textarea.tsx +17 -0
- package/src/components/ui/tooltip.tsx +56 -0
- package/src/context/index.tsx +131 -0
- package/src/context.ts +1 -0
- package/src/hooks.ts +208 -0
- package/src/idb.ts +80 -0
- package/src/index.html +14 -0
- package/src/lib/minimd.css +28 -0
- package/src/lib/minimd.ts +95 -0
- package/src/lib/utils.ts +6 -0
- package/src/load-client.ts +5 -0
- package/src/main.tsx +22 -0
- package/src/mods/editor-ui/CLAUDE.md +3 -0
- package/src/mods/editor-ui/client.ts +8 -0
- package/src/mods/editor-ui/default-view.tsx +148 -0
- package/src/mods/editor-ui/dir-view.tsx +91 -0
- package/src/mods/editor-ui/form-fields.tsx +861 -0
- package/src/mods/editor-ui/layout-view.tsx +62 -0
- package/src/mods/editor-ui/list-items.tsx +63 -0
- package/src/mods/editor-ui/node-utils.ts +84 -0
- package/src/mods/editor-ui/user-view.tsx +101 -0
- package/src/mods/treenity/CLAUDE.md +7 -0
- package/src/mods/treenity/client.ts +6 -0
- package/src/mods/treenity/groups/index.tsx +65 -0
- package/src/mods/treenity/preview.tsx +133 -0
- package/src/mods/treenity/ref-view.tsx +87 -0
- package/src/mods/treenity/schema-form.tsx +65 -0
- package/src/mods/treenity/seed.ts +56 -0
- package/src/mods/treenity/server.ts +1 -0
- package/src/mods/treenity/type-view.tsx +116 -0
- package/src/remote-tree.test.ts +142 -0
- package/src/remote-tree.ts +25 -0
- package/src/schema-loader.ts +84 -0
- package/src/style.css +1269 -0
- package/src/trpc.ts +27 -0
- package/src/vite-env.d.ts +3 -0
package/src/Tree.tsx
ADDED
|
@@ -0,0 +1,237 @@
|
|
|
1
|
+
import { useCallback, useEffect, useRef, useState, useSyncExternalStore } from 'react';
|
|
2
|
+
import * as cache from './cache';
|
|
3
|
+
|
|
4
|
+
type TreeProps = {
|
|
5
|
+
roots: string[];
|
|
6
|
+
expanded: Set<string>;
|
|
7
|
+
loaded: Set<string>;
|
|
8
|
+
selected: string | null;
|
|
9
|
+
filter: string;
|
|
10
|
+
showHidden: boolean;
|
|
11
|
+
onSelect: (path: string) => void;
|
|
12
|
+
onExpand: (path: string) => void;
|
|
13
|
+
onCreateChild: (parentPath: string) => void;
|
|
14
|
+
onDelete?: (path: string) => void;
|
|
15
|
+
onMove?: (fromPath: string, toPath: string) => void;
|
|
16
|
+
};
|
|
17
|
+
|
|
18
|
+
function nodeName(p: string) {
|
|
19
|
+
return p.slice(p.lastIndexOf('/') + 1) || '/';
|
|
20
|
+
}
|
|
21
|
+
|
|
22
|
+
function matchesFilter(name: string, type: string, filter: string): boolean {
|
|
23
|
+
if (!filter) return true;
|
|
24
|
+
const lf = filter.toLowerCase();
|
|
25
|
+
return name.toLowerCase().includes(lf) || type.toLowerCase().includes(lf);
|
|
26
|
+
}
|
|
27
|
+
|
|
28
|
+
function hasMatchingDescendant(path: string, filter: string): boolean {
|
|
29
|
+
const nodes = cache.raw();
|
|
30
|
+
const prefix = path === '/' ? '/' : path + '/';
|
|
31
|
+
for (const [k, v] of nodes) {
|
|
32
|
+
if (k.startsWith(prefix) && matchesFilter(nodeName(k), v.$type, filter)) return true;
|
|
33
|
+
}
|
|
34
|
+
return false;
|
|
35
|
+
}
|
|
36
|
+
|
|
37
|
+
function BadgeMenu({
|
|
38
|
+
path,
|
|
39
|
+
typeLabel,
|
|
40
|
+
fullType,
|
|
41
|
+
onCreateChild,
|
|
42
|
+
onDelete,
|
|
43
|
+
}: {
|
|
44
|
+
path: string;
|
|
45
|
+
typeLabel: string;
|
|
46
|
+
fullType: string;
|
|
47
|
+
onCreateChild: (path: string) => void;
|
|
48
|
+
onDelete?: (path: string) => void;
|
|
49
|
+
}) {
|
|
50
|
+
const [open, setOpen] = useState(false);
|
|
51
|
+
const ref = useRef<HTMLDivElement>(null);
|
|
52
|
+
|
|
53
|
+
useEffect(() => {
|
|
54
|
+
if (!open) return;
|
|
55
|
+
const close = (e: MouseEvent) => {
|
|
56
|
+
if (ref.current && !ref.current.contains(e.target as Node)) setOpen(false);
|
|
57
|
+
};
|
|
58
|
+
document.addEventListener('mousedown', close);
|
|
59
|
+
return () => document.removeEventListener('mousedown', close);
|
|
60
|
+
}, [open]);
|
|
61
|
+
|
|
62
|
+
return (
|
|
63
|
+
<div ref={ref} className="tree-badge-wrap">
|
|
64
|
+
<span
|
|
65
|
+
className="tree-badge"
|
|
66
|
+
title={fullType}
|
|
67
|
+
onClick={(e) => {
|
|
68
|
+
e.stopPropagation();
|
|
69
|
+
setOpen(!open);
|
|
70
|
+
}}
|
|
71
|
+
>
|
|
72
|
+
{typeLabel}
|
|
73
|
+
</span>
|
|
74
|
+
{open && (
|
|
75
|
+
<div className="tree-menu">
|
|
76
|
+
<button
|
|
77
|
+
className="tree-menu-item"
|
|
78
|
+
onClick={(e) => {
|
|
79
|
+
e.stopPropagation();
|
|
80
|
+
setOpen(false);
|
|
81
|
+
onCreateChild(path);
|
|
82
|
+
}}
|
|
83
|
+
>
|
|
84
|
+
+ Add child
|
|
85
|
+
</button>
|
|
86
|
+
{onDelete && (
|
|
87
|
+
<button
|
|
88
|
+
className="tree-menu-item danger"
|
|
89
|
+
onClick={(e) => {
|
|
90
|
+
e.stopPropagation();
|
|
91
|
+
setOpen(false);
|
|
92
|
+
if (confirm(`Delete ${path}?`)) onDelete(path);
|
|
93
|
+
}}
|
|
94
|
+
>
|
|
95
|
+
× Delete
|
|
96
|
+
</button>
|
|
97
|
+
)}
|
|
98
|
+
</div>
|
|
99
|
+
)}
|
|
100
|
+
</div>
|
|
101
|
+
);
|
|
102
|
+
}
|
|
103
|
+
|
|
104
|
+
function TreeNode({
|
|
105
|
+
path,
|
|
106
|
+
expanded,
|
|
107
|
+
loaded = new Set<string>(),
|
|
108
|
+
selected,
|
|
109
|
+
filter,
|
|
110
|
+
showHidden,
|
|
111
|
+
depth,
|
|
112
|
+
onSelect,
|
|
113
|
+
onExpand,
|
|
114
|
+
onCreateChild,
|
|
115
|
+
onDelete,
|
|
116
|
+
onMove,
|
|
117
|
+
}: Omit<TreeProps, 'roots'> & { path: string; depth: number }) {
|
|
118
|
+
// Granular subscriptions — only re-render when THIS node or ITS children change
|
|
119
|
+
const node = useSyncExternalStore(
|
|
120
|
+
useCallback((cb: () => void) => cache.subscribePath(path, cb), [path]),
|
|
121
|
+
useCallback(() => cache.get(path), [path]),
|
|
122
|
+
);
|
|
123
|
+
const childrenNodes = useSyncExternalStore(
|
|
124
|
+
useCallback((cb: () => void) => cache.subscribeChildren(path, cb), [path]),
|
|
125
|
+
useCallback(() => cache.getChildren(path), [path]),
|
|
126
|
+
);
|
|
127
|
+
|
|
128
|
+
const rowRef = useRef<HTMLDivElement>(null);
|
|
129
|
+
const [dragOver, setDragOver] = useState<'above' | 'below' | null>(null);
|
|
130
|
+
|
|
131
|
+
if (!node) return null;
|
|
132
|
+
|
|
133
|
+
const name = nodeName(path);
|
|
134
|
+
const isExp = expanded.has(path);
|
|
135
|
+
const allChildren = childrenNodes.map(n => n.$path).filter(p => p !== path);
|
|
136
|
+
const knownChildren = showHidden
|
|
137
|
+
? allChildren
|
|
138
|
+
: allChildren.filter(p => !nodeName(p).startsWith('_'));
|
|
139
|
+
const showChildren = filter ? knownChildren : isExp ? knownChildren : [];
|
|
140
|
+
const filteredChildren = filter
|
|
141
|
+
? showChildren.filter((c) => {
|
|
142
|
+
const cn = cache.get(c);
|
|
143
|
+
if (!cn) return false;
|
|
144
|
+
return matchesFilter(nodeName(c), cn.$type, filter) || hasMatchingDescendant(c, filter);
|
|
145
|
+
})
|
|
146
|
+
: showChildren;
|
|
147
|
+
|
|
148
|
+
if (filter && !matchesFilter(name, node.$type, filter) && filteredChildren.length === 0) {
|
|
149
|
+
return null;
|
|
150
|
+
}
|
|
151
|
+
|
|
152
|
+
const indent = depth * 12 + 4;
|
|
153
|
+
const typeLabel = node.$type.includes('.') ? node.$type.slice(node.$type.lastIndexOf('.') + 1) : node.$type;
|
|
154
|
+
|
|
155
|
+
return (
|
|
156
|
+
<div className="tree-node">
|
|
157
|
+
<div
|
|
158
|
+
ref={rowRef}
|
|
159
|
+
className={`tree-row${selected === path ? ' selected' : ''}${dragOver === 'above' ? ' tree-drop-above' : ''}${dragOver === 'below' ? ' tree-drop-below' : ''}`}
|
|
160
|
+
style={{ paddingLeft: indent }}
|
|
161
|
+
onClick={() => onSelect(path)}
|
|
162
|
+
draggable
|
|
163
|
+
onDragStart={(e) => {
|
|
164
|
+
e.dataTransfer.setData('text/plain', path);
|
|
165
|
+
e.dataTransfer.setData('application/treenity-path', path);
|
|
166
|
+
e.dataTransfer.effectAllowed = 'all';
|
|
167
|
+
}}
|
|
168
|
+
onDragOver={(e) => {
|
|
169
|
+
e.preventDefault();
|
|
170
|
+
const rect = rowRef.current!.getBoundingClientRect();
|
|
171
|
+
const y = e.clientY - rect.top;
|
|
172
|
+
setDragOver(y < rect.height / 2 ? 'above' : 'below');
|
|
173
|
+
}}
|
|
174
|
+
onDragLeave={() => setDragOver(null)}
|
|
175
|
+
onDrop={(e) => {
|
|
176
|
+
e.preventDefault();
|
|
177
|
+
setDragOver(null);
|
|
178
|
+
const from = e.dataTransfer.getData('text/plain');
|
|
179
|
+
if (from && from !== path && onMove) onMove(from, path);
|
|
180
|
+
}}
|
|
181
|
+
>
|
|
182
|
+
{knownChildren.length > 0 || !loaded.has(path) ? (
|
|
183
|
+
<span
|
|
184
|
+
className="tree-toggle"
|
|
185
|
+
onClick={(e) => {
|
|
186
|
+
e.stopPropagation();
|
|
187
|
+
onExpand(path);
|
|
188
|
+
}}
|
|
189
|
+
>
|
|
190
|
+
{isExp ? '\u25BE' : '\u25B8'}
|
|
191
|
+
</span>
|
|
192
|
+
) : (
|
|
193
|
+
<span className="tree-toggle" />
|
|
194
|
+
)}
|
|
195
|
+
<span className="tree-label">{name}</span>
|
|
196
|
+
<BadgeMenu
|
|
197
|
+
path={path}
|
|
198
|
+
typeLabel={typeLabel}
|
|
199
|
+
fullType={node.$type}
|
|
200
|
+
onCreateChild={onCreateChild}
|
|
201
|
+
onDelete={onDelete}
|
|
202
|
+
/>
|
|
203
|
+
</div>
|
|
204
|
+
{(isExp || filter) && filteredChildren.length > 0 && (
|
|
205
|
+
<div className="tree-children">
|
|
206
|
+
{filteredChildren.map((c) => (
|
|
207
|
+
<TreeNode
|
|
208
|
+
key={c}
|
|
209
|
+
path={c}
|
|
210
|
+
depth={depth + 1}
|
|
211
|
+
expanded={expanded}
|
|
212
|
+
loaded={loaded}
|
|
213
|
+
selected={selected}
|
|
214
|
+
filter={filter}
|
|
215
|
+
showHidden={showHidden}
|
|
216
|
+
onSelect={onSelect}
|
|
217
|
+
onExpand={onExpand}
|
|
218
|
+
onCreateChild={onCreateChild}
|
|
219
|
+
onDelete={onDelete}
|
|
220
|
+
onMove={onMove}
|
|
221
|
+
/>
|
|
222
|
+
))}
|
|
223
|
+
</div>
|
|
224
|
+
)}
|
|
225
|
+
</div>
|
|
226
|
+
);
|
|
227
|
+
}
|
|
228
|
+
|
|
229
|
+
export function Tree(props: TreeProps) {
|
|
230
|
+
return (
|
|
231
|
+
<div>
|
|
232
|
+
{props.roots.map((r) => (
|
|
233
|
+
<TreeNode key={r} path={r} depth={0} {...props} />
|
|
234
|
+
))}
|
|
235
|
+
</div>
|
|
236
|
+
);
|
|
237
|
+
}
|
package/src/ViewPage.tsx
ADDED
|
@@ -0,0 +1,45 @@
|
|
|
1
|
+
// ViewPage — standalone read-only node view at /{path}
|
|
2
|
+
|
|
3
|
+
import { Render, RenderContext } from '#context';
|
|
4
|
+
import { usePath } from './hooks';
|
|
5
|
+
|
|
6
|
+
export function ViewPage({ path, editorLink }: { path: string; editorLink?: boolean }) {
|
|
7
|
+
const node = usePath(path);
|
|
8
|
+
|
|
9
|
+
const name = path === '/' ? '/' : path.slice(path.lastIndexOf('/') + 1);
|
|
10
|
+
|
|
11
|
+
if (!node) {
|
|
12
|
+
return (
|
|
13
|
+
<div className="flex flex-col items-center justify-center h-screen gap-4 text-[--text-3]">
|
|
14
|
+
<div className="text-4xl">404</div>
|
|
15
|
+
<p>Node not found: <span className="font-mono">{path}</span></p>
|
|
16
|
+
{editorLink && <a href={`/t${path}`} className="text-[--accent] hover:underline">Open in editor</a>}
|
|
17
|
+
</div>
|
|
18
|
+
);
|
|
19
|
+
}
|
|
20
|
+
|
|
21
|
+
return (
|
|
22
|
+
<div className="flex flex-col h-screen">
|
|
23
|
+
{editorLink && (
|
|
24
|
+
<div className="flex items-center gap-3 px-4 py-2 border-b border-[--border] bg-[--bg-2]">
|
|
25
|
+
<span className="font-semibold text-sm">{name}</span>
|
|
26
|
+
<span className="text-[10px] font-mono px-1.5 py-0.5 rounded bg-[--accent]/10 text-[--accent]">
|
|
27
|
+
{node.$type}
|
|
28
|
+
</span>
|
|
29
|
+
<span className="flex-1" />
|
|
30
|
+
<a
|
|
31
|
+
href={`/t${path}`}
|
|
32
|
+
className="text-xs text-[--text-3] hover:text-[--text-1] no-underline"
|
|
33
|
+
>
|
|
34
|
+
Open in editor
|
|
35
|
+
</a>
|
|
36
|
+
</div>
|
|
37
|
+
)}
|
|
38
|
+
<div className="flex-1 overflow-auto p-4 has-[.view-full]:p-0">
|
|
39
|
+
<RenderContext name="react">
|
|
40
|
+
<Render value={node} />
|
|
41
|
+
</RenderContext>
|
|
42
|
+
</div>
|
|
43
|
+
</div>
|
|
44
|
+
);
|
|
45
|
+
}
|
|
@@ -0,0 +1,316 @@
|
|
|
1
|
+
import type { NodeData } from '@treenity/core/core';
|
|
2
|
+
import assert from 'node:assert/strict';
|
|
3
|
+
import { describe, it } from 'node:test';
|
|
4
|
+
import { clearComputed, getComputed, setComputed, subscribeComputed } from './computed';
|
|
5
|
+
import { evaluateRef, extractArgPaths, hasOnce, isCollectionRef } from './eval';
|
|
6
|
+
import { isRefArg, parseMapExpr } from './parse';
|
|
7
|
+
|
|
8
|
+
// ── Parser ──
|
|
9
|
+
|
|
10
|
+
describe('parseMapExpr', () => {
|
|
11
|
+
it('parses pipe with field chain', () => {
|
|
12
|
+
const expr = parseMapExpr('last().value | div(5)');
|
|
13
|
+
assert.deepEqual(expr.steps, [
|
|
14
|
+
{ type: 'pipe', name: 'last', args: [] },
|
|
15
|
+
{ type: 'field', name: 'value' },
|
|
16
|
+
{ type: 'pipe', name: 'div', args: [5] },
|
|
17
|
+
]);
|
|
18
|
+
});
|
|
19
|
+
|
|
20
|
+
it('parses bare pipe (no parens)', () => {
|
|
21
|
+
const expr = parseMapExpr('round');
|
|
22
|
+
assert.deepEqual(expr.steps, [
|
|
23
|
+
{ type: 'pipe', name: 'round', args: [] },
|
|
24
|
+
]);
|
|
25
|
+
});
|
|
26
|
+
|
|
27
|
+
it('parses multiple args', () => {
|
|
28
|
+
const expr = parseMapExpr('clamp(0, 10)');
|
|
29
|
+
assert.deepEqual(expr.steps, [
|
|
30
|
+
{ type: 'pipe', name: 'clamp', args: [0, 10] },
|
|
31
|
+
]);
|
|
32
|
+
});
|
|
33
|
+
|
|
34
|
+
it('parses field-only chain', () => {
|
|
35
|
+
const expr = parseMapExpr('.status');
|
|
36
|
+
assert.deepEqual(expr.steps, [
|
|
37
|
+
{ type: 'field', name: 'status' },
|
|
38
|
+
]);
|
|
39
|
+
});
|
|
40
|
+
|
|
41
|
+
it('parses complex chain', () => {
|
|
42
|
+
const expr = parseMapExpr('last().value | sub(20) | abs | div(10)');
|
|
43
|
+
assert.deepEqual(expr.steps, [
|
|
44
|
+
{ type: 'pipe', name: 'last', args: [] },
|
|
45
|
+
{ type: 'field', name: 'value' },
|
|
46
|
+
{ type: 'pipe', name: 'sub', args: [20] },
|
|
47
|
+
{ type: 'pipe', name: 'abs', args: [] },
|
|
48
|
+
{ type: 'pipe', name: 'div', args: [10] },
|
|
49
|
+
]);
|
|
50
|
+
});
|
|
51
|
+
|
|
52
|
+
it('parses map pipe', () => {
|
|
53
|
+
const expr = parseMapExpr('map(value) | avg');
|
|
54
|
+
assert.deepEqual(expr.steps, [
|
|
55
|
+
{ type: 'pipe', name: 'map', args: ['value'] },
|
|
56
|
+
{ type: 'pipe', name: 'avg', args: [] },
|
|
57
|
+
]);
|
|
58
|
+
});
|
|
59
|
+
|
|
60
|
+
it('parses #field as step (self lookup)', () => {
|
|
61
|
+
const expr = parseMapExpr('#width | mul(#height)');
|
|
62
|
+
assert.deepEqual(expr.steps, [
|
|
63
|
+
{ type: 'field', name: 'width' },
|
|
64
|
+
{ type: 'pipe', name: 'mul', args: [{ $ref: '.', fields: ['height'] }] },
|
|
65
|
+
]);
|
|
66
|
+
});
|
|
67
|
+
|
|
68
|
+
it('parses #/path.field ref arg', () => {
|
|
69
|
+
const expr = parseMapExpr('#price | mul(#/config/tax.rate)');
|
|
70
|
+
const mulStep = expr.steps[1];
|
|
71
|
+
assert.equal(mulStep.type, 'pipe');
|
|
72
|
+
if (mulStep.type === 'pipe') {
|
|
73
|
+
assert.equal(isRefArg(mulStep.args[0]), true);
|
|
74
|
+
assert.deepEqual(mulStep.args[0], { $ref: '/config/tax', fields: ['rate'] });
|
|
75
|
+
}
|
|
76
|
+
});
|
|
77
|
+
|
|
78
|
+
it('parses #/path without field (whole node)', () => {
|
|
79
|
+
const expr = parseMapExpr('count(#/sensors)');
|
|
80
|
+
const step = expr.steps[0];
|
|
81
|
+
if (step.type === 'pipe') {
|
|
82
|
+
assert.deepEqual(step.args[0], { $ref: '/sensors', fields: [] });
|
|
83
|
+
}
|
|
84
|
+
});
|
|
85
|
+
|
|
86
|
+
it('parses #/path.deep.field', () => {
|
|
87
|
+
const expr = parseMapExpr('mul(#/config.tax.rate)');
|
|
88
|
+
const step = expr.steps[0];
|
|
89
|
+
if (step.type === 'pipe') {
|
|
90
|
+
assert.deepEqual(step.args[0], { $ref: '/config', fields: ['tax', 'rate'] });
|
|
91
|
+
}
|
|
92
|
+
});
|
|
93
|
+
});
|
|
94
|
+
|
|
95
|
+
// ── Evaluator ──
|
|
96
|
+
|
|
97
|
+
describe('evaluateRef', () => {
|
|
98
|
+
const config = { $path: '/config', $type: 'config', factor: 5, tax: { rate: 0.2 } } as NodeData;
|
|
99
|
+
|
|
100
|
+
const sensors = [
|
|
101
|
+
{ $path: '/s/1', $type: 'reading', value: 10, seq: 0 },
|
|
102
|
+
{ $path: '/s/2', $type: 'reading', value: 20, seq: 1 },
|
|
103
|
+
{ $path: '/s/3', $type: 'reading', value: 30, seq: 2 },
|
|
104
|
+
] as NodeData[];
|
|
105
|
+
|
|
106
|
+
const selfNode = { $path: '/obj', $type: 't3d.object', width: 4, height: 3, price: 100 } as NodeData;
|
|
107
|
+
|
|
108
|
+
const allNodes = [...sensors, config, selfNode];
|
|
109
|
+
|
|
110
|
+
const ctx = {
|
|
111
|
+
getNode: (p: string) => allNodes.find(s => s.$path === p),
|
|
112
|
+
getChildren: (p: string) => p === '/s' ? sensors : [],
|
|
113
|
+
};
|
|
114
|
+
|
|
115
|
+
it('resolves plain ref (no $map)', () => {
|
|
116
|
+
const result = evaluateRef({ $ref: '/s/2' }, ctx);
|
|
117
|
+
assert.equal((result as NodeData)?.value, 20);
|
|
118
|
+
});
|
|
119
|
+
|
|
120
|
+
it('resolves last().value | div(5)', () => {
|
|
121
|
+
const result = evaluateRef({ $ref: '/s', $map: 'last().value | div(5)' }, ctx);
|
|
122
|
+
assert.equal(result, 6); // 30 / 5
|
|
123
|
+
});
|
|
124
|
+
|
|
125
|
+
it('resolves first().value', () => {
|
|
126
|
+
const result = evaluateRef({ $ref: '/s', $map: 'first().value' }, ctx);
|
|
127
|
+
assert.equal(result, 10);
|
|
128
|
+
});
|
|
129
|
+
|
|
130
|
+
it('resolves count()', () => {
|
|
131
|
+
const result = evaluateRef({ $ref: '/s', $map: 'count()' }, ctx);
|
|
132
|
+
assert.equal(result, 3);
|
|
133
|
+
});
|
|
134
|
+
|
|
135
|
+
it('resolves map + avg', () => {
|
|
136
|
+
const result = evaluateRef({ $ref: '/s', $map: 'map(value) | avg' }, ctx);
|
|
137
|
+
assert.equal(result, 20); // (10+20+30)/3
|
|
138
|
+
});
|
|
139
|
+
|
|
140
|
+
it('resolves map + sum', () => {
|
|
141
|
+
const result = evaluateRef({ $ref: '/s', $map: 'map(value) | sum' }, ctx);
|
|
142
|
+
assert.equal(result, 60);
|
|
143
|
+
});
|
|
144
|
+
|
|
145
|
+
it('resolves scalar chain', () => {
|
|
146
|
+
const result = evaluateRef({ $ref: '/s', $map: 'last().value | sub(20) | abs | div(2)' }, ctx);
|
|
147
|
+
assert.equal(result, 5); // |30-20| / 2
|
|
148
|
+
});
|
|
149
|
+
|
|
150
|
+
it('resolves clamp', () => {
|
|
151
|
+
const result = evaluateRef({ $ref: '/s', $map: 'last().value | clamp(0, 25)' }, ctx);
|
|
152
|
+
assert.equal(result, 25); // 30 clamped to 25
|
|
153
|
+
});
|
|
154
|
+
|
|
155
|
+
it('resolves single node field access (no collection)', () => {
|
|
156
|
+
const result = evaluateRef({ $ref: '/s/1', $map: '.value | mul(3)' }, ctx);
|
|
157
|
+
assert.equal(result, 30); // 10 * 3
|
|
158
|
+
});
|
|
159
|
+
|
|
160
|
+
it('returns undefined for empty children', () => {
|
|
161
|
+
const result = evaluateRef({ $ref: '/empty', $map: 'last().value' }, ctx);
|
|
162
|
+
assert.equal(result, undefined);
|
|
163
|
+
});
|
|
164
|
+
|
|
165
|
+
// ── Self-ref + #-args ──
|
|
166
|
+
|
|
167
|
+
it('resolves #field self lookup', () => {
|
|
168
|
+
const result = evaluateRef({ $ref: '/obj', $map: '#width | mul(#height)' }, ctx);
|
|
169
|
+
assert.equal(result, 12); // 4 * 3
|
|
170
|
+
});
|
|
171
|
+
|
|
172
|
+
it('resolves cross-ref #/path.field arg', () => {
|
|
173
|
+
const result = evaluateRef({ $ref: '/obj', $map: '#price | mul(#/config.tax.rate)' }, ctx);
|
|
174
|
+
assert.equal(result, 20); // 100 * 0.2
|
|
175
|
+
});
|
|
176
|
+
|
|
177
|
+
it('resolves external source + self arg via #', () => {
|
|
178
|
+
const result = evaluateRef({ $ref: '/s', $map: 'last().value | div(#/config.factor)' }, ctx);
|
|
179
|
+
assert.equal(result, 6); // 30 / 5
|
|
180
|
+
});
|
|
181
|
+
|
|
182
|
+
it('returns NaN for missing #ref node (loud failure)', () => {
|
|
183
|
+
const result = evaluateRef({ $ref: '/obj', $map: '#width | mul(#/missing.value)' }, ctx);
|
|
184
|
+
assert.equal(Number.isNaN(result), true); // 4 * undefined = NaN
|
|
185
|
+
});
|
|
186
|
+
});
|
|
187
|
+
|
|
188
|
+
// ── isCollectionRef ──
|
|
189
|
+
|
|
190
|
+
describe('isCollectionRef', () => {
|
|
191
|
+
it('true for last()', () => {
|
|
192
|
+
assert.equal(isCollectionRef({ $ref: '/s', $map: 'last().value' }), true);
|
|
193
|
+
});
|
|
194
|
+
|
|
195
|
+
it('true for count()', () => {
|
|
196
|
+
assert.equal(isCollectionRef({ $ref: '/s', $map: 'count()' }), true);
|
|
197
|
+
});
|
|
198
|
+
|
|
199
|
+
it('false for field access', () => {
|
|
200
|
+
assert.equal(isCollectionRef({ $ref: '/s/1', $map: '.value' }), false);
|
|
201
|
+
});
|
|
202
|
+
|
|
203
|
+
it('false for #field self access', () => {
|
|
204
|
+
assert.equal(isCollectionRef({ $ref: '.', $map: '#width' }), false);
|
|
205
|
+
});
|
|
206
|
+
|
|
207
|
+
it('false for no $map', () => {
|
|
208
|
+
assert.equal(isCollectionRef({ $ref: '/s/1' }), false);
|
|
209
|
+
});
|
|
210
|
+
});
|
|
211
|
+
|
|
212
|
+
// ── extractArgPaths ──
|
|
213
|
+
|
|
214
|
+
describe('extractArgPaths', () => {
|
|
215
|
+
it('returns external paths from #/path args', () => {
|
|
216
|
+
const paths = extractArgPaths({ $ref: '/obj', $map: '#price | mul(#/config.rate) | add(#/bonus.value)' });
|
|
217
|
+
assert.deepEqual(paths, ['/config', '/bonus']);
|
|
218
|
+
});
|
|
219
|
+
|
|
220
|
+
it('skips # self refs', () => {
|
|
221
|
+
const paths = extractArgPaths({ $ref: '.', $map: '#width | mul(#height)' });
|
|
222
|
+
assert.deepEqual(paths, []);
|
|
223
|
+
});
|
|
224
|
+
|
|
225
|
+
it('returns empty for no $map', () => {
|
|
226
|
+
assert.deepEqual(extractArgPaths({ $ref: '/x' }), []);
|
|
227
|
+
});
|
|
228
|
+
});
|
|
229
|
+
|
|
230
|
+
// ── hasOnce ──
|
|
231
|
+
|
|
232
|
+
describe('hasOnce', () => {
|
|
233
|
+
it('true when once in pipe chain', () => {
|
|
234
|
+
assert.equal(hasOnce({ $ref: '/s', $map: 'last().value | div(5) | once' }), true);
|
|
235
|
+
});
|
|
236
|
+
|
|
237
|
+
it('true when once is only step', () => {
|
|
238
|
+
assert.equal(hasOnce({ $ref: '/s', $map: 'once' }), true);
|
|
239
|
+
});
|
|
240
|
+
|
|
241
|
+
it('false for normal pipes', () => {
|
|
242
|
+
assert.equal(hasOnce({ $ref: '/s', $map: 'last().value | div(5)' }), false);
|
|
243
|
+
});
|
|
244
|
+
|
|
245
|
+
it('false for no $map', () => {
|
|
246
|
+
assert.equal(hasOnce({ $ref: '/s' }), false);
|
|
247
|
+
});
|
|
248
|
+
});
|
|
249
|
+
|
|
250
|
+
// ── once pipe (identity) ──
|
|
251
|
+
|
|
252
|
+
describe('once pipe in evaluation', () => {
|
|
253
|
+
const nodes = [
|
|
254
|
+
{ $path: '/s/1', $type: 'r', value: 10 },
|
|
255
|
+
{ $path: '/s/2', $type: 'r', value: 20 },
|
|
256
|
+
] as NodeData[];
|
|
257
|
+
|
|
258
|
+
const ctx = {
|
|
259
|
+
getNode: (p: string) => nodes.find(n => n.$path === p),
|
|
260
|
+
getChildren: (p: string) => p === '/s' ? nodes : [],
|
|
261
|
+
};
|
|
262
|
+
|
|
263
|
+
it('once does not alter the computed value', () => {
|
|
264
|
+
const withOnce = evaluateRef({ $ref: '/s', $map: 'last().value | div(5) | once' }, ctx);
|
|
265
|
+
const without = evaluateRef({ $ref: '/s', $map: 'last().value | div(5)' }, ctx);
|
|
266
|
+
assert.equal(withOnce, without);
|
|
267
|
+
assert.equal(withOnce, 4); // 20 / 5
|
|
268
|
+
});
|
|
269
|
+
});
|
|
270
|
+
|
|
271
|
+
// ── Computed store ──
|
|
272
|
+
|
|
273
|
+
describe('computed store', () => {
|
|
274
|
+
it('set + get', () => {
|
|
275
|
+
setComputed('/test', 'sy', 42);
|
|
276
|
+
const c = getComputed('/test');
|
|
277
|
+
assert.equal(c?.sy, 42);
|
|
278
|
+
clearComputed('/test');
|
|
279
|
+
});
|
|
280
|
+
|
|
281
|
+
it('fires subscriber on change', () => {
|
|
282
|
+
let fired = 0;
|
|
283
|
+
const unsub = subscribeComputed('/test2', () => { fired++; });
|
|
284
|
+
setComputed('/test2', 'px', 1);
|
|
285
|
+
assert.equal(fired, 1);
|
|
286
|
+
setComputed('/test2', 'px', 2);
|
|
287
|
+
assert.equal(fired, 2);
|
|
288
|
+
// No-op same value
|
|
289
|
+
setComputed('/test2', 'px', 2);
|
|
290
|
+
assert.equal(fired, 2);
|
|
291
|
+
unsub();
|
|
292
|
+
clearComputed('/test2');
|
|
293
|
+
});
|
|
294
|
+
|
|
295
|
+
it('returns new object reference on change (useSyncExternalStore compat)', () => {
|
|
296
|
+
setComputed('/ref-test', 'a', 1);
|
|
297
|
+
const snap1 = getComputed('/ref-test');
|
|
298
|
+
setComputed('/ref-test', 'a', 2);
|
|
299
|
+
const snap2 = getComputed('/ref-test');
|
|
300
|
+
// Must be different references — Object.is must see the change
|
|
301
|
+
assert.notEqual(snap1, snap2);
|
|
302
|
+
assert.equal(snap2?.a, 2);
|
|
303
|
+
// Old snapshot retains old value (immutable)
|
|
304
|
+
assert.equal(snap1?.a, 1);
|
|
305
|
+
clearComputed('/ref-test');
|
|
306
|
+
});
|
|
307
|
+
|
|
308
|
+
it('unsubscribe stops notifications', () => {
|
|
309
|
+
let fired = 0;
|
|
310
|
+
const unsub = subscribeComputed('/test3', () => { fired++; });
|
|
311
|
+
unsub();
|
|
312
|
+
setComputed('/test3', 'sy', 99);
|
|
313
|
+
assert.equal(fired, 0);
|
|
314
|
+
clearComputed('/test3');
|
|
315
|
+
});
|
|
316
|
+
});
|
|
@@ -0,0 +1,64 @@
|
|
|
1
|
+
// Computed store — separate reactive layer for $ref+$map resolved values
|
|
2
|
+
// Dual: valtio proxies for field-level React reactivity (useSnapshot),
|
|
3
|
+
// plus synchronous manual subs for bind engine and non-React consumers.
|
|
4
|
+
// Never persisted to IDB or server. No Meteor trap.
|
|
5
|
+
|
|
6
|
+
import { proxy, snapshot } from 'valtio/vanilla';
|
|
7
|
+
|
|
8
|
+
type Sub = () => void;
|
|
9
|
+
|
|
10
|
+
const proxies = new Map<string, Record<string, unknown>>();
|
|
11
|
+
const subs = new Map<string, Set<Sub>>();
|
|
12
|
+
|
|
13
|
+
function ensure(path: string): Record<string, unknown> {
|
|
14
|
+
let p = proxies.get(path);
|
|
15
|
+
if (!p) {
|
|
16
|
+
p = proxy<Record<string, unknown>>({});
|
|
17
|
+
proxies.set(path, p);
|
|
18
|
+
}
|
|
19
|
+
return p;
|
|
20
|
+
}
|
|
21
|
+
|
|
22
|
+
function fire(path: string): void {
|
|
23
|
+
const s = subs.get(path);
|
|
24
|
+
if (s) for (const cb of s) cb();
|
|
25
|
+
}
|
|
26
|
+
|
|
27
|
+
export function getComputed(path: string): Record<string, unknown> | undefined {
|
|
28
|
+
const p = proxies.get(path);
|
|
29
|
+
if (!p) return undefined;
|
|
30
|
+
return snapshot(p) as Record<string, unknown>;
|
|
31
|
+
}
|
|
32
|
+
|
|
33
|
+
/** Get the raw valtio proxy — for useSnapshot in hooks */
|
|
34
|
+
export function getComputedProxy(path: string): Record<string, unknown> | undefined {
|
|
35
|
+
return proxies.get(path);
|
|
36
|
+
}
|
|
37
|
+
|
|
38
|
+
export function setComputed(path: string, field: string, value: unknown): void {
|
|
39
|
+
const p = ensure(path);
|
|
40
|
+
if (Object.is(p[field], value)) return; // no-op if unchanged
|
|
41
|
+
p[field] = value;
|
|
42
|
+
fire(path); // synchronous for non-React consumers
|
|
43
|
+
}
|
|
44
|
+
|
|
45
|
+
export function clearComputed(path: string): void {
|
|
46
|
+
const p = proxies.get(path);
|
|
47
|
+
if (p) {
|
|
48
|
+
for (const k of Object.keys(p)) delete p[k];
|
|
49
|
+
proxies.delete(path);
|
|
50
|
+
}
|
|
51
|
+
fire(path);
|
|
52
|
+
}
|
|
53
|
+
|
|
54
|
+
export function subscribeComputed(path: string, cb: Sub): () => void {
|
|
55
|
+
if (!subs.has(path)) subs.set(path, new Set());
|
|
56
|
+
subs.get(path)!.add(cb);
|
|
57
|
+
return () => {
|
|
58
|
+
const s = subs.get(path);
|
|
59
|
+
if (s) {
|
|
60
|
+
s.delete(cb);
|
|
61
|
+
if (!s.size) subs.delete(path);
|
|
62
|
+
}
|
|
63
|
+
};
|
|
64
|
+
}
|