@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,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';
|