@elah/editor 0.4.1 → 1.0.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.
@@ -1,7 +1,7 @@
1
1
  import { jsx as _jsx, jsxs as _jsxs } from "react/jsx-runtime";
2
- import { useCallback, useLayoutEffect, useMemo, useRef, useState, } from 'react';
2
+ import { memo, useCallback, useLayoutEffect, useMemo, useRef, useState, } from 'react';
3
3
  import { computeContainViewport } from '@elah/core';
4
- import { useTimelineEngine, useSelectionStore, usePlaybackStore, } from '@elah/react';
4
+ import { useTimelineEngine, useSelectionStore, usePlaybackStore } from '@elah/react';
5
5
  import { useResolvedScene } from '../useResolvedScene';
6
6
  const DEFAULT_SCALE = 0.5;
7
7
  const DEFAULT_CENTER = 0.5;
@@ -17,9 +17,11 @@ function baseTransform(t) {
17
17
  scale: t?.scale ?? DEFAULT_SCALE,
18
18
  rotation: t?.rotation ?? 0,
19
19
  anchor: t?.anchor ?? { x: 0.5, y: 0.5 },
20
+ scaleX: t?.scaleX ?? 1,
21
+ scaleY: t?.scaleY ?? 1,
20
22
  };
21
23
  }
