@devbycrux/editor 0.1.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (54) 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/carousel/AddElementMenu.tsx +211 -0
  7. package/src/carousel/CarouselEditor.tsx +529 -0
  8. package/src/carousel/CarouselRenderModal.tsx +243 -0
  9. package/src/carousel/OverlayErrorBoundary.tsx +99 -0
  10. package/src/carousel/OverlayPicker.tsx +145 -0
  11. package/src/carousel/SlideCanvas.tsx +588 -0
  12. package/src/carousel/SlidePropertyPanel.tsx +349 -0
  13. package/src/carousel/__tests__/CarouselEditor.test.tsx +235 -0
  14. package/src/crop/CanvasCropOverlay.tsx +193 -0
  15. package/src/crop/__tests__/crop-math.test.ts +174 -0
  16. package/src/crop/crop-math.ts +125 -0
  17. package/src/gestures/helpers/__tests__/element-transform.test.ts +30 -0
  18. package/src/gestures/helpers/drag.ts +24 -0
  19. package/src/gestures/helpers/element-transform.ts +15 -0
  20. package/src/gestures/helpers/resize.ts +60 -0
  21. package/src/gestures/helpers/rotate.ts +44 -0
  22. package/src/gestures/helpers/snap.ts +64 -0
  23. package/src/gestures/hooks/useOverlayDrag.ts +106 -0
  24. package/src/gestures/hooks/useOverlayResize.ts +67 -0
  25. package/src/gestures/hooks/useOverlayRotate.ts +64 -0
  26. package/src/gestures/index.ts +16 -0
  27. package/src/index.ts +112 -0
  28. package/src/overlays/contract.ts +41 -0
  29. package/src/preview/OverlayPreview.tsx +196 -0
  30. package/src/preview/__tests__/OverlayPreview.test.tsx +169 -0
  31. package/src/schema.ts +194 -0
  32. package/src/state/__tests__/project-reducer.test.ts +957 -0
  33. package/src/state/__tests__/use-project-state.test.tsx +258 -0
  34. package/src/state/mutation-queue.ts +62 -0
  35. package/src/state/project-reducer.ts +328 -0
  36. package/src/state/use-project-state.ts +442 -0
  37. package/src/test-setup.ts +1 -0
  38. package/src/text/FontPicker.tsx +218 -0
  39. package/src/text/InlineTextEditor.tsx +92 -0
  40. package/src/text/TextFormattingToolbar.tsx +248 -0
  41. package/src/text/__tests__/InlineTextEditor.test.tsx +139 -0
  42. package/src/text/__tests__/TextFormattingToolbar.test.tsx +416 -0
  43. package/src/theme.ts +93 -0
  44. package/src/types.ts +325 -0
  45. package/src/ui/__tests__/button.test.tsx +17 -0
  46. package/src/ui/badge.tsx +32 -0
  47. package/src/ui/button.tsx +32 -0
  48. package/src/ui/index.ts +16 -0
  49. package/src/ui/input.tsx +15 -0
  50. package/src/ui/label.tsx +10 -0
  51. package/src/ui/select.tsx +23 -0
  52. package/src/ui/switch.tsx +31 -0
  53. package/src/ui/textarea.tsx +15 -0
  54. package/src/ui/utils.ts +7 -0
