@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.
- 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/__tests__/video-adapter-contract.test.ts +89 -0
- package/src/carousel/AddElementMenu.tsx +211 -0
- package/src/carousel/CarouselEditor.tsx +545 -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/ReadOnlySlide.tsx +90 -0
- package/src/carousel/SlideCanvas.tsx +637 -0
- package/src/carousel/SlidePropertyPanel.tsx +387 -0
- package/src/carousel/__tests__/CarouselEditor.test.tsx +291 -0
- package/src/carousel/__tests__/ReadOnlySlide.test.tsx +139 -0
- package/src/carousel/__tests__/SlideCanvasCrop.test.tsx +95 -0
- package/src/carousel/__tests__/SlideCanvasFonts.test.tsx +82 -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 +136 -0
- package/src/lib/google-fonts.ts +28 -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 +201 -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 +486 -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
- package/src/video/RenderModal.tsx +252 -0
- package/src/video/VersionPanel.tsx +83 -0
- package/src/video/VideoEditor.tsx +508 -0
- package/src/video/__tests__/VideoEditor.test.tsx +213 -0
- package/src/video/__tests__/captionRepair.test.ts +134 -0
- package/src/video/__tests__/cuts.test.ts +198 -0
- package/src/video/captionRepair.ts +41 -0
- package/src/video/cuts.ts +369 -0
- package/src/video/design-canvas.ts +11 -0
- package/src/video/preview/CaptionPreview.tsx +83 -0
- package/src/video/preview/CarouselPreview.tsx +35 -0
- package/src/video/preview/OverlayItemsLayer.tsx +584 -0
- package/src/video/preview/PreviewPlayer.tsx +178 -0
- package/src/video/preview/useDragOverlay.ts +167 -0
- package/src/video/preview/useVideoPlayback.ts +761 -0
- package/src/video/timeline/AudioTrackRow.tsx +406 -0
- package/src/video/timeline/AudioWaveformLayer.tsx +117 -0
- package/src/video/timeline/EditableSegment.tsx +30 -0
- package/src/video/timeline/Scrubber.tsx +184 -0
- package/src/video/timeline/Timeline.tsx +375 -0
- package/src/video/timeline/TimelineContext.ts +25 -0
- package/src/video/timeline/TranscriptModal.tsx +63 -0
- package/src/video/timeline/TranscriptPanel.tsx +86 -0
- package/src/video/timeline/VisualTrackRow.tsx +293 -0
- package/src/video/timeline/makeCaptionEdit.ts +32 -0
- package/src/video/timeline/multiSelectOps.ts +157 -0
- package/src/video/timeline/useItemDragDrop.ts +190 -0
- package/src/video/timeline/useTimelineZoom.ts +48 -0
- 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
|
+
})
|