@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.
Files changed (297) hide show
  1. package/dist/AclEditor.d.ts +11 -0
  2. package/dist/AclEditor.d.ts.map +1 -0
  3. package/dist/AclEditor.js +152 -0
  4. package/dist/AclEditor.js.map +1 -0
  5. package/dist/App.d.ts +2 -0
  6. package/dist/App.d.ts.map +1 -0
  7. package/dist/App.js +521 -0
  8. package/dist/App.js.map +1 -0
  9. package/dist/Inspector.d.ts +12 -0
  10. package/dist/Inspector.d.ts.map +1 -0
  11. package/dist/Inspector.js +360 -0
  12. package/dist/Inspector.js.map +1 -0
  13. package/dist/Tree.d.ts +16 -0
  14. package/dist/Tree.d.ts.map +1 -0
  15. package/dist/Tree.js +100 -0
  16. package/dist/Tree.js.map +1 -0
  17. package/dist/ViewPage.d.ts +5 -0
  18. package/dist/ViewPage.d.ts.map +1 -0
  19. package/dist/ViewPage.js +13 -0
  20. package/dist/ViewPage.js.map +1 -0
  21. package/dist/bind/computed.d.ts +9 -0
  22. package/dist/bind/computed.d.ts.map +1 -0
  23. package/dist/bind/computed.js +61 -0
  24. package/dist/bind/computed.js.map +1 -0
  25. package/dist/bind/engine.d.ts +3 -0
  26. package/dist/bind/engine.d.ts.map +1 -0
  27. package/dist/bind/engine.js +184 -0
  28. package/dist/bind/engine.js.map +1 -0
  29. package/dist/bind/eval.d.ts +13 -0
  30. package/dist/bind/eval.d.ts.map +1 -0
  31. package/dist/bind/eval.js +97 -0
  32. package/dist/bind/eval.js.map +1 -0
  33. package/dist/bind/hook.d.ts +8 -0
  34. package/dist/bind/hook.d.ts.map +1 -0
  35. package/dist/bind/hook.js +99 -0
  36. package/dist/bind/hook.js.map +1 -0
  37. package/dist/bind/parse.d.ts +19 -0
  38. package/dist/bind/parse.d.ts.map +1 -0
  39. package/dist/bind/parse.js +86 -0
  40. package/dist/bind/parse.js.map +1 -0
  41. package/dist/bind/pipes.d.ts +4 -0
  42. package/dist/bind/pipes.d.ts.map +1 -0
  43. package/dist/bind/pipes.js +43 -0
  44. package/dist/bind/pipes.js.map +1 -0
  45. package/dist/cache.d.ts +27 -0
  46. package/dist/cache.d.ts.map +1 -0
  47. package/dist/cache.js +236 -0
  48. package/dist/cache.js.map +1 -0
  49. package/dist/client-tree.d.ts +9 -0
  50. package/dist/client-tree.d.ts.map +1 -0
  51. package/dist/client-tree.js +14 -0
  52. package/dist/client-tree.js.map +1 -0
  53. package/dist/client.d.ts +2 -0
  54. package/dist/client.d.ts.map +1 -0
  55. package/dist/client.js +10 -0
  56. package/dist/client.js.map +1 -0
  57. package/dist/components/ui/accordion.d.ts +8 -0
  58. package/dist/components/ui/accordion.d.ts.map +1 -0
  59. package/dist/components/ui/accordion.js +18 -0
  60. package/dist/components/ui/accordion.js.map +1 -0
  61. package/dist/components/ui/badge.d.ts +10 -0
  62. package/dist/components/ui/badge.d.ts.map +1 -0
  63. package/dist/components/ui/badge.js +19 -0
  64. package/dist/components/ui/badge.js.map +1 -0
  65. package/dist/components/ui/button.d.ts +11 -0
  66. package/dist/components/ui/button.d.ts.map +1 -0
  67. package/dist/components/ui/button.js +31 -0
  68. package/dist/components/ui/button.js.map +1 -0
  69. package/dist/components/ui/checkbox.d.ts +4 -0
  70. package/dist/components/ui/checkbox.d.ts.map +1 -0
  71. package/dist/components/ui/checkbox.js +7 -0
  72. package/dist/components/ui/checkbox.js.map +1 -0
  73. package/dist/components/ui/dialog.d.ts +18 -0
  74. package/dist/components/ui/dialog.d.ts.map +1 -0
  75. package/dist/components/ui/dialog.js +37 -0
  76. package/dist/components/ui/dialog.js.map +1 -0
  77. package/dist/components/ui/drawer.d.ts +14 -0
  78. package/dist/components/ui/drawer.d.ts.map +1 -0
  79. package/dist/components/ui/drawer.js +35 -0
  80. package/dist/components/ui/drawer.js.map +1 -0
  81. package/dist/components/ui/input.d.ts +4 -0
  82. package/dist/components/ui/input.d.ts.map +1 -0
  83. package/dist/components/ui/input.js +7 -0
  84. package/dist/components/ui/input.js.map +1 -0
  85. package/dist/components/ui/label.d.ts +5 -0
  86. package/dist/components/ui/label.d.ts.map +1 -0
  87. package/dist/components/ui/label.js +8 -0
  88. package/dist/components/ui/label.js.map +1 -0
  89. package/dist/components/ui/popover.d.ts +11 -0
  90. package/dist/components/ui/popover.d.ts.map +1 -0
  91. package/dist/components/ui/popover.js +26 -0
  92. package/dist/components/ui/popover.js.map +1 -0
  93. package/dist/components/ui/progress.d.ts +5 -0
  94. package/dist/components/ui/progress.d.ts.map +1 -0
  95. package/dist/components/ui/progress.js +9 -0
  96. package/dist/components/ui/progress.js.map +1 -0
  97. package/dist/components/ui/select.d.ts +16 -0
  98. package/dist/components/ui/select.d.ts.map +1 -0
  99. package/dist/components/ui/select.js +39 -0
  100. package/dist/components/ui/select.js.map +1 -0
  101. package/dist/components/ui/slider.d.ts +5 -0
  102. package/dist/components/ui/slider.d.ts.map +1 -0
  103. package/dist/components/ui/slider.js +15 -0
  104. package/dist/components/ui/slider.js.map +1 -0
  105. package/dist/components/ui/sonner.d.ts +4 -0
  106. package/dist/components/ui/sonner.d.ts.map +1 -0
  107. package/dist/components/ui/sonner.js +21 -0
  108. package/dist/components/ui/sonner.js.map +1 -0
  109. package/dist/components/ui/switch.d.ts +7 -0
  110. package/dist/components/ui/switch.d.ts.map +1 -0
  111. package/dist/components/ui/switch.js +9 -0
  112. package/dist/components/ui/switch.js.map +1 -0
  113. package/dist/components/ui/textarea.d.ts +4 -0
  114. package/dist/components/ui/textarea.d.ts.map +1 -0
  115. package/dist/components/ui/textarea.js +7 -0
  116. package/dist/components/ui/textarea.js.map +1 -0
  117. package/dist/components/ui/tooltip.d.ts +8 -0
  118. package/dist/components/ui/tooltip.d.ts.map +1 -0
  119. package/dist/components/ui/tooltip.js +18 -0
  120. package/dist/components/ui/tooltip.js.map +1 -0
  121. package/dist/context/index.d.ts +31 -0
  122. package/dist/context/index.d.ts.map +1 -0
  123. package/dist/context/index.js +98 -0
  124. package/dist/context/index.js.map +1 -0
  125. package/dist/context.d.ts +2 -0
  126. package/dist/context.d.ts.map +1 -0
  127. package/dist/context.js +2 -0
  128. package/dist/context.js.map +1 -0
  129. package/dist/hooks.d.ts +21 -0
  130. package/dist/hooks.d.ts.map +1 -0
  131. package/dist/hooks.js +156 -0
  132. package/dist/hooks.js.map +1 -0
  133. package/dist/idb.d.ts +13 -0
  134. package/dist/idb.d.ts.map +1 -0
  135. package/dist/idb.js +67 -0
  136. package/dist/idb.js.map +1 -0
  137. package/dist/lib/minimd.d.ts +3 -0
  138. package/dist/lib/minimd.d.ts.map +1 -0
  139. package/dist/lib/minimd.js +97 -0
  140. package/dist/lib/minimd.js.map +1 -0
  141. package/dist/lib/utils.d.ts +3 -0
  142. package/dist/lib/utils.d.ts.map +1 -0
  143. package/dist/lib/utils.js +6 -0
  144. package/dist/lib/utils.js.map +1 -0
  145. package/dist/load-client.d.ts +2 -0
  146. package/dist/load-client.d.ts.map +1 -0
  147. package/dist/load-client.js +6 -0
  148. package/dist/load-client.js.map +1 -0
  149. package/dist/main.d.ts +4 -0
  150. package/dist/main.d.ts.map +1 -0
  151. package/dist/main.js +16 -0
  152. package/dist/main.js.map +1 -0
  153. package/dist/mods/editor-ui/client.d.ts +6 -0
  154. package/dist/mods/editor-ui/client.d.ts.map +1 -0
  155. package/dist/mods/editor-ui/client.js +8 -0
  156. package/dist/mods/editor-ui/client.js.map +1 -0
  157. package/dist/mods/editor-ui/default-view.d.ts +2 -0
  158. package/dist/mods/editor-ui/default-view.d.ts.map +1 -0
  159. package/dist/mods/editor-ui/default-view.js +71 -0
  160. package/dist/mods/editor-ui/default-view.js.map +1 -0
  161. package/dist/mods/editor-ui/dir-view.d.ts +2 -0
  162. package/dist/mods/editor-ui/dir-view.d.ts.map +1 -0
  163. package/dist/mods/editor-ui/dir-view.js +42 -0
  164. package/dist/mods/editor-ui/dir-view.js.map +1 -0
  165. package/dist/mods/editor-ui/form-fields.d.ts +6 -0
  166. package/dist/mods/editor-ui/form-fields.d.ts.map +1 -0
  167. package/dist/mods/editor-ui/form-fields.js +401 -0
  168. package/dist/mods/editor-ui/form-fields.js.map +1 -0
  169. package/dist/mods/editor-ui/layout-view.d.ts +2 -0
  170. package/dist/mods/editor-ui/layout-view.d.ts.map +1 -0
  171. package/dist/mods/editor-ui/layout-view.js +22 -0
  172. package/dist/mods/editor-ui/layout-view.js.map +1 -0
  173. package/dist/mods/editor-ui/list-items.d.ts +2 -0
  174. package/dist/mods/editor-ui/list-items.d.ts.map +1 -0
  175. package/dist/mods/editor-ui/list-items.js +38 -0
  176. package/dist/mods/editor-ui/list-items.js.map +1 -0
  177. package/dist/mods/editor-ui/node-utils.d.ts +10 -0
  178. package/dist/mods/editor-ui/node-utils.d.ts.map +1 -0
  179. package/dist/mods/editor-ui/node-utils.js +76 -0
  180. package/dist/mods/editor-ui/node-utils.js.map +1 -0
  181. package/dist/mods/editor-ui/user-view.d.ts +2 -0
  182. package/dist/mods/editor-ui/user-view.d.ts.map +1 -0
  183. package/dist/mods/editor-ui/user-view.js +47 -0
  184. package/dist/mods/editor-ui/user-view.js.map +1 -0
  185. package/dist/mods/treenity/client.d.ts +4 -0
  186. package/dist/mods/treenity/client.d.ts.map +1 -0
  187. package/dist/mods/treenity/client.js +6 -0
  188. package/dist/mods/treenity/client.js.map +1 -0
  189. package/dist/mods/treenity/groups/index.d.ts +2 -0
  190. package/dist/mods/treenity/groups/index.d.ts.map +1 -0
  191. package/dist/mods/treenity/groups/index.js +27 -0
  192. package/dist/mods/treenity/groups/index.js.map +1 -0
  193. package/dist/mods/treenity/preview.d.ts +6 -0
  194. package/dist/mods/treenity/preview.d.ts.map +1 -0
  195. package/dist/mods/treenity/preview.js +95 -0
  196. package/dist/mods/treenity/preview.js.map +1 -0
  197. package/dist/mods/treenity/ref-view.d.ts +2 -0
  198. package/dist/mods/treenity/ref-view.d.ts.map +1 -0
  199. package/dist/mods/treenity/ref-view.js +29 -0
  200. package/dist/mods/treenity/ref-view.js.map +1 -0
  201. package/dist/mods/treenity/schema-form.d.ts +2 -0
  202. package/dist/mods/treenity/schema-form.d.ts.map +1 -0
  203. package/dist/mods/treenity/schema-form.js +38 -0
  204. package/dist/mods/treenity/schema-form.js.map +1 -0
  205. package/dist/mods/treenity/seed.d.ts +2 -0
  206. package/dist/mods/treenity/seed.d.ts.map +1 -0
  207. package/dist/mods/treenity/seed.js +53 -0
  208. package/dist/mods/treenity/seed.js.map +1 -0
  209. package/dist/mods/treenity/server.d.ts +2 -0
  210. package/dist/mods/treenity/server.d.ts.map +1 -0
  211. package/dist/mods/treenity/server.js +2 -0
  212. package/dist/mods/treenity/server.js.map +1 -0
  213. package/dist/mods/treenity/type-view.d.ts +2 -0
  214. package/dist/mods/treenity/type-view.d.ts.map +1 -0
  215. package/dist/mods/treenity/type-view.js +36 -0
  216. package/dist/mods/treenity/type-view.js.map +1 -0
  217. package/dist/remote-tree.d.ts +6 -0
  218. package/dist/remote-tree.d.ts.map +1 -0
  219. package/dist/remote-tree.js +18 -0
  220. package/dist/remote-tree.js.map +1 -0
  221. package/dist/schema-loader.d.ts +19 -0
  222. package/dist/schema-loader.d.ts.map +1 -0
  223. package/dist/schema-loader.js +63 -0
  224. package/dist/schema-loader.js.map +1 -0
  225. package/dist/trpc.d.ts +187 -0
  226. package/dist/trpc.d.ts.map +1 -0
  227. package/dist/trpc.js +21 -0
  228. package/dist/trpc.js.map +1 -0
  229. package/package.json +88 -0
  230. package/src/AclEditor.tsx +330 -0
  231. package/src/App.tsx +775 -0
  232. package/src/CLAUDE.md +16 -0
  233. package/src/Inspector.tsx +857 -0
  234. package/src/Tree.tsx +237 -0
  235. package/src/ViewPage.tsx +45 -0
  236. package/src/bind/bind.test.ts +316 -0
  237. package/src/bind/computed.ts +64 -0
  238. package/src/bind/engine.ts +198 -0
  239. package/src/bind/eval.ts +108 -0
  240. package/src/bind/hook.ts +112 -0
  241. package/src/bind/parse.ts +104 -0
  242. package/src/bind/pipes.ts +71 -0
  243. package/src/cache.test.ts +139 -0
  244. package/src/cache.ts +244 -0
  245. package/src/client-tree.test.ts +116 -0
  246. package/src/client-tree.ts +24 -0
  247. package/src/client.ts +11 -0
  248. package/src/components/ui/accordion.tsx +63 -0
  249. package/src/components/ui/badge.tsx +27 -0
  250. package/src/components/ui/button.tsx +44 -0
  251. package/src/components/ui/checkbox.tsx +19 -0
  252. package/src/components/ui/dialog.tsx +156 -0
  253. package/src/components/ui/drawer.tsx +132 -0
  254. package/src/components/ui/input.tsx +19 -0
  255. package/src/components/ui/label.tsx +21 -0
  256. package/src/components/ui/popover.tsx +86 -0
  257. package/src/components/ui/progress.tsx +30 -0
  258. package/src/components/ui/select.tsx +189 -0
  259. package/src/components/ui/slider.tsx +62 -0
  260. package/src/components/ui/sonner.tsx +32 -0
  261. package/src/components/ui/switch.tsx +34 -0
  262. package/src/components/ui/textarea.tsx +17 -0
  263. package/src/components/ui/tooltip.tsx +56 -0
  264. package/src/context/index.tsx +131 -0
  265. package/src/context.ts +1 -0
  266. package/src/hooks.ts +208 -0
  267. package/src/idb.ts +80 -0
  268. package/src/index.html +14 -0
  269. package/src/lib/minimd.css +28 -0
  270. package/src/lib/minimd.ts +95 -0
  271. package/src/lib/utils.ts +6 -0
  272. package/src/load-client.ts +5 -0
  273. package/src/main.tsx +22 -0
  274. package/src/mods/editor-ui/CLAUDE.md +3 -0
  275. package/src/mods/editor-ui/client.ts +8 -0
  276. package/src/mods/editor-ui/default-view.tsx +148 -0
  277. package/src/mods/editor-ui/dir-view.tsx +91 -0
  278. package/src/mods/editor-ui/form-fields.tsx +861 -0
  279. package/src/mods/editor-ui/layout-view.tsx +62 -0
  280. package/src/mods/editor-ui/list-items.tsx +63 -0
  281. package/src/mods/editor-ui/node-utils.ts +84 -0
  282. package/src/mods/editor-ui/user-view.tsx +101 -0
  283. package/src/mods/treenity/CLAUDE.md +7 -0
  284. package/src/mods/treenity/client.ts +6 -0
  285. package/src/mods/treenity/groups/index.tsx +65 -0
  286. package/src/mods/treenity/preview.tsx +133 -0
  287. package/src/mods/treenity/ref-view.tsx +87 -0
  288. package/src/mods/treenity/schema-form.tsx +65 -0
  289. package/src/mods/treenity/seed.ts +56 -0
  290. package/src/mods/treenity/server.ts +1 -0
  291. package/src/mods/treenity/type-view.tsx +116 -0
  292. package/src/remote-tree.test.ts +142 -0
  293. package/src/remote-tree.ts +25 -0
  294. package/src/schema-loader.ts +84 -0
  295. package/src/style.css +1269 -0
  296. package/src/trpc.ts +27 -0
  297. 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
+ }
@@ -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
+ }
@@ -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);