@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/types.ts
ADDED
|
@@ -0,0 +1,486 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* editor-core / types — the host-agnostic boundary for Montaj's carousel
|
|
3
|
+
* editor.
|
|
4
|
+
*
|
|
5
|
+
* The editor module knows nothing about *where* a project lives or *how* it is
|
|
6
|
+
* transported. The host application (Montaj's own UI, or a Next.js client app
|
|
7
|
+
* like mission-control) supplies an `EditorAdapter` that implements load / save
|
|
8
|
+
* / subscribe / render / image-resolution against whatever transport it owns.
|
|
9
|
+
*
|
|
10
|
+
* The canonical project/slide/element shapes live in `./schema` (the package's
|
|
11
|
+
* own editor-facing schema). Internally we alias `EditorProject` to `Project`
|
|
12
|
+
* so the ported reducer/hook/tests keep their original naming. These names are
|
|
13
|
+
* re-exported from this module so the package's internal modules can import
|
|
14
|
+
* them from `../types`; the public barrel (index.ts) sources the schema types
|
|
15
|
+
* from `./schema` directly to avoid duplicate-export conflicts.
|
|
16
|
+
*/
|
|
17
|
+
import type { ReactElement, ReactNode } from 'react'
|
|
18
|
+
import type {
|
|
19
|
+
EditorProject as Project,
|
|
20
|
+
Slide,
|
|
21
|
+
CarouselElement,
|
|
22
|
+
ImageElement,
|
|
23
|
+
OverlayElement,
|
|
24
|
+
} from './schema'
|
|
25
|
+
|
|
26
|
+
// ── Overlay compiler ─────────────────────────────────────────────────────────
|
|
27
|
+
|
|
28
|
+
/**
|
|
29
|
+
* A compiled overlay factory: given the current frame/fps/durationFrames and
|
|
30
|
+
* the overlay's runtime props, returns a React element (or null on error).
|
|
31
|
+
* Matches the signature produced by `lib/overlay-eval`'s `compileOverlay`.
|
|
32
|
+
*/
|
|
33
|
+
export type OverlayFactory = (
|
|
34
|
+
frame: number,
|
|
35
|
+
fps: number,
|
|
36
|
+
durationFrames: number,
|
|
37
|
+
props: Record<string, unknown>,
|
|
38
|
+
) => ReactElement | null
|
|
39
|
+
|
|
40
|
+
// ── Re-exported canonical carousel types ─────────────────────────────────────
|
|
41
|
+
// schema.ts is the single source of truth. Consumers of editor-core import
|
|
42
|
+
// these from here so the module presents one coherent surface.
|
|
43
|
+
export type { Project, Slide, CarouselElement, ImageElement, OverlayElement }
|
|
44
|
+
|
|
45
|
+
// ── Render ───────────────────────────────────────────────────────────────────
|
|
46
|
+
|
|
47
|
+
/**
|
|
48
|
+
* A single frame of render progress. Discriminated on `type`:
|
|
49
|
+
* - 'log' — a human-readable progress line.
|
|
50
|
+
* - 'done' — terminal success; `outputPath` is the rendered artifact location
|
|
51
|
+
* (a host-resolvable path/URL — Montaj returns a workspace path,
|
|
52
|
+
* a Hub client may return a media URL).
|
|
53
|
+
* - 'error' — terminal failure; `message` describes what went wrong.
|
|
54
|
+
*/
|
|
55
|
+
export type RenderEvent =
|
|
56
|
+
| { type: 'log'; message: string }
|
|
57
|
+
| { type: 'done'; outputPath: string }
|
|
58
|
+
| { type: 'error'; message: string }
|
|
59
|
+
|
|
60
|
+
/**
|
|
61
|
+
* Options for a render request. Kept intentionally minimal — Montaj's render
|
|
62
|
+
* endpoint (`POST /api/projects/:id/render`) takes no body today, so `scale`
|
|
63
|
+
* is the only forward-looking knob and is optional. Hosts ignore fields they
|
|
64
|
+
* don't support.
|
|
65
|
+
*/
|
|
66
|
+
export interface RenderOptions {
|
|
67
|
+
/** Output scale multiplier (1 = native resolution). */
|
|
68
|
+
scale?: number
|
|
69
|
+
}
|
|
70
|
+
|
|
71
|
+
// ── Overlay library types ─────────────────────────────────────────────────────
|
|
72
|
+
// Copied verbatim from Montaj's `ui/src/lib/api.ts` so the package owns the
|
|
73
|
+
// shape the editor consumes. A host's overlay-listing endpoints return these;
|
|
74
|
+
// the adapter wraps whatever transport produces them.
|
|
75
|
+
|
|
76
|
+
/**
|
|
77
|
+
* A single declared prop on an overlay template. `type` drives the input
|
|
78
|
+
* control the editor renders; `default` seeds an unset value.
|
|
79
|
+
*/
|
|
80
|
+
export interface GlobalOverlayProp {
|
|
81
|
+
name: string
|
|
82
|
+
type: 'string' | 'int' | 'float' | 'bool' | 'color'
|
|
83
|
+
default?: unknown
|
|
84
|
+
description?: string
|
|
85
|
+
}
|
|
86
|
+
|
|
87
|
+
/**
|
|
88
|
+
* A reusable overlay template the host exposes (global, system, or
|
|
89
|
+
* profile-scoped). `jsxPath` is the host-resolvable path to the JSX template
|
|
90
|
+
* the adapter feeds to `compileOverlay`; `group` is an optional UI grouping;
|
|
91
|
+
* `empty` flags a placeholder group with no concrete overlay yet.
|
|
92
|
+
*/
|
|
93
|
+
export interface GlobalOverlay {
|
|
94
|
+
name: string
|
|
95
|
+
description: string
|
|
96
|
+
props: GlobalOverlayProp[]
|
|
97
|
+
jsxPath: string
|
|
98
|
+
group?: string
|
|
99
|
+
empty?: boolean
|
|
100
|
+
}
|
|
101
|
+
|
|
102
|
+
// ── Version history (optional capability) ─────────────────────────────────────
|
|
103
|
+
|
|
104
|
+
/**
|
|
105
|
+
* A single entry in a project's version history. The editor-relevant slice of
|
|
106
|
+
* Montaj's `ProjectVersion` (ui/src/lib/types/schema.ts): a content-addressed
|
|
107
|
+
* `hash` to restore by, a human-readable `message`, and a `timestamp`. The
|
|
108
|
+
* adapter maps the host's richer shape down to this.
|
|
109
|
+
*/
|
|
110
|
+
export interface VersionEntry {
|
|
111
|
+
hash: string
|
|
112
|
+
message: string
|
|
113
|
+
timestamp: string
|
|
114
|
+
}
|
|
115
|
+
|
|
116
|
+
// ── Waveform chunks (optional capability) ─────────────────────────────────────
|
|
117
|
+
|
|
118
|
+
/**
|
|
119
|
+
* One rendered waveform-image chunk for an audio track. `path` is a
|
|
120
|
+
* host-resolvable image path (route through `fileUrl` to display); `start`/`end`
|
|
121
|
+
* are source-file seconds the chunk covers. Copied verbatim from Montaj's former
|
|
122
|
+
* `lib/audio-waveform.ts` so the package owns the shape the timeline consumes.
|
|
123
|
+
*/
|
|
124
|
+
export interface WaveformChunk {
|
|
125
|
+
path: string
|
|
126
|
+
start: number
|
|
127
|
+
end: number
|
|
128
|
+
}
|
|
129
|
+
|
|
130
|
+
// ── Media (optional capability) ───────────────────────────────────────────────
|
|
131
|
+
|
|
132
|
+
/**
|
|
133
|
+
* Scope for a media-library query. Grounded in mission-control's
|
|
134
|
+
* `UseMediaListScope`: media is either project-scoped or drawn from the host's
|
|
135
|
+
* universal/global library.
|
|
136
|
+
*/
|
|
137
|
+
export type MediaScope =
|
|
138
|
+
| { kind: 'universal' }
|
|
139
|
+
| { kind: 'project'; projectId: string }
|
|
140
|
+
|
|
141
|
+
/**
|
|
142
|
+
* A minimal media-library item. Hosts may carry more fields, but the editor
|
|
143
|
+
* only relies on these: an id to reference, a resolvable URL to display, a
|
|
144
|
+
* MIME content type, and an optional display name.
|
|
145
|
+
*/
|
|
146
|
+
export interface MediaItem {
|
|
147
|
+
id: string
|
|
148
|
+
/** A directly displayable URL (presigned, workspace, or otherwise host-resolved). */
|
|
149
|
+
url: string
|
|
150
|
+
contentType: string
|
|
151
|
+
name?: string
|
|
152
|
+
}
|
|
153
|
+
|
|
154
|
+
// ── Adapter ────────────────────────────────────────────────────────────────
|
|
155
|
+
|
|
156
|
+
/**
|
|
157
|
+
* The contract a host implements to drive the editor. All transport,
|
|
158
|
+
* authentication, and URL-shape concerns live behind this interface; the
|
|
159
|
+
* editor calls only these methods.
|
|
160
|
+
*
|
|
161
|
+
* Generic over the host's concrete project type `P` (constrained to the
|
|
162
|
+
* editor-facing `Project` = EditorProject). Montaj instantiates it with its
|
|
163
|
+
* full `Project`; a host with no extra fields gets the default `Project`. This
|
|
164
|
+
* lets the host's pipeline fields survive load→edit→save round-trips at the
|
|
165
|
+
* type level without casts.
|
|
166
|
+
*/
|
|
167
|
+
export interface EditorAdapter<P extends Project = Project> {
|
|
168
|
+
/** Fetch the full project by id. */
|
|
169
|
+
loadProject(id: string): Promise<P>
|
|
170
|
+
|
|
171
|
+
/** Persist the full project. Mirrors Montaj's `PUT /api/projects/:id`. */
|
|
172
|
+
saveProject(id: string, project: P): Promise<void>
|
|
173
|
+
|
|
174
|
+
/**
|
|
175
|
+
* Subscribe to live project frames (e.g. an SSE stream). `onFrame` is invoked
|
|
176
|
+
* with each fresh project snapshot. Returns an unsubscribe function the
|
|
177
|
+
* editor calls on teardown.
|
|
178
|
+
*/
|
|
179
|
+
subscribe(id: string, onFrame: (project: P) => void): () => void
|
|
180
|
+
|
|
181
|
+
/**
|
|
182
|
+
* Start a render and stream progress as an async iterable of `RenderEvent`s.
|
|
183
|
+
* The iterable completes after a terminal 'done' or 'error' event.
|
|
184
|
+
*/
|
|
185
|
+
render(id: string, opts?: RenderOptions): AsyncIterable<RenderEvent>
|
|
186
|
+
|
|
187
|
+
/**
|
|
188
|
+
* Resolve an `ImageElement` to a directly displayable URL. This is the host's
|
|
189
|
+
* job because the resolution rule differs per host:
|
|
190
|
+
* - Montaj returns a workspace/files URL (e.g. `/api/files?path=...`).
|
|
191
|
+
* - Hub clients resolve a `mediaId` → presigned URL.
|
|
192
|
+
* The editor never assumes a URL shape — it always routes through here.
|
|
193
|
+
*/
|
|
194
|
+
resolveImageSrc(element: ImageElement): string
|
|
195
|
+
|
|
196
|
+
/**
|
|
197
|
+
* Optional: list media available to the editor in the given scope. Hosts
|
|
198
|
+
* without a media library omit this; the editor must feature-detect it.
|
|
199
|
+
*/
|
|
200
|
+
listMedia?(scope: MediaScope): Promise<MediaItem[]>
|
|
201
|
+
|
|
202
|
+
/**
|
|
203
|
+
* Compile a JSX overlay template file into an `OverlayFactory`.
|
|
204
|
+
* The host supplies this because the compilation pipeline (Babel, fetch
|
|
205
|
+
* strategy, caching) is host-specific. The editor-core preview component
|
|
206
|
+
* receives it as a prop; nothing inside editor-core imports the host's
|
|
207
|
+
* compiler directly.
|
|
208
|
+
*/
|
|
209
|
+
compileOverlay(template: string): Promise<OverlayFactory>
|
|
210
|
+
|
|
211
|
+
/**
|
|
212
|
+
* List the host's global (workspace-wide) overlay templates. The assembled
|
|
213
|
+
* editor's overlay picker reads these. Maps to Montaj's `GET /api/overlays`.
|
|
214
|
+
*/
|
|
215
|
+
listGlobalOverlays(): Promise<GlobalOverlay[]>
|
|
216
|
+
|
|
217
|
+
/**
|
|
218
|
+
* List the host's built-in/system overlay templates. Maps to Montaj's
|
|
219
|
+
* `GET /api/overlays/system`.
|
|
220
|
+
*/
|
|
221
|
+
listSystemOverlays(): Promise<GlobalOverlay[]>
|
|
222
|
+
|
|
223
|
+
/**
|
|
224
|
+
* Upload a file and return a host-resolvable path/ref. When `projectId` is
|
|
225
|
+
* given, the host should store it inside the project (so it stays
|
|
226
|
+
* self-contained); otherwise a shared/upload location is used. Maps to
|
|
227
|
+
* Montaj's `POST /api/projects/:id/upload-asset` (or `POST /api/upload`).
|
|
228
|
+
*/
|
|
229
|
+
uploadFile(file: File, projectId?: string): Promise<string>
|
|
230
|
+
|
|
231
|
+
/**
|
|
232
|
+
* Map a host path to a directly fetchable URL. Synchronous because hosts
|
|
233
|
+
* derive it by string transform (Montaj: `/api/files?path=...`). Distinct
|
|
234
|
+
* from `resolveImageSrc`, which takes an `ImageElement` and applies element
|
|
235
|
+
* resolution rules; `fileUrl` is the raw path→URL primitive.
|
|
236
|
+
*/
|
|
237
|
+
fileUrl(path: string): string
|
|
238
|
+
|
|
239
|
+
/**
|
|
240
|
+
* Optional: list overlay templates scoped to a named profile. Hosts without
|
|
241
|
+
* profile-scoped overlays omit this. Maps to Montaj's
|
|
242
|
+
* `GET /api/profiles/:name/overlays`.
|
|
243
|
+
*/
|
|
244
|
+
listProfileOverlays?(profileName: string): Promise<GlobalOverlay[]>
|
|
245
|
+
|
|
246
|
+
/**
|
|
247
|
+
* Optional: host environment info the editor may surface (e.g. the root
|
|
248
|
+
* skill path for authoring overlays). Maps to Montaj's `GET /api/info`.
|
|
249
|
+
*/
|
|
250
|
+
getInfo?(): Promise<{ root_skill_path?: string }>
|
|
251
|
+
|
|
252
|
+
/**
|
|
253
|
+
* Optional: generate an image from a prompt and return its host path. Hosts
|
|
254
|
+
* without AI image generation omit this; the editor feature-detects it.
|
|
255
|
+
*/
|
|
256
|
+
generateImage?(prompt: string, projectId: string): Promise<{ path: string }>
|
|
257
|
+
|
|
258
|
+
/**
|
|
259
|
+
* Optional: watch a host file path for changes, invoking `onChange` whenever
|
|
260
|
+
* the file is rewritten. Returns an unsubscribe function. The editor uses this
|
|
261
|
+
* to auto-recover an overlay preview when its source is edited on disk. Hosts
|
|
262
|
+
* without a file-watch transport omit this; the editor simply doesn't watch
|
|
263
|
+
* (no fallback EventSource). Montaj wires this to its `/api/files/stream` SSE.
|
|
264
|
+
*/
|
|
265
|
+
watchFile?(path: string, onChange: () => void): () => void
|
|
266
|
+
|
|
267
|
+
/**
|
|
268
|
+
* Optional: resolve the host's default "static text" overlay template — the
|
|
269
|
+
* one the editor's "+ Text" button seeds. Returns null when the host has no
|
|
270
|
+
* such template (the editor then hides "+ Text"). Hosts without any system
|
|
271
|
+
* text overlay omit this entirely. Montaj implements it over
|
|
272
|
+
* `listSystemOverlays()` + its `static-text` matcher.
|
|
273
|
+
*/
|
|
274
|
+
getDefaultTextOverlay?(): Promise<GlobalOverlay | null>
|
|
275
|
+
|
|
276
|
+
// ── Video editor capabilities (optional) ────────────────────────────────────
|
|
277
|
+
// Hosts driving the video editor implement these; carousel-only hosts omit
|
|
278
|
+
// them and the editor feature-detects their absence.
|
|
279
|
+
|
|
280
|
+
/**
|
|
281
|
+
* Optional: list the project's version history, newest-first. Maps to
|
|
282
|
+
* Montaj's `GET /api/projects/:id/versions`, mapped down to `VersionEntry`.
|
|
283
|
+
*/
|
|
284
|
+
listVersionHistory?(id: string): Promise<VersionEntry[]>
|
|
285
|
+
|
|
286
|
+
/**
|
|
287
|
+
* Optional: restore the project to a prior version by `hash`, returning the
|
|
288
|
+
* restored project. Maps to Montaj's
|
|
289
|
+
* `POST /api/projects/:id/versions/:hash/restore`.
|
|
290
|
+
*/
|
|
291
|
+
restoreVersion?(id: string, hash: string): Promise<P>
|
|
292
|
+
|
|
293
|
+
/**
|
|
294
|
+
* Optional: produce rendered waveform-image chunks for an audio track. The
|
|
295
|
+
* editor passes the project id, the track id (used to namespace the output
|
|
296
|
+
* cache), the track's source path, and an optional chunk duration in seconds.
|
|
297
|
+
* The host renders/caches the chunks and returns their resolvable paths. Maps
|
|
298
|
+
* to Montaj's `waveform_image` step.
|
|
299
|
+
*/
|
|
300
|
+
getWaveformChunks?(
|
|
301
|
+
projectId: string,
|
|
302
|
+
trackId: string,
|
|
303
|
+
trackSrc: string,
|
|
304
|
+
chunkDurationS?: number,
|
|
305
|
+
): Promise<WaveformChunk[]>
|
|
306
|
+
|
|
307
|
+
/**
|
|
308
|
+
* Optional: invalidate the host's compiled-overlay cache. When `src` is given,
|
|
309
|
+
* only that entry is dropped; hosts may treat a missing `src` as a no-op or a
|
|
310
|
+
* full clear. Maps to Montaj's `clearOverlayCache` in `lib/overlay-eval`.
|
|
311
|
+
*/
|
|
312
|
+
clearOverlayCache?(src?: string): void
|
|
313
|
+
|
|
314
|
+
/**
|
|
315
|
+
* Optional: resolve the template identifier that `compileOverlay` should
|
|
316
|
+
* receive for a given caption style name. The mapping is host-specific —
|
|
317
|
+
* Montaj uses `/api/caption-template/<style>`; other hosts may differ.
|
|
318
|
+
* When absent the editor renders no captions (graceful no-op). Hosts without
|
|
319
|
+
* caption support omit this entirely.
|
|
320
|
+
*/
|
|
321
|
+
resolveCaptionTemplate?(style: string): string
|
|
322
|
+
}
|
|
323
|
+
|
|
324
|
+
// ── Theme ────────────────────────────────────────────────────────────────────
|
|
325
|
+
|
|
326
|
+
/**
|
|
327
|
+
* A flat token record describing the editor's visual language. The host passes
|
|
328
|
+
* one of these (or relies on the Montaj default). `applyTheme` (in theme.ts)
|
|
329
|
+
* writes these tokens as CSS custom properties so styling stays declarative and
|
|
330
|
+
* host-overridable.
|
|
331
|
+
*/
|
|
332
|
+
export interface EditorTheme {
|
|
333
|
+
colors: {
|
|
334
|
+
/** Outermost canvas/page background. */
|
|
335
|
+
background: string
|
|
336
|
+
/** Raised panels, toolbars, inspectors. */
|
|
337
|
+
surface: string
|
|
338
|
+
/** Primary interactive/brand accent. */
|
|
339
|
+
accent: string
|
|
340
|
+
/** Default text color. */
|
|
341
|
+
text: string
|
|
342
|
+
/** Hairline/divider color. */
|
|
343
|
+
border: string
|
|
344
|
+
/** Selection outline / active-element highlight. */
|
|
345
|
+
selection: string
|
|
346
|
+
}
|
|
347
|
+
fonts: {
|
|
348
|
+
sans: string
|
|
349
|
+
serif?: string
|
|
350
|
+
display?: string
|
|
351
|
+
}
|
|
352
|
+
/** Border-radius scale, smallest → largest. */
|
|
353
|
+
radii: {
|
|
354
|
+
sm: string
|
|
355
|
+
md: string
|
|
356
|
+
lg: string
|
|
357
|
+
}
|
|
358
|
+
/**
|
|
359
|
+
* Spacing scale keyed by step. Indices follow a 4px-base rhythm (matching
|
|
360
|
+
* Tailwind's `1`=4px, `2`=8px, …). Values are CSS lengths.
|
|
361
|
+
*/
|
|
362
|
+
spacing: Record<number, string>
|
|
363
|
+
}
|
|
364
|
+
|
|
365
|
+
// ── Host-injected UI ──────────────────────────────────────────────────────────
|
|
366
|
+
|
|
367
|
+
/**
|
|
368
|
+
* Optional UI the host injects into editor slots — e.g. a "Publish to Hub"
|
|
369
|
+
* button in the toolbar, or app-specific export controls.
|
|
370
|
+
*/
|
|
371
|
+
export interface EditorSlots {
|
|
372
|
+
/** Rendered into the editor toolbar's action area. */
|
|
373
|
+
toolbarActions?: ReactNode
|
|
374
|
+
/** Rendered into the editor's export/render action area. */
|
|
375
|
+
exportActions?: ReactNode
|
|
376
|
+
/** Rendered into the editor's assets/media panel area. */
|
|
377
|
+
assetsPanel?: ReactNode
|
|
378
|
+
/**
|
|
379
|
+
* Rendered in the pending/empty view in place of the default
|
|
380
|
+
* "Message your agent to start" copy. Hosts use this to surface live agent
|
|
381
|
+
* progress (Montaj feeds its SSE log line here); absent → default copy shows.
|
|
382
|
+
*/
|
|
383
|
+
pendingStatus?: ReactNode
|
|
384
|
+
/**
|
|
385
|
+
* Rendered in the right sidebar below the version-history panel — in the same
|
|
386
|
+
* position ReviewView showed "Previous runs". The host supplies the concrete
|
|
387
|
+
* Montaj run-snapshot list (reading `project.history: RunSnapshot[]` and
|
|
388
|
+
* offering a "Restore this run" action via `onProjectChange`). The package
|
|
389
|
+
* never reads `project.history` or `RunSnapshot` — those are host-only types.
|
|
390
|
+
* Absent → nothing is rendered in that slot.
|
|
391
|
+
*/
|
|
392
|
+
runHistory?: ReactNode
|
|
393
|
+
}
|
|
394
|
+
|
|
395
|
+
// ── Top-level component props ──────────────────────────────────────────────────
|
|
396
|
+
|
|
397
|
+
/**
|
|
398
|
+
* Props for the carousel editor component. Controlled shape: the host owns the
|
|
399
|
+
* `project` and is notified of edits via `onProjectChange`. The adapter drives
|
|
400
|
+
* transport; theme and slots are optional, and `readOnly` disables mutation.
|
|
401
|
+
*/
|
|
402
|
+
export interface CarouselEditorProps<P extends Project = Project> {
|
|
403
|
+
project: P
|
|
404
|
+
adapter: EditorAdapter<P>
|
|
405
|
+
onProjectChange?: (p: P) => void
|
|
406
|
+
theme?: EditorTheme
|
|
407
|
+
slots?: EditorSlots
|
|
408
|
+
readOnly?: boolean
|
|
409
|
+
/**
|
|
410
|
+
* Editor-only set of element ids to hide from the interactive canvas. The host
|
|
411
|
+
* owns this state; the package never persists it (hidden elements are omitted
|
|
412
|
+
* from the canvas render only, never from `saveProject`). Lets a host
|
|
413
|
+
* temporarily hide a scrim/background to position overlays beneath it.
|
|
414
|
+
*/
|
|
415
|
+
hiddenElementIds?: string[]
|
|
416
|
+
/**
|
|
417
|
+
* Invoked when the user toggles the selected element's editor-visibility via
|
|
418
|
+
* the property-panel eye button. The host updates its hidden-set and reflects
|
|
419
|
+
* it back through `hiddenElementIds`. Absent → no eye toggle is rendered.
|
|
420
|
+
*/
|
|
421
|
+
onToggleElementVisibility?: (elementId: string) => void
|
|
422
|
+
/**
|
|
423
|
+
* Invoked whenever the selected element changes — with the element, or `null`
|
|
424
|
+
* when selection clears. Lets a host drive selection-aware chrome (e.g. a
|
|
425
|
+
* "regenerate image" action in a toolbar slot that targets the current
|
|
426
|
+
* selection). The package keeps owning selection state.
|
|
427
|
+
*/
|
|
428
|
+
onSelectionChange?: (element: CarouselElement | null) => void
|
|
429
|
+
}
|
|
430
|
+
|
|
431
|
+
/**
|
|
432
|
+
* Props for the video editor component. Mirrors `CarouselEditorProps` —
|
|
433
|
+
* controlled `project` + `onProjectChange`, adapter-driven transport, optional
|
|
434
|
+
* theme/slots/readOnly — and adds `onBackToSetup`, the host-supplied callback
|
|
435
|
+
* the editor invokes when the user leaves the editor for the project's setup
|
|
436
|
+
* view.
|
|
437
|
+
*/
|
|
438
|
+
export interface VideoEditorProps<P extends Project = Project> {
|
|
439
|
+
project: P
|
|
440
|
+
adapter: EditorAdapter<P>
|
|
441
|
+
onProjectChange?: (p: P) => void
|
|
442
|
+
theme?: EditorTheme
|
|
443
|
+
slots?: EditorSlots
|
|
444
|
+
readOnly?: boolean
|
|
445
|
+
onBackToSetup?: () => void
|
|
446
|
+
|
|
447
|
+
// ── Host-supplied Montaj-specific UI (render-prop seams) ──────────────────
|
|
448
|
+
// The clip/audio inspector and the subcut-regeneration tool read host-only
|
|
449
|
+
// fields (regenQueue, storyboard, the host's full Project) the package types
|
|
450
|
+
// don't know. The editor surfaces them as render-props it threads/renders so
|
|
451
|
+
// those components can stay host-side; the editor stays Montaj-agnostic.
|
|
452
|
+
|
|
453
|
+
/**
|
|
454
|
+
* Render-prop seam for the host's clip/audio inspector (Montaj's
|
|
455
|
+
* ClipInspectModal). The editor owns the "which item is being inspected"
|
|
456
|
+
* state — it derives `ctx.item` from the timeline's `onInspectClip` /
|
|
457
|
+
* `onInspectAudio` callbacks (a Montaj-agnostic `{ kind, id }` selector, not
|
|
458
|
+
* a project entity) and passes a close callback. Absent → no inspector.
|
|
459
|
+
*/
|
|
460
|
+
renderClipInspector?: (ctx: {
|
|
461
|
+
item: { kind: 'clip' | 'audio'; id: string }
|
|
462
|
+
onClose: () => void
|
|
463
|
+
}) => ReactNode
|
|
464
|
+
|
|
465
|
+
/**
|
|
466
|
+
* Render-prop seam for the host's subcut-regeneration tool (Montaj's
|
|
467
|
+
* SubcutRegenTool). Threaded straight through to the timeline, which owns the
|
|
468
|
+
* open/close trigger (the per-clip Scissors button). Called with the clip id
|
|
469
|
+
* and a close callback. Absent → the subcut tool isn't rendered.
|
|
470
|
+
*/
|
|
471
|
+
renderSubcutRegen?: (ctx: { clipId: string; onClose: () => void }) => ReactNode
|
|
472
|
+
|
|
473
|
+
/**
|
|
474
|
+
* Host-computed gate for the per-clip subcut-regenerate affordance (Montaj:
|
|
475
|
+
* ai_video projects). Threaded to the timeline. The package never reads
|
|
476
|
+
* `projectType`.
|
|
477
|
+
*/
|
|
478
|
+
regenEnabled?: boolean
|
|
479
|
+
|
|
480
|
+
/**
|
|
481
|
+
* Host-computed predicate driving the per-clip "queued" badge (Montaj:
|
|
482
|
+
* project.regenQueue membership). Threaded to the timeline. The package never
|
|
483
|
+
* reads `regenQueue`.
|
|
484
|
+
*/
|
|
485
|
+
isClipQueued?: (itemId: string) => boolean
|
|
486
|
+
}
|
|
@@ -0,0 +1,17 @@
|
|
|
1
|
+
import { describe, it, expect, vi } from 'vitest'
|
|
2
|
+
import { render, screen, fireEvent } from '@testing-library/react'
|
|
3
|
+
import { Button } from '../button'
|
|
4
|
+
|
|
5
|
+
describe('vendored Button', () => {
|
|
6
|
+
it('renders its children', () => {
|
|
7
|
+
render(<Button>Click me</Button>)
|
|
8
|
+
expect(screen.getByRole('button', { name: 'Click me' })).toBeInTheDocument()
|
|
9
|
+
})
|
|
10
|
+
|
|
11
|
+
it('fires onClick when clicked', () => {
|
|
12
|
+
const onClick = vi.fn()
|
|
13
|
+
render(<Button onClick={onClick}>Go</Button>)
|
|
14
|
+
fireEvent.click(screen.getByRole('button', { name: 'Go' }))
|
|
15
|
+
expect(onClick).toHaveBeenCalledTimes(1)
|
|
16
|
+
})
|
|
17
|
+
})
|
package/src/ui/badge.tsx
ADDED
|
@@ -0,0 +1,32 @@
|
|
|
1
|
+
import { cva, type VariantProps } from 'class-variance-authority'
|
|
2
|
+
import { cn } from './utils'
|
|
3
|
+
|
|
4
|
+
const badgeVariants = cva(
|
|
5
|
+
'inline-flex items-center rounded px-2 py-0.5 text-xs font-semibold',
|
|
6
|
+
{
|
|
7
|
+
variants: {
|
|
8
|
+
variant: {
|
|
9
|
+
pending: 'bg-amber-100 text-amber-700 dark:bg-amber-900/50 dark:text-amber-300',
|
|
10
|
+
draft: 'bg-blue-100 text-blue-700 dark:bg-blue-900/50 dark:text-blue-300',
|
|
11
|
+
final: 'bg-emerald-100 text-emerald-700 dark:bg-green-900/50 dark:text-green-300',
|
|
12
|
+
default: 'bg-gray-100 text-gray-600 dark:bg-gray-700 dark:text-gray-300',
|
|
13
|
+
},
|
|
14
|
+
},
|
|
15
|
+
defaultVariants: { variant: 'default' },
|
|
16
|
+
},
|
|
17
|
+
)
|
|
18
|
+
|
|
19
|
+
export interface BadgeProps
|
|
20
|
+
extends React.HTMLAttributes<HTMLSpanElement>,
|
|
21
|
+
VariantProps<typeof badgeVariants> {}
|
|
22
|
+
|
|
23
|
+
export function Badge({ className, variant, ...props }: BadgeProps) {
|
|
24
|
+
return <span className={cn(badgeVariants({ variant }), className)} {...props} />
|
|
25
|
+
}
|
|
26
|
+
|
|
27
|
+
export function StatusBadge({ status }: { status: string }) {
|
|
28
|
+
const variant = ['pending', 'draft', 'final'].includes(status)
|
|
29
|
+
? (status as 'pending' | 'draft' | 'final')
|
|
30
|
+
: 'default'
|
|
31
|
+
return <Badge variant={variant}>{status}</Badge>
|
|
32
|
+
}
|
|
@@ -0,0 +1,32 @@
|
|
|
1
|
+
import { cva, type VariantProps } from 'class-variance-authority'
|
|
2
|
+
import { cn } from './utils'
|
|
3
|
+
|
|
4
|
+
const buttonVariants = cva(
|
|
5
|
+
'inline-flex items-center justify-center rounded-md text-sm font-medium transition-colors focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-blue-500 disabled:pointer-events-none disabled:opacity-50',
|
|
6
|
+
{
|
|
7
|
+
variants: {
|
|
8
|
+
variant: {
|
|
9
|
+
default: 'bg-blue-600 text-white hover:bg-blue-700',
|
|
10
|
+
secondary: 'bg-gray-100 text-gray-700 hover:bg-gray-200 dark:bg-gray-700 dark:text-gray-100 dark:hover:bg-gray-600',
|
|
11
|
+
ghost: 'text-gray-500 hover:bg-gray-100 hover:text-gray-900 dark:text-gray-400 dark:hover:bg-gray-800 dark:hover:text-gray-100',
|
|
12
|
+
danger: 'bg-red-600 text-white hover:bg-red-700',
|
|
13
|
+
outline: 'border border-gray-300 dark:border-gray-700 text-gray-700 dark:text-gray-300 hover:bg-gray-100 dark:hover:bg-gray-800',
|
|
14
|
+
},
|
|
15
|
+
size: {
|
|
16
|
+
default: 'h-9 px-4 py-2',
|
|
17
|
+
sm: 'h-7 px-3 text-xs',
|
|
18
|
+
lg: 'h-11 px-6',
|
|
19
|
+
icon: 'h-9 w-9',
|
|
20
|
+
},
|
|
21
|
+
},
|
|
22
|
+
defaultVariants: { variant: 'default', size: 'default' },
|
|
23
|
+
},
|
|
24
|
+
)
|
|
25
|
+
|
|
26
|
+
export interface ButtonProps
|
|
27
|
+
extends React.ButtonHTMLAttributes<HTMLButtonElement>,
|
|
28
|
+
VariantProps<typeof buttonVariants> {}
|
|
29
|
+
|
|
30
|
+
export function Button({ className, variant, size, ...props }: ButtonProps) {
|
|
31
|
+
return <button className={cn(buttonVariants({ variant, size }), className)} {...props} />
|
|
32
|
+
}
|
package/src/ui/index.ts
ADDED
|
@@ -0,0 +1,16 @@
|
|
|
1
|
+
// Vendored UI primitives — self-contained copies of Montaj's shadcn-style
|
|
2
|
+
// components so the package has no `@/` host dependency. Tailwind classes are
|
|
3
|
+
// preserved verbatim; the host supplies the matching Tailwind theme.
|
|
4
|
+
export { cn } from './utils'
|
|
5
|
+
export { Button } from './button'
|
|
6
|
+
export type { ButtonProps } from './button'
|
|
7
|
+
export { Input } from './input'
|
|
8
|
+
export type { InputProps } from './input'
|
|
9
|
+
export { Label } from './label'
|
|
10
|
+
export { Select } from './select'
|
|
11
|
+
export type { SelectProps } from './select'
|
|
12
|
+
export { Switch } from './switch'
|
|
13
|
+
export { Textarea } from './textarea'
|
|
14
|
+
export type { TextareaProps } from './textarea'
|
|
15
|
+
export { Badge, StatusBadge } from './badge'
|
|
16
|
+
export type { BadgeProps } from './badge'
|
package/src/ui/input.tsx
ADDED
|
@@ -0,0 +1,15 @@
|
|
|
1
|
+
import { cn } from './utils'
|
|
2
|
+
|
|
3
|
+
export type InputProps = React.InputHTMLAttributes<HTMLInputElement>
|
|
4
|
+
|
|
5
|
+
export function Input({ className, ...props }: InputProps) {
|
|
6
|
+
return (
|
|
7
|
+
<input
|
|
8
|
+
className={cn(
|
|
9
|
+
'flex h-9 w-full rounded-md border border-gray-300 bg-white px-3 py-1 text-sm text-gray-900 placeholder:text-gray-400 focus:outline-none focus:ring-2 focus:ring-blue-500 disabled:opacity-50 dark:border-gray-700 dark:bg-gray-800 dark:text-gray-100 dark:placeholder:text-gray-500',
|
|
10
|
+
className,
|
|
11
|
+
)}
|
|
12
|
+
{...props}
|
|
13
|
+
/>
|
|
14
|
+
)
|
|
15
|
+
}
|
package/src/ui/label.tsx
ADDED
|
@@ -0,0 +1,23 @@
|
|
|
1
|
+
import { cn } from './utils'
|
|
2
|
+
|
|
3
|
+
export interface SelectProps extends React.SelectHTMLAttributes<HTMLSelectElement> {
|
|
4
|
+
options: Array<{ value: string; label: string }>
|
|
5
|
+
}
|
|
6
|
+
|
|
7
|
+
export function Select({ className, options, ...props }: SelectProps) {
|
|
8
|
+
return (
|
|
9
|
+
<select
|
|
10
|
+
className={cn(
|
|
11
|
+
'flex h-9 w-full rounded-md border border-gray-300 bg-white px-3 py-1 text-sm text-gray-900 focus:outline-none focus:ring-2 focus:ring-blue-500 disabled:opacity-50 dark:border-gray-700 dark:bg-gray-800 dark:text-gray-100',
|
|
12
|
+
className,
|
|
13
|
+
)}
|
|
14
|
+
{...props}
|
|
15
|
+
>
|
|
16
|
+
{options.map((o) => (
|
|
17
|
+
<option key={o.value} value={o.value}>
|
|
18
|
+
{o.label}
|
|
19
|
+
</option>
|
|
20
|
+
))}
|
|
21
|
+
</select>
|
|
22
|
+
)
|
|
23
|
+
}
|
|
@@ -0,0 +1,31 @@
|
|
|
1
|
+
import { cn } from './utils'
|
|
2
|
+
|
|
3
|
+
interface SwitchProps {
|
|
4
|
+
checked: boolean
|
|
5
|
+
onCheckedChange: (v: boolean) => void
|
|
6
|
+
className?: string
|
|
7
|
+
disabled?: boolean
|
|
8
|
+
}
|
|
9
|
+
|
|
10
|
+
export function Switch({ checked, onCheckedChange, className, disabled }: SwitchProps) {
|
|
11
|
+
return (
|
|
12
|
+
<button
|
|
13
|
+
role="switch"
|
|
14
|
+
aria-checked={checked}
|
|
15
|
+
disabled={disabled}
|
|
16
|
+
onClick={() => onCheckedChange(!checked)}
|
|
17
|
+
className={cn(
|
|
18
|
+
'relative inline-flex h-5 w-9 items-center rounded-full transition-colors focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-blue-500 disabled:opacity-50',
|
|
19
|
+
checked ? 'bg-blue-600' : 'bg-gray-300 dark:bg-gray-700',
|
|
20
|
+
className,
|
|
21
|
+
)}
|
|
22
|
+
>
|
|
23
|
+
<span
|
|
24
|
+
className={cn(
|
|
25
|
+
'inline-block h-3.5 w-3.5 rounded-full bg-white shadow transition-transform',
|
|
26
|
+
checked ? 'translate-x-4' : 'translate-x-1',
|
|
27
|
+
)}
|
|
28
|
+
/>
|
|
29
|
+
</button>
|
|
30
|
+
)
|
|
31
|
+
}
|