@hypen-space/web 0.4.949 → 0.4.951
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/README.md +13 -20
- package/dist/canvas/index.d.ts +1 -0
- package/dist/canvas/index.js +1 -0
- package/dist/canvas/index.js.map +1 -1
- package/dist/canvas/layout.js +24 -11
- package/dist/canvas/layout.js.map +1 -1
- package/dist/canvas/paint.js +3 -3
- package/dist/canvas/paint.js.map +1 -1
- package/dist/canvas/renderer.d.ts +3 -0
- package/dist/canvas/renderer.js +41 -3
- package/dist/canvas/renderer.js.map +1 -1
- package/dist/canvas/utils.d.ts +8 -0
- package/dist/canvas/utils.js +34 -3
- package/dist/canvas/utils.js.map +1 -1
- package/dist/client.d.ts +36 -0
- package/dist/client.js +47 -0
- package/dist/client.js.map +1 -0
- package/dist/dom/applicators/font.d.ts +6 -11
- package/dist/dom/applicators/font.js +27 -16
- package/dist/dom/applicators/font.js.map +1 -1
- package/dist/dom/applicators/index.js +34 -17
- package/dist/dom/applicators/index.js.map +1 -1
- package/dist/dom/canvas/index.js +2 -2
- package/dist/dom/canvas/index.js.map +1 -1
- package/dist/dom/components/app.js +2 -2
- package/dist/dom/components/app.js.map +1 -1
- package/dist/dom/components/audio.js +2 -2
- package/dist/dom/components/audio.js.map +1 -1
- package/dist/dom/components/avatar.js +4 -3
- package/dist/dom/components/avatar.js.map +1 -1
- package/dist/dom/components/badge.js +2 -2
- package/dist/dom/components/badge.js.map +1 -1
- package/dist/dom/components/button.js +2 -2
- package/dist/dom/components/button.js.map +1 -1
- package/dist/dom/components/card.js +2 -2
- package/dist/dom/components/card.js.map +1 -1
- package/dist/dom/components/center.js +2 -2
- package/dist/dom/components/center.js.map +1 -1
- package/dist/dom/components/checkbox.js +5 -4
- package/dist/dom/components/checkbox.js.map +1 -1
- package/dist/dom/components/column.js +2 -2
- package/dist/dom/components/column.js.map +1 -1
- package/dist/dom/components/container.js +2 -2
- package/dist/dom/components/container.js.map +1 -1
- package/dist/dom/components/divider.js +2 -2
- package/dist/dom/components/divider.js.map +1 -1
- package/dist/dom/components/grid.js +10 -10
- package/dist/dom/components/grid.js.map +1 -1
- package/dist/dom/components/heading.js +4 -3
- package/dist/dom/components/heading.js.map +1 -1
- package/dist/dom/components/hypenapp.js +2 -2
- package/dist/dom/components/hypenapp.js.map +1 -1
- package/dist/dom/components/icon.js +5 -4
- package/dist/dom/components/icon.js.map +1 -1
- package/dist/dom/components/image.js +2 -2
- package/dist/dom/components/image.js.map +1 -1
- package/dist/dom/components/index.d.ts +2 -2
- package/dist/dom/components/index.js +2 -2
- package/dist/dom/components/index.js.map +1 -1
- package/dist/dom/components/input.js +2 -2
- package/dist/dom/components/input.js.map +1 -1
- package/dist/dom/components/link.js +2 -2
- package/dist/dom/components/link.js.map +1 -1
- package/dist/dom/components/list.js +2 -2
- package/dist/dom/components/list.js.map +1 -1
- package/dist/dom/components/paragraph.js +2 -2
- package/dist/dom/components/paragraph.js.map +1 -1
- package/dist/dom/components/progressbar.js +3 -3
- package/dist/dom/components/progressbar.js.map +1 -1
- package/dist/dom/components/route.js +2 -2
- package/dist/dom/components/route.js.map +1 -1
- package/dist/dom/components/router.js +2 -2
- package/dist/dom/components/router.js.map +1 -1
- package/dist/dom/components/row.js +10 -10
- package/dist/dom/components/row.js.map +1 -1
- package/dist/dom/components/select.js +4 -3
- package/dist/dom/components/select.js.map +1 -1
- package/dist/dom/components/slider.js +2 -2
- package/dist/dom/components/slider.js.map +1 -1
- package/dist/dom/components/spacer.js +2 -2
- package/dist/dom/components/spacer.js.map +1 -1
- package/dist/dom/components/spinner.js +4 -4
- package/dist/dom/components/spinner.js.map +1 -1
- package/dist/dom/components/stack.js +10 -10
- package/dist/dom/components/stack.js.map +1 -1
- package/dist/dom/components/switch.js +6 -5
- package/dist/dom/components/switch.js.map +1 -1
- package/dist/dom/components/text.js +2 -2
- package/dist/dom/components/text.js.map +1 -1
- package/dist/dom/components/textarea.js +2 -2
- package/dist/dom/components/textarea.js.map +1 -1
- package/dist/dom/components/video.js +2 -2
- package/dist/dom/components/video.js.map +1 -1
- package/dist/dom/debug.js +8 -6
- package/dist/dom/debug.js.map +1 -1
- package/dist/dom/index.d.ts +1 -0
- package/dist/dom/index.js +1 -0
- package/dist/dom/index.js.map +1 -1
- package/dist/dom/renderer.js +3 -2
- package/dist/dom/renderer.js.map +1 -1
- package/package.json +2 -2
- package/src/canvas/QUICKSTART.md +7 -12
- package/src/canvas/README.md +5 -8
- package/src/canvas/index.ts +8 -0
- package/src/canvas/layout.ts +30 -11
- package/src/canvas/paint.ts +3 -3
- package/src/canvas/renderer.ts +44 -4
- package/src/canvas/utils.ts +37 -3
- package/src/client.ts +88 -0
- package/src/dom/README.md +7 -8
- package/src/dom/applicators/font.ts +29 -16
- package/src/dom/applicators/index.ts +35 -17
- package/src/dom/canvas/index.ts +2 -2
- package/src/dom/components/app.ts +2 -2
- package/src/dom/components/audio.ts +2 -2
- package/src/dom/components/avatar.ts +4 -3
- package/src/dom/components/badge.ts +2 -2
- package/src/dom/components/button.ts +2 -2
- package/src/dom/components/card.ts +2 -2
- package/src/dom/components/center.ts +2 -2
- package/src/dom/components/checkbox.ts +5 -4
- package/src/dom/components/column.ts +2 -2
- package/src/dom/components/container.ts +2 -2
- package/src/dom/components/divider.ts +2 -2
- package/src/dom/components/grid.ts +10 -10
- package/src/dom/components/heading.ts +4 -3
- package/src/dom/components/hypenapp.ts +2 -2
- package/src/dom/components/icon.ts +5 -4
- package/src/dom/components/image.ts +2 -2
- package/src/dom/components/index.ts +3 -3
- package/src/dom/components/input.ts +2 -2
- package/src/dom/components/link.ts +2 -2
- package/src/dom/components/list.ts +2 -2
- package/src/dom/components/paragraph.ts +2 -2
- package/src/dom/components/progressbar.ts +3 -3
- package/src/dom/components/route.ts +2 -2
- package/src/dom/components/router.ts +2 -2
- package/src/dom/components/row.ts +10 -10
- package/src/dom/components/select.ts +4 -3
- package/src/dom/components/slider.ts +2 -2
- package/src/dom/components/spacer.ts +2 -2
- package/src/dom/components/spinner.ts +4 -4
- package/src/dom/components/stack.ts +10 -10
- package/src/dom/components/switch.ts +6 -5
- package/src/dom/components/text.ts +2 -2
- package/src/dom/components/textarea.ts +2 -2
- package/src/dom/components/video.ts +2 -2
- package/src/dom/debug.ts +8 -6
- package/src/dom/index.ts +8 -0
- package/src/dom/renderer.ts +3 -2
package/src/canvas/renderer.ts
CHANGED
|
@@ -73,6 +73,10 @@ export class CanvasRenderer implements Renderer {
|
|
|
73
73
|
private rafId: number | null = null;
|
|
74
74
|
private needsRedraw = false;
|
|
75
75
|
|
|
76
|
+
private resizeObserver: ResizeObserver | null = null;
|
|
77
|
+
private dprMediaQuery: MediaQueryList | null = null;
|
|
78
|
+
private dprChangeHandler: (() => void) | null = null;
|
|
79
|
+
|
|
76
80
|
private frameCount = 0;
|
|
77
81
|
private lastFrameTime = 0;
|
|
78
82
|
|
|
@@ -138,6 +142,30 @@ export class CanvasRenderer implements Renderer {
|
|
|
138
142
|
// Eagerly initialise Taffy WASM for layout (non-blocking — fallback used until ready)
|
|
139
143
|
initTaffyLayout();
|
|
140
144
|
|
|
145
|
+
// Observe size changes — without this, the canvas backing store stays
|
|
146
|
+
// pinned at construction-time dimensions while CSS stretches the
|
|
147
|
+
// display box, and content gets bitmap-stretched / hit-testing drifts.
|
|
148
|
+
if (typeof ResizeObserver !== "undefined") {
|
|
149
|
+
this.resizeObserver = new ResizeObserver(() => {
|
|
150
|
+
this.setupHiDPI();
|
|
151
|
+
this.scheduleRedraw();
|
|
152
|
+
});
|
|
153
|
+
this.resizeObserver.observe(this.canvas);
|
|
154
|
+
}
|
|
155
|
+
|
|
156
|
+
// Track DPR changes (e.g. dragging window across monitors with
|
|
157
|
+
// different scaling). matchMedia fires once per crossing.
|
|
158
|
+
if (typeof window !== "undefined" && typeof window.matchMedia === "function") {
|
|
159
|
+
const dpr = window.devicePixelRatio || 1;
|
|
160
|
+
this.dprMediaQuery = window.matchMedia(`(resolution: ${dpr}dppx)`);
|
|
161
|
+
this.dprChangeHandler = () => {
|
|
162
|
+
this.options.devicePixelRatio = window.devicePixelRatio || 1;
|
|
163
|
+
this.setupHiDPI();
|
|
164
|
+
this.scheduleRedraw();
|
|
165
|
+
};
|
|
166
|
+
this.dprMediaQuery.addEventListener("change", this.dprChangeHandler);
|
|
167
|
+
}
|
|
168
|
+
|
|
141
169
|
// Don't schedule initial render - wait for patches
|
|
142
170
|
}
|
|
143
171
|
|
|
@@ -148,15 +176,18 @@ export class CanvasRenderer implements Renderer {
|
|
|
148
176
|
const dpr = this.options.devicePixelRatio || 1;
|
|
149
177
|
const rect = this.canvas.getBoundingClientRect();
|
|
150
178
|
|
|
179
|
+
// Skip when the host is collapsed — a 0-pixel backing store throws
|
|
180
|
+
// in some 2D contexts and the next non-zero resize will reinitialise.
|
|
181
|
+
if (rect.width === 0 || rect.height === 0) return;
|
|
182
|
+
|
|
151
183
|
this.canvas.width = rect.width * dpr;
|
|
152
184
|
this.canvas.height = rect.height * dpr;
|
|
153
185
|
|
|
186
|
+
// Reset transform first — `scale()` is cumulative across calls, so on
|
|
187
|
+
// a resize the second call would compound (dpr²) and shrink content.
|
|
188
|
+
this.ctx.setTransform(1, 0, 0, 1, 0, 0);
|
|
154
189
|
this.ctx.scale(dpr, dpr);
|
|
155
190
|
|
|
156
|
-
// Update canvas display size
|
|
157
|
-
this.canvas.style.width = `${rect.width}px`;
|
|
158
|
-
this.canvas.style.height = `${rect.height}px`;
|
|
159
|
-
|
|
160
191
|
// Sync dirty tracker with new canvas size
|
|
161
192
|
if (this.dirtyTracker) {
|
|
162
193
|
this.dirtyTracker.setCanvasSize(rect.width, rect.height);
|
|
@@ -733,6 +764,15 @@ export class CanvasRenderer implements Renderer {
|
|
|
733
764
|
if (this.rafId !== null) {
|
|
734
765
|
cancelAnimationFrame(this.rafId);
|
|
735
766
|
}
|
|
767
|
+
if (this.resizeObserver) {
|
|
768
|
+
this.resizeObserver.disconnect();
|
|
769
|
+
this.resizeObserver = null;
|
|
770
|
+
}
|
|
771
|
+
if (this.dprMediaQuery && this.dprChangeHandler) {
|
|
772
|
+
this.dprMediaQuery.removeEventListener("change", this.dprChangeHandler);
|
|
773
|
+
this.dprMediaQuery = null;
|
|
774
|
+
this.dprChangeHandler = null;
|
|
775
|
+
}
|
|
736
776
|
this.eventManager.destroy();
|
|
737
777
|
this.scrollManager.destroy();
|
|
738
778
|
this.selectionManager.destroy();
|
package/src/canvas/utils.ts
CHANGED
|
@@ -12,6 +12,42 @@ import type { BoxSpacing, Rectangle, Point, VirtualNode } from "./types.js";
|
|
|
12
12
|
// and treating `3.5rem` as 3.5px, collapsing whole layouts to 1/16 size.
|
|
13
13
|
const ROOT_FONT_PX = 16;
|
|
14
14
|
|
|
15
|
+
/**
|
|
16
|
+
* Active layout viewport — used to resolve `vw` / `vh` correctly during a
|
|
17
|
+
* layout pass. Set by `computeLayout` (layout.ts) at entry and cleared on
|
|
18
|
+
* exit. Layout traversal is synchronous, so a single module-level slot is
|
|
19
|
+
* sufficient and avoids threading a `viewport` parameter through every
|
|
20
|
+
* `cssLengthToPx` call site (there are dozens).
|
|
21
|
+
*
|
|
22
|
+
* Falls back to `window.innerWidth` / `window.innerHeight` outside a layout
|
|
23
|
+
* pass (e.g. for direct callers that resolve sizes ahead of layout). When
|
|
24
|
+
* neither is available — e.g. headless tests — `vw`/`vh` degrade to the
|
|
25
|
+
* pre-fix unitless-pixel behavior.
|
|
26
|
+
*/
|
|
27
|
+
let currentViewport: { width: number; height: number } | null = null;
|
|
28
|
+
|
|
29
|
+
export function setCurrentViewport(viewport: { width: number; height: number } | null): void {
|
|
30
|
+
currentViewport = viewport;
|
|
31
|
+
}
|
|
32
|
+
|
|
33
|
+
export function getCurrentViewport(): { width: number; height: number } | null {
|
|
34
|
+
return currentViewport;
|
|
35
|
+
}
|
|
36
|
+
|
|
37
|
+
function resolveViewportPx(unit: "vw" | "vh", n: number): number {
|
|
38
|
+
if (currentViewport) {
|
|
39
|
+
const dim = unit === "vw" ? currentViewport.width : currentViewport.height;
|
|
40
|
+
return (n / 100) * dim;
|
|
41
|
+
}
|
|
42
|
+
if (typeof window !== "undefined") {
|
|
43
|
+
const dim = unit === "vw" ? window.innerWidth : window.innerHeight;
|
|
44
|
+
if (Number.isFinite(dim) && dim > 0) return (n / 100) * dim;
|
|
45
|
+
}
|
|
46
|
+
// Last resort: treat as unitless px so the layout still gets a numeric
|
|
47
|
+
// value (matches pre-fix behavior in the no-viewport case).
|
|
48
|
+
return n;
|
|
49
|
+
}
|
|
50
|
+
|
|
15
51
|
/**
|
|
16
52
|
* Parse a CSS length string (or number) into pixels.
|
|
17
53
|
* Returns `null` for `"auto"` or unparseable input; returns `null` for `%`
|
|
@@ -33,10 +69,8 @@ export function cssLengthToPx(value: any): number | null {
|
|
|
33
69
|
case "px": return n;
|
|
34
70
|
case "rem":
|
|
35
71
|
case "em": return n * ROOT_FONT_PX;
|
|
36
|
-
// We don't have a viewport reference here; treat vw/vh as unitless px
|
|
37
|
-
// fallback so the layout gets _some_ numeric value rather than zero.
|
|
38
72
|
case "vw":
|
|
39
|
-
case "vh": return n;
|
|
73
|
+
case "vh": return resolveViewportPx(unit, n);
|
|
40
74
|
// Typographic point — 1/72 inch at CSS reference density (96dpi).
|
|
41
75
|
case "pt": return n * (96 / 72);
|
|
42
76
|
// Density-independent (dp) and scale-independent (sp) logical pixels
|
package/src/client.ts
ADDED
|
@@ -0,0 +1,88 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* createHypenClient — single-call wiring for renderer + engine.
|
|
3
|
+
*
|
|
4
|
+
* Replaces the boilerplate:
|
|
5
|
+
* const renderer = new DOMRenderer(container, engine);
|
|
6
|
+
* engine.onPatches((patches) => renderer.applyPatches(patches));
|
|
7
|
+
*
|
|
8
|
+
* Works with either a local Engine (`setRenderCallback`) or RemoteEngine
|
|
9
|
+
* (`onPatches`) — they expose different APIs but the same intent.
|
|
10
|
+
*/
|
|
11
|
+
|
|
12
|
+
import type { Patch } from "@hypen-space/core/types";
|
|
13
|
+
import { DOMRenderer } from "./dom/renderer.js";
|
|
14
|
+
import type { DebugConfig } from "./dom/debug.js";
|
|
15
|
+
import { CanvasRenderer } from "./canvas/renderer.js";
|
|
16
|
+
import type { CanvasRendererOptions } from "./canvas/types.js";
|
|
17
|
+
|
|
18
|
+
interface PatchSourceLike {
|
|
19
|
+
dispatchAction(name: string, payload?: unknown): void;
|
|
20
|
+
onPatches?(callback: (patches: Patch[]) => void): unknown;
|
|
21
|
+
setRenderCallback?(callback: (patches: Patch[]) => void): void;
|
|
22
|
+
}
|
|
23
|
+
|
|
24
|
+
function attachPatchListener(
|
|
25
|
+
engine: PatchSourceLike,
|
|
26
|
+
callback: (patches: Patch[]) => void,
|
|
27
|
+
): void {
|
|
28
|
+
if (typeof engine.onPatches === "function") {
|
|
29
|
+
engine.onPatches(callback);
|
|
30
|
+
return;
|
|
31
|
+
}
|
|
32
|
+
if (typeof engine.setRenderCallback === "function") {
|
|
33
|
+
engine.setRenderCallback(callback);
|
|
34
|
+
return;
|
|
35
|
+
}
|
|
36
|
+
throw new Error(
|
|
37
|
+
"createHypenClient: engine has neither onPatches() nor setRenderCallback() — cannot subscribe to patches.",
|
|
38
|
+
);
|
|
39
|
+
}
|
|
40
|
+
|
|
41
|
+
export interface DomClientOptions {
|
|
42
|
+
debug?: Partial<DebugConfig>;
|
|
43
|
+
}
|
|
44
|
+
|
|
45
|
+
export interface DomClient {
|
|
46
|
+
renderer: DOMRenderer;
|
|
47
|
+
destroy(): void;
|
|
48
|
+
}
|
|
49
|
+
|
|
50
|
+
export function createDomClient(
|
|
51
|
+
container: HTMLElement,
|
|
52
|
+
engine: PatchSourceLike,
|
|
53
|
+
options?: DomClientOptions,
|
|
54
|
+
): DomClient {
|
|
55
|
+
const renderer = new DOMRenderer(container, engine as any, options?.debug);
|
|
56
|
+
attachPatchListener(engine, (patches) => renderer.applyPatches(patches));
|
|
57
|
+
return {
|
|
58
|
+
renderer,
|
|
59
|
+
destroy() {
|
|
60
|
+
// DOMRenderer has no destroy yet; clearing the container is the
|
|
61
|
+
// closest approximation. Patch subscriptions live on the engine —
|
|
62
|
+
// the caller controls engine lifetime, so we don't tear them down.
|
|
63
|
+
container.innerHTML = "";
|
|
64
|
+
},
|
|
65
|
+
};
|
|
66
|
+
}
|
|
67
|
+
|
|
68
|
+
export interface CanvasClientOptions extends Partial<CanvasRendererOptions> {}
|
|
69
|
+
|
|
70
|
+
export interface CanvasClient {
|
|
71
|
+
renderer: CanvasRenderer;
|
|
72
|
+
destroy(): void;
|
|
73
|
+
}
|
|
74
|
+
|
|
75
|
+
export function createCanvasClient(
|
|
76
|
+
canvas: HTMLCanvasElement,
|
|
77
|
+
engine: PatchSourceLike,
|
|
78
|
+
options?: CanvasClientOptions,
|
|
79
|
+
): CanvasClient {
|
|
80
|
+
const renderer = new CanvasRenderer(canvas, engine as any, options);
|
|
81
|
+
attachPatchListener(engine, (patches) => renderer.applyPatches(patches));
|
|
82
|
+
return {
|
|
83
|
+
renderer,
|
|
84
|
+
destroy() {
|
|
85
|
+
renderer.destroy();
|
|
86
|
+
},
|
|
87
|
+
};
|
|
88
|
+
}
|
package/src/dom/README.md
CHANGED
|
@@ -37,18 +37,17 @@ src/dom/
|
|
|
37
37
|
|
|
38
38
|
### Browser Import
|
|
39
39
|
|
|
40
|
+
The recommended one-call wiring is `createHypenClient`. The bare `DOMRenderer` constructor is still exported for advanced cases (custom subscription, multi-listener fan-out, etc.).
|
|
41
|
+
|
|
40
42
|
```typescript
|
|
41
43
|
import { app, HypenModuleInstance } from "@hypen-space/core";
|
|
42
44
|
import { Engine } from "@hypen-space/web-engine";
|
|
43
|
-
import {
|
|
45
|
+
import { createHypenClient } from "@hypen-space/web/dom";
|
|
44
46
|
|
|
45
47
|
const engine = new Engine();
|
|
46
48
|
await engine.init();
|
|
47
49
|
|
|
48
|
-
|
|
49
|
-
engine.setRenderCallback((patches) => {
|
|
50
|
-
renderer.applyPatches(patches);
|
|
51
|
-
});
|
|
50
|
+
createHypenClient(document.body, engine);
|
|
52
51
|
```
|
|
53
52
|
|
|
54
53
|
### Server Import
|
|
@@ -79,7 +78,7 @@ All components are registered by default:
|
|
|
79
78
|
### Custom Components
|
|
80
79
|
|
|
81
80
|
```typescript
|
|
82
|
-
const renderer =
|
|
81
|
+
const { renderer } = createHypenClient(document.body, engine);
|
|
83
82
|
const registry = renderer.getComponentRegistry();
|
|
84
83
|
|
|
85
84
|
registry.register("mycomponent", {
|
|
@@ -141,7 +140,7 @@ Style applicators are registered by default:
|
|
|
141
140
|
### Custom Applicators
|
|
142
141
|
|
|
143
142
|
```typescript
|
|
144
|
-
const renderer =
|
|
143
|
+
const { renderer } = createHypenClient(document.body, engine);
|
|
145
144
|
const applicators = renderer.getApplicatorRegistry();
|
|
146
145
|
|
|
147
146
|
applicators.register("shadow", (el, value) => {
|
|
@@ -183,7 +182,7 @@ Canvas components have special applicators for drawing:
|
|
|
183
182
|
|
|
184
183
|
```typescript
|
|
185
184
|
// Register canvas applicators
|
|
186
|
-
const renderer =
|
|
185
|
+
const { renderer } = createHypenClient(document.body, engine);
|
|
187
186
|
const applicators = renderer.getApplicatorRegistry();
|
|
188
187
|
|
|
189
188
|
// Canvas-specific applicators
|
|
@@ -5,8 +5,17 @@
|
|
|
5
5
|
import type { ApplicatorHandler } from "./types.js";
|
|
6
6
|
import { toCssLength } from "./size.js";
|
|
7
7
|
|
|
8
|
-
// Track loaded Google Fonts to avoid duplicate link tags
|
|
9
|
-
const loadedGoogleFonts = new Set<string
|
|
8
|
+
// Track loaded Google Fonts per-document to avoid duplicate link tags
|
|
9
|
+
const loadedGoogleFonts = new WeakMap<Document, Set<string>>();
|
|
10
|
+
|
|
11
|
+
function getLoadedSet(doc: Document): Set<string> {
|
|
12
|
+
let set = loadedGoogleFonts.get(doc);
|
|
13
|
+
if (!set) {
|
|
14
|
+
set = new Set<string>();
|
|
15
|
+
loadedGoogleFonts.set(doc, set);
|
|
16
|
+
}
|
|
17
|
+
return set;
|
|
18
|
+
}
|
|
10
19
|
|
|
11
20
|
// System font keywords that shouldn't be loaded from Google Fonts
|
|
12
21
|
const systemFontKeywords = new Set([
|
|
@@ -27,40 +36,42 @@ function isSystemFont(fontName: string): boolean {
|
|
|
27
36
|
}
|
|
28
37
|
|
|
29
38
|
/**
|
|
30
|
-
* Load a Google Font by injecting a link tag.
|
|
39
|
+
* Load a Google Font by injecting a link tag into the given document.
|
|
31
40
|
* @param fontName - The Google Font name (e.g., "Roboto", "Open Sans")
|
|
41
|
+
* @param doc - The Document to inject into.
|
|
32
42
|
*/
|
|
33
|
-
function loadGoogleFont(fontName: string): void {
|
|
43
|
+
function loadGoogleFont(fontName: string, doc: Document): void {
|
|
34
44
|
const normalized = fontName.trim();
|
|
45
|
+
const loaded = getLoadedSet(doc);
|
|
35
46
|
|
|
36
47
|
// Skip if already loaded or is a system font
|
|
37
|
-
if (
|
|
48
|
+
if (loaded.has(normalized) || isSystemFont(normalized)) {
|
|
38
49
|
return;
|
|
39
50
|
}
|
|
40
51
|
|
|
41
52
|
// Mark as loading to avoid duplicates
|
|
42
|
-
|
|
53
|
+
loaded.add(normalized);
|
|
43
54
|
|
|
44
55
|
// Create link element for Google Fonts
|
|
45
|
-
const link =
|
|
56
|
+
const link = doc.createElement("link");
|
|
46
57
|
link.rel = "stylesheet";
|
|
47
58
|
link.href = `https://fonts.googleapis.com/css2?family=${encodeURIComponent(normalized)}:wght@100;200;300;400;500;600;700;800;900&display=swap`;
|
|
48
59
|
|
|
49
60
|
// Add to document head
|
|
50
|
-
|
|
61
|
+
doc.head.appendChild(link);
|
|
51
62
|
}
|
|
52
63
|
|
|
53
64
|
/**
|
|
54
65
|
* Parse a fontFamily value and load any Google Fonts.
|
|
55
66
|
* Returns a CSS-safe font-family string.
|
|
56
67
|
*/
|
|
57
|
-
function processFontFamily(value: string): string {
|
|
68
|
+
function processFontFamily(value: string, doc: Document): string {
|
|
58
69
|
// Split by comma to handle font stacks
|
|
59
70
|
const fonts = value.split(",").map(f => f.trim().replace(/["']/g, ""));
|
|
60
71
|
|
|
61
72
|
for (const font of fonts) {
|
|
62
73
|
if (!isSystemFont(font)) {
|
|
63
|
-
loadGoogleFont(font);
|
|
74
|
+
loadGoogleFont(font, doc);
|
|
64
75
|
}
|
|
65
76
|
}
|
|
66
77
|
|
|
@@ -86,7 +97,7 @@ export const fontHandlers: Record<string, ApplicatorHandler> = {
|
|
|
86
97
|
fontFamily: (el, value) => {
|
|
87
98
|
const fontValue = String(value);
|
|
88
99
|
// Process font family and load Google Fonts as needed
|
|
89
|
-
el.style.fontFamily = processFontFamily(fontValue);
|
|
100
|
+
el.style.fontFamily = processFontFamily(fontValue, el.ownerDocument as Document);
|
|
90
101
|
},
|
|
91
102
|
|
|
92
103
|
textAlign: (el, value) => {
|
|
@@ -127,18 +138,20 @@ export const fontHandlers: Record<string, ApplicatorHandler> = {
|
|
|
127
138
|
export const GoogleFonts = {
|
|
128
139
|
/**
|
|
129
140
|
* Preload a Google Font before it's used.
|
|
141
|
+
* Defaults to the global `document` if no doc is provided.
|
|
130
142
|
*/
|
|
131
|
-
preload: loadGoogleFont,
|
|
143
|
+
preload: (fontName: string, doc: Document = document) => loadGoogleFont(fontName, doc),
|
|
132
144
|
|
|
133
145
|
/**
|
|
134
|
-
* Check if a font has been loaded.
|
|
146
|
+
* Check if a font has been loaded in the given document (defaults to global).
|
|
135
147
|
*/
|
|
136
|
-
isLoaded: (fontName: string) =>
|
|
148
|
+
isLoaded: (fontName: string, doc: Document = document) =>
|
|
149
|
+
getLoadedSet(doc).has(fontName.trim()),
|
|
137
150
|
|
|
138
151
|
/**
|
|
139
|
-
* Get list of loaded fonts.
|
|
152
|
+
* Get list of loaded fonts for the given document (defaults to global).
|
|
140
153
|
*/
|
|
141
|
-
getLoadedFonts: () => Array.from(
|
|
154
|
+
getLoadedFonts: (doc: Document = document) => Array.from(getLoadedSet(doc)),
|
|
142
155
|
|
|
143
156
|
/**
|
|
144
157
|
* Popular Google Fonts for reference.
|
|
@@ -53,26 +53,40 @@ const BREAKPOINTS: Record<string, string> = {
|
|
|
53
53
|
};
|
|
54
54
|
|
|
55
55
|
/**
|
|
56
|
-
*
|
|
56
|
+
* Per-document stylesheet for variant CSS rules
|
|
57
57
|
*/
|
|
58
|
-
|
|
58
|
+
const variantStyleSheets = new Map<Document, CSSStyleSheet>();
|
|
59
59
|
|
|
60
60
|
/**
|
|
61
|
-
* Track inserted rules to avoid duplicates
|
|
61
|
+
* Track inserted rules to avoid duplicates (per-document)
|
|
62
62
|
*/
|
|
63
|
-
const insertedRules = new Set<string
|
|
63
|
+
const insertedRules = new Map<Document, Set<string>>();
|
|
64
64
|
|
|
65
65
|
/**
|
|
66
|
-
* Get or create the variant stylesheet
|
|
66
|
+
* Get or create the variant stylesheet for a document
|
|
67
67
|
*/
|
|
68
|
-
function getVariantStyleSheet(): CSSStyleSheet {
|
|
69
|
-
|
|
70
|
-
|
|
68
|
+
function getVariantStyleSheet(doc: Document): CSSStyleSheet {
|
|
69
|
+
let sheet = variantStyleSheets.get(doc);
|
|
70
|
+
if (!sheet) {
|
|
71
|
+
const style = doc.createElement('style');
|
|
71
72
|
style.id = 'hypen-variants';
|
|
72
|
-
|
|
73
|
-
|
|
73
|
+
doc.head.appendChild(style);
|
|
74
|
+
sheet = style.sheet as CSSStyleSheet;
|
|
75
|
+
variantStyleSheets.set(doc, sheet);
|
|
74
76
|
}
|
|
75
|
-
return
|
|
77
|
+
return sheet;
|
|
78
|
+
}
|
|
79
|
+
|
|
80
|
+
/**
|
|
81
|
+
* Get or create the inserted-rules dedup set for a document
|
|
82
|
+
*/
|
|
83
|
+
function getInsertedRules(doc: Document): Set<string> {
|
|
84
|
+
let set = insertedRules.get(doc);
|
|
85
|
+
if (!set) {
|
|
86
|
+
set = new Set<string>();
|
|
87
|
+
insertedRules.set(doc, set);
|
|
88
|
+
}
|
|
89
|
+
return set;
|
|
76
90
|
}
|
|
77
91
|
|
|
78
92
|
/**
|
|
@@ -334,19 +348,21 @@ export class ApplicatorRegistry {
|
|
|
334
348
|
const minWidth = BREAKPOINTS[breakpoint];
|
|
335
349
|
|
|
336
350
|
if (minWidth) {
|
|
351
|
+
const doc = element.ownerDocument as Document;
|
|
352
|
+
const dedup = getInsertedRules(doc);
|
|
337
353
|
const cssName = this.toKebabCase(prop);
|
|
338
354
|
const cssValue = this.formatCssValue(cssName, value);
|
|
339
355
|
const className = `hypen-${cssName.replace(/[^a-zA-Z0-9-]/g, '')}-${breakpoint}-${hashValue(value)}`;
|
|
340
356
|
const ruleKey = `${className}:${cssValue}`;
|
|
341
357
|
|
|
342
358
|
// Avoid duplicate rule insertion
|
|
343
|
-
if (!
|
|
344
|
-
const sheet = getVariantStyleSheet();
|
|
359
|
+
if (!dedup.has(ruleKey)) {
|
|
360
|
+
const sheet = getVariantStyleSheet(doc);
|
|
345
361
|
sheet.insertRule(
|
|
346
362
|
`@media (min-width: ${minWidth}) { .${className} { ${cssName}: ${cssValue}; } }`,
|
|
347
363
|
sheet.cssRules.length
|
|
348
364
|
);
|
|
349
|
-
|
|
365
|
+
dedup.add(ruleKey);
|
|
350
366
|
}
|
|
351
367
|
|
|
352
368
|
element.classList.add(className);
|
|
@@ -362,19 +378,21 @@ export class ApplicatorRegistry {
|
|
|
362
378
|
// Only handle known CSS pseudo-states
|
|
363
379
|
const validStates = ['hover', 'focus', 'active', 'disabled', 'focus-visible', 'focus-within'];
|
|
364
380
|
if (validStates.includes(state)) {
|
|
381
|
+
const doc = element.ownerDocument as Document;
|
|
382
|
+
const dedup = getInsertedRules(doc);
|
|
365
383
|
const cssName = this.toKebabCase(prop);
|
|
366
384
|
const cssValue = this.formatCssValue(cssName, value);
|
|
367
385
|
const className = `hypen-${cssName.replace(/[^a-zA-Z0-9-]/g, '')}-${state}-${hashValue(value)}`;
|
|
368
386
|
const ruleKey = `${className}:${cssValue}`;
|
|
369
387
|
|
|
370
388
|
// Avoid duplicate rule insertion
|
|
371
|
-
if (!
|
|
372
|
-
const sheet = getVariantStyleSheet();
|
|
389
|
+
if (!dedup.has(ruleKey)) {
|
|
390
|
+
const sheet = getVariantStyleSheet(doc);
|
|
373
391
|
sheet.insertRule(
|
|
374
392
|
`.${className}:${state} { ${cssName}: ${cssValue}; }`,
|
|
375
393
|
sheet.cssRules.length
|
|
376
394
|
);
|
|
377
|
-
|
|
395
|
+
dedup.add(ruleKey);
|
|
378
396
|
}
|
|
379
397
|
|
|
380
398
|
element.classList.add(className);
|
package/src/dom/canvas/index.ts
CHANGED
|
@@ -8,8 +8,8 @@ import type { ComponentHandler } from "../components/index.js";
|
|
|
8
8
|
import type { ApplicatorHandler } from "../applicators/index.js";
|
|
9
9
|
|
|
10
10
|
export const canvasHandler: ComponentHandler = {
|
|
11
|
-
create(): HTMLElement {
|
|
12
|
-
const el =
|
|
11
|
+
create(doc: Document): HTMLElement {
|
|
12
|
+
const el = doc.createElement("canvas");
|
|
13
13
|
el.dataset.hypenType = "canvas";
|
|
14
14
|
return el as any as HTMLElement;
|
|
15
15
|
},
|
|
@@ -8,8 +8,8 @@
|
|
|
8
8
|
import type { ComponentHandler } from "./index.js";
|
|
9
9
|
|
|
10
10
|
export const appHandler: ComponentHandler = {
|
|
11
|
-
create(): HTMLElement {
|
|
12
|
-
const el =
|
|
11
|
+
create(doc: Document): HTMLElement {
|
|
12
|
+
const el = doc.createElement("div");
|
|
13
13
|
el.style.display = "flex";
|
|
14
14
|
el.style.flexDirection = "column";
|
|
15
15
|
el.style.minHeight = "100vh";
|
|
@@ -5,8 +5,8 @@
|
|
|
5
5
|
import type { ComponentHandler } from "./index.js";
|
|
6
6
|
|
|
7
7
|
export const audioHandler: ComponentHandler = {
|
|
8
|
-
create(): HTMLElement {
|
|
9
|
-
const el =
|
|
8
|
+
create(doc: Document): HTMLElement {
|
|
9
|
+
const el = doc.createElement("audio");
|
|
10
10
|
el.dataset.hypenType = "audio";
|
|
11
11
|
return el as any as HTMLElement;
|
|
12
12
|
},
|
|
@@ -5,8 +5,8 @@
|
|
|
5
5
|
import type { ComponentHandler } from "./index.js";
|
|
6
6
|
|
|
7
7
|
export const avatarHandler: ComponentHandler = {
|
|
8
|
-
create(): HTMLElement {
|
|
9
|
-
const el =
|
|
8
|
+
create(doc: Document): HTMLElement {
|
|
9
|
+
const el = doc.createElement("div");
|
|
10
10
|
el.dataset.hypenType = "avatar";
|
|
11
11
|
el.style.display = "inline-flex";
|
|
12
12
|
el.style.alignItems = "center";
|
|
@@ -23,10 +23,11 @@ export const avatarHandler: ComponentHandler = {
|
|
|
23
23
|
},
|
|
24
24
|
|
|
25
25
|
applyProps(el: HTMLElement, props: Record<string, any>): void {
|
|
26
|
+
const doc = el.ownerDocument as Document;
|
|
26
27
|
// Image source - support named arg with .0 suffix, positional arg, or plain name
|
|
27
28
|
const src = props["src.0"] || props["0"] || props.src || props.source;
|
|
28
29
|
if (src !== undefined) {
|
|
29
|
-
const img =
|
|
30
|
+
const img = doc.createElement("img");
|
|
30
31
|
img.src = String(src);
|
|
31
32
|
img.style.width = "100%";
|
|
32
33
|
img.style.height = "100%";
|
|
@@ -5,8 +5,8 @@
|
|
|
5
5
|
import type { ComponentHandler } from "./index.js";
|
|
6
6
|
|
|
7
7
|
export const badgeHandler: ComponentHandler = {
|
|
8
|
-
create(): HTMLElement {
|
|
9
|
-
const el =
|
|
8
|
+
create(doc: Document): HTMLElement {
|
|
9
|
+
const el = doc.createElement("span");
|
|
10
10
|
el.dataset.hypenType = "badge";
|
|
11
11
|
el.style.display = "inline-block";
|
|
12
12
|
el.style.padding = "4px 8px";
|
|
@@ -9,8 +9,8 @@
|
|
|
9
9
|
import type { ComponentHandler } from "./index.js";
|
|
10
10
|
|
|
11
11
|
export const buttonHandler: ComponentHandler = {
|
|
12
|
-
create(): HTMLElement {
|
|
13
|
-
const el =
|
|
12
|
+
create(doc: Document): HTMLElement {
|
|
13
|
+
const el = doc.createElement("button");
|
|
14
14
|
// Reset default button styles for cross-platform consistency
|
|
15
15
|
el.style.border = "none";
|
|
16
16
|
el.style.background = "none";
|
|
@@ -5,8 +5,8 @@
|
|
|
5
5
|
import type { ComponentHandler } from "./index.js";
|
|
6
6
|
|
|
7
7
|
export const cardHandler: ComponentHandler = {
|
|
8
|
-
create(): HTMLElement {
|
|
9
|
-
const el =
|
|
8
|
+
create(doc: Document): HTMLElement {
|
|
9
|
+
const el = doc.createElement("div");
|
|
10
10
|
el.dataset.hypenType = "card";
|
|
11
11
|
el.style.backgroundColor = "#ffffff";
|
|
12
12
|
el.style.borderRadius = "8px";
|
|
@@ -9,8 +9,8 @@
|
|
|
9
9
|
import type { ComponentHandler } from "./index.js";
|
|
10
10
|
|
|
11
11
|
export const centerHandler: ComponentHandler = {
|
|
12
|
-
create(): HTMLElement {
|
|
13
|
-
const el =
|
|
12
|
+
create(doc: Document): HTMLElement {
|
|
13
|
+
const el = doc.createElement("div");
|
|
14
14
|
el.style.display = "flex";
|
|
15
15
|
el.style.alignItems = "center";
|
|
16
16
|
el.style.justifyContent = "center";
|
|
@@ -5,15 +5,15 @@
|
|
|
5
5
|
import type { ComponentHandler } from "./index.js";
|
|
6
6
|
|
|
7
7
|
export const checkboxHandler: ComponentHandler = {
|
|
8
|
-
create(): HTMLElement {
|
|
9
|
-
const wrapper =
|
|
8
|
+
create(doc: Document): HTMLElement {
|
|
9
|
+
const wrapper = doc.createElement("label");
|
|
10
10
|
wrapper.dataset.hypenType = "checkbox";
|
|
11
11
|
wrapper.style.display = "inline-flex";
|
|
12
12
|
wrapper.style.alignItems = "center";
|
|
13
13
|
wrapper.style.gap = "8px";
|
|
14
14
|
wrapper.style.cursor = "pointer";
|
|
15
15
|
|
|
16
|
-
const input =
|
|
16
|
+
const input = doc.createElement("input");
|
|
17
17
|
input.type = "checkbox";
|
|
18
18
|
input.dataset.hypenCheckbox = "true";
|
|
19
19
|
|
|
@@ -23,6 +23,7 @@ export const checkboxHandler: ComponentHandler = {
|
|
|
23
23
|
},
|
|
24
24
|
|
|
25
25
|
applyProps(el: HTMLElement, props: Record<string, any>): void {
|
|
26
|
+
const doc = el.ownerDocument as Document;
|
|
26
27
|
const input = el.querySelector('input[type="checkbox"]') as HTMLInputElement;
|
|
27
28
|
if (!input) return;
|
|
28
29
|
|
|
@@ -46,7 +47,7 @@ export const checkboxHandler: ComponentHandler = {
|
|
|
46
47
|
textNodes.forEach(node => node.remove());
|
|
47
48
|
|
|
48
49
|
// Add new label text
|
|
49
|
-
el.appendChild(
|
|
50
|
+
el.appendChild(doc.createTextNode(String(label)));
|
|
50
51
|
}
|
|
51
52
|
},
|
|
52
53
|
};
|
|
@@ -17,8 +17,8 @@
|
|
|
17
17
|
import type { ComponentHandler } from "./index.js";
|
|
18
18
|
|
|
19
19
|
export const columnHandler: ComponentHandler = {
|
|
20
|
-
create(): HTMLElement {
|
|
21
|
-
const el =
|
|
20
|
+
create(doc: Document): HTMLElement {
|
|
21
|
+
const el = doc.createElement("div");
|
|
22
22
|
el.style.display = "flex";
|
|
23
23
|
el.style.flexDirection = "column";
|
|
24
24
|
el.style.alignItems = "stretch";
|
|
@@ -5,8 +5,8 @@
|
|
|
5
5
|
import type { ComponentHandler } from "./index.js";
|
|
6
6
|
|
|
7
7
|
export const containerHandler: ComponentHandler = {
|
|
8
|
-
create(): HTMLElement {
|
|
9
|
-
const el =
|
|
8
|
+
create(doc: Document): HTMLElement {
|
|
9
|
+
const el = doc.createElement("div");
|
|
10
10
|
// Simple block container - wraps to content by default
|
|
11
11
|
// Use .fillMaxWidth(true) to stretch
|
|
12
12
|
el.dataset.hypenType = "container";
|
|
@@ -5,8 +5,8 @@
|
|
|
5
5
|
import type { ComponentHandler } from "./index.js";
|
|
6
6
|
|
|
7
7
|
export const dividerHandler: ComponentHandler = {
|
|
8
|
-
create(): HTMLElement {
|
|
9
|
-
const el =
|
|
8
|
+
create(doc: Document): HTMLElement {
|
|
9
|
+
const el = doc.createElement("hr");
|
|
10
10
|
el.dataset.hypenType = "divider";
|
|
11
11
|
el.style.border = "none";
|
|
12
12
|
el.style.borderTop = "1px solid #e0e0e0";
|