@bycrux/editor 0.4.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/README.md +165 -0
- package/package.json +46 -0
- package/src/__tests__/adapter-contract.test.ts +123 -0
- package/src/__tests__/adapter.test.ts +185 -0
- package/src/__tests__/schema.test.ts +104 -0
- package/src/__tests__/video-adapter-contract.test.ts +89 -0
- package/src/carousel/AddElementMenu.tsx +211 -0
- package/src/carousel/CarouselEditor.tsx +545 -0
- package/src/carousel/CarouselRenderModal.tsx +243 -0
- package/src/carousel/OverlayErrorBoundary.tsx +99 -0
- package/src/carousel/OverlayPicker.tsx +145 -0
- package/src/carousel/ReadOnlySlide.tsx +90 -0
- package/src/carousel/SlideCanvas.tsx +637 -0
- package/src/carousel/SlidePropertyPanel.tsx +387 -0
- package/src/carousel/__tests__/CarouselEditor.test.tsx +291 -0
- package/src/carousel/__tests__/ReadOnlySlide.test.tsx +139 -0
- package/src/carousel/__tests__/SlideCanvasCrop.test.tsx +95 -0
- package/src/carousel/__tests__/SlideCanvasFonts.test.tsx +82 -0
- package/src/crop/CanvasCropOverlay.tsx +193 -0
- package/src/crop/__tests__/crop-math.test.ts +174 -0
- package/src/crop/crop-math.ts +125 -0
- package/src/gestures/helpers/__tests__/element-transform.test.ts +30 -0
- package/src/gestures/helpers/drag.ts +24 -0
- package/src/gestures/helpers/element-transform.ts +15 -0
- package/src/gestures/helpers/resize.ts +60 -0
- package/src/gestures/helpers/rotate.ts +44 -0
- package/src/gestures/helpers/snap.ts +64 -0
- package/src/gestures/hooks/useOverlayDrag.ts +106 -0
- package/src/gestures/hooks/useOverlayResize.ts +67 -0
- package/src/gestures/hooks/useOverlayRotate.ts +64 -0
- package/src/gestures/index.ts +16 -0
- package/src/index.ts +136 -0
- package/src/lib/google-fonts.ts +28 -0
- package/src/overlays/contract.ts +41 -0
- package/src/preview/OverlayPreview.tsx +196 -0
- package/src/preview/__tests__/OverlayPreview.test.tsx +169 -0
- package/src/schema.ts +201 -0
- package/src/state/__tests__/project-reducer.test.ts +957 -0
- package/src/state/__tests__/use-project-state.test.tsx +258 -0
- package/src/state/mutation-queue.ts +62 -0
- package/src/state/project-reducer.ts +328 -0
- package/src/state/use-project-state.ts +442 -0
- package/src/test-setup.ts +1 -0
- package/src/text/FontPicker.tsx +218 -0
- package/src/text/InlineTextEditor.tsx +92 -0
- package/src/text/TextFormattingToolbar.tsx +248 -0
- package/src/text/__tests__/InlineTextEditor.test.tsx +139 -0
- package/src/text/__tests__/TextFormattingToolbar.test.tsx +416 -0
- package/src/theme.ts +93 -0
- package/src/types.ts +486 -0
- package/src/ui/__tests__/button.test.tsx +17 -0
- package/src/ui/badge.tsx +32 -0
- package/src/ui/button.tsx +32 -0
- package/src/ui/index.ts +16 -0
- package/src/ui/input.tsx +15 -0
- package/src/ui/label.tsx +10 -0
- package/src/ui/select.tsx +23 -0
- package/src/ui/switch.tsx +31 -0
- package/src/ui/textarea.tsx +15 -0
- package/src/ui/utils.ts +7 -0
- package/src/video/RenderModal.tsx +252 -0
- package/src/video/VersionPanel.tsx +83 -0
- package/src/video/VideoEditor.tsx +508 -0
- package/src/video/__tests__/VideoEditor.test.tsx +213 -0
- package/src/video/__tests__/captionRepair.test.ts +134 -0
- package/src/video/__tests__/cuts.test.ts +198 -0
- package/src/video/captionRepair.ts +41 -0
- package/src/video/cuts.ts +369 -0
- package/src/video/design-canvas.ts +11 -0
- package/src/video/preview/CaptionPreview.tsx +83 -0
- package/src/video/preview/CarouselPreview.tsx +35 -0
- package/src/video/preview/OverlayItemsLayer.tsx +584 -0
- package/src/video/preview/PreviewPlayer.tsx +178 -0
- package/src/video/preview/useDragOverlay.ts +167 -0
- package/src/video/preview/useVideoPlayback.ts +761 -0
- package/src/video/timeline/AudioTrackRow.tsx +406 -0
- package/src/video/timeline/AudioWaveformLayer.tsx +117 -0
- package/src/video/timeline/EditableSegment.tsx +30 -0
- package/src/video/timeline/Scrubber.tsx +184 -0
- package/src/video/timeline/Timeline.tsx +375 -0
- package/src/video/timeline/TimelineContext.ts +25 -0
- package/src/video/timeline/TranscriptModal.tsx +63 -0
- package/src/video/timeline/TranscriptPanel.tsx +86 -0
- package/src/video/timeline/VisualTrackRow.tsx +293 -0
- package/src/video/timeline/makeCaptionEdit.ts +32 -0
- package/src/video/timeline/multiSelectOps.ts +157 -0
- package/src/video/timeline/useItemDragDrop.ts +190 -0
- package/src/video/timeline/useTimelineZoom.ts +48 -0
- package/src/video/timeline/utils.ts +17 -0
package/src/index.ts
ADDED
|
@@ -0,0 +1,136 @@
|
|
|
1
|
+
// @bycrux/editor — public API.
|
|
2
|
+
//
|
|
3
|
+
// The package owns the host-agnostic carousel editor: the editor-facing schema,
|
|
4
|
+
// the adapter/theme contracts, and the host-agnostic PIECES (state, gestures,
|
|
5
|
+
// crop, text, preview, overlays). Hosts (Montaj's UI, Hub clients) import from
|
|
6
|
+
// here and supply an EditorAdapter to drive it.
|
|
7
|
+
|
|
8
|
+
// ── Schema (single source of truth for project/slide/element shapes) ──────────
|
|
9
|
+
export type {
|
|
10
|
+
Word,
|
|
11
|
+
AudioTrack,
|
|
12
|
+
CaptionSegment,
|
|
13
|
+
Captions,
|
|
14
|
+
VisualItem,
|
|
15
|
+
Asset,
|
|
16
|
+
ImageElement,
|
|
17
|
+
OverlayElement,
|
|
18
|
+
CarouselElement,
|
|
19
|
+
Slide,
|
|
20
|
+
EditorProject,
|
|
21
|
+
} from './schema'
|
|
22
|
+
|
|
23
|
+
// ── Contracts (adapter, theme, render, media, component props) ────────────────
|
|
24
|
+
// Schema types are sourced from './schema' above; here we export only the
|
|
25
|
+
// symbols types.ts itself owns, plus the `Project` alias (= EditorProject) that
|
|
26
|
+
// the ported state/reducer code is typed against.
|
|
27
|
+
export type {
|
|
28
|
+
Project,
|
|
29
|
+
OverlayFactory,
|
|
30
|
+
RenderEvent,
|
|
31
|
+
RenderOptions,
|
|
32
|
+
MediaScope,
|
|
33
|
+
MediaItem,
|
|
34
|
+
GlobalOverlay,
|
|
35
|
+
GlobalOverlayProp,
|
|
36
|
+
VersionEntry,
|
|
37
|
+
WaveformChunk,
|
|
38
|
+
EditorAdapter,
|
|
39
|
+
EditorTheme,
|
|
40
|
+
EditorSlots,
|
|
41
|
+
CarouselEditorProps,
|
|
42
|
+
VideoEditorProps,
|
|
43
|
+
} from './types'
|
|
44
|
+
|
|
45
|
+
// ── Video editor pure helpers ─────────────────────────────────────────────────
|
|
46
|
+
export {
|
|
47
|
+
applyCutToTracks,
|
|
48
|
+
applyCutToItem,
|
|
49
|
+
collapseGaps,
|
|
50
|
+
splitAtTime,
|
|
51
|
+
} from './video/cuts'
|
|
52
|
+
export type { Cut } from './video/cuts'
|
|
53
|
+
export { getOverlayDesignCanvas } from './video/design-canvas'
|
|
54
|
+
|
|
55
|
+
// ── Theme ─────────────────────────────────────────────────────────────────────
|
|
56
|
+
export { defaultMontajTheme, applyTheme } from './theme'
|
|
57
|
+
|
|
58
|
+
// ── State ───────────────────────────────────────────────────────────────────
|
|
59
|
+
export { useProjectState } from './state/use-project-state'
|
|
60
|
+
export type { Connection, UseProjectState } from './state/use-project-state'
|
|
61
|
+
export { projectReducer } from './state/project-reducer'
|
|
62
|
+
export type { Action, ProjectStatus } from './state/project-reducer'
|
|
63
|
+
export { createMutationQueue } from './state/mutation-queue'
|
|
64
|
+
export type { MutationQueue } from './state/mutation-queue'
|
|
65
|
+
|
|
66
|
+
// ── Gestures ──────────────────────────────────────────────────────────────────
|
|
67
|
+
export * from './gestures'
|
|
68
|
+
|
|
69
|
+
// ── Crop ──────────────────────────────────────────────────────────────────────
|
|
70
|
+
export {
|
|
71
|
+
renderedSourceRect,
|
|
72
|
+
fractionToWrapperPx,
|
|
73
|
+
wrapperPxToFraction,
|
|
74
|
+
applyCropHandleDrag,
|
|
75
|
+
} from './crop/crop-math'
|
|
76
|
+
export type {
|
|
77
|
+
RenderedRect,
|
|
78
|
+
CropFraction,
|
|
79
|
+
WrapperPxRect,
|
|
80
|
+
CropHandle,
|
|
81
|
+
} from './crop/crop-math'
|
|
82
|
+
export { CanvasCropOverlay } from './crop/CanvasCropOverlay'
|
|
83
|
+
export type { CanvasCropOverlayProps } from './crop/CanvasCropOverlay'
|
|
84
|
+
|
|
85
|
+
// ── Text ──────────────────────────────────────────────────────────────────────
|
|
86
|
+
export {
|
|
87
|
+
FONT_OPTIONS,
|
|
88
|
+
findFontOption,
|
|
89
|
+
FontFamilyPicker,
|
|
90
|
+
FontSizePicker,
|
|
91
|
+
} from './text/FontPicker'
|
|
92
|
+
export type { FontOption } from './text/FontPicker'
|
|
93
|
+
export { InlineTextEditor } from './text/InlineTextEditor'
|
|
94
|
+
export type { InlineTextEditorProps } from './text/InlineTextEditor'
|
|
95
|
+
export {
|
|
96
|
+
HEX_PATTERN,
|
|
97
|
+
isColorProp,
|
|
98
|
+
isBold,
|
|
99
|
+
isItalic,
|
|
100
|
+
nextCase,
|
|
101
|
+
isStyleProp,
|
|
102
|
+
nonColorTextEntries,
|
|
103
|
+
TextFormattingToolbar,
|
|
104
|
+
} from './text/TextFormattingToolbar'
|
|
105
|
+
export type { TextFormattingToolbarProps } from './text/TextFormattingToolbar'
|
|
106
|
+
|
|
107
|
+
// ── Preview ─────────────────────────────────────────────────────────────────
|
|
108
|
+
export { OverlayPreview } from './preview/OverlayPreview'
|
|
109
|
+
export type { OverlayPreviewProps } from './preview/OverlayPreview'
|
|
110
|
+
|
|
111
|
+
// ── Video preview ─────────────────────────────────────────────────────────────
|
|
112
|
+
export { default as PreviewPlayer } from './video/preview/PreviewPlayer'
|
|
113
|
+
export { default as CarouselPreview } from './video/preview/CarouselPreview'
|
|
114
|
+
export { default as OverlayItemsLayer } from './video/preview/OverlayItemsLayer'
|
|
115
|
+
export { useVideoPlayback } from './video/preview/useVideoPlayback'
|
|
116
|
+
export { useDragOverlay } from './video/preview/useDragOverlay'
|
|
117
|
+
export type { Corner, DragType } from './video/preview/useDragOverlay'
|
|
118
|
+
|
|
119
|
+
// ── Overlays ──────────────────────────────────────────────────────────────────
|
|
120
|
+
export {
|
|
121
|
+
STANDARD_TEXT_PROPS,
|
|
122
|
+
getSupportedProps,
|
|
123
|
+
readPropAsString,
|
|
124
|
+
} from './overlays/contract'
|
|
125
|
+
|
|
126
|
+
// ── Assembled editors ─────────────────────────────────────────────────────────
|
|
127
|
+
export { default as CarouselEditor } from './carousel/CarouselEditor'
|
|
128
|
+
export { default as VideoEditor } from './video/VideoEditor'
|
|
129
|
+
|
|
130
|
+
// ── Public carousel sub-components ────────────────────────────────────────────
|
|
131
|
+
// Hosts consume these beyond the assembled editor — Montaj's preview/caption
|
|
132
|
+
// components render SlideCanvas thumbnails and wrap overlays in the boundary.
|
|
133
|
+
export { default as SlideCanvas, resolveAsset } from './carousel/SlideCanvas'
|
|
134
|
+
export { default as OverlayErrorBoundary } from './carousel/OverlayErrorBoundary'
|
|
135
|
+
export { default as ReadOnlySlide } from './carousel/ReadOnlySlide'
|
|
136
|
+
export type { ReadOnlySlideProps } from './carousel/ReadOnlySlide'
|
|
@@ -0,0 +1,28 @@
|
|
|
1
|
+
// Shared Google Fonts loader.
|
|
2
|
+
//
|
|
3
|
+
// Injects a Google Fonts stylesheet <link> for the declared family specs so the
|
|
4
|
+
// editor's preview/render uses the same glyphs and metrics the renderer
|
|
5
|
+
// (bundle.js) fetches. Used by both the video overlay layer and the carousel
|
|
6
|
+
// overlay render path. Resilient by design: a font-load failure must never
|
|
7
|
+
// break the render (we only append a <link>; the browser handles the fetch).
|
|
8
|
+
|
|
9
|
+
// Track Google Fonts URLs already injected so we don't add the same <link>
|
|
10
|
+
// twice when multiple overlays declare overlapping fonts. Keyed by the full
|
|
11
|
+
// stylesheet URL — the same URL never produces a duplicate fetch from
|
|
12
|
+
// Chromium regardless, but the duplicate <link> tags would still clutter
|
|
13
|
+
// document.head across long editing sessions.
|
|
14
|
+
const __injectedFontUrls = new Set<string>()
|
|
15
|
+
|
|
16
|
+
export function ensureGoogleFontsLoaded(googleFonts: string[] | undefined): void {
|
|
17
|
+
if (!googleFonts?.length) return
|
|
18
|
+
// Match the format bundle.js uses for the render pipeline so preview and
|
|
19
|
+
// render fetch identical CSS (and identical glyphs / metrics).
|
|
20
|
+
const url = `https://fonts.googleapis.com/css2?${googleFonts.map((f) => `family=${f}`).join('&')}&display=swap`
|
|
21
|
+
if (__injectedFontUrls.has(url)) return
|
|
22
|
+
__injectedFontUrls.add(url)
|
|
23
|
+
if (typeof document === 'undefined') return
|
|
24
|
+
const link = document.createElement('link')
|
|
25
|
+
link.rel = 'stylesheet'
|
|
26
|
+
link.href = url
|
|
27
|
+
document.head.appendChild(link)
|
|
28
|
+
}
|
|
@@ -0,0 +1,41 @@
|
|
|
1
|
+
import type { OverlayElement } from '../types'
|
|
2
|
+
|
|
3
|
+
// The 9-prop editable-text contract. Single source of truth on the FE for
|
|
4
|
+
// which prop keys the floating text toolbar knows how to write. Mirrors
|
|
5
|
+
// hub/backend/src/modules/mcp/overlay-contract.ts REQUIRED_PROPS.
|
|
6
|
+
export const STANDARD_TEXT_PROPS: ReadonlySet<string> = new Set([
|
|
7
|
+
'text', 'fontSize', 'fontFamily', 'fontWeight', 'fontStyle',
|
|
8
|
+
'color', 'textAlign', 'textTransform', 'bgColor',
|
|
9
|
+
])
|
|
10
|
+
|
|
11
|
+
/**
|
|
12
|
+
* Returns the subset of STANDARD_TEXT_PROPS present (non-null) on the
|
|
13
|
+
* element. Toolbar controls render only when their target prop is in this set.
|
|
14
|
+
*
|
|
15
|
+
* Forgiving on value type: numeric values (e.g. `fontSize: 64`) count as
|
|
16
|
+
* present. The toolbar reads them via String(value) and writes back as
|
|
17
|
+
* strings — fixing the round-trip silently rather than blocking the operator
|
|
18
|
+
* with a "this overlay isn't editable" message. Per the resolved product
|
|
19
|
+
* question, this prefers UX over strictness; non-string values on contract
|
|
20
|
+
* props are still flagged upstream by hub.write_overlay's validator.
|
|
21
|
+
*/
|
|
22
|
+
export function getSupportedProps(element: OverlayElement): Set<string> {
|
|
23
|
+
const props = element.overlay.props
|
|
24
|
+
const supported = new Set<string>()
|
|
25
|
+
for (const key of STANDARD_TEXT_PROPS) {
|
|
26
|
+
const value = props[key]
|
|
27
|
+
if (value !== undefined && value !== null) supported.add(key)
|
|
28
|
+
}
|
|
29
|
+
return supported
|
|
30
|
+
}
|
|
31
|
+
|
|
32
|
+
/**
|
|
33
|
+
* Read a contract prop's current value as a string, coercing numbers. Used
|
|
34
|
+
* by the toolbar's read paths so the displayed control value matches what's
|
|
35
|
+
* on the element regardless of its stored type.
|
|
36
|
+
*/
|
|
37
|
+
export function readPropAsString(element: OverlayElement, key: string): string {
|
|
38
|
+
const value = element.overlay.props[key]
|
|
39
|
+
if (value === undefined || value === null) return ''
|
|
40
|
+
return String(value)
|
|
41
|
+
}
|
|
@@ -0,0 +1,196 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* editor-core/preview/OverlayPreview
|
|
3
|
+
*
|
|
4
|
+
* Host-agnostic React component that compiles and renders a JSX overlay
|
|
5
|
+
* template. The overlay compiler is injected via the `compileOverlay` prop so
|
|
6
|
+
* this component has no dependency on any host module (no import from
|
|
7
|
+
* '@/lib/overlay-eval'). The host wires in the compiler from its adapter.
|
|
8
|
+
*
|
|
9
|
+
* States:
|
|
10
|
+
* - Compiling → `loading` node (default: spinner with role="status").
|
|
11
|
+
* - Compile or runtime error → `errorState` node (default: red badge with role="alert").
|
|
12
|
+
* - Success → factory output element.
|
|
13
|
+
*/
|
|
14
|
+
|
|
15
|
+
import React, { useEffect, useState } from 'react'
|
|
16
|
+
import type { OverlayFactory } from '../types'
|
|
17
|
+
|
|
18
|
+
// ── Defaults ─────────────────────────────────────────────────────────────────
|
|
19
|
+
|
|
20
|
+
function DefaultSpinner(): React.ReactElement {
|
|
21
|
+
return (
|
|
22
|
+
<div
|
|
23
|
+
role="status"
|
|
24
|
+
aria-label="Loading overlay"
|
|
25
|
+
style={{
|
|
26
|
+
position: 'absolute',
|
|
27
|
+
inset: 0,
|
|
28
|
+
display: 'flex',
|
|
29
|
+
alignItems: 'center',
|
|
30
|
+
justifyContent: 'center',
|
|
31
|
+
color: 'rgba(255, 255, 255, 0.65)',
|
|
32
|
+
}}
|
|
33
|
+
>
|
|
34
|
+
<svg
|
|
35
|
+
width="36"
|
|
36
|
+
height="36"
|
|
37
|
+
viewBox="0 0 24 24"
|
|
38
|
+
fill="none"
|
|
39
|
+
stroke="currentColor"
|
|
40
|
+
strokeWidth="2"
|
|
41
|
+
strokeLinecap="round"
|
|
42
|
+
>
|
|
43
|
+
<g>
|
|
44
|
+
<path d="M21 12a9 9 0 1 1-6.219-8.56" />
|
|
45
|
+
<animateTransform
|
|
46
|
+
attributeName="transform"
|
|
47
|
+
type="rotate"
|
|
48
|
+
from="0 12 12"
|
|
49
|
+
to="360 12 12"
|
|
50
|
+
dur="0.9s"
|
|
51
|
+
repeatCount="indefinite"
|
|
52
|
+
/>
|
|
53
|
+
</g>
|
|
54
|
+
</svg>
|
|
55
|
+
</div>
|
|
56
|
+
)
|
|
57
|
+
}
|
|
58
|
+
|
|
59
|
+
function DefaultErrorState(): React.ReactElement {
|
|
60
|
+
return (
|
|
61
|
+
<div
|
|
62
|
+
role="alert"
|
|
63
|
+
style={{
|
|
64
|
+
position: 'absolute',
|
|
65
|
+
inset: 0,
|
|
66
|
+
display: 'flex',
|
|
67
|
+
alignItems: 'center',
|
|
68
|
+
justifyContent: 'center',
|
|
69
|
+
padding: 16,
|
|
70
|
+
}}
|
|
71
|
+
>
|
|
72
|
+
<div
|
|
73
|
+
style={{
|
|
74
|
+
display: 'inline-flex',
|
|
75
|
+
alignItems: 'center',
|
|
76
|
+
gap: 6,
|
|
77
|
+
padding: '4px 10px',
|
|
78
|
+
borderRadius: 6,
|
|
79
|
+
background: 'rgba(220, 38, 38, 0.15)',
|
|
80
|
+
color: 'rgb(220, 38, 38)',
|
|
81
|
+
fontSize: 12,
|
|
82
|
+
fontFamily: 'system-ui, -apple-system, sans-serif',
|
|
83
|
+
fontWeight: 500,
|
|
84
|
+
}}
|
|
85
|
+
>
|
|
86
|
+
<svg
|
|
87
|
+
width="14"
|
|
88
|
+
height="14"
|
|
89
|
+
viewBox="0 0 24 24"
|
|
90
|
+
fill="none"
|
|
91
|
+
stroke="currentColor"
|
|
92
|
+
strokeWidth="2"
|
|
93
|
+
strokeLinecap="round"
|
|
94
|
+
strokeLinejoin="round"
|
|
95
|
+
>
|
|
96
|
+
<circle cx="12" cy="12" r="10" />
|
|
97
|
+
<line x1="12" y1="8" x2="12" y2="12" />
|
|
98
|
+
<line x1="12" y1="16" x2="12.01" y2="16" />
|
|
99
|
+
</svg>
|
|
100
|
+
<span>overlay error</span>
|
|
101
|
+
</div>
|
|
102
|
+
</div>
|
|
103
|
+
)
|
|
104
|
+
}
|
|
105
|
+
|
|
106
|
+
// ── Props ─────────────────────────────────────────────────────────────────────
|
|
107
|
+
|
|
108
|
+
export interface OverlayPreviewProps {
|
|
109
|
+
/**
|
|
110
|
+
* Host-supplied compiler. Receives a template path and returns a compiled
|
|
111
|
+
* OverlayFactory. Injected from the adapter so editor-core never imports
|
|
112
|
+
* the host's overlay-eval module directly.
|
|
113
|
+
*/
|
|
114
|
+
compileOverlay: (template: string) => Promise<OverlayFactory>
|
|
115
|
+
|
|
116
|
+
/**
|
|
117
|
+
* Path to the overlay template file. Passed to the injected compileOverlay.
|
|
118
|
+
* Matches OverlayElement.overlay.template from Montaj's schema.
|
|
119
|
+
*/
|
|
120
|
+
template: string
|
|
121
|
+
|
|
122
|
+
/**
|
|
123
|
+
* Runtime props forwarded to the overlay factory.
|
|
124
|
+
* Matches OverlayElement.overlay.props.
|
|
125
|
+
*/
|
|
126
|
+
props: Record<string, unknown>
|
|
127
|
+
|
|
128
|
+
/**
|
|
129
|
+
* Frame number to render. Matches OverlayElement.frame or the caller's
|
|
130
|
+
* scrubber position.
|
|
131
|
+
*/
|
|
132
|
+
frame: number
|
|
133
|
+
|
|
134
|
+
/** Frames per second. Passed to the factory alongside frame. */
|
|
135
|
+
fps: number
|
|
136
|
+
|
|
137
|
+
/**
|
|
138
|
+
* Duration in frames. Passed to the factory as `durationFrames`.
|
|
139
|
+
* Mirrors how SlideCanvas derives duration from element.overlay.props.duration.
|
|
140
|
+
*/
|
|
141
|
+
duration: number
|
|
142
|
+
|
|
143
|
+
/** Shown while compileOverlay is in-flight. Default: spinner. */
|
|
144
|
+
loading?: React.ReactNode
|
|
145
|
+
|
|
146
|
+
/** Shown on compile error or factory runtime error. Default: red badge. */
|
|
147
|
+
errorState?: React.ReactNode
|
|
148
|
+
}
|
|
149
|
+
|
|
150
|
+
// ── Component ─────────────────────────────────────────────────────────────────
|
|
151
|
+
|
|
152
|
+
export function OverlayPreview({
|
|
153
|
+
compileOverlay,
|
|
154
|
+
template,
|
|
155
|
+
props,
|
|
156
|
+
frame,
|
|
157
|
+
fps,
|
|
158
|
+
duration,
|
|
159
|
+
loading,
|
|
160
|
+
errorState,
|
|
161
|
+
}: OverlayPreviewProps): React.ReactElement {
|
|
162
|
+
const [factory, setFactory] = useState<OverlayFactory | null>(null)
|
|
163
|
+
const [error, setError] = useState<Error | null>(null)
|
|
164
|
+
|
|
165
|
+
useEffect(() => {
|
|
166
|
+
let cancelled = false
|
|
167
|
+
// Reset state when template changes so the loading spinner re-appears.
|
|
168
|
+
setFactory(null)
|
|
169
|
+
setError(null)
|
|
170
|
+
|
|
171
|
+
compileOverlay(template)
|
|
172
|
+
.then((f) => {
|
|
173
|
+
if (!cancelled) setFactory(() => f)
|
|
174
|
+
})
|
|
175
|
+
.catch((e) => {
|
|
176
|
+
if (!cancelled) setError(e instanceof Error ? e : new Error(String(e)))
|
|
177
|
+
})
|
|
178
|
+
|
|
179
|
+
return () => {
|
|
180
|
+
cancelled = true
|
|
181
|
+
}
|
|
182
|
+
}, [template])
|
|
183
|
+
|
|
184
|
+
const loadingNode = loading ?? <DefaultSpinner />
|
|
185
|
+
const errorNode = errorState ?? <DefaultErrorState />
|
|
186
|
+
|
|
187
|
+
if (error) return <>{errorNode}</>
|
|
188
|
+
if (!factory) return <>{loadingNode}</>
|
|
189
|
+
|
|
190
|
+
try {
|
|
191
|
+
const out = factory(frame, fps, duration, props)
|
|
192
|
+
return out ?? <>{errorNode}</>
|
|
193
|
+
} catch {
|
|
194
|
+
return <>{errorNode}</>
|
|
195
|
+
}
|
|
196
|
+
}
|
|
@@ -0,0 +1,169 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* editor-core/preview/OverlayPreview — unit tests.
|
|
3
|
+
*
|
|
4
|
+
* The overlay compiler is injected via the `compileOverlay` prop so no module
|
|
5
|
+
* mock is needed. Each test passes its own fake compiler directly.
|
|
6
|
+
*/
|
|
7
|
+
|
|
8
|
+
import React from 'react'
|
|
9
|
+
import { render, screen, waitFor } from '@testing-library/react'
|
|
10
|
+
import { vi, describe, it, expect, beforeEach, afterEach } from 'vitest'
|
|
11
|
+
import type { OverlayFactory } from '../../types'
|
|
12
|
+
|
|
13
|
+
// ── Import component under test ──────────────────────────────────────────────
|
|
14
|
+
|
|
15
|
+
import { OverlayPreview } from '../OverlayPreview'
|
|
16
|
+
|
|
17
|
+
// ── Helpers ──────────────────────────────────────────────────────────────────
|
|
18
|
+
|
|
19
|
+
/** Factory that returns a simple div with a test id. */
|
|
20
|
+
const trivialFactory: OverlayFactory = (_frame, _fps, _duration, _props) =>
|
|
21
|
+
React.createElement('div', { 'data-testid': 'overlay-output' }, 'hello overlay')
|
|
22
|
+
|
|
23
|
+
/** A compileOverlay prop that always resolves with the given factory. */
|
|
24
|
+
const makeCompiler =
|
|
25
|
+
(factory: OverlayFactory) =>
|
|
26
|
+
(_template: string): Promise<OverlayFactory> =>
|
|
27
|
+
Promise.resolve(factory)
|
|
28
|
+
|
|
29
|
+
/** A compileOverlay prop that always rejects with the given error. */
|
|
30
|
+
const makeFailingCompiler =
|
|
31
|
+
(message: string) =>
|
|
32
|
+
(_template: string): Promise<OverlayFactory> =>
|
|
33
|
+
Promise.reject(new Error(message))
|
|
34
|
+
|
|
35
|
+
/** A compileOverlay prop that never resolves (simulates in-flight). */
|
|
36
|
+
const pendingCompiler = (_template: string): Promise<OverlayFactory> =>
|
|
37
|
+
new Promise(() => {})
|
|
38
|
+
|
|
39
|
+
const DEFAULT_PROPS = {
|
|
40
|
+
template: '/path/to/overlay.jsx',
|
|
41
|
+
props: { text: 'hi' },
|
|
42
|
+
frame: 0,
|
|
43
|
+
fps: 30,
|
|
44
|
+
duration: 60,
|
|
45
|
+
compileOverlay: makeCompiler(trivialFactory),
|
|
46
|
+
}
|
|
47
|
+
|
|
48
|
+
// ── Tests ─────────────────────────────────────────────────────────────────────
|
|
49
|
+
|
|
50
|
+
describe('OverlayPreview', () => {
|
|
51
|
+
beforeEach(() => {
|
|
52
|
+
vi.clearAllMocks()
|
|
53
|
+
})
|
|
54
|
+
|
|
55
|
+
afterEach(() => {
|
|
56
|
+
vi.restoreAllMocks()
|
|
57
|
+
})
|
|
58
|
+
|
|
59
|
+
it('shows a loading state while compiling', async () => {
|
|
60
|
+
render(<OverlayPreview {...DEFAULT_PROPS} compileOverlay={pendingCompiler} />)
|
|
61
|
+
|
|
62
|
+
expect(screen.getByRole('status')).toBeInTheDocument()
|
|
63
|
+
})
|
|
64
|
+
|
|
65
|
+
it('renders the factory output when compilation succeeds', async () => {
|
|
66
|
+
render(<OverlayPreview {...DEFAULT_PROPS} />)
|
|
67
|
+
|
|
68
|
+
await waitFor(() =>
|
|
69
|
+
expect(screen.getByTestId('overlay-output')).toBeInTheDocument(),
|
|
70
|
+
)
|
|
71
|
+
expect(screen.getByTestId('overlay-output')).toHaveTextContent('hello overlay')
|
|
72
|
+
})
|
|
73
|
+
|
|
74
|
+
it('passes frame/fps/duration/props to the factory', async () => {
|
|
75
|
+
const factorySpy = vi.fn(trivialFactory)
|
|
76
|
+
|
|
77
|
+
render(
|
|
78
|
+
<OverlayPreview
|
|
79
|
+
template="/t.jsx"
|
|
80
|
+
props={{ color: 'red' }}
|
|
81
|
+
frame={12}
|
|
82
|
+
fps={24}
|
|
83
|
+
duration={90}
|
|
84
|
+
compileOverlay={makeCompiler(factorySpy)}
|
|
85
|
+
/>,
|
|
86
|
+
)
|
|
87
|
+
|
|
88
|
+
await waitFor(() => expect(factorySpy).toHaveBeenCalled())
|
|
89
|
+
expect(factorySpy).toHaveBeenCalledWith(12, 24, 90, expect.objectContaining({ color: 'red' }))
|
|
90
|
+
})
|
|
91
|
+
|
|
92
|
+
it('surfaces an error badge when compileOverlay rejects', async () => {
|
|
93
|
+
render(
|
|
94
|
+
<OverlayPreview
|
|
95
|
+
{...DEFAULT_PROPS}
|
|
96
|
+
compileOverlay={makeFailingCompiler('bad JSX syntax')}
|
|
97
|
+
/>,
|
|
98
|
+
)
|
|
99
|
+
|
|
100
|
+
await waitFor(() =>
|
|
101
|
+
expect(screen.getByRole('alert')).toBeInTheDocument(),
|
|
102
|
+
)
|
|
103
|
+
const alert = screen.getByRole('alert')
|
|
104
|
+
expect(alert).toBeInTheDocument()
|
|
105
|
+
})
|
|
106
|
+
|
|
107
|
+
it('surfaces an error badge when the factory throws at render time', async () => {
|
|
108
|
+
const throwingFactory: OverlayFactory = () => {
|
|
109
|
+
throw new Error('runtime render error')
|
|
110
|
+
}
|
|
111
|
+
|
|
112
|
+
render(
|
|
113
|
+
<OverlayPreview
|
|
114
|
+
{...DEFAULT_PROPS}
|
|
115
|
+
compileOverlay={makeCompiler(throwingFactory)}
|
|
116
|
+
/>,
|
|
117
|
+
)
|
|
118
|
+
|
|
119
|
+
await waitFor(() =>
|
|
120
|
+
expect(screen.getByRole('alert')).toBeInTheDocument(),
|
|
121
|
+
)
|
|
122
|
+
})
|
|
123
|
+
|
|
124
|
+
it('re-compiles when template changes', async () => {
|
|
125
|
+
const factory1: OverlayFactory = () =>
|
|
126
|
+
React.createElement('div', { 'data-testid': 'v1' }, 'v1')
|
|
127
|
+
const factory2: OverlayFactory = () =>
|
|
128
|
+
React.createElement('div', { 'data-testid': 'v2' }, 'v2')
|
|
129
|
+
|
|
130
|
+
const compilerSpy = vi.fn()
|
|
131
|
+
compilerSpy.mockResolvedValueOnce(factory1).mockResolvedValueOnce(factory2)
|
|
132
|
+
|
|
133
|
+
const { rerender } = render(
|
|
134
|
+
<OverlayPreview {...DEFAULT_PROPS} template="/overlay-v1.jsx" compileOverlay={compilerSpy} />,
|
|
135
|
+
)
|
|
136
|
+
await waitFor(() => expect(screen.getByTestId('v1')).toBeInTheDocument())
|
|
137
|
+
|
|
138
|
+
rerender(<OverlayPreview {...DEFAULT_PROPS} template="/overlay-v2.jsx" compileOverlay={compilerSpy} />)
|
|
139
|
+
await waitFor(() => expect(screen.getByTestId('v2')).toBeInTheDocument())
|
|
140
|
+
|
|
141
|
+
expect(compilerSpy).toHaveBeenCalledTimes(2)
|
|
142
|
+
})
|
|
143
|
+
|
|
144
|
+
it('accepts a custom loading node', async () => {
|
|
145
|
+
render(
|
|
146
|
+
<OverlayPreview
|
|
147
|
+
{...DEFAULT_PROPS}
|
|
148
|
+
compileOverlay={pendingCompiler}
|
|
149
|
+
loading={<div data-testid="custom-loading">Loading…</div>}
|
|
150
|
+
/>,
|
|
151
|
+
)
|
|
152
|
+
|
|
153
|
+
expect(screen.getByTestId('custom-loading')).toBeInTheDocument()
|
|
154
|
+
})
|
|
155
|
+
|
|
156
|
+
it('accepts a custom errorState node', async () => {
|
|
157
|
+
render(
|
|
158
|
+
<OverlayPreview
|
|
159
|
+
{...DEFAULT_PROPS}
|
|
160
|
+
compileOverlay={makeFailingCompiler('boom')}
|
|
161
|
+
errorState={<div data-testid="custom-error">Error!</div>}
|
|
162
|
+
/>,
|
|
163
|
+
)
|
|
164
|
+
|
|
165
|
+
await waitFor(() =>
|
|
166
|
+
expect(screen.getByTestId('custom-error')).toBeInTheDocument(),
|
|
167
|
+
)
|
|
168
|
+
})
|
|
169
|
+
})
|