22
- export function ShapeOverlay() {
24
+ export const ShapeOverlay = memo(function ShapeOverlay() {
23
25
  const engine = useTimelineEngine();
24
26
  const scene = useResolvedScene();
25
27
  const isPlaying = usePlaybackStore((s) => s.isPlaying);
@@ -48,14 +50,17 @@ export function ShapeOverlay() {
48
50
  const shortSide = Math.min(stage.width, stage.height);
49
51
  return scene.shapes.map((clip) => {
50
52
  const t = baseTransform(clip.transform);
51
- const sideStage = t.scale * shortSide;
53
+ const baseSideStage = t.scale * shortSide;
54
+ const widthStage = baseSideStage * (t.scaleX ?? 1);
55
+ const heightStage = baseSideStage * (t.scaleY ?? 1);
52
56
  const cxStage = t.x * stage.width;
53
57
  const cyStage = t.y * stage.height;
54
58
  const centerScreenX = fit.x + cxStage * scale;
55
59
  const centerScreenY = fit.y + cyStage * scale;
56
- const rawSide = sideStage * scale;
57
- const w = Math.max(rawSide, MIN_BOX_PX);
58
- const h = Math.max(rawSide, MIN_BOX_PX);
60
+ const rawW = widthStage * scale;
61
+ const rawH = heightStage * scale;
62
+ const w = Math.max(rawW, MIN_BOX_PX);
63
+ const h = Math.max(rawH, MIN_BOX_PX);
59
64
  const left = centerScreenX - w / 2;
60
65
  const top = centerScreenY - h / 2;
61
66
  return {
@@ -67,6 +72,13 @@ export function ShapeOverlay() {
67
72
  });
68
73
  }, [scene.shapes, stage.width, stage.height, fit, scale]);
69
74
  const ownsSelection = useMemo(() => items.some((it) => selectedClipIds.has(it.clip.id)), [items, selectedClipIds]);
75
+ const readAuthored = useCallback((clip) => {
76
+ const found = engine.findClip(clip.id);
77
+ if (!found) {
78
+ return { trackId: clip.trackId, base: baseTransform(clip.transform) };
79
+ }
80
+ return { trackId: found.trackId, base: baseTransform(found.clip.transform) };
81
+ }, [engine]);
70
82
  const handlePointerMove = useCallback((e) => {
71
83
  const g = gestureRef.current;
72
84
  if (!g)
@@ -79,10 +91,37 @@ export function ShapeOverlay() {
79
91
  engine.previewClip(g.id, g.trackId, { transform: { ...g.base, x, y } });
80
92
  }
81
93
  else {
82
- const dist = Math.hypot(e.clientX - g.centerClientX, e.clientY - g.centerClientY);
83
- const ratio = g.startDist > 0 ? dist / g.startDist : 1;
84
- const next = clamp(g.base.scale * ratio, MIN_SCALE, MAX_SCALE);
85
- engine.previewClip(g.id, g.trackId, { transform: { ...g.base, scale: next } });
94
+ const h = g.handle;
95
+ const halfW0 = g.halfW0;
96
+ const halfH0 = g.halfH0;
97
+ const dxStage = (e.clientX - g.startClientX) / scale;
98
+ const dyStage = (e.clientY - g.startClientY) / scale;
99
+ const shortSide = Math.min(g.stageW, g.stageH);
100
+ const minHalf = (MIN_SCALE * shortSide) / 2;
101
+ const maxHalf = (MAX_SCALE * shortSide) / 2;
102
+ let newHalfW = h.sx !== 0 ? clamp(halfW0 + h.sx * dxStage, minHalf, maxHalf) : halfW0;
103
+ let newHalfH = h.sy !== 0 ? clamp(halfH0 + h.sy * dyStage, minHalf, maxHalf) : halfH0;
104
+ if (e.shiftKey) {
105
+ const ratioW = newHalfW / halfW0;
106
+ const ratioH = newHalfH / halfH0;
107
+ const ratio = h.sx !== 0 && h.sy !== 0 ? Math.max(ratioW, ratioH) : h.sx !== 0 ? ratioW : ratioH;
108
+ newHalfW = clamp(halfW0 * ratio, minHalf, maxHalf);
109
+ newHalfH = clamp(halfH0 * ratio, minHalf, maxHalf);
110
+ }
111
+ const centerX = g.anchorStageX + h.sx * newHalfW;
112
+ const centerY = g.anchorStageY + h.sy * newHalfH;
113
+ const baseHalf = (g.base.scale * shortSide) / 2;
114
+ const scaleX = newHalfW / Math.max(1, baseHalf);
115
+ const scaleY = newHalfH / Math.max(1, baseHalf);
116
+ engine.previewClip(g.id, g.trackId, {
117
+ transform: {
118
+ ...g.base,
119
+ x: centerX / g.stageW,
120
+ y: centerY / g.stageH,
121
+ scaleX,
122
+ scaleY,
123
+ },
124
+ });
86
125
  }
87
126
  }, [engine, scale]);
88
127
  const endGesture = useCallback((e) => {
@@ -101,42 +140,45 @@ export function ShapeOverlay() {
101
140
  if (!selectedClipIds.has(item.clip.id))
102
141
  selectClip(item.clip.id);
103
142
  e.currentTarget.setPointerCapture(e.pointerId);
143
+ const authored = readAuthored(item.clip);
104
144
  gestureRef.current = {
105
145
  type: 'move',
106
146
  id: item.clip.id,
107
- trackId: item.clip.trackId,
108
- base: baseTransform(item.clip.transform),
147
+ trackId: authored.trackId,
148
+ base: authored.base,
109
149
  stageW: stage.width,
110
150
  stageH: stage.height,
111
151
  startClientX: e.clientX,
112
152
  startClientY: e.clientY,
113
- centerClientX: 0,
114
- centerClientY: 0,
115
- startDist: 0,
116
153
  };
117
- }, [selectedClipIds, selectClip, stage.width, stage.height]);
118
- const beginResize = useCallback((e, item) => {
154
+ }, [selectedClipIds, selectClip, stage.width, stage.height, readAuthored]);
155
+ const beginResize = useCallback((e, item, handle) => {
119
156
  e.stopPropagation();
120
157
  if (!selectedClipIds.has(item.clip.id))
121
158
  selectClip(item.clip.id);
122
159
  e.currentTarget.setPointerCapture(e.pointerId);
123
- const rootRect = rootRef.current?.getBoundingClientRect();
124
- const centerClientX = (rootRect?.left ?? 0) + item.centerScreenX;
125
- const centerClientY = (rootRect?.top ?? 0) + item.centerScreenY;
160
+ const { trackId, base } = readAuthored(item.clip);
161
+ const shortSide = Math.min(stage.width, stage.height);
162
+ const halfW0 = (base.scale * shortSide * (base.scaleX ?? 1)) / 2;
163
+ const halfH0 = (base.scale * shortSide * (base.scaleY ?? 1)) / 2;
164
+ const centerX = base.x * stage.width;
165
+ const centerY = base.y * stage.height;
126
166
  gestureRef.current = {
127
167
  type: 'resize',
128
168
  id: item.clip.id,
129
- trackId: item.clip.trackId,
130
- base: baseTransform(item.clip.transform),
169
+ trackId,
170
+ base,
131
171
  stageW: stage.width,
132
172
  stageH: stage.height,
133
173
  startClientX: e.clientX,
134
174
  startClientY: e.clientY,
135
- centerClientX,
136
- centerClientY,
137
- startDist: Math.hypot(e.clientX - centerClientX, e.clientY - centerClientY),
175
+ handle: { sx: handle.sx, sy: handle.sy },
176
+ anchorStageX: centerX - handle.sx * halfW0,
177
+ anchorStageY: centerY - handle.sy * halfH0,
178
+ halfW0,
179
+ halfH0,
138
180
  };
139
- }, [selectedClipIds, selectClip, stage.width, stage.height]);
181
+ }, [selectedClipIds, selectClip, stage.width, stage.height, readAuthored]);
140
182
  return (_jsxs("div", { ref: rootRef, className: "absolute inset-0 z-[3] pointer-events-none overflow-hidden", children: [!isPlaying && ownsSelection && (_jsx("div", { className: "absolute inset-0 pointer-events-auto", onPointerDown: () => clearSelection() })), !isPlaying &&
141
183
  items.map((item) => {
142
184
  const { clip, rect } = item;
@@ -157,23 +199,27 @@ export function ShapeOverlay() {
157
199
  touchAction: 'none',
158
200
  };
159
201
  return (_jsx("div", { style: boxStyle, onPointerDown: (e) => beginMove(e, item), onPointerMove: handlePointerMove, onPointerUp: endGesture, onPointerCancel: endGesture, children: selected &&
160
- CORNERS.map((corner) => (_jsx("div", { onPointerDown: (e) => beginResize(e, item), onPointerMove: handlePointerMove, onPointerUp: endGesture, onPointerCancel: endGesture, style: {
202
+ HANDLES.map((handle) => (_jsx("div", { onPointerDown: (e) => beginResize(e, item, handle), onPointerMove: handlePointerMove, onPointerUp: endGesture, onPointerCancel: endGesture, style: {
161
203
  position: 'absolute',
162
- ...corner.pos,
204
+ ...handle.pos,
163
205
  width: 10,
164
206
  height: 10,
165
207
  background: 'var(--elah-selection-handle, #fff)',
166
208
  border: '1px solid var(--elah-selection-color, #4c9aff)',
167
209
  borderRadius: 2,
168
- cursor: corner.cursor,
210
+ cursor: handle.cursor,
169
211
  pointerEvents: 'auto',
170
212
  touchAction: 'none',
171
- } }, corner.key))) }, clip.id));
213
+ } }, handle.key))) }, clip.id));
172
214
  })] }));
