@devbycrux/editor 0.1.0
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/carousel/AddElementMenu.tsx +211 -0
- package/src/carousel/CarouselEditor.tsx +529 -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/SlideCanvas.tsx +588 -0
- package/src/carousel/SlidePropertyPanel.tsx +349 -0
- package/src/carousel/__tests__/CarouselEditor.test.tsx +235 -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 +112 -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 +194 -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 +325 -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/README.md
ADDED
|
@@ -0,0 +1,165 @@
|
|
|
1
|
+
# @devbycrux/editor
|
|
2
|
+
|
|
3
|
+
Host-agnostic carousel editor for the ByCrux platform. Ships raw TypeScript/TSX source — the consuming application is responsible for transpilation.
|
|
4
|
+
|
|
5
|
+
The package owns:
|
|
6
|
+
- The editor-facing project/slide/element schema (`./schema`)
|
|
7
|
+
- The `EditorAdapter` and `EditorTheme` contracts
|
|
8
|
+
- All editor UI components (carousel editor, crop, text formatting, overlay preview)
|
|
9
|
+
- State management (reducer + mutation queue + `useProjectState`)
|
|
10
|
+
|
|
11
|
+
**Video editing is not yet included** — that is a future phase. Today the package covers carousel slides only.
|
|
12
|
+
|
|
13
|
+
---
|
|
14
|
+
|
|
15
|
+
## Install
|
|
16
|
+
|
|
17
|
+
The package is published publicly to the npm registry under the `@devbycrux` org.
|
|
18
|
+
|
|
19
|
+
```bash
|
|
20
|
+
npm install @devbycrux/editor
|
|
21
|
+
```
|
|
22
|
+
|
|
23
|
+
---
|
|
24
|
+
|
|
25
|
+
## Required consumer configuration
|
|
26
|
+
|
|
27
|
+
The package ships raw source and uses Tailwind utility classes internally. Consumers must configure three things.
|
|
28
|
+
|
|
29
|
+
### 1. Tailwind content glob
|
|
30
|
+
|
|
31
|
+
Add the package source to your Tailwind `content` array so utility classes are not purged:
|
|
32
|
+
|
|
33
|
+
```ts
|
|
34
|
+
// tailwind.config.ts
|
|
35
|
+
export default {
|
|
36
|
+
content: [
|
|
37
|
+
'./src/**/*.{ts,tsx}',
|
|
38
|
+
'node_modules/@devbycrux/editor/src/**/*.{ts,tsx}', // required
|
|
39
|
+
],
|
|
40
|
+
// ...
|
|
41
|
+
}
|
|
42
|
+
```
|
|
43
|
+
|
|
44
|
+
### 2. React deduplication (Vite)
|
|
45
|
+
|
|
46
|
+
The package lists `react` and `react-dom` as peer dependencies. In a Vite project, deduplicate them to guarantee a single React instance:
|
|
47
|
+
|
|
48
|
+
```ts
|
|
49
|
+
// vite.config.ts
|
|
50
|
+
export default defineConfig({
|
|
51
|
+
resolve: {
|
|
52
|
+
dedupe: ['react', 'react-dom'],
|
|
53
|
+
},
|
|
54
|
+
optimizeDeps: {
|
|
55
|
+
include: ['@devbycrux/editor'],
|
|
56
|
+
},
|
|
57
|
+
})
|
|
58
|
+
```
|
|
59
|
+
|
|
60
|
+
### 3. Next.js transpilePackages
|
|
61
|
+
|
|
62
|
+
For Next.js consumers, add the package to `transpilePackages` so Next's compiler handles the raw TypeScript source:
|
|
63
|
+
|
|
64
|
+
```ts
|
|
65
|
+
// next.config.ts
|
|
66
|
+
const nextConfig = {
|
|
67
|
+
transpilePackages: ['@devbycrux/editor'],
|
|
68
|
+
}
|
|
69
|
+
export default nextConfig
|
|
70
|
+
```
|
|
71
|
+
|
|
72
|
+
---
|
|
73
|
+
|
|
74
|
+
## Usage
|
|
75
|
+
|
|
76
|
+
### Implementing an EditorAdapter
|
|
77
|
+
|
|
78
|
+
The editor is host-agnostic. You supply an `EditorAdapter` that wires up your transport, auth, and URL resolution. The interface is generic over your concrete project type `P extends EditorProject`, so host-specific fields survive load→edit→save round-trips at the type level.
|
|
79
|
+
|
|
80
|
+
**Required methods:**
|
|
81
|
+
|
|
82
|
+
| Method | Description |
|
|
83
|
+
|---|---|
|
|
84
|
+
| `loadProject(id)` | Fetch the full project by id |
|
|
85
|
+
| `saveProject(id, project)` | Persist the full project |
|
|
86
|
+
| `subscribe(id, onFrame)` | Subscribe to live project frames (e.g. SSE); returns unsubscribe |
|
|
87
|
+
| `render(id, opts?)` | Start a render; returns `AsyncIterable<RenderEvent>` |
|
|
88
|
+
| `resolveImageSrc(element)` | Map an `ImageElement` to a displayable URL |
|
|
89
|
+
| `compileOverlay(template)` | Compile a JSX overlay template → `OverlayFactory`; host-supplied (editor carries no overlay runtime / Three.js) |
|
|
90
|
+
| `listGlobalOverlays()` | List workspace-wide overlay templates |
|
|
91
|
+
| `listSystemOverlays()` | List built-in/system overlay templates |
|
|
92
|
+
| `uploadFile(file, projectId?)` | Upload a file; returns a host-resolvable path |
|
|
93
|
+
| `fileUrl(path)` | Map a raw host path to a fetchable URL (synchronous) |
|
|
94
|
+
|
|
95
|
+
**Optional methods:**
|
|
96
|
+
|
|
97
|
+
| Method | Description |
|
|
98
|
+
|---|---|
|
|
99
|
+
| `listMedia?(scope)` | List media items in the given scope (`universal` or `project`) |
|
|
100
|
+
| `listProfileOverlays?(profileName)` | List overlay templates scoped to a named profile |
|
|
101
|
+
| `getInfo?()` | Return host environment info (e.g. `root_skill_path`) |
|
|
102
|
+
| `generateImage?(prompt, projectId)` | AI image generation; editor feature-detects presence |
|
|
103
|
+
|
|
104
|
+
### Rendering the editor
|
|
105
|
+
|
|
106
|
+
```tsx
|
|
107
|
+
import { CarouselEditor } from '@devbycrux/editor'
|
|
108
|
+
import type { EditorAdapter, EditorProject } from '@devbycrux/editor'
|
|
109
|
+
|
|
110
|
+
// Your adapter wired to your host's transport
|
|
111
|
+
const adapter: EditorAdapter = { /* ... */ }
|
|
112
|
+
|
|
113
|
+
function MyEditorPage() {
|
|
114
|
+
const [project, setProject] = useState<EditorProject | null>(null)
|
|
115
|
+
|
|
116
|
+
return project ? (
|
|
117
|
+
<CarouselEditor
|
|
118
|
+
project={project}
|
|
119
|
+
adapter={adapter}
|
|
120
|
+
onProjectChange={setProject}
|
|
121
|
+
theme={myTheme} // optional — defaults to Montaj dark theme
|
|
122
|
+
slots={mySlots} // optional — inject host UI into editor slots
|
|
123
|
+
/>
|
|
124
|
+
) : null
|
|
125
|
+
}
|
|
126
|
+
```
|
|
127
|
+
|
|
128
|
+
### EditorSlots
|
|
129
|
+
|
|
130
|
+
The `slots` prop lets you inject host-specific UI into named regions inside the assembled editor:
|
|
131
|
+
|
|
132
|
+
| Slot | Where it renders |
|
|
133
|
+
|---|---|
|
|
134
|
+
| `toolbarActions` | Editor toolbar action area |
|
|
135
|
+
| `exportActions` | Export / render action area |
|
|
136
|
+
| `assetsPanel` | Assets / media panel area |
|
|
137
|
+
| `pendingStatus` | Pending/empty view; replaces default "Message your agent" copy (e.g. live agent progress) |
|
|
138
|
+
|
|
139
|
+
### Generic project type
|
|
140
|
+
|
|
141
|
+
If your host carries fields beyond `EditorProject`, pass your richer type through the generic:
|
|
142
|
+
|
|
143
|
+
```ts
|
|
144
|
+
interface MyProject extends EditorProject {
|
|
145
|
+
pipelineId: string
|
|
146
|
+
tenantId: string
|
|
147
|
+
}
|
|
148
|
+
|
|
149
|
+
const adapter: EditorAdapter<MyProject> = { /* ... */ }
|
|
150
|
+
|
|
151
|
+
<CarouselEditor<MyProject>
|
|
152
|
+
project={myProject}
|
|
153
|
+
adapter={adapter}
|
|
154
|
+
onProjectChange={setMyProject}
|
|
155
|
+
/>
|
|
156
|
+
```
|
|
157
|
+
|
|
158
|
+
Your extra fields survive the edit cycle without casts.
|
|
159
|
+
|
|
160
|
+
---
|
|
161
|
+
|
|
162
|
+
## Notes
|
|
163
|
+
|
|
164
|
+
- `compileOverlay` is **always host-supplied**. The package carries no overlay runtime, no Three.js, and no Babel compiler. Montaj provides its own `lib/overlay-eval` implementation; Hub-side consumers will supply their own.
|
|
165
|
+
- The `./schema` export (`import ... from '@devbycrux/editor/schema'`) is the single source of truth for project/slide/element shapes and can be imported independently of the React components.
|
package/package.json
ADDED
|
@@ -0,0 +1,46 @@
|
|
|
1
|
+
{
|
|
2
|
+
"name": "@devbycrux/editor",
|
|
3
|
+
"version": "0.1.0",
|
|
4
|
+
"private": false,
|
|
5
|
+
"type": "module",
|
|
6
|
+
"exports": {
|
|
7
|
+
".": "./src/index.ts",
|
|
8
|
+
"./schema": "./src/schema.ts"
|
|
9
|
+
},
|
|
10
|
+
"files": [
|
|
11
|
+
"src",
|
|
12
|
+
"README.md"
|
|
13
|
+
],
|
|
14
|
+
"sideEffects": false,
|
|
15
|
+
"scripts": {
|
|
16
|
+
"test": "vitest run",
|
|
17
|
+
"lint": "eslint src"
|
|
18
|
+
},
|
|
19
|
+
"dependencies": {
|
|
20
|
+
"class-variance-authority": "^0.7",
|
|
21
|
+
"clsx": "^2",
|
|
22
|
+
"lucide-react": "^0.400",
|
|
23
|
+
"tailwind-merge": "^2"
|
|
24
|
+
},
|
|
25
|
+
"peerDependencies": {
|
|
26
|
+
"react": "^19.0.0",
|
|
27
|
+
"react-dom": "^19.0.0"
|
|
28
|
+
},
|
|
29
|
+
"devDependencies": {
|
|
30
|
+
"@testing-library/jest-dom": "^6",
|
|
31
|
+
"@testing-library/react": "^16",
|
|
32
|
+
"@types/react": "^19",
|
|
33
|
+
"@types/react-dom": "^19",
|
|
34
|
+
"@vitejs/plugin-react": "^4",
|
|
35
|
+
"eslint": "^9",
|
|
36
|
+
"jsdom": "^26",
|
|
37
|
+
"react": "^19.0.0",
|
|
38
|
+
"react-dom": "^19.0.0",
|
|
39
|
+
"typescript": "^5",
|
|
40
|
+
"typescript-eslint": "^8.61.1",
|
|
41
|
+
"vitest": "^2"
|
|
42
|
+
},
|
|
43
|
+
"publishConfig": {
|
|
44
|
+
"access": "public"
|
|
45
|
+
}
|
|
46
|
+
}
|
|
@@ -0,0 +1,123 @@
|
|
|
1
|
+
import { describe, it, expect } from 'vitest'
|
|
2
|
+
import type {
|
|
3
|
+
EditorAdapter,
|
|
4
|
+
RenderEvent,
|
|
5
|
+
RenderOptions,
|
|
6
|
+
MediaItem,
|
|
7
|
+
MediaScope,
|
|
8
|
+
GlobalOverlay,
|
|
9
|
+
} from '../types'
|
|
10
|
+
import type { EditorProject, ImageElement } from '../schema'
|
|
11
|
+
|
|
12
|
+
// ── Full adapter contract ─────────────────────────────────────────────────────
|
|
13
|
+
// A fake object implementing the FULL EditorAdapter<EditorProject> — every
|
|
14
|
+
// required method, including the ones T4 adds — must type-check. This file
|
|
15
|
+
// fails to compile if a required method is missing or mistyped.
|
|
16
|
+
|
|
17
|
+
const project: EditorProject = {
|
|
18
|
+
version: '1',
|
|
19
|
+
id: 'p1',
|
|
20
|
+
status: 'draft' as EditorProject['status'],
|
|
21
|
+
name: 'Fake',
|
|
22
|
+
workflow: 'carousel',
|
|
23
|
+
editingPrompt: '',
|
|
24
|
+
settings: { resolution: [1080, 1080] },
|
|
25
|
+
assets: [],
|
|
26
|
+
slides: [],
|
|
27
|
+
}
|
|
28
|
+
|
|
29
|
+
const overlay: GlobalOverlay = {
|
|
30
|
+
name: 'caption',
|
|
31
|
+
description: 'A caption overlay',
|
|
32
|
+
props: [
|
|
33
|
+
{ name: 'text', type: 'string', default: '', description: 'caption text' },
|
|
34
|
+
{ name: 'color', type: 'color', default: '#fff' },
|
|
35
|
+
],
|
|
36
|
+
jsxPath: '/overlays/caption.jsx',
|
|
37
|
+
}
|
|
38
|
+
|
|
39
|
+
// (a) FULL adapter: every required method present, optional ones present too.
|
|
40
|
+
function makeFullAdapter(): EditorAdapter<EditorProject> {
|
|
41
|
+
return {
|
|
42
|
+
loadProject: async (_id: string): Promise<EditorProject> => project,
|
|
43
|
+
saveProject: async (_id: string, _p: EditorProject): Promise<void> => {},
|
|
44
|
+
subscribe: (_id: string, _onFrame: (p: EditorProject) => void): (() => void) =>
|
|
45
|
+
() => {},
|
|
46
|
+
render: async function* (
|
|
47
|
+
_id: string,
|
|
48
|
+
_opts?: RenderOptions,
|
|
49
|
+
): AsyncIterable<RenderEvent> {
|
|
50
|
+
yield { type: 'done', outputPath: '/out/p1.mp4' }
|
|
51
|
+
},
|
|
52
|
+
resolveImageSrc: (el: ImageElement): string => el.src,
|
|
53
|
+
compileOverlay: async (_template: string) => () => null,
|
|
54
|
+
// New REQUIRED methods (T4).
|
|
55
|
+
listGlobalOverlays: async (): Promise<GlobalOverlay[]> => [overlay],
|
|
56
|
+
listSystemOverlays: async (): Promise<GlobalOverlay[]> => [],
|
|
57
|
+
uploadFile: async (_file: File, _projectId?: string): Promise<string> => '/path',
|
|
58
|
+
fileUrl: (path: string): string => `/api/files?path=${encodeURIComponent(path)}`,
|
|
59
|
+
// Optional methods present.
|
|
60
|
+
listMedia: async (_scope: MediaScope): Promise<MediaItem[]> => [],
|
|
61
|
+
listProfileOverlays: async (_profileName: string): Promise<GlobalOverlay[]> => [],
|
|
62
|
+
getInfo: async (): Promise<{ root_skill_path?: string }> => ({}),
|
|
63
|
+
generateImage: async (_prompt: string, _projectId: string): Promise<{ path: string }> => ({
|
|
64
|
+
path: '/gen.png',
|
|
65
|
+
}),
|
|
66
|
+
}
|
|
67
|
+
}
|
|
68
|
+
|
|
69
|
+
// (b) Minimal adapter: only required methods; optional ones omitted. Must still
|
|
70
|
+
// type-check.
|
|
71
|
+
function makeMinimalAdapter(): EditorAdapter<EditorProject> {
|
|
72
|
+
return {
|
|
73
|
+
loadProject: async (_id: string): Promise<EditorProject> => project,
|
|
74
|
+
saveProject: async (_id: string, _p: EditorProject): Promise<void> => {},
|
|
75
|
+
subscribe: (_id: string, _onFrame: (p: EditorProject) => void): (() => void) =>
|
|
76
|
+
() => {},
|
|
77
|
+
render: async function* (
|
|
78
|
+
_id: string,
|
|
79
|
+
_opts?: RenderOptions,
|
|
80
|
+
): AsyncIterable<RenderEvent> {
|
|
81
|
+
yield { type: 'done', outputPath: '/out/p1.mp4' }
|
|
82
|
+
},
|
|
83
|
+
resolveImageSrc: (el: ImageElement): string => el.src,
|
|
84
|
+
compileOverlay: async (_template: string) => () => null,
|
|
85
|
+
listGlobalOverlays: async (): Promise<GlobalOverlay[]> => [],
|
|
86
|
+
listSystemOverlays: async (): Promise<GlobalOverlay[]> => [],
|
|
87
|
+
uploadFile: async (_file: File): Promise<string> => '/path',
|
|
88
|
+
fileUrl: (path: string): string => path,
|
|
89
|
+
}
|
|
90
|
+
}
|
|
91
|
+
|
|
92
|
+
describe('EditorAdapter full contract', () => {
|
|
93
|
+
it('a full adapter implements every required + optional method', () => {
|
|
94
|
+
const a = makeFullAdapter()
|
|
95
|
+
expect(typeof a.listGlobalOverlays).toBe('function')
|
|
96
|
+
expect(typeof a.listSystemOverlays).toBe('function')
|
|
97
|
+
expect(typeof a.uploadFile).toBe('function')
|
|
98
|
+
expect(typeof a.fileUrl).toBe('function')
|
|
99
|
+
expect(typeof a.listProfileOverlays).toBe('function')
|
|
100
|
+
expect(typeof a.getInfo).toBe('function')
|
|
101
|
+
expect(typeof a.generateImage).toBe('function')
|
|
102
|
+
})
|
|
103
|
+
|
|
104
|
+
it('a minimal adapter omits the optional methods and still type-checks', () => {
|
|
105
|
+
const a = makeMinimalAdapter()
|
|
106
|
+
expect(a.listProfileOverlays).toBeUndefined()
|
|
107
|
+
expect(a.getInfo).toBeUndefined()
|
|
108
|
+
expect(a.generateImage).toBeUndefined()
|
|
109
|
+
expect(typeof a.listGlobalOverlays).toBe('function')
|
|
110
|
+
})
|
|
111
|
+
|
|
112
|
+
it('listGlobalOverlays resolves to GlobalOverlay[]', async () => {
|
|
113
|
+
const a = makeFullAdapter()
|
|
114
|
+
const overlays = await a.listGlobalOverlays()
|
|
115
|
+
expect(overlays[0].name).toBe('caption')
|
|
116
|
+
expect(overlays[0].props[0].type).toBe('string')
|
|
117
|
+
})
|
|
118
|
+
|
|
119
|
+
it('fileUrl maps a path to a string url', () => {
|
|
120
|
+
const a = makeFullAdapter()
|
|
121
|
+
expect(a.fileUrl('/ws/p1/x.png')).toContain('/api/files?path=')
|
|
122
|
+
})
|
|
123
|
+
})
|
|
@@ -0,0 +1,185 @@
|
|
|
1
|
+
import { describe, it, expect } from 'vitest'
|
|
2
|
+
import type {
|
|
3
|
+
EditorAdapter,
|
|
4
|
+
RenderEvent,
|
|
5
|
+
RenderOptions,
|
|
6
|
+
MediaItem,
|
|
7
|
+
MediaScope,
|
|
8
|
+
} from '../types'
|
|
9
|
+
import type { Project, ImageElement } from '../types'
|
|
10
|
+
import { defaultMontajTheme, applyTheme } from '../theme'
|
|
11
|
+
|
|
12
|
+
// ── (a) A fake adapter literal must satisfy the EditorAdapter interface ──────
|
|
13
|
+
// This test is purely structural: it type-checks at build time. The runtime
|
|
14
|
+
// assertions below also exercise the methods to keep the fake honest.
|
|
15
|
+
function makeFakeAdapter(): EditorAdapter {
|
|
16
|
+
const project: Project = {
|
|
17
|
+
version: '1',
|
|
18
|
+
id: 'p1',
|
|
19
|
+
status: 'draft' as Project['status'],
|
|
20
|
+
name: 'Fake',
|
|
21
|
+
workflow: 'carousel',
|
|
22
|
+
editingPrompt: '',
|
|
23
|
+
settings: { resolution: [1080, 1080] },
|
|
24
|
+
assets: [],
|
|
25
|
+
slides: [],
|
|
26
|
+
}
|
|
27
|
+
|
|
28
|
+
return {
|
|
29
|
+
loadProject: async (_id: string): Promise<Project> => project,
|
|
30
|
+
saveProject: async (_id: string, _project: Project): Promise<void> => {},
|
|
31
|
+
subscribe: (_id: string, _onFrame: (p: Project) => void): (() => void) => {
|
|
32
|
+
return () => {}
|
|
33
|
+
},
|
|
34
|
+
render: async function* (
|
|
35
|
+
_id: string,
|
|
36
|
+
_opts?: RenderOptions,
|
|
37
|
+
): AsyncIterable<RenderEvent> {
|
|
38
|
+
yield { type: 'log', message: 'starting' }
|
|
39
|
+
yield { type: 'done', outputPath: '/out/p1.mp4' }
|
|
40
|
+
},
|
|
41
|
+
// (b) Montaj-style resolver: returns a workspace/files URL string.
|
|
42
|
+
resolveImageSrc: (element: ImageElement): string =>
|
|
43
|
+
`/api/files?path=${encodeURIComponent(element.src)}`,
|
|
44
|
+
// Optional method present.
|
|
45
|
+
listMedia: async (_scope: MediaScope): Promise<MediaItem[]> => [],
|
|
46
|
+
compileOverlay: async (_template: string) => () => null,
|
|
47
|
+
// Required contract methods.
|
|
48
|
+
listGlobalOverlays: async () => [],
|
|
49
|
+
listSystemOverlays: async () => [],
|
|
50
|
+
uploadFile: async (_file: File, _projectId?: string) => '/path',
|
|
51
|
+
fileUrl: (path: string) => path,
|
|
52
|
+
}
|
|
53
|
+
}
|
|
54
|
+
|
|
55
|
+
describe('EditorAdapter', () => {
|
|
56
|
+
it('a fake adapter literal satisfies the interface', () => {
|
|
57
|
+
const adapter = makeFakeAdapter()
|
|
58
|
+
expect(typeof adapter.loadProject).toBe('function')
|
|
59
|
+
expect(typeof adapter.saveProject).toBe('function')
|
|
60
|
+
expect(typeof adapter.subscribe).toBe('function')
|
|
61
|
+
expect(typeof adapter.render).toBe('function')
|
|
62
|
+
expect(typeof adapter.resolveImageSrc).toBe('function')
|
|
63
|
+
})
|
|
64
|
+
|
|
65
|
+
it('subscribe returns an unsubscribe function', () => {
|
|
66
|
+
const adapter = makeFakeAdapter()
|
|
67
|
+
const unsub = adapter.subscribe('p1', () => {})
|
|
68
|
+
expect(typeof unsub).toBe('function')
|
|
69
|
+
// Calling it must not throw.
|
|
70
|
+
unsub()
|
|
71
|
+
})
|
|
72
|
+
|
|
73
|
+
it('loadProject resolves to a Project', async () => {
|
|
74
|
+
const adapter = makeFakeAdapter()
|
|
75
|
+
const p = await adapter.loadProject('p1')
|
|
76
|
+
expect(p.id).toBe('p1')
|
|
77
|
+
})
|
|
78
|
+
|
|
79
|
+
// (b) resolveImageSrc: both a workspace path string and an https presigned
|
|
80
|
+
// URL must satisfy the same signature (string in, string out).
|
|
81
|
+
it('resolveImageSrc supports a Montaj workspace path resolver', () => {
|
|
82
|
+
const montajResolver: EditorAdapter['resolveImageSrc'] = (el) =>
|
|
83
|
+
`/api/files?path=${encodeURIComponent(el.src)}`
|
|
84
|
+
const el: ImageElement = {
|
|
85
|
+
id: 'img1',
|
|
86
|
+
type: 'image',
|
|
87
|
+
src: '/workspace/p1/photo.jpg',
|
|
88
|
+
x: 0,
|
|
89
|
+
y: 0,
|
|
90
|
+
w: 100,
|
|
91
|
+
h: 100,
|
|
92
|
+
rotation: 0,
|
|
93
|
+
}
|
|
94
|
+
const out = montajResolver(el)
|
|
95
|
+
expect(out.startsWith('/api/files?path=')).toBe(true)
|
|
96
|
+
})
|
|
97
|
+
|
|
98
|
+
it('resolveImageSrc supports a Hub presigned-URL resolver', () => {
|
|
99
|
+
const presigned = 'https://bucket.s3.amazonaws.com/media/abc?X-Amz-Signature=xyz'
|
|
100
|
+
const hubResolver: EditorAdapter['resolveImageSrc'] = (_el) => presigned
|
|
101
|
+
const el: ImageElement = {
|
|
102
|
+
id: 'img2',
|
|
103
|
+
type: 'image',
|
|
104
|
+
src: 'mediaId:abc',
|
|
105
|
+
x: 0,
|
|
106
|
+
y: 0,
|
|
107
|
+
w: 100,
|
|
108
|
+
h: 100,
|
|
109
|
+
rotation: 0,
|
|
110
|
+
}
|
|
111
|
+
const out = hubResolver(el)
|
|
112
|
+
expect(out.startsWith('https://')).toBe(true)
|
|
113
|
+
})
|
|
114
|
+
})
|
|
115
|
+
|
|
116
|
+
// ── (d) RenderEvent union must narrow correctly per discriminant ─────────────
|
|
117
|
+
describe('RenderEvent discriminated union', () => {
|
|
118
|
+
function describeEvent(e: RenderEvent): string {
|
|
119
|
+
switch (e.type) {
|
|
120
|
+
case 'log':
|
|
121
|
+
return e.message
|
|
122
|
+
case 'done':
|
|
123
|
+
return e.outputPath
|
|
124
|
+
case 'error':
|
|
125
|
+
return e.message
|
|
126
|
+
}
|
|
127
|
+
}
|
|
128
|
+
|
|
129
|
+
it('narrows on the `type` discriminant', () => {
|
|
130
|
+
expect(describeEvent({ type: 'log', message: 'hi' })).toBe('hi')
|
|
131
|
+
expect(describeEvent({ type: 'done', outputPath: '/o.mp4' })).toBe('/o.mp4')
|
|
132
|
+
expect(describeEvent({ type: 'error', message: 'boom' })).toBe('boom')
|
|
133
|
+
})
|
|
134
|
+
})
|
|
135
|
+
|
|
136
|
+
// ── (c) applyTheme writes the expected CSS custom properties onto an element ─
|
|
137
|
+
describe('applyTheme', () => {
|
|
138
|
+
it('writes editor CSS vars onto a div', () => {
|
|
139
|
+
const el = document.createElement('div')
|
|
140
|
+
applyTheme(el, defaultMontajTheme)
|
|
141
|
+
|
|
142
|
+
expect(el.style.getPropertyValue('--editor-bg')).toBe(
|
|
143
|
+
defaultMontajTheme.colors.background,
|
|
144
|
+
)
|
|
145
|
+
expect(el.style.getPropertyValue('--editor-surface')).toBe(
|
|
146
|
+
defaultMontajTheme.colors.surface,
|
|
147
|
+
)
|
|
148
|
+
expect(el.style.getPropertyValue('--editor-accent')).toBe(
|
|
149
|
+
defaultMontajTheme.colors.accent,
|
|
150
|
+
)
|
|
151
|
+
expect(el.style.getPropertyValue('--editor-text')).toBe(
|
|
152
|
+
defaultMontajTheme.colors.text,
|
|
153
|
+
)
|
|
154
|
+
expect(el.style.getPropertyValue('--editor-border')).toBe(
|
|
155
|
+
defaultMontajTheme.colors.border,
|
|
156
|
+
)
|
|
157
|
+
expect(el.style.getPropertyValue('--editor-selection')).toBe(
|
|
158
|
+
defaultMontajTheme.colors.selection,
|
|
159
|
+
)
|
|
160
|
+
expect(el.style.getPropertyValue('--editor-font-sans')).toBe(
|
|
161
|
+
defaultMontajTheme.fonts.sans,
|
|
162
|
+
)
|
|
163
|
+
})
|
|
164
|
+
|
|
165
|
+
it('writes serif/display font vars only when present', () => {
|
|
166
|
+
const el = document.createElement('div')
|
|
167
|
+
applyTheme(el, {
|
|
168
|
+
...defaultMontajTheme,
|
|
169
|
+
fonts: { sans: 'Inter' },
|
|
170
|
+
})
|
|
171
|
+
expect(el.style.getPropertyValue('--editor-font-serif')).toBe('')
|
|
172
|
+
expect(el.style.getPropertyValue('--editor-font-display')).toBe('')
|
|
173
|
+
})
|
|
174
|
+
|
|
175
|
+
it('writes radii and spacing scale vars', () => {
|
|
176
|
+
const el = document.createElement('div')
|
|
177
|
+
applyTheme(el, defaultMontajTheme)
|
|
178
|
+
expect(el.style.getPropertyValue('--editor-radius-md')).toBe(
|
|
179
|
+
defaultMontajTheme.radii.md,
|
|
180
|
+
)
|
|
181
|
+
expect(el.style.getPropertyValue('--editor-space-2')).toBe(
|
|
182
|
+
defaultMontajTheme.spacing[2],
|
|
183
|
+
)
|
|
184
|
+
})
|
|
185
|
+
})
|
|
@@ -0,0 +1,104 @@
|
|
|
1
|
+
import { describe, it, expect } from 'vitest'
|
|
2
|
+
import type {
|
|
3
|
+
EditorProject,
|
|
4
|
+
ImageElement,
|
|
5
|
+
OverlayElement,
|
|
6
|
+
Slide,
|
|
7
|
+
} from '../schema'
|
|
8
|
+
|
|
9
|
+
// Compile-time assignability helper: if the argument doesn't satisfy T,
|
|
10
|
+
// this fails to type-check (and thus fails `tsc`/build, which vitest runs through).
|
|
11
|
+
function expectAssignable<T>(value: T): T {
|
|
12
|
+
return value
|
|
13
|
+
}
|
|
14
|
+
|
|
15
|
+
describe('editor schema', () => {
|
|
16
|
+
it('accepts an object with the editor-facing fields as EditorProject', () => {
|
|
17
|
+
const project: EditorProject = {
|
|
18
|
+
id: 'p1',
|
|
19
|
+
status: 'draft',
|
|
20
|
+
settings: { resolution: [1080, 1080], fps: 30 },
|
|
21
|
+
name: 'My carousel',
|
|
22
|
+
editingPrompt: 'make it pop',
|
|
23
|
+
slides: [],
|
|
24
|
+
captions: { style: 'subtitle', segments: [] },
|
|
25
|
+
assets: [],
|
|
26
|
+
carousel: { aspect: 'square' },
|
|
27
|
+
profile: 'default',
|
|
28
|
+
}
|
|
29
|
+
expectAssignable<EditorProject>(project)
|
|
30
|
+
expect(project.id).toBe('p1')
|
|
31
|
+
expect(project.status).toBe('draft')
|
|
32
|
+
})
|
|
33
|
+
|
|
34
|
+
it('allows a minimal EditorProject (only required fields)', () => {
|
|
35
|
+
const minimal: EditorProject = {
|
|
36
|
+
id: 'p2',
|
|
37
|
+
status: 'pending',
|
|
38
|
+
settings: { resolution: [1920, 1080] },
|
|
39
|
+
}
|
|
40
|
+
// Index signature lets host-only fields pass through.
|
|
41
|
+
minimal.hostOnlyField = { anything: true }
|
|
42
|
+
expectAssignable<EditorProject>(minimal)
|
|
43
|
+
expect(minimal.settings.resolution).toEqual([1920, 1080])
|
|
44
|
+
})
|
|
45
|
+
|
|
46
|
+
it('type-checks an ImageElement with and without a mediaId', () => {
|
|
47
|
+
const withMedia: ImageElement = {
|
|
48
|
+
id: 'img1',
|
|
49
|
+
type: 'image',
|
|
50
|
+
src: 'media://1',
|
|
51
|
+
x: 0,
|
|
52
|
+
y: 0,
|
|
53
|
+
w: 100,
|
|
54
|
+
h: 100,
|
|
55
|
+
rotation: 0,
|
|
56
|
+
mediaId: 'media-123',
|
|
57
|
+
}
|
|
58
|
+
const withoutMedia: ImageElement = {
|
|
59
|
+
id: 'img2',
|
|
60
|
+
type: 'image',
|
|
61
|
+
src: 'https://example.com/a.png',
|
|
62
|
+
x: 10,
|
|
63
|
+
y: 10,
|
|
64
|
+
w: 50,
|
|
65
|
+
h: 50,
|
|
66
|
+
rotation: 0,
|
|
67
|
+
}
|
|
68
|
+
expectAssignable<ImageElement>(withMedia)
|
|
69
|
+
expectAssignable<ImageElement>(withoutMedia)
|
|
70
|
+
expect(withMedia.mediaId).toBe('media-123')
|
|
71
|
+
expect(withoutMedia.mediaId).toBeUndefined()
|
|
72
|
+
})
|
|
73
|
+
|
|
74
|
+
it('type-checks a Slide with mixed image + overlay elements', () => {
|
|
75
|
+
const image: ImageElement = {
|
|
76
|
+
id: 'img',
|
|
77
|
+
type: 'image',
|
|
78
|
+
src: 's',
|
|
79
|
+
x: 0,
|
|
80
|
+
y: 0,
|
|
81
|
+
w: 1,
|
|
82
|
+
h: 1,
|
|
83
|
+
rotation: 0,
|
|
84
|
+
}
|
|
85
|
+
const overlay: OverlayElement = {
|
|
86
|
+
id: 'ov',
|
|
87
|
+
type: 'overlay',
|
|
88
|
+
overlay: { template: 'title', props: { text: 'hi' } },
|
|
89
|
+
frame: 0,
|
|
90
|
+
x: 0,
|
|
91
|
+
y: 0,
|
|
92
|
+
w: 1,
|
|
93
|
+
h: 1,
|
|
94
|
+
rotation: 0,
|
|
95
|
+
}
|
|
96
|
+
const slide: Slide = {
|
|
97
|
+
id: 's1',
|
|
98
|
+
base_color: '#000000',
|
|
99
|
+
elements: [image, overlay],
|
|
100
|
+
}
|
|
101
|
+
expectAssignable<Slide>(slide)
|
|
102
|
+
expect(slide.elements).toHaveLength(2)
|
|
103
|
+
})
|
|
104
|
+
})
|