@@ -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
+ })
@@ -0,0 +1,125 @@
1
+ // Pure math helpers for in-canvas image cropping.
2
+ //
3
+ // Coordinate spaces:
4
+ // - "wrapper" px: pixels inside the SlideElement wrapper, which is sized
5
+ // `(element.w * scale, element.h * scale)` and rotated as a whole.
6
+ // - "rendered source" px: pixels inside the letterboxed source image's
7
+ // rendered rectangle (which sits inside the wrapper at `(offsetX, offsetY)`).
8
+ // - "source fraction": 0–1 fractions of the source's natural dimensions —
9
+ // exactly the storage format on `ImageElement.crop`.
10
+
11
+ export type RenderedRect = {
12
+ offsetX: number
13
+ offsetY: number
14
+ width: number
15
+ height: number
16
+ }
17
+
18
+ export function renderedSourceRect(args: {
19
+ wrapperW: number
20
+ wrapperH: number
21
+ srcWidth: number
22
+ srcHeight: number
23
+ }): RenderedRect {
24
+ const { wrapperW, wrapperH, srcWidth, srcHeight } = args
25
+ const wrapperAspect = wrapperW / wrapperH
26
+ const srcAspect = srcWidth / srcHeight
27
+
28
+ if (srcAspect >= wrapperAspect) {
29
+ const width = wrapperW
30
+ const height = wrapperW / srcAspect
31
+ return { offsetX: 0, offsetY: (wrapperH - height) / 2, width, height }
32
+ }
33
+ const height = wrapperH
34
+ const width = wrapperH * srcAspect
35
+ return { offsetX: (wrapperW - width) / 2, offsetY: 0, width, height }
36
+ }
37
+
38
+ export type CropFraction = { x: number; y: number; w: number; h: number }
39
+ export type WrapperPxRect = { x: number; y: number; w: number; h: number }
40
+
41
+ export function fractionToWrapperPx(args: {
42
+ crop: CropFraction
43
+ rendered: RenderedRect
44
+ }): WrapperPxRect {
45
+ const { crop, rendered } = args
46
+ return {
47
+ x: rendered.offsetX + crop.x * rendered.width,
48
+ y: rendered.offsetY + crop.y * rendered.height,
49
+ w: crop.w * rendered.width,
50
+ h: crop.h * rendered.height,
51
+ }
52
+ }
53
+
54
+ export function wrapperPxToFraction(args: {
55
+ px: WrapperPxRect
56
+ rendered: RenderedRect
57
+ }): CropFraction {
58
+ const { px, rendered } = args
59
+ return {
60
+ x: (px.x - rendered.offsetX) / rendered.width,
61
+ y: (px.y - rendered.offsetY) / rendered.height,
62
+ w: px.w / rendered.width,
63
+ h: px.h / rendered.height,
64
+ }
65
+ }
66
+
67
+ export type CropHandle = 'nw' | 'n' | 'ne' | 'w' | 'e' | 'sw' | 's' | 'se'
68
+
69
+ const MIN_FRACTION = 0.02 // never let the crop shrink below 2% of source on either axis.
70
+
71
+ // Free-form crop drag. Each handle moves its own edges by the delta; corners
72
+ // move two edges, edges move one. Result is min-size enforced and clamped to
73
+ // the rendered source bounds. The element box will be resized at commit time
74
+ // to match the resulting crop's aspect — see commitAndExitCropMode.
75
+ export function applyCropHandleDrag(args: {
76
+ handle: CropHandle
77
+ initialCrop: CropFraction
78
+ deltaPx: { x: number; y: number }
79
+ wrapperW: number
80
+ wrapperH: number
81
+ srcWidth: number
82
+ srcHeight: number
83
+ }): CropFraction {
84
+ const { handle, initialCrop, deltaPx, wrapperW, wrapperH, srcWidth, srcHeight } = args
85
+ const rendered = renderedSourceRect({ wrapperW, wrapperH, srcWidth, srcHeight })
86
+
87
+ const initialPx = fractionToWrapperPx({ crop: initialCrop, rendered })
88
+
89
+ let left = initialPx.x
90
+ let top = initialPx.y
91
+ let right = initialPx.x + initialPx.w
92
+ let bottom = initialPx.y + initialPx.h
93
+
94
+ if (handle === 'nw' || handle === 'w' || handle === 'sw') left += deltaPx.x
95
+ if (handle === 'ne' || handle === 'e' || handle === 'se') right += deltaPx.x
96
+ if (handle === 'nw' || handle === 'n' || handle === 'ne') top += deltaPx.y
97
+ if (handle === 'sw' || handle === 's' || handle === 'se') bottom += deltaPx.y
98
+
99
+ // Enforce min size, anchored on the side opposite to the dragged edge.
100
+ const minW = MIN_FRACTION * rendered.width
101
+ const minH = MIN_FRACTION * rendered.height
102
+ if (right - left < minW) {
103
+ if (handle === 'nw' || handle === 'w' || handle === 'sw') left = right - minW
104
+ else right = left + minW
105
+ }
106
+ if (bottom - top < minH) {
107
+ if (handle === 'nw' || handle === 'n' || handle === 'ne') top = bottom - minH
108
+ else bottom = top + minH
109
+ }
110
+
111
+ // Clamp to source bounds.
112
+ const minLeft = rendered.offsetX
113
+ const minTop = rendered.offsetY
114
+ const maxRight = rendered.offsetX + rendered.width
115
+ const maxBottom = rendered.offsetY + rendered.height
116
+ if (left < minLeft) left = minLeft
117
+ if (top < minTop) top = minTop
118
+ if (right > maxRight) right = maxRight
119
+ if (bottom > maxBottom) bottom = maxBottom
120
+
121
+ return wrapperPxToFraction({
122
+ px: { x: left, y: top, w: right - left, h: bottom - top },
123
+ rendered,
124
+ })
125
+ }
@@ -0,0 +1,30 @@
1
+ /// <reference types="vitest/globals" />
2
+ import { buildElementTransform } from '../element-transform'
3
+
4
+ describe('buildElementTransform', () => {
5
+ it('returns only translate when rotation is zero', () => {
6
+ expect(buildElementTransform(10, 20, 2, 0)).toBe('translate(20px, 40px)')
7
+ })
8
+
9
+ it('omits rotation when rotation is undefined', () => {
10
+ expect(buildElementTransform(0, 0, 1, undefined)).toBe('translate(0px, 0px)')
11
+ })
12
+
13
+ it('omits rotation when rotation is null', () => {
14
+ expect(buildElementTransform(0, 0, 1, null as unknown as undefined))
15
+ .toBe('translate(0px, 0px)')
16
+ })
17
+
18
+ it('composes translate + rotate when rotation is non-zero', () => {
19
+ expect(buildElementTransform(5, 5, 2, 45))
20
+ .toBe('translate(10px, 10px) rotate(45deg)')
21
+ })
22
+
23
+ it('handles negative coordinates', () => {
24
+ expect(buildElementTransform(-3, -4, 1, 0)).toBe('translate(-3px, -4px)')
25
+ })
26
+
27
+ it('handles fractional scale (passes through to sub-pixel CSS)', () => {
28
+ expect(buildElementTransform(10, 10, 0.5, 0)).toBe('translate(5px, 5px)')
29
+ })
30
+ })
@@ -0,0 +1,24 @@
1
+ export type DragState = {
2
+ elementId: string;
3
+ startCursor: { x: number; y: number };
4
+ startElement: { x: number; y: number };
5
+ };
6
+
7
+ export function startDrag(
8
+ elementId: string,
9
+ cursor: { x: number; y: number },
10
+ element: { x: number; y: number },
11
+ ): DragState {
12
+ return { elementId, startCursor: cursor, startElement: element };
13
+ }
14
+
15
+ export function nextDragPosition(
16
+ state: DragState,
17
+ cursor: { x: number; y: number },
18
+ scale: number,
19
+ ): { x: number; y: number } {
20
+ return {
21
+ x: state.startElement.x + (cursor.x - state.startCursor.x) / scale,
22
+ y: state.startElement.y + (cursor.y - state.startCursor.y) / scale,
23
+ };
24
+ }
@@ -0,0 +1,15 @@
1
+ // Builds the CSS `transform` value for an element wrapper. Centralised so
2
+ // React renders (JSX style) and in-gesture mutations (direct DOM style
3
+ // writes in SlideCanvas) produce byte-identical strings — that prevents
4
+ // the wrapper from "snapping" on commit when React's render writes a
5
+ // slightly different transform than the one the DOM already has.
6
+
7
+ export function buildElementTransform(
8
+ x: number,
9
+ y: number,
10
+ scale: number,
11
+ rotation: number | undefined | null,
12
+ ): string {
13
+ const base = `translate(${x * scale}px, ${y * scale}px)`
14
+ return rotation ? `${base} rotate(${rotation}deg)` : base
15
+ }
@@ -0,0 +1,60 @@
1
+ export type ResizeHandle = 'nw' | 'n' | 'ne' | 'w' | 'e' | 'sw' | 's' | 'se';
2
+
3
+ export type ResizeState = {
4
+ elementId: string;
5
+ handle: ResizeHandle;
6
+ startCursor: { x: number; y: number };
7
+ startBox: { x: number; y: number; w: number; h: number };
8
+ };
9
+
10
+ export function startResize(
11
+ elementId: string,
12
+ handle: ResizeHandle,
13
+ cursor: { x: number; y: number },
14
+ box: { x: number; y: number; w: number; h: number },
15
+ ): ResizeState {
16
+ return { elementId, handle, startCursor: cursor, startBox: box };
17
+ }
18
+
19
+ export function nextResizeBox(
20
+ state: ResizeState,
21
+ cursor: { x: number; y: number },
22
+ scale: number,
23
+ ): { x: number; y: number; w: number; h: number } {
24
+ const dx = (cursor.x - state.startCursor.x) / scale;
25
+ const dy = (cursor.y - state.startCursor.y) / scale;
26
+ let { x, y, w, h } = state.startBox;
27
+
28
+ if (state.handle.includes('w')) {
29
+ const proposedW = state.startBox.w - dx;
30
+ if (proposedW < 1) {
31
+ x = state.startBox.x + state.startBox.w - 1;
32
+ w = 1;
33
+ } else {
34
+ x = state.startBox.x + dx;
35
+ w = proposedW;
36
+ }
37
+ }
38
+ if (state.handle.includes('e')) {
39
+ w = state.startBox.w + dx;
40
+ }
41
+ if (state.handle.includes('n')) {
42
+ const proposedH = state.startBox.h - dy;
43
+ if (proposedH < 1) {
44
+ y = state.startBox.y + state.startBox.h - 1;
45
+ h = 1;
46
+ } else {
47
+ y = state.startBox.y + dy;
48
+ h = proposedH;
49
+ }
50
+ }
51
+ if (state.handle.includes('s')) {
52
+ h = state.startBox.h + dy;
53
+ }
54
+ return {
55
+ x,
56
+ y,
57
+ w: Math.max(w, 1),
58
+ h: Math.max(h, 1),
59
+ };
60
+ }
@@ -0,0 +1,44 @@
1
+ export type RotateState = {
2
+ elementId: string;
3
+ center: { x: number; y: number };
4
+ startCursorAngle: number; // degrees
5
+ startRotation: number; // degrees
6
+ };
7
+
8
+ const RAD_TO_DEG = 180 / Math.PI;
9
+
10
+ function angleOf(center: { x: number; y: number }, cursor: { x: number; y: number }): number {
11
+ return Math.atan2(cursor.y - center.y, cursor.x - center.x) * RAD_TO_DEG;
12
+ }
13
+
14
+ export function startRotate(
15
+ elementId: string,
16
+ center: { x: number; y: number },
17
+ cursor: { x: number; y: number },
18
+ rotation: number,
19
+ ): RotateState {
20
+ return {
21
+ elementId,
22
+ center,
23
+ startCursorAngle: angleOf(center, cursor),
24
+ startRotation: rotation,
25
+ };
26
+ }
27
+
28
+ export function nextRotation(
29
+ state: RotateState,
30
+ cursor: { x: number; y: number },
31
+ snapThresholdDeg: number = 5,
32
+ ): number {
33
+ const cursorAngle = angleOf(state.center, cursor);
34
+ const delta = cursorAngle - state.startCursorAngle;
35
+ const raw = state.startRotation + delta;
36
+ // Normalize to [0, 360).
37
+ const norm = ((raw % 360) + 360) % 360;
38
+ for (const snap of [0, 90, 180, 270]) {
39
+ if (Math.abs(norm - snap) <= snapThresholdDeg) return snap;
40
+ }
41
+ // Also check the 360 boundary (which is 0).
42
+ if (Math.abs(norm - 360) <= snapThresholdDeg) return 0;
43
+ return norm;
44
+ }