@bycrux/editor 0.4.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.
Files changed (89) hide show
  1. package/README.md +165 -0
  2. package/package.json +46 -0
  3. package/src/__tests__/adapter-contract.test.ts +123 -0
  4. package/src/__tests__/adapter.test.ts +185 -0
  5. package/src/__tests__/schema.test.ts +104 -0
  6. package/src/__tests__/video-adapter-contract.test.ts +89 -0
  7. package/src/carousel/AddElementMenu.tsx +211 -0
  8. package/src/carousel/CarouselEditor.tsx +545 -0
  9. package/src/carousel/CarouselRenderModal.tsx +243 -0
  10. package/src/carousel/OverlayErrorBoundary.tsx +99 -0
  11. package/src/carousel/OverlayPicker.tsx +145 -0
  12. package/src/carousel/ReadOnlySlide.tsx +90 -0
  13. package/src/carousel/SlideCanvas.tsx +637 -0
  14. package/src/carousel/SlidePropertyPanel.tsx +387 -0
  15. package/src/carousel/__tests__/CarouselEditor.test.tsx +291 -0
  16. package/src/carousel/__tests__/ReadOnlySlide.test.tsx +139 -0
  17. package/src/carousel/__tests__/SlideCanvasCrop.test.tsx +95 -0
  18. package/src/carousel/__tests__/SlideCanvasFonts.test.tsx +82 -0
  19. package/src/crop/CanvasCropOverlay.tsx +193 -0
  20. package/src/crop/__tests__/crop-math.test.ts +174 -0
  21. package/src/crop/crop-math.ts +125 -0
  22. package/src/gestures/helpers/__tests__/element-transform.test.ts +30 -0
  23. package/src/gestures/helpers/drag.ts +24 -0
  24. package/src/gestures/helpers/element-transform.ts +15 -0
  25. package/src/gestures/helpers/resize.ts +60 -0
  26. package/src/gestures/helpers/rotate.ts +44 -0
  27. package/src/gestures/helpers/snap.ts +64 -0
  28. package/src/gestures/hooks/useOverlayDrag.ts +106 -0
  29. package/src/gestures/hooks/useOverlayResize.ts +67 -0
  30. package/src/gestures/hooks/useOverlayRotate.ts +64 -0
  31. package/src/gestures/index.ts +16 -0
  32. package/src/index.ts +136 -0
  33. package/src/lib/google-fonts.ts +28 -0
  34. package/src/overlays/contract.ts +41 -0
  35. package/src/preview/OverlayPreview.tsx +196 -0
  36. package/src/preview/__tests__/OverlayPreview.test.tsx +169 -0
  37. package/src/schema.ts +201 -0
  38. package/src/state/__tests__/project-reducer.test.ts +957 -0
  39. package/src/state/__tests__/use-project-state.test.tsx +258 -0
  40. package/src/state/mutation-queue.ts +62 -0
  41. package/src/state/project-reducer.ts +328 -0
  42. package/src/state/use-project-state.ts +442 -0
  43. package/src/test-setup.ts +1 -0
  44. package/src/text/FontPicker.tsx +218 -0
  45. package/src/text/InlineTextEditor.tsx +92 -0
  46. package/src/text/TextFormattingToolbar.tsx +248 -0
  47. package/src/text/__tests__/InlineTextEditor.test.tsx +139 -0
  48. package/src/text/__tests__/TextFormattingToolbar.test.tsx +416 -0
  49. package/src/theme.ts +93 -0
  50. package/src/types.ts +486 -0
  51. package/src/ui/__tests__/button.test.tsx +17 -0
  52. package/src/ui/badge.tsx +32 -0
  53. package/src/ui/button.tsx +32 -0
  54. package/src/ui/index.ts +16 -0
  55. package/src/ui/input.tsx +15 -0
  56. package/src/ui/label.tsx +10 -0
  57. package/src/ui/select.tsx +23 -0
  58. package/src/ui/switch.tsx +31 -0
  59. package/src/ui/textarea.tsx +15 -0
  60. package/src/ui/utils.ts +7 -0
  61. package/src/video/RenderModal.tsx +252 -0
  62. package/src/video/VersionPanel.tsx +83 -0
  63. package/src/video/VideoEditor.tsx +508 -0
  64. package/src/video/__tests__/VideoEditor.test.tsx +213 -0
  65. package/src/video/__tests__/captionRepair.test.ts +134 -0
  66. package/src/video/__tests__/cuts.test.ts +198 -0
  67. package/src/video/captionRepair.ts +41 -0
  68. package/src/video/cuts.ts +369 -0
  69. package/src/video/design-canvas.ts +11 -0
  70. package/src/video/preview/CaptionPreview.tsx +83 -0
  71. package/src/video/preview/CarouselPreview.tsx +35 -0
  72. package/src/video/preview/OverlayItemsLayer.tsx +584 -0
  73. package/src/video/preview/PreviewPlayer.tsx +178 -0
  74. package/src/video/preview/useDragOverlay.ts +167 -0
  75. package/src/video/preview/useVideoPlayback.ts +761 -0
  76. package/src/video/timeline/AudioTrackRow.tsx +406 -0
  77. package/src/video/timeline/AudioWaveformLayer.tsx +117 -0
  78. package/src/video/timeline/EditableSegment.tsx +30 -0
  79. package/src/video/timeline/Scrubber.tsx +184 -0
  80. package/src/video/timeline/Timeline.tsx +375 -0
  81. package/src/video/timeline/TimelineContext.ts +25 -0
  82. package/src/video/timeline/TranscriptModal.tsx +63 -0
  83. package/src/video/timeline/TranscriptPanel.tsx +86 -0
  84. package/src/video/timeline/VisualTrackRow.tsx +293 -0
  85. package/src/video/timeline/makeCaptionEdit.ts +32 -0
  86. package/src/video/timeline/multiSelectOps.ts +157 -0
  87. package/src/video/timeline/useItemDragDrop.ts +190 -0
  88. package/src/video/timeline/useTimelineZoom.ts +48 -0
  89. package/src/video/timeline/utils.ts +17 -0
