@bycrux/editor 0.7.0 → 0.7.2
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
CHANGED
|
@@ -0,0 +1,56 @@
|
|
|
1
|
+
import { describe, it, expect, beforeEach } from 'vitest'
|
|
2
|
+
import { ensureGoogleFontsLoaded } from '../google-fonts'
|
|
3
|
+
|
|
4
|
+
// Each test injects fonts that have not been requested by any prior test so the
|
|
5
|
+
// module-level dedupe Set never short-circuits the <link> append we assert on.
|
|
6
|
+
function injectedHrefs(): string[] {
|
|
7
|
+
return Array.from(document.head.querySelectorAll('link[rel="stylesheet"]')).map(
|
|
8
|
+
(l) => (l as HTMLLinkElement).href,
|
|
9
|
+
)
|
|
10
|
+
}
|
|
11
|
+
|
|
12
|
+
beforeEach(() => {
|
|
13
|
+
document.head.querySelectorAll('link[rel="stylesheet"]').forEach((l) => l.remove())
|
|
14
|
+
})
|
|
15
|
+
|
|
16
|
+
describe('ensureGoogleFontsLoaded', () => {
|
|
17
|
+
it('accepts a proper string[] and appends one <link> with each family', () => {
|
|
18
|
+
ensureGoogleFontsLoaded(['Syne:wght@800', 'Inter:wght@400'])
|
|
19
|
+
const hrefs = injectedHrefs()
|
|
20
|
+
expect(hrefs).toHaveLength(1)
|
|
21
|
+
expect(hrefs[0]).toContain('family=Syne:wght@800')
|
|
22
|
+
expect(hrefs[0]).toContain('family=Inter:wght@400')
|
|
23
|
+
})
|
|
24
|
+
|
|
25
|
+
it('does NOT throw and loads family=Anton when given a bare string "Anton"', () => {
|
|
26
|
+
// Regression: a non-empty string used to pass the `.length` guard and then
|
|
27
|
+
// `.map` threw "n.map is not a function", breaking the overlay layer.
|
|
28
|
+
expect(() => ensureGoogleFontsLoaded('Anton' as unknown as string[])).not.toThrow()
|
|
29
|
+
const hrefs = injectedHrefs()
|
|
30
|
+
expect(hrefs).toHaveLength(1)
|
|
31
|
+
expect(hrefs[0]).toContain('family=Anton')
|
|
32
|
+
})
|
|
33
|
+
|
|
34
|
+
it('splits a comma-separated string into multiple families', () => {
|
|
35
|
+
ensureGoogleFontsLoaded('Anton,Inter:wght@400' as unknown as string[])
|
|
36
|
+
const hrefs = injectedHrefs()
|
|
37
|
+
expect(hrefs).toHaveLength(1)
|
|
38
|
+
expect(hrefs[0]).toContain('family=Anton')
|
|
39
|
+
expect(hrefs[0]).toContain('family=Inter:wght@400')
|
|
40
|
+
})
|
|
41
|
+
|
|
42
|
+
it('is a no-op for empty string, undefined, and []', () => {
|
|
43
|
+
ensureGoogleFontsLoaded('' as unknown as string[])
|
|
44
|
+
ensureGoogleFontsLoaded(undefined)
|
|
45
|
+
ensureGoogleFontsLoaded([])
|
|
46
|
+
expect(injectedHrefs()).toHaveLength(0)
|
|
47
|
+
})
|
|
48
|
+
|
|
49
|
+
it('trims whitespace and drops empty entries from a comma string', () => {
|
|
50
|
+
ensureGoogleFontsLoaded(' Anton , , Inter ' as unknown as string[])
|
|
51
|
+
const hrefs = injectedHrefs()
|
|
52
|
+
expect(hrefs).toHaveLength(1)
|
|
53
|
+
expect(hrefs[0]).toContain('family=Anton')
|
|
54
|
+
expect(hrefs[0]).toContain('family=Inter')
|
|
55
|
+
})
|
|
56
|
+
})
|
package/src/lib/google-fonts.ts
CHANGED
|
@@ -13,11 +13,22 @@
|
|
|
13
13
|
// document.head across long editing sessions.
|
|
14
14
|
const __injectedFontUrls = new Set<string>()
|
|
15
15
|
|
|
16
|
-
export function ensureGoogleFontsLoaded(googleFonts: string[] | undefined): void {
|
|
17
|
-
|
|
16
|
+
export function ensureGoogleFontsLoaded(googleFonts: string[] | string | undefined): void {
|
|
17
|
+
// Defensive coercion: persisted project items have occasionally stored the
|
|
18
|
+
// `googleFonts` field as a bare string (e.g. "Anton") instead of the typed
|
|
19
|
+
// string[] (["Anton"]). A non-empty string passes a naive `.length` guard and
|
|
20
|
+
// then `.map` throws "n.map is not a function", which surfaces in the editor
|
|
21
|
+
// as a cryptic "overlay error: <file>.jsx" and breaks the whole overlay layer.
|
|
22
|
+
// Coerce a string into a family list (supporting comma-separated values) and
|
|
23
|
+
// bail on anything that isn't a non-empty array.
|
|
24
|
+
const families =
|
|
25
|
+
typeof googleFonts === 'string'
|
|
26
|
+
? googleFonts.split(',').map((s) => s.trim()).filter(Boolean)
|
|
27
|
+
: googleFonts
|
|
28
|
+
if (!Array.isArray(families) || !families.length) return
|
|
18
29
|
// Match the format bundle.js uses for the render pipeline so preview and
|
|
19
30
|
// render fetch identical CSS (and identical glyphs / metrics).
|
|
20
|
-
const url = `https://fonts.googleapis.com/css2?${
|
|
31
|
+
const url = `https://fonts.googleapis.com/css2?${families.map((f) => `family=${f}`).join('&')}&display=swap`
|
|
21
32
|
if (__injectedFontUrls.has(url)) return
|
|
22
33
|
__injectedFontUrls.add(url)
|
|
23
34
|
if (typeof document === 'undefined') return
|
|
@@ -702,7 +702,13 @@ export function useVideoPlayback(
|
|
|
702
702
|
}
|
|
703
703
|
}
|
|
704
704
|
|
|
705
|
-
|
|
705
|
+
// A normalized cache can encode a few ms SHORTER than its computed window
|
|
706
|
+
// (outPoint − inPoint), so the <video> reaches its natural end (`ended`)
|
|
707
|
+
// before currentTime ever reaches outPoint. Treat EOF as reaching the
|
|
708
|
+
// boundary too — otherwise the clip switch never fires and playback stalls
|
|
709
|
+
// at that clip's end (raw full-length sources never hit this; trimmed
|
|
710
|
+
// window caches can).
|
|
711
|
+
if (video.currentTime >= outPoint || video.ended) {
|
|
706
712
|
if (clip.loop) {
|
|
707
713
|
const projectT = clip.start + loopOffsetRef.current + (video.currentTime - clipInPoint)
|
|
708
714
|
if (projectT < clip.end) {
|
|
@@ -131,7 +131,7 @@ export default function Timeline({ project, currentTime, onTimeUpdate, onProject
|
|
|
131
131
|
|
|
132
132
|
const [subcutClipId, setSubcutClipId] = useState<string | null>(null)
|
|
133
133
|
|
|
134
|
-
const { zoom, zoomRef, scrollRef, zoomTo
|
|
134
|
+
const { zoom, zoomRef, scrollRef, zoomTo } = useTimelineZoom(totalDuration)
|
|
135
135
|
|
|
136
136
|
useEffect(() => {
|
|
137
137
|
if (totalDuration === 0) return
|
|
@@ -245,7 +245,7 @@ export default function Timeline({ project, currentTime, onTimeUpdate, onProject
|
|
|
245
245
|
)}
|
|
246
246
|
|
|
247
247
|
{/* Scroll container for zoomed tracks */}
|
|
248
|
-
<div ref={scrollRef} className="overflow-x-auto"
|
|
248
|
+
<div ref={scrollRef} className="overflow-x-auto">
|
|
249
249
|
<div style={{ width: zoom > 1 ? `${zoom * 100}%` : '100%' }} className="min-w-full">
|
|
250
250
|
|
|
251
251
|
{/* Scrubber + tracks wrapped in a relative container so the hover indicator spans the full height */}
|
|
@@ -1,4 +1,4 @@
|
|
|
1
|
-
import { useLayoutEffect, useRef, useState } from 'react'
|
|
1
|
+
import { useEffect, useLayoutEffect, useRef, useState } from 'react'
|
|
2
2
|
|
|
3
3
|
export function useTimelineZoom(totalDuration: number) {
|
|
4
4
|
const [zoom, setZoom] = useState(1)
|
|
@@ -31,7 +31,14 @@ export function useTimelineZoom(totalDuration: number) {
|
|
|
31
31
|
setZoom(clamped)
|
|
32
32
|
}
|
|
33
33
|
|
|
34
|
-
|
|
34
|
+
// ⌘/Ctrl + wheel = zoom; Alt + wheel = horizontal scroll. Both call
|
|
35
|
+
// preventDefault, so the listener MUST be non-passive. React's onWheel prop is
|
|
36
|
+
// registered passive at the root, which makes preventDefault a no-op and spams
|
|
37
|
+
// "Unable to preventDefault inside passive event listener" — so attach a native
|
|
38
|
+
// listener with { passive: false } instead. A ref to the latest handler keeps
|
|
39
|
+
// it current without re-binding on every render.
|
|
40
|
+
const wheelHandlerRef = useRef<(e: WheelEvent) => void>(() => {})
|
|
41
|
+
wheelHandlerRef.current = (e: WheelEvent) => {
|
|
35
42
|
if (e.ctrlKey || e.metaKey) {
|
|
36
43
|
e.preventDefault()
|
|
37
44
|
// Multiplicative step so perceived speed is consistent across zoom levels.
|
|
@@ -44,5 +51,13 @@ export function useTimelineZoom(totalDuration: number) {
|
|
|
44
51
|
}
|
|
45
52
|
}
|
|
46
53
|
|
|
47
|
-
|
|
54
|
+
useEffect(() => {
|
|
55
|
+
const el = scrollRef.current
|
|
56
|
+
if (!el) return
|
|
57
|
+
const onWheel = (e: WheelEvent) => wheelHandlerRef.current(e)
|
|
58
|
+
el.addEventListener('wheel', onWheel, { passive: false })
|
|
59
|
+
return () => el.removeEventListener('wheel', onWheel)
|
|
60
|
+
}, [])
|
|
61
|
+
|
|
62
|
+
return { zoom, zoomRef, scrollRef, pendingScrollRef, zoomTo }
|
|
48
63
|
}
|