173
- }
174
- const CORNERS = [
175
- { key: 'nw', pos: { left: -5, top: -5 }, cursor: 'nwse-resize' },
176
- { key: 'ne', pos: { right: -5, top: -5 }, cursor: 'nesw-resize' },
177
- { key: 'sw', pos: { left: -5, bottom: -5 }, cursor: 'nesw-resize' },
178
- { key: 'se', pos: { right: -5, bottom: -5 }, cursor: 'nwse-resize' },
215
+ });
216
+ const HANDLES = [
217
+ { key: 'nw', sx: -1, sy: -1, pos: { left: -5, top: -5 }, cursor: 'nwse-resize' },
218
+ { key: 'n', sx: 0, sy: -1, pos: { left: '50%', top: -5, transform: 'translateX(-50%)' }, cursor: 'ns-resize' },
219
+ { key: 'ne', sx: 1, sy: -1, pos: { right: -5, top: -5 }, cursor: 'nesw-resize' },
220
+ { key: 'e', sx: 1, sy: 0, pos: { right: -5, top: '50%', transform: 'translateY(-50%)' }, cursor: 'ew-resize' },
221
+ { key: 'se', sx: 1, sy: 1, pos: { right: -5, bottom: -5 }, cursor: 'nwse-resize' },
222
+ { key: 's', sx: 0, sy: 1, pos: { left: '50%', bottom: -5, transform: 'translateX(-50%)' }, cursor: 'ns-resize' },
223
+ { key: 'sw', sx: -1, sy: 1, pos: { left: -5, bottom: -5 }, cursor: 'nesw-resize' },
224
+ { key: 'w', sx: -1, sy: 0, pos: { left: -5, top: '50%', transform: 'translateY(-50%)' }, cursor: 'ew-resize' },
179
225
  ];
@@ -23,8 +23,8 @@ export function StageBorder() {
23
23
  top: fit.y,
24
24
  width: fit.width,
25
25
  height: fit.height,
26
- border: '1px solid var(--elah-stage-border, rgba(225, 29, 72, 0.45))',
27
- boxShadow: 'var(--elah-stage-glow, 0 0 20px rgba(225, 29, 72, 0.08))',
26
+ border: '1px solid var(--elah-stage-border, rgba(0, 194, 255, 0.45))',
27
+ boxShadow: 'var(--elah-stage-glow, 0 0 20px rgba(0, 194, 255, 0.08))',
28
28
  boxSizing: 'border-box',
29
29
  } })) }));
30
30
  }
@@ -1 +1 @@
1
- export declare function TextOverlay(): import("react/jsx-runtime").JSX.Element;
1
+ export declare const TextOverlay: import("react").NamedExoticComponent<object>;
@@ -1,12 +1,16 @@
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, SIDE_MARGIN, } from '@elah/core';
4
- import { useTimelineEngine, useSelectionStore, usePlaybackStore, } from '@elah/react';
1
+ import { jsx as _jsx, Fragment as _Fragment, jsxs as _jsxs } from "react/jsx-runtime";
2
+ import { memo, useCallback, useEffect, useLayoutEffect, useMemo, useRef, useState, } from 'react';
3
+ import { computeTextLayout, computeContainViewport, SIDE_MARGIN } from '@elah/core';
4
+ import { useTimelineEngine, useSelectionStore, usePlaybackStore } from '@elah/react';
5
5
  import { useResolvedScene } from '../useResolvedScene';
6
6
  const MIN_FONT_SIZE = 6;
7
7
  const MAX_FONT_SIZE = 4000;
8
8
  const MIN_BOX_PX = 28;
9
9
  const BOX_PAD = 4;
10
+ const ROTATE_STEM_PX = 20;
11
+ const ROTATE_KNOB_PX = 14;
12
+ const ROTATE_SNAP_STEP = Math.PI / 12;
13
+ const ROTATE_SNAP_EPS = Math.PI / 60;
10
14
  const clamp = (v, lo, hi) => Math.min(hi, Math.max(lo, v));
11
15
  const clamp01 = (v) => clamp(v, 0, 1);
12
16
  function makeTransform(x, y, base) {
@@ -18,7 +22,11 @@ function makeTransform(x, y, base) {
18
22
  anchor: base?.anchor ?? { x: 0.5, y: 0.5 },
19
23
  };
20
24
  }
