@hypen-space/web 0.4.45 → 0.4.81

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 (69) hide show
  1. package/README.md +4 -4
  2. package/dist/canvas/dirty.d.ts +40 -0
  3. package/dist/canvas/dirty.js +100 -0
  4. package/dist/canvas/dirty.js.map +1 -0
  5. package/dist/canvas/events.d.ts +5 -0
  6. package/dist/canvas/events.js +30 -3
  7. package/dist/canvas/events.js.map +1 -1
  8. package/dist/canvas/index.d.ts +5 -1
  9. package/dist/canvas/index.js +4 -0
  10. package/dist/canvas/index.js.map +1 -1
  11. package/dist/canvas/layout.d.ts +12 -2
  12. package/dist/canvas/layout.js +593 -63
  13. package/dist/canvas/layout.js.map +1 -1
  14. package/dist/canvas/paint.d.ts +2 -0
  15. package/dist/canvas/paint.js +88 -11
  16. package/dist/canvas/paint.js.map +1 -1
  17. package/dist/canvas/renderer.d.ts +16 -0
  18. package/dist/canvas/renderer.js +154 -15
  19. package/dist/canvas/renderer.js.map +1 -1
  20. package/dist/canvas/scroll.d.ts +82 -0
  21. package/dist/canvas/scroll.js +639 -0
  22. package/dist/canvas/scroll.js.map +1 -0
  23. package/dist/canvas/selection.d.ts +63 -0
  24. package/dist/canvas/selection.js +466 -0
  25. package/dist/canvas/selection.js.map +1 -0
  26. package/dist/canvas/text.d.ts +6 -3
  27. package/dist/canvas/text.js +82 -43
  28. package/dist/canvas/text.js.map +1 -1
  29. package/dist/canvas/types.d.ts +18 -0
  30. package/dist/canvas/utils.d.ts +8 -2
  31. package/dist/canvas/utils.js +42 -16
  32. package/dist/canvas/utils.js.map +1 -1
  33. package/dist/canvas/virtualize.d.ts +25 -0
  34. package/dist/canvas/virtualize.js +65 -0
  35. package/dist/canvas/virtualize.js.map +1 -0
  36. package/dist/dom/applicators/index.js +0 -1
  37. package/dist/dom/applicators/index.js.map +1 -1
  38. package/dist/dom/applicators/margin.js +58 -17
  39. package/dist/dom/applicators/margin.js.map +1 -1
  40. package/dist/dom/applicators/padding.js +42 -17
  41. package/dist/dom/applicators/padding.js.map +1 -1
  42. package/dist/dom/components/icon.d.ts +9 -0
  43. package/dist/dom/components/icon.js +67 -0
  44. package/dist/dom/components/icon.js.map +1 -0
  45. package/dist/dom/components/index.js +2 -0
  46. package/dist/dom/components/index.js.map +1 -1
  47. package/dist/dom/renderer.d.ts +19 -5
  48. package/dist/dom/renderer.js +147 -17
  49. package/dist/dom/renderer.js.map +1 -1
  50. package/package.json +4 -2
  51. package/src/canvas/QUICKSTART.md +5 -5
  52. package/src/canvas/dirty.ts +116 -0
  53. package/src/canvas/events.ts +34 -3
  54. package/src/canvas/index.ts +5 -0
  55. package/src/canvas/layout.ts +653 -100
  56. package/src/canvas/paint.ts +109 -11
  57. package/src/canvas/renderer.ts +189 -16
  58. package/src/canvas/scroll.ts +729 -0
  59. package/src/canvas/selection.ts +560 -0
  60. package/src/canvas/text.ts +97 -54
  61. package/src/canvas/types.ts +26 -0
  62. package/src/canvas/utils.ts +47 -17
  63. package/src/canvas/virtualize.ts +81 -0
  64. package/src/dom/applicators/index.ts +1 -1
  65. package/src/dom/applicators/margin.ts +57 -11
  66. package/src/dom/applicators/padding.ts +45 -11
  67. package/src/dom/components/icon.ts +84 -0
  68. package/src/dom/components/index.ts +2 -0
  69. package/src/dom/renderer.ts +155 -17
