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.
- package/package.json +22 -0
- package/src/assets/Roboto-OFL.txt +93 -0
- package/src/assets/Roboto-Variable.woff2 +0 -0
- package/src/canvas/createCanvasHost.ts +103 -0
- package/src/canvas/gen/drawlist.ts +138 -0
- package/src/canvas/gen/painter.ts +176 -0
- package/src/canvas/gen/spec.ts +201 -0
- package/src/sourcemapError.ts +178 -0
- package/src/ui/nativeView.ts +12 -0
- package/src/ui/safeAreaSpec.ts +30 -0
- package/src/ui/viewScale.ts +19 -0
- package/src/utils/fetch.ts +162 -0
- package/src/utils/fonts.ts +88 -0
- package/src/utils/mediaplayer.ts +128 -0
- package/src/utils/svg.ts +61 -0
- package/src/vite-env.d.ts +1 -0
|
@@ -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
|
+
}
|
package/src/utils/svg.ts
ADDED
|
@@ -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" />
|