@velumo/renderer-dom 0.1.0-beta.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/background-renderer.d.ts +16 -0
- package/dist/background-renderer.d.ts.map +1 -0
- package/dist/background-renderer.js +41 -0
- package/dist/background-renderer.js.map +1 -0
- package/dist/camera-adapter.d.ts +16 -0
- package/dist/camera-adapter.d.ts.map +1 -0
- package/dist/camera-adapter.js +13 -0
- package/dist/camera-adapter.js.map +1 -0
- package/dist/default-theme.d.ts +5 -0
- package/dist/default-theme.d.ts.map +1 -0
- package/dist/default-theme.js +579 -0
- package/dist/default-theme.js.map +1 -0
- package/dist/dom-renderer.d.ts +62 -0
- package/dist/dom-renderer.d.ts.map +1 -0
- package/dist/dom-renderer.js +306 -0
- package/dist/dom-renderer.js.map +1 -0
- package/dist/html-sanitize.d.ts +18 -0
- package/dist/html-sanitize.d.ts.map +1 -0
- package/dist/html-sanitize.js +379 -0
- package/dist/html-sanitize.js.map +1 -0
- package/dist/index.d.ts +12 -0
- package/dist/index.d.ts.map +1 -0
- package/dist/index.js +10 -0
- package/dist/index.js.map +1 -0
- package/dist/layout-renderer.d.ts +16 -0
- package/dist/layout-renderer.d.ts.map +1 -0
- package/dist/layout-renderer.js +450 -0
- package/dist/layout-renderer.js.map +1 -0
- package/dist/slide-thumbnail.d.ts +63 -0
- package/dist/slide-thumbnail.d.ts.map +1 -0
- package/dist/slide-thumbnail.js +93 -0
- package/dist/slide-thumbnail.js.map +1 -0
- package/dist/spatial-renderer.d.ts +18 -0
- package/dist/spatial-renderer.d.ts.map +1 -0
- package/dist/spatial-renderer.js +36 -0
- package/dist/spatial-renderer.js.map +1 -0
- package/dist/theme-fonts.d.ts +8 -0
- package/dist/theme-fonts.d.ts.map +1 -0
- package/dist/theme-fonts.js +68 -0
- package/dist/theme-fonts.js.map +1 -0
- package/dist/theme-tokens.d.ts +3 -0
- package/dist/theme-tokens.d.ts.map +1 -0
- package/dist/theme-tokens.js +43 -0
- package/dist/theme-tokens.js.map +1 -0
- package/dist/tsconfig.tsbuildinfo +1 -0
- package/package.json +20 -0
- package/src/background-renderer.ts +77 -0
- package/src/camera-adapter.ts +21 -0
- package/src/default-theme.ts +582 -0
- package/src/dom-renderer.ts +486 -0
- package/src/html-sanitize.ts +446 -0
- package/src/index.ts +25 -0
- package/src/layout-renderer.ts +721 -0
- package/src/slide-thumbnail.ts +146 -0
- package/src/spatial-renderer.ts +62 -0
- package/src/theme-fonts.ts +75 -0
- package/src/theme-tokens.ts +66 -0
- package/tsconfig.json +10 -0
|
@@ -0,0 +1,146 @@
|
|
|
1
|
+
import type { Manifest } from "@velumo/core";
|
|
2
|
+
import { RuntimeController } from "@velumo/runtime";
|
|
3
|
+
import { DomRenderer, type DomRendererOptions } from "./dom-renderer.js";
|
|
4
|
+
|
|
5
|
+
// Default reference dimensions for a 16:9 slide stage.
|
|
6
|
+
const DEFAULT_REFERENCE = { width: 1280, height: 720 };
|
|
7
|
+
|
|
8
|
+
export interface SlideThumbnailOptions {
|
|
9
|
+
/** The host element that will contain the thumbnail. The HOST is responsible
|
|
10
|
+
* for sizing this element's width; the thumbnail fills it via aspect-ratio. */
|
|
11
|
+
readonly root: {
|
|
12
|
+
readonly ownerDocument: {
|
|
13
|
+
createElement(tagName: string): any;
|
|
14
|
+
createElementNS?(ns: string, tagName: string): any;
|
|
15
|
+
createTextNode(text: string): any;
|
|
16
|
+
readonly head?: any;
|
|
17
|
+
querySelector?(selectors: string): any;
|
|
18
|
+
readonly defaultView?: {
|
|
19
|
+
ResizeObserver?: new (cb: ResizeObserverCallback) => ResizeObserver;
|
|
20
|
+
} | null;
|
|
21
|
+
};
|
|
22
|
+
replaceChildren(...children: any[]): void;
|
|
23
|
+
querySelector(selector: string): any;
|
|
24
|
+
getBoundingClientRect?(): {
|
|
25
|
+
width: number;
|
|
26
|
+
height: number;
|
|
27
|
+
top: number;
|
|
28
|
+
left: number;
|
|
29
|
+
right: number;
|
|
30
|
+
bottom: number;
|
|
31
|
+
};
|
|
32
|
+
};
|
|
33
|
+
readonly manifest: Manifest;
|
|
34
|
+
readonly slideId: string;
|
|
35
|
+
/** Override the canonical reference dimensions (default: 1280x720). */
|
|
36
|
+
readonly reference?: { width: number; height: number };
|
|
37
|
+
/** The runtime registry, so plugin-registered layer renderers (e.g. `fx:*`)
|
|
38
|
+
* render real content in the thumbnail instead of fallback layer-type text. */
|
|
39
|
+
readonly registry?: DomRendererOptions["registry"];
|
|
40
|
+
}
|
|
41
|
+
|
|
42
|
+
export interface SlideThumbnailHandle {
|
|
43
|
+
dispose(): void;
|
|
44
|
+
}
|
|
45
|
+
|
|
46
|
+
/**
|
|
47
|
+
* Mounts a slide thumbnail into `options.root`.
|
|
48
|
+
*
|
|
49
|
+
* DOM structure:
|
|
50
|
+
* root
|
|
51
|
+
* └── box [data-velumo-thumbnail] (overflow:hidden, aspect-ratio:16/9)
|
|
52
|
+
* └── stage [data-velumo-thumbnail-stage] (1280×720, container-type:size)
|
|
53
|
+
* └── surface [data-velumo-renderer="dom"] (DomRenderer root)
|
|
54
|
+
*
|
|
55
|
+
* The HOST sizes box's width. The stage is always 1280px wide (reference.width)
|
|
56
|
+
* so that cqw units resolve canonically. A CSS transform scales the stage to fit
|
|
57
|
+
* the host-sized box (transform-origin: top left).
|
|
58
|
+
*
|
|
59
|
+
* ResizeObserver (when available) keeps the scale updated. A synchronous
|
|
60
|
+
* initial measure is taken at mount time; a zero width falls back to reference.width.
|
|
61
|
+
*
|
|
62
|
+
* The [data-velumo-thumbnail] marker on the box causes the defaultThemeCss rule
|
|
63
|
+
* [data-velumo-thumbnail] [data-velumo-animation] { animation: none !important; }
|
|
64
|
+
* to suppress entry animations, keeping thumbnails visually settled.
|
|
65
|
+
*/
|
|
66
|
+
export function mountSlideThumbnail(
|
|
67
|
+
options: SlideThumbnailOptions,
|
|
68
|
+
): SlideThumbnailHandle {
|
|
69
|
+
const reference = options.reference ?? DEFAULT_REFERENCE;
|
|
70
|
+
const doc = options.root.ownerDocument;
|
|
71
|
+
|
|
72
|
+
// Build DOM: box > stage > surface
|
|
73
|
+
const box = doc.createElement("div");
|
|
74
|
+
box.setAttribute("data-velumo-thumbnail", "");
|
|
75
|
+
box.style.overflow = "hidden";
|
|
76
|
+
box.style.aspectRatio = "16 / 9";
|
|
77
|
+
|
|
78
|
+
const stage = doc.createElement("div");
|
|
79
|
+
stage.setAttribute("data-velumo-thumbnail-stage", "");
|
|
80
|
+
stage.style.width = `${reference.width}px`;
|
|
81
|
+
stage.style.height = `${reference.height}px`;
|
|
82
|
+
stage.style.containerType = "size";
|
|
83
|
+
stage.style.transformOrigin = "top left";
|
|
84
|
+
|
|
85
|
+
const surface = doc.createElement("div");
|
|
86
|
+
// Fill the stage so the renderer root has a DEFINITE 1280x720 size. Without an
|
|
87
|
+
// explicit height the surface shrink-wraps its content, so its `container-type:
|
|
88
|
+
// size` would resolve `cqh` to the content height instead of 720px and the
|
|
89
|
+
// slide's `cqh`-based frame padding would be wrong (gate-3 finding 1).
|
|
90
|
+
surface.style.width = "100%";
|
|
91
|
+
surface.style.height = "100%";
|
|
92
|
+
|
|
93
|
+
stage.appendChild(surface);
|
|
94
|
+
box.appendChild(stage);
|
|
95
|
+
options.root.replaceChildren(box);
|
|
96
|
+
|
|
97
|
+
// Wire up runtime + renderer
|
|
98
|
+
const runtime = new RuntimeController(options.manifest, {
|
|
99
|
+
initialSlideId: options.slideId,
|
|
100
|
+
});
|
|
101
|
+
const renderer = new DomRenderer({
|
|
102
|
+
root: surface,
|
|
103
|
+
manifest: options.manifest,
|
|
104
|
+
runtime,
|
|
105
|
+
registry: options.registry,
|
|
106
|
+
});
|
|
107
|
+
renderer.mount();
|
|
108
|
+
|
|
109
|
+
// Scale helpers
|
|
110
|
+
function applyScale(boxWidth: number): void {
|
|
111
|
+
stage.style.transform = `scale(${boxWidth / reference.width})`;
|
|
112
|
+
}
|
|
113
|
+
|
|
114
|
+
// Synchronous initial measure
|
|
115
|
+
const initialWidth = box.getBoundingClientRect?.()?.width ?? 0;
|
|
116
|
+
applyScale(initialWidth > 0 ? initialWidth : reference.width);
|
|
117
|
+
|
|
118
|
+
// ResizeObserver (observe the BOX — a test asserts this)
|
|
119
|
+
let disposed = false;
|
|
120
|
+
const ROCtor:
|
|
121
|
+
| (new (cb: ResizeObserverCallback) => ResizeObserver)
|
|
122
|
+
| undefined =
|
|
123
|
+
(globalThis as any).ResizeObserver ?? doc.defaultView?.ResizeObserver;
|
|
124
|
+
|
|
125
|
+
let observer: ResizeObserver | undefined;
|
|
126
|
+
if (ROCtor !== undefined) {
|
|
127
|
+
observer = new ROCtor((entries) => {
|
|
128
|
+
if (disposed) return;
|
|
129
|
+
const width = entries[0]?.contentRect?.width;
|
|
130
|
+
if (typeof width === "number") {
|
|
131
|
+
applyScale(width);
|
|
132
|
+
}
|
|
133
|
+
});
|
|
134
|
+
observer.observe(box);
|
|
135
|
+
}
|
|
136
|
+
|
|
137
|
+
return {
|
|
138
|
+
dispose(): void {
|
|
139
|
+
if (disposed) return;
|
|
140
|
+
disposed = true;
|
|
141
|
+
observer?.disconnect();
|
|
142
|
+
renderer.dispose();
|
|
143
|
+
options.root.replaceChildren();
|
|
144
|
+
},
|
|
145
|
+
};
|
|
146
|
+
}
|
|
@@ -0,0 +1,62 @@
|
|
|
1
|
+
import type { CameraEasing, Scene } from "@velumo/core";
|
|
2
|
+
import {
|
|
3
|
+
type LayoutRendererElement,
|
|
4
|
+
type LayoutRendererFactories,
|
|
5
|
+
renderLayoutNode,
|
|
6
|
+
} from "./layout-renderer.js";
|
|
7
|
+
|
|
8
|
+
export interface SpatialWorldElements {
|
|
9
|
+
readonly viewport: LayoutRendererElement;
|
|
10
|
+
readonly world: LayoutRendererElement;
|
|
11
|
+
}
|
|
12
|
+
|
|
13
|
+
/**
|
|
14
|
+
* Build a clipping viewport + an inner world layer for a spatial scene.
|
|
15
|
+
* The world layer carries the scene's `layout` (the world canvas) and is the
|
|
16
|
+
* element the camera transform is applied to.
|
|
17
|
+
*/
|
|
18
|
+
export function renderSpatialWorld(
|
|
19
|
+
scene: Scene,
|
|
20
|
+
factories: LayoutRendererFactories,
|
|
21
|
+
): SpatialWorldElements {
|
|
22
|
+
const viewport = factories.createElement("div");
|
|
23
|
+
viewport.setAttribute("data-velumo-spatial", "viewport");
|
|
24
|
+
factories.setStyleProperty(viewport, "position", "absolute");
|
|
25
|
+
factories.setStyleProperty(viewport, "inset", "0");
|
|
26
|
+
factories.setStyleProperty(viewport, "overflow", "hidden");
|
|
27
|
+
|
|
28
|
+
const world = factories.createElement("div");
|
|
29
|
+
world.setAttribute("data-velumo-spatial", "world");
|
|
30
|
+
factories.setStyleProperty(world, "position", "absolute");
|
|
31
|
+
factories.setStyleProperty(world, "inset", "0");
|
|
32
|
+
// A flex column so a flowing canvas layout (flex: 1) fills the world; the
|
|
33
|
+
// canvas's percent-positioned items then map onto the full world box.
|
|
34
|
+
factories.setStyleProperty(world, "display", "flex");
|
|
35
|
+
factories.setStyleProperty(world, "flex-direction", "column");
|
|
36
|
+
factories.setStyleProperty(world, "transform-origin", "0 0");
|
|
37
|
+
factories.setStyleProperty(world, "will-change", "transform");
|
|
38
|
+
|
|
39
|
+
if (scene.layout !== undefined) {
|
|
40
|
+
world.appendChild(renderLayoutNode(scene.layout, factories));
|
|
41
|
+
}
|
|
42
|
+
|
|
43
|
+
viewport.appendChild(world);
|
|
44
|
+
return { viewport, world };
|
|
45
|
+
}
|
|
46
|
+
|
|
47
|
+
/**
|
|
48
|
+
* Set the CSS transition that animates the next camera move. A zero duration
|
|
49
|
+
* (e.g. reduced motion) disables the transition so the move is an instant cut.
|
|
50
|
+
*/
|
|
51
|
+
export function applyCameraTransition(
|
|
52
|
+
world: LayoutRendererElement,
|
|
53
|
+
durationMs: number,
|
|
54
|
+
easing: CameraEasing,
|
|
55
|
+
factories: LayoutRendererFactories,
|
|
56
|
+
): void {
|
|
57
|
+
factories.setStyleProperty(
|
|
58
|
+
world,
|
|
59
|
+
"transition",
|
|
60
|
+
durationMs <= 0 ? "none" : `transform ${durationMs}ms ${easing}`,
|
|
61
|
+
);
|
|
62
|
+
}
|
|
@@ -0,0 +1,75 @@
|
|
|
1
|
+
import type { ThemeFontFace } from "@velumo/core";
|
|
2
|
+
|
|
3
|
+
// Escape a value for safe interpolation inside a double-quoted CSS string
|
|
4
|
+
// (a `"..."` literal, a `url("...")` descriptor, or a `[attr="..."]` selector
|
|
5
|
+
// value). Backslash and double-quote are escaped; a literal newline becomes the
|
|
6
|
+
// CSS `\A ` escape. `ThemeFontFace`/`Theme.id` are validated only as non-empty
|
|
7
|
+
// strings, so a `"` in a family/source/id must not be able to break out of the
|
|
8
|
+
// string (invalid CSS, a dropped @font-face, or a `querySelector` SyntaxError).
|
|
9
|
+
export function escapeCssString(value: string): string {
|
|
10
|
+
return value.replace(/[\\"]/g, "\\$&").replace(/\n/g, "\\A ");
|
|
11
|
+
}
|
|
12
|
+
|
|
13
|
+
// Resolve the `format()` hint for a face's `src` descriptor. An explicit
|
|
14
|
+
// `format` wins; otherwise we sniff `woff2` from a `data:font/woff2` URI or a
|
|
15
|
+
// `.woff2` path (ignoring any query suffix). For anything else we emit no
|
|
16
|
+
// `format()` at all and let the browser sniff the bytes — keeping the contract
|
|
17
|
+
// format-agnostic while defaulting correctly for the self-contained woff2
|
|
18
|
+
// data-URIs the themes ship.
|
|
19
|
+
function resolveFormat(face: ThemeFontFace): string | undefined {
|
|
20
|
+
if (face.format !== undefined) {
|
|
21
|
+
return face.format;
|
|
22
|
+
}
|
|
23
|
+
if (/^data:font\/woff2[;,]/.test(face.source)) {
|
|
24
|
+
return "woff2";
|
|
25
|
+
}
|
|
26
|
+
const pathOnly = face.source.split("?")[0];
|
|
27
|
+
if (/\.woff2$/i.test(pathOnly)) {
|
|
28
|
+
return "woff2";
|
|
29
|
+
}
|
|
30
|
+
return undefined;
|
|
31
|
+
}
|
|
32
|
+
|
|
33
|
+
// Compile one `ThemeFontFace` into a single `@font-face` block. The family is
|
|
34
|
+
// always quoted; the source is a single combined descriptor; every optional
|
|
35
|
+
// descriptor is emitted only when present (a string `weight` such as the
|
|
36
|
+
// variable range "100 900" passes through verbatim).
|
|
37
|
+
function compileFontFace(face: ThemeFontFace): string {
|
|
38
|
+
const format = resolveFormat(face);
|
|
39
|
+
// Always quote the url() so sources with spaces or other characters that are
|
|
40
|
+
// invalid in an unquoted url() token (e.g. "/fonts/Bebas Neue.woff2") still
|
|
41
|
+
// parse — a quoted data-URI is equally valid.
|
|
42
|
+
const url = `url("${escapeCssString(face.source)}")`;
|
|
43
|
+
const src =
|
|
44
|
+
format === undefined ? url : `${url} format("${escapeCssString(format)}")`;
|
|
45
|
+
|
|
46
|
+
const lines = [
|
|
47
|
+
` font-family: "${escapeCssString(face.family)}";`,
|
|
48
|
+
` src: ${src};`,
|
|
49
|
+
];
|
|
50
|
+
if (face.weight !== undefined) {
|
|
51
|
+
lines.push(` font-weight: ${face.weight};`);
|
|
52
|
+
}
|
|
53
|
+
if (face.style !== undefined) {
|
|
54
|
+
lines.push(` font-style: ${face.style};`);
|
|
55
|
+
}
|
|
56
|
+
if (face.display !== undefined) {
|
|
57
|
+
lines.push(` font-display: ${face.display};`);
|
|
58
|
+
}
|
|
59
|
+
if (face.unicodeRange !== undefined) {
|
|
60
|
+
lines.push(` unicode-range: ${face.unicodeRange};`);
|
|
61
|
+
}
|
|
62
|
+
if (face.stretch !== undefined) {
|
|
63
|
+
lines.push(` font-stretch: ${face.stretch};`);
|
|
64
|
+
}
|
|
65
|
+
|
|
66
|
+
return `@font-face {\n${lines.join("\n")}\n}`;
|
|
67
|
+
}
|
|
68
|
+
|
|
69
|
+
/**
|
|
70
|
+
* Compile a theme's declared font faces into `@font-face` CSS — one block per
|
|
71
|
+
* face. Pure and DOM-free; the DOM renderer injects the result at mount.
|
|
72
|
+
*/
|
|
73
|
+
export function compileFontFaceCss(fonts: readonly ThemeFontFace[]): string {
|
|
74
|
+
return fonts.map(compileFontFace).join("\n\n");
|
|
75
|
+
}
|
|
@@ -0,0 +1,66 @@
|
|
|
1
|
+
import type { ThemeTokens } from "@velumo/core";
|
|
2
|
+
|
|
3
|
+
export function tokensToCssProperties(
|
|
4
|
+
tokens: ThemeTokens | undefined,
|
|
5
|
+
): Map<string, string> {
|
|
6
|
+
const properties = new Map<string, string>();
|
|
7
|
+
|
|
8
|
+
if (tokens === undefined) {
|
|
9
|
+
return properties;
|
|
10
|
+
}
|
|
11
|
+
|
|
12
|
+
addStringRecord(properties, "color", tokens.colors);
|
|
13
|
+
addNestedTokenRecord(properties, "typography", tokens.typography);
|
|
14
|
+
addStringRecord(properties, "spacing", tokens.spacing);
|
|
15
|
+
addStringRecord(properties, "radius", tokens.radii);
|
|
16
|
+
addStringRecord(properties, "shadow", tokens.shadows);
|
|
17
|
+
addNestedTokenRecord(properties, "motion", tokens.motion);
|
|
18
|
+
addStringRecord(properties, "surface", tokens.surfaces);
|
|
19
|
+
addStringRecord(properties, "text", tokens.text);
|
|
20
|
+
|
|
21
|
+
return properties;
|
|
22
|
+
}
|
|
23
|
+
|
|
24
|
+
function addStringRecord(
|
|
25
|
+
properties: Map<string, string>,
|
|
26
|
+
family: string,
|
|
27
|
+
tokens: Record<string, string | number> | undefined,
|
|
28
|
+
): void {
|
|
29
|
+
if (tokens === undefined) {
|
|
30
|
+
return;
|
|
31
|
+
}
|
|
32
|
+
|
|
33
|
+
for (const [name, value] of Object.entries(tokens)) {
|
|
34
|
+
properties.set(`--velumo-${family}-${toKebabCase(name)}`, String(value));
|
|
35
|
+
}
|
|
36
|
+
}
|
|
37
|
+
|
|
38
|
+
function addNestedTokenRecord(
|
|
39
|
+
properties: Map<string, string>,
|
|
40
|
+
family: string,
|
|
41
|
+
tokens: Record<string, object> | undefined,
|
|
42
|
+
): void {
|
|
43
|
+
if (tokens === undefined) {
|
|
44
|
+
return;
|
|
45
|
+
}
|
|
46
|
+
|
|
47
|
+
for (const [name, values] of Object.entries(tokens)) {
|
|
48
|
+
for (const [field, value] of Object.entries(values)) {
|
|
49
|
+
if (typeof value !== "string" && typeof value !== "number") {
|
|
50
|
+
continue;
|
|
51
|
+
}
|
|
52
|
+
|
|
53
|
+
properties.set(
|
|
54
|
+
`--velumo-${family}-${toKebabCase(name)}-${toKebabCase(field)}`,
|
|
55
|
+
String(value),
|
|
56
|
+
);
|
|
57
|
+
}
|
|
58
|
+
}
|
|
59
|
+
}
|
|
60
|
+
|
|
61
|
+
function toKebabCase(value: string): string {
|
|
62
|
+
return value
|
|
63
|
+
.replace(/([a-z0-9])([A-Z])/g, "$1-$2")
|
|
64
|
+
.replace(/[_\s.]+/g, "-")
|
|
65
|
+
.toLowerCase();
|
|
66
|
+
}
|
package/tsconfig.json
ADDED