@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
|
@@ -0,0 +1,211 @@
|
|
|
1
|
+
import { useRef, useState } from 'react'
|
|
2
|
+
import type { Project, CarouselElement, OverlayElement, EditorAdapter } from '../types'
|
|
3
|
+
import { Button } from '../ui'
|
|
4
|
+
import OverlayPicker from './OverlayPicker'
|
|
5
|
+
|
|
6
|
+
interface Props {
|
|
7
|
+
project: Project
|
|
8
|
+
selectedSlideId: string | null
|
|
9
|
+
adapter: EditorAdapter<Project>
|
|
10
|
+
onAddElement: (slideId: string, element: CarouselElement) => void
|
|
11
|
+
}
|
|
12
|
+
|
|
13
|
+
export default function AddElementMenu({ project, selectedSlideId, adapter, onAddElement }: Props) {
|
|
14
|
+
const [showPrompt, setShowPrompt] = useState(false)
|
|
15
|
+
const [prompt, setPrompt] = useState('')
|
|
16
|
+
const [generating, setGenerating] = useState(false)
|
|
17
|
+
const [genError, setGenError] = useState<string | null>(null)
|
|
18
|
+
const [showOverlayPicker, setShowOverlayPicker] = useState(false)
|
|
19
|
+
const fileInputRef = useRef<HTMLInputElement>(null)
|
|
20
|
+
const [uploadError, setUploadError] = useState<string | null>(null)
|
|
21
|
+
const [addingText, setAddingText] = useState(false)
|
|
22
|
+
const [textError, setTextError] = useState<string | null>(null)
|
|
23
|
+
|
|
24
|
+
const disabled = selectedSlideId === null
|
|
25
|
+
|
|
26
|
+
// Add a static-text overlay at parity with mission-control's "add text".
|
|
27
|
+
// Resolves the host's default text overlay via the adapter (the package does
|
|
28
|
+
// not know the host's overlay naming) and seeds the standard text contract
|
|
29
|
+
// props from its declared defaults.
|
|
30
|
+
async function handleAddText() {
|
|
31
|
+
if (!selectedSlideId || !adapter.getDefaultTextOverlay) return
|
|
32
|
+
setAddingText(true)
|
|
33
|
+
setTextError(null)
|
|
34
|
+
try {
|
|
35
|
+
const tpl = await adapter.getDefaultTextOverlay()
|
|
36
|
+
if (!tpl) throw new Error('static-text overlay template not found')
|
|
37
|
+
const props: Record<string, unknown> = Object.fromEntries(
|
|
38
|
+
tpl.props.filter(p => p.default !== undefined).map(p => [p.name, p.default]),
|
|
39
|
+
)
|
|
40
|
+
if (typeof props.text !== 'string') props.text = 'Your text here'
|
|
41
|
+
const [fullW, fullH] = project.settings.resolution
|
|
42
|
+
const elementW = Math.round(fullW * 0.7)
|
|
43
|
+
const elementH = Math.round(fullH * 0.18)
|
|
44
|
+
const element: OverlayElement = {
|
|
45
|
+
id: crypto.randomUUID(),
|
|
46
|
+
type: 'overlay',
|
|
47
|
+
overlay: { template: tpl.jsxPath, props },
|
|
48
|
+
frame: 0,
|
|
49
|
+
x: Math.round(fullW / 2 - elementW / 2),
|
|
50
|
+
y: Math.round(fullH / 2 - elementH / 2),
|
|
51
|
+
w: elementW,
|
|
52
|
+
h: elementH,
|
|
53
|
+
rotation: 0,
|
|
54
|
+
}
|
|
55
|
+
onAddElement(selectedSlideId, element)
|
|
56
|
+
} catch (e) {
|
|
57
|
+
setTextError(e instanceof Error ? e.message : String(e))
|
|
58
|
+
} finally {
|
|
59
|
+
setAddingText(false)
|
|
60
|
+
}
|
|
61
|
+
}
|
|
62
|
+
|
|
63
|
+
async function handleGenerate() {
|
|
64
|
+
if (!selectedSlideId || !prompt.trim() || !adapter.generateImage) return
|
|
65
|
+
setGenerating(true)
|
|
66
|
+
setGenError(null)
|
|
67
|
+
try {
|
|
68
|
+
const [w] = project.settings.resolution
|
|
69
|
+
const result = await adapter.generateImage(prompt.trim(), project.id)
|
|
70
|
+
const elementW = Math.round(w * 0.8)
|
|
71
|
+
const elementH = elementW
|
|
72
|
+
const [fullW, fullH] = project.settings.resolution
|
|
73
|
+
const element: CarouselElement = {
|
|
74
|
+
id: crypto.randomUUID(),
|
|
75
|
+
type: 'image',
|
|
76
|
+
src: result.path,
|
|
77
|
+
x: Math.round(fullW / 2 - elementW / 2),
|
|
78
|
+
y: Math.round(fullH / 2 - elementH / 2),
|
|
79
|
+
w: elementW,
|
|
80
|
+
h: elementH,
|
|
81
|
+
rotation: 0,
|
|
82
|
+
}
|
|
83
|
+
onAddElement(selectedSlideId, element)
|
|
84
|
+
setShowPrompt(false)
|
|
85
|
+
setPrompt('')
|
|
86
|
+
} catch (e) {
|
|
87
|
+
setGenError(e instanceof Error ? e.message : String(e))
|
|
88
|
+
} finally {
|
|
89
|
+
setGenerating(false)
|
|
90
|
+
}
|
|
91
|
+
}
|
|
92
|
+
|
|
93
|
+
async function handleFileUpload(e: React.ChangeEvent<HTMLInputElement>) {
|
|
94
|
+
const file = e.target.files?.[0]
|
|
95
|
+
if (!file || !selectedSlideId) return
|
|
96
|
+
setUploadError(null)
|
|
97
|
+
try {
|
|
98
|
+
// Shared upload (no projectId) — matches the pre-extraction carousel
|
|
99
|
+
// behavior. The adapter stores it in a shared location, not project-local.
|
|
100
|
+
const uploadedPath = await adapter.uploadFile(file)
|
|
101
|
+
const [fullW, fullH] = project.settings.resolution
|
|
102
|
+
const elementW = Math.round(fullW * 0.8)
|
|
103
|
+
const elementH = elementW
|
|
104
|
+
const element: CarouselElement = {
|
|
105
|
+
id: crypto.randomUUID(),
|
|
106
|
+
type: 'image',
|
|
107
|
+
src: uploadedPath,
|
|
108
|
+
x: Math.round(fullW / 2 - elementW / 2),
|
|
109
|
+
y: Math.round(fullH / 2 - elementH / 2),
|
|
110
|
+
w: elementW,
|
|
111
|
+
h: elementH,
|
|
112
|
+
rotation: 0,
|
|
113
|
+
}
|
|
114
|
+
onAddElement(selectedSlideId, element)
|
|
115
|
+
} catch (err) {
|
|
116
|
+
setUploadError(err instanceof Error ? err.message : String(err))
|
|
117
|
+
} finally {
|
|
118
|
+
// Reset so the same file can be re-picked
|
|
119
|
+
if (fileInputRef.current) fileInputRef.current.value = ''
|
|
120
|
+
}
|
|
121
|
+
}
|
|
122
|
+
|
|
123
|
+
return (
|
|
124
|
+
<div className="flex flex-col gap-2">
|
|
125
|
+
<div className="flex items-center gap-2">
|
|
126
|
+
{adapter.generateImage && (
|
|
127
|
+
<Button
|
|
128
|
+
size="sm"
|
|
129
|
+
variant="outline"
|
|
130
|
+
disabled={disabled}
|
|
131
|
+
onClick={() => { setShowPrompt(p => !p); setGenError(null) }}
|
|
132
|
+
className="text-xs"
|
|
133
|
+
>
|
|
134
|
+
+ AI Image
|
|
135
|
+
</Button>
|
|
136
|
+
)}
|
|
137
|
+
<Button
|
|
138
|
+
size="sm"
|
|
139
|
+
variant="outline"
|
|
140
|
+
disabled={disabled}
|
|
141
|
+
onClick={() => fileInputRef.current?.click()}
|
|
142
|
+
className="text-xs"
|
|
143
|
+
>
|
|
144
|
+
+ Upload Image
|
|
145
|
+
</Button>
|
|
146
|
+
<input
|
|
147
|
+
ref={fileInputRef}
|
|
148
|
+
type="file"
|
|
149
|
+
accept="image/*"
|
|
150
|
+
className="hidden"
|
|
151
|
+
onChange={handleFileUpload}
|
|
152
|
+
/>
|
|
153
|
+
{adapter.getDefaultTextOverlay && (
|
|
154
|
+
<Button
|
|
155
|
+
size="sm"
|
|
156
|
+
variant="outline"
|
|
157
|
+
disabled={disabled || addingText}
|
|
158
|
+
onClick={handleAddText}
|
|
159
|
+
className="text-xs"
|
|
160
|
+
>
|
|
161
|
+
{addingText ? 'Adding…' : '+ Text'}
|
|
162
|
+
</Button>
|
|
163
|
+
)}
|
|
164
|
+
<Button
|
|
165
|
+
size="sm"
|
|
166
|
+
variant="outline"
|
|
167
|
+
disabled={disabled}
|
|
168
|
+
onClick={() => setShowOverlayPicker(true)}
|
|
169
|
+
className="text-xs"
|
|
170
|
+
>
|
|
171
|
+
+ Overlay
|
|
172
|
+
</Button>
|
|
173
|
+
</div>
|
|
174
|
+
{uploadError && <div className="text-xs text-red-400">{uploadError}</div>}
|
|
175
|
+
{textError && <div className="text-xs text-red-400">{textError}</div>}
|
|
176
|
+
|
|
177
|
+
{showPrompt && !disabled && (
|
|
178
|
+
<div className="flex flex-col gap-2 p-3 bg-gray-800 border border-gray-700 rounded-lg">
|
|
179
|
+
<textarea
|
|
180
|
+
className="w-full bg-gray-900 border border-gray-700 rounded px-2 py-1.5 text-xs text-white placeholder-gray-500 resize-none focus:outline-none focus:border-gray-500"
|
|
181
|
+
rows={3}
|
|
182
|
+
placeholder="Describe the image to generate…"
|
|
183
|
+
value={prompt}
|
|
184
|
+
onChange={e => setPrompt(e.target.value)}
|
|
185
|
+
disabled={generating}
|
|
186
|
+
onKeyDown={e => { if (e.key === 'Enter' && (e.metaKey || e.ctrlKey)) handleGenerate() }}
|
|
187
|
+
/>
|
|
188
|
+
{genError && <div className="text-xs text-red-400">{genError}</div>}
|
|
189
|
+
<div className="flex gap-2">
|
|
190
|
+
<Button size="sm" onClick={handleGenerate} disabled={generating || !prompt.trim()} className="text-xs">
|
|
191
|
+
{generating ? 'Generating…' : 'Generate'}
|
|
192
|
+
</Button>
|
|
193
|
+
<Button size="sm" variant="outline" onClick={() => { setShowPrompt(false); setGenError(null) }} disabled={generating} className="text-xs">
|
|
194
|
+
Cancel
|
|
195
|
+
</Button>
|
|
196
|
+
</div>
|
|
197
|
+
</div>
|
|
198
|
+
)}
|
|
199
|
+
|
|
200
|
+
<OverlayPicker
|
|
201
|
+
open={showOverlayPicker}
|
|
202
|
+
onClose={() => setShowOverlayPicker(false)}
|
|
203
|
+
project={project}
|
|
204
|
+
adapter={adapter}
|
|
205
|
+
onPick={element => {
|
|
206
|
+
if (selectedSlideId) onAddElement(selectedSlideId, element)
|
|
207
|
+
}}
|
|
208
|
+
/>
|
|
209
|
+
</div>
|
|
210
|
+
)
|
|
211
|
+
}
|