@bycrux/editor 1.1.0 → 1.2.1
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 +2 -2
- package/src/ControlsInfoModal.tsx +1 -0
- package/src/engine/__tests__/scheduler.test.ts +56 -1
- package/src/engine/scheduler.ts +23 -5
- package/src/index.ts +36 -0
- package/src/lib/__tests__/font-faces.test.ts +244 -0
- package/src/lib/__tests__/font-loader-parity.test.tsx +236 -0
- package/src/lib/__tests__/google-fonts.test.ts +319 -2
- package/src/lib/font-families.ts +286 -0
- package/src/lib/google-fonts.ts +132 -10
- package/src/schema.ts +21 -0
- package/src/text/FontPicker.tsx +82 -11
- package/src/text/__tests__/FontPicker.baseUrl.test.tsx +112 -0
- package/src/video/VersionPanel.tsx +1 -1
- package/src/video/VideoEditor.tsx +98 -4
- package/src/video/__tests__/VideoEditor.keymap.test.tsx +64 -0
- package/src/video/__tests__/cuts.test.ts +84 -0
- package/src/video/__tests__/exportDurationSec.test.ts +60 -0
- package/src/video/__tests__/markerDropTime.test.ts +25 -0
- package/src/video/cuts.ts +30 -2
- package/src/video/preview/PreviewPlayer.tsx +52 -2
- package/src/video/preview/__tests__/useVideoPlayback.canvasClock.test.ts +99 -0
- package/src/video/preview/__tests__/useVideoPlayback.muted.test.ts +239 -0
- package/src/video/preview/useEnginePlayback.ts +58 -8
- package/src/video/preview/useVideoPlayback.ts +64 -17
- package/src/video/timeline/Timeline.tsx +6 -0
- package/src/video/timeline/__tests__/Timeline.keymap.test.tsx +16 -0
- package/src/video/timeline/__tests__/markers.test.ts +125 -0
- package/src/video/timeline/canvas/TimelineCanvas.tsx +110 -12
- package/src/video/timeline/canvas/__tests__/TimelineCanvas.edgeScroll.test.tsx +38 -7
- package/src/video/timeline/canvas/__tests__/TimelineCanvas.test.tsx +106 -2
- package/src/video/timeline/canvas/__tests__/draw.test.ts +73 -0
- package/src/video/timeline/canvas/__tests__/hit-test.test.ts +76 -0
- package/src/video/timeline/canvas/__tests__/pointer-machine.test.ts +100 -1
- package/src/video/timeline/canvas/draw.ts +182 -8
- package/src/video/timeline/canvas/hit-test.ts +72 -1
- package/src/video/timeline/canvas/pointer-machine.ts +83 -4
- package/src/video/timeline/markers.ts +109 -0
- package/src/video/timeline/timeline-model.ts +19 -0
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@bycrux/editor",
|
|
3
|
-
"version": "1.1
|
|
3
|
+
"version": "1.2.1",
|
|
4
4
|
"private": false,
|
|
5
5
|
"type": "module",
|
|
6
6
|
"exports": {
|
|
@@ -17,7 +17,7 @@
|
|
|
17
17
|
"lint": "eslint src"
|
|
18
18
|
},
|
|
19
19
|
"dependencies": {
|
|
20
|
-
"@bycrux/timeline-core": "^0.2.
|
|
20
|
+
"@bycrux/timeline-core": "^0.2.1",
|
|
21
21
|
"class-variance-authority": "^0.7",
|
|
22
22
|
"clsx": "^2",
|
|
23
23
|
"lucide-react": "^0.400",
|
|
@@ -309,6 +309,7 @@ export const VIDEO_CONTROLS: ControlSection[] = [
|
|
|
309
309
|
heading: 'Keyboard',
|
|
310
310
|
entries: [
|
|
311
311
|
{ keys: ['S'], label: 'Split at the playhead' },
|
|
312
|
+
{ keys: ['M'], label: 'Drop a marker at the playhead (or the preview axis)' },
|
|
312
313
|
{ keys: ['⌘/Ctrl', 'A'], label: 'Toggle the preview axis' },
|
|
313
314
|
{ keys: ['⇧', 'Delete'], label: 'Ripple-delete the selection' },
|
|
314
315
|
{ keys: ['⌘/Ctrl', 'Z'], label: 'Undo' },
|
|
@@ -483,7 +483,27 @@ describe('transportEndFor', () => {
|
|
|
483
483
|
expect(transportEndFor(p)).toBe(8)
|
|
484
484
|
})
|
|
485
485
|
|
|
486
|
-
it('
|
|
486
|
+
it('counts track 0 in a canvas project — an overlay-only single track is not a zero-length transport', () => {
|
|
487
|
+
// The shape an animations-workflow project actually has: ONE track, holding
|
|
488
|
+
// nothing but overlays, and no captions. Reading the ceiling off
|
|
489
|
+
// `tracks.slice(1)` made this 0, so play/space started the transport and
|
|
490
|
+
// stopped it in the same tick — the picture never moved.
|
|
491
|
+
const p = project([overlay('o1', 0, 5), overlay('o2', 5, 12)])
|
|
492
|
+
expect(p.tracks).toHaveLength(1)
|
|
493
|
+
expect(transportEndFor(p)).toBe(12)
|
|
494
|
+
})
|
|
495
|
+
|
|
496
|
+
it('counts track 0 images in a canvas project, even past the overlay tracks', () => {
|
|
497
|
+
// Same defect, other content kind: a background image outlasting every
|
|
498
|
+
// overlay used to be invisible to the ceiling.
|
|
499
|
+
const p = project(
|
|
500
|
+
[{ id: 'img', type: 'image', src: '/a.png', start: 0, end: 9 }],
|
|
501
|
+
[overlay('o', 0, 4)],
|
|
502
|
+
)
|
|
503
|
+
expect(transportEndFor(p)).toBe(9)
|
|
504
|
+
})
|
|
505
|
+
|
|
506
|
+
it('uses max(visualEnd, captionEnd) for a canvas project — audio EXCLUDED', () => {
|
|
487
507
|
// The legacy canvas rAF's ceiling is `canvasMaxEndRef`, which really does
|
|
488
508
|
// leave audio out. Faithful, not unified.
|
|
489
509
|
const p = project([{ id: 'img', type: 'image', src: '/a.png', start: 0, end: 3 }], [
|
|
@@ -494,6 +514,41 @@ describe('transportEndFor', () => {
|
|
|
494
514
|
})
|
|
495
515
|
expect(transportEndFor(p)).toBe(6)
|
|
496
516
|
})
|
|
517
|
+
|
|
518
|
+
it('falls back to the audio end when NOTHING visual sets a ceiling', () => {
|
|
519
|
+
// Audio-only timeline: the shape an animations-workflow project has while
|
|
520
|
+
// the music is wired but no overlay exists yet. The ceiling was 0, so the
|
|
521
|
+
// transport started and stopped on the same tick and play did nothing at
|
|
522
|
+
// all — with no feedback saying why.
|
|
523
|
+
const p = project([], [], {
|
|
524
|
+
audio: { tracks: [{ id: 'm', src: '/m.wav', start: 0, end: 15 }] },
|
|
525
|
+
})
|
|
526
|
+
expect(transportEndFor(p)).toBe(15)
|
|
527
|
+
})
|
|
528
|
+
|
|
529
|
+
it('resolves an audio-only track carrying no explicit end to its natural length', () => {
|
|
530
|
+
const p = project([], [], {
|
|
531
|
+
// `AudioTrack` types `start`/`end` as required, but project.json files on
|
|
532
|
+
// disk routinely omit them — `resolveAudioWindow` exists precisely to cope
|
|
533
|
+
// at runtime. That gap between the type and the real shape is what this
|
|
534
|
+
// test pins, so the under-specified track is the point, not a shortcut.
|
|
535
|
+
// @ts-expect-error — deliberately the under-specified on-disk shape
|
|
536
|
+
audio: { tracks: [{ id: 'm', src: '/m.wav', sourceDuration: 15 }] },
|
|
537
|
+
})
|
|
538
|
+
expect(transportEndFor(p)).toBe(15)
|
|
539
|
+
})
|
|
540
|
+
|
|
541
|
+
it('skips muted tracks in the audio-only fallback', () => {
|
|
542
|
+
const p = project([], [], {
|
|
543
|
+
audio: {
|
|
544
|
+
tracks: [
|
|
545
|
+
{ id: 'a', src: '/a.wav', start: 0, end: 30, muted: true },
|
|
546
|
+
{ id: 'b', src: '/b.wav', start: 0, end: 15 },
|
|
547
|
+
],
|
|
548
|
+
},
|
|
549
|
+
})
|
|
550
|
+
expect(transportEndFor(p)).toBe(15)
|
|
551
|
+
})
|
|
497
552
|
})
|
|
498
553
|
|
|
499
554
|
describe('engineSrcFor — proxy-only playback is structural', () => {
|
package/src/engine/scheduler.ts
CHANGED
|
@@ -83,7 +83,7 @@ import type { ItemCrossfade, Scene, SourceWindow } from '@bycrux/timeline-core'
|
|
|
83
83
|
import type { EditorProject as Project, VisualItem, VisualTrack } from '../schema'
|
|
84
84
|
import type { ClipTimebase, MasterClock } from './audio-clock'
|
|
85
85
|
import type { FrameServer } from './frame-server'
|
|
86
|
-
import { effectiveItemAudio, enabledTrackItems, enabledTracks, withEnabledItemTracks } from '../video/timeline/timeline-model'
|
|
86
|
+
import { audioEnd, effectiveItemAudio, enabledTrackItems, enabledTracks, withEnabledItemTracks } from '../video/timeline/timeline-model'
|
|
87
87
|
|
|
88
88
|
// ── Tuning constants ────────────────────────────────────────────────────────
|
|
89
89
|
|
|
@@ -517,9 +517,9 @@ function withTrackAudio(track: VisualTrack | undefined, item: VisualItem): Visua
|
|
|
517
517
|
* Two formulas, because the legacy hook has two and they legitimately differ:
|
|
518
518
|
*
|
|
519
519
|
* - **Canvas projects** (no track-0 video) run the `isCanvasProject` rAF,
|
|
520
|
-
* whose ceiling is `canvasMaxEndRef` = `max(
|
|
520
|
+
* whose ceiling is `canvasMaxEndRef` = `max(visualEnd, captionEnd)`. Note
|
|
521
521
|
* what is NOT in it: audio. A canvas project whose music outlasts its
|
|
522
|
-
*
|
|
522
|
+
* visuals stops at the visuals today.
|
|
523
523
|
* - **Video projects** use `projectEnd` = `max(videoEnd, overlayEnd,
|
|
524
524
|
* audioEnd)` — captions excluded, audio included — which timeline-core
|
|
525
525
|
* already ports verbatim (including its two documented faithfulness warts:
|
|
@@ -527,18 +527,36 @@ function withTrackAudio(track: VisualTrack | undefined, item: VisualItem): Visua
|
|
|
527
527
|
*
|
|
528
528
|
* Unifying them would be a behavior change in one mode or the other, so both
|
|
529
529
|
* are kept and the divergence is named here rather than smoothed over.
|
|
530
|
+
*
|
|
531
|
+
* `visualEnd` spans EVERY enabled track, track 0 included — it is deliberately
|
|
532
|
+
* not the video path's `overlayEnd` (`tracks.slice(1)`). In canvas mode track 0
|
|
533
|
+
* is a content track like any other: it carries the background images, and an
|
|
534
|
+
* agent-authored project can put its overlays there too (an animations-workflow
|
|
535
|
+
* project is frequently ONE track holding nothing but overlays). Skipping it
|
|
536
|
+
* made the ceiling collapse to 0 for exactly those projects, so play/space
|
|
537
|
+
* started the transport and stopped it in the same tick. `OverlayItemsLayer`
|
|
538
|
+
* has always drawn track 0 in canvas mode (`isCanvasProject ? enabledTrackItems
|
|
539
|
+
* : overlayTracks`); this is the transport agreeing with what is on screen.
|
|
530
540
|
*/
|
|
531
541
|
export function transportEndFor(project: Project): number {
|
|
532
542
|
const clips = track0VideoItems(project)
|
|
533
543
|
if (clips.length > 0) return timelineProjectEnd(withEnabledItemTracks(project))
|
|
534
|
-
const
|
|
544
|
+
const visualEnd = enabledTrackItems(project)
|
|
535
545
|
.flat()
|
|
536
546
|
.reduce((m, i) => Math.max(m, i?.end ?? 0), 0)
|
|
537
547
|
const captionEnd = (project.captions?.segments ?? []).reduce(
|
|
538
548
|
(m: number, s) => Math.max(m, s.end ?? 0),
|
|
539
549
|
0,
|
|
540
550
|
)
|
|
541
|
-
|
|
551
|
+
const ceiling = Math.max(visualEnd, captionEnd)
|
|
552
|
+
if (ceiling > 0) return ceiling
|
|
553
|
+
// Nothing visual sets a ceiling: an audio-only timeline. Returning 0 here
|
|
554
|
+
// started the transport and stopped it in the same tick, so play did nothing
|
|
555
|
+
// at all. Audio is otherwise deliberately kept out of the ceiling (the
|
|
556
|
+
// canvas/video divergence over the audio tail, above) — this is the last
|
|
557
|
+
// resort that only fires when there is no visual content to measure.
|
|
558
|
+
// Mirrored in `canvasMaxEndRef` (video/preview/useVideoPlayback.ts).
|
|
559
|
+
return audioEnd(project)
|
|
542
560
|
}
|
|
543
561
|
|
|
544
562
|
/**
|
package/src/index.ts
CHANGED
|
@@ -19,6 +19,7 @@ export type {
|
|
|
19
19
|
CarouselElement,
|
|
20
20
|
Slide,
|
|
21
21
|
EditorProject,
|
|
22
|
+
Marker,
|
|
22
23
|
} from './schema'
|
|
23
24
|
|
|
24
25
|
// ── Contracts (adapter, theme, render, media, component props) ────────────────
|
|
@@ -103,6 +104,9 @@ export {
|
|
|
103
104
|
// "land where you dropped it, without stomping existing footage".
|
|
104
105
|
export { placeDroppedClip, resolveDropTrackIndex } from './video/timeline/placement'
|
|
105
106
|
export type { DroppedClipPlacement, PlacedClipResult } from './video/timeline/placement'
|
|
107
|
+
// Marker model — pure mutations over `project.markers` (see markers.ts's file
|
|
108
|
+
// header for the "same reference when unchanged" contract they all share).
|
|
109
|
+
export { addMarker, moveMarker, renameMarker, removeMarkers, nextMarkerLabel } from './video/timeline/markers'
|
|
106
110
|
|
|
107
111
|
// ── Image tone (HDR image color mapping) ─────────────────────────────────────
|
|
108
112
|
// The picker component is exported so hosts using `onProvideImageTone` can
|
|
@@ -165,6 +169,38 @@ export {
|
|
|
165
169
|
FontSizePicker,
|
|
166
170
|
} from './text/FontPicker'
|
|
167
171
|
export type { FontOption } from './text/FontPicker'
|
|
172
|
+
// Two independent Google Fonts base-URL setters, one per loader — see each
|
|
173
|
+
// file's own header for why there are two. `setFontsBaseUrl` (from
|
|
174
|
+
// `lib/google-fonts.ts`) covers the broader surface: captions, overlays and
|
|
175
|
+
// the timeline/overlay preview. `setPickerFontsBaseUrl` (renamed at this
|
|
176
|
+
// export boundary only — the source file still calls it `setFontsBaseUrl`,
|
|
177
|
+
// same as the other loader) covers just the font-family picker's own
|
|
178
|
+
// preview list. A host must call BOTH to eliminate Google Fonts egress
|
|
179
|
+
// entirely; calling only one still leaves the other loader fetching from
|
|
180
|
+
// fonts.googleapis.com.
|
|
181
|
+
//
|
|
182
|
+
// Both take the same two arguments: `(url, families)`. `families` is the list
|
|
183
|
+
// of families the stylesheet at `url` declares — the host reads its own
|
|
184
|
+
// `families.json` (or equivalent) ONCE at app init and passes the result to
|
|
185
|
+
// both setters. It is deliberately not fetched inside the loaders: they are
|
|
186
|
+
// synchronous and called from effects, and a pending fetch would leave them
|
|
187
|
+
// unable to decide the partition at the moment they have to act on it.
|
|
188
|
+
//
|
|
189
|
+
// **Passing a url with no families means NOTHING is treated as vendored** —
|
|
190
|
+
// the vendored stylesheet is not linked and every family is fetched from
|
|
191
|
+
// Google. That is the safe direction: glyphs stay correct and preview and
|
|
192
|
+
// render still agree, at the cost of the egress the base exists to remove.
|
|
193
|
+
// Assuming the stylesheet covers everything would instead preview unvendored
|
|
194
|
+
// families as a system fallback while the renderer — reading its own manifest
|
|
195
|
+
// off local disk — got them right, and nothing on screen would say so.
|
|
196
|
+
//
|
|
197
|
+
// `vendoredFamiliesDigest()` returns a short fingerprint of the family list in
|
|
198
|
+
// force. The renderers log the same digest for the manifest they read; two
|
|
199
|
+
// different strings mean the two sides are partitioning against different
|
|
200
|
+
// vendored sets, and captions will differ between editing and export.
|
|
201
|
+
export { setFontsBaseUrl, vendoredFamiliesDigest } from './lib/google-fonts'
|
|
202
|
+
export { setFontsBaseUrl as setPickerFontsBaseUrl } from './text/FontPicker'
|
|
203
|
+
export { fontFamilyKey, familiesDigest, partitionFontSpecs, vendoredKeySet } from './lib/font-families'
|
|
168
204
|
export { InlineTextEditor } from './text/InlineTextEditor'
|
|
169
205
|
export type { InlineTextEditorProps } from './text/InlineTextEditor'
|
|
170
206
|
export {
|
|
@@ -0,0 +1,244 @@
|
|
|
1
|
+
import { describe, it, expect, beforeEach, afterEach, vi } from 'vitest'
|
|
2
|
+
import { ensureGoogleFontsLoaded, setFontsBaseUrl } from '../google-fonts'
|
|
3
|
+
import { requiredFaces, specFacesAvailable, vendoredFaceIndex } from '../font-families'
|
|
4
|
+
|
|
5
|
+
// THE FACE-LEVEL PARTITION (L1).
|
|
6
|
+
//
|
|
7
|
+
// The vendored set is family + STYLE + WEIGHT, not family. `fonts.css` carries
|
|
8
|
+
// only the faces the vendoring pass actually received — every face is
|
|
9
|
+
// `font-style: normal`, and the weights are only the ones it asked for. A
|
|
10
|
+
// family-level partition therefore gets two shapes silently wrong:
|
|
11
|
+
//
|
|
12
|
+
// Playfair+Display:ital@1 — family vendored, no italic face exists, so the
|
|
13
|
+
// browser synthesises an oblique from the upright.
|
|
14
|
+
// Inter:wght@300 — family vendored, no 300 face exists, so the
|
|
15
|
+
// browser synthesises a light weight from the 400.
|
|
16
|
+
//
|
|
17
|
+
// Neither produces a failed request or a console error, and both change what
|
|
18
|
+
// the user sees. `skills/write-overlay/SKILL.md` uses the first as its own
|
|
19
|
+
// documented example.
|
|
20
|
+
//
|
|
21
|
+
// Every assertion here has a twin on the render side
|
|
22
|
+
// (`montaj_assets/render/test/fonts-fallthrough.test.mjs`). The two sides MUST
|
|
23
|
+
// partition identically — a caption laid out in one face while editing and
|
|
24
|
+
// another at export is the Syne bug that case study documents.
|
|
25
|
+
|
|
26
|
+
const N = (weight: number) => ({ style: 'normal', weight })
|
|
27
|
+
const I = (weight: number) => ({ style: 'italic', weight })
|
|
28
|
+
|
|
29
|
+
describe('requiredFaces: a spec resolves to the faces it asks Google for', () => {
|
|
30
|
+
for (const [spec, want] of [
|
|
31
|
+
// No axis list — Google serves the family default, which is normal 400.
|
|
32
|
+
['Anton', [N(400)]],
|
|
33
|
+
['Playfair Display', [N(400)]],
|
|
34
|
+
['Inter:wght@400;700', [N(400), N(700)]],
|
|
35
|
+
['Inter:wght@300', [N(300)]],
|
|
36
|
+
['Playfair+Display:ital@1', [I(400)]],
|
|
37
|
+
['Playfair+Display:ital@0', [N(400)]],
|
|
38
|
+
// The axes are named in one list and their values in another, positionally.
|
|
39
|
+
['Playfair+Display:ital,wght@1,700', [I(700)]],
|
|
40
|
+
['Playfair+Display:ital,wght@0,400;1,700', [N(400), I(700)]],
|
|
41
|
+
['Baloo+2:wght@400;500;600;700;800', [N(400), N(500), N(600), N(700), N(800)]],
|
|
42
|
+
] as [string, ReturnType<typeof N>[]][]) {
|
|
43
|
+
it(`${spec} → ${JSON.stringify(want)}`, () => {
|
|
44
|
+
expect(requiredFaces(spec)).toEqual(want)
|
|
45
|
+
})
|
|
46
|
+
}
|
|
47
|
+
|
|
48
|
+
// `null` means "I cannot parse this confidently" and the caller must fall
|
|
49
|
+
// through. Fetching a font we happen to have costs one request; silently
|
|
50
|
+
// dropping one we lack costs the author a wrong face in a finished export.
|
|
51
|
+
for (const spec of [
|
|
52
|
+
'Inter:', // a colon with no axis list
|
|
53
|
+
'Inter:wght', // an axis with no '@'
|
|
54
|
+
'Inter:wght@', // an '@' with no value
|
|
55
|
+
'Inter:wght@400;', // a trailing ';' leaves an empty tuple
|
|
56
|
+
'Inter:wght@100..900', // a variable RANGE is not a face
|
|
57
|
+
'Inter:ital@0..1',
|
|
58
|
+
'Inter:opsz@14', // an axis we do not model
|
|
59
|
+
'Inter:slnt@-10',
|
|
60
|
+
'Inter:GRAD@150',
|
|
61
|
+
'Inter:wght,opsz@400,14', // one modelled axis, one not
|
|
62
|
+
'Inter:wght,wght@400,700', // a duplicated axis
|
|
63
|
+
'Inter:ital,wght@1', // arity mismatch: two axes, one value
|
|
64
|
+
'Inter:wght@400@700', // two '@'
|
|
65
|
+
'Inter:ital@2', // ital is 0 or 1, nothing else
|
|
66
|
+
'Inter:wght@0', // out of range
|
|
67
|
+
'Inter:wght@abc',
|
|
68
|
+
'Inter:@400', // an empty axis name
|
|
69
|
+
]) {
|
|
70
|
+
it(`${spec} → null, so the caller falls through`, () => {
|
|
71
|
+
expect(requiredFaces(spec)).toBeNull()
|
|
72
|
+
})
|
|
73
|
+
}
|
|
74
|
+
})
|
|
75
|
+
|
|
76
|
+
describe('vendoredFaceIndex: `faces` and `requested` together say what is available', () => {
|
|
77
|
+
it('merges both maps — a weight in either one counts as available', () => {
|
|
78
|
+
// Bebas Neue publishes no 700, so we asked for it and got only the 400.
|
|
79
|
+
// Falling through would fetch a stylesheet that omits it identically.
|
|
80
|
+
const index = vendoredFaceIndex(
|
|
81
|
+
{ 'Bebas Neue': { normal: [400] } },
|
|
82
|
+
{ 'Bebas Neue': { normal: [400, 700] } },
|
|
83
|
+
)!
|
|
84
|
+
expect(specFacesAvailable('Bebas+Neue:wght@400;700', index)).toBe(true)
|
|
85
|
+
expect(specFacesAvailable('Bebas+Neue:wght@500', index)).toBe(false)
|
|
86
|
+
})
|
|
87
|
+
|
|
88
|
+
it('distinguishes NO face information from face information covering nothing', () => {
|
|
89
|
+
// Nothing supplied at all: the partition has nothing to refine against and
|
|
90
|
+
// must stay at family level, which the loader signals with `undefined`.
|
|
91
|
+
expect(vendoredFaceIndex(undefined, undefined)).toBeUndefined()
|
|
92
|
+
// An empty map IS information: it says nothing is available.
|
|
93
|
+
const empty = vendoredFaceIndex({}, undefined)
|
|
94
|
+
expect(empty).toBeInstanceOf(Map)
|
|
95
|
+
expect(specFacesAvailable('Inter', empty!)).toBe(false)
|
|
96
|
+
})
|
|
97
|
+
|
|
98
|
+
it('normalises family keys the way the family list does', () => {
|
|
99
|
+
const index = vendoredFaceIndex({ 'Open+Sans': { normal: [400] } })!
|
|
100
|
+
expect(specFacesAvailable('Open+Sans', index)).toBe(true)
|
|
101
|
+
expect(specFacesAvailable('OPEN SANS', index)).toBe(true)
|
|
102
|
+
})
|
|
103
|
+
|
|
104
|
+
it('skips malformed entries rather than throwing inside a font load', () => {
|
|
105
|
+
const index = vendoredFaceIndex({
|
|
106
|
+
Inter: { normal: [400, 'x' as unknown as number, null as unknown as number] },
|
|
107
|
+
Bad: null as unknown as { normal: number[] },
|
|
108
|
+
Worse: { normal: 'nope' as unknown as number[] },
|
|
109
|
+
})!
|
|
110
|
+
expect(specFacesAvailable('Inter', index)).toBe(true)
|
|
111
|
+
expect(specFacesAvailable('Bad', index)).toBe(false)
|
|
112
|
+
expect(specFacesAvailable('Worse', index)).toBe(false)
|
|
113
|
+
})
|
|
114
|
+
|
|
115
|
+
it('a family with no index entry is not covered', () => {
|
|
116
|
+
const index = vendoredFaceIndex({ Inter: { normal: [400] } })!
|
|
117
|
+
expect(specFacesAvailable('Anton', index)).toBe(false)
|
|
118
|
+
})
|
|
119
|
+
|
|
120
|
+
it('a spec it cannot parse is not covered, however complete the index', () => {
|
|
121
|
+
const index = vendoredFaceIndex({ Inter: { normal: [100, 200, 300, 400, 700, 900] } })!
|
|
122
|
+
expect(specFacesAvailable('Inter:wght@100..900', index)).toBe(false)
|
|
123
|
+
})
|
|
124
|
+
})
|
|
125
|
+
|
|
126
|
+
// ---------------------------------------------------------------------------
|
|
127
|
+
// Through the loader
|
|
128
|
+
// ---------------------------------------------------------------------------
|
|
129
|
+
|
|
130
|
+
function injectedHrefs(): string[] {
|
|
131
|
+
return Array.from(document.head.querySelectorAll('link[rel="stylesheet"]')).map(
|
|
132
|
+
(l) => (l as HTMLLinkElement).href,
|
|
133
|
+
)
|
|
134
|
+
}
|
|
135
|
+
|
|
136
|
+
// The injected-URL Set is module state that outlives a single test, so every
|
|
137
|
+
// base and every unvendored family below must be unique or a later test is
|
|
138
|
+
// served a suppressed <link> and passes for the wrong reason.
|
|
139
|
+
const B = (name: string) => `https://example.com/faces/${name}`
|
|
140
|
+
|
|
141
|
+
const REAL = {
|
|
142
|
+
faces: {
|
|
143
|
+
'Playfair Display': { normal: [400, 700] },
|
|
144
|
+
Inter: { normal: [400, 700] },
|
|
145
|
+
'Bebas Neue': { normal: [400] },
|
|
146
|
+
},
|
|
147
|
+
requested: {
|
|
148
|
+
'Playfair Display': { normal: [400, 700] },
|
|
149
|
+
Inter: { normal: [400, 700] },
|
|
150
|
+
'Bebas Neue': { normal: [400, 700] },
|
|
151
|
+
},
|
|
152
|
+
}
|
|
153
|
+
const FAMILIES = ['Playfair Display', 'Inter', 'Bebas Neue']
|
|
154
|
+
|
|
155
|
+
beforeEach(() => {
|
|
156
|
+
document.head.querySelectorAll('link[rel="stylesheet"]').forEach((l) => l.remove())
|
|
157
|
+
vi.spyOn(console, 'info').mockImplementation(() => {})
|
|
158
|
+
vi.spyOn(console, 'warn').mockImplementation(() => {})
|
|
159
|
+
})
|
|
160
|
+
afterEach(() => {
|
|
161
|
+
setFontsBaseUrl(undefined)
|
|
162
|
+
vi.restoreAllMocks()
|
|
163
|
+
})
|
|
164
|
+
|
|
165
|
+
describe('ensureGoogleFontsLoaded: the face refinement', () => {
|
|
166
|
+
for (const [spec, vendored, why] of [
|
|
167
|
+
['Playfair+Display:ital@1', false, 'no italic face is vendored'],
|
|
168
|
+
['Playfair+Display:ital,wght@1,700', false, 'italic 700 is not on disk either'],
|
|
169
|
+
['Playfair+Display:ital@0', true, 'ital@0 is normal 400, which IS on disk'],
|
|
170
|
+
['Inter:wght@300', false, '300 was never vendored'],
|
|
171
|
+
['Inter:wght@400;700', true, 'both weights are on disk'],
|
|
172
|
+
['Bebas+Neue:wght@400;700', true, 'Google publishes no 700, so asking again cannot help'],
|
|
173
|
+
['Bebas+Neue:wght@500', false, '500 was neither vendored nor asked for'],
|
|
174
|
+
] as [string, boolean, string][]) {
|
|
175
|
+
it(`${spec} is ${vendored ? 'served locally' : 'fetched from Google'} — ${why}`, () => {
|
|
176
|
+
const base = B(spec.replace(/\W/g, ''))
|
|
177
|
+
setFontsBaseUrl(base, FAMILIES, REAL)
|
|
178
|
+
ensureGoogleFontsLoaded([spec])
|
|
179
|
+
const hrefs = injectedHrefs()
|
|
180
|
+
if (vendored) {
|
|
181
|
+
expect(hrefs).toEqual([`${base}/fonts.css`])
|
|
182
|
+
} else {
|
|
183
|
+
expect(hrefs).toEqual([`https://fonts.googleapis.com/css2?family=${spec}&display=swap`])
|
|
184
|
+
}
|
|
185
|
+
})
|
|
186
|
+
}
|
|
187
|
+
|
|
188
|
+
it('a partially vendored spec falls through WHOLE, exactly as the author wrote it', () => {
|
|
189
|
+
// Splitting would mean synthesising a new spec string, and a spec is the
|
|
190
|
+
// author's — ours to honour or pass on untouched, never to rewrite.
|
|
191
|
+
const base = B('partial')
|
|
192
|
+
setFontsBaseUrl(base, FAMILIES, REAL)
|
|
193
|
+
ensureGoogleFontsLoaded(['Inter:wght@400;300'])
|
|
194
|
+
expect(injectedHrefs()).toEqual([
|
|
195
|
+
'https://fonts.googleapis.com/css2?family=Inter:wght@400;300&display=swap',
|
|
196
|
+
])
|
|
197
|
+
})
|
|
198
|
+
|
|
199
|
+
it('a MIX puts each spec on exactly one side, by face and not by family', () => {
|
|
200
|
+
const base = B('mix')
|
|
201
|
+
setFontsBaseUrl(base, FAMILIES, REAL)
|
|
202
|
+
// Same family on both sides: the 700 is vendored, the italic is not.
|
|
203
|
+
//
|
|
204
|
+
// The italic spec here is spelled `ital,wght@1,400` rather than `ital@1`
|
|
205
|
+
// — the same FACE, a different URL. `__injectedFontUrls` dedupes on the
|
|
206
|
+
// full URL and is module state outliving a single test, so reusing the
|
|
207
|
+
// spelling an earlier case already injected would suppress this <link>
|
|
208
|
+
// and the assertion would fail for a reason that has nothing to do with
|
|
209
|
+
// the partition.
|
|
210
|
+
ensureGoogleFontsLoaded(['Playfair+Display:wght@700', 'Playfair+Display:ital,wght@1,400'])
|
|
211
|
+
const hrefs = injectedHrefs()
|
|
212
|
+
expect(hrefs).toEqual([
|
|
213
|
+
`${base}/fonts.css`,
|
|
214
|
+
'https://fonts.googleapis.com/css2?family=Playfair+Display:ital,wght@1,400&display=swap',
|
|
215
|
+
])
|
|
216
|
+
})
|
|
217
|
+
|
|
218
|
+
it('omitting face data leaves the partition at family level', () => {
|
|
219
|
+
// A manifest vendored before the refinement existed carries no face
|
|
220
|
+
// information. That is a real shape, and it must degrade to the previous
|
|
221
|
+
// behaviour rather than treating every face as missing.
|
|
222
|
+
const base = B('nofaces')
|
|
223
|
+
setFontsBaseUrl(base, FAMILIES)
|
|
224
|
+
ensureGoogleFontsLoaded(['Playfair+Display:ital@1'])
|
|
225
|
+
expect(injectedHrefs()).toEqual([`${base}/fonts.css`])
|
|
226
|
+
})
|
|
227
|
+
|
|
228
|
+
it('faces cannot promote a family the list does not declare', () => {
|
|
229
|
+
const base = B('narrowing')
|
|
230
|
+
setFontsBaseUrl(base, ['Inter'], { faces: { Inter: { normal: [400] }, Zilch: { normal: [400] } } })
|
|
231
|
+
ensureGoogleFontsLoaded(['Zilch'])
|
|
232
|
+
expect(injectedHrefs()).toEqual(['https://fonts.googleapis.com/css2?family=Zilch&display=swap'])
|
|
233
|
+
})
|
|
234
|
+
|
|
235
|
+
it('names the fallen-through spec in the warning, and not the vendored one', () => {
|
|
236
|
+
const warn = vi.spyOn(console, 'warn').mockImplementation(() => {})
|
|
237
|
+
const base = B('logged')
|
|
238
|
+
setFontsBaseUrl(base, FAMILIES, REAL)
|
|
239
|
+
ensureGoogleFontsLoaded(['Inter:wght@400', 'Quire:ital@1'])
|
|
240
|
+
expect(warn).toHaveBeenCalledOnce()
|
|
241
|
+
expect(warn.mock.calls[0][0]).toContain('Quire:ital@1')
|
|
242
|
+
expect(warn.mock.calls[0][0]).not.toContain('Inter')
|
|
243
|
+
})
|
|
244
|
+
})
|