@hyperframes/studio 0.5.0-alpha.1 → 0.5.0-alpha.11
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/dist/assets/index-Bl4Deziq.js +105 -0
- package/dist/assets/index-KioPDrX6.css +1 -0
- package/dist/index.html +2 -2
- package/package.json +4 -4
- package/src/App.tsx +494 -185
- package/src/captions/components/CaptionOverlay.tsx +2 -1
- package/src/captions/keyboard.test.ts +38 -0
- package/src/captions/keyboard.ts +8 -0
- package/src/components/LintModal.tsx +3 -4
- package/src/components/editor/DomEditOverlay.tsx +41 -6
- package/src/components/editor/PropertyPanel.tsx +7 -3
- package/src/components/editor/domEditing.test.ts +110 -0
- package/src/components/editor/domEditing.ts +33 -4
- package/src/components/nle/NLELayout.tsx +43 -8
- package/src/components/nle/NLEPreview.tsx +5 -1
- package/src/components/sidebar/AssetsTab.tsx +3 -4
- package/src/components/sidebar/LeftSidebar.tsx +64 -36
- package/src/hooks/usePersistentEditHistory.test.ts +255 -0
- package/src/hooks/usePersistentEditHistory.ts +336 -0
- package/src/icons/SystemIcons.tsx +4 -0
- package/src/player/components/AudioWaveform.tsx +44 -29
- package/src/player/components/CompositionThumbnail.test.ts +19 -0
- package/src/player/components/CompositionThumbnail.tsx +42 -10
- package/src/player/components/EditModal.tsx +5 -20
- package/src/player/components/PlayerControls.tsx +117 -49
- package/src/player/components/Timeline.test.ts +84 -0
- package/src/player/components/Timeline.tsx +198 -27
- package/src/player/components/timelineEditing.test.ts +2 -2
- package/src/player/components/timelineEditing.ts +1 -1
- package/src/player/components/timelineTheme.ts +3 -3
- package/src/player/components/timelineZoom.test.ts +21 -0
- package/src/player/components/timelineZoom.ts +11 -0
- package/src/player/hooks/useTimelinePlayer.test.ts +138 -0
- package/src/player/hooks/useTimelinePlayer.ts +354 -43
- package/src/player/lib/time.test.ts +29 -1
- package/src/player/lib/time.ts +26 -0
- package/src/player/store/playerStore.test.ts +11 -1
- package/src/player/store/playerStore.ts +5 -1
- package/src/styles/studio.css +9 -0
- package/src/utils/clipboard.test.ts +88 -0
- package/src/utils/clipboard.ts +57 -0
- package/src/utils/editHistory.test.ts +244 -0
- package/src/utils/editHistory.ts +218 -0
- package/src/utils/editHistoryStorage.test.ts +37 -0
- package/src/utils/editHistoryStorage.ts +99 -0
- package/src/utils/frameCapture.test.ts +26 -0
- package/src/utils/frameCapture.ts +38 -0
- package/src/utils/studioFileHistory.test.ts +156 -0
- package/src/utils/studioFileHistory.ts +61 -0
- package/src/utils/timelineAssetDrop.test.ts +64 -4
- package/src/utils/timelineAssetDrop.ts +27 -5
- package/dist/assets/index-Bi30tos-.js +0 -105
- package/dist/assets/index-Dm9VsShj.css +0 -1
|
@@ -1,6 +1,7 @@
|
|
|
1
1
|
import { memo, useState, useCallback, useRef } from "react";
|
|
2
2
|
import { useCaptionStore } from "../store";
|
|
3
3
|
import { useMountEffect } from "../../hooks/useMountEffect";
|
|
4
|
+
import { shouldHandleCaptionNudgeKey } from "../keyboard";
|
|
4
5
|
|
|
5
6
|
interface CaptionOverlayProps {
|
|
6
7
|
iframeRef: React.RefObject<HTMLIFrameElement | null>;
|
|
@@ -329,7 +330,7 @@ export const CaptionOverlay = memo(function CaptionOverlay({ iframeRef }: Captio
|
|
|
329
330
|
const { selectedSegmentIds: sel, model: m } = useCaptionStore.getState();
|
|
330
331
|
if (sel.size === 0 || !m) return;
|
|
331
332
|
const arrow = e.key;
|
|
332
|
-
if (!
|
|
333
|
+
if (!shouldHandleCaptionNudgeKey(e)) return;
|
|
333
334
|
|
|
334
335
|
e.preventDefault();
|
|
335
336
|
const step = e.shiftKey ? 10 : 1;
|
|
@@ -0,0 +1,38 @@
|
|
|
1
|
+
import { describe, expect, it } from "vitest";
|
|
2
|
+
import { shouldHandleCaptionNudgeKey } from "./keyboard";
|
|
3
|
+
|
|
4
|
+
function mockKeyboardEvent(
|
|
5
|
+
key: string,
|
|
6
|
+
overrides: Partial<Pick<KeyboardEvent, "altKey" | "ctrlKey" | "metaKey">> = {},
|
|
7
|
+
): Pick<KeyboardEvent, "altKey" | "ctrlKey" | "metaKey" | "key"> {
|
|
8
|
+
return {
|
|
9
|
+
altKey: false,
|
|
10
|
+
ctrlKey: false,
|
|
11
|
+
metaKey: false,
|
|
12
|
+
key,
|
|
13
|
+
...overrides,
|
|
14
|
+
};
|
|
15
|
+
}
|
|
16
|
+
|
|
17
|
+
describe("shouldHandleCaptionNudgeKey", () => {
|
|
18
|
+
it("handles plain and Shift-modified arrow keys for caption nudging", () => {
|
|
19
|
+
expect(shouldHandleCaptionNudgeKey(mockKeyboardEvent("ArrowLeft"))).toBe(true);
|
|
20
|
+
expect(shouldHandleCaptionNudgeKey(mockKeyboardEvent("ArrowRight"))).toBe(true);
|
|
21
|
+
});
|
|
22
|
+
|
|
23
|
+
it("ignores browser and app shortcut chords", () => {
|
|
24
|
+
expect(shouldHandleCaptionNudgeKey(mockKeyboardEvent("ArrowLeft", { altKey: true }))).toBe(
|
|
25
|
+
false,
|
|
26
|
+
);
|
|
27
|
+
expect(shouldHandleCaptionNudgeKey(mockKeyboardEvent("ArrowRight", { ctrlKey: true }))).toBe(
|
|
28
|
+
false,
|
|
29
|
+
);
|
|
30
|
+
expect(shouldHandleCaptionNudgeKey(mockKeyboardEvent("ArrowRight", { metaKey: true }))).toBe(
|
|
31
|
+
false,
|
|
32
|
+
);
|
|
33
|
+
});
|
|
34
|
+
|
|
35
|
+
it("ignores non-arrow keys", () => {
|
|
36
|
+
expect(shouldHandleCaptionNudgeKey(mockKeyboardEvent("KeyL"))).toBe(false);
|
|
37
|
+
});
|
|
38
|
+
});
|
|
@@ -0,0 +1,8 @@
|
|
|
1
|
+
const CAPTION_NUDGE_KEYS = new Set(["ArrowUp", "ArrowDown", "ArrowLeft", "ArrowRight"]);
|
|
2
|
+
|
|
3
|
+
type CaptionNudgeKeyEvent = Pick<KeyboardEvent, "altKey" | "ctrlKey" | "metaKey" | "key">;
|
|
4
|
+
|
|
5
|
+
export function shouldHandleCaptionNudgeKey(event: CaptionNudgeKeyEvent): boolean {
|
|
6
|
+
if (event.metaKey || event.ctrlKey || event.altKey) return false;
|
|
7
|
+
return CAPTION_NUDGE_KEYS.has(event.key);
|
|
8
|
+
}
|
|
@@ -1,5 +1,6 @@
|
|
|
1
1
|
import { useState } from "react";
|
|
2
2
|
import { XIcon, WarningIcon, CheckCircleIcon, CaretRightIcon } from "@phosphor-icons/react";
|
|
3
|
+
import { copyTextToClipboard } from "../utils/clipboard";
|
|
3
4
|
|
|
4
5
|
export interface LintFinding {
|
|
5
6
|
severity: "error" | "warning";
|
|
@@ -30,12 +31,10 @@ export function LintModal({
|
|
|
30
31
|
return line;
|
|
31
32
|
});
|
|
32
33
|
const text = `Fix these HyperFrames lint issues for project "${projectId}":\n\nProject path: ${window.location.href}\n\n${lines.join("\n\n")}`;
|
|
33
|
-
|
|
34
|
-
|
|
34
|
+
const copiedText = await copyTextToClipboard(text);
|
|
35
|
+
if (copiedText) {
|
|
35
36
|
setCopied(true);
|
|
36
37
|
setTimeout(() => setCopied(false), 2000);
|
|
37
|
-
} catch {
|
|
38
|
-
// ignore
|
|
39
38
|
}
|
|
40
39
|
};
|
|
41
40
|
|
|
@@ -14,7 +14,11 @@ interface OverlayRect {
|
|
|
14
14
|
interface DomEditOverlayProps {
|
|
15
15
|
iframeRef: RefObject<HTMLIFrameElement | null>;
|
|
16
16
|
selection: DomEditSelection | null;
|
|
17
|
-
|
|
17
|
+
allowCanvasMovement?: boolean;
|
|
18
|
+
onCanvasMouseDown: (
|
|
19
|
+
event: React.MouseEvent<HTMLDivElement>,
|
|
20
|
+
options?: { preferClipAncestor?: boolean },
|
|
21
|
+
) => void;
|
|
18
22
|
onCanvasDoubleClick: (event: React.MouseEvent<HTMLDivElement>) => void;
|
|
19
23
|
onSelectedDoubleClick: () => void;
|
|
20
24
|
onBlockedMove: (selection: DomEditSelection) => void;
|
|
@@ -85,10 +89,21 @@ function selectionCacheKey(
|
|
|
85
89
|
].join("|");
|
|
86
90
|
}
|
|
87
91
|
|
|
92
|
+
function restoreInlineStyle(
|
|
93
|
+
element: HTMLElement,
|
|
94
|
+
property: "left" | "top" | "width" | "height",
|
|
95
|
+
value: string,
|
|
96
|
+
) {
|
|
97
|
+
if (value) element.style.setProperty(property, value);
|
|
98
|
+
else element.style.removeProperty(property);
|
|
99
|
+
}
|
|
100
|
+
|
|
88
101
|
interface GestureState {
|
|
89
102
|
kind: GestureKind;
|
|
90
103
|
startX: number;
|
|
91
104
|
startY: number;
|
|
105
|
+
initialStyleLeft: string;
|
|
106
|
+
initialStyleTop: string;
|
|
92
107
|
originLeft: number;
|
|
93
108
|
originTop: number;
|
|
94
109
|
originWidth: number;
|
|
@@ -111,6 +126,7 @@ interface BlockedMoveState {
|
|
|
111
126
|
export const DomEditOverlay = memo(function DomEditOverlay({
|
|
112
127
|
iframeRef,
|
|
113
128
|
selection,
|
|
129
|
+
allowCanvasMovement = true,
|
|
114
130
|
onCanvasMouseDown,
|
|
115
131
|
onCanvasDoubleClick,
|
|
116
132
|
onSelectedDoubleClick,
|
|
@@ -226,6 +242,8 @@ export const DomEditOverlay = memo(function DomEditOverlay({
|
|
|
226
242
|
kind,
|
|
227
243
|
startX: e.clientX,
|
|
228
244
|
startY: e.clientY,
|
|
245
|
+
initialStyleLeft: sel.element.style.left,
|
|
246
|
+
initialStyleTop: sel.element.style.top,
|
|
229
247
|
originLeft: rect.left,
|
|
230
248
|
originTop: rect.top,
|
|
231
249
|
originWidth: rect.width,
|
|
@@ -277,9 +295,10 @@ export const DomEditOverlay = memo(function DomEditOverlay({
|
|
|
277
295
|
}
|
|
278
296
|
};
|
|
279
297
|
|
|
280
|
-
const onPointerUp = () => {
|
|
298
|
+
const onPointerUp = (e: React.PointerEvent<HTMLDivElement>) => {
|
|
281
299
|
const g = gestureRef.current;
|
|
282
300
|
const sel = selectionRef.current;
|
|
301
|
+
const box = boxRef.current;
|
|
283
302
|
blockedMoveRef.current = null;
|
|
284
303
|
if (!g || !sel) {
|
|
285
304
|
gestureRef.current = null;
|
|
@@ -290,6 +309,21 @@ export const DomEditOverlay = memo(function DomEditOverlay({
|
|
|
290
309
|
gestureRef.current = null;
|
|
291
310
|
rafPausedRef.current = false;
|
|
292
311
|
|
|
312
|
+
const movedDistance = Math.hypot(e.clientX - g.startX, e.clientY - g.startY);
|
|
313
|
+
if (g.kind === "drag" && movedDistance < BLOCKED_MOVE_THRESHOLD_PX) {
|
|
314
|
+
restoreInlineStyle(sel.element, "left", g.initialStyleLeft);
|
|
315
|
+
restoreInlineStyle(sel.element, "top", g.initialStyleTop);
|
|
316
|
+
if (box) {
|
|
317
|
+
box.style.left = `${g.originLeft}px`;
|
|
318
|
+
box.style.top = `${g.originTop}px`;
|
|
319
|
+
}
|
|
320
|
+
suppressNextBoxClickRef.current = true;
|
|
321
|
+
onCanvasMouseDown(e as unknown as React.MouseEvent<HTMLDivElement>, {
|
|
322
|
+
preferClipAncestor: false,
|
|
323
|
+
});
|
|
324
|
+
return;
|
|
325
|
+
}
|
|
326
|
+
|
|
293
327
|
if (g.kind === "drag") {
|
|
294
328
|
const finalLeft = Number.parseFloat(sel.element.style.left) || g.actualLeft;
|
|
295
329
|
const finalTop = Number.parseFloat(sel.element.style.top) || g.actualTop;
|
|
@@ -320,7 +354,7 @@ export const DomEditOverlay = memo(function DomEditOverlay({
|
|
|
320
354
|
const handleOverlayMouseDown = (event: React.MouseEvent<HTMLDivElement>) => {
|
|
321
355
|
const target = event.target as HTMLElement | null;
|
|
322
356
|
if (target?.closest('[data-dom-edit-selection-box="true"]')) return;
|
|
323
|
-
onCanvasMouseDown(event);
|
|
357
|
+
onCanvasMouseDown(event, { preferClipAncestor: false });
|
|
324
358
|
};
|
|
325
359
|
|
|
326
360
|
const handleOverlayDoubleClick = (event: React.MouseEvent<HTMLDivElement>) => {
|
|
@@ -339,7 +373,7 @@ export const DomEditOverlay = memo(function DomEditOverlay({
|
|
|
339
373
|
event.stopPropagation();
|
|
340
374
|
return;
|
|
341
375
|
}
|
|
342
|
-
onCanvasMouseDown(event);
|
|
376
|
+
onCanvasMouseDown(event, { preferClipAncestor: false });
|
|
343
377
|
};
|
|
344
378
|
|
|
345
379
|
const clearPointerState = () => {
|
|
@@ -371,9 +405,10 @@ export const DomEditOverlay = memo(function DomEditOverlay({
|
|
|
371
405
|
top: overlayRect.top,
|
|
372
406
|
width: overlayRect.width,
|
|
373
407
|
height: overlayRect.height,
|
|
374
|
-
cursor: selection.capabilities.canMove ? "move" : "default",
|
|
408
|
+
cursor: allowCanvasMovement && selection.capabilities.canMove ? "move" : "default",
|
|
375
409
|
}}
|
|
376
410
|
onPointerDown={(e) => {
|
|
411
|
+
if (!allowCanvasMovement) return;
|
|
377
412
|
if (selection.capabilities.canMove) {
|
|
378
413
|
startGesture("drag", e);
|
|
379
414
|
return;
|
|
@@ -392,7 +427,7 @@ export const DomEditOverlay = memo(function DomEditOverlay({
|
|
|
392
427
|
onDoubleClick={onSelectedDoubleClick}
|
|
393
428
|
>
|
|
394
429
|
{/* Resize handle — bottom-right corner */}
|
|
395
|
-
{selection.capabilities.canResize && (
|
|
430
|
+
{allowCanvasMovement && selection.capabilities.canResize && (
|
|
396
431
|
<div
|
|
397
432
|
className="absolute -right-1.5 -bottom-1.5 w-3 h-3 rounded-sm bg-studio-accent border border-studio-accent/60"
|
|
398
433
|
style={{ cursor: "se-resize", touchAction: "none" }}
|
|
@@ -64,6 +64,7 @@ interface PropertyPanelProps {
|
|
|
64
64
|
onImportAssets?: (files: FileList) => Promise<string[]>;
|
|
65
65
|
fontAssets?: ImportedFontAsset[];
|
|
66
66
|
onImportFonts?: (files: FileList | File[]) => Promise<ImportedFontAsset[]>;
|
|
67
|
+
allowLayoutDetach?: boolean;
|
|
67
68
|
}
|
|
68
69
|
|
|
69
70
|
const FIELD =
|
|
@@ -1984,6 +1985,7 @@ export const PropertyPanel = memo(function PropertyPanel({
|
|
|
1984
1985
|
onImportAssets,
|
|
1985
1986
|
fontAssets = [],
|
|
1986
1987
|
onImportFonts,
|
|
1988
|
+
allowLayoutDetach = true,
|
|
1987
1989
|
}: PropertyPanelProps) {
|
|
1988
1990
|
const styles = element?.computedStyles ?? EMPTY_STYLES;
|
|
1989
1991
|
const selectionColors = useMemo(() => collectSelectionColors(styles), [styles]);
|
|
@@ -2020,7 +2022,7 @@ export const PropertyPanel = memo(function PropertyPanel({
|
|
|
2020
2022
|
<p className="text-sm font-medium text-neutral-200">Select an element in the preview.</p>
|
|
2021
2023
|
<p className="mt-2 max-w-[260px] text-xs leading-5 text-neutral-500">
|
|
2022
2024
|
The inspector is tuned for direct DOM edits with safer geometry controls, color picking,
|
|
2023
|
-
and cleaner
|
|
2025
|
+
and cleaner grouped layer controls.
|
|
2024
2026
|
</p>
|
|
2025
2027
|
</div>
|
|
2026
2028
|
);
|
|
@@ -2036,7 +2038,9 @@ export const PropertyPanel = memo(function PropertyPanel({
|
|
|
2036
2038
|
const sourceLabel = element.id ? `#${element.id}` : element.selector;
|
|
2037
2039
|
const showEditableSections = element.capabilities.canEditStyles;
|
|
2038
2040
|
const disabledMoveReason =
|
|
2039
|
-
|
|
2041
|
+
allowLayoutDetach &&
|
|
2042
|
+
element.capabilities.reasonIfDisabled &&
|
|
2043
|
+
!element.capabilities.canDetachFromLayout
|
|
2040
2044
|
? element.capabilities.reasonIfDisabled
|
|
2041
2045
|
: null;
|
|
2042
2046
|
|
|
@@ -2131,7 +2135,7 @@ export const PropertyPanel = memo(function PropertyPanel({
|
|
|
2131
2135
|
</button>
|
|
2132
2136
|
</div>
|
|
2133
2137
|
)}
|
|
2134
|
-
{element.capabilities.canDetachFromLayout && (
|
|
2138
|
+
{allowLayoutDetach && element.capabilities.canDetachFromLayout && (
|
|
2135
2139
|
<div className="mt-4 flex min-w-0 flex-wrap items-center justify-between gap-3 border-l border-amber-500/40 pl-3">
|
|
2136
2140
|
<div className="min-w-0 text-[11px] leading-5 text-neutral-400">
|
|
2137
2141
|
<div className="font-medium text-neutral-200">
|
|
@@ -108,6 +108,61 @@ describe("resolveDomEditCapabilities", () => {
|
|
|
108
108
|
});
|
|
109
109
|
});
|
|
110
110
|
|
|
111
|
+
it("treats identity transforms left behind by animation libraries as movable", () => {
|
|
112
|
+
expect(
|
|
113
|
+
resolveDomEditCapabilities({
|
|
114
|
+
selector: "#card",
|
|
115
|
+
inlineStyles: {
|
|
116
|
+
left: "120px",
|
|
117
|
+
top: "80px",
|
|
118
|
+
width: "240px",
|
|
119
|
+
height: "140px",
|
|
120
|
+
},
|
|
121
|
+
computedStyles: {
|
|
122
|
+
position: "absolute",
|
|
123
|
+
left: "120px",
|
|
124
|
+
top: "80px",
|
|
125
|
+
width: "240px",
|
|
126
|
+
height: "140px",
|
|
127
|
+
transform: "matrix(1, 0, 0, 1, 0, 0)",
|
|
128
|
+
},
|
|
129
|
+
isCompositionHost: false,
|
|
130
|
+
isMasterView: false,
|
|
131
|
+
}),
|
|
132
|
+
).toMatchObject({
|
|
133
|
+
canMove: true,
|
|
134
|
+
canResize: true,
|
|
135
|
+
canDetachFromLayout: false,
|
|
136
|
+
});
|
|
137
|
+
});
|
|
138
|
+
|
|
139
|
+
it("treats identity matrix3d transforms as movable", () => {
|
|
140
|
+
expect(
|
|
141
|
+
resolveDomEditCapabilities({
|
|
142
|
+
selector: "#card",
|
|
143
|
+
inlineStyles: {
|
|
144
|
+
left: "120px",
|
|
145
|
+
top: "80px",
|
|
146
|
+
width: "240px",
|
|
147
|
+
height: "140px",
|
|
148
|
+
},
|
|
149
|
+
computedStyles: {
|
|
150
|
+
position: "absolute",
|
|
151
|
+
left: "120px",
|
|
152
|
+
top: "80px",
|
|
153
|
+
width: "240px",
|
|
154
|
+
height: "140px",
|
|
155
|
+
transform: "matrix3d(1, 0, 0, 0, 0, 1, 0, 0, 0, 0, 1, 0, 0, 0, 0, 1)",
|
|
156
|
+
},
|
|
157
|
+
isCompositionHost: false,
|
|
158
|
+
isMasterView: false,
|
|
159
|
+
}),
|
|
160
|
+
).toMatchObject({
|
|
161
|
+
canMove: true,
|
|
162
|
+
canResize: true,
|
|
163
|
+
});
|
|
164
|
+
});
|
|
165
|
+
|
|
111
166
|
it("allows imported absolute media to resize from computed px geometry", () => {
|
|
112
167
|
expect(
|
|
113
168
|
resolveDomEditCapabilities({
|
|
@@ -228,6 +283,24 @@ describe("resolveDomEditSelection", () => {
|
|
|
228
283
|
expect(selection?.selector).toBe("#card");
|
|
229
284
|
});
|
|
230
285
|
|
|
286
|
+
it("can resolve the exact child when clip-ancestor preference is disabled", () => {
|
|
287
|
+
const document = createDocument(`
|
|
288
|
+
<section id="card" class="clip" style="left: 10px; top: 20px; width: 200px; height: 100px; position: absolute;">
|
|
289
|
+
<p id="copy">Hello</p>
|
|
290
|
+
</section>
|
|
291
|
+
`);
|
|
292
|
+
|
|
293
|
+
const child = document.getElementById("copy") as HTMLElement;
|
|
294
|
+
const selection = resolveDomEditSelection(child, {
|
|
295
|
+
activeCompositionPath: null,
|
|
296
|
+
isMasterView: false,
|
|
297
|
+
preferClipAncestor: false,
|
|
298
|
+
});
|
|
299
|
+
|
|
300
|
+
expect(selection?.id).toBe("copy");
|
|
301
|
+
expect(selection?.selector).toBe("#copy");
|
|
302
|
+
});
|
|
303
|
+
|
|
231
304
|
it("collects simple child text blocks as separate editable fields", () => {
|
|
232
305
|
const document = createDocument(`
|
|
233
306
|
<section id="card" class="clip" style="left: 10px; top: 20px; width: 200px; height: 100px; position: absolute;">
|
|
@@ -394,6 +467,43 @@ describe("patch builders and prompt builder", () => {
|
|
|
394
467
|
expect(prompt).toContain("Do not modify other elements' data-* attributes or positioning.");
|
|
395
468
|
});
|
|
396
469
|
|
|
470
|
+
it("uses an absolute source path in copied agent prompts when provided", () => {
|
|
471
|
+
const selection = {
|
|
472
|
+
element: {} as HTMLElement,
|
|
473
|
+
id: "editable-card",
|
|
474
|
+
selector: "#editable-card",
|
|
475
|
+
selectorIndex: undefined,
|
|
476
|
+
sourceFile: "index.html",
|
|
477
|
+
compositionPath: "index.html",
|
|
478
|
+
compositionSrc: undefined,
|
|
479
|
+
isCompositionHost: false,
|
|
480
|
+
label: "Drag me first",
|
|
481
|
+
tagName: "div",
|
|
482
|
+
boundingBox: { x: 108, y: 112, width: 380, height: 196 },
|
|
483
|
+
textContent: "Drag me first",
|
|
484
|
+
dataAttributes: {},
|
|
485
|
+
inlineStyles: {},
|
|
486
|
+
computedStyles: {},
|
|
487
|
+
textFields: [],
|
|
488
|
+
capabilities: {
|
|
489
|
+
canSelect: true,
|
|
490
|
+
canEditStyles: true,
|
|
491
|
+
canMove: true,
|
|
492
|
+
canResize: true,
|
|
493
|
+
canDetachFromLayout: false,
|
|
494
|
+
},
|
|
495
|
+
} satisfies DomEditSelection;
|
|
496
|
+
|
|
497
|
+
const prompt = buildElementAgentPrompt({
|
|
498
|
+
selection,
|
|
499
|
+
currentTime: 1.25,
|
|
500
|
+
sourceFilePath: "/tmp/hf-studio-project/index.html",
|
|
501
|
+
});
|
|
502
|
+
|
|
503
|
+
expect(prompt).toContain("Source file: /tmp/hf-studio-project/index.html");
|
|
504
|
+
expect(prompt).not.toContain("Source file: index.html");
|
|
505
|
+
});
|
|
506
|
+
|
|
397
507
|
it("serializes child text fields back into HTML", () => {
|
|
398
508
|
expect(
|
|
399
509
|
serializeDomEditTextFields([
|
|
@@ -93,6 +93,32 @@ function parsePx(value: string | undefined): number | null {
|
|
|
93
93
|
return Number.isFinite(parsed) ? parsed : null;
|
|
94
94
|
}
|
|
95
95
|
|
|
96
|
+
function isIdentityTransform(value: string | undefined): boolean {
|
|
97
|
+
const transform = (value ?? "none").trim();
|
|
98
|
+
if (!transform || transform === "none") return true;
|
|
99
|
+
|
|
100
|
+
const matrix = transform.match(/^matrix\(([^)]+)\)$/i);
|
|
101
|
+
if (matrix) {
|
|
102
|
+
const values = matrix[1].split(",").map((part) => Number.parseFloat(part.trim()));
|
|
103
|
+
if (values.length !== 6 || values.some((part) => !Number.isFinite(part))) return false;
|
|
104
|
+
return (
|
|
105
|
+
Math.abs(values[0] - 1) < 0.0001 &&
|
|
106
|
+
Math.abs(values[1]) < 0.0001 &&
|
|
107
|
+
Math.abs(values[2]) < 0.0001 &&
|
|
108
|
+
Math.abs(values[3] - 1) < 0.0001 &&
|
|
109
|
+
Math.abs(values[4]) < 0.0001 &&
|
|
110
|
+
Math.abs(values[5]) < 0.0001
|
|
111
|
+
);
|
|
112
|
+
}
|
|
113
|
+
|
|
114
|
+
const matrix3d = transform.match(/^matrix3d\(([^)]+)\)$/i);
|
|
115
|
+
if (!matrix3d) return false;
|
|
116
|
+
const values = matrix3d[1].split(",").map((part) => Number.parseFloat(part.trim()));
|
|
117
|
+
if (values.length !== 16 || values.some((part) => !Number.isFinite(part))) return false;
|
|
118
|
+
const identity = [1, 0, 0, 0, 0, 1, 0, 0, 0, 0, 1, 0, 0, 0, 0, 1];
|
|
119
|
+
return values.every((part, index) => Math.abs(part - identity[index]) < 0.0001);
|
|
120
|
+
}
|
|
121
|
+
|
|
96
122
|
function isClipClassName(className: string | undefined): boolean {
|
|
97
123
|
return Boolean(className?.split(/\s+/).includes("clip"));
|
|
98
124
|
}
|
|
@@ -426,13 +452,13 @@ export function resolveDomEditCapabilities(args: {
|
|
|
426
452
|
const top = parsePx(args.inlineStyles.top) ?? parsePx(args.computedStyles.top);
|
|
427
453
|
const width = parsePx(args.inlineStyles.width) ?? parsePx(args.computedStyles.width);
|
|
428
454
|
const height = parsePx(args.inlineStyles.height) ?? parsePx(args.computedStyles.height);
|
|
429
|
-
const
|
|
455
|
+
const hasTransformDrivenGeometry = !isIdentityTransform(args.computedStyles.transform);
|
|
430
456
|
|
|
431
457
|
const canMove =
|
|
432
458
|
(position === "absolute" || position === "fixed") &&
|
|
433
459
|
left != null &&
|
|
434
460
|
top != null &&
|
|
435
|
-
|
|
461
|
+
!hasTransformDrivenGeometry;
|
|
436
462
|
|
|
437
463
|
const canResize = canMove && (width != null || height != null);
|
|
438
464
|
const isBlockishLayer =
|
|
@@ -442,7 +468,7 @@ export function resolveDomEditCapabilities(args: {
|
|
|
442
468
|
isBlockishDisplay(args.computedStyles.display);
|
|
443
469
|
const canDetachFromLayout =
|
|
444
470
|
!canMove &&
|
|
445
|
-
|
|
471
|
+
!hasTransformDrivenGeometry &&
|
|
446
472
|
isBlockishLayer &&
|
|
447
473
|
(!isInlineTextTag(args.tagName) || isClipClassName(args.className));
|
|
448
474
|
const reasonIfDisabled = !canMove
|
|
@@ -671,12 +697,15 @@ export function buildElementAgentPrompt({
|
|
|
671
697
|
currentTime,
|
|
672
698
|
tagSnippet,
|
|
673
699
|
userInstruction,
|
|
700
|
+
sourceFilePath,
|
|
674
701
|
}: {
|
|
675
702
|
selection: DomEditSelection;
|
|
676
703
|
currentTime: number;
|
|
677
704
|
tagSnippet?: string;
|
|
678
705
|
userInstruction?: string;
|
|
706
|
+
sourceFilePath?: string;
|
|
679
707
|
}): string {
|
|
708
|
+
const displayedSourceFile = sourceFilePath?.trim() || selection.sourceFile;
|
|
680
709
|
const lines = [
|
|
681
710
|
"## HyperFrames element edit request v1",
|
|
682
711
|
"Schema version: 1",
|
|
@@ -685,7 +714,7 @@ export function buildElementAgentPrompt({
|
|
|
685
714
|
"",
|
|
686
715
|
`Composition: ${selection.compositionPath}`,
|
|
687
716
|
`Playback time: ${formatTime(currentTime)}`,
|
|
688
|
-
`Source file: ${
|
|
717
|
+
`Source file: ${displayedSourceFile}`,
|
|
689
718
|
`DOM id: ${selection.id ?? "(none)"}`,
|
|
690
719
|
`Selector: ${selection.selector ?? "(none)"}`,
|
|
691
720
|
`Selector index: ${selection.selectorIndex ?? 0}`,
|
|
@@ -5,6 +5,10 @@ import type { TimelineElement } from "../../player";
|
|
|
5
5
|
import type { BlockedTimelineEditIntent } from "../../player/components/timelineEditing";
|
|
6
6
|
import { NLEPreview } from "./NLEPreview";
|
|
7
7
|
import { CompositionBreadcrumb, type CompositionLevel } from "./CompositionBreadcrumb";
|
|
8
|
+
import {
|
|
9
|
+
TIMELINE_TOGGLE_SHORTCUT_LABEL,
|
|
10
|
+
getTimelineToggleTitle,
|
|
11
|
+
} from "../../utils/timelineDiscovery";
|
|
8
12
|
|
|
9
13
|
interface NLELayoutProps {
|
|
10
14
|
projectId: string;
|
|
@@ -198,6 +202,7 @@ export const NLELayout = memo(function NLELayout({
|
|
|
198
202
|
|
|
199
203
|
// Resizable timeline height
|
|
200
204
|
const [timelineH, setTimelineH] = useState(DEFAULT_TIMELINE_H);
|
|
205
|
+
const isTimelineVisible = timelineVisible ?? true;
|
|
201
206
|
const isDragging = useRef(false);
|
|
202
207
|
const containerRef = useRef<HTMLDivElement>(null);
|
|
203
208
|
|
|
@@ -371,16 +376,11 @@ export const NLELayout = memo(function NLELayout({
|
|
|
371
376
|
onNavigate={handleNavigateComposition}
|
|
372
377
|
/>
|
|
373
378
|
)}
|
|
374
|
-
<PlayerControls
|
|
375
|
-
onTogglePlay={togglePlay}
|
|
376
|
-
onSeek={seek}
|
|
377
|
-
timelineVisible={timelineVisible ?? true}
|
|
378
|
-
onToggleTimeline={onToggleTimeline}
|
|
379
|
-
/>
|
|
379
|
+
<PlayerControls onTogglePlay={togglePlay} onSeek={seek} />
|
|
380
380
|
</div>
|
|
381
381
|
</div>
|
|
382
382
|
|
|
383
|
-
{
|
|
383
|
+
{isTimelineVisible ? (
|
|
384
384
|
<>
|
|
385
385
|
{/* Resize divider */}
|
|
386
386
|
<div
|
|
@@ -422,7 +422,42 @@ export const NLELayout = memo(function NLELayout({
|
|
|
422
422
|
{timelineFooter && <div className="flex-shrink-0">{timelineFooter}</div>}
|
|
423
423
|
</div>
|
|
424
424
|
</>
|
|
425
|
-
)
|
|
425
|
+
) : onToggleTimeline ? (
|
|
426
|
+
<div className="flex-shrink-0 border-t border-neutral-800/50 bg-neutral-950/96">
|
|
427
|
+
<div className="flex h-10 items-center justify-between px-3">
|
|
428
|
+
<div className="text-[10px] font-medium uppercase tracking-[0.16em] text-neutral-500">
|
|
429
|
+
Timeline
|
|
430
|
+
</div>
|
|
431
|
+
<button
|
|
432
|
+
type="button"
|
|
433
|
+
onClick={onToggleTimeline}
|
|
434
|
+
className="flex h-7 items-center gap-1.5 rounded-md border border-neutral-800 px-2.5 text-[11px] font-medium text-neutral-300 transition-colors hover:border-neutral-700 hover:bg-neutral-900 hover:text-neutral-100"
|
|
435
|
+
title={getTimelineToggleTitle(false)}
|
|
436
|
+
aria-label="Show timeline editor"
|
|
437
|
+
>
|
|
438
|
+
<svg
|
|
439
|
+
width="13"
|
|
440
|
+
height="13"
|
|
441
|
+
viewBox="0 0 24 24"
|
|
442
|
+
fill="none"
|
|
443
|
+
stroke="currentColor"
|
|
444
|
+
strokeWidth="1.7"
|
|
445
|
+
strokeLinecap="round"
|
|
446
|
+
strokeLinejoin="round"
|
|
447
|
+
aria-hidden="true"
|
|
448
|
+
>
|
|
449
|
+
<rect x="3" y="13" width="18" height="8" rx="1" />
|
|
450
|
+
<path d="M7 9h10" />
|
|
451
|
+
<path d="M8 5h8" />
|
|
452
|
+
</svg>
|
|
453
|
+
<span>Show</span>
|
|
454
|
+
<span className="hidden rounded bg-white/5 px-1 py-0.5 font-mono text-[9px] text-neutral-500 sm:inline">
|
|
455
|
+
{TIMELINE_TOGGLE_SHORTCUT_LABEL}
|
|
456
|
+
</span>
|
|
457
|
+
</button>
|
|
458
|
+
</div>
|
|
459
|
+
</div>
|
|
460
|
+
) : null}
|
|
426
461
|
</div>
|
|
427
462
|
);
|
|
428
463
|
});
|
|
@@ -67,7 +67,11 @@ export const NLEPreview = memo(function NLEPreview({
|
|
|
67
67
|
|
|
68
68
|
return (
|
|
69
69
|
<div className="flex flex-col h-full min-h-0">
|
|
70
|
-
<div
|
|
70
|
+
<div
|
|
71
|
+
className="relative flex-1 flex items-center justify-center p-2 overflow-hidden min-h-0 outline-none focus:ring-1 focus:ring-studio-accent/40"
|
|
72
|
+
tabIndex={0}
|
|
73
|
+
aria-label="Composition preview"
|
|
74
|
+
>
|
|
71
75
|
{retiringKey && (
|
|
72
76
|
<Player
|
|
73
77
|
key={retiringKey}
|
|
@@ -2,6 +2,7 @@ import { memo, useState, useCallback, useRef } from "react";
|
|
|
2
2
|
import { VideoFrameThumbnail } from "../ui/VideoFrameThumbnail";
|
|
3
3
|
import { MEDIA_EXT, IMAGE_EXT, VIDEO_EXT, AUDIO_EXT } from "../../utils/mediaTypes";
|
|
4
4
|
import { TIMELINE_ASSET_MIME } from "../../utils/timelineAssetDrop";
|
|
5
|
+
import { copyTextToClipboard } from "../../utils/clipboard";
|
|
5
6
|
|
|
6
7
|
interface AssetsTabProps {
|
|
7
8
|
projectId: string;
|
|
@@ -298,12 +299,10 @@ export const AssetsTab = memo(function AssetsTab({
|
|
|
298
299
|
);
|
|
299
300
|
|
|
300
301
|
const handleCopyPath = useCallback(async (path: string) => {
|
|
301
|
-
|
|
302
|
-
|
|
302
|
+
const copied = await copyTextToClipboard(path);
|
|
303
|
+
if (copied) {
|
|
303
304
|
setCopiedPath(path);
|
|
304
305
|
setTimeout(() => setCopiedPath(null), 1500);
|
|
305
|
-
} catch {
|
|
306
|
-
// ignore
|
|
307
306
|
}
|
|
308
307
|
}, []);
|
|
309
308
|
|