@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
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@bycrux/editor",
3
- "version": "0.7.0",
3
+ "version": "0.7.2",
4
4
  "private": false,
5
5
  "type": "module",
6
6
  "exports": {
@@ -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
+ })
@@ -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
- if (!googleFonts?.length) return
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?${googleFonts.map((f) => `family=${f}`).join('&')}&display=swap`
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
- if (video.currentTime >= outPoint) {
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, handleTimelineWheel } = useTimelineZoom(totalDuration)
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" onWheel={handleTimelineWheel}>
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
- function handleTimelineWheel(e: React.WheelEvent) {
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
- return { zoom, zoomRef, scrollRef, pendingScrollRef, zoomTo, handleTimelineWheel }
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
  }