@@ -0,0 +1,139 @@
1
+ import { describe, it, expect, vi, beforeEach, afterEach } from 'vitest'
2
+ import { render, waitFor } from '@testing-library/react'
3
+ import type { ImageElement, OverlayElement, Slide } from '../../types'
4
+ import ReadOnlySlide from '../ReadOnlySlide'
5
+
6
+ // ── ResizeObserver mock plumbing ──────────────────────────────────────────────
7
+ // jsdom has no ResizeObserver. We capture the instances so autoFit tests can
8
+ // drive a callback with a controlled parent size.
9
+ let roInstances: Array<{ cb: ResizeObserverCallback; el: Element | null }> = []
10
+ beforeEach(() => {
11
+ roInstances = []
12
+ ;(globalThis as unknown as { ResizeObserver: unknown }).ResizeObserver = class {
13
+ cb: ResizeObserverCallback
14
+ el: Element | null = null
15
+ constructor(cb: ResizeObserverCallback) {
16
+ this.cb = cb
17
+ roInstances.push(this)
18
+ }
19
+ observe(el: Element) {
20
+ this.el = el
21
+ }
22
+ unobserve() {}
23
+ disconnect() {}
24
+ }
25
+ })
26
+ afterEach(() => vi.restoreAllMocks())
27
+
28
+ const imgEl: ImageElement = {
29
+ id: 'el-img',
30
+ type: 'image',
31
+ src: 'a.png',
32
+ x: 100,
33
+ y: 100,
34
+ w: 200,
35
+ h: 200,
36
+ rotation: 0,
37
+ }
38
+
39
+ const overlayEl: OverlayElement = {
40
+ id: 'el-ov',
41
+ type: 'overlay',
42
+ overlay: { template: '/overlays/lp-text.jsx', props: { text: 'Hola' } },
43
+ frame: 0,
44
+ x: 50,
45
+ y: 800,
46
+ w: 400,
47
+ h: 120,
48
+ rotation: 0,
49
+ }
50
+
51
+ function makeSlide(elements: Slide['elements']): Slide {
52
+ return { id: 'slide-0', base_color: '#ffffff', elements }
53
+ }
54
+
55
+ describe('ReadOnlySlide', () => {
56
+ it("renders a slide's image + overlay non-interactively (no interactive chrome)", async () => {
57
+ const { container } = render(
58
+ <ReadOnlySlide
59
+ slide={makeSlide([imgEl, overlayEl])}
60
+ width={1080}
61
+ height={1080}
62
+ scale={0.5}
63
+ resolveImageSrc={(el) => el.src}
64
+ compileOverlay={vi.fn(async () => () => null)}
65
+ />,
66
+ )
67
+
68
+ // Image element renders.
69
+ const img = await waitFor(() => {
70
+ const el = container.querySelector('[data-element-id="el-img"]') as HTMLElement
71
+ if (!el) throw new Error('image wrapper not yet rendered')
72
+ return el
73
+ })
74
+
75
+ // Non-interactive: the canvas root must not advertise interactivity, and the
76
+ // element wrappers must not receive pointer events (no selection chrome).
77
+ expect(container.querySelector('[data-interactive]')).toBeNull()
78
+ expect(img.style.pointerEvents).toBe('none')
79
+
80
+ // No resize / rotate handles rendered in read-only mode.
81
+ expect(container.querySelector('[data-testid="rotate-handle"]')).toBeNull()
82
+ expect(container.querySelector('[data-testid="resize-handle-nw"]')).toBeNull()
83
+ })
84
+
85
+ it('applies the explicit scale when provided', async () => {
86
+ const { container } = render(
87
+ <ReadOnlySlide
88
+ slide={makeSlide([imgEl])}
89
+ width={1080}
90
+ height={1080}
91
+ scale={0.5}
92
+ resolveImageSrc={(el) => el.src}
93
+ />,
94
+ )
95
+ // displayW = width * scale = 1080 * 0.5 = 540.
96
+ await waitFor(() => {
97
+ const inner = container.querySelector('[data-element-id="el-img"]') as HTMLElement
98
+ if (!inner) throw new Error('not rendered')
99
+ })
100
+ const canvas = container.querySelector('div > div') as HTMLElement
101
+ // The SlideCanvas root sets width to displayW; find a node sized 540px.
102
+ const sized = Array.from(container.querySelectorAll('div')).some(
103
+ (d) => (d as HTMLElement).style.width === '540px',
104
+ )
105
+ expect(sized).toBe(true)
106
+ expect(canvas).toBeTruthy()
107
+ })
108
+
109
+ it('measures the parent and applies a fitted scale when autoFit is on', async () => {
110
+ const { container } = render(
111
+ <ReadOnlySlide
112
+ slide={makeSlide([imgEl])}
113
+ width={1080}
114
+ height={1080}
115
+ autoFit
116
+ resolveImageSrc={(el) => el.src}
117
+ />,
118
+ )
119
+
120
+ // A ResizeObserver was created and is observing the root.
121
+ await waitFor(() => expect(roInstances.length).toBeGreaterThan(0))
122
+ const ro = roInstances[0]
123
+ expect(ro.el).not.toBeNull()
124
+
125
+ // Drive the observer: parent of the root is 540x540 → fit = 540/1080 = 0.5.
126
+ const root = ro.el as HTMLElement
127
+ Object.defineProperty(root, 'clientWidth', { value: 540, configurable: true })
128
+ Object.defineProperty(root, 'clientHeight', { value: 540, configurable: true })
129
+ ro.cb([], ro as unknown as ResizeObserver)
130
+
131
+ // After measuring, SlideCanvas renders at displayW = 1080 * 0.5 = 540.
132
+ await waitFor(() => {
133
+ const sized = Array.from(container.querySelectorAll('div')).some(
134
+ (d) => (d as HTMLElement).style.width === '540px',
135
+ )
136
+ expect(sized).toBe(true)
137
+ })
138
+ })
139
+ })
@@ -0,0 +1,95 @@
1
+ import { describe, it, expect, beforeEach, afterEach, vi } from 'vitest'
2
+ import { render, waitFor } from '@testing-library/react'
3
+ import type { ImageElement, Slide } from '../../types'
4
+ import SlideCanvas from '../SlideCanvas'
5
+
6
+ beforeEach(() => {
7
+ ;(globalThis as unknown as { ResizeObserver: unknown }).ResizeObserver = class {
8
+ observe() {}
9
+ unobserve() {}
10
+ disconnect() {}
11
+ }
12
+ })
13
+ afterEach(() => vi.restoreAllMocks())
14
+
15
+ function makeSlide(el: ImageElement): Slide {
16
+ return { id: 'slide-0', base_color: '#ffffff', elements: [el] }
17
+ }
18
+
19
+ describe('SlideCanvas crop display', () => {
20
+ it('renders the oversized-cover crop variant when element.crop is present (non-interactive)', async () => {
21
+ const el: ImageElement = {
22
+ id: 'el-img',
23
+ type: 'image',
24
+ src: 'a.png',
25
+ x: 0,
26
+ y: 0,
27
+ w: 200,
28
+ h: 200,
29
+ rotation: 0,
30
+ crop: { x: 0.25, y: 0.1, w: 0.5, h: 0.5 },
31
+ }
32
+ const { container } = render(
33
+ <SlideCanvas
34
+ slide={makeSlide(el)}
35
+ width={1080}
36
+ height={1080}
37
+ interactive={false}
38
+ scale={1}
39
+ resolveImageSrc={(e) => e.src}
40
+ />,
41
+ )
42
+
43
+ const img = await waitFor(() => {
44
+ const found = container.querySelector('[data-element-id="el-img"] img') as HTMLImageElement
45
+ if (!found) throw new Error('img not rendered')
46
+ return found
47
+ })
48
+
49
+ // Oversized cover: width = 100/cw = 200%, height = 100/ch = 200%.
50
+ expect(img.style.width).toBe('200%')
51
+ expect(img.style.height).toBe('200%')
52
+ // marginLeft = -cx*100/cw = -0.25*100/0.5 = -50%; marginTop = -0.1*100/0.5 = -20%.
53
+ expect(img.style.marginLeft).toBe('-50%')
54
+ expect(img.style.marginTop).toBe('-20%')
55
+ // Tailwind-preflight defeat.
56
+ expect(img.style.maxWidth).toBe('none')
57
+ expect(img.style.maxHeight).toBe('none')
58
+
59
+ // The crop wrapper clips overflow.
60
+ const wrapper = img.parentElement as HTMLElement
61
+ expect(wrapper.style.overflow).toBe('hidden')
62
+ })
63
+
64
+ it('renders plain cover (no crop wrapper) when element.crop is absent', async () => {
65
+ const el: ImageElement = {
66
+ id: 'el-img',
67
+ type: 'image',
68
+ src: 'a.png',
69
+ x: 0,
70
+ y: 0,
71
+ w: 200,
72
+ h: 200,
73
+ rotation: 0,
74
+ }
75
+ const { container } = render(
76
+ <SlideCanvas
77
+ slide={makeSlide(el)}
78
+ width={1080}
79
+ height={1080}
80
+ interactive={false}
81
+ scale={1}
82
+ resolveImageSrc={(e) => e.src}
83
+ />,
84
+ )
85
+
86
+ const img = await waitFor(() => {
87
+ const found = container.querySelector('[data-element-id="el-img"] img') as HTMLImageElement
88
+ if (!found) throw new Error('img not rendered')
89
+ return found
90
+ })
91
+ expect(img.style.objectFit).toBe('cover')
92
+ expect(img.style.width).toBe('100%')
93
+ expect(img.style.height).toBe('100%')
94
+ })
95
+ })
@@ -0,0 +1,82 @@
1
+ import { describe, it, expect, beforeEach, afterEach, vi } from 'vitest'
2
+ import { render, waitFor } from '@testing-library/react'
3
+ import type { OverlayElement, Slide } from '../../types'
4
+
5
+ // Spy on the shared font loader so we can assert the carousel overlay path
6
+ // invokes it with the element's googleFonts list (unit-level — we do not assert
7
+ // actual font bytes load in jsdom).
8
+ const { ensureGoogleFontsLoaded } = vi.hoisted(() => ({ ensureGoogleFontsLoaded: vi.fn() }))
9
+ vi.mock('../../lib/google-fonts', () => ({ ensureGoogleFontsLoaded }))
10
+
11
+ import SlideCanvas from '../SlideCanvas'
12
+
13
+ beforeEach(() => {
14
+ ensureGoogleFontsLoaded.mockClear()
15
+ ;(globalThis as unknown as { ResizeObserver: unknown }).ResizeObserver = class {
16
+ observe() {}
17
+ unobserve() {}
18
+ disconnect() {}
19
+ }
20
+ })
21
+ afterEach(() => vi.restoreAllMocks())
22
+
23
+ function makeSlide(el: OverlayElement): Slide {
24
+ return { id: 'slide-0', base_color: '#ffffff', elements: [el] }
25
+ }
26
+
27
+ describe('SlideCanvas carousel overlay Google Fonts', () => {
28
+ it('loads an overlay element’s googleFonts via the shared helper', async () => {
29
+ const el: OverlayElement = {
30
+ id: 'el-ov',
31
+ type: 'overlay',
32
+ overlay: { template: '/overlays/lp-text.jsx', props: { text: 'Hola' } },
33
+ frame: 0,
34
+ x: 0,
35
+ y: 0,
36
+ w: 400,
37
+ h: 120,
38
+ rotation: 0,
39
+ googleFonts: ['Syne:wght@800', 'Inter:wght@400'],
40
+ }
41
+ render(
42
+ <SlideCanvas
43
+ slide={makeSlide(el)}
44
+ width={1080}
45
+ height={1080}
46
+ interactive={false}
47
+ scale={1}
48
+ compileOverlay={vi.fn(async () => () => null)}
49
+ />,
50
+ )
51
+
52
+ await waitFor(() =>
53
+ expect(ensureGoogleFontsLoaded).toHaveBeenCalledWith(['Syne:wght@800', 'Inter:wght@400']),
54
+ )
55
+ })
56
+
57
+ it('does not throw when an overlay element has no googleFonts', async () => {
58
+ const el: OverlayElement = {
59
+ id: 'el-ov',
60
+ type: 'overlay',
61
+ overlay: { template: '/overlays/lp-text.jsx', props: { text: 'Hola' } },
62
+ frame: 0,
63
+ x: 0,
64
+ y: 0,
65
+ w: 400,
66
+ h: 120,
67
+ rotation: 0,
68
+ }
69
+ render(
70
+ <SlideCanvas
71
+ slide={makeSlide(el)}
72
+ width={1080}
73
+ height={1080}
74
+ interactive={false}
75
+ scale={1}
76
+ compileOverlay={vi.fn(async () => () => null)}
77
+ />,
78
+ )
79
+ // Helper is still invoked (with undefined) — it no-ops internally.
80
+ await waitFor(() => expect(ensureGoogleFontsLoaded).toHaveBeenCalledWith(undefined))
81
+ })
82
+ })
@@ -0,0 +1,193 @@
1
+ import * as React from 'react'
2
+ import type { ImageElement } from '../types'
3
+ import {
4
+ renderedSourceRect,
5
+ fractionToWrapperPx,
6
+ applyCropHandleDrag,
7
+ type CropHandle,
8
+ } from './crop-math'
9
+
10
+ const CROP_HANDLES: CropHandle[] = ['nw', 'n', 'ne', 'w', 'e', 'sw', 's', 'se']
11
+
12
+ const HANDLE_OFFSET: Record<CropHandle, { dx: 0 | 0.5 | 1; dy: 0 | 0.5 | 1; cursor: string }> = {
13
+ nw: { dx: 0, dy: 0, cursor: 'nw-resize' },
14
+ n: { dx: 0.5, dy: 0, cursor: 'n-resize' },
15
+ ne: { dx: 1, dy: 0, cursor: 'ne-resize' },
16
+ w: { dx: 0, dy: 0.5, cursor: 'w-resize' },
17
+ e: { dx: 1, dy: 0.5, cursor: 'e-resize' },
18
+ sw: { dx: 0, dy: 1, cursor: 'sw-resize' },
19
+ s: { dx: 0.5, dy: 1, cursor: 's-resize' },
20
+ se: { dx: 1, dy: 1, cursor: 'se-resize' },
21
+ }
22
+
23
+ export type CanvasCropOverlayProps = {
24
+ /**
25
+ * The image element being cropped. Passed to `resolveImageSrc` to obtain a
26
+ * displayable URL — the overlay never constructs URLs itself.
27
+ */
28
+ element: ImageElement
29
+ /**
30
+ * Host-supplied resolver. Implement as `(el) => adapter.resolveImageSrc(el)`
31
+ * on the caller side; the overlay stays host-agnostic.
32
+ */
33
+ resolveImageSrc: (element: ImageElement) => string
34
+ /** Canvas scale factor (CSS pixels per logical element unit). */
35
+ scale: number
36
+ /** Current crop as source fractions. Controlled — caller owns the state. */
37
+ localCrop: { x: number; y: number; w: number; h: number }
38
+ /** Called on every pointer-move while a handle is held. */
39
+ onLocalCropChange: (next: { x: number; y: number; w: number; h: number }) => void
40
+ /** Called once when the <img> fires onLoad with the source's natural dims. */
41
+ onSrcDimsLoaded: (dims: { width: number; height: number }) => void
42
+ /** Natural dimensions of the source image, if already known. */
43
+ srcDims?: { width: number; height: number }
44
+ }
45
+
46
+ export function CanvasCropOverlay({
47
+ element,
48
+ resolveImageSrc,
49
+ scale,
50
+ localCrop,
51
+ onLocalCropChange,
52
+ onSrcDimsLoaded,
53
+ srcDims,
54
+ }: CanvasCropOverlayProps) {
55
+ const srcUrl = resolveImageSrc(element)
56
+
57
+ const wrapperW = element.w * scale
58
+ const wrapperH = element.h * scale
59
+
60
+ const rendered = srcDims
61
+ ? renderedSourceRect({ wrapperW, wrapperH, srcWidth: srcDims.width, srcHeight: srcDims.height })
62
+ : null
63
+ const windowPx = rendered ? fractionToWrapperPx({ crop: localCrop, rendered }) : null
64
+
65
+ const dragStateRef = React.useRef<{
66
+ handle: CropHandle
67
+ startClient: { x: number; y: number }
68
+ startCrop: { x: number; y: number; w: number; h: number }
69
+ } | null>(null)
70
+
71
+ const onHandlePointerDown = (handle: CropHandle, e: React.PointerEvent<HTMLDivElement>) => {
72
+ e.stopPropagation()
73
+ e.currentTarget.setPointerCapture(e.pointerId)
74
+ dragStateRef.current = {
75
+ handle,
76
+ startClient: { x: e.clientX, y: e.clientY },
77
+ startCrop: localCrop,
78
+ }
79
+ }
80
+
81
+ const onHandlePointerMove = (e: React.PointerEvent<HTMLDivElement>) => {
82
+ const drag = dragStateRef.current
83
+ if (!drag || !srcDims) return
84
+ const dx = e.clientX - drag.startClient.x
85
+ const dy = e.clientY - drag.startClient.y
86
+ const next = applyCropHandleDrag({
87
+ handle: drag.handle,
88
+ initialCrop: drag.startCrop,
89
+ deltaPx: { x: dx, y: dy },
90
+ wrapperW,
91
+ wrapperH,
92
+ srcWidth: srcDims.width,
93
+ srcHeight: srcDims.height,
94
+ })
95
+ onLocalCropChange(next)
96
+ }
97
+
98
+ const onHandlePointerUp = () => {
99
+ dragStateRef.current = null
100
+ }
101
+
102
+ return (
103
+ <div
104
+ style={{ position: 'absolute', inset: 0, overflow: 'hidden', pointerEvents: 'auto' }}
105
+ onPointerDown={(e) => {
106
+ e.stopPropagation()
107
+ }}
108
+ >
109
+ {/* Letterbox preview of the full source image */}
110
+ <img
111
+ src={srcUrl}
112
+ alt="Crop source"
113
+ onLoad={(e) => {
114
+ const img = e.currentTarget
115
+ onSrcDimsLoaded({ width: img.naturalWidth, height: img.naturalHeight })
116
+ }}
117
+ style={{
118
+ position: 'absolute',
119
+ inset: 0,
120
+ width: '100%',
121
+ height: '100%',
122
+ objectFit: 'contain',
123
+ pointerEvents: 'none',
124
+ }}
125
+ />
126
+
127
+ {windowPx && (
128
+ <>
129
+ {/* Dark dimming mask outside the crop window */}
130
+ <div
131
+ data-testid="crop-dim"
132
+ style={{
133
+ position: 'absolute',
134
+ inset: 0,
135
+ background: 'rgba(0, 0, 0, 0.55)',
136
+ clipPath: `polygon(
137
+ 0 0, 100% 0, 100% 100%, 0 100%, 0 0,
138
+ ${windowPx.x}px ${windowPx.y}px,
139
+ ${windowPx.x}px ${windowPx.y + windowPx.h}px,
140
+ ${windowPx.x + windowPx.w}px ${windowPx.y + windowPx.h}px,
141
+ ${windowPx.x + windowPx.w}px ${windowPx.y}px,
142
+ ${windowPx.x}px ${windowPx.y}px
143
+ )`,
144
+ pointerEvents: 'none',
145
+ }}
146
+ />
147
+
148
+ {/* Crop window border */}
149
+ <div
150
+ data-testid="crop-window"
151
+ style={{
152
+ position: 'absolute',
153
+ left: windowPx.x,
154
+ top: windowPx.y,
155
+ width: windowPx.w,
156
+ height: windowPx.h,
157
+ outline: '2px solid var(--editor-selection)',
158
+ pointerEvents: 'none',
159
+ }}
160
+ />
161
+
162
+ {/* 8 resize handles */}
163
+ {CROP_HANDLES.map((handle) => {
164
+ const o = HANDLE_OFFSET[handle]
165
+ return (
166
+ <div
167
+ key={handle}
168
+ data-testid={`crop-handle-${handle}`}
169
+ onPointerDown={(e) => onHandlePointerDown(handle, e)}
170
+ onPointerMove={onHandlePointerMove}
171
+ onPointerUp={onHandlePointerUp}
172
+ style={{
173
+ position: 'absolute',
174
+ left: windowPx.x + o.dx * windowPx.w - 6,
175
+ top: windowPx.y + o.dy * windowPx.h - 6,
176
+ width: 12,
177
+ height: 12,
178
+ backgroundColor: '#fff',
179
+ border: '1.5px solid var(--editor-selection)',
180
+ borderRadius: 2,
181
+ cursor: o.cursor,
182
+ zIndex: 10,
183
+ pointerEvents: 'auto',
184
+ touchAction: 'none',
185
+ }}
186
+ />
187
+ )
188
+ })}
189
+ </>
190
+ )}
191
+ </div>
192
+ )
193
+ }
@@ -0,0 +1,174 @@
1
+ /// <reference types="vitest/globals" />
2
+ import { renderedSourceRect } from '../crop-math'
3
+ import { fractionToWrapperPx, wrapperPxToFraction } from '../crop-math'
4
+
5
+ describe('renderedSourceRect', () => {
6
+ it('1. fits source to width when source is wider than wrapper (letterboxes top/bottom)', () => {
7
+ // wrapper 400x500 (aspect 0.8), source 1920x1080 (aspect 1.78)
8
+ const result = renderedSourceRect({ wrapperW: 400, wrapperH: 500, srcWidth: 1920, srcHeight: 1080 })
9
+ expect(result.width).toBeCloseTo(400)
10
+ expect(result.height).toBeCloseTo(400 / (1920 / 1080)) // ≈ 225
11
+ expect(result.offsetX).toBeCloseTo(0)
12
+ expect(result.offsetY).toBeCloseTo((500 - 225) / 2) // ≈ 137.5
13
+ })
14
+
15
+ it('2. fits source to height when source is taller than wrapper (letterboxes left/right)', () => {
16
+ // wrapper 500x400 (aspect 1.25), source 1080x1920 (aspect 0.5625)
17
+ const result = renderedSourceRect({ wrapperW: 500, wrapperH: 400, srcWidth: 1080, srcHeight: 1920 })
18
+ expect(result.height).toBeCloseTo(400)
19
+ expect(result.width).toBeCloseTo(400 * (1080 / 1920)) // ≈ 225
20
+ expect(result.offsetY).toBeCloseTo(0)
21
+ expect(result.offsetX).toBeCloseTo((500 - 225) / 2) // ≈ 137.5
22
+ })
23
+
24
+ it('3. exact aspect match — no letterbox', () => {
25
+ const result = renderedSourceRect({ wrapperW: 400, wrapperH: 500, srcWidth: 800, srcHeight: 1000 })
26
+ expect(result.width).toBeCloseTo(400)
27
+ expect(result.height).toBeCloseTo(500)
28
+ expect(result.offsetX).toBeCloseTo(0)
29
+ expect(result.offsetY).toBeCloseTo(0)
30
+ })
31
+ })
32
+
33
+ describe('fractionToWrapperPx', () => {
34
+ it('4. maps source-fraction crop to wrapper pixels via the rendered source rect', () => {
35
+ const rendered = { offsetX: 0, offsetY: 137.5, width: 400, height: 225 }
36
+ const crop = { x: 0.1, y: 0.2, w: 0.5, h: 0.6 }
37
+ const px = fractionToWrapperPx({ crop, rendered })
38
+ expect(px.x).toBeCloseTo(0 + 0.1 * 400) // 40
39
+ expect(px.y).toBeCloseTo(137.5 + 0.2 * 225) // 182.5
40
+ expect(px.w).toBeCloseTo(0.5 * 400) // 200
41
+ expect(px.h).toBeCloseTo(0.6 * 225) // 135
42
+ })
43
+ })
44
+
45
+ describe('wrapperPxToFraction', () => {
46
+ it('5. round-trips with fractionToWrapperPx', () => {
47
+ const rendered = { offsetX: 50, offsetY: 0, width: 300, height: 400 }
48
+ const orig = { x: 0.25, y: 0.4, w: 0.3, h: 0.5 }
49
+ const px = fractionToWrapperPx({ crop: orig, rendered })
50
+ const back = wrapperPxToFraction({ px, rendered })
51
+ expect(back.x).toBeCloseTo(orig.x)
52
+ expect(back.y).toBeCloseTo(orig.y)
53
+ expect(back.w).toBeCloseTo(orig.w)
54
+ expect(back.h).toBeCloseTo(orig.h)
55
+ })
56
+ })
57
+
58
+ import { applyCropHandleDrag } from '../crop-math'
59
+
60
+ // Standard fixture: 400x500 element, 800x1000 source — aspect-matched so the
61
+ // rendered source fills the wrapper. Crop fractions map 1:1 to wrapper px on
62
+ // the x axis (400 px wide) and on the y axis (500 px tall) since there's no
63
+ // letterbox.
64
+ const FIXTURE = {
65
+ wrapperW: 400,
66
+ wrapperH: 500,
67
+ srcWidth: 800,
68
+ srcHeight: 1000,
69
+ initialCrop: { x: 0.2, y: 0.2, w: 0.6, h: 0.6 }, // pixel: (80, 100)–(320, 400), 240×300
70
+ }
71
+
72
+ describe('applyCropHandleDrag', () => {
73
+ it('6. SE handle: only the dragged axis moves (free-form, no aspect lock)', () => {
74
+ const next = applyCropHandleDrag({
75
+ handle: 'se',
76
+ initialCrop: FIXTURE.initialCrop,
77
+ deltaPx: { x: -50, y: 0 },
78
+ wrapperW: FIXTURE.wrapperW,
79
+ wrapperH: FIXTURE.wrapperH,
80
+ srcWidth: FIXTURE.srcWidth,
81
+ srcHeight: FIXTURE.srcHeight,
82
+ })
83
+ // NW corner anchored. dx=-50 in 400-px-wide rendered = 0.125 in fractions.
84
+ expect(next.x).toBeCloseTo(0.2)
85
+ expect(next.y).toBeCloseTo(0.2)
86
+ expect(next.w).toBeCloseTo(0.6 - 50 / 400) // 0.475
87
+ expect(next.h).toBeCloseTo(0.6) // unchanged — no aspect coupling
88
+ })
89
+
90
+ it('7. NW handle: dragging inward shrinks from the top-left only on the dragged axes', () => {
91
+ const next = applyCropHandleDrag({
92
+ handle: 'nw',
93
+ initialCrop: FIXTURE.initialCrop,
94
+ deltaPx: { x: 40, y: 0 },
95
+ wrapperW: FIXTURE.wrapperW,
96
+ wrapperH: FIXTURE.wrapperH,
97
+ srcWidth: FIXTURE.srcWidth,
98
+ srcHeight: FIXTURE.srcHeight,
99
+ })
100
+ // SE corner anchored at (0.8, 0.8). Only x changed — y/h untouched.
101
+ expect(next.x).toBeCloseTo(0.3)
102
+ expect(next.w).toBeCloseTo(0.5)
103
+ expect(next.y).toBeCloseTo(0.2)
104
+ expect(next.h).toBeCloseTo(0.6)
105
+ expect(next.x + next.w).toBeCloseTo(0.8)
106
+ })
107
+
108
+ it('8. N handle: only top edge moves; width is unchanged (free-form)', () => {
109
+ const next = applyCropHandleDrag({
110
+ handle: 'n',
111
+ initialCrop: FIXTURE.initialCrop,
112
+ deltaPx: { x: 0, y: 50 }, // drag top edge down 50 px in 500-px-tall rendered
113
+ wrapperW: FIXTURE.wrapperW,
114
+ wrapperH: FIXTURE.wrapperH,
115
+ srcWidth: FIXTURE.srcWidth,
116
+ srcHeight: FIXTURE.srcHeight,
117
+ })
118
+ expect(next.y).toBeCloseTo(0.3)
119
+ expect(next.h).toBeCloseTo(0.5)
120
+ // width/x untouched
121
+ expect(next.x).toBeCloseTo(0.2)
122
+ expect(next.w).toBeCloseTo(0.6)
123
+ })
124
+
125
+ it('9. clamps so the crop never extends past source bounds', () => {
126
+ const next = applyCropHandleDrag({
127
+ handle: 'se',
128
+ initialCrop: { x: 0.5, y: 0.5, w: 0.4, h: 0.4 },
129
+ deltaPx: { x: 200, y: 200 },
130
+ wrapperW: FIXTURE.wrapperW,
131
+ wrapperH: FIXTURE.wrapperH,
132
+ srcWidth: FIXTURE.srcWidth,
133
+ srcHeight: FIXTURE.srcHeight,
134
+ })
135
+ expect(next.x + next.w).toBeLessThanOrEqual(1.0 + 1e-9)
136
+ expect(next.y + next.h).toBeLessThanOrEqual(1.0 + 1e-9)
137
+ expect(next.x).toBeGreaterThanOrEqual(-1e-9)
138
+ expect(next.y).toBeGreaterThanOrEqual(-1e-9)
139
+ })
140
+
141
+ it('10. enforces a minimum size (no zero-width / zero-height crop)', () => {
142
+ const next = applyCropHandleDrag({
143
+ handle: 'se',
144
+ initialCrop: { x: 0.4, y: 0.4, w: 0.2, h: 0.2 },
145
+ deltaPx: { x: -1000, y: -1000 },
146
+ wrapperW: FIXTURE.wrapperW,
147
+ wrapperH: FIXTURE.wrapperH,
148
+ srcWidth: FIXTURE.srcWidth,
149
+ srcHeight: FIXTURE.srcHeight,
150
+ })
151
+ expect(next.w).toBeGreaterThan(0)
152
+ expect(next.h).toBeGreaterThan(0)
153
+ })
154
+
155
+ it('11. aspect-mismatched source: free-form drag still respects letterbox bounds', () => {
156
+ // Source wider than wrapper → rendered letterboxes top/bottom. Initial crop
157
+ // is fully inside rendered bounds; a small inward drag should land inside
158
+ // [0, 1] on both axes.
159
+ const next = applyCropHandleDrag({
160
+ handle: 'se',
161
+ initialCrop: { x: 0.1, y: 0.1, w: 0.5, h: 0.5 },
162
+ deltaPx: { x: -20, y: 0 },
163
+ wrapperW: 400,
164
+ wrapperH: 500,
165
+ srcWidth: 1920,
166
+ srcHeight: 1080,
167
+ })
168
+ expect(next.x).toBeCloseTo(0.1)
169
+ expect(next.y).toBeCloseTo(0.1)
170
+ // dx=-20 px in 400-px-wide rendered = -0.05 in fractions
171
+ expect(next.w).toBeCloseTo(0.45)
172
+ expect(next.h).toBeCloseTo(0.5)
173
+ })
174
+ })