@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,92 @@
1
+ import { useEffect, useLayoutEffect, useRef, useState } from 'react'
2
+
3
+ export type InlineTextEditorProps = {
4
+ initialValue: string
5
+ rect: { left: number; top: number; width: number; height: number }
6
+ styleSnapshot: Partial<CSSStyleDeclaration>
7
+ onCommit: (value: string) => void
8
+ onCancel: () => void
9
+ onChange: (value: string) => void
10
+ }
11
+
12
+ export function InlineTextEditor({
13
+ initialValue,
14
+ rect,
15
+ styleSnapshot,
16
+ onCommit,
17
+ onCancel,
18
+ onChange,
19
+ }: InlineTextEditorProps): React.ReactElement {
20
+ const [value, setValue] = useState(initialValue)
21
+ const ref = useRef<HTMLTextAreaElement>(null)
22
+
23
+ useEffect(() => {
24
+ const el = ref.current
25
+ if (el) {
26
+ el.focus()
27
+ el.select()
28
+ }
29
+ }, [])
30
+
31
+ useLayoutEffect(() => {
32
+ const el = ref.current
33
+ if (!el) return
34
+ el.style.height = 'auto'
35
+ el.style.height = `${Math.max(rect.height, el.scrollHeight)}px`
36
+ }, [value, rect.height])
37
+
38
+ function handleChange(e: React.ChangeEvent<HTMLTextAreaElement>) {
39
+ const next = e.target.value
40
+ setValue(next)
41
+ onChange(next)
42
+ }
43
+
44
+ function handleKeyDown(e: React.KeyboardEvent<HTMLTextAreaElement>) {
45
+ if (e.key === 'Escape') {
46
+ e.preventDefault()
47
+ onCancel()
48
+ } else if (e.key === 'Enter') {
49
+ if (e.shiftKey) {
50
+ // Let the newline insert naturally.
51
+ return
52
+ }
53
+ // Bare Enter (without Shift) commits the text.
54
+ e.preventDefault()
55
+ onCommit(value)
56
+ }
57
+ }
58
+
59
+ function stopProp(e: React.SyntheticEvent) {
60
+ e.stopPropagation()
61
+ }
62
+
63
+ return (
64
+ <textarea
65
+ ref={ref}
66
+ value={value}
67
+ onChange={handleChange}
68
+ onKeyDown={handleKeyDown}
69
+ onBlur={() => onCommit(value)}
70
+ onPointerDown={stopProp}
71
+ onPointerMove={stopProp}
72
+ onClick={stopProp}
73
+ style={{
74
+ ...(styleSnapshot as React.CSSProperties),
75
+ position: 'absolute',
76
+ left: rect.left,
77
+ top: rect.top,
78
+ width: rect.width,
79
+ height: rect.height,
80
+ background: 'transparent',
81
+ outline: 'none',
82
+ padding: '0',
83
+ border: 'none',
84
+ resize: 'none',
85
+ overflow: 'hidden',
86
+ boxSizing: 'content-box',
87
+ zIndex: 25,
88
+ pointerEvents: 'auto',
89
+ }}
90
+ />
91
+ )
92
+ }
@@ -0,0 +1,248 @@
1
+ import { Bold, Italic, AlignLeft, AlignCenter, AlignRight, Trash2 } from 'lucide-react'
2
+ import type { OverlayElement } from '../types'
3
+ import { FontFamilyPicker, FontSizePicker } from './FontPicker'
4
+ import { getSupportedProps, readPropAsString } from '../overlays/contract'
5
+
6
+ export const HEX_PATTERN = /^#[0-9a-fA-F]{3,8}$/
7
+
8
+ export function isColorProp(key: string, value: string): boolean {
9
+ if (HEX_PATTERN.test(value.trim())) return true
10
+ const k = key.toLowerCase()
11
+ return /color|accent|primary|secondary|bg|background|fg|foreground|fill|ink|cream/.test(k)
12
+ }
13
+
14
+ export function isBold(weight: unknown): boolean {
15
+ if (weight === 'bold') return true
16
+ const n = Number(weight)
17
+ return Number.isFinite(n) && n >= 600
18
+ }
19
+
20
+ export function isItalic(style: unknown): boolean {
21
+ return style === 'italic'
22
+ }
23
+
24
+ const CASE_CYCLE = ['none', 'uppercase', 'lowercase', 'capitalize'] as const
25
+ type TextTransformValue = 'none' | 'uppercase' | 'lowercase' | 'capitalize'
26
+
27
+ export function nextCase(current: unknown): TextTransformValue {
28
+ const idx = CASE_CYCLE.indexOf(current as TextTransformValue)
29
+ if (idx === -1) return 'uppercase'
30
+ return CASE_CYCLE[(idx + 1) % CASE_CYCLE.length]
31
+ }
32
+
33
+ export function isStyleProp(key: string): boolean {
34
+ return (
35
+ key === 'fontWeight' ||
36
+ key === 'fontStyle' ||
37
+ key === 'textTransform' ||
38
+ key === 'textAlign' ||
39
+ key === 'fontFamily' ||
40
+ key === 'fontSize'
41
+ )
42
+ }
43
+
44
+ export function nonColorTextEntries(props: Record<string, unknown>): Array<[string, string]> {
45
+ return Object.entries(props).filter(
46
+ (entry): entry is [string, string] =>
47
+ typeof entry[1] === 'string' && !isColorProp(entry[0], entry[1]) && !isStyleProp(entry[0]),
48
+ )
49
+ }
50
+
51
+ function caseLabel(current: unknown): string {
52
+ if (current === 'uppercase') return 'AA'
53
+ if (current === 'lowercase') return 'aa'
54
+ if (current === 'capitalize') return 'Aa·'
55
+ return 'Aa'
56
+ }
57
+
58
+ export type TextFormattingToolbarProps = {
59
+ slideId: string
60
+ element: OverlayElement
61
+ updateOverlayProp: (slideId: string, elementId: string, key: string, value: string) => Promise<unknown>
62
+ onDelete?: () => void
63
+ }
64
+
65
+ export function TextFormattingToolbar({
66
+ slideId,
67
+ element,
68
+ updateOverlayProp,
69
+ onDelete,
70
+ }: TextFormattingToolbarProps) {
71
+ const props = element.overlay.props
72
+
73
+ const supported = getSupportedProps(element)
74
+ // `text` is content, not a toolbar control — it's edited inline. A non-compliant
75
+ // overlay that happens to carry a `text` key (sometimes with a garbage value
76
+ // like a color hex left over from legacy naming) would otherwise hide the
77
+ // fallback hint and leave the operator staring at an empty toolbar.
78
+ const hasAnyControlProp = [...supported].some((k) => k !== 'text')
79
+
80
+ const boldActive = isBold(props.fontWeight)
81
+ const italicActive = isItalic(props.fontStyle)
82
+ const caseActive = props.textTransform !== undefined && props.textTransform !== 'none'
83
+ const currentAlign = typeof props.textAlign === 'string' ? props.textAlign : undefined
84
+ const textTransformDisplay = typeof props.textTransform === 'string' ? props.textTransform : 'none'
85
+
86
+ const handleBoldClick = () => void updateOverlayProp(slideId, element.id, 'fontWeight', boldActive ? '400' : '700')
87
+ const handleItalicClick = () => void updateOverlayProp(slideId, element.id, 'fontStyle', italicActive ? 'normal' : 'italic')
88
+ const handleCaseClick = () => void updateOverlayProp(slideId, element.id, 'textTransform', nextCase(props.textTransform))
89
+ const handleAlignClick = (v: 'left' | 'center' | 'right') => void updateOverlayProp(slideId, element.id, 'textAlign', v)
90
+ const handleFontSizeChange = (v: string) => void updateOverlayProp(slideId, element.id, 'fontSize', v)
91
+ const handleFontFamilyChange = (v: string) => void updateOverlayProp(slideId, element.id, 'fontFamily', v)
92
+ const handleColorChange = (v: string) => void updateOverlayProp(slideId, element.id, 'color', v)
93
+
94
+ const fontSizeValue = readPropAsString(element, 'fontSize')
95
+ const fontFamilyValue = readPropAsString(element, 'fontFamily')
96
+ const rawColor = readPropAsString(element, 'color')
97
+ const colorValue = HEX_PATTERN.test(rawColor) && rawColor.length === 7 ? rawColor : '#111111'
98
+
99
+ // Montaj dark-palette button styles (gray-800 surface, gray-700 hover, blue-500 ring)
100
+ const toolbarBtnBase = 'flex items-center justify-center rounded px-1.5 py-1 text-sm transition-colors hover:bg-gray-700 focus:outline-none focus:ring-1 focus:ring-blue-500'
101
+ const toolbarBtnActive = 'bg-gray-700 text-gray-100'
102
+ const toolbarBtnInactive = 'text-gray-400'
103
+
104
+ // Divider visibility helpers
105
+ const hasLeftGroup = supported.has('fontWeight') || supported.has('fontStyle') || supported.has('textTransform') || supported.has('color')
106
+ const hasMidGroup = supported.has('fontSize') || supported.has('fontFamily')
107
+ const hasRightGroup = supported.has('textAlign')
108
+
109
+ return (
110
+ <div
111
+ className="rounded-lg border border-gray-600 bg-gray-800 text-gray-100 shadow-md"
112
+ onPointerDown={(e) => e.stopPropagation()}
113
+ onPointerMove={(e) => e.stopPropagation()}
114
+ onClick={(e) => e.stopPropagation()}
115
+ >
116
+ <div
117
+ role="toolbar"
118
+ aria-label="Text formatting"
119
+ className="flex items-center gap-0.5 p-1.5"
120
+ >
121
+ {supported.has('fontWeight') && (
122
+ <button
123
+ type="button"
124
+ aria-label="Bold"
125
+ aria-pressed={boldActive}
126
+ onClick={handleBoldClick}
127
+ className={`${toolbarBtnBase} ${boldActive ? toolbarBtnActive : toolbarBtnInactive}`}
128
+ >
129
+ <Bold className="h-3.5 w-3.5" />
130
+ </button>
131
+ )}
132
+
133
+ {supported.has('fontStyle') && (
134
+ <button
135
+ type="button"
136
+ aria-label="Italic"
137
+ aria-pressed={italicActive}
138
+ onClick={handleItalicClick}
139
+ className={`${toolbarBtnBase} ${italicActive ? toolbarBtnActive : toolbarBtnInactive}`}
140
+ >
141
+ <Italic className="h-3.5 w-3.5" />
142
+ </button>
143
+ )}
144
+
145
+ {supported.has('textTransform') && (
146
+ <button
147
+ type="button"
148
+ aria-label={`Text case (currently ${textTransformDisplay})`}
149
+ onClick={handleCaseClick}
150
+ className={`${toolbarBtnBase} min-w-[2rem] font-mono text-xs ${caseActive ? toolbarBtnActive : toolbarBtnInactive}`}
151
+ >
152
+ {caseLabel(props.textTransform)}
153
+ </button>
154
+ )}
155
+
156
+ {supported.has('color') && (
157
+ <label
158
+ className={`${toolbarBtnBase} ${toolbarBtnInactive} relative cursor-pointer`}
159
+ title="Text color"
160
+ >
161
+ <span
162
+ className="block h-3.5 w-3.5 rounded-sm border border-gray-500"
163
+ style={{ backgroundColor: colorValue }}
164
+ aria-hidden
165
+ />
166
+ <input
167
+ type="color"
168
+ value={colorValue}
169
+ onChange={(e) => handleColorChange(e.target.value)}
170
+ className="absolute inset-0 h-full w-full cursor-pointer opacity-0"
171
+ aria-label="Text color"
172
+ />
173
+ </label>
174
+ )}
175
+
176
+ {hasLeftGroup && hasMidGroup && (
177
+ <div className="mx-1 h-4 w-px bg-gray-600" aria-hidden />
178
+ )}
179
+
180
+ {supported.has('fontSize') && (
181
+ <FontSizePicker
182
+ value={fontSizeValue}
183
+ onChange={handleFontSizeChange}
184
+ />
185
+ )}
186
+
187
+ {supported.has('fontFamily') && (
188
+ <FontFamilyPicker
189
+ value={fontFamilyValue}
190
+ onChange={handleFontFamilyChange}
191
+ className="w-32"
192
+ />
193
+ )}
194
+
195
+ {hasMidGroup && hasRightGroup && (
196
+ <div className="mx-1 h-4 w-px bg-gray-600" aria-hidden />
197
+ )}
198
+
199
+ {supported.has('textAlign') && (
200
+ <div role="radiogroup" aria-label="Text alignment" className="flex items-center gap-0.5">
201
+ {(
202
+ [
203
+ { value: 'left', Icon: AlignLeft, label: 'Align left' },
204
+ { value: 'center', Icon: AlignCenter, label: 'Align center' },
205
+ { value: 'right', Icon: AlignRight, label: 'Align right' },
206
+ ] as const
207
+ ).map(({ value, Icon, label }) => {
208
+ const checked = currentAlign === value
209
+ return (
210
+ <button
211
+ key={value}
212
+ type="button"
213
+ role="radio"
214
+ aria-checked={checked}
215
+ aria-label={label}
216
+ onClick={() => handleAlignClick(value)}
217
+ className={`${toolbarBtnBase} ${checked ? toolbarBtnActive : toolbarBtnInactive}`}
218
+ >
219
+ <Icon className="h-3.5 w-3.5" />
220
+ </button>
221
+ )
222
+ })}
223
+ </div>
224
+ )}
225
+
226
+ {!hasAnyControlProp && (
227
+ <span className="px-2 py-1 text-xs text-gray-400">
228
+ Edit via property panel
229
+ </span>
230
+ )}
231
+
232
+ {onDelete && (
233
+ <>
234
+ {hasAnyControlProp && <div className="mx-1 h-4 w-px bg-gray-600" aria-hidden />}
235
+ <button
236
+ type="button"
237
+ aria-label="Delete text overlay"
238
+ onClick={onDelete}
239
+ className={`${toolbarBtnBase} text-gray-400 hover:bg-red-900/30 hover:text-red-400`}
240
+ >
241
+ <Trash2 className="h-3.5 w-3.5" />
242
+ </button>
243
+ </>
244
+ )}
245
+ </div>
246
+ </div>
247
+ )
248
+ }
@@ -0,0 +1,139 @@
1
+ // @vitest-environment jsdom
2
+ /// <reference types="vitest/globals" />
3
+ import { render, fireEvent } from '@testing-library/react'
4
+ import { InlineTextEditor } from '../InlineTextEditor'
5
+
6
+ const DEFAULT_RECT = { left: 10, top: 20, width: 100, height: 30 }
7
+ const DEFAULT_STYLE: Partial<CSSStyleDeclaration> = { fontSize: '16px' }
8
+
9
+ function renderEditor(overrides: {
10
+ initialValue?: string
11
+ rect?: typeof DEFAULT_RECT
12
+ styleSnapshot?: Partial<CSSStyleDeclaration>
13
+ onCommit?: ReturnType<typeof vi.fn>
14
+ onCancel?: ReturnType<typeof vi.fn>
15
+ onChange?: ReturnType<typeof vi.fn>
16
+ } = {}) {
17
+ const onCommit = overrides.onCommit ?? vi.fn()
18
+ const onCancel = overrides.onCancel ?? vi.fn()
19
+ const onChange = overrides.onChange ?? vi.fn()
20
+
21
+ const { container } = render(
22
+ <InlineTextEditor
23
+ initialValue={overrides.initialValue ?? 'hello'}
24
+ rect={overrides.rect ?? DEFAULT_RECT}
25
+ styleSnapshot={overrides.styleSnapshot ?? DEFAULT_STYLE}
26
+ onCommit={onCommit}
27
+ onCancel={onCancel}
28
+ onChange={onChange}
29
+ />,
30
+ )
31
+
32
+ const textarea = container.querySelector('textarea') as HTMLTextAreaElement
33
+ return { textarea, onCommit, onCancel, onChange }
34
+ }
35
+
36
+ describe('InlineTextEditor', () => {
37
+ it('1. renders with initialValue prefilled', () => {
38
+ const { textarea } = renderEditor({ initialValue: 'prefilled text' })
39
+ expect(textarea.value).toBe('prefilled text')
40
+ })
41
+
42
+ it('2. mounts focused', () => {
43
+ const { textarea } = renderEditor()
44
+ expect(textarea).toHaveFocus()
45
+ })
46
+
47
+ it('3. selects all text on mount', () => {
48
+ const { textarea } = renderEditor({ initialValue: 'select me' })
49
+ expect(textarea.selectionStart).toBe(0)
50
+ expect(textarea.selectionEnd).toBe('select me'.length)
51
+ })
52
+
53
+ it('4. typing fires onChange with the new value', () => {
54
+ const onChange = vi.fn()
55
+ const { textarea } = renderEditor({ onChange })
56
+ fireEvent.change(textarea, { target: { value: 'abc' } })
57
+ expect(onChange).toHaveBeenCalledWith('abc')
58
+ })
59
+
60
+ it('5. blur calls onCommit with the current value', () => {
61
+ const onCommit = vi.fn()
62
+ const { textarea } = renderEditor({ onCommit })
63
+ fireEvent.change(textarea, { target: { value: 'updated' } })
64
+ fireEvent.blur(textarea)
65
+ expect(onCommit).toHaveBeenCalledWith('updated')
66
+ })
67
+
68
+ it('6. Enter (no shift) calls onCommit and calls preventDefault', () => {
69
+ const onCommit = vi.fn()
70
+ const { textarea } = renderEditor({ onCommit, initialValue: 'hello' })
71
+
72
+ const ev = new KeyboardEvent('keydown', { key: 'Enter', bubbles: true, cancelable: true })
73
+ textarea.dispatchEvent(ev)
74
+ expect(ev.defaultPrevented).toBe(true)
75
+ expect(onCommit).toHaveBeenCalled()
76
+ })
77
+
78
+ it('7. Shift+Enter does NOT call onCommit', () => {
79
+ const onCommit = vi.fn()
80
+ const { textarea } = renderEditor({ onCommit })
81
+ fireEvent.keyDown(textarea, { key: 'Enter', shiftKey: true })
82
+ expect(onCommit).not.toHaveBeenCalled()
83
+ })
84
+
85
+ it('8. Cmd/Ctrl+Enter calls onCommit', () => {
86
+ const onCommit = vi.fn()
87
+ const { textarea } = renderEditor({ onCommit })
88
+
89
+ fireEvent.keyDown(textarea, { key: 'Enter', metaKey: true })
90
+ expect(onCommit).toHaveBeenCalledTimes(1)
91
+
92
+ fireEvent.keyDown(textarea, { key: 'Enter', ctrlKey: true })
93
+ expect(onCommit).toHaveBeenCalledTimes(2)
94
+ })
95
+
96
+ it('9. Escape calls onCancel and calls preventDefault', () => {
97
+ const onCancel = vi.fn()
98
+ const onCommit = vi.fn()
99
+ const { textarea } = renderEditor({ onCancel, onCommit })
100
+
101
+ const ev = new KeyboardEvent('keydown', { key: 'Escape', bubbles: true, cancelable: true })
102
+ textarea.dispatchEvent(ev)
103
+ expect(ev.defaultPrevented).toBe(true)
104
+ expect(onCancel).toHaveBeenCalled()
105
+ expect(onCommit).not.toHaveBeenCalled()
106
+ })
107
+
108
+ it('10. pointer events do not bubble to parent', () => {
109
+ const parentPointerDown = vi.fn()
110
+ const parentPointerMove = vi.fn()
111
+ const parentClick = vi.fn()
112
+
113
+ const { container } = render(
114
+ <div
115
+ onPointerDown={parentPointerDown}
116
+ onPointerMove={parentPointerMove}
117
+ onClick={parentClick}
118
+ >
119
+ <InlineTextEditor
120
+ initialValue="test"
121
+ rect={DEFAULT_RECT}
122
+ styleSnapshot={DEFAULT_STYLE}
123
+ onCommit={vi.fn()}
124
+ onCancel={vi.fn()}
125
+ onChange={vi.fn()}
126
+ />
127
+ </div>,
128
+ )
129
+
130
+ const textarea = container.querySelector('textarea') as HTMLTextAreaElement
131
+ fireEvent.pointerDown(textarea)
132
+ fireEvent.pointerMove(textarea)
133
+ fireEvent.click(textarea)
134
+
135
+ expect(parentPointerDown).not.toHaveBeenCalled()
136
+ expect(parentPointerMove).not.toHaveBeenCalled()
137
+ expect(parentClick).not.toHaveBeenCalled()
138
+ })
139
+ })