lecodes-web-shared 2.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.
@@ -0,0 +1,88 @@
1
+ // The viewer measures text with a real canvas 2D context (the DOM measure system's OffscreenCanvas
2
+ // and the canvas-ui BrowserHost), which only sees fonts the document has actually loaded — so the
3
+ // measurement font must be fully loaded before the first layout, or text lays out against a
4
+ // fallback and never recovers (Yoga caches the measure).
5
+ //
6
+ // The font is the vendored variable Roboto (weights 100–900, latin + cyrillic + greek + vietnamese
7
+ // in ONE file), self-hosted and same-origin. The previous Google-Fonts Inter pipeline was broken on
8
+ // a cold cache: css2 serves each family split into unicode-range subsets, and
9
+ // `fonts.load("400 14px Inter")` (default text = a space) only awaited the *latin* subset — the
10
+ // first measure of Cyrillic text ran against a fallback and produced a wrong layout that later
11
+ // cache-warm loads never reproduced. One complete file makes the await cover every glyph, every
12
+ // weight, with no CDN round trip.
13
+ import robotoUrl from "../assets/Roboto-Variable.woff2?url"
14
+
15
+ // The default measurement/paint family (createMeasureSystem / paintTree / the lite CSS renderer).
16
+ export const DEFAULT_FONT_FAMILY = "Roboto"
17
+
18
+ // One variable face serves every weight — a single load covers 400..800.
19
+ const DEFAULT_FONTS = [`400 14px ${DEFAULT_FONT_FAMILY}`]
20
+
21
+ const FONT_STYLE_MARKER = "data-lecodes-fonts"
22
+
23
+ /** Register the vendored @font-face in `doc` (idempotent) and resolve once the bytes are in. */
24
+ export const ensureFontsLoaded = async (fonts: string[] = DEFAULT_FONTS, doc?: Document) => {
25
+ // Fonts are per-document: the lite host renders inside its own iframe and passes that
26
+ // document; the default serves the classic full-viewer path.
27
+ const target = doc ?? (typeof document === "undefined" ? undefined : document)
28
+ if (!target || !target.fonts) return
29
+
30
+ if (!target.head.querySelector(`style[${FONT_STYLE_MARKER}]`)) {
31
+ // Resolve against the embedder document: the lite host's iframe is a base-less about:blank,
32
+ // and Vite emits a root-relative asset path.
33
+ const absUrl = new URL(robotoUrl, document.baseURI).href
34
+ const style = target.createElement("style")
35
+ style.setAttribute(FONT_STYLE_MARKER, "")
36
+ style.textContent =
37
+ `@font-face{font-family:"${DEFAULT_FONT_FAMILY}";src:url("${absUrl}") format("woff2");` +
38
+ `font-weight:100 900;font-display:swap}`
39
+ target.head.appendChild(style)
40
+ }
41
+
42
+ try {
43
+ // load() resolves once each matched face's bytes are in, and resolves (not rejects) with an
44
+ // empty array when nothing matches — so a missing face is a no-op, not a failure.
45
+ await Promise.all(fonts.map(font => target.fonts.load(font)))
46
+ } catch {
47
+ // Best-effort: a font that fails to fetch must never block the simulator from starting.
48
+ }
49
+ }
50
+
51
+ // ---- app-declared boot fonts (the bundle's `// fonts:` header) ----------------------------------
52
+
53
+ export type BootFont = { family: string, weight: number, italic: boolean, url: string }
54
+
55
+ /** Parse the bundle's `// fonts:` header value (`Family@<weight>[i]=<url>;…`) from the header
56
+ * region of a compiled bundle. A `{fmt}` placeholder in a URL is the container-format choice —
57
+ * replaced with this host's preference (browsers: woff2). */
58
+ export const parseFontsHeader = (header: string, format = "woff2"): BootFont[] => {
59
+ const m = /^\/\/ fonts: (.+)$/m.exec(header)
60
+ if (!m) return []
61
+ const out: BootFont[] = []
62
+ for (const entry of m[1].split(";")) {
63
+ const eq = entry.indexOf("=")
64
+ if (eq === -1) continue
65
+ const tag = /^(.+)@(\d+)(i?)$/.exec(entry.slice(0, eq))
66
+ if (!tag) continue
67
+ out.push({ family: tag[1], weight: parseInt(tag[2], 10), italic: tag[3] === "i", url: entry.slice(eq + 1).replace("{fmt}", format) })
68
+ }
69
+ return out
70
+ }
71
+
72
+ /** Boot hold: start loading the app's declared faces, resolving when they settle or the budget
73
+ * expires (~2s, like font-display's block period but shorter) — the host delays first paint on
74
+ * this so text appears in the right font from the start. Faces are added to the document FIRST,
75
+ * so one that loses the race still swaps in when it lands (the degraded-network fallback). */
76
+ export const loadBootFonts = async (fonts: BootFont[], doc?: Document, budgetMs = 2000): Promise<void> => {
77
+ const target = doc ?? (typeof document === "undefined" ? undefined : document)
78
+ if (!target?.fonts || fonts.length === 0) return
79
+ const loads: Promise<unknown>[] = []
80
+ for (const f of fonts) {
81
+ try {
82
+ const face = new FontFace(f.family, `url("${f.url}")`, { weight: String(f.weight), style: f.italic ? "italic" : "normal" } as FontFaceDescriptors)
83
+ target.fonts.add(face)
84
+ loads.push(face.load().catch(() => { /* missing face → fallback, never a boot failure */ }))
85
+ } catch { /* malformed entry — skip */ }
86
+ }
87
+ await Promise.race([ Promise.all(loads), new Promise((res) => setTimeout(res, budgetMs)) ])
88
+ }
@@ -0,0 +1,128 @@
1
+
2
+ // Slots are addressed by id (their original index) and are never spliced — disposing a single
3
+ // player nulls its slot so every other id stays valid. `releasePlayer` does the actual teardown.
4
+ // Slot 0 stays empty: id 0 is the SDK's dead handle (sdk/src/core/pins.ts DEAD_HANDLE), and a
5
+ // player with that id would never get its end-of-playback report.
6
+ const players: (HTMLVideoElement | HTMLAudioElement | null)[] = [null]
7
+
8
+ // Looping is driven manually rather than through the element's native `loop` flag: a natively
9
+ // looping element never fires "ended", so we could not signal each loop boundary. Instead we keep
10
+ // the desired loop state per player, listen for "ended", invoke the SDK's finished callback, and —
11
+ // when looping — rewind and replay ourselves. `onFinished` lets the SDK decide loopReached vs
12
+ // completed based on its own loop flag.
13
+ const loopFlags: boolean[] = [false]
14
+
15
+ const attachFinished = (
16
+ player: HTMLVideoElement | HTMLAudioElement,
17
+ id: number,
18
+ onFinished: () => void
19
+ ) => {
20
+ player.addEventListener("ended", () => {
21
+ onFinished()
22
+ if (loopFlags[id]) {
23
+ player.currentTime = 0
24
+ player.play()
25
+ }
26
+ })
27
+ }
28
+
29
+ export const createVideoPlayer = (src: string, onFinished: () => void) => {
30
+ const player = document.createElement("video")
31
+ document.body.appendChild(player)
32
+ player.crossOrigin = "anonymous"
33
+ player.style = "opacity: 0; position: absolute; top: 0; left: 0; width: 1px; height: 1px"
34
+ player.src = src
35
+ player.playsInline = true
36
+ player.controls = false
37
+ const id = players.length
38
+ players.push(player)
39
+ loopFlags.push(false)
40
+ attachFinished(player, id, onFinished)
41
+ return id
42
+ }
43
+
44
+ export const createAudioPlayer = (src: string, onFinished: () => void) => {
45
+ const player = document.createElement("audio")
46
+ document.body.appendChild(player)
47
+ player.style = "opacity: 0; position: absolute; top: 0; left: 0; width: 1px; height: 1px"
48
+ player.src = src
49
+ const id = players.length
50
+ players.push(player)
51
+ loopFlags.push(false)
52
+ attachFinished(player, id, onFinished)
53
+ return id
54
+ }
55
+
56
+ export const updateMediaPlayerVolume = (id: number, volume: number) => {
57
+ const player = players[id]
58
+ if (!player) return
59
+ if (volume === 0) {
60
+ player.muted = true
61
+ } else {
62
+ if (player.muted) {
63
+ player.muted = false
64
+ }
65
+ player.volume = volume
66
+ }
67
+ }
68
+
69
+ export const updateMediaPlayerPlaying = (id: number, play: boolean) => {
70
+ const player = players[id]
71
+ if (!player) return
72
+ if (play){
73
+ player.play()
74
+ } else {
75
+ player.pause()
76
+ }
77
+ }
78
+
79
+ export const updateMediaPlayerLoop = (id: number, loop: boolean) => {
80
+ // Tracked for the "ended" handler; the native loop flag stays off so "ended" keeps firing.
81
+ loopFlags[id] = loop
82
+ }
83
+
84
+ export const getMediaPlayerTime = (id: number) => {
85
+ return players[id]?.currentTime ?? 0
86
+ }
87
+
88
+ export const setMediaPlayerTime = (id: number, time: number) => {
89
+ const player = players[id]
90
+ if (player) player.currentTime = time
91
+ }
92
+
93
+ export const getMediaPlayerDuration = (id: number) => {
94
+ // NaN before metadata loads — normalise to 0 so callers get a clean number.
95
+ const duration = players[id]?.duration ?? 0
96
+ return isNaN(duration) ? 0 : duration
97
+ }
98
+
99
+ export const getPlayer = (id: number) => {
100
+ return players[id]
101
+ }
102
+
103
+ // Tear down one element's media pipeline (decoder, buffering, network) and detach it from the DOM.
104
+ const releasePlayer = (player: HTMLVideoElement | HTMLAudioElement) => {
105
+ // Removing the element from the DOM alone keeps the media pipeline alive. Pause, drop the source
106
+ // and load() to release it before detaching.
107
+ player.pause()
108
+ player.removeAttribute("src")
109
+ player.load()
110
+ player.remove()
111
+ }
112
+
113
+ export const removeMediaPlayer = (id: number) => {
114
+ const player = players[id]
115
+ if (!player) return
116
+ releasePlayer(player)
117
+ // Null the slot rather than splice, so all other ids keep pointing at the right element.
118
+ players[id] = null
119
+ loopFlags[id] = false
120
+ }
121
+
122
+ export const removePlayers = () => {
123
+ for (let player of players) {
124
+ if (player) releasePlayer(player)
125
+ }
126
+ players.length = 1 // keep the reserved slot 0
127
+ loopFlags.length = 1
128
+ }
@@ -0,0 +1,61 @@
1
+ export type SvgSrc = { svg: string; tintColor?: string | null }
2
+ export type ImageSrc = string | { _id: number } | SvgSrc
3
+
4
+ export function isSvgSrc(src: ImageSrc | undefined | null): src is SvgSrc {
5
+ return typeof src === "object" && src !== null && "svg" in src
6
+ }
7
+
8
+ // A SvgSource carries its own source-level tint (`SvgSource(x).tintColor = "#.."`), a second channel
9
+ // distinct from the style-level `tintColor`. The native hosts honour it; so must the web renderer.
10
+ // Returns the source tint string, or undefined when there is none.
11
+ export function svgSrcTint(src: ImageSrc | undefined | null): string | undefined {
12
+ return isSvgSrc(src) && typeof src.tintColor === "string" && src.tintColor.length > 0
13
+ ? src.tintColor
14
+ : undefined
15
+ }
16
+
17
+ export function convertSvgToDataUri(svg: string): string {
18
+ let s = svg.trim()
19
+
20
+ // A data: URI is parsed as a standalone XML document — xmlns is mandatory there.
21
+ // Inline <svg> in the DOM tolerates its absence; a data-uri image without it renders broken.
22
+ if (!/\sxmlns\s*=/.test(s)) {
23
+ s = s.replace(/<svg\b/, '<svg xmlns="http://www.w3.org/2000/svg"')
24
+ }
25
+
26
+ const bytes = new TextEncoder().encode(s)
27
+ let binary = ""
28
+ for (const byte of bytes) binary += String.fromCharCode(byte)
29
+ return `data:image/svg+xml;base64,${btoa(binary)}`
30
+ }
31
+
32
+ export function extractViewBox(svg: string): string | null {
33
+ const m = svg.match(/viewBox\s*=\s*["']([^"']+)["']/)
34
+ return m ? m[1] : null
35
+ }
36
+
37
+ // SVG + tintColor renders as a masked <div>: an <img> can't be recolored by CSS.
38
+ // The mask takes the SVG's alpha channel and fills it with a single background color —
39
+ // which is exactly the "replace all fill and stroke with one color" semantics of tintColor,
40
+ // and leaves fill="none" transparent on its own (no shape gets accidentally filled).
41
+ export function applySvgMask(el: HTMLElement, svg: string, tint: string): void {
42
+ const s = el.style as any
43
+ const cssUrl = `url("${convertSvgToDataUri(svg)}")`
44
+
45
+ s.webkitMaskImage = s.maskImage = cssUrl
46
+ s.webkitMaskRepeat = s.maskRepeat = "no-repeat"
47
+ s.webkitMaskPosition = s.maskPosition = "center"
48
+ // objectFit overrides this via applyStyle (contain | cover | "100% 100%")
49
+ if (!s.maskSize) s.webkitMaskSize = s.maskSize = "contain"
50
+
51
+ el.style.backgroundColor = tint
52
+
53
+ const vb = extractViewBox(svg)
54
+ if (vb) el.setAttribute("data-viewbox", vb)
55
+ }
56
+
57
+ export function clearSvgMask(el: HTMLElement): void {
58
+ const s = el.style as any
59
+ s.webkitMaskImage = s.maskImage = ""
60
+ s.backgroundColor = ""
61
+ }
@@ -0,0 +1 @@
1
+ /// <reference types="vite/client" />