@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,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
|
+
})
|