@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
|
@@ -0,0 +1,198 @@
|
|
|
1
|
+
// Binding engine — scans cache for $ref+$map fields, subscribes to sources,
|
|
2
|
+
// evaluates on change, writes to computed store.
|
|
3
|
+
// Supports $ref: "." (self) and @/path.field ref args in $map expressions.
|
|
4
|
+
|
|
5
|
+
import * as cache from '#cache';
|
|
6
|
+
import { trpc } from '#trpc';
|
|
7
|
+
import { isRef, type NodeData, type Ref } from '@treenity/core/core';
|
|
8
|
+
import { clearComputed, getComputed, setComputed } from './computed';
|
|
9
|
+
import { evaluateRef, extractArgPaths, hasOnce, isCollectionRef } from './eval';
|
|
10
|
+
|
|
11
|
+
type Unsub = () => void;
|
|
12
|
+
|
|
13
|
+
// Active bindings: targetPath → field → { ref, unsub }
|
|
14
|
+
const active = new Map<string, Map<string, { ref: Ref; unsub: Unsub }>>();
|
|
15
|
+
|
|
16
|
+
// Collection paths with active SSE watches
|
|
17
|
+
const watchedCollections = new Set<string>();
|
|
18
|
+
// Single-node paths already fetched
|
|
19
|
+
const fetchedNodes = new Set<string>();
|
|
20
|
+
|
|
21
|
+
const ctx = {
|
|
22
|
+
getNode: (p: string) => cache.get(p),
|
|
23
|
+
getChildren: (p: string) => cache.getChildren(p),
|
|
24
|
+
};
|
|
25
|
+
|
|
26
|
+
/** Fetch collection children from server into cache */
|
|
27
|
+
function fetchChildren(path: string): void {
|
|
28
|
+
trpc.getChildren
|
|
29
|
+
.query({ path, watch: true, watchNew: true })
|
|
30
|
+
.then((r: any) => cache.putMany(r.items as NodeData[], path));
|
|
31
|
+
}
|
|
32
|
+
|
|
33
|
+
/** Ensure source data is in cache by fetching from server */
|
|
34
|
+
function ensureInCache(path: string, collection: boolean): void {
|
|
35
|
+
if (collection) {
|
|
36
|
+
if (!watchedCollections.has(path)) {
|
|
37
|
+
watchedCollections.add(path);
|
|
38
|
+
fetchChildren(path);
|
|
39
|
+
}
|
|
40
|
+
} else {
|
|
41
|
+
if (fetchedNodes.has(path)) return;
|
|
42
|
+
fetchedNodes.add(path);
|
|
43
|
+
trpc.get.query({ path }).then((n: any) => {
|
|
44
|
+
if (n) cache.put(n as NodeData);
|
|
45
|
+
});
|
|
46
|
+
}
|
|
47
|
+
}
|
|
48
|
+
|
|
49
|
+
function evaluate(targetPath: string, field: string, ref: Ref): void {
|
|
50
|
+
// Resolve $ref: "." → actual target path before eval
|
|
51
|
+
const resolved = ref.$ref === '.' ? { ...ref, $ref: targetPath } : ref;
|
|
52
|
+
try {
|
|
53
|
+
const value = evaluateRef(resolved, ctx);
|
|
54
|
+
setComputed(targetPath, field, value);
|
|
55
|
+
} catch (e) {
|
|
56
|
+
console.warn(`[bind] eval error ${targetPath}.${field}:`, e);
|
|
57
|
+
}
|
|
58
|
+
}
|
|
59
|
+
|
|
60
|
+
function registerBinding(targetPath: string, field: string, ref: Ref): void {
|
|
61
|
+
const once = hasOnce(ref);
|
|
62
|
+
const unsubs: Unsub[] = [];
|
|
63
|
+
const cb = () => evaluate(targetPath, field, ref);
|
|
64
|
+
|
|
65
|
+
const mainPath = ref.$ref === '.' ? targetPath : ref.$ref;
|
|
66
|
+
const collection = isCollectionRef(ref);
|
|
67
|
+
|
|
68
|
+
if (mainPath !== targetPath) {
|
|
69
|
+
ensureInCache(mainPath, collection);
|
|
70
|
+
}
|
|
71
|
+
|
|
72
|
+
// `once` — evaluate once, no reactive subscription
|
|
73
|
+
if (!once) {
|
|
74
|
+
unsubs.push(
|
|
75
|
+
collection
|
|
76
|
+
? cache.subscribeChildren(mainPath, cb)
|
|
77
|
+
: cache.subscribePath(mainPath, cb),
|
|
78
|
+
);
|
|
79
|
+
|
|
80
|
+
for (const argPath of extractArgPaths(ref)) {
|
|
81
|
+
unsubs.push(cache.subscribePath(argPath, cb));
|
|
82
|
+
ensureInCache(argPath, false);
|
|
83
|
+
}
|
|
84
|
+
}
|
|
85
|
+
|
|
86
|
+
if (!active.has(targetPath)) active.set(targetPath, new Map());
|
|
87
|
+
active.get(targetPath)!.set(field, { ref, unsub: () => unsubs.forEach(u => u()) });
|
|
88
|
+
|
|
89
|
+
evaluate(targetPath, field, ref);
|
|
90
|
+
}
|
|
91
|
+
|
|
92
|
+
function unregisterAll(targetPath: string): void {
|
|
93
|
+
const bindings = active.get(targetPath);
|
|
94
|
+
if (!bindings) return;
|
|
95
|
+
for (const { unsub } of bindings.values()) unsub();
|
|
96
|
+
active.delete(targetPath);
|
|
97
|
+
clearComputed(targetPath);
|
|
98
|
+
}
|
|
99
|
+
|
|
100
|
+
/** Scan a single node for $ref+$map fields, register/update bindings */
|
|
101
|
+
function scanNode(node: NodeData): void {
|
|
102
|
+
const path = node.$path;
|
|
103
|
+
const newRefs = new Map<string, Ref>();
|
|
104
|
+
|
|
105
|
+
// Scan node-level fields
|
|
106
|
+
for (const [key, value] of Object.entries(node)) {
|
|
107
|
+
if (key.startsWith('$')) continue;
|
|
108
|
+
if (isRef(value) && value.$map) {
|
|
109
|
+
newRefs.set(key, value as Ref);
|
|
110
|
+
}
|
|
111
|
+
// Scan sub-component fields
|
|
112
|
+
if (typeof value === 'object' && value !== null && '$type' in value) {
|
|
113
|
+
for (const [subKey, subVal] of Object.entries(value)) {
|
|
114
|
+
if (subKey.startsWith('$')) continue;
|
|
115
|
+
if (isRef(subVal) && (subVal as Ref).$map) {
|
|
116
|
+
newRefs.set(`${key}.${subKey}`, subVal as Ref);
|
|
117
|
+
}
|
|
118
|
+
}
|
|
119
|
+
}
|
|
120
|
+
}
|
|
121
|
+
|
|
122
|
+
const existing = active.get(path);
|
|
123
|
+
|
|
124
|
+
// No bindings before or after — nothing to do
|
|
125
|
+
if (!existing && newRefs.size === 0) return;
|
|
126
|
+
|
|
127
|
+
// Remove stale bindings
|
|
128
|
+
if (existing) {
|
|
129
|
+
for (const [field, { unsub }] of existing) {
|
|
130
|
+
if (!newRefs.has(field)) {
|
|
131
|
+
unsub();
|
|
132
|
+
existing.delete(field);
|
|
133
|
+
}
|
|
134
|
+
}
|
|
135
|
+
if (existing.size === 0 && newRefs.size === 0) {
|
|
136
|
+
active.delete(path);
|
|
137
|
+
clearComputed(path);
|
|
138
|
+
return;
|
|
139
|
+
}
|
|
140
|
+
}
|
|
141
|
+
|
|
142
|
+
// Register new/updated bindings
|
|
143
|
+
for (const [field, ref] of newRefs) {
|
|
144
|
+
const prev = existing?.get(field);
|
|
145
|
+
// Skip if same expression
|
|
146
|
+
if (prev && prev.ref.$ref === ref.$ref && prev.ref.$map === ref.$map) continue;
|
|
147
|
+
// Unregister old
|
|
148
|
+
if (prev) prev.unsub();
|
|
149
|
+
registerBinding(path, field, ref);
|
|
150
|
+
}
|
|
151
|
+
}
|
|
152
|
+
|
|
153
|
+
function handleRemove(path: string): void {
|
|
154
|
+
unregisterAll(path);
|
|
155
|
+
}
|
|
156
|
+
|
|
157
|
+
/** Start the binding engine. Returns cleanup function. */
|
|
158
|
+
export function startBindEngine(): () => void {
|
|
159
|
+
// 1. Scan all existing nodes in cache
|
|
160
|
+
for (const [, node] of cache.raw()) {
|
|
161
|
+
scanNode(node);
|
|
162
|
+
}
|
|
163
|
+
|
|
164
|
+
// 2. Reactive: scan each node as it arrives/changes — instant, no polling
|
|
165
|
+
const unsubPut = cache.onNodePut((path) => {
|
|
166
|
+
const node = cache.get(path);
|
|
167
|
+
if (node) scanNode(node);
|
|
168
|
+
});
|
|
169
|
+
|
|
170
|
+
// 3. Detect removed nodes — clean up stale bindings
|
|
171
|
+
const unsubGlobal = cache.subscribeGlobal(() => {
|
|
172
|
+
for (const path of active.keys()) {
|
|
173
|
+
if (!cache.get(path)) handleRemove(path);
|
|
174
|
+
}
|
|
175
|
+
});
|
|
176
|
+
|
|
177
|
+
// Dev debug — inspect binding engine state from console
|
|
178
|
+
if (typeof window !== 'undefined') {
|
|
179
|
+
(window as any).__bind = {
|
|
180
|
+
active: () => Object.fromEntries([...active].map(([k, v]) => [k, [...v.keys()]])),
|
|
181
|
+
watched: () => [...watchedCollections],
|
|
182
|
+
fetched: () => [...fetchedNodes],
|
|
183
|
+
computed: () => {
|
|
184
|
+
const result: Record<string, unknown> = {};
|
|
185
|
+
for (const p of active.keys()) result[p] = getComputed(p);
|
|
186
|
+
return result;
|
|
187
|
+
},
|
|
188
|
+
};
|
|
189
|
+
}
|
|
190
|
+
|
|
191
|
+
return () => {
|
|
192
|
+
unsubPut();
|
|
193
|
+
unsubGlobal();
|
|
194
|
+
for (const path of [...active.keys()]) unregisterAll(path);
|
|
195
|
+
watchedCollections.clear();
|
|
196
|
+
fetchedNodes.clear();
|
|
197
|
+
};
|
|
198
|
+
}
|
package/src/bind/eval.ts
ADDED
|
@@ -0,0 +1,108 @@
|
|
|
1
|
+
// $ref + $map evaluator
|
|
2
|
+
// Resolves source from $ref, applies $map pipeline
|
|
3
|
+
// #field (self) and #/path.field (external) args resolved from context
|
|
4
|
+
|
|
5
|
+
import type { NodeData, Ref } from '@treenity/core/core';
|
|
6
|
+
import { isRefArg, type MapExpr, parseMapExpr, type PipeArg } from './parse';
|
|
7
|
+
import { getPipe } from './pipes';
|
|
8
|
+
|
|
9
|
+
export type BindCtx = {
|
|
10
|
+
getNode: (path: string) => NodeData | undefined;
|
|
11
|
+
getChildren: (path: string) => NodeData[];
|
|
12
|
+
};
|
|
13
|
+
|
|
14
|
+
// Collection pipe names — when first step is one of these, resolve source as children
|
|
15
|
+
const COLLECTION_PIPES = new Set(['last', 'first', 'count', 'avg', 'max', 'min', 'sum', 'map']);
|
|
16
|
+
|
|
17
|
+
// Parsed expression cache
|
|
18
|
+
const exprCache = new Map<string, MapExpr>();
|
|
19
|
+
|
|
20
|
+
function getCachedExpr(map: string): MapExpr {
|
|
21
|
+
let expr = exprCache.get(map);
|
|
22
|
+
if (!expr) {
|
|
23
|
+
expr = parseMapExpr(map);
|
|
24
|
+
exprCache.set(map, expr);
|
|
25
|
+
}
|
|
26
|
+
return expr;
|
|
27
|
+
}
|
|
28
|
+
|
|
29
|
+
/** Resolve a pipe argument — ref args looked up from context, scalars pass through */
|
|
30
|
+
function resolveArg(arg: PipeArg, ctx: BindCtx, refPath: string): unknown {
|
|
31
|
+
if (!isRefArg(arg)) return arg;
|
|
32
|
+
const path = arg.$ref === '.' ? refPath : arg.$ref;
|
|
33
|
+
let val: unknown = ctx.getNode(path);
|
|
34
|
+
for (const f of arg.fields) {
|
|
35
|
+
if (val == null || typeof val !== 'object') return undefined;
|
|
36
|
+
val = (val as Record<string, unknown>)[f];
|
|
37
|
+
}
|
|
38
|
+
return val;
|
|
39
|
+
}
|
|
40
|
+
|
|
41
|
+
export function evaluateRef(ref: Ref, ctx: BindCtx): unknown {
|
|
42
|
+
// Plain ref without $map — resolve to node
|
|
43
|
+
if (!ref.$map) {
|
|
44
|
+
return ctx.getNode(ref.$ref);
|
|
45
|
+
}
|
|
46
|
+
|
|
47
|
+
const expr = getCachedExpr(ref.$map);
|
|
48
|
+
const firstStep = expr.steps[0];
|
|
49
|
+
|
|
50
|
+
// Determine source: children (if first pipe is collection) or single node
|
|
51
|
+
let value: unknown;
|
|
52
|
+
if (firstStep?.type === 'pipe' && COLLECTION_PIPES.has(firstStep.name)) {
|
|
53
|
+
value = ctx.getChildren(ref.$ref);
|
|
54
|
+
} else {
|
|
55
|
+
value = ctx.getNode(ref.$ref);
|
|
56
|
+
}
|
|
57
|
+
|
|
58
|
+
// Apply pipeline left-to-right
|
|
59
|
+
for (const step of expr.steps) {
|
|
60
|
+
if (value === undefined || value === null) return undefined;
|
|
61
|
+
|
|
62
|
+
if (step.type === 'field') {
|
|
63
|
+
value = (value as Record<string, unknown>)[step.name];
|
|
64
|
+
} else {
|
|
65
|
+
const fn = getPipe(step.name);
|
|
66
|
+
if (!fn) {
|
|
67
|
+
console.warn(`[bind] unknown pipe: ${step.name}`);
|
|
68
|
+
return undefined;
|
|
69
|
+
}
|
|
70
|
+
const resolved = step.args.map(a => resolveArg(a, ctx, ref.$ref));
|
|
71
|
+
value = fn(value, ...resolved);
|
|
72
|
+
}
|
|
73
|
+
}
|
|
74
|
+
|
|
75
|
+
return value;
|
|
76
|
+
}
|
|
77
|
+
|
|
78
|
+
/** Check if first pipe in $map is a collection pipe (needs children subscription) */
|
|
79
|
+
export function isCollectionRef(ref: Ref): boolean {
|
|
80
|
+
if (!ref.$map) return false;
|
|
81
|
+
const expr = getCachedExpr(ref.$map);
|
|
82
|
+
const first = expr.steps[0];
|
|
83
|
+
return first?.type === 'pipe' && COLLECTION_PIPES.has(first.name);
|
|
84
|
+
}
|
|
85
|
+
|
|
86
|
+
/** Check if $map contains `once` pipe — disables reactive subscription */
|
|
87
|
+
export function hasOnce(ref: Ref): boolean {
|
|
88
|
+
if (!ref.$map) return false;
|
|
89
|
+
const expr = getCachedExpr(ref.$map);
|
|
90
|
+
return expr.steps.some(s => s.type === 'pipe' && s.name === 'once');
|
|
91
|
+
}
|
|
92
|
+
|
|
93
|
+
/** Extract all external #/path refs from a $map expression (for subscriptions) */
|
|
94
|
+
export function extractArgPaths(ref: Ref): string[] {
|
|
95
|
+
if (!ref.$map) return [];
|
|
96
|
+
const expr = getCachedExpr(ref.$map);
|
|
97
|
+
const paths: string[] = [];
|
|
98
|
+
for (const step of expr.steps) {
|
|
99
|
+
if (step.type === 'pipe') {
|
|
100
|
+
for (const arg of step.args) {
|
|
101
|
+
if (isRefArg(arg) && arg.$ref !== '.') {
|
|
102
|
+
paths.push(arg.$ref);
|
|
103
|
+
}
|
|
104
|
+
}
|
|
105
|
+
}
|
|
106
|
+
}
|
|
107
|
+
return paths;
|
|
108
|
+
}
|
package/src/bind/hook.ts
ADDED
|
@@ -0,0 +1,112 @@
|
|
|
1
|
+
// React hooks for computed bindings
|
|
2
|
+
|
|
3
|
+
import * as cache from '#cache';
|
|
4
|
+
import { set, usePath } from '#hooks';
|
|
5
|
+
import { isRef, type NodeData } from '@treenity/core/core';
|
|
6
|
+
import { useCallback, useMemo, useSyncExternalStore } from 'react';
|
|
7
|
+
import { useSnapshot } from 'valtio';
|
|
8
|
+
import { proxy } from 'valtio/vanilla';
|
|
9
|
+
import { getComputed, getComputedProxy, subscribeComputed } from './computed';
|
|
10
|
+
import { evaluateRef, extractArgPaths, isCollectionRef } from './eval';
|
|
11
|
+
|
|
12
|
+
const EMPTY_PROXY = proxy<Record<string, unknown>>({});
|
|
13
|
+
|
|
14
|
+
/** Reactive access to computed binding values for a node */
|
|
15
|
+
export function useComputed(path: string): Record<string, unknown> {
|
|
16
|
+
// Structural: detect proxy creation (first computed value for this path)
|
|
17
|
+
useSyncExternalStore(
|
|
18
|
+
useCallback((cb: () => void) => subscribeComputed(path, cb), [path]),
|
|
19
|
+
useCallback(() => getComputed(path), [path]),
|
|
20
|
+
);
|
|
21
|
+
|
|
22
|
+
// Field-level: re-render only when accessed computed fields change
|
|
23
|
+
const p = getComputedProxy(path);
|
|
24
|
+
return useSnapshot(p ?? EMPTY_PROXY) as Record<string, unknown>;
|
|
25
|
+
}
|
|
26
|
+
|
|
27
|
+
/** Node with computed bindings merged — raw + computed overlay */
|
|
28
|
+
export function useResolvedNode(path: string): [NodeData | undefined, (next: NodeData) => Promise<void>] {
|
|
29
|
+
const node = usePath(path);
|
|
30
|
+
const computed = useComputed(path);
|
|
31
|
+
|
|
32
|
+
if (!node) return [undefined, set];
|
|
33
|
+
|
|
34
|
+
// Check if node has any $ref+$map bindings that need resolving
|
|
35
|
+
const hasComputed = Object.keys(computed).length > 0;
|
|
36
|
+
const hasBindings = Object.values(node).some(v => isRef(v) && (v as any).$map);
|
|
37
|
+
if (!hasComputed && !hasBindings) return [node, set];
|
|
38
|
+
|
|
39
|
+
const merged = { ...node } as Record<string, unknown>;
|
|
40
|
+
|
|
41
|
+
// Apply computed values
|
|
42
|
+
for (const [key, value] of Object.entries(computed)) {
|
|
43
|
+
// Support dotted keys for sub-component fields (e.g. "mesh.width")
|
|
44
|
+
if (key.includes('.')) {
|
|
45
|
+
const [comp, field] = key.split('.');
|
|
46
|
+
if (merged[comp] && typeof merged[comp] === 'object') {
|
|
47
|
+
merged[comp] = { ...(merged[comp] as object), [field]: value };
|
|
48
|
+
}
|
|
49
|
+
} else {
|
|
50
|
+
merged[key] = value;
|
|
51
|
+
}
|
|
52
|
+
}
|
|
53
|
+
|
|
54
|
+
// Strip unresolved $ref+$map bindings — computed not ready yet, use 0 default
|
|
55
|
+
// Without this, raw ref objects leak to consumers (e.g. Three.js rotation={[0, {$ref:...}, 0]} → NaN)
|
|
56
|
+
for (const key of Object.keys(merged)) {
|
|
57
|
+
if (key.startsWith('$')) continue;
|
|
58
|
+
const v = merged[key];
|
|
59
|
+
if (isRef(v) && (v as any).$map) {
|
|
60
|
+
merged[key] = 0;
|
|
61
|
+
}
|
|
62
|
+
// Sub-component fields
|
|
63
|
+
if (v && typeof v === 'object' && !isRef(v) && '$type' in (v as any)) {
|
|
64
|
+
let changed = false;
|
|
65
|
+
const comp = { ...(v as Record<string, unknown>) };
|
|
66
|
+
for (const sk of Object.keys(comp)) {
|
|
67
|
+
if (sk.startsWith('$')) continue;
|
|
68
|
+
if (isRef(comp[sk]) && (comp[sk] as any).$map) {
|
|
69
|
+
comp[sk] = 0;
|
|
70
|
+
changed = true;
|
|
71
|
+
}
|
|
72
|
+
}
|
|
73
|
+
if (changed) merged[key] = comp;
|
|
74
|
+
}
|
|
75
|
+
}
|
|
76
|
+
|
|
77
|
+
return [merged as NodeData, set];
|
|
78
|
+
}
|
|
79
|
+
|
|
80
|
+
const evalCtx = {
|
|
81
|
+
getNode: (p: string) => cache.get(p),
|
|
82
|
+
getChildren: (p: string) => cache.getChildren(p),
|
|
83
|
+
};
|
|
84
|
+
|
|
85
|
+
/** Evaluate a $ref+$map expression reactively — no node needed */
|
|
86
|
+
export function useEvalRef(path: string, map: string): unknown {
|
|
87
|
+
const ref = useMemo(() => ({ $ref: path, $map: map }), [path, map]);
|
|
88
|
+
|
|
89
|
+
// Subscribe to all relevant paths: main source + @/path args
|
|
90
|
+
const subscribe = useCallback((cb: () => void) => {
|
|
91
|
+
const unsubs: (() => void)[] = [];
|
|
92
|
+
|
|
93
|
+
unsubs.push(
|
|
94
|
+
isCollectionRef(ref)
|
|
95
|
+
? cache.subscribeChildren(path, cb)
|
|
96
|
+
: cache.subscribePath(path, cb),
|
|
97
|
+
);
|
|
98
|
+
|
|
99
|
+
for (const argPath of extractArgPaths(ref)) {
|
|
100
|
+
unsubs.push(cache.subscribePath(argPath, cb));
|
|
101
|
+
}
|
|
102
|
+
|
|
103
|
+
return () => unsubs.forEach(u => u());
|
|
104
|
+
}, [ref, path]);
|
|
105
|
+
|
|
106
|
+
const getSnapshot = useCallback(() => {
|
|
107
|
+
try { return evaluateRef(ref, evalCtx); }
|
|
108
|
+
catch { return undefined; }
|
|
109
|
+
}, [ref]);
|
|
110
|
+
|
|
111
|
+
return useSyncExternalStore(subscribe, getSnapshot);
|
|
112
|
+
}
|
|
@@ -0,0 +1,104 @@
|
|
|
1
|
+
// $map expression parser
|
|
2
|
+
// Syntax: selector.field | pipe1(args) | pipe2(args)
|
|
3
|
+
// Source path lives in $ref, not here — parser handles $map content only
|
|
4
|
+
//
|
|
5
|
+
// Access modes:
|
|
6
|
+
// .field = drill into current pipe value
|
|
7
|
+
// #field = lookup from source node ($ref path) — like URI fragment
|
|
8
|
+
// #/path.field = lookup from external node — absolute path after #
|
|
9
|
+
|
|
10
|
+
export type RefArg = { $ref: string; fields: string[] };
|
|
11
|
+
export type PipeArg = number | string | RefArg;
|
|
12
|
+
|
|
13
|
+
export type MapExpr = { steps: PipeStep[] };
|
|
14
|
+
|
|
15
|
+
export type PipeStep =
|
|
16
|
+
| { type: 'pipe'; name: string; args: PipeArg[] }
|
|
17
|
+
| { type: 'field'; name: string };
|
|
18
|
+
|
|
19
|
+
export function isRefArg(arg: PipeArg): arg is RefArg {
|
|
20
|
+
return typeof arg === 'object' && arg !== null && '$ref' in arg;
|
|
21
|
+
}
|
|
22
|
+
|
|
23
|
+
const PIPE_RE = /^([a-zA-Z_]\w*)\(([^)]*)\)(.*)$/;
|
|
24
|
+
|
|
25
|
+
function parseArg(s: string): PipeArg {
|
|
26
|
+
const t = s.trim();
|
|
27
|
+
|
|
28
|
+
// # = node ref: #field (self), #/path.field (external)
|
|
29
|
+
if (t.startsWith('#')) {
|
|
30
|
+
const rest = t.slice(1);
|
|
31
|
+
if (rest.startsWith('/')) {
|
|
32
|
+
// External: #/path.field
|
|
33
|
+
const dotIdx = rest.indexOf('.');
|
|
34
|
+
if (dotIdx === -1) return { $ref: rest, fields: [] };
|
|
35
|
+
return { $ref: rest.slice(0, dotIdx), fields: rest.slice(dotIdx + 1).split('.') };
|
|
36
|
+
}
|
|
37
|
+
// Self: #field or #comp.field
|
|
38
|
+
return { $ref: '.', fields: rest.length ? rest.split('.') : [] };
|
|
39
|
+
}
|
|
40
|
+
|
|
41
|
+
const n = Number(t);
|
|
42
|
+
return Number.isFinite(n) ? n : t;
|
|
43
|
+
}
|
|
44
|
+
|
|
45
|
+
function parseSegment(raw: string): PipeStep[] {
|
|
46
|
+
const s = raw.trim();
|
|
47
|
+
if (!s) return [];
|
|
48
|
+
|
|
49
|
+
// #field — self-ref field access (like URI fragment)
|
|
50
|
+
if (s.startsWith('#') && !s.startsWith('#/')) {
|
|
51
|
+
const rest = s.slice(1);
|
|
52
|
+
return rest.split('.').map(name => ({ type: 'field' as const, name }));
|
|
53
|
+
}
|
|
54
|
+
|
|
55
|
+
// Pure field chain: .foo.bar — drill into pipe value
|
|
56
|
+
if (s.startsWith('.')) {
|
|
57
|
+
return parseFieldChain(s);
|
|
58
|
+
}
|
|
59
|
+
|
|
60
|
+
// pipe(args) optionally followed by .field chain
|
|
61
|
+
const m = PIPE_RE.exec(s);
|
|
62
|
+
if (m) {
|
|
63
|
+
const steps: PipeStep[] = [{
|
|
64
|
+
type: 'pipe',
|
|
65
|
+
name: m[1],
|
|
66
|
+
args: m[2] ? m[2].split(',').map(parseArg) : [],
|
|
67
|
+
}];
|
|
68
|
+
if (m[3]) steps.push(...parseFieldChain(m[3]));
|
|
69
|
+
return steps;
|
|
70
|
+
}
|
|
71
|
+
|
|
72
|
+
// Bare name (round, abs): pipe with no args, optionally followed by .field
|
|
73
|
+
const bare = /^([a-zA-Z_]\w*)(.*)$/.exec(s);
|
|
74
|
+
if (bare) {
|
|
75
|
+
const steps: PipeStep[] = [{ type: 'pipe', name: bare[1], args: [] }];
|
|
76
|
+
if (bare[2]) steps.push(...parseFieldChain(bare[2]));
|
|
77
|
+
return steps;
|
|
78
|
+
}
|
|
79
|
+
|
|
80
|
+
return [];
|
|
81
|
+
}
|
|
82
|
+
|
|
83
|
+
function parseFieldChain(s: string): PipeStep[] {
|
|
84
|
+
const steps: PipeStep[] = [];
|
|
85
|
+
let rest = s;
|
|
86
|
+
while (rest) {
|
|
87
|
+
const m = /^\.([a-zA-Z_$]\w*)(.*)$/.exec(rest);
|
|
88
|
+
if (!m) break;
|
|
89
|
+
steps.push({ type: 'field', name: m[1] });
|
|
90
|
+
rest = m[2];
|
|
91
|
+
}
|
|
92
|
+
return steps;
|
|
93
|
+
}
|
|
94
|
+
|
|
95
|
+
export function parseMapExpr(expr: string): MapExpr {
|
|
96
|
+
const parts = expr.split('|').map(s => s.trim());
|
|
97
|
+
const steps: PipeStep[] = [];
|
|
98
|
+
|
|
99
|
+
for (const part of parts) {
|
|
100
|
+
steps.push(...parseSegment(part));
|
|
101
|
+
}
|
|
102
|
+
|
|
103
|
+
return { steps };
|
|
104
|
+
}
|
|
@@ -0,0 +1,71 @@
|
|
|
1
|
+
// Pipe registry — Angular-style transforms for $map expressions
|
|
2
|
+
|
|
3
|
+
import type { NodeData } from '@treenity/core/core';
|
|
4
|
+
|
|
5
|
+
export type PipeFn = (input: unknown, ...args: unknown[]) => unknown;
|
|
6
|
+
|
|
7
|
+
const registry = new Map<string, PipeFn>();
|
|
8
|
+
|
|
9
|
+
export function registerPipe(name: string, fn: PipeFn): void {
|
|
10
|
+
registry.set(name, fn);
|
|
11
|
+
}
|
|
12
|
+
|
|
13
|
+
export function getPipe(name: string): PipeFn | undefined {
|
|
14
|
+
return registry.get(name);
|
|
15
|
+
}
|
|
16
|
+
|
|
17
|
+
// ── Collection pipes ──
|
|
18
|
+
|
|
19
|
+
registerPipe('last', (input) => {
|
|
20
|
+
const arr = input as NodeData[];
|
|
21
|
+
return arr.length ? arr[arr.length - 1] : undefined;
|
|
22
|
+
});
|
|
23
|
+
|
|
24
|
+
registerPipe('first', (input) => {
|
|
25
|
+
const arr = input as NodeData[];
|
|
26
|
+
return arr.length ? arr[0] : undefined;
|
|
27
|
+
});
|
|
28
|
+
|
|
29
|
+
registerPipe('count', (input) => (input as unknown[]).length);
|
|
30
|
+
|
|
31
|
+
registerPipe('map', (input, field) => {
|
|
32
|
+
return (input as Record<string, unknown>[]).map(item => item[field as string]);
|
|
33
|
+
});
|
|
34
|
+
|
|
35
|
+
registerPipe('sum', (input) => {
|
|
36
|
+
return (input as number[]).reduce((a, b) => a + b, 0);
|
|
37
|
+
});
|
|
38
|
+
|
|
39
|
+
registerPipe('avg', (input) => {
|
|
40
|
+
const arr = input as number[];
|
|
41
|
+
return arr.length ? arr.reduce((a, b) => a + b, 0) / arr.length : 0;
|
|
42
|
+
});
|
|
43
|
+
|
|
44
|
+
registerPipe('max', (input) => Math.max(...(input as number[])));
|
|
45
|
+
|
|
46
|
+
registerPipe('min', (input) => Math.min(...(input as number[])));
|
|
47
|
+
|
|
48
|
+
// ── Scalar pipes ──
|
|
49
|
+
|
|
50
|
+
registerPipe('div', (input, n) => (input as number) / (n as number));
|
|
51
|
+
|
|
52
|
+
registerPipe('mul', (input, n) => (input as number) * (n as number));
|
|
53
|
+
|
|
54
|
+
registerPipe('add', (input, n) => (input as number) + (n as number));
|
|
55
|
+
|
|
56
|
+
registerPipe('sub', (input, n) => (input as number) - (n as number));
|
|
57
|
+
|
|
58
|
+
registerPipe('clamp', (input, min, max) =>
|
|
59
|
+
Math.min(Math.max(input as number, min as number), max as number));
|
|
60
|
+
|
|
61
|
+
registerPipe('round', (input) => Math.round(input as number));
|
|
62
|
+
|
|
63
|
+
registerPipe('abs', (input) => Math.abs(input as number));
|
|
64
|
+
|
|
65
|
+
registerPipe('floor', (input) => Math.floor(input as number));
|
|
66
|
+
|
|
67
|
+
registerPipe('ceil', (input) => Math.ceil(input as number));
|
|
68
|
+
|
|
69
|
+
// ── Reactivity control ──
|
|
70
|
+
|
|
71
|
+
registerPipe('once', (input) => input);
|