@bycrux/editor 0.11.2 → 1.0.1
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/package.json +3 -1
- package/src/ControlsInfoModal.tsx +251 -61
- package/src/__tests__/ControlsInfoModal.test.tsx +43 -0
- package/src/__tests__/adapter.test.ts +59 -1
- package/src/__tests__/schema-assignability.test.ts +93 -0
- package/src/__tests__/schema.test.ts +15 -0
- package/src/__tests__/video-adapter-contract.test.ts +128 -5
- package/src/carousel/AddElementMenu.tsx +10 -4
- package/src/carousel/CarouselEditor.tsx +46 -8
- package/src/carousel/CarouselRenderModal.tsx +15 -10
- package/src/carousel/OverlayPicker.tsx +10 -3
- package/src/carousel/SlidePropertyPanel.tsx +43 -21
- package/src/components/FilmstripScrubber.tsx +277 -0
- package/src/components/__tests__/FilmstripScrubber.test.tsx +216 -0
- package/src/engine/__tests__/audio-clock.test.ts +655 -0
- package/src/engine/__tests__/audio-worklet-source.test.ts +316 -0
- package/src/engine/__tests__/batch-planner.test.ts +298 -0
- package/src/engine/__tests__/decode-worker-source.test.ts +249 -0
- package/src/engine/__tests__/demux-ranged.test.ts +495 -0
- package/src/engine/__tests__/demux-truncated.test.ts +136 -0
- package/src/engine/__tests__/demux.test.ts +305 -0
- package/src/engine/__tests__/eligibility.test.ts +146 -0
- package/src/engine/__tests__/engine-recovery.test.ts +263 -0
- package/src/engine/__tests__/engine.test.ts +326 -0
- package/src/engine/__tests__/frame-server-ranged.test.ts +261 -0
- package/src/engine/__tests__/frame-server.test.ts +326 -0
- package/src/engine/__tests__/media-loader-ranged.test.ts +289 -0
- package/src/engine/__tests__/media-loader.test.ts +100 -0
- package/src/engine/__tests__/scheduler-crop.test.ts +353 -0
- package/src/engine/__tests__/scheduler.test.ts +1310 -0
- package/src/engine/__tests__/scrub-resolve.test.ts +96 -0
- package/src/engine/__tests__/scrub-source.test.ts +195 -0
- package/src/engine/__tests__/source-host.test.ts +455 -0
- package/src/engine/__tests__/time-stretch.test.ts +182 -0
- package/src/engine/audio-clock.ts +1179 -0
- package/src/engine/audio-worklet-source.ts +160 -0
- package/src/engine/batch-planner.ts +308 -0
- package/src/engine/debug-hud.tsx +54 -0
- package/src/engine/decode-worker-source.ts +142 -0
- package/src/engine/demux.ts +900 -0
- package/src/engine/eligibility.ts +140 -0
- package/src/engine/frame-server.ts +644 -0
- package/src/engine/index.ts +950 -0
- package/src/engine/media-loader.ts +380 -0
- package/src/engine/mp4box.d.ts +214 -0
- package/src/engine/scheduler.ts +1324 -0
- package/src/engine/scrub-resolve.ts +66 -0
- package/src/engine/scrub-source.ts +496 -0
- package/src/engine/time-stretch.ts +224 -0
- package/src/index.ts +83 -1
- package/src/preview/OverlayPreview.tsx +2 -24
- package/src/schema.ts +146 -3
- package/src/state/__tests__/use-project-sync.test.tsx +56 -0
- package/src/state/use-project-sync.ts +17 -0
- package/src/test-setup.ts +32 -0
- package/src/text/FontPicker.tsx +85 -51
- package/src/text/TextFormattingToolbar.tsx +6 -1
- package/src/text/__tests__/FontPicker.options.test.ts +43 -0
- package/src/text/__tests__/TextFormattingToolbar.test.tsx +4 -4
- package/src/theme.ts +108 -3
- package/src/types.ts +601 -22
- package/src/ui/Loader.tsx +64 -0
- package/src/ui/NumberField.tsx +254 -0
- package/src/ui/Slider.tsx +128 -0
- package/src/ui/Tooltip.tsx +118 -0
- package/src/ui/__tests__/NumberField.test.tsx +298 -0
- package/src/ui/__tests__/Slider.test.tsx +150 -0
- package/src/ui/__tests__/Tooltip.test.tsx +105 -0
- package/src/ui/__tests__/usePersistentState.test.tsx +112 -0
- package/src/ui/badge.tsx +12 -4
- package/src/ui/index.ts +6 -0
- package/src/ui/input.tsx +1 -1
- package/src/ui/select.tsx +1 -1
- package/src/ui/switch.tsx +12 -2
- package/src/ui/textarea.tsx +1 -1
- package/src/ui/usePersistentState.ts +63 -0
- package/src/video/AudioPolishModal.tsx +983 -0
- package/src/video/CaptionListPanel.test.tsx +944 -0
- package/src/video/CaptionListPanel.tsx +1106 -0
- package/src/video/CaptionRegenModal.tsx +37 -9
- package/src/video/CaptionSpecimen.tsx +160 -0
- package/src/video/CaptionStyleGallery.tsx +466 -0
- package/src/video/CommandPalette.tsx +165 -0
- package/src/video/ImageToneMenu.tsx +137 -0
- package/src/video/OverlayInspector.tsx +977 -0
- package/src/video/RenderModal.tsx +1035 -62
- package/src/video/VersionCompare.tsx +258 -0
- package/src/video/VersionPanel.tsx +117 -42
- package/src/video/VideoEditor.tsx +2229 -243
- package/src/video/__tests__/AudioPolishModal.test.tsx +825 -0
- package/src/video/__tests__/CaptionListPanel.font.test.tsx +397 -0
- package/src/video/__tests__/CaptionListPanel.generate.test.tsx +153 -0
- package/src/video/__tests__/CaptionRegenModal.test.tsx +29 -0
- package/src/video/__tests__/CaptionSpecimen.test.tsx +213 -0
- package/src/video/__tests__/CaptionStyleGallery.test.tsx +362 -0
- package/src/video/__tests__/CommandPalette.test.tsx +119 -0
- package/src/video/__tests__/OverlayInspector.test.tsx +1367 -0
- package/src/video/__tests__/RenderModal.exportControls.test.tsx +319 -0
- package/src/video/__tests__/RenderModal.options.test.tsx +562 -0
- package/src/video/__tests__/RenderModal.progress.test.ts +65 -0
- package/src/video/__tests__/VersionCompare.test.tsx +182 -0
- package/src/video/__tests__/VersionPanel.test.tsx +279 -0
- package/src/video/__tests__/VideoEditor.audioPolish.test.tsx +241 -0
- package/src/video/__tests__/VideoEditor.captionDelete.test.tsx +147 -0
- package/src/video/__tests__/VideoEditor.captionGesture.test.tsx +170 -0
- package/src/video/__tests__/VideoEditor.captionSeam.test.tsx +134 -0
- package/src/video/__tests__/VideoEditor.clipKeyframes.test.tsx +59 -0
- package/src/video/__tests__/VideoEditor.context.test.tsx +44 -0
- package/src/video/__tests__/VideoEditor.editFocus.test.tsx +55 -0
- package/src/video/__tests__/VideoEditor.keymap.test.tsx +725 -0
- package/src/video/__tests__/VideoEditor.layout.test.tsx +338 -0
- package/src/video/__tests__/VideoEditor.propertiesPanel.test.tsx +765 -0
- package/src/video/__tests__/VideoEditor.rippleDeleteCaptions.test.tsx +159 -0
- package/src/video/__tests__/VideoEditor.sourcePreview.test.tsx +122 -0
- package/src/video/__tests__/VideoEditor.test.tsx +403 -50
- package/src/video/__tests__/audioMagnet.test.ts +158 -0
- package/src/video/__tests__/audioPolish.test.ts +1202 -0
- package/src/video/__tests__/captionActiveWord.test.ts +107 -0
- package/src/video/__tests__/captionLanes.test.ts +262 -0
- package/src/video/__tests__/captionPositioning.test.tsx +8 -3
- package/src/video/__tests__/captionWordFloor.test.ts +228 -0
- package/src/video/__tests__/clipboard-ops.test.ts +430 -0
- package/src/video/__tests__/cuts.insert.test.ts +244 -0
- package/src/video/__tests__/cuts.test.ts +1213 -34
- package/src/video/__tests__/export-limits.test.ts +195 -0
- package/src/video/__tests__/hover-scrub.test.ts +163 -0
- package/src/video/__tests__/keyframeOps.canKeyframeProp.test.ts +61 -0
- package/src/video/__tests__/keyframeOps.test.ts +643 -0
- package/src/video/__tests__/keymap.test.tsx +171 -0
- package/src/video/__tests__/render-progress.test.tsx +20 -4
- package/src/video/__tests__/shuttle.test.ts +210 -0
- package/src/video/__tests__/source-preview.test.ts +60 -0
- package/src/video/__tests__/timecode.test.ts +77 -0
- package/src/video/__tests__/use-report-context.test.tsx +101 -0
- package/src/video/audioMagnet.ts +72 -0
- package/src/video/audioPolish.ts +774 -0
- package/src/video/captionActiveWord.ts +74 -0
- package/src/video/captionLanes.ts +202 -0
- package/src/video/captionRepair.ts +9 -5
- package/src/video/captionStyleDefaults.ts +100 -0
- package/src/video/captionWordFloor.ts +83 -0
- package/src/video/clipboard-ops.ts +377 -0
- package/src/video/cuts.ts +713 -37
- package/src/video/export-limits.ts +102 -0
- package/src/video/hover-scrub.ts +102 -0
- package/src/video/imageTone.ts +58 -0
- package/src/video/imageToneExamples.ts +10 -0
- package/src/video/keyframeOps.ts +384 -0
- package/src/video/keymap.ts +146 -0
- package/src/video/panels/ClipPropertiesPanel.tsx +706 -0
- package/src/video/panels/LeftPanelTabs.tsx +187 -0
- package/src/video/panels/OverlayContentPanel.tsx +351 -0
- package/src/video/panels/TabNav.tsx +60 -0
- package/src/video/panels/__tests__/ClipPropertiesPanel.test.tsx +645 -0
- package/src/video/panels/__tests__/LeftPanelTabs.test.tsx +189 -0
- package/src/video/panels/__tests__/OverlayContentPanel.test.tsx +222 -0
- package/src/video/panels/__tests__/TabNav.test.tsx +71 -0
- package/src/video/preview/CaptionPreview.tsx +57 -69
- package/src/video/preview/EngineSurface.tsx +103 -0
- package/src/video/preview/OverlayItemsLayer.tsx +315 -103
- package/src/video/preview/PreviewPlayer.tsx +337 -56
- package/src/video/preview/SocialPreviewMenu.tsx +214 -0
- package/src/video/preview/SocialSafeZoneOverlay.tsx +478 -0
- package/src/video/preview/__tests__/CaptionPreview.fonts.test.tsx +97 -0
- package/src/video/preview/__tests__/EngineSurface.test.tsx +114 -0
- package/src/video/preview/__tests__/OverlayItemsLayer.edit.test.tsx +4 -2
- package/src/video/preview/__tests__/OverlayItemsLayer.keyframes.test.tsx +363 -0
- package/src/video/preview/__tests__/OverlayItemsLayer.selection.test.tsx +329 -0
- package/src/video/preview/__tests__/PreviewPlayer.engine.test.tsx +139 -0
- package/src/video/preview/__tests__/SocialPreviewMenu.test.tsx +121 -0
- package/src/video/preview/__tests__/SocialSafeZoneOverlay.test.tsx +165 -0
- package/src/video/preview/__tests__/captionDragState.test.ts +120 -1
- package/src/video/preview/__tests__/latencyCompensation.test.tsx +451 -0
- package/src/video/preview/__tests__/proxySupport.test.ts +75 -0
- package/src/video/preview/__tests__/transformStyle.test.ts +29 -1
- package/src/video/preview/__tests__/useDragOverlay.perAxis.test.ts +197 -0
- package/src/video/preview/__tests__/useEnginePlayback.test.tsx +530 -0
- package/src/video/preview/__tests__/useVideoPlayback.corpus.test.ts +313 -0
- package/src/video/preview/__tests__/useVideoPlayback.test.ts +38 -5
- package/src/video/preview/__tests__/useVideoPlayback.trackAudio.test.ts +278 -0
- package/src/video/preview/audio-context.ts +111 -0
- package/src/video/preview/captionDragState.ts +91 -1
- package/src/video/preview/proxySupport.ts +86 -0
- package/src/video/preview/transformStyle.ts +22 -12
- package/src/video/preview/useDragOverlay.ts +92 -18
- package/src/video/preview/useEnginePlayback.ts +625 -0
- package/src/video/preview/useVideoPlayback.ts +211 -167
- package/src/video/sdrCurves.ts +56 -0
- package/src/video/shuttle.ts +159 -0
- package/src/video/source-preview.ts +66 -0
- package/src/video/timecode.ts +59 -0
- package/src/video/timeline/EditableSegment.tsx +1 -1
- package/src/video/timeline/Scrubber.tsx +46 -174
- package/src/video/timeline/SpeedControl.tsx +95 -0
- package/src/video/timeline/Timeline.tsx +1061 -328
- package/src/video/timeline/TimelineContext.ts +17 -10
- package/src/video/timeline/TrackGutter.tsx +560 -0
- package/src/video/timeline/TrackSettingsPopover.tsx +228 -0
- package/src/video/timeline/VolumeControl.tsx +113 -0
- package/src/video/timeline/__tests__/Timeline.backgroundClick.test.tsx +110 -0
- package/src/video/timeline/__tests__/Timeline.crossfade.test.tsx +104 -0
- package/src/video/timeline/__tests__/Timeline.fadeCurveMenu.test.tsx +174 -0
- package/src/video/timeline/__tests__/Timeline.keyframeDelete.test.tsx +273 -0
- package/src/video/timeline/__tests__/Timeline.keyframeFollow.test.tsx +215 -0
- package/src/video/timeline/__tests__/Timeline.keyframeMenu.test.tsx +253 -0
- package/src/video/timeline/__tests__/Timeline.keymap.test.tsx +372 -0
- package/src/video/timeline/__tests__/Timeline.subcutRegen.test.tsx +351 -0
- package/src/video/timeline/__tests__/TrackGutter.test.tsx +372 -0
- package/src/video/timeline/__tests__/_canvasSelect.test.tsx +273 -0
- package/src/video/timeline/__tests__/_canvasSelect.ts +414 -0
- package/src/video/timeline/__tests__/dragdrop-math.test.ts +135 -0
- package/src/video/timeline/__tests__/effectiveItemAudio.test.ts +50 -0
- package/src/video/timeline/__tests__/enabledTrackItems.test.ts +164 -0
- package/src/video/timeline/__tests__/moveItemAcrossTracks.test.ts +363 -0
- package/src/video/timeline/__tests__/multiSelectOps.test.ts +447 -0
- package/src/video/timeline/__tests__/placement.test.ts +278 -0
- package/src/video/timeline/__tests__/resizeWindowedItem.test.ts +140 -0
- package/src/video/timeline/__tests__/timeline-model.test.ts +576 -0
- package/src/video/timeline/__tests__/visualItemLabel.test.ts +69 -0
- package/src/video/timeline/canvas/TimelineCanvas.tsx +1447 -0
- package/src/video/timeline/canvas/__tests__/TimelineCanvas.drop.test.tsx +439 -0
- package/src/video/timeline/canvas/__tests__/TimelineCanvas.edgeScroll.test.tsx +346 -0
- package/src/video/timeline/canvas/__tests__/TimelineCanvas.panefill.test.tsx +122 -0
- package/src/video/timeline/canvas/__tests__/TimelineCanvas.pendingDrops.test.tsx +316 -0
- package/src/video/timeline/canvas/__tests__/TimelineCanvas.pointer.test.tsx +606 -0
- package/src/video/timeline/canvas/__tests__/TimelineCanvas.test.tsx +407 -0
- package/src/video/timeline/canvas/__tests__/clip-bands.test.ts +77 -0
- package/src/video/timeline/canvas/__tests__/draw.test.ts +2198 -0
- package/src/video/timeline/canvas/__tests__/fade-curve.test.ts +187 -0
- package/src/video/timeline/canvas/__tests__/filmstrips.test.ts +561 -0
- package/src/video/timeline/canvas/__tests__/hit-test.test.ts +818 -0
- package/src/video/timeline/canvas/__tests__/pending-drop.test.ts +210 -0
- package/src/video/timeline/canvas/__tests__/pointer-machine.test.ts +3358 -0
- package/src/video/timeline/canvas/__tests__/snap.test.ts +257 -0
- package/src/video/timeline/canvas/__tests__/viewport.test.ts +399 -0
- package/src/video/timeline/canvas/__tests__/waveforms.test.ts +946 -0
- package/src/video/timeline/canvas/clip-bands.ts +56 -0
- package/src/video/timeline/canvas/draw.ts +2187 -0
- package/src/video/timeline/canvas/fade-curve.ts +111 -0
- package/src/video/timeline/canvas/filmstrips.ts +418 -0
- package/src/video/timeline/canvas/hit-test.ts +501 -0
- package/src/video/timeline/canvas/keyframe-strip.ts +73 -0
- package/src/video/timeline/canvas/pointer-machine.ts +1828 -0
- package/src/video/timeline/canvas/snap.ts +232 -0
- package/src/video/timeline/canvas/viewport.ts +457 -0
- package/src/video/timeline/canvas/waveforms.ts +664 -0
- package/src/video/timeline/makeCaptionEdit.ts +5 -1
- package/src/video/timeline/multiSelectOps.ts +214 -55
- package/src/video/timeline/placement.ts +282 -0
- package/src/video/timeline/timeline-model.ts +919 -0
- package/src/video/timeline/useItemDragDrop.ts +151 -178
- package/src/video/timeline/useTimelineZoom.ts +25 -60
- package/src/video/timeline/utils.ts +0 -12
- package/src/video/use-report-context.ts +75 -0
- package/src/video/preview/OverlayPropsModal.tsx +0 -292
- package/src/video/preview/__tests__/OverlayPropsModal.test.tsx +0 -32
- package/src/video/timeline/AudioTrackRow.tsx +0 -404
- package/src/video/timeline/AudioWaveformLayer.tsx +0 -117
- package/src/video/timeline/CaptionTrackRow.tsx +0 -235
- package/src/video/timeline/PlayheadLine.tsx +0 -18
- package/src/video/timeline/TranscriptModal.tsx +0 -70
- package/src/video/timeline/TranscriptPanel.tsx +0 -273
- package/src/video/timeline/VisualTrackRow.tsx +0 -300
- package/src/video/timeline/__tests__/CaptionTrackRow.test.tsx +0 -241
- package/src/video/timeline/__tests__/PlayheadLine.test.tsx +0 -60
- package/src/video/timeline/__tests__/TranscriptModal.test.tsx +0 -41
- package/src/video/timeline/__tests__/TranscriptPanel.test.tsx +0 -184
- package/src/video/timeline/__tests__/useItemDragDrop.test.ts +0 -72
|
@@ -258,6 +258,62 @@ describe('useProjectSync — transient gestures', () => {
|
|
|
258
258
|
await act(async () => { result.current.undo() })
|
|
259
259
|
expect(result.current.project.name).toBe('Test Project')
|
|
260
260
|
})
|
|
261
|
+
|
|
262
|
+
it('discardTransient restores the pre-gesture state without saving or touching undo', async () => {
|
|
263
|
+
const adapter = makeFakeAdapter()
|
|
264
|
+
const initial = makeProject()
|
|
265
|
+
const { result } = renderHook(() => useProjectSync(adapter, initial.id, initial))
|
|
266
|
+
const canUndoBefore = result.current.canUndo
|
|
267
|
+
|
|
268
|
+
act(() => {
|
|
269
|
+
result.current.mutateTransient((p) => ({ ...p, name: 'Drag 1' }))
|
|
270
|
+
result.current.mutateTransient((p) => ({ ...p, name: 'Drag 2' }))
|
|
271
|
+
})
|
|
272
|
+
expect(result.current.project.name).toBe('Drag 2')
|
|
273
|
+
|
|
274
|
+
act(() => { result.current.discardTransient() })
|
|
275
|
+
|
|
276
|
+
expect(result.current.project).toEqual(initial)
|
|
277
|
+
expect(adapter.saveProject).not.toHaveBeenCalled()
|
|
278
|
+
expect(result.current.canUndo).toBe(canUndoBefore)
|
|
279
|
+
})
|
|
280
|
+
|
|
281
|
+
it('discardTransient clears the baseline so a later unrelated commit does not push a stale undo step', async () => {
|
|
282
|
+
const adapter = makeFakeAdapter()
|
|
283
|
+
const initial = makeProject()
|
|
284
|
+
const { result } = renderHook(() => useProjectSync(adapter, initial.id, initial))
|
|
285
|
+
|
|
286
|
+
// Gesture that gets abandoned.
|
|
287
|
+
act(() => {
|
|
288
|
+
result.current.mutateTransient((p) => ({ ...p, name: 'Abandoned Drag' }))
|
|
289
|
+
})
|
|
290
|
+
act(() => { result.current.discardTransient() })
|
|
291
|
+
expect(result.current.project.name).toBe('Test Project')
|
|
292
|
+
expect(result.current.canUndo).toBe(false)
|
|
293
|
+
|
|
294
|
+
// A later, unrelated commit() — NOT preceded by any new mutateTransient —
|
|
295
|
+
// must not resurrect the discarded gesture's baseline as a phantom undo
|
|
296
|
+
// step. If discardTransient had merely restored state (the naive
|
|
297
|
+
// `mutateTransient(() => baseline)` a caller would otherwise have to do)
|
|
298
|
+
// without clearing `transientBaseline`, this commit() would call
|
|
299
|
+
// pushUndo(baseline) here and canUndo would flip to true despite no new
|
|
300
|
+
// gesture ever having happened.
|
|
301
|
+
await act(async () => { await result.current.commit() })
|
|
302
|
+
|
|
303
|
+
expect(result.current.canUndo).toBe(false)
|
|
304
|
+
})
|
|
305
|
+
|
|
306
|
+
it('discardTransient with no transient in flight is a safe no-op', async () => {
|
|
307
|
+
const adapter = makeFakeAdapter()
|
|
308
|
+
const initial = makeProject()
|
|
309
|
+
const { result } = renderHook(() => useProjectSync(adapter, initial.id, initial))
|
|
310
|
+
|
|
311
|
+
act(() => { result.current.discardTransient() })
|
|
312
|
+
|
|
313
|
+
expect(result.current.project).toEqual(initial)
|
|
314
|
+
expect(adapter.saveProject).not.toHaveBeenCalled()
|
|
315
|
+
expect(result.current.canUndo).toBe(false)
|
|
316
|
+
})
|
|
261
317
|
})
|
|
262
318
|
|
|
263
319
|
describe('useProjectSync — stale baseline regression', () => {
|
|
@@ -52,6 +52,8 @@ export interface UseProjectSync<P extends Project = Project> {
|
|
|
52
52
|
mutateTransient: (fn: (p: P) => P) => void
|
|
53
53
|
/** One queued save for the accumulated transient state; one undo step. */
|
|
54
54
|
commit: () => Promise<void>
|
|
55
|
+
/** Abandon an in-progress transient gesture — no save, no undo push. */
|
|
56
|
+
discardTransient: () => void
|
|
55
57
|
/** Apply server-authored state — no save, no undo push (e.g. caption regen). */
|
|
56
58
|
applyExternal: (p: P) => void
|
|
57
59
|
undo: () => void
|
|
@@ -230,6 +232,20 @@ export function useProjectSync<P extends Project = Project>(
|
|
|
230
232
|
)
|
|
231
233
|
}, [save, pushUndo])
|
|
232
234
|
|
|
235
|
+
// Abandon an in-progress transient gesture: restore projectRef/state to the
|
|
236
|
+
// pre-gesture baseline and clear it. No save, no undo push. A no-op when no
|
|
237
|
+
// transient baseline is set. Exists so callers don't have to reproduce this
|
|
238
|
+
// themselves via `mutateTransient(() => baseline)`, which would restore the
|
|
239
|
+
// state but leave `transientBaseline` set — corrupting the NEXT unrelated
|
|
240
|
+
// commit() into pushing a stale baseline for a gesture that never happened.
|
|
241
|
+
const discardTransient = useCallback((): void => {
|
|
242
|
+
const baseline = transientBaseline.current
|
|
243
|
+
if (baseline === null) return
|
|
244
|
+
transientBaseline.current = null
|
|
245
|
+
projectRef.current = baseline
|
|
246
|
+
setProject(baseline)
|
|
247
|
+
}, [])
|
|
248
|
+
|
|
233
249
|
// undo()/redo() — snapshot swap. Pops the target stack, pushes current state
|
|
234
250
|
// to the opposite stack, replaces the entire state, and enqueues a save so the
|
|
235
251
|
// host persists the swap. Also clears transientBaseline: it replaces state
|
|
@@ -297,6 +313,7 @@ export function useProjectSync<P extends Project = Project>(
|
|
|
297
313
|
mutate,
|
|
298
314
|
mutateTransient,
|
|
299
315
|
commit,
|
|
316
|
+
discardTransient,
|
|
300
317
|
applyExternal,
|
|
301
318
|
undo,
|
|
302
319
|
redo,
|
package/src/test-setup.ts
CHANGED
|
@@ -1 +1,33 @@
|
|
|
1
1
|
import '@testing-library/jest-dom'
|
|
2
|
+
|
|
3
|
+
// SP3 fix B2: jsdom's canPlayType returns '' for everything, which would make
|
|
4
|
+
// the proxy capability gate strip proxySrc in EVERY test and silently change
|
|
5
|
+
// what the src-selection tests assert. Report the proxy codec as playable so
|
|
6
|
+
// tests exercise the supported path by default; the unsupported path is
|
|
7
|
+
// covered explicitly via __setProxySupportForTests in proxySupport.test.ts.
|
|
8
|
+
const realCanPlayType = HTMLMediaElement.prototype.canPlayType
|
|
9
|
+
HTMLMediaElement.prototype.canPlayType = function (type: string) {
|
|
10
|
+
if (type.includes('avc1')) return 'probably'
|
|
11
|
+
return realCanPlayType.call(this, type)
|
|
12
|
+
}
|
|
13
|
+
|
|
14
|
+
// jsdom implements no ResizeObserver, and several editor components measure
|
|
15
|
+
// themselves with one (CaptionSpecimen, SocialSafeZoneOverlay, the preview's
|
|
16
|
+
// scale observer). Without a stub, merely MOUNTING one of them throws
|
|
17
|
+
// `ReferenceError: ResizeObserver is not defined` — a test-infrastructure
|
|
18
|
+
// failure that reads as a behaviour regression. Eight test files had each
|
|
19
|
+
// grown their own local stub; this is that same stub, installed once.
|
|
20
|
+
//
|
|
21
|
+
// A no-op is the right shape: the callback never fires, so every consumer
|
|
22
|
+
// takes its "not measured yet" path, which each one already handles (e.g.
|
|
23
|
+
// CaptionSpecimen clamps to its readable font-size floor). Tests that need
|
|
24
|
+
// real measurements still override this with their own fake.
|
|
25
|
+
//
|
|
26
|
+
// Guarded so a future jsdom that ships a real implementation wins over ours.
|
|
27
|
+
if (typeof globalThis.ResizeObserver === 'undefined') {
|
|
28
|
+
globalThis.ResizeObserver = class {
|
|
29
|
+
observe() {}
|
|
30
|
+
unobserve() {}
|
|
31
|
+
disconnect() {}
|
|
32
|
+
} as unknown as typeof ResizeObserver
|
|
33
|
+
}
|
package/src/text/FontPicker.tsx
CHANGED
|
@@ -1,37 +1,62 @@
|
|
|
1
1
|
import { useEffect, useRef, useState } from 'react'
|
|
2
2
|
import { ChevronDown } from 'lucide-react'
|
|
3
|
+
import { NumberField, stepValue } from '../ui'
|
|
3
4
|
|
|
4
5
|
export type FontOption = {
|
|
5
6
|
label: string
|
|
6
7
|
value: string
|
|
7
8
|
isGoogleFont: boolean
|
|
9
|
+
/** The `googleFonts` fetch spec for this family (e.g. 'Baloo+2:wght@400;700').
|
|
10
|
+
* A caller that persists `fontFamily` must persist this alongside it — a
|
|
11
|
+
* family whose file is never fetched renders as the fallback face. Absent
|
|
12
|
+
* for the non-Google System option. */
|
|
13
|
+
spec?: string
|
|
8
14
|
}
|
|
9
15
|
|
|
16
|
+
// No spec below requests a weight above 800, even though `highlight-box` and
|
|
17
|
+
// `outline` (captionStyleDefaults.ts) default to 900 — deliberate, not an
|
|
18
|
+
// oversight. Requesting a weight a family doesn't publish makes Google Fonts
|
|
19
|
+
// drop that family from the returned CSS ENTIRELY, so a synthesized
|
|
20
|
+
// (browser-faked) 900 is strictly better than a font that silently fails to
|
|
21
|
+
// load. Do not "fix" this by adding 900 to any spec.
|
|
10
22
|
export const FONT_OPTIONS: FontOption[] = [
|
|
11
23
|
{ label: 'System', value: 'system-ui, -apple-system, "Helvetica Neue", sans-serif', isGoogleFont: false },
|
|
12
|
-
{ label: 'Inter', value: '"Inter", system-ui, sans-serif', isGoogleFont: true },
|
|
13
|
-
{ label: 'Roboto', value: '"Roboto", system-ui, sans-serif', isGoogleFont: true },
|
|
14
|
-
{ label: 'Open Sans', value: '"Open Sans", system-ui, sans-serif', isGoogleFont: true },
|
|
15
|
-
{ label: 'Lato', value: '"Lato", system-ui, sans-serif', isGoogleFont: true },
|
|
16
|
-
{ label: 'Montserrat', value: '"Montserrat", system-ui, sans-serif', isGoogleFont: true },
|
|
17
|
-
{ label: 'Poppins', value: '"Poppins", system-ui, sans-serif', isGoogleFont: true },
|
|
18
|
-
{ label: 'Raleway', value: '"Raleway", system-ui, sans-serif', isGoogleFont: true },
|
|
19
|
-
{ label: 'Nunito', value: '"Nunito", system-ui, sans-serif', isGoogleFont: true },
|
|
20
|
-
{ label: 'Work Sans', value: '"Work Sans", system-ui, sans-serif', isGoogleFont: true },
|
|
21
|
-
{ label: 'DM Sans', value: '"DM Sans", system-ui, sans-serif', isGoogleFont: true },
|
|
22
|
-
{ label: 'Rubik', value: '"Rubik", system-ui, sans-serif', isGoogleFont: true },
|
|
23
|
-
{ label: 'Oswald', value: '"Oswald", system-ui, sans-serif', isGoogleFont: true },
|
|
24
|
-
{ label: 'Bebas Neue', value: '"Bebas Neue", system-ui, sans-serif', isGoogleFont: true },
|
|
25
|
-
{ label: 'Playfair Display', value: '"Playfair Display", Georgia, serif', isGoogleFont: true },
|
|
26
|
-
{ label: 'Merriweather', value: '"Merriweather", Georgia, serif', isGoogleFont: true },
|
|
27
|
-
{ label: 'Source Serif 4', value: '"Source Serif 4", Georgia, serif', isGoogleFont: true },
|
|
28
|
-
{ label: 'JetBrains Mono', value: '"JetBrains Mono", ui-monospace, monospace', isGoogleFont: true },
|
|
24
|
+
{ label: 'Inter', value: '"Inter", system-ui, sans-serif', isGoogleFont: true, spec: 'Inter:wght@400;700' },
|
|
25
|
+
{ label: 'Roboto', value: '"Roboto", system-ui, sans-serif', isGoogleFont: true, spec: 'Roboto:wght@400;700' },
|
|
26
|
+
{ label: 'Open Sans', value: '"Open Sans", system-ui, sans-serif', isGoogleFont: true, spec: 'Open+Sans:wght@400;700' },
|
|
27
|
+
{ label: 'Lato', value: '"Lato", system-ui, sans-serif', isGoogleFont: true, spec: 'Lato:wght@400;700' },
|
|
28
|
+
{ label: 'Montserrat', value: '"Montserrat", system-ui, sans-serif', isGoogleFont: true, spec: 'Montserrat:wght@400;700' },
|
|
29
|
+
{ label: 'Poppins', value: '"Poppins", system-ui, sans-serif', isGoogleFont: true, spec: 'Poppins:wght@400;700' },
|
|
30
|
+
{ label: 'Raleway', value: '"Raleway", system-ui, sans-serif', isGoogleFont: true, spec: 'Raleway:wght@400;700' },
|
|
31
|
+
{ label: 'Nunito', value: '"Nunito", system-ui, sans-serif', isGoogleFont: true, spec: 'Nunito:wght@400;700' },
|
|
32
|
+
{ label: 'Work Sans', value: '"Work Sans", system-ui, sans-serif', isGoogleFont: true, spec: 'Work+Sans:wght@400;700' },
|
|
33
|
+
{ label: 'DM Sans', value: '"DM Sans", system-ui, sans-serif', isGoogleFont: true, spec: 'DM+Sans:wght@400;700' },
|
|
34
|
+
{ label: 'Rubik', value: '"Rubik", system-ui, sans-serif', isGoogleFont: true, spec: 'Rubik:wght@400;700' },
|
|
35
|
+
{ label: 'Oswald', value: '"Oswald", system-ui, sans-serif', isGoogleFont: true, spec: 'Oswald:wght@400;700' },
|
|
36
|
+
{ label: 'Bebas Neue', value: '"Bebas Neue", system-ui, sans-serif', isGoogleFont: true, spec: 'Bebas+Neue:wght@400;700' },
|
|
37
|
+
{ label: 'Playfair Display', value: '"Playfair Display", Georgia, serif', isGoogleFont: true, spec: 'Playfair+Display:wght@400;700' },
|
|
38
|
+
{ label: 'Merriweather', value: '"Merriweather", Georgia, serif', isGoogleFont: true, spec: 'Merriweather:wght@400;700' },
|
|
39
|
+
{ label: 'Source Serif 4', value: '"Source Serif 4", Georgia, serif', isGoogleFont: true, spec: 'Source+Serif+4:wght@400;700' },
|
|
40
|
+
{ label: 'JetBrains Mono', value: '"JetBrains Mono", ui-monospace, monospace', isGoogleFont: true, spec: 'JetBrains+Mono:wght@400;700' },
|
|
41
|
+
// Rounded / display group — approved for the @cruxthedoberman account.
|
|
42
|
+
{ label: 'Baloo 2', value: '"Baloo 2", system-ui, sans-serif', isGoogleFont: true, spec: 'Baloo+2:wght@400;500;600;700;800' },
|
|
43
|
+
{ label: 'Fredoka', value: '"Fredoka", system-ui, sans-serif', isGoogleFont: true, spec: 'Fredoka:wght@300;400;500;600;700' },
|
|
44
|
+
{ label: 'Sniglet', value: '"Sniglet", system-ui, sans-serif', isGoogleFont: true, spec: 'Sniglet:wght@400;800' },
|
|
29
45
|
]
|
|
30
46
|
|
|
47
|
+
// Fetches exactly the weights each family publishes, via the `spec` carried
|
|
48
|
+
// on every Google-font option — the same spec a caller persists alongside
|
|
49
|
+
// fontFamily. Falls back to the old label-derived guess only if `spec` is
|
|
50
|
+
// somehow missing, so a stale/partial FontOption still resolves to a URL.
|
|
51
|
+
// The list is now a slightly bigger stylesheet than the old flat
|
|
52
|
+
// wght@400;700-everywhere version (Baloo 2/Fredoka/Sniglet pull in
|
|
53
|
+
// 300/500/600/800), but that's the correct trade: the picker preview and
|
|
54
|
+
// the persisted spec fetch identical weights, so what you see in the
|
|
55
|
+
// dropdown is what actually renders.
|
|
31
56
|
const GOOGLE_FONTS_URL = (() => {
|
|
32
57
|
const params = FONT_OPTIONS
|
|
33
58
|
.filter((f) => f.isGoogleFont)
|
|
34
|
-
.map((f) => `family=${f.label.replace(/ /g, '+')}:wght@400;700`)
|
|
59
|
+
.map((f) => `family=${f.spec ?? `${f.label.replace(/ /g, '+')}:wght@400;700`}`)
|
|
35
60
|
.join('&')
|
|
36
61
|
return `https://fonts.googleapis.com/css2?${params}&display=swap`
|
|
37
62
|
})()
|
|
@@ -160,59 +185,68 @@ type FontSizePickerProps = {
|
|
|
160
185
|
|
|
161
186
|
// Extract the numeric portion of a stored fontSize. Tolerates "25", "25px",
|
|
162
187
|
// " 25px ", "25 px", or a bare number. Returns '' when no numeric prefix is
|
|
163
|
-
// present so the
|
|
188
|
+
// present so the NumberField renders empty instead of NaN-empty — `''` is
|
|
189
|
+
// exactly how that component spells "no explicit value" (see its `value` doc).
|
|
164
190
|
function parseFontSizeNumeric(value: string): string {
|
|
165
191
|
const m = /^\s*(-?\d+(?:\.\d+)?)/.exec(value)
|
|
166
192
|
return m ? m[1] : ''
|
|
167
193
|
}
|
|
168
194
|
|
|
169
195
|
export function FontSizePicker({ value, onChange, disabled, min = 8, max = 9999, className }: FontSizePickerProps) {
|
|
170
|
-
|
|
171
|
-
|
|
172
|
-
|
|
173
|
-
|
|
174
|
-
|
|
175
|
-
}, [onChange])
|
|
176
|
-
|
|
177
|
-
useEffect(() => {
|
|
178
|
-
if (!isFocused.current) setLocal(parseFontSizeNumeric(value))
|
|
179
|
-
}, [value])
|
|
196
|
+
// NumberField carries its own "what's on screen while typing" draft (see its
|
|
197
|
+
// doc comment) — the bespoke `local` state and `isFocused` ref this used to
|
|
198
|
+
// hand-roll did exactly that job, so converting drops them rather than
|
|
199
|
+
// keeping a second copy of the same fix alongside NumberField's.
|
|
200
|
+
const numericValue = parseFontSizeNumeric(value)
|
|
180
201
|
|
|
181
202
|
// Commit immediately on every keystroke. Debouncing here is the wrong call
|
|
182
203
|
// for typography — the rendered text is the live preview, so delaying the
|
|
183
|
-
//
|
|
204
|
+
// write delays user feedback. `onPreview` points at this function for that
|
|
205
|
+
// reason: there is no separate "final" edit here, every valid keystroke
|
|
206
|
+
// already writes through — and that write is itself a COMMITTED, async
|
|
207
|
+
// mutator (`onChange` flows to `TextFormattingToolbar`'s
|
|
208
|
+
// `void updateOverlayProp(...)`, a real round trip; `value` only reflects
|
|
209
|
+
// it once the promise resolves). `onCommit` is therefore a no-op below,
|
|
210
|
+
// NOT this same function: blur has nothing left to do once typing already
|
|
211
|
+
// persisted the edit, and re-firing `commit` there would race the async
|
|
212
|
+
// write — `outgoing !== value` could still read true for a value
|
|
213
|
+
// `onPreview` already sent, because `value` has not caught up yet, and
|
|
214
|
+
// that would fire a second, fully redundant `updateOverlayProp` (and
|
|
215
|
+
// undo entry) for the same edit. `SlidePropertyPanel`'s `numInput` faces
|
|
216
|
+
// the identical hazard against its own async mutator — see its comment.
|
|
184
217
|
//
|
|
185
218
|
// CSS font-size requires a unit; a unitless value is invalid and the
|
|
186
219
|
// overlay template falls back to its default. The picker is number-only
|
|
187
220
|
// for the operator, so we attach `px` here before writing — never asking
|
|
188
|
-
// the user to type the unit themselves.
|
|
189
|
-
|
|
190
|
-
|
|
191
|
-
|
|
221
|
+
// the user to type the unit themselves. The `outgoing !== value` guard
|
|
222
|
+
// skips a write that would only echo back what is already stored.
|
|
223
|
+
//
|
|
224
|
+
function commit(next: number): void {
|
|
192
225
|
const outgoing = `${next}px`
|
|
193
|
-
if (outgoing !== value)
|
|
226
|
+
if (outgoing !== value) onChange(outgoing)
|
|
227
|
+
}
|
|
228
|
+
|
|
229
|
+
/** A stepper nudge. Falls back to `min` when the box is empty — the value is
|
|
230
|
+
* a free-text CSS length that may carry no numeric prefix at all, and
|
|
231
|
+
* nudging from `NaN` would write `NaNpx`. */
|
|
232
|
+
function step(direction: 1 | -1): void {
|
|
233
|
+
const from = numericValue === '' ? min : Number(numericValue)
|
|
234
|
+
commit(stepValue(from, direction, { step: 1, min, max }))
|
|
194
235
|
}
|
|
195
236
|
|
|
196
237
|
return (
|
|
197
|
-
<
|
|
198
|
-
|
|
238
|
+
<NumberField
|
|
239
|
+
name="Font size"
|
|
240
|
+
value={numericValue}
|
|
241
|
+
onPreview={commit}
|
|
242
|
+
onCommit={() => {}}
|
|
243
|
+
onStep={step}
|
|
244
|
+
disabled={disabled}
|
|
199
245
|
min={min}
|
|
200
246
|
max={max}
|
|
201
|
-
disabled={disabled}
|
|
202
|
-
value={local}
|
|
203
247
|
placeholder="size"
|
|
204
|
-
|
|
205
|
-
|
|
206
|
-
isFocused.current = true
|
|
207
|
-
}}
|
|
208
|
-
onBlur={() => {
|
|
209
|
-
isFocused.current = false
|
|
210
|
-
}}
|
|
211
|
-
className={
|
|
212
|
-
className ??
|
|
213
|
-
'h-8 w-14 rounded-md border border-[var(--editor-border)] bg-[var(--editor-surface)] px-2.5 text-sm text-[var(--editor-text)] focus:outline-none focus:border-[var(--editor-accent)] focus:ring-1 focus:ring-[var(--editor-accent)] disabled:opacity-50'
|
|
214
|
-
}
|
|
215
|
-
aria-label="Font size"
|
|
248
|
+
unit="px"
|
|
249
|
+
className={className ?? 'w-14'}
|
|
216
250
|
/>
|
|
217
251
|
)
|
|
218
252
|
}
|
|
@@ -60,6 +60,10 @@ export type TextFormattingToolbarProps = {
|
|
|
60
60
|
element: OverlayElement
|
|
61
61
|
updateOverlayProp: (slideId: string, elementId: string, key: string, value: string) => Promise<unknown>
|
|
62
62
|
onDelete?: () => void
|
|
63
|
+
/** Editor theme mode — light/dark. Only affects the delete button's hover
|
|
64
|
+
* hue (red-400 is sub-AA on a light `--editor-surface`). Absent -> dark,
|
|
65
|
+
* matching every existing caller. */
|
|
66
|
+
mode?: 'light' | 'dark'
|
|
63
67
|
}
|
|
64
68
|
|
|
65
69
|
export function TextFormattingToolbar({
|
|
@@ -67,6 +71,7 @@ export function TextFormattingToolbar({
|
|
|
67
71
|
element,
|
|
68
72
|
updateOverlayProp,
|
|
69
73
|
onDelete,
|
|
74
|
+
mode = 'dark',
|
|
70
75
|
}: TextFormattingToolbarProps) {
|
|
71
76
|
const props = element.overlay.props
|
|
72
77
|
|
|
@@ -238,7 +243,7 @@ export function TextFormattingToolbar({
|
|
|
238
243
|
type="button"
|
|
239
244
|
aria-label="Delete text overlay"
|
|
240
245
|
onClick={onDelete}
|
|
241
|
-
className={`${toolbarBtnBase} border-[var(--editor-border)] bg-[var(--editor-surface)] text-[var(--editor-text)]/60 hover:border-red-500/50 hover:bg-red-900/30 hover:text-red-400`}
|
|
246
|
+
className={`${toolbarBtnBase} border-[var(--editor-border)] bg-[var(--editor-surface)] text-[var(--editor-text)]/60 ${mode === 'light' ? 'hover:border-red-300 hover:bg-red-50 hover:text-red-700' : 'hover:border-red-500/50 hover:bg-red-900/30 hover:text-red-400'}`}
|
|
242
247
|
>
|
|
243
248
|
<Trash2 className="h-3.5 w-3.5" />
|
|
244
249
|
</button>
|
|
@@ -0,0 +1,43 @@
|
|
|
1
|
+
import { describe, expect, it } from 'vitest'
|
|
2
|
+
import { FONT_OPTIONS, findFontOption } from '../FontPicker'
|
|
3
|
+
|
|
4
|
+
describe('FONT_OPTIONS', () => {
|
|
5
|
+
it('gives every Google font a non-empty spec, and no non-Google font a spec', () => {
|
|
6
|
+
for (const opt of FONT_OPTIONS) {
|
|
7
|
+
if (opt.isGoogleFont) {
|
|
8
|
+
expect(opt.spec, `${opt.label} should have a spec`).toBeTruthy()
|
|
9
|
+
} else {
|
|
10
|
+
expect(opt.spec, `${opt.label} should not have a spec`).toBeUndefined()
|
|
11
|
+
}
|
|
12
|
+
}
|
|
13
|
+
})
|
|
14
|
+
|
|
15
|
+
it('includes the rounded/display group: Baloo 2, Fredoka, Sniglet', () => {
|
|
16
|
+
const labels = FONT_OPTIONS.map((f) => f.label)
|
|
17
|
+
expect(labels).toContain('Baloo 2')
|
|
18
|
+
expect(labels).toContain('Fredoka')
|
|
19
|
+
expect(labels).toContain('Sniglet')
|
|
20
|
+
})
|
|
21
|
+
|
|
22
|
+
it('resolves Baloo 2 via findFontOption by its CSS value', () => {
|
|
23
|
+
const resolved = findFontOption('"Baloo 2", system-ui, sans-serif')
|
|
24
|
+
expect(resolved?.label).toBe('Baloo 2')
|
|
25
|
+
})
|
|
26
|
+
|
|
27
|
+
it('does not request weight 700 for Sniglet, which only publishes 400 and 800', () => {
|
|
28
|
+
const sniglet = FONT_OPTIONS.find((f) => f.label === 'Sniglet')
|
|
29
|
+
expect(sniglet?.spec).toBeTruthy()
|
|
30
|
+
const weights = sniglet!.spec!.split('wght@')[1]?.split(';') ?? []
|
|
31
|
+
expect(weights).not.toContain('700')
|
|
32
|
+
expect(weights).toContain('400')
|
|
33
|
+
expect(weights).toContain('800')
|
|
34
|
+
})
|
|
35
|
+
|
|
36
|
+
it('has no literal spaces in any spec (URL-safe as a css2 family param)', () => {
|
|
37
|
+
for (const opt of FONT_OPTIONS) {
|
|
38
|
+
if (opt.spec) {
|
|
39
|
+
expect(opt.spec.includes(' '), `${opt.label} spec "${opt.spec}" contains a space`).toBe(false)
|
|
40
|
+
}
|
|
41
|
+
}
|
|
42
|
+
})
|
|
43
|
+
})
|
|
@@ -347,7 +347,7 @@ describe('TextFormattingToolbar — adaptive rendering by element prop schema',
|
|
|
347
347
|
expect(screen.getByRole('button', { name: /italic/i })).toBeInTheDocument();
|
|
348
348
|
expect(screen.getByRole('button', { name: /text case/i })).toBeInTheDocument();
|
|
349
349
|
expect(screen.getByLabelText(/text color/i)).toBeInTheDocument();
|
|
350
|
-
expect(screen.getByLabelText(
|
|
350
|
+
expect(screen.getByLabelText('Font size')).toBeInTheDocument();
|
|
351
351
|
expect(screen.getByLabelText(/font family/i)).toBeInTheDocument();
|
|
352
352
|
expect(screen.getByRole('radio', { name: /align left/i })).toBeInTheDocument();
|
|
353
353
|
});
|
|
@@ -365,7 +365,7 @@ describe('TextFormattingToolbar — adaptive rendering by element prop schema',
|
|
|
365
365
|
expect(screen.queryByRole('button', { name: /italic/i })).not.toBeInTheDocument();
|
|
366
366
|
expect(screen.queryByRole('button', { name: /text case/i })).not.toBeInTheDocument();
|
|
367
367
|
expect(screen.queryByLabelText(/text color/i)).not.toBeInTheDocument();
|
|
368
|
-
expect(screen.queryByLabelText(
|
|
368
|
+
expect(screen.queryByLabelText('Font size')).toBeInTheDocument();
|
|
369
369
|
expect(screen.queryByLabelText(/font family/i)).not.toBeInTheDocument();
|
|
370
370
|
expect(screen.queryByRole('radio', { name: /align left/i })).not.toBeInTheDocument();
|
|
371
371
|
});
|
|
@@ -380,7 +380,7 @@ describe('TextFormattingToolbar — adaptive rendering by element prop schema',
|
|
|
380
380
|
/>,
|
|
381
381
|
);
|
|
382
382
|
expect(screen.queryByRole('button', { name: /bold/i })).not.toBeInTheDocument();
|
|
383
|
-
expect(screen.queryByLabelText(
|
|
383
|
+
expect(screen.queryByLabelText('Font size')).not.toBeInTheDocument();
|
|
384
384
|
expect(screen.getByText(/property panel/i)).toBeInTheDocument();
|
|
385
385
|
});
|
|
386
386
|
|
|
@@ -396,7 +396,7 @@ describe('TextFormattingToolbar — adaptive rendering by element prop schema',
|
|
|
396
396
|
updateOverlayProp={vi.fn(() => Promise.resolve())}
|
|
397
397
|
/>,
|
|
398
398
|
);
|
|
399
|
-
expect(screen.queryByLabelText(
|
|
399
|
+
expect(screen.queryByLabelText('Font size')).not.toBeInTheDocument();
|
|
400
400
|
expect(screen.queryByRole('button', { name: /bold/i })).not.toBeInTheDocument();
|
|
401
401
|
expect(screen.getByText(/property panel/i)).toBeInTheDocument();
|
|
402
402
|
});
|
package/src/theme.ts
CHANGED
|
@@ -6,8 +6,8 @@
|
|
|
6
6
|
* ── CSS variable naming convention ───────────────────────────────────────────
|
|
7
7
|
* Every token is written as a custom property prefixed `--editor-`:
|
|
8
8
|
* colors → --editor-bg, --editor-surface, --editor-accent,
|
|
9
|
-
* --editor-accent-foreground, --editor-text, --editor-
|
|
10
|
-
* --editor-selection
|
|
9
|
+
* --editor-accent-foreground, --editor-accent-text, --editor-text,
|
|
10
|
+
* --editor-border, --editor-selection
|
|
11
11
|
* fonts → --editor-font-sans, --editor-font-serif, --editor-font-display
|
|
12
12
|
* radii → --editor-radius-{sm|md|lg}
|
|
13
13
|
* spacing → --editor-space-{n} (n = scale step)
|
|
@@ -69,14 +69,113 @@ export const defaultMontajTheme: EditorTheme = {
|
|
|
69
69
|
},
|
|
70
70
|
}
|
|
71
71
|
|
|
72
|
+
/**
|
|
73
|
+
* Montaj's light editor theme — the counterpart to `defaultMontajTheme` for
|
|
74
|
+
* hosts that toggle to light mode. Values are chosen to mirror the dark
|
|
75
|
+
* palette's relationships rather than its literal colors:
|
|
76
|
+
* background gray-100 (#f3f4f6) — the editor shell
|
|
77
|
+
* surface white (#ffffff) — raised panels/inputs/buttons, brighter
|
|
78
|
+
* than the shell (the dark theme's surface is
|
|
79
|
+
* lighter than its bg too, just inverted in sense)
|
|
80
|
+
* border gray-200 (#e5e7eb) — the hairline
|
|
81
|
+
* text gray-900 (#111827) — primary text
|
|
82
|
+
* accent indigo-500 (#6366f1) — unchanged from the dark theme; Montaj's
|
|
83
|
+
* interactive accent reads fine on either ground
|
|
84
|
+
* accentForeground white (#ffffff) — unchanged; readable on the indigo accent
|
|
85
|
+
* selection indigo-500 (#6366f1) — deliberately darker than the dark
|
|
86
|
+
* theme's indigo-400 (#818cf8) so the selection
|
|
87
|
+
* outline has enough contrast against a light ground
|
|
88
|
+
* font Inter (identical to the dark theme)
|
|
89
|
+
*
|
|
90
|
+
* fonts/radii/spacing are identical to `defaultMontajTheme` — only the color
|
|
91
|
+
* ramp changes between modes.
|
|
92
|
+
*/
|
|
93
|
+
export const lightMontajTheme: EditorTheme = {
|
|
94
|
+
colors: {
|
|
95
|
+
background: '#f3f4f6',
|
|
96
|
+
surface: '#ffffff',
|
|
97
|
+
accent: '#6366f1',
|
|
98
|
+
accentForeground: '#ffffff',
|
|
99
|
+
text: '#111827',
|
|
100
|
+
border: '#e5e7eb',
|
|
101
|
+
selection: '#6366f1',
|
|
102
|
+
},
|
|
103
|
+
fonts: {
|
|
104
|
+
sans: "'Inter', system-ui, -apple-system, sans-serif",
|
|
105
|
+
},
|
|
106
|
+
radii: {
|
|
107
|
+
sm: '0.25rem',
|
|
108
|
+
md: '0.5rem',
|
|
109
|
+
lg: '0.75rem',
|
|
110
|
+
},
|
|
111
|
+
spacing: {
|
|
112
|
+
1: '0.25rem',
|
|
113
|
+
2: '0.5rem',
|
|
114
|
+
3: '0.75rem',
|
|
115
|
+
4: '1rem',
|
|
116
|
+
6: '1.5rem',
|
|
117
|
+
8: '2rem',
|
|
118
|
+
},
|
|
119
|
+
}
|
|
120
|
+
|
|
121
|
+
/**
|
|
122
|
+
* Relative-luminance test on `theme.colors.background`, used to classify a
|
|
123
|
+
* theme as light or dark. Exists because the canvas timeline and
|
|
124
|
+
* `VideoEditor` render onto an HTML canvas — they can't rely on CSS to pick
|
|
125
|
+
* their palette, so they need a cheap, direct way to ask "is this theme
|
|
126
|
+
* light?" without the `EditorTheme` type growing timeline-specific color
|
|
127
|
+
* tokens (background/grid/playhead/etc.) that only those two consumers care
|
|
128
|
+
* about. Parses 3- and 6-digit `#rgb`/`#rrggbb` hex; an unparseable or
|
|
129
|
+
* unrecognized color string falls back to `false` (dark), since dark has
|
|
130
|
+
* always been the default here and must never silently regress.
|
|
131
|
+
*/
|
|
132
|
+
export function isLightTheme(theme: EditorTheme): boolean {
|
|
133
|
+
const hex = theme.colors.background.trim()
|
|
134
|
+
const match = /^#([0-9a-fA-F]{3}|[0-9a-fA-F]{6})$/.exec(hex)
|
|
135
|
+
if (!match) return false
|
|
136
|
+
|
|
137
|
+
const digits = match[1]
|
|
138
|
+
const expand = (c: string): number => parseInt(c + c, 16)
|
|
139
|
+
|
|
140
|
+
let r: number
|
|
141
|
+
let g: number
|
|
142
|
+
let b: number
|
|
143
|
+
if (digits.length === 3) {
|
|
144
|
+
r = expand(digits[0])
|
|
145
|
+
g = expand(digits[1])
|
|
146
|
+
b = expand(digits[2])
|
|
147
|
+
} else {
|
|
148
|
+
r = parseInt(digits.slice(0, 2), 16)
|
|
149
|
+
g = parseInt(digits.slice(2, 4), 16)
|
|
150
|
+
b = parseInt(digits.slice(4, 6), 16)
|
|
151
|
+
}
|
|
152
|
+
|
|
153
|
+
// Standard relative-luminance weighting (ITU-R BT.601-ish perceptual
|
|
154
|
+
// weights); thresholded at the midpoint of the 0-255 range.
|
|
155
|
+
const luminance = 0.299 * r + 0.587 * g + 0.114 * b
|
|
156
|
+
return luminance > 127.5
|
|
157
|
+
}
|
|
158
|
+
|
|
72
159
|
/**
|
|
73
160
|
* Write `theme`'s tokens onto `el` as CSS custom properties following the
|
|
74
161
|
* convention documented at the top of this file. Idempotent — calling it again
|
|
75
162
|
* with a different theme overwrites the previously-set vars.
|
|
76
163
|
*/
|
|
77
164
|
export function applyTheme(el: HTMLElement, theme: EditorTheme): void {
|
|
78
|
-
|
|
165
|
+
// Write the vars onto the editor container AND :root. The container is the
|
|
166
|
+
// primary target (scopes the theme to the editor); mirroring to
|
|
167
|
+
// document.documentElement lets components that portal OUTSIDE the container —
|
|
168
|
+
// RenderModal renders to document.body so a transformed/filtered host ancestor
|
|
169
|
+
// can't break its `fixed` overlay — still resolve `var(--editor-*)`. Without
|
|
170
|
+
// this, those var()-based backgrounds fall back to transparent. Harmless: the
|
|
171
|
+
// vars are all `--editor-*` prefixed and unused outside editor components.
|
|
172
|
+
writeThemeVars(el.style, theme)
|
|
173
|
+
if (typeof document !== 'undefined') {
|
|
174
|
+
writeThemeVars(document.documentElement.style, theme)
|
|
175
|
+
}
|
|
176
|
+
}
|
|
79
177
|
|
|
178
|
+
function writeThemeVars(style: CSSStyleDeclaration, theme: EditorTheme): void {
|
|
80
179
|
// Colors
|
|
81
180
|
style.setProperty('--editor-bg', theme.colors.background)
|
|
82
181
|
style.setProperty('--editor-surface', theme.colors.surface)
|
|
@@ -84,6 +183,12 @@ export function applyTheme(el: HTMLElement, theme: EditorTheme): void {
|
|
|
84
183
|
// Accent-foreground falls back to `text` so it's never empty (e.g. a host
|
|
85
184
|
// theme that omits it still gets a readable foreground for accent controls).
|
|
86
185
|
style.setProperty('--editor-accent-foreground', theme.colors.accentForeground ?? theme.colors.text)
|
|
186
|
+
// Accent as small TEXT. In LIGHT mode the indigo-500 accent falls just under
|
|
187
|
+
// the 4.5:1 AA floor as ~10px text on the light ground (~4.06:1), so accent
|
|
188
|
+
// text nudges to indigo-600 (#4f46e5) there. Dark mode (and any host theme)
|
|
189
|
+
// keeps the accent unchanged. Fills, borders, rings and buttons keep
|
|
190
|
+
// `--editor-accent`; ONLY small accent text should read this token.
|
|
191
|
+
style.setProperty('--editor-accent-text', isLightTheme(theme) ? '#4f46e5' : theme.colors.accent)
|
|
87
192
|
style.setProperty('--editor-text', theme.colors.text)
|
|
88
193
|
style.setProperty('--editor-border', theme.colors.border)
|
|
89
194
|
style.setProperty('--editor-selection', theme.colors.selection)
|