21
- export function TextOverlay() {
25
+ function unscaleFontSize(layout, resolved) {
26
+ const s = resolved?.scale ?? 1;
27
+ return s > 0 ? layout.style.fontSize / s : layout.style.fontSize;
28
+ }
29
+ export const TextOverlay = memo(function TextOverlay() {
22
30
  const engine = useTimelineEngine();
23
31
  const scene = useResolvedScene();
24
32
  const isPlaying = usePlaybackStore((s) => s.isPlaying);
@@ -60,16 +68,45 @@ export function TextOverlay() {
60
68
  return scene.texts.map((clip) => {
61
69
  const layout = computeTextLayout(measurer, clip, stage);
62
70
  const centerScreenX = fit.x + layout.center.x * stage.width * scale;
71
+ const centerScreenY = fit.y + layout.center.y * stage.height * scale;
63
72
  const rawLeft = fit.x + layout.box.x * scale;
64
73
  const rawW = layout.box.width * scale;
65
74
  const w = Math.max(rawW, MIN_BOX_PX);
66
75
  const left = rawW < MIN_BOX_PX ? centerScreenX - w / 2 : rawLeft;
67
76
  const h = Math.max(layout.box.height * scale, MIN_BOX_PX);
68
77
  const top = fit.y + layout.box.y * scale;
69
- return { clip, layout, centerScreenX, rect: { left, top, width: w, height: h } };
78
+ const rotation = clip.transform?.rotation ?? 0;
79
+ return {
80
+ clip,
81
+ layout,
82
+ centerScreenX,
83
+ centerScreenY,
84
+ rotation,
85
+ rect: { left, top, width: w, height: h },
86
+ };
70
87
  });
71
88
  }, [scene.texts, stage, fit, scale, getMeasurer]);
72
89
  const ownsSelection = useMemo(() => items.some((it) => selectedClipIds.has(it.clip.id)), [items, selectedClipIds]);
90
+ const readAuthored = useCallback((clip, layout) => {
91
+ const found = engine.findClip(clip.id);
92
+ if (!found) {
93
+ return {
94
+ trackId: clip.trackId,
95
+ transform: clip.transform,
96
+ center: { ...layout.center },
97
+ fontSize: layout.style.fontSize,
98
+ };
99
+ }
100
+ const authored = found.clip;
101
+ return {
102
+ trackId: found.trackId,
103
+ transform: authored.transform,
104
+ center: authored.transform
105
+ ? { x: authored.transform.x, y: authored.transform.y }
106
+ : { x: 0.5, y: 0.5 },
107
+ fontSize: authored.fontSize ?? unscaleFontSize(layout, clip.transform),
108
+ };
109
+ }, [engine]);
73
110
  const handlePointerMove = useCallback((e) => {
74
111
  const g = gestureRef.current;
75
112
  if (!g)
@@ -83,12 +120,30 @@ export function TextOverlay() {
83
120
  transform: makeTransform(nx, ny, g.startTransform),
84
121
  });
85
122
  }
86
- else {
123
+ else if (g.type === 'resize') {
87
124
  const dist = Math.hypot(e.clientX - g.centerClientX, e.clientY - g.centerClientY);
88
125
  const ratio = g.startDist > 0 ? dist / g.startDist : 1;
89
126
  const fontSize = clamp(Math.round(g.startFontSize * ratio), MIN_FONT_SIZE, MAX_FONT_SIZE);
90
127
  engine.previewClip(g.id, g.trackId, { fontSize });
91
128
  }
129
+ else {
130
+ const angle = Math.atan2(e.clientY - g.centerClientY, e.clientX - g.centerClientX);
131
+ let rotation = g.startRotation + (angle - g.startAngle);
132
+ if (e.shiftKey) {
133
+ rotation = Math.round(rotation / ROTATE_SNAP_STEP) * ROTATE_SNAP_STEP;
134
+ }
135
+ else {
136
+ const nearest = Math.round(rotation / (Math.PI / 2)) * (Math.PI / 2);
137
+ if (Math.abs(rotation - nearest) < ROTATE_SNAP_EPS)
138
+ rotation = nearest;
139
+ }
140
+ engine.previewClip(g.id, g.trackId, {
141
+ transform: {
142
+ ...makeTransform(g.startCenter.x, g.startCenter.y, g.startTransform),
143
+ rotation,
144
+ },
145
+ });
146
+ }
92
147
  }, [engine, scale, stage.width, stage.height]);
93
148
  const endGesture = useCallback((e) => {
94
149
  const g = gestureRef.current;
@@ -99,7 +154,7 @@ export function TextOverlay() {
99
154
  if (target.hasPointerCapture?.(e.pointerId)) {
100
155
  target.releasePointerCapture(e.pointerId);
101
156
  }
102
- engine.commitInteraction(g.type === 'move' ? 'Move text' : 'Resize text');
157
+ engine.commitInteraction(g.type === 'move' ? 'Move text' : g.type === 'rotate' ? 'Rotate text' : 'Resize text');
103
158
  }, [engine]);
104
159
  const beginMove = useCallback((e, clip, layout) => {
105
160
  if (editingId === clip.id)
@@ -108,44 +163,75 @@ export function TextOverlay() {
108
163
  if (!selectedClipIds.has(clip.id))
109
164
  selectClip(clip.id);
110
165
  e.currentTarget.setPointerCapture(e.pointerId);
166
+ const authored = readAuthored(clip, layout);
111
167
  gestureRef.current = {
112
168
  type: 'move',
113
169
  id: clip.id,
114
- trackId: clip.trackId,
170
+ trackId: authored.trackId,
115
171
  startClientX: e.clientX,
116
172
  startClientY: e.clientY,
117
- startCenter: { ...layout.center },
118
- startTransform: clip.transform,
173
+ startCenter: authored.center,
174
+ startTransform: authored.transform,
119
175
  centerClientX: 0,
120
176
  centerClientY: 0,
121
177
  startDist: 0,
122
- startFontSize: layout.style.fontSize,
178
+ startFontSize: authored.fontSize,
179
+ startAngle: 0,
180
+ startRotation: 0,
123
181
  };
124
- }, [editingId, selectedClipIds, selectClip]);
182
+ }, [editingId, selectedClipIds, selectClip, readAuthored]);
125
183
  const beginResize = useCallback((e, item) => {
126
184
  e.stopPropagation();
127
- const { clip, layout, centerScreenX } = item;
185
+ const { clip, layout, centerScreenX, centerScreenY } = item;
128
186
  if (!selectedClipIds.has(clip.id))
129
187
  selectClip(clip.id);
130
188
  e.currentTarget.setPointerCapture(e.pointerId);
131
189
  const rootRect = rootRef.current?.getBoundingClientRect();
132
- const centerScreenY = fit.y + layout.center.y * stage.height * scale;
133
190
  const centerClientX = (rootRect?.left ?? 0) + centerScreenX;
134
191
  const centerClientY = (rootRect?.top ?? 0) + centerScreenY;
192
+ const authored = readAuthored(clip, layout);
135
193
  gestureRef.current = {
136
194
  type: 'resize',
137
195
  id: clip.id,
138
- trackId: clip.trackId,
196
+ trackId: authored.trackId,
139
197
  startClientX: e.clientX,
140
198
  startClientY: e.clientY,
141
- startCenter: { ...layout.center },
142
- startTransform: clip.transform,
199
+ startCenter: authored.center,
200
+ startTransform: authored.transform,
143
201
  centerClientX,
144
202
  centerClientY,
145
203
  startDist: Math.hypot(e.clientX - centerClientX, e.clientY - centerClientY),
146
- startFontSize: layout.style.fontSize,
204
+ startFontSize: authored.fontSize,
205
+ startAngle: 0,
206
+ startRotation: 0,
147
207
  };
148
- }, [selectedClipIds, selectClip, fit.y, stage.height, scale]);
208
+ }, [selectedClipIds, selectClip, readAuthored]);
209
+ const beginRotate = useCallback((e, item) => {
210
+ e.stopPropagation();
211
+ const { clip, layout, centerScreenX, centerScreenY } = item;
212
+ if (!selectedClipIds.has(clip.id))
213
+ selectClip(clip.id);
214
+ e.currentTarget.setPointerCapture(e.pointerId);
215
+ const rootRect = rootRef.current?.getBoundingClientRect();
216
+ const centerClientX = (rootRect?.left ?? 0) + centerScreenX;
217
+ const centerClientY = (rootRect?.top ?? 0) + centerScreenY;
218
+ const authored = readAuthored(clip, layout);
219
+ gestureRef.current = {
220
+ type: 'rotate',
221
+ id: clip.id,
222
+ trackId: authored.trackId,
223
+ startClientX: e.clientX,
224
+ startClientY: e.clientY,
225
+ startCenter: authored.center,
226
+ startTransform: authored.transform,
227
+ centerClientX,
228
+ centerClientY,
229
+ startDist: 0,
230
+ startFontSize: authored.fontSize,
231
+ startAngle: Math.atan2(e.clientY - centerClientY, e.clientX - centerClientX),
232
+ startRotation: authored.transform?.rotation ?? 0,
233
+ };
234
+ }, [selectedClipIds, selectClip, readAuthored]);
149
235
  const startEditing = useCallback((clip) => {
150
236
  selectClip(clip.id);
151
237
  setEditText(clip.content);
@@ -185,22 +271,45 @@ export function TextOverlay() {
185
271
  cursor: isEditing ? 'text' : 'move',
186
272
  pointerEvents: isEditing ? 'none' : 'auto',
187
273
  touchAction: 'none',
274
+ transform: item.rotation ? `rotate(${item.rotation}rad)` : undefined,
275
+ transformOrigin: `${item.centerScreenX - (rect.left - BOX_PAD)}px ${item.centerScreenY - (rect.top - BOX_PAD)}px`,
188
276
  };
189
- return (_jsx("div", { style: boxStyle, onPointerDown: (e) => beginMove(e, clip, layout), onPointerMove: handlePointerMove, onPointerUp: endGesture, onPointerCancel: endGesture, onDoubleClick: (e) => {
277
+ return (_jsxs("div", { style: boxStyle, onPointerDown: (e) => beginMove(e, clip, layout), onPointerMove: handlePointerMove, onPointerUp: endGesture, onPointerCancel: endGesture, onDoubleClick: (e) => {
190
278
  e.stopPropagation();
191
279
  startEditing(clip);
192
- }, children: selected && !isEditing && CORNERS.map((corner) => (_jsx("div", { onPointerDown: (e) => beginResize(e, item), onPointerMove: handlePointerMove, onPointerUp: endGesture, onPointerCancel: endGesture, style: {
193
- position: 'absolute',
194
- ...corner.pos,
195
- width: 10,
196
- height: 10,
197
- background: 'var(--elah-selection-handle, #fff)',
198
- border: '1px solid var(--elah-selection-color, #4c9aff)',
199
- borderRadius: 2,
200
- cursor: corner.cursor,
201
- pointerEvents: 'auto',
202
- touchAction: 'none',
203
- } }, corner.key))) }, clip.id));
280
+ }, children: [selected && !isEditing && (_jsxs(_Fragment, { children: [_jsx("div", { style: {
281
+ position: 'absolute',
282
+ left: '50%',
283
+ top: -ROTATE_STEM_PX,
284
+ width: 1,
285
+ height: ROTATE_STEM_PX,
286
+ background: 'var(--elah-selection-color, #4c9aff)',
287
+ pointerEvents: 'none',
288
+ } }), _jsx("div", { onPointerDown: (e) => beginRotate(e, item), onPointerMove: handlePointerMove, onPointerUp: endGesture, onPointerCancel: endGesture, title: "Rotate (Shift: 15\u00B0 steps)", style: {
289
+ position: 'absolute',
290
+ left: '50%',
291
+ top: -(ROTATE_STEM_PX + ROTATE_KNOB_PX),
292
+ transform: 'translateX(-50%)',
293
+ width: ROTATE_KNOB_PX,
294
+ height: ROTATE_KNOB_PX,
295
+ background: 'var(--elah-selection-handle, #fff)',
296
+ border: '1px solid var(--elah-selection-color, #4c9aff)',
297
+ borderRadius: '50%',
298
+ cursor: 'grab',
299
+ pointerEvents: 'auto',
300
+ touchAction: 'none',
301
+ } })] })), selected && !isEditing && CORNERS.map((corner) => (_jsx("div", { onPointerDown: (e) => beginResize(e, item), onPointerMove: handlePointerMove, onPointerUp: endGesture, onPointerCancel: endGesture, style: {
302
+ position: 'absolute',
303
+ ...corner.pos,
304
+ width: 10,
305
+ height: 10,
306
+ background: 'var(--elah-selection-handle, #fff)',
307
+ border: '1px solid var(--elah-selection-color, #4c9aff)',
308
+ borderRadius: 2,
309
+ cursor: corner.cursor,
310
+ pointerEvents: 'auto',
311
+ touchAction: 'none',
312
+ } }, corner.key)))] }, clip.id));
204
313
  }), !isPlaying && editingItem && (_jsx("textarea", { autoFocus: true, value: editText, onChange: (e) => {
205
314
  const next = e.target.value;
206
315
  setEditText(next);
@@ -238,7 +347,7 @@ export function TextOverlay() {
238
347
  pointerEvents: 'auto',
239
348
  boxSizing: 'border-box',
240
349
  } }))] }));
