@devbycrux/editor 0.1.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (54) hide show
  1. package/README.md +165 -0
  2. package/package.json +46 -0
  3. package/src/__tests__/adapter-contract.test.ts +123 -0
  4. package/src/__tests__/adapter.test.ts +185 -0
  5. package/src/__tests__/schema.test.ts +104 -0
  6. package/src/carousel/AddElementMenu.tsx +211 -0
  7. package/src/carousel/CarouselEditor.tsx +529 -0
  8. package/src/carousel/CarouselRenderModal.tsx +243 -0
  9. package/src/carousel/OverlayErrorBoundary.tsx +99 -0
  10. package/src/carousel/OverlayPicker.tsx +145 -0
  11. package/src/carousel/SlideCanvas.tsx +588 -0
  12. package/src/carousel/SlidePropertyPanel.tsx +349 -0
  13. package/src/carousel/__tests__/CarouselEditor.test.tsx +235 -0
  14. package/src/crop/CanvasCropOverlay.tsx +193 -0
  15. package/src/crop/__tests__/crop-math.test.ts +174 -0
  16. package/src/crop/crop-math.ts +125 -0
  17. package/src/gestures/helpers/__tests__/element-transform.test.ts +30 -0
  18. package/src/gestures/helpers/drag.ts +24 -0
  19. package/src/gestures/helpers/element-transform.ts +15 -0
  20. package/src/gestures/helpers/resize.ts +60 -0
  21. package/src/gestures/helpers/rotate.ts +44 -0
  22. package/src/gestures/helpers/snap.ts +64 -0
  23. package/src/gestures/hooks/useOverlayDrag.ts +106 -0
  24. package/src/gestures/hooks/useOverlayResize.ts +67 -0
  25. package/src/gestures/hooks/useOverlayRotate.ts +64 -0
  26. package/src/gestures/index.ts +16 -0
  27. package/src/index.ts +112 -0
  28. package/src/overlays/contract.ts +41 -0
  29. package/src/preview/OverlayPreview.tsx +196 -0
  30. package/src/preview/__tests__/OverlayPreview.test.tsx +169 -0
  31. package/src/schema.ts +194 -0
  32. package/src/state/__tests__/project-reducer.test.ts +957 -0
  33. package/src/state/__tests__/use-project-state.test.tsx +258 -0
  34. package/src/state/mutation-queue.ts +62 -0
  35. package/src/state/project-reducer.ts +328 -0
  36. package/src/state/use-project-state.ts +442 -0
  37. package/src/test-setup.ts +1 -0
  38. package/src/text/FontPicker.tsx +218 -0
  39. package/src/text/InlineTextEditor.tsx +92 -0
  40. package/src/text/TextFormattingToolbar.tsx +248 -0
  41. package/src/text/__tests__/InlineTextEditor.test.tsx +139 -0
  42. package/src/text/__tests__/TextFormattingToolbar.test.tsx +416 -0
  43. package/src/theme.ts +93 -0
  44. package/src/types.ts +325 -0
  45. package/src/ui/__tests__/button.test.tsx +17 -0
  46. package/src/ui/badge.tsx +32 -0
  47. package/src/ui/button.tsx +32 -0
  48. package/src/ui/index.ts +16 -0
  49. package/src/ui/input.tsx +15 -0
  50. package/src/ui/label.tsx +10 -0
  51. package/src/ui/select.tsx +23 -0
  52. package/src/ui/switch.tsx +31 -0
  53. package/src/ui/textarea.tsx +15 -0
  54. package/src/ui/utils.ts +7 -0
@@ -0,0 +1,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
+ }