@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,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
+ }
@@ -0,0 +1,64 @@
1
+ export type SnapGuide = { axis: 'x' | 'y'; at: number };
2
+
3
+ export type SnapResult = {
4
+ position: { x: number; y: number };
5
+ guides: SnapGuide[];
6
+ };
7
+
8
+ // `threshold` is in the SAME units as proposed/slide (logical, unscaled).
9
+ // Callers that want a screen-pixel-relative feel should pass roughly
10
+ // `desired_screen_px / scale`. Default is a conservative ~0.7% of a 1080-unit
11
+ // slide — tight enough that snap doesn't trap a near-aligned element at rest,
12
+ // loose enough to still feel magnetic on deliberate alignment passes.
13
+ export function snapToSlide(
14
+ proposed: { x: number; y: number; w: number; h: number },
15
+ slide: { w: number; h: number },
16
+ threshold: number = 8,
17
+ ): SnapResult {
18
+ const thresholdX = threshold;
19
+ const thresholdY = threshold;
20
+ const guides: SnapGuide[] = [];
21
+ let { x, y } = proposed;
22
+
23
+ // X-axis snap candidates: element-left/center/right vs slide-left/center/right.
24
+ const elCenterX = proposed.x + proposed.w / 2;
25
+ const elRightX = proposed.x + proposed.w;
26
+ const slideCenterX = slide.w / 2;
27
+ const xCandidates: Array<{ at: number; dx: number }> = [
28
+ { at: 0, dx: -proposed.x }, // align element left to slide left
29
+ { at: slideCenterX, dx: slideCenterX - elCenterX }, // center
30
+ { at: slide.w, dx: slide.w - elRightX }, // align element right to slide right
31
+ ];
32
+ let bestX = { dx: Infinity, at: 0 };
33
+ for (const c of xCandidates) {
34
+ if (Math.abs(c.dx) < Math.abs(bestX.dx) && Math.abs(c.dx) <= thresholdX) {
35
+ bestX = c;
36
+ }
37
+ }
38
+ if (bestX.dx !== Infinity) {
39
+ x = proposed.x + bestX.dx;
40
+ guides.push({ axis: 'x', at: bestX.at });
41
+ }
42
+
43
+ // Y-axis snap (mirror).
44
+ const elCenterY = proposed.y + proposed.h / 2;
45
+ const elBottomY = proposed.y + proposed.h;
46
+ const slideCenterY = slide.h / 2;
47
+ const yCandidates: Array<{ at: number; dy: number }> = [
48
+ { at: 0, dy: -proposed.y },
49
+ { at: slideCenterY, dy: slideCenterY - elCenterY },
50
+ { at: slide.h, dy: slide.h - elBottomY },
51
+ ];
52
+ let bestY = { dy: Infinity, at: 0 };
53
+ for (const c of yCandidates) {
54
+ if (Math.abs(c.dy) < Math.abs(bestY.dy) && Math.abs(c.dy) <= thresholdY) {
55
+ bestY = c;
56
+ }
57
+ }
58
+ if (bestY.dy !== Infinity) {
59
+ y = proposed.y + bestY.dy;
60
+ guides.push({ axis: 'y', at: bestY.at });
61
+ }
62
+
63
+ return { position: { x, y }, guides };
64
+ }
@@ -0,0 +1,106 @@
1
+ import { useCallback, useRef, useState } from 'react';
2
+ import { startDrag, nextDragPosition, type DragState } from '../helpers/drag';
3
+ import { snapToSlide, type SnapGuide } from '../helpers/snap';
4
+
5
+ // Cursor must travel this many screen pixels from pointer-down before snap
6
+ // engages. Prevents the element from being trapped at a snap target that
7
+ // happens to align with its starting position (e.g. an element whose bottom
8
+ // edge sits exactly on the slide's bottom edge would otherwise re-snap on
9
+ // every pointer event until the user crosses the threshold).
10
+ const DEAD_ZONE_PX = 12;
11
+
12
+ // Screen-pixel-relative snap distance. Converted to logical units per move
13
+ // using the current scale; floored at 1 logical unit so very-zoomed-out views
14
+ // still snap meaningfully.
15
+ const SNAP_PX = 6;
16
+
17
+ export type UseOverlayDragArgs = {
18
+ scale: number;
19
+ slide: { w: number; h: number };
20
+ // Called on every pointer move with the latest position and current rotation.
21
+ // Consumers should perform a cheap, side-effect-only update here (e.g. mutate
22
+ // DOM style) — do NOT trigger React state updates, since this fires at
23
+ // pointer-event rate.
24
+ onPreview: (elementId: string, x: number, y: number, rotation: number) => void;
25
+ // Called once on pointer up with the final position. This is where React
26
+ // state should be committed and persistence should be triggered.
27
+ onCommit?: (elementId: string, x: number, y: number) => void | Promise<void>;
28
+ };
29
+
30
+ export type UseOverlayDragReturn = {
31
+ guides: SnapGuide[];
32
+ onPointerDown: (
33
+ elementId: string,
34
+ cursor: { x: number; y: number },
35
+ element: { x: number; y: number; w: number; h: number; rotation?: number },
36
+ ) => void;
37
+ onPointerMove: (cursor: { x: number; y: number }) => void;
38
+ onPointerUp: () => void;
39
+ };
40
+
41
+ export function useOverlayDrag({ scale, slide, onPreview, onCommit }: UseOverlayDragArgs): UseOverlayDragReturn {
42
+ const [guides, setGuides] = useState<SnapGuide[]>([]);
43
+ const stateRef = useRef<DragState | null>(null);
44
+ const sizeRef = useRef<{ w: number; h: number } | null>(null);
45
+ const latestRef = useRef<{ id: string; x: number; y: number } | null>(null);
46
+ const rotationRef = useRef<number>(0);
47
+
48
+ const onPointerDown = useCallback(
49
+ (
50
+ elementId: string,
51
+ cursor: { x: number; y: number },
52
+ element: { x: number; y: number; w: number; h: number; rotation?: number },
53
+ ) => {
54
+ stateRef.current = startDrag(elementId, cursor, element);
55
+ sizeRef.current = { w: element.w, h: element.h };
56
+ rotationRef.current = element.rotation ?? 0;
57
+ latestRef.current = { id: elementId, x: element.x, y: element.y };
58
+ },
59
+ [],
60
+ );
61
+
62
+ const onPointerMove = useCallback(
63
+ (cursor: { x: number; y: number }) => {
64
+ const s = stateRef.current;
65
+ const size = sizeRef.current;
66
+ if (!s || !size) return;
67
+ const raw = nextDragPosition(s, cursor, scale);
68
+
69
+ // Skip snapping until the cursor has cleared the dead-zone. This is a
70
+ // gesture-level escape hatch: elements that start at a snap target can
71
+ // never get unstuck otherwise (every move re-snaps them back).
72
+ const cursorDx = cursor.x - s.startCursor.x;
73
+ const cursorDy = cursor.y - s.startCursor.y;
74
+ const cursorDistSq = cursorDx * cursorDx + cursorDy * cursorDy;
75
+ const inDeadZone = cursorDistSq < DEAD_ZONE_PX * DEAD_ZONE_PX;
76
+
77
+ const snap = inDeadZone
78
+ ? { position: raw, guides: [] as SnapGuide[] }
79
+ : snapToSlide(
80
+ { x: raw.x, y: raw.y, w: size.w, h: size.h },
81
+ slide,
82
+ Math.max(SNAP_PX / scale, 1),
83
+ );
84
+
85
+ setGuides(snap.guides);
86
+ latestRef.current = { id: s.elementId, x: snap.position.x, y: snap.position.y };
87
+ onPreview(s.elementId, snap.position.x, snap.position.y, rotationRef.current);
88
+ },
89
+ [scale, slide, onPreview],
90
+ );
91
+
92
+ const onPointerUp = useCallback(() => {
93
+ const final = latestRef.current;
94
+ // Idempotent — `pointercancel` and `pointerup` can both fire for the same
95
+ // gesture (especially after `setPointerCapture`); the second arrival sees
96
+ // null state and exits before re-committing.
97
+ if (!final) return;
98
+ stateRef.current = null;
99
+ sizeRef.current = null;
100
+ latestRef.current = null;
101
+ setGuides([]);
102
+ void onCommit?.(final.id, final.x, final.y);
103
+ }, [onCommit]);
104
+
105
+ return { guides, onPointerDown, onPointerMove, onPointerUp };
106
+ }
@@ -0,0 +1,67 @@
1
+ import { useCallback, useRef } from 'react';
2
+ import { startResize, nextResizeBox, type ResizeState, type ResizeHandle } from '../helpers/resize';
3
+
4
+ export type UseOverlayResizeArgs = {
5
+ scale: number;
6
+ // Called on every pointer move with the new box and current rotation. Use for
7
+ // cheap, side-effect-only previews (e.g. direct DOM mutation) — do NOT trigger
8
+ // React state updates here.
9
+ onPreview: (
10
+ elementId: string,
11
+ box: { x: number; y: number; w: number; h: number },
12
+ rotation: number,
13
+ ) => void;
14
+ // Called once on pointer up with the final box. Commit React state here.
15
+ onCommit?: (elementId: string, box: { x: number; y: number; w: number; h: number }) => void | Promise<void>;
16
+ };
17
+
18
+ export type UseOverlayResizeReturn = {
19
+ onPointerDown: (
20
+ elementId: string,
21
+ handle: ResizeHandle,
22
+ cursor: { x: number; y: number },
23
+ box: { x: number; y: number; w: number; h: number; rotation?: number },
24
+ ) => void;
25
+ onPointerMove: (cursor: { x: number; y: number }) => void;
26
+ onPointerUp: () => void;
27
+ };
28
+
29
+ export function useOverlayResize({ scale, onPreview, onCommit }: UseOverlayResizeArgs): UseOverlayResizeReturn {
30
+ const stateRef = useRef<ResizeState | null>(null);
31
+ const latestRef = useRef<{ id: string; box: { x: number; y: number; w: number; h: number } } | null>(null);
32
+ const rotationRef = useRef<number>(0);
33
+
34
+ const onPointerDown = useCallback(
35
+ (
36
+ elementId: string,
37
+ handle: ResizeHandle,
38
+ cursor: { x: number; y: number },
39
+ box: { x: number; y: number; w: number; h: number; rotation?: number },
40
+ ) => {
41
+ stateRef.current = startResize(elementId, handle, cursor, box);
42
+ rotationRef.current = box.rotation ?? 0;
43
+ latestRef.current = { id: elementId, box };
44
+ },
45
+ [],
46
+ );
47
+
48
+ const onPointerMove = useCallback(
49
+ (cursor: { x: number; y: number }) => {
50
+ const s = stateRef.current;
51
+ if (!s) return;
52
+ const box = nextResizeBox(s, cursor, scale);
53
+ latestRef.current = { id: s.elementId, box };
54
+ onPreview(s.elementId, box, rotationRef.current);
55
+ },
56
+ [scale, onPreview],
57
+ );
58
+
59
+ const onPointerUp = useCallback(() => {
60
+ const final = latestRef.current;
61
+ stateRef.current = null;
62
+ latestRef.current = null;
63
+ if (final) void onCommit?.(final.id, final.box);
64
+ }, [onCommit]);
65
+
66
+ return { onPointerDown, onPointerMove, onPointerUp };
67
+ }
@@ -0,0 +1,64 @@
1
+ import { useCallback, useRef } from 'react';
2
+ import { startRotate, nextRotation, type RotateState } from '../helpers/rotate';
3
+
4
+ export type UseOverlayRotateArgs = {
5
+ // Called on every pointer move with the new rotation and the element's (x, y).
6
+ // Use for cheap, side-effect-only previews (e.g. direct DOM mutation) — do NOT
7
+ // trigger React state updates here.
8
+ onPreview: (elementId: string, rotation: number, x: number, y: number) => void;
9
+ // Called once on pointer up with the final rotation. Commit React state here.
10
+ onCommit?: (elementId: string, rotation: number) => void | Promise<void>;
11
+ };
12
+
13
+ export type UseOverlayRotateReturn = {
14
+ onPointerDown: (
15
+ elementId: string,
16
+ center: { x: number; y: number },
17
+ cursor: { x: number; y: number },
18
+ currentRotation: number,
19
+ position: { x: number; y: number },
20
+ ) => void;
21
+ onPointerMove: (cursor: { x: number; y: number }) => void;
22
+ onPointerUp: () => void;
23
+ };
24
+
25
+ export function useOverlayRotate({ onPreview, onCommit }: UseOverlayRotateArgs): UseOverlayRotateReturn {
26
+ const stateRef = useRef<RotateState | null>(null);
27
+ const latestRef = useRef<{ id: string; rotation: number } | null>(null);
28
+ const positionRef = useRef<{ x: number; y: number }>({ x: 0, y: 0 });
29
+
30
+ const onPointerDown = useCallback(
31
+ (
32
+ elementId: string,
33
+ center: { x: number; y: number },
34
+ cursor: { x: number; y: number },
35
+ currentRotation: number,
36
+ position: { x: number; y: number },
37
+ ) => {
38
+ stateRef.current = startRotate(elementId, center, cursor, currentRotation);
39
+ positionRef.current = position;
40
+ latestRef.current = { id: elementId, rotation: currentRotation };
41
+ },
42
+ [],
43
+ );
44
+
45
+ const onPointerMove = useCallback(
46
+ (cursor: { x: number; y: number }) => {
47
+ const s = stateRef.current;
48
+ if (!s) return;
49
+ const rotation = nextRotation(s, cursor);
50
+ latestRef.current = { id: s.elementId, rotation };
51
+ onPreview(s.elementId, rotation, positionRef.current.x, positionRef.current.y);
52
+ },
53
+ [onPreview],
54
+ );
55
+
56
+ const onPointerUp = useCallback(() => {
57
+ const final = latestRef.current;
58
+ stateRef.current = null;
59
+ latestRef.current = null;
60
+ if (final) void onCommit?.(final.id, final.rotation);
61
+ }, [onCommit]);
62
+
63
+ return { onPointerDown, onPointerMove, onPointerUp };
64
+ }
@@ -0,0 +1,16 @@
1
+ export { buildElementTransform } from './helpers/element-transform';
2
+ export { startDrag, nextDragPosition } from './helpers/drag';
3
+ export type { DragState } from './helpers/drag';
4
+ export { startResize, nextResizeBox } from './helpers/resize';
5
+ export type { ResizeHandle, ResizeState } from './helpers/resize';
6
+ export { startRotate, nextRotation } from './helpers/rotate';
7
+ export type { RotateState } from './helpers/rotate';
8
+ export { snapToSlide } from './helpers/snap';
9
+ export type { SnapGuide, SnapResult } from './helpers/snap';
10
+
11
+ export { useOverlayDrag } from './hooks/useOverlayDrag';
12
+ export type { UseOverlayDragArgs, UseOverlayDragReturn } from './hooks/useOverlayDrag';
13
+ export { useOverlayResize } from './hooks/useOverlayResize';
14
+ export type { UseOverlayResizeArgs, UseOverlayResizeReturn } from './hooks/useOverlayResize';
15
+ export { useOverlayRotate } from './hooks/useOverlayRotate';
16
+ export type { UseOverlayRotateArgs, UseOverlayRotateReturn } from './hooks/useOverlayRotate';