@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.
- 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/carousel/AddElementMenu.tsx +211 -0
- package/src/carousel/CarouselEditor.tsx +529 -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/SlideCanvas.tsx +588 -0
- package/src/carousel/SlidePropertyPanel.tsx +349 -0
- package/src/carousel/__tests__/CarouselEditor.test.tsx +235 -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 +112 -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 +194 -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 +325 -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
|
@@ -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';
|
package/src/index.ts
ADDED
|
@@ -0,0 +1,112 @@
|
|
|
1
|
+
// @devbycrux/editor — public API.
|
|
2
|
+
//
|
|
3
|
+
// The package owns the host-agnostic carousel editor: the editor-facing schema,
|
|
4
|
+
// the adapter/theme contracts, and the host-agnostic PIECES (state, gestures,
|
|
5
|
+
// crop, text, preview, overlays). Hosts (Montaj's UI, Hub clients) import from
|
|
6
|
+
// here and supply an EditorAdapter to drive it.
|
|
7
|
+
|
|
8
|
+
// ── Schema (single source of truth for project/slide/element shapes) ──────────
|
|
9
|
+
export type {
|
|
10
|
+
Word,
|
|
11
|
+
AudioTrack,
|
|
12
|
+
CaptionSegment,
|
|
13
|
+
Captions,
|
|
14
|
+
VisualItem,
|
|
15
|
+
Asset,
|
|
16
|
+
ImageElement,
|
|
17
|
+
OverlayElement,
|
|
18
|
+
CarouselElement,
|
|
19
|
+
Slide,
|
|
20
|
+
EditorProject,
|
|
21
|
+
} from './schema'
|
|
22
|
+
|
|
23
|
+
// ── Contracts (adapter, theme, render, media, component props) ────────────────
|
|
24
|
+
// Schema types are sourced from './schema' above; here we export only the
|
|
25
|
+
// symbols types.ts itself owns, plus the `Project` alias (= EditorProject) that
|
|
26
|
+
// the ported state/reducer code is typed against.
|
|
27
|
+
export type {
|
|
28
|
+
Project,
|
|
29
|
+
OverlayFactory,
|
|
30
|
+
RenderEvent,
|
|
31
|
+
RenderOptions,
|
|
32
|
+
MediaScope,
|
|
33
|
+
MediaItem,
|
|
34
|
+
GlobalOverlay,
|
|
35
|
+
GlobalOverlayProp,
|
|
36
|
+
EditorAdapter,
|
|
37
|
+
EditorTheme,
|
|
38
|
+
EditorSlots,
|
|
39
|
+
CarouselEditorProps,
|
|
40
|
+
} from './types'
|
|
41
|
+
|
|
42
|
+
// ── Theme ─────────────────────────────────────────────────────────────────────
|
|
43
|
+
export { defaultMontajTheme, applyTheme } from './theme'
|
|
44
|
+
|
|
45
|
+
// ── State ───────────────────────────────────────────────────────────────────
|
|
46
|
+
export { useProjectState } from './state/use-project-state'
|
|
47
|
+
export type { Connection, UseProjectState } from './state/use-project-state'
|
|
48
|
+
export { projectReducer } from './state/project-reducer'
|
|
49
|
+
export type { Action, ProjectStatus } from './state/project-reducer'
|
|
50
|
+
export { createMutationQueue } from './state/mutation-queue'
|
|
51
|
+
export type { MutationQueue } from './state/mutation-queue'
|
|
52
|
+
|
|
53
|
+
// ── Gestures ──────────────────────────────────────────────────────────────────
|
|
54
|
+
export * from './gestures'
|
|
55
|
+
|
|
56
|
+
// ── Crop ──────────────────────────────────────────────────────────────────────
|
|
57
|
+
export {
|
|
58
|
+
renderedSourceRect,
|
|
59
|
+
fractionToWrapperPx,
|
|
60
|
+
wrapperPxToFraction,
|
|
61
|
+
applyCropHandleDrag,
|
|
62
|
+
} from './crop/crop-math'
|
|
63
|
+
export type {
|
|
64
|
+
RenderedRect,
|
|
65
|
+
CropFraction,
|
|
66
|
+
WrapperPxRect,
|
|
67
|
+
CropHandle,
|
|
68
|
+
} from './crop/crop-math'
|
|
69
|
+
export { CanvasCropOverlay } from './crop/CanvasCropOverlay'
|
|
70
|
+
export type { CanvasCropOverlayProps } from './crop/CanvasCropOverlay'
|
|
71
|
+
|
|
72
|
+
// ── Text ──────────────────────────────────────────────────────────────────────
|
|
73
|
+
export {
|
|
74
|
+
FONT_OPTIONS,
|
|
75
|
+
findFontOption,
|
|
76
|
+
FontFamilyPicker,
|
|
77
|
+
FontSizePicker,
|
|
78
|
+
} from './text/FontPicker'
|
|
79
|
+
export type { FontOption } from './text/FontPicker'
|
|
80
|
+
export { InlineTextEditor } from './text/InlineTextEditor'
|
|
81
|
+
export type { InlineTextEditorProps } from './text/InlineTextEditor'
|
|
82
|
+
export {
|
|
83
|
+
HEX_PATTERN,
|
|
84
|
+
isColorProp,
|
|
85
|
+
isBold,
|
|
86
|
+
isItalic,
|
|
87
|
+
nextCase,
|
|
88
|
+
isStyleProp,
|
|
89
|
+
nonColorTextEntries,
|
|
90
|
+
TextFormattingToolbar,
|
|
91
|
+
} from './text/TextFormattingToolbar'
|
|
92
|
+
export type { TextFormattingToolbarProps } from './text/TextFormattingToolbar'
|
|
93
|
+
|
|
94
|
+
// ── Preview ─────────────────────────────────────────────────────────────────
|
|
95
|
+
export { OverlayPreview } from './preview/OverlayPreview'
|
|
96
|
+
export type { OverlayPreviewProps } from './preview/OverlayPreview'
|
|
97
|
+
|
|
98
|
+
// ── Overlays ──────────────────────────────────────────────────────────────────
|
|
99
|
+
export {
|
|
100
|
+
STANDARD_TEXT_PROPS,
|
|
101
|
+
getSupportedProps,
|
|
102
|
+
readPropAsString,
|
|
103
|
+
} from './overlays/contract'
|
|
104
|
+
|
|
105
|
+
// ── Assembled carousel editor ───────────────────────────────────────────────
|
|
106
|
+
export { default as CarouselEditor } from './carousel/CarouselEditor'
|
|
107
|
+
|
|
108
|
+
// ── Public carousel sub-components ────────────────────────────────────────────
|
|
109
|
+
// Hosts consume these beyond the assembled editor — Montaj's preview/caption
|
|
110
|
+
// components render SlideCanvas thumbnails and wrap overlays in the boundary.
|
|
111
|
+
export { default as SlideCanvas, resolveAsset } from './carousel/SlideCanvas'
|
|
112
|
+
export { default as OverlayErrorBoundary } from './carousel/OverlayErrorBoundary'
|
|
@@ -0,0 +1,41 @@
|
|
|
1
|
+
import type { OverlayElement } from '../types'
|
|
2
|
+
|
|
3
|
+
// The 9-prop editable-text contract. Single source of truth on the FE for
|
|
4
|
+
// which prop keys the floating text toolbar knows how to write. Mirrors
|
|
5
|
+
// hub/backend/src/modules/mcp/overlay-contract.ts REQUIRED_PROPS.
|
|
6
|
+
export const STANDARD_TEXT_PROPS: ReadonlySet<string> = new Set([
|
|
7
|
+
'text', 'fontSize', 'fontFamily', 'fontWeight', 'fontStyle',
|
|
8
|
+
'color', 'textAlign', 'textTransform', 'bgColor',
|
|
9
|
+
])
|
|
10
|
+
|
|
11
|
+
/**
|
|
12
|
+
* Returns the subset of STANDARD_TEXT_PROPS present (non-null) on the
|
|
13
|
+
* element. Toolbar controls render only when their target prop is in this set.
|
|
14
|
+
*
|
|
15
|
+
* Forgiving on value type: numeric values (e.g. `fontSize: 64`) count as
|
|
16
|
+
* present. The toolbar reads them via String(value) and writes back as
|
|
17
|
+
* strings — fixing the round-trip silently rather than blocking the operator
|
|
18
|
+
* with a "this overlay isn't editable" message. Per the resolved product
|
|
19
|
+
* question, this prefers UX over strictness; non-string values on contract
|
|
20
|
+
* props are still flagged upstream by hub.write_overlay's validator.
|
|
21
|
+
*/
|
|
22
|
+
export function getSupportedProps(element: OverlayElement): Set<string> {
|
|
23
|
+
const props = element.overlay.props
|
|
24
|
+
const supported = new Set<string>()
|
|
25
|
+
for (const key of STANDARD_TEXT_PROPS) {
|
|
26
|
+
const value = props[key]
|
|
27
|
+
if (value !== undefined && value !== null) supported.add(key)
|
|
28
|
+
}
|
|
29
|
+
return supported
|
|
30
|
+
}
|
|
31
|
+
|
|
32
|
+
/**
|
|
33
|
+
* Read a contract prop's current value as a string, coercing numbers. Used
|
|
34
|
+
* by the toolbar's read paths so the displayed control value matches what's
|
|
35
|
+
* on the element regardless of its stored type.
|
|
36
|
+
*/
|
|
37
|
+
export function readPropAsString(element: OverlayElement, key: string): string {
|
|
38
|
+
const value = element.overlay.props[key]
|
|
39
|
+
if (value === undefined || value === null) return ''
|
|
40
|
+
return String(value)
|
|
41
|
+
}
|
|
@@ -0,0 +1,196 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* editor-core/preview/OverlayPreview
|
|
3
|
+
*
|
|
4
|
+
* Host-agnostic React component that compiles and renders a JSX overlay
|
|
5
|
+
* template. The overlay compiler is injected via the `compileOverlay` prop so
|
|
6
|
+
* this component has no dependency on any host module (no import from
|
|
7
|
+
* '@/lib/overlay-eval'). The host wires in the compiler from its adapter.
|
|
8
|
+
*
|
|
9
|
+
* States:
|
|
10
|
+
* - Compiling → `loading` node (default: spinner with role="status").
|
|
11
|
+
* - Compile or runtime error → `errorState` node (default: red badge with role="alert").
|
|
12
|
+
* - Success → factory output element.
|
|
13
|
+
*/
|
|
14
|
+
|
|
15
|
+
import React, { useEffect, useState } from 'react'
|
|
16
|
+
import type { OverlayFactory } from '../types'
|
|
17
|
+
|
|
18
|
+
// ── Defaults ─────────────────────────────────────────────────────────────────
|
|
19
|
+
|
|
20
|
+
function DefaultSpinner(): React.ReactElement {
|
|
21
|
+
return (
|
|
22
|
+
<div
|
|
23
|
+
role="status"
|
|
24
|
+
aria-label="Loading overlay"
|
|
25
|
+
style={{
|
|
26
|
+
position: 'absolute',
|
|
27
|
+
inset: 0,
|
|
28
|
+
display: 'flex',
|
|
29
|
+
alignItems: 'center',
|
|
30
|
+
justifyContent: 'center',
|
|
31
|
+
color: 'rgba(255, 255, 255, 0.65)',
|
|
32
|
+
}}
|
|
33
|
+
>
|
|
34
|
+
<svg
|
|
35
|
+
width="36"
|
|
36
|
+
height="36"
|
|
37
|
+
viewBox="0 0 24 24"
|
|
38
|
+
fill="none"
|
|
39
|
+
stroke="currentColor"
|
|
40
|
+
strokeWidth="2"
|
|
41
|
+
strokeLinecap="round"
|
|
42
|
+
>
|
|
43
|
+
<g>
|
|
44
|
+
<path d="M21 12a9 9 0 1 1-6.219-8.56" />
|
|
45
|
+
<animateTransform
|
|
46
|
+
attributeName="transform"
|
|
47
|
+
type="rotate"
|
|
48
|
+
from="0 12 12"
|
|
49
|
+
to="360 12 12"
|
|
50
|
+
dur="0.9s"
|
|
51
|
+
repeatCount="indefinite"
|
|
52
|
+
/>
|
|
53
|
+
</g>
|
|
54
|
+
</svg>
|
|
55
|
+
</div>
|
|
56
|
+
)
|
|
57
|
+
}
|
|
58
|
+
|
|
59
|
+
function DefaultErrorState(): React.ReactElement {
|
|
60
|
+
return (
|
|
61
|
+
<div
|
|
62
|
+
role="alert"
|
|
63
|
+
style={{
|
|
64
|
+
position: 'absolute',
|
|
65
|
+
inset: 0,
|
|
66
|
+
display: 'flex',
|
|
67
|
+
alignItems: 'center',
|
|
68
|
+
justifyContent: 'center',
|
|
69
|
+
padding: 16,
|
|
70
|
+
}}
|
|
71
|
+
>
|
|
72
|
+
<div
|
|
73
|
+
style={{
|
|
74
|
+
display: 'inline-flex',
|
|
75
|
+
alignItems: 'center',
|
|
76
|
+
gap: 6,
|
|
77
|
+
padding: '4px 10px',
|
|
78
|
+
borderRadius: 6,
|
|
79
|
+
background: 'rgba(220, 38, 38, 0.15)',
|
|
80
|
+
color: 'rgb(220, 38, 38)',
|
|
81
|
+
fontSize: 12,
|
|
82
|
+
fontFamily: 'system-ui, -apple-system, sans-serif',
|
|
83
|
+
fontWeight: 500,
|
|
84
|
+
}}
|
|
85
|
+
>
|
|
86
|
+
<svg
|
|
87
|
+
width="14"
|
|
88
|
+
height="14"
|
|
89
|
+
viewBox="0 0 24 24"
|
|
90
|
+
fill="none"
|
|
91
|
+
stroke="currentColor"
|
|
92
|
+
strokeWidth="2"
|
|
93
|
+
strokeLinecap="round"
|
|
94
|
+
strokeLinejoin="round"
|
|
95
|
+
>
|
|
96
|
+
<circle cx="12" cy="12" r="10" />
|
|
97
|
+
<line x1="12" y1="8" x2="12" y2="12" />
|
|
98
|
+
<line x1="12" y1="16" x2="12.01" y2="16" />
|
|
99
|
+
</svg>
|
|
100
|
+
<span>overlay error</span>
|
|
101
|
+
</div>
|
|
102
|
+
</div>
|
|
103
|
+
)
|
|
104
|
+
}
|
|
105
|
+
|
|
106
|
+
// ── Props ─────────────────────────────────────────────────────────────────────
|
|
107
|
+
|
|
108
|
+
export interface OverlayPreviewProps {
|
|
109
|
+
/**
|
|
110
|
+
* Host-supplied compiler. Receives a template path and returns a compiled
|
|
111
|
+
* OverlayFactory. Injected from the adapter so editor-core never imports
|
|
112
|
+
* the host's overlay-eval module directly.
|
|
113
|
+
*/
|
|
114
|
+
compileOverlay: (template: string) => Promise<OverlayFactory>
|
|
115
|
+
|
|
116
|
+
/**
|
|
117
|
+
* Path to the overlay template file. Passed to the injected compileOverlay.
|
|
118
|
+
* Matches OverlayElement.overlay.template from Montaj's schema.
|
|
119
|
+
*/
|
|
120
|
+
template: string
|
|
121
|
+
|
|
122
|
+
/**
|
|
123
|
+
* Runtime props forwarded to the overlay factory.
|
|
124
|
+
* Matches OverlayElement.overlay.props.
|
|
125
|
+
*/
|
|
126
|
+
props: Record<string, unknown>
|
|
127
|
+
|
|
128
|
+
/**
|
|
129
|
+
* Frame number to render. Matches OverlayElement.frame or the caller's
|
|
130
|
+
* scrubber position.
|
|
131
|
+
*/
|
|
132
|
+
frame: number
|
|
133
|
+
|
|
134
|
+
/** Frames per second. Passed to the factory alongside frame. */
|
|
135
|
+
fps: number
|
|
136
|
+
|
|
137
|
+
/**
|
|
138
|
+
* Duration in frames. Passed to the factory as `durationFrames`.
|
|
139
|
+
* Mirrors how SlideCanvas derives duration from element.overlay.props.duration.
|
|
140
|
+
*/
|
|
141
|
+
duration: number
|
|
142
|
+
|
|
143
|
+
/** Shown while compileOverlay is in-flight. Default: spinner. */
|
|
144
|
+
loading?: React.ReactNode
|
|
145
|
+
|
|
146
|
+
/** Shown on compile error or factory runtime error. Default: red badge. */
|
|
147
|
+
errorState?: React.ReactNode
|
|
148
|
+
}
|
|
149
|
+
|
|
150
|
+
// ── Component ─────────────────────────────────────────────────────────────────
|
|
151
|
+
|
|
152
|
+
export function OverlayPreview({
|
|
153
|
+
compileOverlay,
|
|
154
|
+
template,
|
|
155
|
+
props,
|
|
156
|
+
frame,
|
|
157
|
+
fps,
|
|
158
|
+
duration,
|
|
159
|
+
loading,
|
|
160
|
+
errorState,
|
|
161
|
+
}: OverlayPreviewProps): React.ReactElement {
|
|
162
|
+
const [factory, setFactory] = useState<OverlayFactory | null>(null)
|
|
163
|
+
const [error, setError] = useState<Error | null>(null)
|
|
164
|
+
|
|
165
|
+
useEffect(() => {
|
|
166
|
+
let cancelled = false
|
|
167
|
+
// Reset state when template changes so the loading spinner re-appears.
|
|
168
|
+
setFactory(null)
|
|
169
|
+
setError(null)
|
|
170
|
+
|
|
171
|
+
compileOverlay(template)
|
|
172
|
+
.then((f) => {
|
|
173
|
+
if (!cancelled) setFactory(() => f)
|
|
174
|
+
})
|
|
175
|
+
.catch((e) => {
|
|
176
|
+
if (!cancelled) setError(e instanceof Error ? e : new Error(String(e)))
|
|
177
|
+
})
|
|
178
|
+
|
|
179
|
+
return () => {
|
|
180
|
+
cancelled = true
|
|
181
|
+
}
|
|
182
|
+
}, [template])
|
|
183
|
+
|
|
184
|
+
const loadingNode = loading ?? <DefaultSpinner />
|
|
185
|
+
const errorNode = errorState ?? <DefaultErrorState />
|
|
186
|
+
|
|
187
|
+
if (error) return <>{errorNode}</>
|
|
188
|
+
if (!factory) return <>{loadingNode}</>
|
|
189
|
+
|
|
190
|
+
try {
|
|
191
|
+
const out = factory(frame, fps, duration, props)
|
|
192
|
+
return out ?? <>{errorNode}</>
|
|
193
|
+
} catch {
|
|
194
|
+
return <>{errorNode}</>
|
|
195
|
+
}
|
|
196
|
+
}
|