@elah/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 +169 -0
- package/dist/editor/AssetPanel/AssetPanel.d.ts +6 -0
- package/dist/editor/AssetPanel/AssetPanel.js +272 -0
- package/dist/editor/AssetPanel/index.d.ts +2 -0
- package/dist/editor/AssetPanel/index.js +1 -0
- package/dist/editor/EditorProvider.d.ts +14 -0
- package/dist/editor/EditorProvider.js +80 -0
- package/dist/editor/ElementsPanel/ElementsPanel.d.ts +6 -0
- package/dist/editor/ElementsPanel/ElementsPanel.js +51 -0
- package/dist/editor/ElementsPanel/index.d.ts +2 -0
- package/dist/editor/ElementsPanel/index.js +1 -0
- package/dist/editor/Preview/MediaTransformOverlay.d.ts +1 -0
- package/dist/editor/Preview/MediaTransformOverlay.js +180 -0
- package/dist/editor/Preview/Preview.d.ts +18 -0
- package/dist/editor/Preview/Preview.js +72 -0
- package/dist/editor/Preview/StageBorder.d.ts +1 -0
- package/dist/editor/Preview/StageBorder.js +29 -0
- package/dist/editor/Preview/TextOverlay.d.ts +1 -0
- package/dist/editor/Preview/TextOverlay.js +246 -0
- package/dist/editor/Preview/TransitionOverlay.d.ts +6 -0
- package/dist/editor/Preview/TransitionOverlay.js +67 -0
- package/dist/editor/Preview/index.d.ts +2 -0
- package/dist/editor/Preview/index.js +1 -0
- package/dist/editor/index.d.ts +8 -0
- package/dist/editor/index.js +4 -0
- package/dist/editor/useResolvedScene.d.ts +2 -0
- package/dist/editor/useResolvedScene.js +25 -0
- package/dist/index.d.ts +54 -0
- package/dist/index.js +34 -0
- package/package.json +40 -0
|
@@ -0,0 +1,180 @@
|
|
|
1
|
+
import { jsx as _jsx, jsxs as _jsxs } from "react/jsx-runtime";
|
|
2
|
+
import { useCallback, useLayoutEffect, useMemo, useRef, useState, } from 'react';
|
|
3
|
+
import { useTimelineEngine, useSelectionStore, usePlaybackStore, useMediaLibraryStore, resolveDrawRect, transformFromContainRect, computeContainViewport, } from '@elah/core';
|
|
4
|
+
import { useResolvedScene } from '../useResolvedScene';
|
|
5
|
+
const MIN_BOX_PX = 28;
|
|
6
|
+
const BOX_PAD = 0;
|
|
7
|
+
const MIN_RENDER_PX = 16;
|
|
8
|
+
const MAX_RENDER_STAGE_MULTIPLE = 8;
|
|
9
|
+
const clamp = (v, lo, hi) => Math.min(hi, Math.max(lo, v));
|
|
10
|
+
const clamp01 = (v) => clamp(v, 0, 1);
|
|
11
|
+
export function MediaTransformOverlay() {
|
|
12
|
+
const engine = useTimelineEngine();
|
|
13
|
+
const scene = useResolvedScene();
|
|
14
|
+
const isPlaying = usePlaybackStore((s) => s.isPlaying);
|
|
15
|
+
const selectedClipIds = useSelectionStore((s) => s.selectedClipIds);
|
|
16
|
+
const selectClip = useSelectionStore((s) => s.selectClip);
|
|
17
|
+
const clearSelection = useSelectionStore((s) => s.clearSelection);
|
|
18
|
+
const assets = useMediaLibraryStore((s) => s.assets);
|
|
19
|
+
const rootRef = useRef(null);
|
|
20
|
+
const [size, setSize] = useState({ width: 0, height: 0 });
|
|
21
|
+
const gestureRef = useRef(null);
|
|
22
|
+
useLayoutEffect(() => {
|
|
23
|
+
const el = rootRef.current;
|
|
24
|
+
if (!el)
|
|
25
|
+
return;
|
|
26
|
+
const apply = () => setSize({ width: el.clientWidth, height: el.clientHeight });
|
|
27
|
+
apply();
|
|
28
|
+
const obs = new ResizeObserver(apply);
|
|
29
|
+
obs.observe(el);
|
|
30
|
+
return () => obs.disconnect();
|
|
31
|
+
}, []);
|
|
32
|
+
const stage = scene.stage;
|
|
33
|
+
const fit = useMemo(() => computeContainViewport(size.width, size.height, stage.width, stage.height), [size.width, size.height, stage.width, stage.height]);
|
|
34
|
+
const scale = stage.width > 0 ? fit.width / stage.width : 1;
|
|
35
|
+
const items = useMemo(() => {
|
|
36
|
+
if (fit.width <= 0)
|
|
37
|
+
return [];
|
|
38
|
+
const clips = [...scene.videos, ...scene.images];
|
|
39
|
+
return clips.map((clip) => {
|
|
40
|
+
const assetId = engine.findClip(clip.id)?.clip.assetId;
|
|
41
|
+
const asset = assetId ? assets[assetId] : undefined;
|
|
42
|
+
const contentW = asset?.width ?? stage.width;
|
|
43
|
+
const contentH = asset?.height ?? stage.height;
|
|
44
|
+
const r = resolveDrawRect(clip.transform, stage.width, stage.height, contentW, contentH);
|
|
45
|
+
const centerScreenX = fit.x + (r.x + r.width / 2) * scale;
|
|
46
|
+
const centerScreenY = fit.y + (r.y + r.height / 2) * scale;
|
|
47
|
+
const rawW = r.width * scale;
|
|
48
|
+
const rawH = r.height * scale;
|
|
49
|
+
const w = Math.max(rawW, MIN_BOX_PX);
|
|
50
|
+
const h = Math.max(rawH, MIN_BOX_PX);
|
|
51
|
+
const left = rawW < MIN_BOX_PX ? centerScreenX - w / 2 : fit.x + r.x * scale;
|
|
52
|
+
const top = rawH < MIN_BOX_PX ? centerScreenY - h / 2 : fit.y + r.y * scale;
|
|
53
|
+
return {
|
|
54
|
+
clip,
|
|
55
|
+
contentW,
|
|
56
|
+
contentH,
|
|
57
|
+
rect: { left, top, width: w, height: h },
|
|
58
|
+
centerScreenX,
|
|
59
|
+
centerScreenY,
|
|
60
|
+
};
|
|
61
|
+
});
|
|
62
|
+
}, [scene.videos, scene.images, assets, engine, stage.width, stage.height, fit, scale]);
|
|
63
|
+
const ownsSelection = useMemo(() => items.some((it) => selectedClipIds.has(it.clip.id)), [items, selectedClipIds]);
|
|
64
|
+
const handlePointerMove = useCallback((e) => {
|
|
65
|
+
const g = gestureRef.current;
|
|
66
|
+
if (!g)
|
|
67
|
+
return;
|
|
68
|
+
if (g.type === 'move') {
|
|
69
|
+
const dx = (e.clientX - g.startClientX) / scale;
|
|
70
|
+
const dy = (e.clientY - g.startClientY) / scale;
|
|
71
|
+
const x = clamp01(g.base.x + dx / g.stageW);
|
|
72
|
+
const y = clamp01(g.base.y + dy / g.stageH);
|
|
73
|
+
engine.previewClip(g.id, g.trackId, { transform: { ...g.base, x, y } });
|
|
74
|
+
}
|
|
75
|
+
else {
|
|
76
|
+
const dist = Math.hypot(e.clientX - g.centerClientX, e.clientY - g.centerClientY);
|
|
77
|
+
const ratio = g.startDist > 0 ? dist / g.startDist : 1;
|
|
78
|
+
const minScale = MIN_RENDER_PX / Math.max(1, Math.min(g.contentW, g.contentH));
|
|
79
|
+
const maxScale = (MAX_RENDER_STAGE_MULTIPLE * Math.max(g.stageW, g.stageH)) /
|
|
80
|
+
Math.max(1, Math.max(g.contentW, g.contentH));
|
|
81
|
+
const next = clamp(g.base.scale * ratio, minScale, maxScale);
|
|
82
|
+
engine.previewClip(g.id, g.trackId, { transform: { ...g.base, scale: next } });
|
|
83
|
+
}
|
|
84
|
+
}, [engine, scale]);
|
|
85
|
+
const endGesture = useCallback((e) => {
|
|
86
|
+
const g = gestureRef.current;
|
|
87
|
+
if (!g)
|
|
88
|
+
return;
|
|
89
|
+
gestureRef.current = null;
|
|
90
|
+
const target = e.currentTarget;
|
|
91
|
+
if (target.hasPointerCapture?.(e.pointerId)) {
|
|
92
|
+
target.releasePointerCapture(e.pointerId);
|
|
93
|
+
}
|
|
94
|
+
engine.commitInteraction(g.type === 'move' ? 'Move clip' : 'Resize clip');
|
|
95
|
+
}, [engine]);
|
|
96
|
+
const baseTransformFor = useCallback((item) => item.clip.transform ??
|
|
97
|
+
transformFromContainRect(item.contentW, item.contentH, stage.width, stage.height), [stage.width, stage.height]);
|
|
98
|
+
const beginMove = useCallback((e, item) => {
|
|
99
|
+
e.stopPropagation();
|
|
100
|
+
if (!selectedClipIds.has(item.clip.id))
|
|
101
|
+
selectClip(item.clip.id);
|
|
102
|
+
e.currentTarget.setPointerCapture(e.pointerId);
|
|
103
|
+
gestureRef.current = {
|
|
104
|
+
type: 'move',
|
|
105
|
+
id: item.clip.id,
|
|
106
|
+
trackId: item.clip.trackId,
|
|
107
|
+
base: baseTransformFor(item),
|
|
108
|
+
contentW: item.contentW,
|
|
109
|
+
contentH: item.contentH,
|
|
110
|
+
stageW: stage.width,
|
|
111
|
+
stageH: stage.height,
|
|
112
|
+
startClientX: e.clientX,
|
|
113
|
+
startClientY: e.clientY,
|
|
114
|
+
centerClientX: 0,
|
|
115
|
+
centerClientY: 0,
|
|
116
|
+
startDist: 0,
|
|
117
|
+
};
|
|
118
|
+
}, [selectedClipIds, selectClip, baseTransformFor, stage.width, stage.height]);
|
|
119
|
+
const beginResize = useCallback((e, item) => {
|
|
120
|
+
e.stopPropagation();
|
|
121
|
+
if (!selectedClipIds.has(item.clip.id))
|
|
122
|
+
selectClip(item.clip.id);
|
|
123
|
+
e.currentTarget.setPointerCapture(e.pointerId);
|
|
124
|
+
const rootRect = rootRef.current?.getBoundingClientRect();
|
|
125
|
+
const centerClientX = (rootRect?.left ?? 0) + item.centerScreenX;
|
|
126
|
+
const centerClientY = (rootRect?.top ?? 0) + item.centerScreenY;
|
|
127
|
+
gestureRef.current = {
|
|
128
|
+
type: 'resize',
|
|
129
|
+
id: item.clip.id,
|
|
130
|
+
trackId: item.clip.trackId,
|
|
131
|
+
base: baseTransformFor(item),
|
|
132
|
+
contentW: item.contentW,
|
|
133
|
+
contentH: item.contentH,
|
|
134
|
+
stageW: stage.width,
|
|
135
|
+
stageH: stage.height,
|
|
136
|
+
startClientX: e.clientX,
|
|
137
|
+
startClientY: e.clientY,
|
|
138
|
+
centerClientX,
|
|
139
|
+
centerClientY,
|
|
140
|
+
startDist: Math.hypot(e.clientX - centerClientX, e.clientY - centerClientY),
|
|
141
|
+
};
|
|
142
|
+
}, [selectedClipIds, selectClip, baseTransformFor, stage.width, stage.height]);
|
|
143
|
+
return (_jsxs("div", { ref: rootRef, style: { position: 'absolute', inset: 0, zIndex: 2, pointerEvents: 'none', overflow: 'hidden' }, children: [!isPlaying && ownsSelection && (_jsx("div", { style: { position: 'absolute', inset: 0, pointerEvents: 'auto' }, onPointerDown: () => clearSelection() })), !isPlaying &&
|
|
144
|
+
items.map((item) => {
|
|
145
|
+
const { clip, rect } = item;
|
|
146
|
+
const selected = selectedClipIds.has(clip.id);
|
|
147
|
+
const boxStyle = {
|
|
148
|
+
position: 'absolute',
|
|
149
|
+
left: rect.left - BOX_PAD,
|
|
150
|
+
top: rect.top - BOX_PAD,
|
|
151
|
+
width: rect.width + BOX_PAD * 2,
|
|
152
|
+
height: rect.height + BOX_PAD * 2,
|
|
153
|
+
boxSizing: 'border-box',
|
|
154
|
+
border: selected ? '1px solid #4c9aff' : '1px solid transparent',
|
|
155
|
+
borderRadius: 2,
|
|
156
|
+
cursor: 'move',
|
|
157
|
+
pointerEvents: 'auto',
|
|
158
|
+
touchAction: 'none',
|
|
159
|
+
};
|
|
160
|
+
return (_jsx("div", { style: boxStyle, onPointerDown: (e) => beginMove(e, item), onPointerMove: handlePointerMove, onPointerUp: endGesture, onPointerCancel: endGesture, children: selected &&
|
|
161
|
+
CORNERS.map((corner) => (_jsx("div", { onPointerDown: (e) => beginResize(e, item), onPointerMove: handlePointerMove, onPointerUp: endGesture, onPointerCancel: endGesture, style: {
|
|
162
|
+
position: 'absolute',
|
|
163
|
+
...corner.pos,
|
|
164
|
+
width: 10,
|
|
165
|
+
height: 10,
|
|
166
|
+
background: '#fff',
|
|
167
|
+
border: '1px solid #4c9aff',
|
|
168
|
+
borderRadius: 2,
|
|
169
|
+
cursor: corner.cursor,
|
|
170
|
+
pointerEvents: 'auto',
|
|
171
|
+
touchAction: 'none',
|
|
172
|
+
} }, corner.key))) }, clip.id));
|
|
173
|
+
})] }));
|
|
174
|
+
}
|
|
175
|
+
const CORNERS = [
|
|
176
|
+
{ key: 'nw', pos: { left: -5, top: -5 }, cursor: 'nwse-resize' },
|
|
177
|
+
{ key: 'ne', pos: { right: -5, top: -5 }, cursor: 'nesw-resize' },
|
|
178
|
+
{ key: 'sw', pos: { left: -5, bottom: -5 }, cursor: 'nesw-resize' },
|
|
179
|
+
{ key: 'se', pos: { right: -5, bottom: -5 }, cursor: 'nwse-resize' },
|
|
180
|
+
];
|
|
@@ -0,0 +1,18 @@
|
|
|
1
|
+
import { type CSSProperties } from 'react';
|
|
2
|
+
import { GpuRenderer } from '@elah/core';
|
|
3
|
+
import type { DemuxerFactory } from '@elah/core';
|
|
4
|
+
export interface PreviewHandle {
|
|
5
|
+
getCanvas(): HTMLCanvasElement | null;
|
|
6
|
+
getRenderer(): GpuRenderer | null;
|
|
7
|
+
}
|
|
8
|
+
export interface PreviewProps {
|
|
9
|
+
demuxerFactory?: DemuxerFactory;
|
|
10
|
+
debug?: boolean;
|
|
11
|
+
probeLayer?: boolean;
|
|
12
|
+
clearColor?: [number, number, number, number];
|
|
13
|
+
preserveDrawingBuffer?: boolean;
|
|
14
|
+
enableAudio?: boolean;
|
|
15
|
+
style?: CSSProperties;
|
|
16
|
+
className?: string;
|
|
17
|
+
}
|
|
18
|
+
export declare const Preview: import("react").ForwardRefExoticComponent<PreviewProps & import("react").RefAttributes<PreviewHandle>>;
|
|
@@ -0,0 +1,72 @@
|
|
|
1
|
+
import { jsx as _jsx, jsxs as _jsxs } from "react/jsx-runtime";
|
|
2
|
+
import { forwardRef, useEffect, useImperativeHandle, useRef, } from 'react';
|
|
3
|
+
import { GpuRenderer } from '@elah/core';
|
|
4
|
+
import { resolveTimeline } from '@elah/core';
|
|
5
|
+
import { useTimelineEngine, usePlaybackEngine } from '@elah/core';
|
|
6
|
+
import { AudioPlaybackController } from '@elah/core';
|
|
7
|
+
import { TextOverlay } from './TextOverlay';
|
|
8
|
+
import { MediaTransformOverlay } from './MediaTransformOverlay';
|
|
9
|
+
import { TransitionOverlay } from './TransitionOverlay';
|
|
10
|
+
import { StageBorder } from './StageBorder';
|
|
11
|
+
export const Preview = forwardRef(function Preview({ demuxerFactory, debug = false, probeLayer = false, clearColor, preserveDrawingBuffer, enableAudio = true, style, className, }, ref) {
|
|
12
|
+
const containerRef = useRef(null);
|
|
13
|
+
const rendererRef = useRef(null);
|
|
14
|
+
const transitionOverlayRef = useRef(null);
|
|
15
|
+
const engine = useTimelineEngine();
|
|
16
|
+
const playback = usePlaybackEngine();
|
|
17
|
+
useImperativeHandle(ref, () => ({
|
|
18
|
+
getCanvas: () => rendererRef.current?.getCanvas() ?? null,
|
|
19
|
+
getRenderer: () => rendererRef.current,
|
|
20
|
+
}), []);
|
|
21
|
+
useEffect(() => {
|
|
22
|
+
const container = containerRef.current;
|
|
23
|
+
if (!container)
|
|
24
|
+
return;
|
|
25
|
+
const renderer = new GpuRenderer({
|
|
26
|
+
probeLayer,
|
|
27
|
+
demuxerFactory,
|
|
28
|
+
preserveDrawingBuffer,
|
|
29
|
+
...(clearColor ? { clearColor } : {}),
|
|
30
|
+
});
|
|
31
|
+
renderer.mount(container);
|
|
32
|
+
renderer.setDebug(debug);
|
|
33
|
+
rendererRef.current = renderer;
|
|
34
|
+
const resize = () => {
|
|
35
|
+
const dpr = window.devicePixelRatio ?? 1;
|
|
36
|
+
renderer.resize(container.clientWidth, container.clientHeight, dpr);
|
|
37
|
+
};
|
|
38
|
+
const observer = new ResizeObserver(resize);
|
|
39
|
+
observer.observe(container);
|
|
40
|
+
resize();
|
|
41
|
+
const audio = enableAudio
|
|
42
|
+
? new AudioPlaybackController(playback, () => engine.getProject())
|
|
43
|
+
: null;
|
|
44
|
+
audio?.start();
|
|
45
|
+
let rafId = 0;
|
|
46
|
+
const tick = () => {
|
|
47
|
+
const frame = Math.floor(playback.getFrameAt());
|
|
48
|
+
const scene = resolveTimeline(frame, engine.getProject());
|
|
49
|
+
const canvas = renderer.getCanvas();
|
|
50
|
+
if (canvas)
|
|
51
|
+
transitionOverlayRef.current?.captureIfNewTransition(scene, canvas);
|
|
52
|
+
renderer.render(scene);
|
|
53
|
+
transitionOverlayRef.current?.update(scene);
|
|
54
|
+
rafId = requestAnimationFrame(tick);
|
|
55
|
+
};
|
|
56
|
+
rafId = requestAnimationFrame(tick);
|
|
57
|
+
return () => {
|
|
58
|
+
cancelAnimationFrame(rafId);
|
|
59
|
+
observer.disconnect();
|
|
60
|
+
audio?.destroy();
|
|
61
|
+
renderer.dispose();
|
|
62
|
+
rendererRef.current = null;
|
|
63
|
+
};
|
|
64
|
+
}, [engine, playback, demuxerFactory, debug, probeLayer, preserveDrawingBuffer, enableAudio]);
|
|
65
|
+
return (_jsxs("div", { ref: containerRef, className: className, style: {
|
|
66
|
+
position: 'relative',
|
|
67
|
+
width: '100%',
|
|
68
|
+
height: '100%',
|
|
69
|
+
background: '#06070A',
|
|
70
|
+
...style,
|
|
71
|
+
}, children: [_jsx(StageBorder, {}), _jsx(TransitionOverlay, { ref: transitionOverlayRef }), _jsx(MediaTransformOverlay, {}), _jsx(TextOverlay, {})] }));
|
|
72
|
+
});
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
export declare function StageBorder(): import("react/jsx-runtime").JSX.Element;
|
|
@@ -0,0 +1,29 @@
|
|
|
1
|
+
import { jsx as _jsx } from "react/jsx-runtime";
|
|
2
|
+
import { useLayoutEffect, useMemo, useRef, useState } from 'react';
|
|
3
|
+
import { computeContainViewport, useTracksStore } from '@elah/core';
|
|
4
|
+
export function StageBorder() {
|
|
5
|
+
const rootRef = useRef(null);
|
|
6
|
+
const [size, setSize] = useState({ width: 0, height: 0 });
|
|
7
|
+
const stage = useTracksStore((s) => s.stage);
|
|
8
|
+
useLayoutEffect(() => {
|
|
9
|
+
const el = rootRef.current;
|
|
10
|
+
if (!el)
|
|
11
|
+
return;
|
|
12
|
+
const apply = () => setSize({ width: el.clientWidth, height: el.clientHeight });
|
|
13
|
+
apply();
|
|
14
|
+
const obs = new ResizeObserver(apply);
|
|
15
|
+
obs.observe(el);
|
|
16
|
+
return () => obs.disconnect();
|
|
17
|
+
}, []);
|
|
18
|
+
const fit = useMemo(() => computeContainViewport(size.width, size.height, stage.width, stage.height), [size.width, size.height, stage.width, stage.height]);
|
|
19
|
+
return (_jsx("div", { ref: rootRef, style: { position: 'absolute', inset: 0, zIndex: 1, pointerEvents: 'none', overflow: 'hidden' }, children: fit.width > 0 && fit.height > 0 && (_jsx("div", { style: {
|
|
20
|
+
position: 'absolute',
|
|
21
|
+
left: fit.x,
|
|
22
|
+
top: fit.y,
|
|
23
|
+
width: fit.width,
|
|
24
|
+
height: fit.height,
|
|
25
|
+
border: '1px solid rgba(225, 29, 72, 0.45)',
|
|
26
|
+
boxShadow: '0 0 20px rgba(225, 29, 72, 0.08)',
|
|
27
|
+
boxSizing: 'border-box',
|
|
28
|
+
} })) }));
|
|
29
|
+
}
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
export declare function TextOverlay(): import("react/jsx-runtime").JSX.Element;
|
|
@@ -0,0 +1,246 @@
|
|
|
1
|
+
import { jsx as _jsx, jsxs as _jsxs } from "react/jsx-runtime";
|
|
2
|
+
import { useCallback, useEffect, useLayoutEffect, useMemo, useRef, useState, } from 'react';
|
|
3
|
+
import { computeTextLayout, computeContainViewport, useTimelineEngine, useSelectionStore, usePlaybackStore, SIDE_MARGIN, } from '@elah/core';
|
|
4
|
+
import { useResolvedScene } from '../useResolvedScene';
|
|
5
|
+
const MIN_FONT_SIZE = 6;
|
|
6
|
+
const MAX_FONT_SIZE = 4000;
|
|
7
|
+
const MIN_BOX_PX = 28;
|
|
8
|
+
const BOX_PAD = 4;
|
|
9
|
+
const clamp = (v, lo, hi) => Math.min(hi, Math.max(lo, v));
|
|
10
|
+
const clamp01 = (v) => clamp(v, 0, 1);
|
|
11
|
+
function makeTransform(x, y, base) {
|
|
12
|
+
return {
|
|
13
|
+
x,
|
|
14
|
+
y,
|
|
15
|
+
scale: base?.scale ?? 1,
|
|
16
|
+
rotation: base?.rotation ?? 0,
|
|
17
|
+
anchor: base?.anchor ?? { x: 0.5, y: 0.5 },
|
|
18
|
+
};
|
|
19
|
+
}
|
|
20
|
+
export function TextOverlay() {
|
|
21
|
+
const engine = useTimelineEngine();
|
|
22
|
+
const scene = useResolvedScene();
|
|
23
|
+
const isPlaying = usePlaybackStore((s) => s.isPlaying);
|
|
24
|
+
const selectedClipIds = useSelectionStore((s) => s.selectedClipIds);
|
|
25
|
+
const selectClip = useSelectionStore((s) => s.selectClip);
|
|
26
|
+
const clearSelection = useSelectionStore((s) => s.clearSelection);
|
|
27
|
+
const rootRef = useRef(null);
|
|
28
|
+
const [size, setSize] = useState({ width: 0, height: 0 });
|
|
29
|
+
const measureRef = useRef(null);
|
|
30
|
+
const getMeasurer = useCallback(() => {
|
|
31
|
+
if (measureRef.current)
|
|
32
|
+
return measureRef.current;
|
|
33
|
+
if (typeof document === 'undefined')
|
|
34
|
+
return null;
|
|
35
|
+
const ctx = document.createElement('canvas').getContext('2d');
|
|
36
|
+
measureRef.current = ctx;
|
|
37
|
+
return ctx;
|
|
38
|
+
}, []);
|
|
39
|
+
const gestureRef = useRef(null);
|
|
40
|
+
const [editingId, setEditingId] = useState(null);
|
|
41
|
+
const [editText, setEditText] = useState('');
|
|
42
|
+
useLayoutEffect(() => {
|
|
43
|
+
const el = rootRef.current;
|
|
44
|
+
if (!el)
|
|
45
|
+
return;
|
|
46
|
+
const apply = () => setSize({ width: el.clientWidth, height: el.clientHeight });
|
|
47
|
+
apply();
|
|
48
|
+
const obs = new ResizeObserver(apply);
|
|
49
|
+
obs.observe(el);
|
|
50
|
+
return () => obs.disconnect();
|
|
51
|
+
}, []);
|
|
52
|
+
const stage = scene.stage;
|
|
53
|
+
const fit = useMemo(() => computeContainViewport(size.width, size.height, stage.width, stage.height), [size.width, size.height, stage.width, stage.height]);
|
|
54
|
+
const scale = stage.width > 0 ? fit.width / stage.width : 1;
|
|
55
|
+
const items = useMemo(() => {
|
|
56
|
+
const measurer = getMeasurer();
|
|
57
|
+
if (!measurer || fit.width <= 0)
|
|
58
|
+
return [];
|
|
59
|
+
return scene.texts.map((clip) => {
|
|
60
|
+
const layout = computeTextLayout(measurer, clip, stage);
|
|
61
|
+
const centerScreenX = fit.x + layout.center.x * stage.width * scale;
|
|
62
|
+
const rawLeft = fit.x + layout.box.x * scale;
|
|
63
|
+
const rawW = layout.box.width * scale;
|
|
64
|
+
const w = Math.max(rawW, MIN_BOX_PX);
|
|
65
|
+
const left = rawW < MIN_BOX_PX ? centerScreenX - w / 2 : rawLeft;
|
|
66
|
+
const h = Math.max(layout.box.height * scale, MIN_BOX_PX);
|
|
67
|
+
const top = fit.y + layout.box.y * scale;
|
|
68
|
+
return { clip, layout, centerScreenX, rect: { left, top, width: w, height: h } };
|
|
69
|
+
});
|
|
70
|
+
}, [scene.texts, stage, fit, scale, getMeasurer]);
|
|
71
|
+
const ownsSelection = useMemo(() => items.some((it) => selectedClipIds.has(it.clip.id)), [items, selectedClipIds]);
|
|
72
|
+
const handlePointerMove = useCallback((e) => {
|
|
73
|
+
const g = gestureRef.current;
|
|
74
|
+
if (!g)
|
|
75
|
+
return;
|
|
76
|
+
if (g.type === 'move') {
|
|
77
|
+
const dx = (e.clientX - g.startClientX) / scale;
|
|
78
|
+
const dy = (e.clientY - g.startClientY) / scale;
|
|
79
|
+
const nx = clamp01(g.startCenter.x + dx / stage.width);
|
|
80
|
+
const ny = clamp01(g.startCenter.y + dy / stage.height);
|
|
81
|
+
engine.previewClip(g.id, g.trackId, {
|
|
82
|
+
transform: makeTransform(nx, ny, g.startTransform),
|
|
83
|
+
});
|
|
84
|
+
}
|
|
85
|
+
else {
|
|
86
|
+
const dist = Math.hypot(e.clientX - g.centerClientX, e.clientY - g.centerClientY);
|
|
87
|
+
const ratio = g.startDist > 0 ? dist / g.startDist : 1;
|
|
88
|
+
const fontSize = clamp(Math.round(g.startFontSize * ratio), MIN_FONT_SIZE, MAX_FONT_SIZE);
|
|
89
|
+
engine.previewClip(g.id, g.trackId, { fontSize });
|
|
90
|
+
}
|
|
91
|
+
}, [engine, scale, stage.width, stage.height]);
|
|
92
|
+
const endGesture = useCallback((e) => {
|
|
93
|
+
const g = gestureRef.current;
|
|
94
|
+
if (!g)
|
|
95
|
+
return;
|
|
96
|
+
gestureRef.current = null;
|
|
97
|
+
const target = e.currentTarget;
|
|
98
|
+
if (target.hasPointerCapture?.(e.pointerId)) {
|
|
99
|
+
target.releasePointerCapture(e.pointerId);
|
|
100
|
+
}
|
|
101
|
+
engine.commitInteraction(g.type === 'move' ? 'Move text' : 'Resize text');
|
|
102
|
+
}, [engine]);
|
|
103
|
+
const beginMove = useCallback((e, clip, layout) => {
|
|
104
|
+
if (editingId === clip.id)
|
|
105
|
+
return;
|
|
106
|
+
e.stopPropagation();
|
|
107
|
+
if (!selectedClipIds.has(clip.id))
|
|
108
|
+
selectClip(clip.id);
|
|
109
|
+
e.currentTarget.setPointerCapture(e.pointerId);
|
|
110
|
+
gestureRef.current = {
|
|
111
|
+
type: 'move',
|
|
112
|
+
id: clip.id,
|
|
113
|
+
trackId: clip.trackId,
|
|
114
|
+
startClientX: e.clientX,
|
|
115
|
+
startClientY: e.clientY,
|
|
116
|
+
startCenter: { ...layout.center },
|
|
117
|
+
startTransform: clip.transform,
|
|
118
|
+
centerClientX: 0,
|
|
119
|
+
centerClientY: 0,
|
|
120
|
+
startDist: 0,
|
|
121
|
+
startFontSize: layout.style.fontSize,
|
|
122
|
+
};
|
|
123
|
+
}, [editingId, selectedClipIds, selectClip]);
|
|
124
|
+
const beginResize = useCallback((e, item) => {
|
|
125
|
+
e.stopPropagation();
|
|
126
|
+
const { clip, layout, centerScreenX } = item;
|
|
127
|
+
if (!selectedClipIds.has(clip.id))
|
|
128
|
+
selectClip(clip.id);
|
|
129
|
+
e.currentTarget.setPointerCapture(e.pointerId);
|
|
130
|
+
const rootRect = rootRef.current?.getBoundingClientRect();
|
|
131
|
+
const centerScreenY = fit.y + layout.center.y * stage.height * scale;
|
|
132
|
+
const centerClientX = (rootRect?.left ?? 0) + centerScreenX;
|
|
133
|
+
const centerClientY = (rootRect?.top ?? 0) + centerScreenY;
|
|
134
|
+
gestureRef.current = {
|
|
135
|
+
type: 'resize',
|
|
136
|
+
id: clip.id,
|
|
137
|
+
trackId: clip.trackId,
|
|
138
|
+
startClientX: e.clientX,
|
|
139
|
+
startClientY: e.clientY,
|
|
140
|
+
startCenter: { ...layout.center },
|
|
141
|
+
startTransform: clip.transform,
|
|
142
|
+
centerClientX,
|
|
143
|
+
centerClientY,
|
|
144
|
+
startDist: Math.hypot(e.clientX - centerClientX, e.clientY - centerClientY),
|
|
145
|
+
startFontSize: layout.style.fontSize,
|
|
146
|
+
};
|
|
147
|
+
}, [selectedClipIds, selectClip, fit.y, stage.height, scale]);
|
|
148
|
+
const startEditing = useCallback((clip) => {
|
|
149
|
+
selectClip(clip.id);
|
|
150
|
+
setEditText(clip.content);
|
|
151
|
+
setEditingId(clip.id);
|
|
152
|
+
}, [selectClip]);
|
|
153
|
+
const commitEditing = useCallback(() => {
|
|
154
|
+
engine.commitInteraction('Edit text');
|
|
155
|
+
setEditingId(null);
|
|
156
|
+
}, [engine]);
|
|
157
|
+
const cancelEditing = useCallback(() => {
|
|
158
|
+
engine.cancelInteraction();
|
|
159
|
+
setEditingId(null);
|
|
160
|
+
}, [engine]);
|
|
161
|
+
useEffect(() => {
|
|
162
|
+
if (isPlaying && editingId)
|
|
163
|
+
commitEditing();
|
|
164
|
+
}, [isPlaying]);
|
|
165
|
+
const editingItem = editingId ? items.find((it) => it.clip.id === editingId) : undefined;
|
|
166
|
+
return (_jsxs("div", { ref: rootRef, style: { position: 'absolute', inset: 0, zIndex: 3, pointerEvents: 'none', overflow: 'hidden' }, children: [!isPlaying && (ownsSelection || editingId) && (_jsx("div", { style: { position: 'absolute', inset: 0, pointerEvents: 'auto' }, onPointerDown: () => {
|
|
167
|
+
if (editingId)
|
|
168
|
+
commitEditing();
|
|
169
|
+
clearSelection();
|
|
170
|
+
} })), !isPlaying &&
|
|
171
|
+
items.map((item) => {
|
|
172
|
+
const { clip, layout, rect } = item;
|
|
173
|
+
const selected = selectedClipIds.has(clip.id);
|
|
174
|
+
const isEditing = editingId === clip.id;
|
|
175
|
+
const boxStyle = {
|
|
176
|
+
position: 'absolute',
|
|
177
|
+
left: rect.left - BOX_PAD,
|
|
178
|
+
top: rect.top - BOX_PAD,
|
|
179
|
+
width: rect.width + BOX_PAD * 2,
|
|
180
|
+
height: rect.height + BOX_PAD * 2,
|
|
181
|
+
boxSizing: 'border-box',
|
|
182
|
+
border: selected ? '1px solid #4c9aff' : '1px solid transparent',
|
|
183
|
+
borderRadius: 2,
|
|
184
|
+
cursor: isEditing ? 'text' : 'move',
|
|
185
|
+
pointerEvents: isEditing ? 'none' : 'auto',
|
|
186
|
+
touchAction: 'none',
|
|
187
|
+
};
|
|
188
|
+
return (_jsx("div", { style: boxStyle, onPointerDown: (e) => beginMove(e, clip, layout), onPointerMove: handlePointerMove, onPointerUp: endGesture, onPointerCancel: endGesture, onDoubleClick: (e) => {
|
|
189
|
+
e.stopPropagation();
|
|
190
|
+
startEditing(clip);
|
|
191
|
+
}, children: selected && !isEditing && CORNERS.map((corner) => (_jsx("div", { onPointerDown: (e) => beginResize(e, item), onPointerMove: handlePointerMove, onPointerUp: endGesture, onPointerCancel: endGesture, style: {
|
|
192
|
+
position: 'absolute',
|
|
193
|
+
...corner.pos,
|
|
194
|
+
width: 10,
|
|
195
|
+
height: 10,
|
|
196
|
+
background: '#fff',
|
|
197
|
+
border: '1px solid #4c9aff',
|
|
198
|
+
borderRadius: 2,
|
|
199
|
+
cursor: corner.cursor,
|
|
200
|
+
pointerEvents: 'auto',
|
|
201
|
+
touchAction: 'none',
|
|
202
|
+
} }, corner.key))) }, clip.id));
|
|
203
|
+
}), !isPlaying && editingItem && (_jsx("textarea", { autoFocus: true, value: editText, onChange: (e) => {
|
|
204
|
+
const next = e.target.value;
|
|
205
|
+
setEditText(next);
|
|
206
|
+
const found = engine.findClip(editingItem.clip.id);
|
|
207
|
+
if (found)
|
|
208
|
+
engine.previewClip(editingItem.clip.id, found.trackId, { content: next });
|
|
209
|
+
}, onPointerDown: (e) => e.stopPropagation(), onBlur: commitEditing, onKeyDown: (e) => {
|
|
210
|
+
if (e.key === 'Enter' && !e.shiftKey) {
|
|
211
|
+
e.preventDefault();
|
|
212
|
+
commitEditing();
|
|
213
|
+
}
|
|
214
|
+
else if (e.key === 'Escape') {
|
|
215
|
+
e.preventDefault();
|
|
216
|
+
cancelEditing();
|
|
217
|
+
}
|
|
218
|
+
}, onFocus: (e) => e.currentTarget.select(), style: {
|
|
219
|
+
position: 'absolute',
|
|
220
|
+
left: editingItem.centerScreenX - (stage.width * (1 - 2 * SIDE_MARGIN) * scale) / 2,
|
|
221
|
+
top: fit.y + editingItem.layout.box.y * scale,
|
|
222
|
+
width: stage.width * (1 - 2 * SIDE_MARGIN) * scale,
|
|
223
|
+
height: editingItem.layout.box.height * scale,
|
|
224
|
+
color: 'transparent',
|
|
225
|
+
caretColor: editingItem.layout.style.color,
|
|
226
|
+
background: 'transparent',
|
|
227
|
+
font: `${editingItem.layout.style.fontWeight} ${editingItem.layout.style.fontSize * scale}px ${editingItem.layout.style.fontFamily}`,
|
|
228
|
+
lineHeight: `${editingItem.layout.lineAdvance * scale}px`,
|
|
229
|
+
textAlign: editingItem.layout.style.textAlign,
|
|
230
|
+
border: '1px solid #4c9aff',
|
|
231
|
+
outline: 'none',
|
|
232
|
+
resize: 'none',
|
|
233
|
+
padding: 0,
|
|
234
|
+
margin: 0,
|
|
235
|
+
overflow: 'hidden',
|
|
236
|
+
whiteSpace: 'pre-wrap',
|
|
237
|
+
pointerEvents: 'auto',
|
|
238
|
+
boxSizing: 'border-box',
|
|
239
|
+
} }))] }));
|
|
240
|
+
}
|
|
241
|
+
const CORNERS = [
|
|
242
|
+
{ key: 'nw', pos: { left: -5, top: -5 }, cursor: 'nwse-resize' },
|
|
243
|
+
{ key: 'ne', pos: { right: -5, top: -5 }, cursor: 'nesw-resize' },
|
|
244
|
+
{ key: 'sw', pos: { left: -5, bottom: -5 }, cursor: 'nesw-resize' },
|
|
245
|
+
{ key: 'se', pos: { right: -5, bottom: -5 }, cursor: 'nwse-resize' },
|
|
246
|
+
];
|
|
@@ -0,0 +1,6 @@
|
|
|
1
|
+
import type { Scene } from '@elah/core';
|
|
2
|
+
export interface TransitionOverlayHandle {
|
|
3
|
+
captureIfNewTransition(scene: Scene, webglCanvas: HTMLCanvasElement): void;
|
|
4
|
+
update(scene: Scene): void;
|
|
5
|
+
}
|
|
6
|
+
export declare const TransitionOverlay: import("react").ForwardRefExoticComponent<import("react").RefAttributes<TransitionOverlayHandle>>;
|
|
@@ -0,0 +1,67 @@
|
|
|
1
|
+
import { jsx as _jsx } from "react/jsx-runtime";
|
|
2
|
+
import { forwardRef, useImperativeHandle, useRef } from 'react';
|
|
3
|
+
export const TransitionOverlay = forwardRef(function TransitionOverlay(_, ref) {
|
|
4
|
+
const rootRef = useRef(null);
|
|
5
|
+
const snapshotsRef = useRef(new Map());
|
|
6
|
+
useImperativeHandle(ref, () => ({
|
|
7
|
+
captureIfNewTransition(scene, webglCanvas) {
|
|
8
|
+
const root = rootRef.current;
|
|
9
|
+
if (!root)
|
|
10
|
+
return;
|
|
11
|
+
for (const tr of scene.transitions) {
|
|
12
|
+
if (snapshotsRef.current.has(tr.id))
|
|
13
|
+
continue;
|
|
14
|
+
const snap = document.createElement('canvas');
|
|
15
|
+
snap.width = webglCanvas.width;
|
|
16
|
+
snap.height = webglCanvas.height;
|
|
17
|
+
const ctx2d = snap.getContext('2d');
|
|
18
|
+
if (!ctx2d)
|
|
19
|
+
continue;
|
|
20
|
+
ctx2d.drawImage(webglCanvas, 0, 0);
|
|
21
|
+
const div = document.createElement('div');
|
|
22
|
+
div.style.cssText = 'position:absolute;inset:0;pointer-events:none;';
|
|
23
|
+
snap.style.cssText = 'display:block;width:100%;height:100%;';
|
|
24
|
+
div.appendChild(snap);
|
|
25
|
+
root.appendChild(div);
|
|
26
|
+
snapshotsRef.current.set(tr.id, { div });
|
|
27
|
+
}
|
|
28
|
+
},
|
|
29
|
+
update(scene) {
|
|
30
|
+
const activeIds = new Set(scene.transitions.map(tr => tr.id));
|
|
31
|
+
for (const [id, { div }] of snapshotsRef.current) {
|
|
32
|
+
if (!activeIds.has(id)) {
|
|
33
|
+
div.remove();
|
|
34
|
+
snapshotsRef.current.delete(id);
|
|
35
|
+
}
|
|
36
|
+
}
|
|
37
|
+
for (const tr of scene.transitions) {
|
|
38
|
+
const snap = snapshotsRef.current.get(tr.id);
|
|
39
|
+
if (!snap)
|
|
40
|
+
continue;
|
|
41
|
+
if (tr.kind === 'slide') {
|
|
42
|
+
const sign = tr.direction === 'left' ? -1 : 1;
|
|
43
|
+
snap.div.style.opacity = '1';
|
|
44
|
+
snap.div.style.transform = `translateX(${sign * tr.t * 100}%)`;
|
|
45
|
+
snap.div.style.clipPath = '';
|
|
46
|
+
}
|
|
47
|
+
else if (tr.kind === 'wipe') {
|
|
48
|
+
snap.div.style.opacity = '1';
|
|
49
|
+
snap.div.style.transform = '';
|
|
50
|
+
snap.div.style.clipPath = `inset(0 ${tr.t * 100}% 0 0)`;
|
|
51
|
+
}
|
|
52
|
+
else {
|
|
53
|
+
snap.div.style.opacity = String(1 - tr.t);
|
|
54
|
+
snap.div.style.transform = '';
|
|
55
|
+
snap.div.style.clipPath = '';
|
|
56
|
+
}
|
|
57
|
+
}
|
|
58
|
+
},
|
|
59
|
+
}), []);
|
|
60
|
+
return (_jsx("div", { ref: rootRef, style: {
|
|
61
|
+
position: 'absolute',
|
|
62
|
+
inset: 0,
|
|
63
|
+
zIndex: 1,
|
|
64
|
+
pointerEvents: 'none',
|
|
65
|
+
overflow: 'hidden',
|
|
66
|
+
} }));
|
|
67
|
+
});
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
export { Preview } from './Preview';
|
|
@@ -0,0 +1,8 @@
|
|
|
1
|
+
export { EditorProvider } from './EditorProvider';
|
|
2
|
+
export type { EditorProviderProps } from './EditorProvider';
|
|
3
|
+
export { AssetPanel } from './AssetPanel';
|
|
4
|
+
export type { AssetPanelProps } from './AssetPanel';
|
|
5
|
+
export { ElementsPanel } from './ElementsPanel';
|
|
6
|
+
export type { ElementsPanelProps } from './ElementsPanel';
|
|
7
|
+
export { Preview } from './Preview';
|
|
8
|
+
export type { PreviewProps, PreviewHandle } from './Preview';
|