@@ -11,9 +11,14 @@ import type { HypenGlobalContext } from "@hypen-space/core/context";
11
11
  import { frameworkLoggers } from "@hypen-space/core/logger";
12
12
 
13
13
  const log = frameworkLoggers.renderer;
14
+
15
+ /** Element types that treat the "action" prop as an onClick handler */
16
+ const ACTIONABLE_TYPES = new Set(["button", "link", "card"]);
17
+
14
18
  import { ComponentRegistry } from "./components/index.js";
15
19
  import { ApplicatorRegistry } from "./applicators/index.js";
16
20
  import { canvasHandler, canvasApplicators } from "./canvas/index.js";
21
+ import { CanvasRenderer } from "../canvas/renderer.js";
17
22
  import { RerenderTracker, type DebugConfig, defaultDebugConfig } from "./debug.js";
18
23
  import { setEngine, disposeHypenElement } from "./element-data.js";
19
24
 
@@ -35,6 +40,14 @@ export class DOMRenderer {
35
40
  private componentInstances = new Map<string, HypenModuleInstance>();
36
41
  private debugTracker: RerenderTracker;
37
42
 
43
+ // Canvas subtree routing — Canvas components get their own CanvasRenderer
44
+ // and all descendant patches are forwarded to it instead of the DOM.
45
+ private canvasRenderers = new Map<string, CanvasRenderer>();
46
+ private canvasSubtreeMap = new Map<string, string>(); // nodeId → canvasRootId
47
+ private canvasElements = new Map<string, HTMLCanvasElement>();
48
+ /** Props stashed at create-time so we can forward them to CanvasRenderer */
49
+ private pendingCreateProps = new Map<string, { elementType: string; props: Record<string, any> }>();
50
+
38
51
  constructor(container: HTMLElement, engine: IEngine, debugConfig?: Partial<DebugConfig>) {
39
52
  this.container = container;
40
53
  this.engine = engine;
@@ -58,12 +71,86 @@ export class DOMRenderer {
58
71
  }
59
72
 
60
73
  /**
61
- * Apply a batch of patches to the DOM
74
+ * Apply a batch of patches to the DOM.
75
+ *
76
+ * Uses two-pass routing: first discovers which nodes belong to a Canvas
77
+ * subtree (via insert/move parents), then routes each patch to either
78
+ * the DOMRenderer or the owning CanvasRenderer.
62
79
  */
63
80
  applyPatches(patches: Patch[]): void {
81
+ // Phase 1: scan inserts to discover new canvas-subtree members
64
82
  for (const patch of patches) {
83
+ if (patch.type === "insert" || patch.type === "move") {
84
+ const parentId = patch.parentId;
85
+ const childId = patch.id;
86
+ if (!parentId || !childId) continue;
87
+
88
+ // Is the parent a canvas root or inside a canvas subtree?
89
+ const canvasRootId =
90
+ this.canvasRenderers.has(parentId) ? parentId
91
+ : this.canvasSubtreeMap.get(parentId);
92
+ if (canvasRootId) {
93
+ this.canvasSubtreeMap.set(childId, canvasRootId);
94
+ }
95
+ }
96
+ }
97
+
98
+ // Phase 2: route patches
99
+ const canvasBatches = new Map<string, Patch[]>();
100
+ const domPatches: Patch[] = [];
101
+
102
+ for (const patch of patches) {
103
+ const canvasRootId = this.getCanvasRouteTarget(patch);
104
+ if (canvasRootId) {
105
+ let batch = canvasBatches.get(canvasRootId);
106
+ if (!batch) { batch = []; canvasBatches.set(canvasRootId, batch); }
107
+ batch.push(patch);
108
+ } else {
109
+ domPatches.push(patch);
110
+ }
111
+ }
112
+
113
+ // Apply DOM patches normally
114
+ for (const patch of domPatches) {
65
115
  this.applyPatch(patch);
66
116
  }
117
+
118
+ // Forward canvas-subtree patches to their CanvasRenderer instances
119
+ for (const [rootId, batch] of canvasBatches) {
120
+ const renderer = this.canvasRenderers.get(rootId);
121
+ if (renderer) {
122
+ renderer.applyPatches(batch);
123
+ }
124
+ }
125
+ }
126
+
127
+ /**
128
+ * Determine which canvas root (if any) a patch should be routed to.
129
+ * Returns the canvas root node ID, or undefined for DOM patches.
130
+ */
131
+ private getCanvasRouteTarget(patch: Patch): string | undefined {
132
+ const id = patch.id;
133
+ const parentId = patch.parentId;
134
+
135
+ switch (patch.type) {
136
+ case "insert":
137
+ case "move": {
138
+ // Route by parent — if parent is a canvas root or inside one
139
+ if (!parentId) return undefined;
140
+ if (this.canvasRenderers.has(parentId)) return parentId;
141
+ return this.canvasSubtreeMap.get(parentId);
142
+ }
143
+ case "create": {
144
+ // create patches are routed if the node was pre-registered in phase 1
145
+ if (!id) return undefined;
146
+ return this.canvasSubtreeMap.get(id);
147
+ }
148
+ default: {
149
+ // setProp, removeProp, setText, remove — route by node id
150
+ if (!id) return undefined;
151
+ return this.canvasSubtreeMap.get(id);
152
+ }
153
+ }
67
154
  }
68
155
 
69
156
  /**
@@ -105,10 +192,12 @@ export class DOMRenderer {
105
192
  }
106
193
 
107
194
  /**
108
- * Interpolate state values in text template
195
+ * Interpolate state values in text template.
196
+ *
197
+ * Bindings use the `@{state.path}` syntax (matching the engine's parser).
109
198
  */
110
199
  private interpolateText(template: string, state: Record<string, any>): string {
111
- return template.replace(/\$\{([^}]+)\}/g, (match, path) => {
200
+ return template.replace(/@\{([^}]+)\}/g, (match, path) => {
112
201
  try {
113
202
  const value = path.split('.').reduce((obj: any, key: string) => {
114
203
  if (key === 'state') return state;
@@ -122,20 +211,13 @@ export class DOMRenderer {
122
211
  }
123
212
 
124
213
  /**
125
- * Apply a single patch
126
- *
127
- * Handles both camelCase (direct WASM) and snake_case (WebSocket JSON) field names.
214
+ * Apply a single patch.
128
215
  */
129
216
  private applyPatch(patch: Patch): void {
130
- // Normalize: accept both camelCase and snake_case field names.
131
- // WASM serde outputs camelCase, but patches forwarded via JSON over WebSocket
132
- // may arrive with snake_case depending on the serialization path.
133
- const p = patch as any;
134
- const id: string | undefined = patch.id ?? p.id;
135
- const elementType: string | undefined = patch.elementType ?? p.element_type;
136
- const parentId: string | undefined = patch.parentId ?? p.parent_id;
137
- const beforeId: string | undefined = patch.beforeId ?? p.before_id;
138
- const eventName: string | undefined = patch.eventName ?? p.event_name;
217
+ const id = patch.id;
218
+ const elementType = patch.elementType;
219
+ const parentId = patch.parentId;
220
+ const beforeId = patch.beforeId;
139
221
 
140
222
  switch (patch.type) {
141
223
  case "create":
@@ -186,9 +268,34 @@ export class DOMRenderer {
186
268
  setEngine(element, this.engine);
187
269
 
188
270
  this.applicators.applyAll(element, propsObj);
271
+
272
+ // Actionable components: wire "action" prop as onClick
273
+ if (propsObj.action && ACTIONABLE_TYPES.has(elementType.toLowerCase())) {
274
+ this.applicators.apply(element, "onClick", propsObj.action);
275
+ }
276
+
189
277
  this.nodes.set(id, element);
190
278
  this.debugTracker.trackRerender(id, element, `create:${elementType}`);
191
279
 
280
+ // Canvas component: create a CanvasRenderer for its subtree
281
+ if (elementType.toLowerCase() === "canvas") {
282
+ const canvasEl = element as unknown as HTMLCanvasElement;
283
+ // Apply sensible defaults if no explicit size
284
+ if (!canvasEl.width || canvasEl.width === 300) canvasEl.width = propsObj.width ? Number(propsObj.width) : 800;
285
+ if (!canvasEl.height || canvasEl.height === 150) canvasEl.height = propsObj.height ? Number(propsObj.height) : 600;
286
+
287
+ const canvasRenderer = new CanvasRenderer(canvasEl, this.engine);
288
+ this.canvasRenderers.set(id, canvasRenderer);
289
+ this.canvasElements.set(id, canvasEl);
290
+
291
+ // Bootstrap a root container inside the CanvasRenderer so child
292
+ // inserts with parentId === this canvas node find a parent.
293
+ canvasRenderer.applyPatches([
294
+ { type: "create", id, elementType: "container", props: {} },
295
+ { type: "insert", parentId: "root", id },
296
+ ]);
297
+ }
298
+
192
299
  if (!this.rootId) {
193
300
  this.rootId = id;
194
301
  if (!this.container.contains(element)) {
@@ -223,8 +330,8 @@ export class DOMRenderer {
223
330
  // Engine patches may send interpolated strings; if we overwrite the template,
224
331
  // future state updates won't be able to re-interpolate.
225
332
  const currentTemplate = element.dataset.textTemplate;
226
- const nextLooksLikeTemplate = nextText.includes("${");
227
- const currentLooksLikeTemplate = typeof currentTemplate === "string" && currentTemplate.includes("${");
333
+ const nextLooksLikeTemplate = nextText.includes("@{");
334
+ const currentLooksLikeTemplate = typeof currentTemplate === "string" && currentTemplate.includes("@{");
228
335
 
229
336
  if (nextLooksLikeTemplate) {
230
337
  element.dataset.textTemplate = nextText;
@@ -239,7 +346,19 @@ export class DOMRenderer {
239
346
  return;
240
347
  }
241
348
 
349
+ // Actionable components: wire "action" prop as onClick
350
+ if (name === "action" && ACTIONABLE_TYPES.has(element.dataset.hypenType || "")) {
351
+ this.applicators.apply(element, "onClick", value);
352
+ return;
353
+ }
354
+
242
355
  this.applicators.apply(element, name, value);
356
+
357
+ // Forward canvas dimension changes to its CanvasRenderer
358
+ if ((name === "width" || name === "height") && this.canvasRenderers.has(id)) {
359
+ const canvasEl = this.canvasElements.get(id)!;
360
+ this.canvasRenderers.get(id)!.resize(canvasEl.width, canvasEl.height);
361
+ }
243
362
  }
244
363
 
245
364
  /**
@@ -311,6 +430,17 @@ export class DOMRenderer {
311
430
  const element = this.nodes.get(id);
312
431
  if (!element) return;
313
432
 
433
+ // Clean up canvas renderer if this is a canvas root
434
+ if (this.canvasRenderers.has(id)) {
435
+ this.canvasRenderers.get(id)!.destroy();
436
+ this.canvasRenderers.delete(id);
437
+ this.canvasElements.delete(id);
438
+ // Remove all subtree entries pointing to this canvas root
439
+ for (const [nodeId, rootId] of this.canvasSubtreeMap) {
440
+ if (rootId === id) this.canvasSubtreeMap.delete(nodeId);
441
+ }
442
+ }
443
+
314
444
  // Dispose event listeners and other resources before removing from DOM
315
445
  disposeHypenElement(element);
316
446
 
@@ -336,6 +466,14 @@ export class DOMRenderer {
336
466
  * Clear all nodes
337
467
  */
338
468
  clear(): void {
469
+ // Destroy all canvas renderers
470
+ for (const renderer of this.canvasRenderers.values()) {
471
+ renderer.destroy();
472
+ }
473
+ this.canvasRenderers.clear();
474
+ this.canvasElements.clear();
475
+ this.canvasSubtreeMap.clear();
476
+
339
477
  // Dispose all element resources before clearing
340
478
  for (const element of this.nodes.values()) {
341
479
  disposeHypenElement(element);