@hyperframes/studio 0.5.0-alpha.7 → 0.5.0-alpha.9
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-DKaNgV2Z.css +1 -0
- package/dist/assets/index-peNJzL-4.js +105 -0
- package/dist/index.html +2 -2
- package/package.json +4 -4
- package/src/App.tsx +66 -26
- 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 +5 -2
- package/src/components/editor/PropertyPanel.tsx +7 -3
- package/src/components/editor/domEditing.test.ts +37 -0
- package/src/components/editor/domEditing.ts +4 -1
- package/src/components/nle/NLEPreview.tsx +5 -1
- package/src/components/sidebar/AssetsTab.tsx +3 -4
- package/src/player/components/EditModal.tsx +5 -20
- package/src/player/components/PlayerControls.tsx +120 -11
- package/src/player/components/Timeline.test.ts +84 -0
- package/src/player/components/Timeline.tsx +197 -27
- package/src/player/components/timelineZoom.test.ts +21 -0
- package/src/player/components/timelineZoom.ts +11 -0
- package/src/player/hooks/useTimelinePlayer.test.ts +79 -0
- package/src/player/hooks/useTimelinePlayer.ts +284 -16
- package/src/player/lib/time.test.ts +19 -1
- package/src/player/lib/time.ts +20 -0
- package/src/player/store/playerStore.test.ts +11 -1
- package/src/player/store/playerStore.ts +5 -1
- package/src/utils/clipboard.test.ts +88 -0
- package/src/utils/clipboard.ts +57 -0
- package/dist/assets/index-0Zt0t13W.css +0 -1
- package/dist/assets/index-CDSQavT7.js +0 -105
package/dist/index.html
CHANGED
|
@@ -4,8 +4,8 @@
|
|
|
4
4
|
<meta charset="UTF-8" />
|
|
5
5
|
<meta name="viewport" content="width=device-width, initial-scale=1.0, viewport-fit=cover" />
|
|
6
6
|
<title>HyperFrames Studio</title>
|
|
7
|
-
<script type="module" crossorigin src="/assets/index-
|
|
8
|
-
<link rel="stylesheet" crossorigin href="/assets/index-
|
|
7
|
+
<script type="module" crossorigin src="/assets/index-peNJzL-4.js"></script>
|
|
8
|
+
<link rel="stylesheet" crossorigin href="/assets/index-DKaNgV2Z.css">
|
|
9
9
|
</head>
|
|
10
10
|
<body>
|
|
11
11
|
<div id="root"></div>
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@hyperframes/studio",
|
|
3
|
-
"version": "0.5.0-alpha.
|
|
3
|
+
"version": "0.5.0-alpha.9",
|
|
4
4
|
"description": "",
|
|
5
5
|
"repository": {
|
|
6
6
|
"type": "git",
|
|
@@ -32,8 +32,8 @@
|
|
|
32
32
|
"@phosphor-icons/react": "^2.1.10",
|
|
33
33
|
"codemirror": "^6.0.1",
|
|
34
34
|
"motion": "^12.38.0",
|
|
35
|
-
"@hyperframes/core": "0.5.0-alpha.
|
|
36
|
-
"@hyperframes/player": "0.5.0-alpha.
|
|
35
|
+
"@hyperframes/core": "0.5.0-alpha.9",
|
|
36
|
+
"@hyperframes/player": "0.5.0-alpha.9"
|
|
37
37
|
},
|
|
38
38
|
"devDependencies": {
|
|
39
39
|
"@types/react": "^19.0.0",
|
|
@@ -47,7 +47,7 @@
|
|
|
47
47
|
"vite": "^6.4.2",
|
|
48
48
|
"vitest": "^3.2.4",
|
|
49
49
|
"zustand": "^5.0.0",
|
|
50
|
-
"@hyperframes/producer": "0.5.0-alpha.
|
|
50
|
+
"@hyperframes/producer": "0.5.0-alpha.9"
|
|
51
51
|
},
|
|
52
52
|
"peerDependencies": {
|
|
53
53
|
"react": "^18.0.0 || ^19.0.0",
|
package/src/App.tsx
CHANGED
|
@@ -28,6 +28,7 @@ import { CaptionTimeline } from "./captions/components/CaptionTimeline";
|
|
|
28
28
|
import { useCaptionStore } from "./captions/store";
|
|
29
29
|
import { useCaptionSync } from "./captions/hooks/useCaptionSync";
|
|
30
30
|
import { parseCaptionComposition } from "./captions/parser";
|
|
31
|
+
import { copyTextToClipboard } from "./utils/clipboard";
|
|
31
32
|
import {
|
|
32
33
|
applyPatchByTarget,
|
|
33
34
|
readAttributeByTarget,
|
|
@@ -166,6 +167,23 @@ function toRelativeProjectAssetPath(sourceFile: string, assetPath: string): stri
|
|
|
166
167
|
return [...fromParts.map(() => ".."), ...targetParts].join("/") || assetPath;
|
|
167
168
|
}
|
|
168
169
|
|
|
170
|
+
function isAbsoluteFilePath(value: string): boolean {
|
|
171
|
+
return /^(?:\/|[A-Za-z]:[\\/]|\\\\)/.test(value);
|
|
172
|
+
}
|
|
173
|
+
|
|
174
|
+
function toProjectAbsolutePath(projectDir: string | null, sourceFile: string): string | undefined {
|
|
175
|
+
const trimmedSource = sourceFile.trim();
|
|
176
|
+
if (!trimmedSource) return undefined;
|
|
177
|
+
|
|
178
|
+
const normalizedSource = trimmedSource.replace(/\\/g, "/");
|
|
179
|
+
if (isAbsoluteFilePath(normalizedSource)) return normalizedSource;
|
|
180
|
+
|
|
181
|
+
const normalizedRoot = projectDir?.trim().replace(/\\/g, "/").replace(/\/+$/, "");
|
|
182
|
+
if (!normalizedRoot) return undefined;
|
|
183
|
+
|
|
184
|
+
return `${normalizedRoot}/${normalizedSource.replace(/^\.?\//, "")}`;
|
|
185
|
+
}
|
|
186
|
+
|
|
169
187
|
function ensureImportedFontFace(
|
|
170
188
|
html: string,
|
|
171
189
|
asset: ImportedFontAsset,
|
|
@@ -574,6 +592,7 @@ export function StudioApp() {
|
|
|
574
592
|
});
|
|
575
593
|
|
|
576
594
|
const [editingFile, setEditingFile] = useState<EditingFile | null>(null);
|
|
595
|
+
const [projectDir, setProjectDir] = useState<string | null>(null);
|
|
577
596
|
const [activeCompPath, setActiveCompPath] = useState<string | null>(null);
|
|
578
597
|
const [fileTree, setFileTree] = useState<string[]>([]);
|
|
579
598
|
const [compIdToSrc, setCompIdToSrc] = useState<Map<string, string>>(new Map());
|
|
@@ -589,6 +608,7 @@ export function StudioApp() {
|
|
|
589
608
|
const [rightCollapsed, setRightCollapsed] = useState(true);
|
|
590
609
|
const [rightPanelTab, setRightPanelTab] = useState<RightPanelTab>("renders");
|
|
591
610
|
const [domEditSelection, setDomEditSelection] = useState<DomEditSelection | null>(null);
|
|
611
|
+
const [agentPromptTagSnippet, setAgentPromptTagSnippet] = useState<string | undefined>();
|
|
592
612
|
const [copiedAgentPrompt, setCopiedAgentPrompt] = useState(false);
|
|
593
613
|
const [agentModalOpen, setAgentModalOpen] = useState(false);
|
|
594
614
|
const [previewIframe, setPreviewIframe] = useState<HTMLIFrameElement | null>(null);
|
|
@@ -1036,10 +1056,13 @@ export function StudioApp() {
|
|
|
1036
1056
|
let cancelled = false;
|
|
1037
1057
|
fetch(`/api/projects/${projectId}`)
|
|
1038
1058
|
.then((r) => r.json())
|
|
1039
|
-
.then((data: { files?: string[] }) => {
|
|
1059
|
+
.then((data: { files?: string[]; dir?: string }) => {
|
|
1040
1060
|
if (!cancelled && data.files) setFileTree(data.files);
|
|
1061
|
+
if (!cancelled) setProjectDir(typeof data.dir === "string" ? data.dir : null);
|
|
1041
1062
|
})
|
|
1042
|
-
.catch(() => {
|
|
1063
|
+
.catch(() => {
|
|
1064
|
+
if (!cancelled) setProjectDir(null);
|
|
1065
|
+
});
|
|
1043
1066
|
return () => {
|
|
1044
1067
|
cancelled = true;
|
|
1045
1068
|
};
|
|
@@ -1428,6 +1451,7 @@ export function StudioApp() {
|
|
|
1428
1451
|
const applyDomSelection = useCallback(
|
|
1429
1452
|
(selection: DomEditSelection | null, options?: { revealPanel?: boolean }) => {
|
|
1430
1453
|
setDomEditSelection(selection);
|
|
1454
|
+
setAgentPromptTagSnippet(undefined);
|
|
1431
1455
|
setCopiedAgentPrompt(false);
|
|
1432
1456
|
if (selection) {
|
|
1433
1457
|
if (options?.revealPanel !== false) {
|
|
@@ -1490,6 +1514,34 @@ export function StudioApp() {
|
|
|
1490
1514
|
[activeCompPath, compIdToSrc, fileTree, isMasterView, timelineElements],
|
|
1491
1515
|
);
|
|
1492
1516
|
|
|
1517
|
+
const preloadAgentPromptSnippet = useCallback(
|
|
1518
|
+
async (selection: DomEditSelection) => {
|
|
1519
|
+
const pid = projectIdRef.current;
|
|
1520
|
+
if (!pid) return;
|
|
1521
|
+
|
|
1522
|
+
const targetPath = selection.sourceFile || activeCompPath || "index.html";
|
|
1523
|
+
try {
|
|
1524
|
+
const response = await fetch(
|
|
1525
|
+
`/api/projects/${pid}/files/${encodeURIComponent(targetPath)}`,
|
|
1526
|
+
);
|
|
1527
|
+
if (!response.ok) return;
|
|
1528
|
+
|
|
1529
|
+
const data = (await response.json()) as { content?: string };
|
|
1530
|
+
const html = data.content;
|
|
1531
|
+
const tagSnippet =
|
|
1532
|
+
typeof html === "string" ? readTagSnippetByTarget(html, selection) : undefined;
|
|
1533
|
+
|
|
1534
|
+
setAgentPromptTagSnippet((current) => {
|
|
1535
|
+
if (domEditSelectionRef.current !== selection) return current;
|
|
1536
|
+
return tagSnippet;
|
|
1537
|
+
});
|
|
1538
|
+
} catch {
|
|
1539
|
+
// Runtime outerHTML is still available as a synchronous copy fallback.
|
|
1540
|
+
}
|
|
1541
|
+
},
|
|
1542
|
+
[activeCompPath],
|
|
1543
|
+
);
|
|
1544
|
+
|
|
1493
1545
|
const resolveImportedFontAsset = useCallback(
|
|
1494
1546
|
(fontFamilyValue: string): ImportedFontAsset | null => {
|
|
1495
1547
|
const family = primaryFontFamilyValue(fontFamilyValue);
|
|
@@ -1892,43 +1944,29 @@ export function StudioApp() {
|
|
|
1892
1944
|
|
|
1893
1945
|
const handleAskAgent = useCallback(() => {
|
|
1894
1946
|
if (!domEditSelection) return;
|
|
1947
|
+
setAgentPromptTagSnippet(undefined);
|
|
1948
|
+
void preloadAgentPromptSnippet(domEditSelection);
|
|
1895
1949
|
setAgentModalOpen(true);
|
|
1896
|
-
}, [domEditSelection]);
|
|
1950
|
+
}, [domEditSelection, preloadAgentPromptSnippet]);
|
|
1897
1951
|
|
|
1898
1952
|
const handleAgentModalSubmit = useCallback(
|
|
1899
1953
|
async (userInstruction: string) => {
|
|
1900
1954
|
if (!domEditSelection) return;
|
|
1901
1955
|
|
|
1902
|
-
const pid = projectIdRef.current;
|
|
1903
|
-
if (!pid) return;
|
|
1904
|
-
|
|
1905
1956
|
const targetPath = domEditSelection.sourceFile || activeCompPath || "index.html";
|
|
1906
|
-
const
|
|
1907
|
-
if (!response.ok) throw new Error(`Failed to read ${targetPath}`);
|
|
1908
|
-
|
|
1909
|
-
const data = (await response.json()) as { content?: string };
|
|
1910
|
-
const html = data.content;
|
|
1911
|
-
const tagSnippet =
|
|
1912
|
-
typeof html === "string" ? readTagSnippetByTarget(html, domEditSelection) : undefined;
|
|
1957
|
+
const tagSnippet = agentPromptTagSnippet ?? domEditSelection.element.outerHTML;
|
|
1913
1958
|
const prompt = buildElementAgentPrompt({
|
|
1914
1959
|
selection: domEditSelection,
|
|
1915
1960
|
currentTime,
|
|
1916
1961
|
tagSnippet,
|
|
1917
1962
|
userInstruction,
|
|
1963
|
+
sourceFilePath: toProjectAbsolutePath(projectDir, targetPath),
|
|
1918
1964
|
});
|
|
1919
1965
|
|
|
1920
|
-
|
|
1921
|
-
|
|
1922
|
-
|
|
1923
|
-
|
|
1924
|
-
textarea.value = prompt;
|
|
1925
|
-
textarea.setAttribute("readonly", "true");
|
|
1926
|
-
textarea.style.position = "fixed";
|
|
1927
|
-
textarea.style.opacity = "0";
|
|
1928
|
-
document.body.appendChild(textarea);
|
|
1929
|
-
textarea.select();
|
|
1930
|
-
document.execCommand("copy");
|
|
1931
|
-
document.body.removeChild(textarea);
|
|
1966
|
+
const copied = await copyTextToClipboard(prompt);
|
|
1967
|
+
if (!copied) {
|
|
1968
|
+
showToast("Could not copy prompt to clipboard.", "error");
|
|
1969
|
+
return;
|
|
1932
1970
|
}
|
|
1933
1971
|
|
|
1934
1972
|
setAgentModalOpen(false);
|
|
@@ -1936,7 +1974,7 @@ export function StudioApp() {
|
|
|
1936
1974
|
setCopiedAgentPrompt(true);
|
|
1937
1975
|
copiedAgentTimerRef.current = setTimeout(() => setCopiedAgentPrompt(false), 1600);
|
|
1938
1976
|
},
|
|
1939
|
-
[activeCompPath, currentTime, domEditSelection],
|
|
1977
|
+
[activeCompPath, agentPromptTagSnippet, currentTime, domEditSelection, projectDir, showToast],
|
|
1940
1978
|
);
|
|
1941
1979
|
|
|
1942
1980
|
const handlePreviewIframeRef = useCallback(
|
|
@@ -2759,6 +2797,7 @@ export function StudioApp() {
|
|
|
2759
2797
|
selection={
|
|
2760
2798
|
!rightCollapsed && rightPanelTab === "design" ? domEditSelection : null
|
|
2761
2799
|
}
|
|
2800
|
+
allowCanvasMovement={false}
|
|
2762
2801
|
onCanvasMouseDown={handlePreviewCanvasMouseDown}
|
|
2763
2802
|
onCanvasDoubleClick={handlePreviewCanvasDoubleClick}
|
|
2764
2803
|
onSelectedDoubleClick={handleSelectedOverlayDoubleClick}
|
|
@@ -2853,6 +2892,7 @@ export function StudioApp() {
|
|
|
2853
2892
|
onImportAssets={handleImportFiles}
|
|
2854
2893
|
fontAssets={fontAssets}
|
|
2855
2894
|
onImportFonts={handleImportFonts}
|
|
2895
|
+
allowLayoutDetach={false}
|
|
2856
2896
|
/>
|
|
2857
2897
|
) : (
|
|
2858
2898
|
<RenderQueue
|
|
@@ -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,6 +14,7 @@ interface OverlayRect {
|
|
|
14
14
|
interface DomEditOverlayProps {
|
|
15
15
|
iframeRef: RefObject<HTMLIFrameElement | null>;
|
|
16
16
|
selection: DomEditSelection | null;
|
|
17
|
+
allowCanvasMovement?: boolean;
|
|
17
18
|
onCanvasMouseDown: (
|
|
18
19
|
event: React.MouseEvent<HTMLDivElement>,
|
|
19
20
|
options?: { preferClipAncestor?: boolean },
|
|
@@ -125,6 +126,7 @@ interface BlockedMoveState {
|
|
|
125
126
|
export const DomEditOverlay = memo(function DomEditOverlay({
|
|
126
127
|
iframeRef,
|
|
127
128
|
selection,
|
|
129
|
+
allowCanvasMovement = true,
|
|
128
130
|
onCanvasMouseDown,
|
|
129
131
|
onCanvasDoubleClick,
|
|
130
132
|
onSelectedDoubleClick,
|
|
@@ -403,9 +405,10 @@ export const DomEditOverlay = memo(function DomEditOverlay({
|
|
|
403
405
|
top: overlayRect.top,
|
|
404
406
|
width: overlayRect.width,
|
|
405
407
|
height: overlayRect.height,
|
|
406
|
-
cursor: selection.capabilities.canMove ? "move" : "default",
|
|
408
|
+
cursor: allowCanvasMovement && selection.capabilities.canMove ? "move" : "default",
|
|
407
409
|
}}
|
|
408
410
|
onPointerDown={(e) => {
|
|
411
|
+
if (!allowCanvasMovement) return;
|
|
409
412
|
if (selection.capabilities.canMove) {
|
|
410
413
|
startGesture("drag", e);
|
|
411
414
|
return;
|
|
@@ -424,7 +427,7 @@ export const DomEditOverlay = memo(function DomEditOverlay({
|
|
|
424
427
|
onDoubleClick={onSelectedDoubleClick}
|
|
425
428
|
>
|
|
426
429
|
{/* Resize handle — bottom-right corner */}
|
|
427
|
-
{selection.capabilities.canResize && (
|
|
430
|
+
{allowCanvasMovement && selection.capabilities.canResize && (
|
|
428
431
|
<div
|
|
429
432
|
className="absolute -right-1.5 -bottom-1.5 w-3 h-3 rounded-sm bg-studio-accent border border-studio-accent/60"
|
|
430
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">
|
|
@@ -467,6 +467,43 @@ describe("patch builders and prompt builder", () => {
|
|
|
467
467
|
expect(prompt).toContain("Do not modify other elements' data-* attributes or positioning.");
|
|
468
468
|
});
|
|
469
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
|
+
|
|
470
507
|
it("serializes child text fields back into HTML", () => {
|
|
471
508
|
expect(
|
|
472
509
|
serializeDomEditTextFields([
|
|
@@ -697,12 +697,15 @@ export function buildElementAgentPrompt({
|
|
|
697
697
|
currentTime,
|
|
698
698
|
tagSnippet,
|
|
699
699
|
userInstruction,
|
|
700
|
+
sourceFilePath,
|
|
700
701
|
}: {
|
|
701
702
|
selection: DomEditSelection;
|
|
702
703
|
currentTime: number;
|
|
703
704
|
tagSnippet?: string;
|
|
704
705
|
userInstruction?: string;
|
|
706
|
+
sourceFilePath?: string;
|
|
705
707
|
}): string {
|
|
708
|
+
const displayedSourceFile = sourceFilePath?.trim() || selection.sourceFile;
|
|
706
709
|
const lines = [
|
|
707
710
|
"## HyperFrames element edit request v1",
|
|
708
711
|
"Schema version: 1",
|
|
@@ -711,7 +714,7 @@ export function buildElementAgentPrompt({
|
|
|
711
714
|
"",
|
|
712
715
|
`Composition: ${selection.compositionPath}`,
|
|
713
716
|
`Playback time: ${formatTime(currentTime)}`,
|
|
714
|
-
`Source file: ${
|
|
717
|
+
`Source file: ${displayedSourceFile}`,
|
|
715
718
|
`DOM id: ${selection.id ?? "(none)"}`,
|
|
716
719
|
`Selector: ${selection.selector ?? "(none)"}`,
|
|
717
720
|
`Selector index: ${selection.selectorIndex ?? 0}`,
|
|
@@ -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
|
|
|
@@ -3,6 +3,7 @@ import { useMountEffect } from "../../hooks/useMountEffect";
|
|
|
3
3
|
import { usePlayerStore } from "../store/playerStore";
|
|
4
4
|
import { formatTime } from "../lib/time";
|
|
5
5
|
import { buildPromptCopyText, buildTimelineAgentPrompt } from "./timelineEditing";
|
|
6
|
+
import { copyTextToClipboard } from "../../utils/clipboard";
|
|
6
7
|
|
|
7
8
|
interface EditPopoverProps {
|
|
8
9
|
rangeStart: number;
|
|
@@ -62,16 +63,8 @@ export function EditPopover({ rangeStart, rangeEnd, anchorX, anchorY, onClose }:
|
|
|
62
63
|
}, [start, end, elementsInRange, prompt]);
|
|
63
64
|
|
|
64
65
|
const handleCopy = useCallback(async () => {
|
|
65
|
-
|
|
66
|
-
|
|
67
|
-
} catch {
|
|
68
|
-
const ta = document.createElement("textarea");
|
|
69
|
-
ta.value = buildClipboardText();
|
|
70
|
-
document.body.appendChild(ta);
|
|
71
|
-
ta.select();
|
|
72
|
-
document.execCommand("copy");
|
|
73
|
-
document.body.removeChild(ta);
|
|
74
|
-
}
|
|
66
|
+
const copied = await copyTextToClipboard(buildClipboardText());
|
|
67
|
+
if (!copied) return;
|
|
75
68
|
setCopiedAgentPrompt(true);
|
|
76
69
|
setTimeout(() => {
|
|
77
70
|
setCopiedAgentPrompt(false);
|
|
@@ -82,16 +75,8 @@ export function EditPopover({ rangeStart, rangeEnd, anchorX, anchorY, onClose }:
|
|
|
82
75
|
const handleCopyPrompt = useCallback(async () => {
|
|
83
76
|
const promptText = buildPromptCopyText(prompt);
|
|
84
77
|
if (!promptText) return;
|
|
85
|
-
|
|
86
|
-
|
|
87
|
-
} catch {
|
|
88
|
-
const ta = document.createElement("textarea");
|
|
89
|
-
ta.value = promptText;
|
|
90
|
-
document.body.appendChild(ta);
|
|
91
|
-
ta.select();
|
|
92
|
-
document.execCommand("copy");
|
|
93
|
-
document.body.removeChild(ta);
|
|
94
|
-
}
|
|
78
|
+
const copied = await copyTextToClipboard(promptText);
|
|
79
|
+
if (!copied) return;
|
|
95
80
|
setCopiedPromptOnly(true);
|
|
96
81
|
setTimeout(() => {
|
|
97
82
|
setCopiedPromptOnly(false);
|