@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,416 @@
|
|
|
1
|
+
// @vitest-environment jsdom
|
|
2
|
+
/// <reference types="vitest/globals" />
|
|
3
|
+
import { render, screen, fireEvent } from '@testing-library/react'
|
|
4
|
+
import {
|
|
5
|
+
isBold,
|
|
6
|
+
isItalic,
|
|
7
|
+
nextCase,
|
|
8
|
+
isStyleProp,
|
|
9
|
+
isColorProp,
|
|
10
|
+
nonColorTextEntries,
|
|
11
|
+
TextFormattingToolbar,
|
|
12
|
+
} from '../TextFormattingToolbar'
|
|
13
|
+
import type { OverlayElement } from '../../types'
|
|
14
|
+
|
|
15
|
+
function makeOverlay(props: Record<string, unknown> = {}): OverlayElement {
|
|
16
|
+
return {
|
|
17
|
+
id: 'el-1',
|
|
18
|
+
type: 'overlay',
|
|
19
|
+
frame: 0,
|
|
20
|
+
x: 0,
|
|
21
|
+
y: 0,
|
|
22
|
+
w: 200,
|
|
23
|
+
h: 100,
|
|
24
|
+
rotation: 0,
|
|
25
|
+
overlay: { template: 'assets/test.jsx', props },
|
|
26
|
+
}
|
|
27
|
+
}
|
|
28
|
+
|
|
29
|
+
const SLIDE_ID = 'slide-1'
|
|
30
|
+
|
|
31
|
+
function renderToolbar(
|
|
32
|
+
props: Record<string, unknown>,
|
|
33
|
+
updateOverlayProp: ReturnType<typeof vi.fn>,
|
|
34
|
+
) {
|
|
35
|
+
return render(
|
|
36
|
+
<TextFormattingToolbar
|
|
37
|
+
slideId={SLIDE_ID}
|
|
38
|
+
element={makeOverlay(props)}
|
|
39
|
+
updateOverlayProp={updateOverlayProp}
|
|
40
|
+
/>,
|
|
41
|
+
)
|
|
42
|
+
}
|
|
43
|
+
|
|
44
|
+
// ---------------------------------------------------------------------------
|
|
45
|
+
// Pure helper unit tests
|
|
46
|
+
// ---------------------------------------------------------------------------
|
|
47
|
+
|
|
48
|
+
describe('isBold', () => {
|
|
49
|
+
it.each([
|
|
50
|
+
['700', true],
|
|
51
|
+
['bold', true],
|
|
52
|
+
[700, true],
|
|
53
|
+
['600', true],
|
|
54
|
+
['400', false],
|
|
55
|
+
[400, false],
|
|
56
|
+
['normal', false],
|
|
57
|
+
[undefined, false],
|
|
58
|
+
['', false],
|
|
59
|
+
])('isBold(%s) → %s', (input, expected) => {
|
|
60
|
+
expect(isBold(input)).toBe(expected)
|
|
61
|
+
})
|
|
62
|
+
})
|
|
63
|
+
|
|
64
|
+
describe('isItalic', () => {
|
|
65
|
+
it.each([
|
|
66
|
+
['italic', true],
|
|
67
|
+
['normal', false],
|
|
68
|
+
['oblique', false],
|
|
69
|
+
[undefined, false],
|
|
70
|
+
])('isItalic(%s) → %s', (input, expected) => {
|
|
71
|
+
expect(isItalic(input)).toBe(expected)
|
|
72
|
+
})
|
|
73
|
+
})
|
|
74
|
+
|
|
75
|
+
describe('nextCase', () => {
|
|
76
|
+
it.each([
|
|
77
|
+
['none', 'uppercase'],
|
|
78
|
+
['uppercase', 'lowercase'],
|
|
79
|
+
['lowercase', 'capitalize'],
|
|
80
|
+
['capitalize', 'none'],
|
|
81
|
+
['unknown', 'uppercase'],
|
|
82
|
+
])('nextCase(%s) → %s', (input, expected) => {
|
|
83
|
+
expect(nextCase(input)).toBe(expected)
|
|
84
|
+
})
|
|
85
|
+
})
|
|
86
|
+
|
|
87
|
+
describe('isStyleProp', () => {
|
|
88
|
+
it.each([
|
|
89
|
+
['fontWeight', true],
|
|
90
|
+
['fontStyle', true],
|
|
91
|
+
['textTransform', true],
|
|
92
|
+
['textAlign', true],
|
|
93
|
+
['title', false],
|
|
94
|
+
['bg', false],
|
|
95
|
+
['color', false],
|
|
96
|
+
['', false],
|
|
97
|
+
])('isStyleProp(%s) → %s', (key, expected) => {
|
|
98
|
+
expect(isStyleProp(key)).toBe(expected)
|
|
99
|
+
})
|
|
100
|
+
})
|
|
101
|
+
|
|
102
|
+
describe('isColorProp', () => {
|
|
103
|
+
it('returns true for hex values', () => {
|
|
104
|
+
expect(isColorProp('anything', '#ff0000')).toBe(true)
|
|
105
|
+
expect(isColorProp('anything', '#abc')).toBe(true)
|
|
106
|
+
expect(isColorProp('foo', '#AABBCC')).toBe(true)
|
|
107
|
+
})
|
|
108
|
+
|
|
109
|
+
it('returns true for name-matched color keys', () => {
|
|
110
|
+
expect(isColorProp('bg', 'white')).toBe(true)
|
|
111
|
+
expect(isColorProp('accent', 'blue')).toBe(true)
|
|
112
|
+
expect(isColorProp('foreground', 'dark')).toBe(true)
|
|
113
|
+
expect(isColorProp('background', 'light')).toBe(true)
|
|
114
|
+
expect(isColorProp('color', 'red')).toBe(true)
|
|
115
|
+
})
|
|
116
|
+
|
|
117
|
+
it('returns false for plain text values with non-matching keys', () => {
|
|
118
|
+
expect(isColorProp('title', 'Hello world')).toBe(false)
|
|
119
|
+
expect(isColorProp('subtitle', 'Some text')).toBe(false)
|
|
120
|
+
})
|
|
121
|
+
})
|
|
122
|
+
|
|
123
|
+
describe('nonColorTextEntries', () => {
|
|
124
|
+
it('filters out colors, style props, and non-strings', () => {
|
|
125
|
+
const result = nonColorTextEntries({
|
|
126
|
+
title: 'hi',
|
|
127
|
+
bg: '#fff',
|
|
128
|
+
fontWeight: '700',
|
|
129
|
+
count: 5,
|
|
130
|
+
})
|
|
131
|
+
expect(result).toEqual([['title', 'hi']])
|
|
132
|
+
})
|
|
133
|
+
|
|
134
|
+
it('keeps multiple valid text entries', () => {
|
|
135
|
+
const result = nonColorTextEntries({ title: 'Hello', subtitle: 'World' })
|
|
136
|
+
expect(result).toEqual([
|
|
137
|
+
['title', 'Hello'],
|
|
138
|
+
['subtitle', 'World'],
|
|
139
|
+
])
|
|
140
|
+
})
|
|
141
|
+
|
|
142
|
+
it('returns empty array when all entries are filtered out', () => {
|
|
143
|
+
const result = nonColorTextEntries({
|
|
144
|
+
fontStyle: 'italic',
|
|
145
|
+
bg: '#000',
|
|
146
|
+
count: 3,
|
|
147
|
+
})
|
|
148
|
+
expect(result).toEqual([])
|
|
149
|
+
})
|
|
150
|
+
})
|
|
151
|
+
|
|
152
|
+
// ---------------------------------------------------------------------------
|
|
153
|
+
// TextFormattingToolbar component tests
|
|
154
|
+
// ---------------------------------------------------------------------------
|
|
155
|
+
|
|
156
|
+
describe('TextFormattingToolbar — bold button', () => {
|
|
157
|
+
it('7. bold button writes fontWeight "700" then "400" on second click', () => {
|
|
158
|
+
const updateFn = vi.fn(() => Promise.resolve())
|
|
159
|
+
const { rerender } = render(
|
|
160
|
+
<TextFormattingToolbar
|
|
161
|
+
slideId={SLIDE_ID}
|
|
162
|
+
element={makeOverlay({ title: 'Hello', fontWeight: '400' })}
|
|
163
|
+
updateOverlayProp={updateFn}
|
|
164
|
+
/>,
|
|
165
|
+
)
|
|
166
|
+
const boldBtn = screen.getByRole('button', { name: /bold/i })
|
|
167
|
+
fireEvent.click(boldBtn)
|
|
168
|
+
expect(updateFn).toHaveBeenCalledWith(SLIDE_ID, 'el-1', 'fontWeight', '700')
|
|
169
|
+
|
|
170
|
+
rerender(
|
|
171
|
+
<TextFormattingToolbar
|
|
172
|
+
slideId={SLIDE_ID}
|
|
173
|
+
element={makeOverlay({ title: 'Hello', fontWeight: '700' })}
|
|
174
|
+
updateOverlayProp={updateFn}
|
|
175
|
+
/>,
|
|
176
|
+
)
|
|
177
|
+
fireEvent.click(screen.getByRole('button', { name: /bold/i }))
|
|
178
|
+
expect(updateFn).toHaveBeenCalledWith(SLIDE_ID, 'el-1', 'fontWeight', '400')
|
|
179
|
+
})
|
|
180
|
+
|
|
181
|
+
it('8. bold aria-pressed is true for bold-weight values, false otherwise', () => {
|
|
182
|
+
const boldCases: Array<[unknown, boolean]> = [
|
|
183
|
+
['700', true],
|
|
184
|
+
['bold', true],
|
|
185
|
+
[700, true],
|
|
186
|
+
['600', true],
|
|
187
|
+
['400', false],
|
|
188
|
+
[400, false],
|
|
189
|
+
['normal', false],
|
|
190
|
+
[undefined, false],
|
|
191
|
+
['', false],
|
|
192
|
+
]
|
|
193
|
+
|
|
194
|
+
for (const [fontWeight, expectedPressed] of boldCases) {
|
|
195
|
+
const props: Record<string, unknown> = { title: 'Hi' }
|
|
196
|
+
if (fontWeight !== undefined) props.fontWeight = fontWeight
|
|
197
|
+
const { unmount } = render(
|
|
198
|
+
<TextFormattingToolbar
|
|
199
|
+
slideId={SLIDE_ID}
|
|
200
|
+
element={makeOverlay(props)}
|
|
201
|
+
updateOverlayProp={vi.fn(() => Promise.resolve())}
|
|
202
|
+
/>,
|
|
203
|
+
)
|
|
204
|
+
if (fontWeight === undefined) {
|
|
205
|
+
// fontWeight absent → bold button is hidden (prop not in contract)
|
|
206
|
+
expect(screen.queryByRole('button', { name: /bold/i })).not.toBeInTheDocument()
|
|
207
|
+
} else {
|
|
208
|
+
const boldBtn = screen.getByRole('button', { name: /bold/i })
|
|
209
|
+
expect(boldBtn.getAttribute('aria-pressed')).toBe(String(expectedPressed))
|
|
210
|
+
}
|
|
211
|
+
unmount()
|
|
212
|
+
}
|
|
213
|
+
})
|
|
214
|
+
})
|
|
215
|
+
|
|
216
|
+
describe('TextFormattingToolbar — italic button', () => {
|
|
217
|
+
it('9. italic button writes fontStyle "italic" then "normal" on second click', () => {
|
|
218
|
+
const updateFn = vi.fn(() => Promise.resolve())
|
|
219
|
+
const { rerender } = render(
|
|
220
|
+
<TextFormattingToolbar
|
|
221
|
+
slideId={SLIDE_ID}
|
|
222
|
+
element={makeOverlay({ title: 'Hello', fontStyle: 'normal' })}
|
|
223
|
+
updateOverlayProp={updateFn}
|
|
224
|
+
/>,
|
|
225
|
+
)
|
|
226
|
+
fireEvent.click(screen.getByRole('button', { name: /italic/i }))
|
|
227
|
+
expect(updateFn).toHaveBeenCalledWith(SLIDE_ID, 'el-1', 'fontStyle', 'italic')
|
|
228
|
+
|
|
229
|
+
rerender(
|
|
230
|
+
<TextFormattingToolbar
|
|
231
|
+
slideId={SLIDE_ID}
|
|
232
|
+
element={makeOverlay({ title: 'Hello', fontStyle: 'italic' })}
|
|
233
|
+
updateOverlayProp={updateFn}
|
|
234
|
+
/>,
|
|
235
|
+
)
|
|
236
|
+
fireEvent.click(screen.getByRole('button', { name: /italic/i }))
|
|
237
|
+
expect(updateFn).toHaveBeenCalledWith(SLIDE_ID, 'el-1', 'fontStyle', 'normal')
|
|
238
|
+
})
|
|
239
|
+
})
|
|
240
|
+
|
|
241
|
+
describe('TextFormattingToolbar — case button', () => {
|
|
242
|
+
it('10. case button cycles none → uppercase → lowercase → capitalize → none', () => {
|
|
243
|
+
const updateFn = vi.fn(() => Promise.resolve())
|
|
244
|
+
|
|
245
|
+
const { rerender } = render(
|
|
246
|
+
<TextFormattingToolbar
|
|
247
|
+
slideId={SLIDE_ID}
|
|
248
|
+
element={makeOverlay({ title: 'Hello', textTransform: 'none' })}
|
|
249
|
+
updateOverlayProp={updateFn}
|
|
250
|
+
/>,
|
|
251
|
+
)
|
|
252
|
+
const getCaseBtn = () => screen.getByRole('button', { name: /text case/i })
|
|
253
|
+
fireEvent.click(getCaseBtn())
|
|
254
|
+
expect(updateFn).toHaveBeenLastCalledWith(SLIDE_ID, 'el-1', 'textTransform', 'uppercase')
|
|
255
|
+
|
|
256
|
+
const steps: Array<[string, string]> = [
|
|
257
|
+
['none', 'uppercase'],
|
|
258
|
+
['uppercase', 'lowercase'],
|
|
259
|
+
['lowercase', 'capitalize'],
|
|
260
|
+
['capitalize', 'none'],
|
|
261
|
+
]
|
|
262
|
+
for (const [current, expected] of steps) {
|
|
263
|
+
rerender(
|
|
264
|
+
<TextFormattingToolbar
|
|
265
|
+
slideId={SLIDE_ID}
|
|
266
|
+
element={makeOverlay({ title: 'Hello', textTransform: current })}
|
|
267
|
+
updateOverlayProp={updateFn}
|
|
268
|
+
/>,
|
|
269
|
+
)
|
|
270
|
+
fireEvent.click(getCaseBtn())
|
|
271
|
+
expect(updateFn).toHaveBeenLastCalledWith(SLIDE_ID, 'el-1', 'textTransform', expected)
|
|
272
|
+
}
|
|
273
|
+
})
|
|
274
|
+
})
|
|
275
|
+
|
|
276
|
+
describe('TextFormattingToolbar — alignment buttons', () => {
|
|
277
|
+
it('11. each alignment button writes the matching textAlign value', () => {
|
|
278
|
+
const updateFn = vi.fn(() => Promise.resolve())
|
|
279
|
+
renderToolbar({ title: 'Hello', textAlign: 'center' }, updateFn)
|
|
280
|
+
|
|
281
|
+
fireEvent.click(screen.getByRole('radio', { name: /align left/i }))
|
|
282
|
+
expect(updateFn).toHaveBeenLastCalledWith(SLIDE_ID, 'el-1', 'textAlign', 'left')
|
|
283
|
+
|
|
284
|
+
fireEvent.click(screen.getByRole('radio', { name: /align center/i }))
|
|
285
|
+
expect(updateFn).toHaveBeenLastCalledWith(SLIDE_ID, 'el-1', 'textAlign', 'center')
|
|
286
|
+
|
|
287
|
+
fireEvent.click(screen.getByRole('radio', { name: /align right/i }))
|
|
288
|
+
expect(updateFn).toHaveBeenLastCalledWith(SLIDE_ID, 'el-1', 'textAlign', 'right')
|
|
289
|
+
})
|
|
290
|
+
|
|
291
|
+
it('12. no alignment button is highlighted (aria-checked false) when textAlign is unset', () => {
|
|
292
|
+
// textAlign must be present for the radiogroup to render, but the value
|
|
293
|
+
// should not match any button. Use an empty string to make the prop present
|
|
294
|
+
// but unmatched so none of the three alignment buttons show as checked.
|
|
295
|
+
renderToolbar({ title: 'Hello', textAlign: '' }, vi.fn(() => Promise.resolve()))
|
|
296
|
+
|
|
297
|
+
const radios = screen.getAllByRole('radio')
|
|
298
|
+
expect(radios).toHaveLength(3)
|
|
299
|
+
for (const radio of radios) {
|
|
300
|
+
expect(radio.getAttribute('aria-checked')).toBe('false')
|
|
301
|
+
}
|
|
302
|
+
})
|
|
303
|
+
})
|
|
304
|
+
|
|
305
|
+
describe('TextFormattingToolbar — ARIA', () => {
|
|
306
|
+
it('15. toolbar has role="toolbar", alignment group has role="radiogroup", B/I have aria-pressed', () => {
|
|
307
|
+
renderToolbar(
|
|
308
|
+
{ title: 'Hello', fontWeight: '400', fontStyle: 'normal', textAlign: 'center' },
|
|
309
|
+
vi.fn(() => Promise.resolve()),
|
|
310
|
+
)
|
|
311
|
+
|
|
312
|
+
expect(screen.getByRole('toolbar', { name: /text formatting/i })).toBeInTheDocument()
|
|
313
|
+
expect(screen.getByRole('radiogroup', { name: /text alignment/i })).toBeInTheDocument()
|
|
314
|
+
|
|
315
|
+
const boldBtn = screen.getByRole('button', { name: /bold/i })
|
|
316
|
+
expect(boldBtn).toHaveAttribute('aria-pressed')
|
|
317
|
+
|
|
318
|
+
const italicBtn = screen.getByRole('button', { name: /italic/i })
|
|
319
|
+
expect(italicBtn).toHaveAttribute('aria-pressed')
|
|
320
|
+
})
|
|
321
|
+
})
|
|
322
|
+
|
|
323
|
+
describe('TextFormattingToolbar — adaptive rendering by element prop schema', () => {
|
|
324
|
+
function fullCompliantOverlay(): OverlayElement {
|
|
325
|
+
return {
|
|
326
|
+
id: 'el-1', type: 'overlay', frame: 0, x: 0, y: 0, w: 200, h: 100, rotation: 0,
|
|
327
|
+
overlay: {
|
|
328
|
+
template: 'assets/static-text.jsx',
|
|
329
|
+
props: {
|
|
330
|
+
text: 'hi', fontSize: '64', fontFamily: 'Inter', fontWeight: '400',
|
|
331
|
+
fontStyle: 'normal', color: '#000', textAlign: 'center',
|
|
332
|
+
textTransform: 'none', bgColor: 'transparent',
|
|
333
|
+
},
|
|
334
|
+
},
|
|
335
|
+
};
|
|
336
|
+
}
|
|
337
|
+
|
|
338
|
+
it('renders all standard controls when every contract prop is present', () => {
|
|
339
|
+
render(
|
|
340
|
+
<TextFormattingToolbar
|
|
341
|
+
slideId={SLIDE_ID}
|
|
342
|
+
element={fullCompliantOverlay()}
|
|
343
|
+
updateOverlayProp={vi.fn(() => Promise.resolve())}
|
|
344
|
+
/>,
|
|
345
|
+
);
|
|
346
|
+
expect(screen.getByRole('button', { name: /bold/i })).toBeInTheDocument();
|
|
347
|
+
expect(screen.getByRole('button', { name: /italic/i })).toBeInTheDocument();
|
|
348
|
+
expect(screen.getByRole('button', { name: /text case/i })).toBeInTheDocument();
|
|
349
|
+
expect(screen.getByLabelText(/text color/i)).toBeInTheDocument();
|
|
350
|
+
expect(screen.getByLabelText(/font size/i)).toBeInTheDocument();
|
|
351
|
+
expect(screen.getByLabelText(/font family/i)).toBeInTheDocument();
|
|
352
|
+
expect(screen.getByRole('radio', { name: /align left/i })).toBeInTheDocument();
|
|
353
|
+
});
|
|
354
|
+
|
|
355
|
+
it('hides each control whose target prop is absent from the element', () => {
|
|
356
|
+
const partial = makeOverlay({ text: 'hi', fontSize: '64' });
|
|
357
|
+
render(
|
|
358
|
+
<TextFormattingToolbar
|
|
359
|
+
slideId={SLIDE_ID}
|
|
360
|
+
element={partial}
|
|
361
|
+
updateOverlayProp={vi.fn(() => Promise.resolve())}
|
|
362
|
+
/>,
|
|
363
|
+
);
|
|
364
|
+
expect(screen.queryByRole('button', { name: /bold/i })).not.toBeInTheDocument();
|
|
365
|
+
expect(screen.queryByRole('button', { name: /italic/i })).not.toBeInTheDocument();
|
|
366
|
+
expect(screen.queryByRole('button', { name: /text case/i })).not.toBeInTheDocument();
|
|
367
|
+
expect(screen.queryByLabelText(/text color/i)).not.toBeInTheDocument();
|
|
368
|
+
expect(screen.queryByLabelText(/font size/i)).toBeInTheDocument();
|
|
369
|
+
expect(screen.queryByLabelText(/font family/i)).not.toBeInTheDocument();
|
|
370
|
+
expect(screen.queryByRole('radio', { name: /align left/i })).not.toBeInTheDocument();
|
|
371
|
+
});
|
|
372
|
+
|
|
373
|
+
it('shows a fallback hint when no standard contract props are present', () => {
|
|
374
|
+
const noncompliant = makeOverlay({ headline: 'hi', headlineSize: '92', fg: '#000' });
|
|
375
|
+
render(
|
|
376
|
+
<TextFormattingToolbar
|
|
377
|
+
slideId={SLIDE_ID}
|
|
378
|
+
element={noncompliant}
|
|
379
|
+
updateOverlayProp={vi.fn(() => Promise.resolve())}
|
|
380
|
+
/>,
|
|
381
|
+
);
|
|
382
|
+
expect(screen.queryByRole('button', { name: /bold/i })).not.toBeInTheDocument();
|
|
383
|
+
expect(screen.queryByLabelText(/font size/i)).not.toBeInTheDocument();
|
|
384
|
+
expect(screen.getByText(/property panel/i)).toBeInTheDocument();
|
|
385
|
+
});
|
|
386
|
+
|
|
387
|
+
it('shows the fallback hint when `text` is the only standard prop (no style controls available)', () => {
|
|
388
|
+
// Realistic legacy carousel: text key carries a color hex (mis-used by an
|
|
389
|
+
// older agent), no fontSize/color/weight/etc. on the element. Without the
|
|
390
|
+
// hint here, the operator sees an empty toolbar with no signal about why.
|
|
391
|
+
const textOnly = makeOverlay({ text: '#f8fafc', copy: 'real content', size: 58 });
|
|
392
|
+
render(
|
|
393
|
+
<TextFormattingToolbar
|
|
394
|
+
slideId={SLIDE_ID}
|
|
395
|
+
element={textOnly}
|
|
396
|
+
updateOverlayProp={vi.fn(() => Promise.resolve())}
|
|
397
|
+
/>,
|
|
398
|
+
);
|
|
399
|
+
expect(screen.queryByLabelText(/font size/i)).not.toBeInTheDocument();
|
|
400
|
+
expect(screen.queryByRole('button', { name: /bold/i })).not.toBeInTheDocument();
|
|
401
|
+
expect(screen.getByText(/property panel/i)).toBeInTheDocument();
|
|
402
|
+
});
|
|
403
|
+
|
|
404
|
+
it('keeps the delete button regardless of contract compliance (when onDelete is supplied)', () => {
|
|
405
|
+
const noncompliant = makeOverlay({ headline: 'hi' });
|
|
406
|
+
render(
|
|
407
|
+
<TextFormattingToolbar
|
|
408
|
+
slideId={SLIDE_ID}
|
|
409
|
+
element={noncompliant}
|
|
410
|
+
updateOverlayProp={vi.fn(() => Promise.resolve())}
|
|
411
|
+
onDelete={vi.fn()}
|
|
412
|
+
/>,
|
|
413
|
+
);
|
|
414
|
+
expect(screen.getByRole('button', { name: /delete text overlay/i })).toBeInTheDocument();
|
|
415
|
+
});
|
|
416
|
+
});
|
package/src/theme.ts
ADDED
|
@@ -0,0 +1,93 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* editor-core / theme — the default theme for the editor running inside Montaj,
|
|
3
|
+
* plus the `applyTheme` helper that projects an `EditorTheme` onto an element as
|
|
4
|
+
* CSS custom properties.
|
|
5
|
+
*
|
|
6
|
+
* ── CSS variable naming convention ───────────────────────────────────────────
|
|
7
|
+
* Every token is written as a custom property prefixed `--editor-`:
|
|
8
|
+
* colors → --editor-bg, --editor-surface, --editor-accent,
|
|
9
|
+
* --editor-text, --editor-border, --editor-selection
|
|
10
|
+
* fonts → --editor-font-sans, --editor-font-serif, --editor-font-display
|
|
11
|
+
* radii → --editor-radius-{sm|md|lg}
|
|
12
|
+
* spacing → --editor-space-{n} (n = scale step)
|
|
13
|
+
*
|
|
14
|
+
* Optional tokens (serif/display fonts) are only written when present, so a
|
|
15
|
+
* host can detect their absence via an empty `getPropertyValue`. Editor styles
|
|
16
|
+
* reference these vars exclusively, which is what lets a host re-theme the same
|
|
17
|
+
* component without forking its CSS.
|
|
18
|
+
*/
|
|
19
|
+
import type { EditorTheme } from './types'
|
|
20
|
+
|
|
21
|
+
/**
|
|
22
|
+
* Montaj's default editor theme. Values mirror Montaj's existing Tailwind /
|
|
23
|
+
* `index.css` palette, which is dark-first:
|
|
24
|
+
* background gray-900 (#111827, matches `surface.DEFAULT`)
|
|
25
|
+
* surface gray-800 (#1f2937, matches `surface.raised`)
|
|
26
|
+
* border gray-700 (#374151, matches `surface.overlay`)
|
|
27
|
+
* text gray-100 (#f3f4f6, matches `html.dark` text)
|
|
28
|
+
* accent indigo-500 (#6366f1) — Montaj's interactive accent
|
|
29
|
+
* selection indigo-400 (#818cf8) — element-selection highlight
|
|
30
|
+
* font Inter (the configured `fontFamily.sans`)
|
|
31
|
+
*/
|
|
32
|
+
export const defaultMontajTheme: EditorTheme = {
|
|
33
|
+
colors: {
|
|
34
|
+
background: '#111827',
|
|
35
|
+
surface: '#1f2937',
|
|
36
|
+
accent: '#6366f1',
|
|
37
|
+
text: '#f3f4f6',
|
|
38
|
+
border: '#374151',
|
|
39
|
+
selection: '#818cf8',
|
|
40
|
+
},
|
|
41
|
+
fonts: {
|
|
42
|
+
sans: "'Inter', system-ui, -apple-system, sans-serif",
|
|
43
|
+
},
|
|
44
|
+
radii: {
|
|
45
|
+
sm: '0.25rem',
|
|
46
|
+
md: '0.5rem',
|
|
47
|
+
lg: '0.75rem',
|
|
48
|
+
},
|
|
49
|
+
spacing: {
|
|
50
|
+
1: '0.25rem',
|
|
51
|
+
2: '0.5rem',
|
|
52
|
+
3: '0.75rem',
|
|
53
|
+
4: '1rem',
|
|
54
|
+
6: '1.5rem',
|
|
55
|
+
8: '2rem',
|
|
56
|
+
},
|
|
57
|
+
}
|
|
58
|
+
|
|
59
|
+
/**
|
|
60
|
+
* Write `theme`'s tokens onto `el` as CSS custom properties following the
|
|
61
|
+
* convention documented at the top of this file. Idempotent — calling it again
|
|
62
|
+
* with a different theme overwrites the previously-set vars.
|
|
63
|
+
*/
|
|
64
|
+
export function applyTheme(el: HTMLElement, theme: EditorTheme): void {
|
|
65
|
+
const { style } = el
|
|
66
|
+
|
|
67
|
+
// Colors
|
|
68
|
+
style.setProperty('--editor-bg', theme.colors.background)
|
|
69
|
+
style.setProperty('--editor-surface', theme.colors.surface)
|
|
70
|
+
style.setProperty('--editor-accent', theme.colors.accent)
|
|
71
|
+
style.setProperty('--editor-text', theme.colors.text)
|
|
72
|
+
style.setProperty('--editor-border', theme.colors.border)
|
|
73
|
+
style.setProperty('--editor-selection', theme.colors.selection)
|
|
74
|
+
|
|
75
|
+
// Fonts — sans is required; serif/display are written only when present.
|
|
76
|
+
style.setProperty('--editor-font-sans', theme.fonts.sans)
|
|
77
|
+
if (theme.fonts.serif !== undefined) {
|
|
78
|
+
style.setProperty('--editor-font-serif', theme.fonts.serif)
|
|
79
|
+
}
|
|
80
|
+
if (theme.fonts.display !== undefined) {
|
|
81
|
+
style.setProperty('--editor-font-display', theme.fonts.display)
|
|
82
|
+
}
|
|
83
|
+
|
|
84
|
+
// Radii
|
|
85
|
+
style.setProperty('--editor-radius-sm', theme.radii.sm)
|
|
86
|
+
style.setProperty('--editor-radius-md', theme.radii.md)
|
|
87
|
+
style.setProperty('--editor-radius-lg', theme.radii.lg)
|
|
88
|
+
|
|
89
|
+
// Spacing scale — one var per step.
|
|
90
|
+
for (const [step, value] of Object.entries(theme.spacing)) {
|
|
91
|
+
style.setProperty(`--editor-space-${step}`, value)
|
|
92
|
+
}
|
|
93
|
+
}
|