@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.
Files changed (54) hide show
  1. package/README.md +165 -0
  2. package/package.json +46 -0
  3. package/src/__tests__/adapter-contract.test.ts +123 -0
  4. package/src/__tests__/adapter.test.ts +185 -0
  5. package/src/__tests__/schema.test.ts +104 -0
  6. package/src/carousel/AddElementMenu.tsx +211 -0
  7. package/src/carousel/CarouselEditor.tsx +529 -0
  8. package/src/carousel/CarouselRenderModal.tsx +243 -0
  9. package/src/carousel/OverlayErrorBoundary.tsx +99 -0
  10. package/src/carousel/OverlayPicker.tsx +145 -0
  11. package/src/carousel/SlideCanvas.tsx +588 -0
  12. package/src/carousel/SlidePropertyPanel.tsx +349 -0
  13. package/src/carousel/__tests__/CarouselEditor.test.tsx +235 -0
  14. package/src/crop/CanvasCropOverlay.tsx +193 -0
  15. package/src/crop/__tests__/crop-math.test.ts +174 -0
  16. package/src/crop/crop-math.ts +125 -0
  17. package/src/gestures/helpers/__tests__/element-transform.test.ts +30 -0
  18. package/src/gestures/helpers/drag.ts +24 -0
  19. package/src/gestures/helpers/element-transform.ts +15 -0
  20. package/src/gestures/helpers/resize.ts +60 -0
  21. package/src/gestures/helpers/rotate.ts +44 -0
  22. package/src/gestures/helpers/snap.ts +64 -0
  23. package/src/gestures/hooks/useOverlayDrag.ts +106 -0
  24. package/src/gestures/hooks/useOverlayResize.ts +67 -0
  25. package/src/gestures/hooks/useOverlayRotate.ts +64 -0
  26. package/src/gestures/index.ts +16 -0
  27. package/src/index.ts +112 -0
  28. package/src/overlays/contract.ts +41 -0
  29. package/src/preview/OverlayPreview.tsx +196 -0
  30. package/src/preview/__tests__/OverlayPreview.test.tsx +169 -0
  31. package/src/schema.ts +194 -0
  32. package/src/state/__tests__/project-reducer.test.ts +957 -0
  33. package/src/state/__tests__/use-project-state.test.tsx +258 -0
  34. package/src/state/mutation-queue.ts +62 -0
  35. package/src/state/project-reducer.ts +328 -0
  36. package/src/state/use-project-state.ts +442 -0
  37. package/src/test-setup.ts +1 -0
  38. package/src/text/FontPicker.tsx +218 -0
  39. package/src/text/InlineTextEditor.tsx +92 -0
  40. package/src/text/TextFormattingToolbar.tsx +248 -0
  41. package/src/text/__tests__/InlineTextEditor.test.tsx +139 -0
  42. package/src/text/__tests__/TextFormattingToolbar.test.tsx +416 -0
  43. package/src/theme.ts +93 -0
  44. package/src/types.ts +325 -0
  45. package/src/ui/__tests__/button.test.tsx +17 -0
  46. package/src/ui/badge.tsx +32 -0
  47. package/src/ui/button.tsx +32 -0
  48. package/src/ui/index.ts +16 -0
  49. package/src/ui/input.tsx +15 -0
  50. package/src/ui/label.tsx +10 -0
  51. package/src/ui/select.tsx +23 -0
  52. package/src/ui/switch.tsx +31 -0
  53. package/src/ui/textarea.tsx +15 -0
  54. 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
+ }