@hyperframes/studio 0.2.0 → 0.2.2-alpha.1
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/LICENSE +190 -21
- package/dist/assets/index-BT9D8I7B.css +1 -0
- package/dist/assets/index-DA_l-VKo.js +93 -0
- package/dist/index.html +2 -2
- package/package.json +2 -2
- package/src/App.tsx +213 -8
- package/src/captions/components/CaptionAnimationPanel.tsx +269 -0
- package/src/captions/components/CaptionOverlay.tsx +622 -0
- package/src/captions/components/CaptionPropertyPanel.tsx +275 -0
- package/src/captions/components/CaptionTimeline.tsx +187 -0
- package/src/captions/components/shared.tsx +26 -0
- package/src/captions/generator.test.ts +279 -0
- package/src/captions/generator.ts +376 -0
- package/src/captions/hooks/useCaptionSync.ts +168 -0
- package/src/captions/index.ts +10 -0
- package/src/captions/parser.test.ts +377 -0
- package/src/captions/parser.ts +314 -0
- package/src/captions/store.ts +272 -0
- package/src/captions/types.ts +207 -0
- package/src/components/nle/NLELayout.tsx +1 -1
- package/dist/assets/index-Bkp9HQbo.css +0 -1
- package/dist/assets/index-DfhSlTti.js +0 -93
|
@@ -0,0 +1,275 @@
|
|
|
1
|
+
import { memo, useCallback, useState } from "react";
|
|
2
|
+
import { useCaptionStore } from "../store";
|
|
3
|
+
import type { CaptionStyle } from "../types";
|
|
4
|
+
import { CaptionAnimationPanel } from "./CaptionAnimationPanel";
|
|
5
|
+
import { Section, Row, inputCls } from "./shared";
|
|
6
|
+
|
|
7
|
+
// ---------------------------------------------------------------------------
|
|
8
|
+
// Main component
|
|
9
|
+
// ---------------------------------------------------------------------------
|
|
10
|
+
|
|
11
|
+
interface CaptionPropertyPanelProps {
|
|
12
|
+
iframeRef: React.RefObject<HTMLIFrameElement | null>;
|
|
13
|
+
}
|
|
14
|
+
|
|
15
|
+
export const CaptionPropertyPanel = memo(function CaptionPropertyPanel({
|
|
16
|
+
iframeRef,
|
|
17
|
+
}: CaptionPropertyPanelProps) {
|
|
18
|
+
const model = useCaptionStore((s) => s.model);
|
|
19
|
+
const selectedSegmentIds = useCaptionStore((s) => s.selectedSegmentIds);
|
|
20
|
+
const selectedGroupId = useCaptionStore((s) => s.selectedGroupId);
|
|
21
|
+
const updateSelectedStyle = useCaptionStore((s) => s.updateSelectedStyle);
|
|
22
|
+
const updateGroupStyle = useCaptionStore((s) => s.updateGroupStyle);
|
|
23
|
+
|
|
24
|
+
const [activeTab, setActiveTab] = useState<"style" | "animation">("style");
|
|
25
|
+
|
|
26
|
+
// Resolve effective style for the first selected segment
|
|
27
|
+
const firstSegmentId = selectedSegmentIds.size > 0 ? [...selectedSegmentIds][0] : undefined;
|
|
28
|
+
const firstSegment = model?.segments.get(firstSegmentId ?? "");
|
|
29
|
+
|
|
30
|
+
// Find the group that owns the first segment
|
|
31
|
+
let ownerGroupId: string | null = null;
|
|
32
|
+
if (model && firstSegmentId) {
|
|
33
|
+
for (const gid of model.groupOrder) {
|
|
34
|
+
const group = model.groups.get(gid);
|
|
35
|
+
if (group && group.segmentIds.includes(firstSegmentId)) {
|
|
36
|
+
ownerGroupId = gid;
|
|
37
|
+
break;
|
|
38
|
+
}
|
|
39
|
+
}
|
|
40
|
+
}
|
|
41
|
+
|
|
42
|
+
const groupStyle = ownerGroupId ? model?.groups.get(ownerGroupId)?.style : undefined;
|
|
43
|
+
const segmentOverrides = firstSegment?.style ?? {};
|
|
44
|
+
|
|
45
|
+
// Merge group style with segment overrides for display
|
|
46
|
+
const effectiveStyle: Partial<CaptionStyle> = {
|
|
47
|
+
...groupStyle,
|
|
48
|
+
...segmentOverrides,
|
|
49
|
+
};
|
|
50
|
+
|
|
51
|
+
/**
|
|
52
|
+
* Apply a CSS style change to selected word elements in the iframe DOM in real time.
|
|
53
|
+
* Maps CaptionStyle property names to CSS properties.
|
|
54
|
+
*/
|
|
55
|
+
const applyToIframeDom = useCallback(
|
|
56
|
+
(updates: Partial<CaptionStyle>) => {
|
|
57
|
+
const iframe = iframeRef.current;
|
|
58
|
+
if (!iframe || !model) return;
|
|
59
|
+
let doc: Document | null = null;
|
|
60
|
+
try {
|
|
61
|
+
doc = iframe.contentDocument;
|
|
62
|
+
} catch {
|
|
63
|
+
return;
|
|
64
|
+
}
|
|
65
|
+
if (!doc) return;
|
|
66
|
+
|
|
67
|
+
const groupEls = doc.querySelectorAll<HTMLElement>(".caption-group");
|
|
68
|
+
|
|
69
|
+
// Build list of word elements to update
|
|
70
|
+
const targetEls: HTMLElement[] = [];
|
|
71
|
+
for (const segId of selectedSegmentIds) {
|
|
72
|
+
for (let gi = 0; gi < model.groupOrder.length; gi++) {
|
|
73
|
+
const group = model.groups.get(model.groupOrder[gi]);
|
|
74
|
+
if (!group) continue;
|
|
75
|
+
const wi = group.segmentIds.indexOf(segId);
|
|
76
|
+
if (wi < 0) continue;
|
|
77
|
+
const groupEl = groupEls[gi];
|
|
78
|
+
if (!groupEl) continue;
|
|
79
|
+
// Resolve word span, handling wrappers
|
|
80
|
+
const children = groupEl.children;
|
|
81
|
+
let idx = 0;
|
|
82
|
+
for (const child of children) {
|
|
83
|
+
const c = child as HTMLElement;
|
|
84
|
+
if (c.dataset.captionWrapper === "true") {
|
|
85
|
+
const inner = c.querySelector<HTMLElement>(":scope > span");
|
|
86
|
+
if (inner && idx === wi) {
|
|
87
|
+
targetEls.push(inner);
|
|
88
|
+
break;
|
|
89
|
+
}
|
|
90
|
+
} else if (c.tagName === "SPAN") {
|
|
91
|
+
if (idx === wi) {
|
|
92
|
+
targetEls.push(c);
|
|
93
|
+
break;
|
|
94
|
+
}
|
|
95
|
+
}
|
|
96
|
+
idx++;
|
|
97
|
+
}
|
|
98
|
+
break;
|
|
99
|
+
}
|
|
100
|
+
}
|
|
101
|
+
|
|
102
|
+
// Apply transform updates via gsap.set on the WRAPPER (not the word span)
|
|
103
|
+
const hasTransform =
|
|
104
|
+
updates.x !== undefined ||
|
|
105
|
+
updates.y !== undefined ||
|
|
106
|
+
updates.scaleX !== undefined ||
|
|
107
|
+
updates.scaleY !== undefined ||
|
|
108
|
+
updates.rotation !== undefined;
|
|
109
|
+
|
|
110
|
+
if (hasTransform) {
|
|
111
|
+
try {
|
|
112
|
+
const iframeGsap = (
|
|
113
|
+
iframeRef.current?.contentWindow as unknown as {
|
|
114
|
+
gsap?: {
|
|
115
|
+
set: (el: HTMLElement, props: Record<string, unknown>) => void;
|
|
116
|
+
getProperty: (el: HTMLElement, prop: string) => number;
|
|
117
|
+
};
|
|
118
|
+
}
|
|
119
|
+
)?.gsap;
|
|
120
|
+
if (iframeGsap) {
|
|
121
|
+
for (const el of targetEls) {
|
|
122
|
+
// Get or create wrapper
|
|
123
|
+
let wrapper = el.parentElement;
|
|
124
|
+
if (!wrapper || wrapper.dataset.captionWrapper !== "true") {
|
|
125
|
+
wrapper = doc.createElement("span") as HTMLElement;
|
|
126
|
+
wrapper.style.display = "inline-block";
|
|
127
|
+
wrapper.dataset.captionWrapper = "true";
|
|
128
|
+
el.parentNode?.insertBefore(wrapper, el);
|
|
129
|
+
wrapper.appendChild(el);
|
|
130
|
+
}
|
|
131
|
+
// Read current wrapper state and merge with updates
|
|
132
|
+
const curX = iframeGsap.getProperty(wrapper, "x") || 0;
|
|
133
|
+
const curY = iframeGsap.getProperty(wrapper, "y") || 0;
|
|
134
|
+
const curScale = iframeGsap.getProperty(wrapper, "scale") || 1;
|
|
135
|
+
const curRotation = iframeGsap.getProperty(wrapper, "rotation") || 0;
|
|
136
|
+
iframeGsap.set(wrapper, {
|
|
137
|
+
x: updates.x ?? curX,
|
|
138
|
+
y: updates.y ?? curY,
|
|
139
|
+
scale: updates.scaleX ?? curScale,
|
|
140
|
+
rotation: updates.rotation ?? curRotation,
|
|
141
|
+
});
|
|
142
|
+
}
|
|
143
|
+
}
|
|
144
|
+
} catch {
|
|
145
|
+
/* cross-origin */
|
|
146
|
+
}
|
|
147
|
+
}
|
|
148
|
+
},
|
|
149
|
+
[iframeRef, model, selectedSegmentIds],
|
|
150
|
+
);
|
|
151
|
+
|
|
152
|
+
// All hooks must be called before any early return
|
|
153
|
+
const handleStyleChange = useCallback(
|
|
154
|
+
(updates: Partial<CaptionStyle>) => {
|
|
155
|
+
if (selectedGroupId) {
|
|
156
|
+
updateGroupStyle(selectedGroupId, updates);
|
|
157
|
+
} else {
|
|
158
|
+
updateSelectedStyle(updates);
|
|
159
|
+
}
|
|
160
|
+
applyToIframeDom(updates);
|
|
161
|
+
},
|
|
162
|
+
[selectedGroupId, updateGroupStyle, updateSelectedStyle, applyToIframeDom],
|
|
163
|
+
);
|
|
164
|
+
|
|
165
|
+
// Empty state — after all hooks
|
|
166
|
+
if (selectedSegmentIds.size === 0) {
|
|
167
|
+
return (
|
|
168
|
+
<div className="flex items-center justify-center h-full px-4 text-center">
|
|
169
|
+
<p className="text-xs text-neutral-500">Select caption words to edit their style</p>
|
|
170
|
+
</div>
|
|
171
|
+
);
|
|
172
|
+
}
|
|
173
|
+
|
|
174
|
+
// ---------------------------------------------------------------------------
|
|
175
|
+
// Derived style values with fallbacks
|
|
176
|
+
// ---------------------------------------------------------------------------
|
|
177
|
+
|
|
178
|
+
const x = effectiveStyle.x ?? 0;
|
|
179
|
+
const y = effectiveStyle.y ?? 0;
|
|
180
|
+
const rotation = effectiveStyle.rotation ?? 0;
|
|
181
|
+
const scaleX = effectiveStyle.scaleX ?? 1;
|
|
182
|
+
|
|
183
|
+
// Count label
|
|
184
|
+
const countLabel = selectedSegmentIds.size === 1 ? "1 word" : `${selectedSegmentIds.size} words`;
|
|
185
|
+
|
|
186
|
+
return (
|
|
187
|
+
<div className="flex flex-col h-full min-h-0">
|
|
188
|
+
{/* Header */}
|
|
189
|
+
<div className="px-3 py-2 border-b border-neutral-800 flex-shrink-0">
|
|
190
|
+
<div className="flex items-center justify-between mb-1.5">
|
|
191
|
+
<span className="text-2xs text-neutral-500">{countLabel}</span>
|
|
192
|
+
</div>
|
|
193
|
+
{/* Tab switcher */}
|
|
194
|
+
<div className="flex gap-1">
|
|
195
|
+
<button
|
|
196
|
+
type="button"
|
|
197
|
+
onClick={() => setActiveTab("style")}
|
|
198
|
+
className={[
|
|
199
|
+
"flex-1 py-0.5 rounded text-2xs font-medium transition-colors",
|
|
200
|
+
activeTab === "style"
|
|
201
|
+
? "bg-studio-accent/20 text-studio-accent border border-studio-accent/50"
|
|
202
|
+
: "text-neutral-500 border border-neutral-800 hover:text-neutral-300 hover:border-neutral-600",
|
|
203
|
+
].join(" ")}
|
|
204
|
+
>
|
|
205
|
+
Style
|
|
206
|
+
</button>
|
|
207
|
+
<button
|
|
208
|
+
type="button"
|
|
209
|
+
onClick={() => setActiveTab("animation")}
|
|
210
|
+
className={[
|
|
211
|
+
"flex-1 py-0.5 rounded text-2xs font-medium transition-colors",
|
|
212
|
+
activeTab === "animation"
|
|
213
|
+
? "bg-studio-accent/20 text-studio-accent border border-studio-accent/50"
|
|
214
|
+
: "text-neutral-500 border border-neutral-800 hover:text-neutral-300 hover:border-neutral-600",
|
|
215
|
+
].join(" ")}
|
|
216
|
+
>
|
|
217
|
+
Animation
|
|
218
|
+
</button>
|
|
219
|
+
</div>
|
|
220
|
+
</div>
|
|
221
|
+
|
|
222
|
+
{/* Animation tab */}
|
|
223
|
+
{activeTab === "animation" && <CaptionAnimationPanel />}
|
|
224
|
+
|
|
225
|
+
{/* Style tab — Transform only */}
|
|
226
|
+
{activeTab === "style" && (
|
|
227
|
+
<div className="flex-1 overflow-y-auto px-3 py-2">
|
|
228
|
+
<Section label="Position">
|
|
229
|
+
<Row label="X">
|
|
230
|
+
<input
|
|
231
|
+
type="number"
|
|
232
|
+
value={x}
|
|
233
|
+
onChange={(e) => handleStyleChange({ x: Number(e.target.value) })}
|
|
234
|
+
className={inputCls}
|
|
235
|
+
/>
|
|
236
|
+
</Row>
|
|
237
|
+
<Row label="Y">
|
|
238
|
+
<input
|
|
239
|
+
type="number"
|
|
240
|
+
value={y}
|
|
241
|
+
onChange={(e) => handleStyleChange({ y: Number(e.target.value) })}
|
|
242
|
+
className={inputCls}
|
|
243
|
+
/>
|
|
244
|
+
</Row>
|
|
245
|
+
</Section>
|
|
246
|
+
|
|
247
|
+
<Section label="Transform">
|
|
248
|
+
<Row label="Scale">
|
|
249
|
+
<input
|
|
250
|
+
type="number"
|
|
251
|
+
value={scaleX}
|
|
252
|
+
step={0.1}
|
|
253
|
+
onChange={(e) =>
|
|
254
|
+
handleStyleChange({
|
|
255
|
+
scaleX: Number(e.target.value),
|
|
256
|
+
scaleY: Number(e.target.value),
|
|
257
|
+
})
|
|
258
|
+
}
|
|
259
|
+
className={inputCls}
|
|
260
|
+
/>
|
|
261
|
+
</Row>
|
|
262
|
+
<Row label="Rotation">
|
|
263
|
+
<input
|
|
264
|
+
type="number"
|
|
265
|
+
value={rotation}
|
|
266
|
+
onChange={(e) => handleStyleChange({ rotation: Number(e.target.value) })}
|
|
267
|
+
className={inputCls}
|
|
268
|
+
/>
|
|
269
|
+
</Row>
|
|
270
|
+
</Section>
|
|
271
|
+
</div>
|
|
272
|
+
)}
|
|
273
|
+
</div>
|
|
274
|
+
);
|
|
275
|
+
});
|
|
@@ -0,0 +1,187 @@
|
|
|
1
|
+
import { memo, useCallback, useRef } from "react";
|
|
2
|
+
import { useCaptionStore } from "../store";
|
|
3
|
+
|
|
4
|
+
// ---------------------------------------------------------------------------
|
|
5
|
+
// Constants
|
|
6
|
+
// ---------------------------------------------------------------------------
|
|
7
|
+
|
|
8
|
+
const GROUP_COLORS = [
|
|
9
|
+
"#3CE6AC",
|
|
10
|
+
"#FF6B6B",
|
|
11
|
+
"#4ECDC4",
|
|
12
|
+
"#FFE66D",
|
|
13
|
+
"#A78BFA",
|
|
14
|
+
"#F472B6",
|
|
15
|
+
"#34D399",
|
|
16
|
+
"#FB923C",
|
|
17
|
+
"#60A5FA",
|
|
18
|
+
"#C084FC",
|
|
19
|
+
];
|
|
20
|
+
|
|
21
|
+
// ---------------------------------------------------------------------------
|
|
22
|
+
// Types
|
|
23
|
+
// ---------------------------------------------------------------------------
|
|
24
|
+
|
|
25
|
+
interface CaptionTimelineProps {
|
|
26
|
+
pixelsPerSecond: number;
|
|
27
|
+
onSeek?: (time: number) => void;
|
|
28
|
+
}
|
|
29
|
+
|
|
30
|
+
interface DragState {
|
|
31
|
+
segId: string;
|
|
32
|
+
edge: "start" | "end";
|
|
33
|
+
originalStart: number;
|
|
34
|
+
originalEnd: number;
|
|
35
|
+
startX: number;
|
|
36
|
+
}
|
|
37
|
+
|
|
38
|
+
// ---------------------------------------------------------------------------
|
|
39
|
+
// Component
|
|
40
|
+
// ---------------------------------------------------------------------------
|
|
41
|
+
|
|
42
|
+
export const CaptionTimeline = memo(function CaptionTimeline({
|
|
43
|
+
pixelsPerSecond,
|
|
44
|
+
onSeek,
|
|
45
|
+
}: CaptionTimelineProps) {
|
|
46
|
+
const model = useCaptionStore((s) => s.model);
|
|
47
|
+
const selectedSegmentIds = useCaptionStore((s) => s.selectedSegmentIds);
|
|
48
|
+
const selectSegment = useCaptionStore((s) => s.selectSegment);
|
|
49
|
+
const updateSegmentTiming = useCaptionStore((s) => s.updateSegmentTiming);
|
|
50
|
+
const splitGroup = useCaptionStore((s) => s.splitGroup);
|
|
51
|
+
|
|
52
|
+
const dragRef = useRef<DragState | null>(null);
|
|
53
|
+
|
|
54
|
+
const handleEdgePointerDown = useCallback(
|
|
55
|
+
(
|
|
56
|
+
e: React.PointerEvent<HTMLDivElement>,
|
|
57
|
+
segId: string,
|
|
58
|
+
edge: "start" | "end",
|
|
59
|
+
originalStart: number,
|
|
60
|
+
originalEnd: number,
|
|
61
|
+
) => {
|
|
62
|
+
e.stopPropagation();
|
|
63
|
+
e.preventDefault();
|
|
64
|
+
(e.target as HTMLElement).setPointerCapture(e.pointerId);
|
|
65
|
+
dragRef.current = { segId, edge, originalStart, originalEnd, startX: e.clientX };
|
|
66
|
+
},
|
|
67
|
+
[],
|
|
68
|
+
);
|
|
69
|
+
|
|
70
|
+
const handlePointerMove = useCallback(
|
|
71
|
+
(e: React.PointerEvent<HTMLDivElement>) => {
|
|
72
|
+
const drag = dragRef.current;
|
|
73
|
+
if (!drag) return;
|
|
74
|
+
|
|
75
|
+
const delta = (e.clientX - drag.startX) / pixelsPerSecond;
|
|
76
|
+
|
|
77
|
+
if (drag.edge === "start") {
|
|
78
|
+
const newStart = Math.max(0, drag.originalStart + delta);
|
|
79
|
+
const clampedStart = Math.min(newStart, drag.originalEnd - 0.05);
|
|
80
|
+
updateSegmentTiming(drag.segId, clampedStart, drag.originalEnd);
|
|
81
|
+
} else {
|
|
82
|
+
const newEnd = Math.max(drag.originalStart + 0.05, drag.originalEnd + delta);
|
|
83
|
+
const clampedEnd = Math.max(0, newEnd);
|
|
84
|
+
updateSegmentTiming(drag.segId, drag.originalStart, clampedEnd);
|
|
85
|
+
}
|
|
86
|
+
},
|
|
87
|
+
[pixelsPerSecond, updateSegmentTiming],
|
|
88
|
+
);
|
|
89
|
+
|
|
90
|
+
const handlePointerUp = useCallback(() => {
|
|
91
|
+
dragRef.current = null;
|
|
92
|
+
}, []);
|
|
93
|
+
|
|
94
|
+
const handleBlockClick = useCallback(
|
|
95
|
+
(e: React.MouseEvent, segId: string) => {
|
|
96
|
+
e.stopPropagation();
|
|
97
|
+
selectSegment(segId, e.shiftKey);
|
|
98
|
+
},
|
|
99
|
+
[selectSegment],
|
|
100
|
+
);
|
|
101
|
+
|
|
102
|
+
const handleBlockDoubleClick = useCallback(
|
|
103
|
+
(e: React.MouseEvent, groupId: string, segId: string) => {
|
|
104
|
+
e.stopPropagation();
|
|
105
|
+
splitGroup(groupId, segId);
|
|
106
|
+
},
|
|
107
|
+
[splitGroup],
|
|
108
|
+
);
|
|
109
|
+
|
|
110
|
+
const handleTrackClick = useCallback(
|
|
111
|
+
(e: React.MouseEvent<HTMLDivElement>) => {
|
|
112
|
+
if (!onSeek) return;
|
|
113
|
+
const rect = (e.currentTarget as HTMLDivElement).getBoundingClientRect();
|
|
114
|
+
const x = e.clientX - rect.left - 32;
|
|
115
|
+
const time = Math.max(0, x / pixelsPerSecond);
|
|
116
|
+
onSeek(time);
|
|
117
|
+
},
|
|
118
|
+
[onSeek, pixelsPerSecond],
|
|
119
|
+
);
|
|
120
|
+
|
|
121
|
+
if (!model) return null;
|
|
122
|
+
|
|
123
|
+
return (
|
|
124
|
+
<div
|
|
125
|
+
className="relative select-none overflow-x-auto"
|
|
126
|
+
style={{ height: 40, minWidth: "100%" }}
|
|
127
|
+
onPointerMove={handlePointerMove}
|
|
128
|
+
onPointerUp={handlePointerUp}
|
|
129
|
+
onPointerLeave={handlePointerUp}
|
|
130
|
+
onClick={handleTrackClick}
|
|
131
|
+
>
|
|
132
|
+
{model.groupOrder.map((groupId, groupIdx) => {
|
|
133
|
+
const group = model.groups.get(groupId);
|
|
134
|
+
if (!group) return null;
|
|
135
|
+
const color = GROUP_COLORS[groupIdx % GROUP_COLORS.length];
|
|
136
|
+
|
|
137
|
+
return group.segmentIds.map((segId) => {
|
|
138
|
+
const seg = model.segments.get(segId);
|
|
139
|
+
if (!seg) return null;
|
|
140
|
+
|
|
141
|
+
const left = 32 + seg.start * pixelsPerSecond;
|
|
142
|
+
const width = Math.max((seg.end - seg.start) * pixelsPerSecond, 4);
|
|
143
|
+
const isSelected = selectedSegmentIds.has(segId);
|
|
144
|
+
|
|
145
|
+
return (
|
|
146
|
+
<div
|
|
147
|
+
key={segId}
|
|
148
|
+
className={`absolute top-1 bottom-1 rounded flex items-center overflow-hidden cursor-pointer${
|
|
149
|
+
isSelected ? " ring-1 ring-white/50 z-10" : ""
|
|
150
|
+
}`}
|
|
151
|
+
style={{
|
|
152
|
+
left,
|
|
153
|
+
width,
|
|
154
|
+
backgroundColor: color,
|
|
155
|
+
zIndex: isSelected ? 10 : 1,
|
|
156
|
+
}}
|
|
157
|
+
onClick={(e) => handleBlockClick(e, segId)}
|
|
158
|
+
onDoubleClick={(e) => handleBlockDoubleClick(e, groupId, segId)}
|
|
159
|
+
>
|
|
160
|
+
{/* Left edge drag handle */}
|
|
161
|
+
<div
|
|
162
|
+
className="absolute left-0 top-0 bottom-0 cursor-col-resize z-20"
|
|
163
|
+
style={{ width: 6 }}
|
|
164
|
+
onPointerDown={(e) => handleEdgePointerDown(e, segId, "start", seg.start, seg.end)}
|
|
165
|
+
/>
|
|
166
|
+
|
|
167
|
+
{/* Text label */}
|
|
168
|
+
<span
|
|
169
|
+
className="flex-1 truncate px-2 pointer-events-none"
|
|
170
|
+
style={{ fontSize: 9, color: "#000000", lineHeight: 1 }}
|
|
171
|
+
>
|
|
172
|
+
{seg.text}
|
|
173
|
+
</span>
|
|
174
|
+
|
|
175
|
+
{/* Right edge drag handle */}
|
|
176
|
+
<div
|
|
177
|
+
className="absolute right-0 top-0 bottom-0 cursor-col-resize z-20"
|
|
178
|
+
style={{ width: 6 }}
|
|
179
|
+
onPointerDown={(e) => handleEdgePointerDown(e, segId, "end", seg.start, seg.end)}
|
|
180
|
+
/>
|
|
181
|
+
</div>
|
|
182
|
+
);
|
|
183
|
+
});
|
|
184
|
+
})}
|
|
185
|
+
</div>
|
|
186
|
+
);
|
|
187
|
+
});
|
|
@@ -0,0 +1,26 @@
|
|
|
1
|
+
import type React from "react";
|
|
2
|
+
|
|
3
|
+
export const inputCls =
|
|
4
|
+
"w-full bg-neutral-900 border border-neutral-800 rounded px-1.5 py-0.5 text-2xs text-neutral-200 font-mono outline-none focus:border-neutral-600";
|
|
5
|
+
|
|
6
|
+
export function Section({ label, children }: { label: string; children: React.ReactNode }) {
|
|
7
|
+
return (
|
|
8
|
+
<div className="mb-3">
|
|
9
|
+
<div className="flex items-center gap-1.5 mt-2 mb-1.5">
|
|
10
|
+
<span className="text-2xs font-medium text-neutral-500 uppercase tracking-wider">
|
|
11
|
+
{label}
|
|
12
|
+
</span>
|
|
13
|
+
</div>
|
|
14
|
+
<div className="space-y-1">{children}</div>
|
|
15
|
+
</div>
|
|
16
|
+
);
|
|
17
|
+
}
|
|
18
|
+
|
|
19
|
+
export function Row({ label, children }: { label: string; children: React.ReactNode }) {
|
|
20
|
+
return (
|
|
21
|
+
<div className="flex items-center gap-2">
|
|
22
|
+
<span className="text-2xs text-neutral-600 w-14 text-right flex-shrink-0">{label}</span>
|
|
23
|
+
<div className="flex-1 min-w-0">{children}</div>
|
|
24
|
+
</div>
|
|
25
|
+
);
|
|
26
|
+
}
|