241
- }
350
+ });
242
351
  const CORNERS = [
243
352
  { key: 'nw', pos: { left: -5, top: -5 }, cursor: 'nwse-resize' },
244
353
  { key: 'ne', pos: { right: -5, top: -5 }, cursor: 'nesw-resize' },
@@ -346,7 +346,7 @@ export function SourcePanel({ style, className, defaultLane = 'media', className
346
346
  color: toast.tone === 'warn' ? 'var(--elah-danger-text, #f5d0a9)' : 'var(--elah-info-text, #c8d8f0)',
347
347
  background: toast.tone === 'warn' ? 'var(--elah-danger-bg, #3a2418)' : 'var(--elah-info-bg, #1a2433)',
348
348
  border: `1px solid ${toast.tone === 'warn' ? 'var(--elah-danger-border, #7a4a2a)' : 'var(--elah-info-border, #355070)'}`,
349
- boxShadow: '0 4px 12px rgba(0,0,0,0.35)',
349
+ boxShadow: 'var(--elah-toast-shadow, 0 4px 12px rgba(0, 0, 0, 0.35))',
350
350
  }, children: toast.message })), assets.length === 0 ? (_jsxs("div", { className: "flex flex-col items-center justify-center min-h-[120px] p-4 text-center text-ed-text-muted text-[11px] border border-dashed border-ed-border rounded-md", children: [_jsx("span", { className: "mb-2 text-2xl opacity-50", children: "\u2193" }), "Drop files here", _jsx("br", {}), "or click Add"] })) : visibleAssets.length === 0 ? (_jsx("div", { className: "text-center text-ed-text-muted text-[11px] pt-8", children: "No matches" })) : (_jsx("div", { className: "flex flex-col gap-[6px]", children: visibleAssets.map((asset) => (_jsx(ClipCard, { asset: asset, viewMode: viewMode, onDelete: handleDeleteAsset, onActivate: activationEnabled ? handleAssetActivate : undefined, slots: cardSlots }, asset.id))) }))] })] })), lane === 'elements' && (_jsxs("div", { className: "flex flex-col flex-1 min-h-0 overflow-auto", children: [_jsx("div", { className: cn('px-[10px] py-[6px] border-b border-ed-border shrink-0', classNames?.toolbar), children: _jsx("input", { type: "search", placeholder: "Search elements\u2026", className: classNames?.searchInput, style: {
351
351
  width: '100%',
352
352
  padding: '4px 8px',
@@ -1,5 +1,5 @@
1
- export { EditorProvider } from './EditorProvider';
2
- export type { EditorProviderProps } from './EditorProvider';
1
+ export { EditorProvider } from '@elah/react';
2
+ export type { EditorProviderProps } from '@elah/react';
3
3
  export { AssetPanel } from './AssetPanel';
4
4
  export type { AssetPanelProps } from './AssetPanel';
5
5
  export { ElementsPanel } from './ElementsPanel';
@@ -1,4 +1,4 @@
1
- export { EditorProvider } from './EditorProvider';
1
+ export { EditorProvider } from '@elah/react';
2
2
  export { AssetPanel } from './AssetPanel';
3
3
  export { ElementsPanel } from './ElementsPanel';
4
4
  export { Preview } from './Preview';
package/dist/index.d.ts CHANGED
@@ -1,5 +1,13 @@
1
- export type { Clip, Track, Project, Transform, TextAnimation, TextAnimationKind, ClipType, TrackKind, FrameCount, TimelineConfig, InitialTrackConfig, EngineEvent, Transition, TransitionKind, TransitionEasing, TransitionDirection, } from '@elah/core';
1
+ export type { Clip, Track, Project, Transform, TextAnimation, TextAnimationKind, TextAnimationKindOption, TextAnimationEasing, MotionSpec, TextStylePreset, ClipType, TrackKind, FrameCount, TimelineConfig, InitialTrackConfig, EngineEvent, Transition, TransitionKind, TransitionEasing, TransitionDirection, LoadProjectHistory, LoadProjectTransport, ProjectLoadedEvent, } from '@elah/core';
2
2
  export { TimelineEngine } from '@elah/core';
3
+ export { BUILT_IN_TEXT_STYLE_PRESETS, BUILT_IN_TEXT_TEMPLATES, applyTextTemplate, findTextTemplate, resolveTemplateRamp, LINE_HEIGHT, } from '@elah/core';
4
+ export { MAX_TEMPLATE_RAMP_FRAMES, MIN_TEMPLATE_RAMP_FRAMES, } from '@elah/core';
5
+ export type { TextTemplate, TextTemplateStyle, TextTemplateAnimation, TextTemplateStagger, } from '@elah/core';
6
+ export { sampleTextAnimation, resolveRampFrames, TEXT_ANIMATION_EASINGS, motionForKind, DEFAULT_SHAPE_TRANSFORM, SLIDE_TRAVEL_NORMALIZED, SPIN_TRAVEL_RADIANS, } from '@elah/core';
7
+ export type { TextAnimationSample, SampleTextAnimationArgs } from '@elah/core';
8
+ export { PROJECT_VERSION, ProjectDocumentError, readProjectDocument, isReadableProjectDocument, isRecoverableMediaSrc, relinkProjectMedia, missingMediaSummary, } from '@elah/core';
9
+ export type { ProjectDocumentErrorCode, ReadProjectDocumentOptions, MediaClipType, MissingMedia, RelinkMediaResult, } from '@elah/core';
10
+ export { serializeProject, deserializeProject } from '@elah/core';
3
11
  export { PlaybackEngine } from '@elah/core';
4
12
  export type { PlaybackSnapshot, PlaybackEngineConfig } from '@elah/core';
5
13
  export { resolveTimeline } from '@elah/core';
@@ -12,6 +20,8 @@ export type { CounterSnapshot } from '@elah/core';
12
20
  export { createDefaultDemuxerFactory, createMediabunnyBackend, isMediabunnyCompatible } from '@elah/core';
13
21
  export type { MediabunnyModule, CreateMediabunnyBackendOpts } from '@elah/core';
14
22
  export type { DemuxerBackend, DemuxerFactory, MediabunnyDemuxer } from '@elah/core';
23
+ export { sourceBlobCache, createSourceBlobCache, warmVideoSrc } from '@elah/core';
24
+ export type { SourceBlobCache, CreateSourceBlobCacheOpts, BlobFetcher } from '@elah/core';
15
25
  export type { VideoFrameProvider, VideoFrameProviderDeps } from '@elah/core';
16
26
  export { createVideoFrameProvider, MockVideoFrameProvider, SyntheticVideoFrameProvider } from '@elah/core';
17
27
  export { AudioPlaybackController } from '@elah/core';
@@ -20,17 +30,16 @@ export { defaultAudioResolver } from '@elah/core';
20
30
  export type { AudioResolver } from '@elah/core';
21
31
  export { warmImageSrc, preloadProjectImages } from '@elah/core';
22
32
  export type { ImageLoader, LoadedImage } from '@elah/core';
23
- export { MEDIA_DRAG_MIME, mediaDragKindMime, importFiles, importUrl, importBlob } from '@elah/core';
33
+ export { MEDIA_DRAG_MIME, mediaDragKindMime, importFiles, importUrl, importBlob, beginImportUrl, determineAssetHasAudio, probeHasAudio, } from '@elah/core';
24
34
  export { useMediaLibrary, useAssets, useMediaLibraryStore } from '@elah/react';
25
- export type { MediaAsset, MediaKind, DragMediaPayload, ImportFilesOptions, ImportFilesResult, ImportUrlOptions, ImportBlobOptions, SkippedImport, MediaLibraryState, MediaLibraryActions, } from '@elah/core';
35
+ export type { MediaAsset, MediaKind, DragMediaPayload, MediaAssetAnalysis, MediaAssetTopObject, ImportFilesOptions, ImportFilesResult, ImportUrlOptions, ImportBlobOptions, SkippedImport, MediaLibraryState, MediaLibraryActions, } from '@elah/core';
26
36
  export type { UseMediaLibraryApi } from '@elah/react';
27
- export { useTracksStore } from '@elah/react';
28
- export { usePlaybackStore } from '@elah/react';
29
- export { useSelectionStore } from '@elah/react';
30
- export { useTransitionsStore } from '@elah/react';
37
+ export { scheduleThumbnailById, snapshotMediaLibrary, hydrateMediaLibrary, refreshMissingThumbnails, } from '@elah/core';
38
+ export type { MediaLibrarySnapshotEntry, HydrateMediaLibraryOptions, HydrateMediaLibraryResult, } from '@elah/core';
39
+ export { useTracksStore, usePlaybackStore, useSelectionStore, useTransitionsStore, useTextStylePresetsStore, useClipLoadStore, } from '@elah/react';
31
40
  export type { BoundStoreHook } from '@elah/react';
32
- export { tracksStore, playbackStore, selectionStore, transitionsStore, mediaLibraryStore } from '@elah/core';
33
- export type { TracksState, TracksActions, PlaybackState, PlaybackActions, SelectionState, SelectionActions, TransitionsState, TransitionsActions, } from '@elah/core';
41
+ export { tracksStore, playbackStore, selectionStore, transitionsStore, mediaLibraryStore, clipLoadStore, textStylePresetsStore, } from '@elah/core';
42
+ export type { TracksState, TracksActions, PlaybackState, PlaybackActions, SelectionState, SelectionActions, TransitionsState, TransitionsActions, ClipLoadState, ClipLoadStoreState, ClipLoadStoreActions, TextStylePresetsState, TextStylePresetsActions, } from '@elah/core';
34
43
  export { createVideoClip } from '@elah/core';
35
44
  export { createAudioClip } from '@elah/core';
36
45
  export { createTextClip } from '@elah/core';
@@ -38,14 +47,20 @@ export { createImageClip } from '@elah/core';
38
47
  export { createShapeClip } from '@elah/core';
39
48
  export { createFreehandClip } from '@elah/core';
40
49
  export type { CreateClipOptions, CreateShapeClipOptions, CreateFreehandClipOptions, ShapeClipMetadata, FreehandClipMetadata, } from '@elah/core';
50
+ export { TEXT_ANIMATION_KINDS, DEFAULT_TEXT_TRANSFORM, } from '@elah/core';
41
51
  export { splitClipAtPlayhead } from '@elah/core';
42
52
  export type { SplitAtPlayheadData } from '@elah/core';
43
53
  export type { ActionResult, ActionFailureReason } from '@elah/core';
44
54
  export { framesToTimecode, secondsToFrames, framesToSeconds, getTotalFrames } from '@elah/core';
45
55
  export { generateId } from '@elah/core';
46
- export { transformFromCoverRect } from '@elah/core';
56
+ export { transformFromContainRect, transformFromCoverRect, normalizeCrop, FULL_CROP, } from '@elah/core';
57
+ export type { CropRect } from '@elah/core';
47
58
  export { snapFrame, buildSnapPoints, resolveOverlapEdgeSnap, clipsOverlap, DEFAULT_OVERLAP_TOLERANCE } from '@elah/core';
48
- export { serializeProject, deserializeProject } from '@elah/core';
59
+ export { TIMELINE_MIN_CONTENT_WIDTH, timelineContentWidth, pxToFrames, xToFrame, seekFrameAtX, snapThresholdFrames, ZOOM_MIN, ZOOM_MAX, clampZoom, computeAnchoredScrollLeft, resolveZoomAnchorX, wheelZoomStep, pinchZoom, RULER_TICK_INTERVALS_SEC, formatRulerLabel, computeRulerTicks, isCompatibleTrackKind, isClipAllowedOnTrack, TRIM_HANDLE_WIDTH_PX, isUnlimitedClipType, maxTrimDuration, minTrimDuration, minLeftTrimStart, neighbourBounds, clampLeftTrim, clampRightTrim, } from '@elah/core';
60
+ export type { RulerTick, TrimResult, TrimLimits } from '@elah/core';
61
+ export { createFrameSequence, frameAt, frameCount, normalizeFrameIndex, FrameSequenceController, createFramePreloader, pickFrameSource, supportsImageType, frameSequenceToProject, } from '@elah/core';
62
+ export type { Frame, FrameSource, FrameSequence, FrameLoopMode, CreateFrameSequenceOptions, FrameSequenceSnapshot, FrameSequenceControllerOptions, FramePreloader, FramePreloaderOptions, FramePreloaderStatus, FrameSourceSizeHint, FrameSequenceToProjectOptions, } from '@elah/core';
63
+ export { PerfSummary } from '@elah/core';
49
64
  export { exportVideo } from '@elah/core';
50
65
  export { lazyExportVideo } from '@elah/core';
51
66
  export type { ExportOptions, ExportProgress, ExportVideoCodec, ExportAudioCodec } from '@elah/core';
@@ -58,11 +73,12 @@ export { usePlayback } from '@elah/timeline';
58
73
  export { useSelection } from '@elah/timeline';
59
74
  export { useTimelineDrop } from '@elah/timeline';
60
75
  export type { TimelineDropState } from '@elah/timeline';
61
- export { insertMediaAsset, insertElement } from '@elah/timeline';
76
+ export { insertMediaAsset, insertElement, growClipToAssetDuration } from '@elah/timeline';
62
77
  export { ELEMENT_DRAG_MIME } from '@elah/timeline';
78
+ export { useKeySelectionStore } from '@elah/timeline';
63
79
  export type { DragElementPayload, ElementKind, ShapeVariant, InsertAssetOptions, InsertAssetResult, InsertAssetFailureReason, InsertedKind, } from '@elah/timeline';
64
- export { EditorProvider } from './editor/EditorProvider';
65
- export type { EditorProviderProps } from './editor/EditorProvider';
80
+ export { EditorProvider } from '@elah/react';
81
+ export type { EditorProviderProps } from '@elah/react';
66
82
  export { EditorContext, useEditor, useTimelineEngine, usePlaybackEngine } from '@elah/react';
67
83
  export type { EditorContextValue } from '@elah/react';
68
84
  export { useAudioMixer, useMasterVolume, useTrackLevels } from '@elah/react';