@hyperframes/studio 0.5.0-alpha.1 → 0.5.0-alpha.10
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 +132 -41
- 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/NLEPreview.tsx +5 -1
- package/src/components/sidebar/AssetsTab.tsx +3 -4
- 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 +120 -11
- 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/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 +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/styles/studio.css +9 -0
- package/src/utils/clipboard.test.ts +88 -0
- package/src/utils/clipboard.ts +57 -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
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.10",
|
|
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.10",
|
|
36
|
+
"@hyperframes/player": "0.5.0-alpha.10"
|
|
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.10"
|
|
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);
|
|
@@ -756,6 +776,9 @@ export function StudioApp() {
|
|
|
756
776
|
const toggleTimelineVisibility = useCallback(() => {
|
|
757
777
|
setTimelineVisible((visible) => !visible);
|
|
758
778
|
}, []);
|
|
779
|
+
useMountEffect(() => () => {
|
|
780
|
+
if (toastTimerRef.current) clearTimeout(toastTimerRef.current);
|
|
781
|
+
});
|
|
759
782
|
const dismissTimelineEditorHint = useCallback(() => {
|
|
760
783
|
setTimelineEditorHintState(true);
|
|
761
784
|
setTimelineEditorHintDismissed(true);
|
|
@@ -838,6 +861,8 @@ export function StudioApp() {
|
|
|
838
861
|
label={el.id || el.tag}
|
|
839
862
|
labelColor={style.label}
|
|
840
863
|
accentColor={style.clip}
|
|
864
|
+
selector={el.selector}
|
|
865
|
+
selectorIndex={el.selectorIndex}
|
|
841
866
|
seekTime={el.start}
|
|
842
867
|
duration={el.duration}
|
|
843
868
|
/>
|
|
@@ -852,13 +877,28 @@ export function StudioApp() {
|
|
|
852
877
|
|
|
853
878
|
// Audio clips — waveform visualization
|
|
854
879
|
if (el.tag === "audio") {
|
|
855
|
-
const
|
|
856
|
-
|
|
857
|
-
|
|
858
|
-
|
|
859
|
-
|
|
880
|
+
const previewBase = `/api/projects/${pid}/preview/`;
|
|
881
|
+
const previewIdx = el.src?.startsWith("http") ? el.src.indexOf(previewBase) : -1;
|
|
882
|
+
const srcRelative = el.src
|
|
883
|
+
? previewIdx !== -1
|
|
884
|
+
? decodeURIComponent(el.src.slice(previewIdx + previewBase.length))
|
|
885
|
+
: el.src.startsWith("http")
|
|
886
|
+
? null
|
|
887
|
+
: el.src
|
|
888
|
+
: null;
|
|
889
|
+
const audioUrl = srcRelative
|
|
890
|
+
? `/api/projects/${pid}/preview/${srcRelative}`
|
|
891
|
+
: (el.src ?? "");
|
|
892
|
+
const waveformUrl = srcRelative
|
|
893
|
+
? `/api/projects/${pid}/waveform/${srcRelative}`
|
|
894
|
+
: undefined;
|
|
860
895
|
return (
|
|
861
|
-
<AudioWaveform
|
|
896
|
+
<AudioWaveform
|
|
897
|
+
audioUrl={audioUrl}
|
|
898
|
+
waveformUrl={waveformUrl}
|
|
899
|
+
label={el.id || el.tag}
|
|
900
|
+
labelColor={style.label}
|
|
901
|
+
/>
|
|
862
902
|
);
|
|
863
903
|
}
|
|
864
904
|
|
|
@@ -883,6 +923,8 @@ export function StudioApp() {
|
|
|
883
923
|
label={el.id || el.tag}
|
|
884
924
|
labelColor={style.label}
|
|
885
925
|
accentColor={style.clip}
|
|
926
|
+
selector={el.selector}
|
|
927
|
+
selectorIndex={el.selectorIndex}
|
|
886
928
|
seekTime={el.start}
|
|
887
929
|
duration={el.duration}
|
|
888
930
|
/>
|
|
@@ -1014,10 +1056,13 @@ export function StudioApp() {
|
|
|
1014
1056
|
let cancelled = false;
|
|
1015
1057
|
fetch(`/api/projects/${projectId}`)
|
|
1016
1058
|
.then((r) => r.json())
|
|
1017
|
-
.then((data: { files?: string[] }) => {
|
|
1059
|
+
.then((data: { files?: string[]; dir?: string }) => {
|
|
1018
1060
|
if (!cancelled && data.files) setFileTree(data.files);
|
|
1061
|
+
if (!cancelled) setProjectDir(typeof data.dir === "string" ? data.dir : null);
|
|
1019
1062
|
})
|
|
1020
|
-
.catch(() => {
|
|
1063
|
+
.catch(() => {
|
|
1064
|
+
if (!cancelled) setProjectDir(null);
|
|
1065
|
+
});
|
|
1021
1066
|
return () => {
|
|
1022
1067
|
cancelled = true;
|
|
1023
1068
|
};
|
|
@@ -1406,6 +1451,7 @@ export function StudioApp() {
|
|
|
1406
1451
|
const applyDomSelection = useCallback(
|
|
1407
1452
|
(selection: DomEditSelection | null, options?: { revealPanel?: boolean }) => {
|
|
1408
1453
|
setDomEditSelection(selection);
|
|
1454
|
+
setAgentPromptTagSnippet(undefined);
|
|
1409
1455
|
setCopiedAgentPrompt(false);
|
|
1410
1456
|
if (selection) {
|
|
1411
1457
|
if (options?.revealPanel !== false) {
|
|
@@ -1468,6 +1514,34 @@ export function StudioApp() {
|
|
|
1468
1514
|
[activeCompPath, compIdToSrc, fileTree, isMasterView, timelineElements],
|
|
1469
1515
|
);
|
|
1470
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
|
+
|
|
1471
1545
|
const resolveImportedFontAsset = useCallback(
|
|
1472
1546
|
(fontFamilyValue: string): ImportedFontAsset | null => {
|
|
1473
1547
|
const family = primaryFontFamilyValue(fontFamilyValue);
|
|
@@ -1870,43 +1944,29 @@ export function StudioApp() {
|
|
|
1870
1944
|
|
|
1871
1945
|
const handleAskAgent = useCallback(() => {
|
|
1872
1946
|
if (!domEditSelection) return;
|
|
1947
|
+
setAgentPromptTagSnippet(undefined);
|
|
1948
|
+
void preloadAgentPromptSnippet(domEditSelection);
|
|
1873
1949
|
setAgentModalOpen(true);
|
|
1874
|
-
}, [domEditSelection]);
|
|
1950
|
+
}, [domEditSelection, preloadAgentPromptSnippet]);
|
|
1875
1951
|
|
|
1876
1952
|
const handleAgentModalSubmit = useCallback(
|
|
1877
1953
|
async (userInstruction: string) => {
|
|
1878
1954
|
if (!domEditSelection) return;
|
|
1879
1955
|
|
|
1880
|
-
const pid = projectIdRef.current;
|
|
1881
|
-
if (!pid) return;
|
|
1882
|
-
|
|
1883
1956
|
const targetPath = domEditSelection.sourceFile || activeCompPath || "index.html";
|
|
1884
|
-
const
|
|
1885
|
-
if (!response.ok) throw new Error(`Failed to read ${targetPath}`);
|
|
1886
|
-
|
|
1887
|
-
const data = (await response.json()) as { content?: string };
|
|
1888
|
-
const html = data.content;
|
|
1889
|
-
const tagSnippet =
|
|
1890
|
-
typeof html === "string" ? readTagSnippetByTarget(html, domEditSelection) : undefined;
|
|
1957
|
+
const tagSnippet = agentPromptTagSnippet ?? domEditSelection.element.outerHTML;
|
|
1891
1958
|
const prompt = buildElementAgentPrompt({
|
|
1892
1959
|
selection: domEditSelection,
|
|
1893
1960
|
currentTime,
|
|
1894
1961
|
tagSnippet,
|
|
1895
1962
|
userInstruction,
|
|
1963
|
+
sourceFilePath: toProjectAbsolutePath(projectDir, targetPath),
|
|
1896
1964
|
});
|
|
1897
1965
|
|
|
1898
|
-
|
|
1899
|
-
|
|
1900
|
-
|
|
1901
|
-
|
|
1902
|
-
textarea.value = prompt;
|
|
1903
|
-
textarea.setAttribute("readonly", "true");
|
|
1904
|
-
textarea.style.position = "fixed";
|
|
1905
|
-
textarea.style.opacity = "0";
|
|
1906
|
-
document.body.appendChild(textarea);
|
|
1907
|
-
textarea.select();
|
|
1908
|
-
document.execCommand("copy");
|
|
1909
|
-
document.body.removeChild(textarea);
|
|
1966
|
+
const copied = await copyTextToClipboard(prompt);
|
|
1967
|
+
if (!copied) {
|
|
1968
|
+
showToast("Could not copy prompt to clipboard.", "error");
|
|
1969
|
+
return;
|
|
1910
1970
|
}
|
|
1911
1971
|
|
|
1912
1972
|
setAgentModalOpen(false);
|
|
@@ -1914,7 +1974,7 @@ export function StudioApp() {
|
|
|
1914
1974
|
setCopiedAgentPrompt(true);
|
|
1915
1975
|
copiedAgentTimerRef.current = setTimeout(() => setCopiedAgentPrompt(false), 1600);
|
|
1916
1976
|
},
|
|
1917
|
-
[activeCompPath, currentTime, domEditSelection],
|
|
1977
|
+
[activeCompPath, agentPromptTagSnippet, currentTime, domEditSelection, projectDir, showToast],
|
|
1918
1978
|
);
|
|
1919
1979
|
|
|
1920
1980
|
const handlePreviewIframeRef = useCallback(
|
|
@@ -1929,7 +1989,7 @@ export function StudioApp() {
|
|
|
1929
1989
|
);
|
|
1930
1990
|
|
|
1931
1991
|
const handlePreviewCanvasMouseDown = useCallback(
|
|
1932
|
-
(e: React.MouseEvent<HTMLDivElement
|
|
1992
|
+
(e: React.MouseEvent<HTMLDivElement>, options?: { preferClipAncestor?: boolean }) => {
|
|
1933
1993
|
const iframe = previewIframeRef.current;
|
|
1934
1994
|
if (!iframe || captionEditMode) return;
|
|
1935
1995
|
const target = getPreviewTargetFromPointer(iframe, e.clientX, e.clientY);
|
|
@@ -1941,7 +2001,7 @@ export function StudioApp() {
|
|
|
1941
2001
|
e.preventDefault();
|
|
1942
2002
|
e.stopPropagation();
|
|
1943
2003
|
const nextSelection = buildDomSelectionFromTarget(target, {
|
|
1944
|
-
preferClipAncestor: true,
|
|
2004
|
+
preferClipAncestor: options?.preferClipAncestor ?? true,
|
|
1945
2005
|
});
|
|
1946
2006
|
if (!nextSelection) {
|
|
1947
2007
|
lastPreviewClickRef.current = null;
|
|
@@ -2139,7 +2199,11 @@ export function StudioApp() {
|
|
|
2139
2199
|
);
|
|
2140
2200
|
|
|
2141
2201
|
const handleTimelineAssetDrop = useCallback(
|
|
2142
|
-
async (
|
|
2202
|
+
async (
|
|
2203
|
+
assetPath: string,
|
|
2204
|
+
placement: Pick<TimelineElement, "start" | "track">,
|
|
2205
|
+
durationOverride?: number,
|
|
2206
|
+
) => {
|
|
2143
2207
|
const pid = projectIdRef.current;
|
|
2144
2208
|
if (!pid) throw new Error("No active project");
|
|
2145
2209
|
|
|
@@ -2165,9 +2229,11 @@ export function StudioApp() {
|
|
|
2165
2229
|
}
|
|
2166
2230
|
|
|
2167
2231
|
const normalizedStart = Number(formatTimelineAttributeNumber(placement.start));
|
|
2168
|
-
const
|
|
2169
|
-
|
|
2170
|
-
|
|
2232
|
+
const duration =
|
|
2233
|
+
Number.isFinite(durationOverride) && durationOverride != null && durationOverride > 0
|
|
2234
|
+
? durationOverride
|
|
2235
|
+
: await resolveDroppedAssetDuration(pid, assetPath, kind);
|
|
2236
|
+
const normalizedDuration = Number(formatTimelineAttributeNumber(duration));
|
|
2171
2237
|
const newId = buildTimelineAssetId(assetPath, collectHtmlIds(originalContent));
|
|
2172
2238
|
const resolvedAssetSrc = resolveTimelineAssetSrc(targetPath, assetPath);
|
|
2173
2239
|
|
|
@@ -2247,17 +2313,40 @@ export function StudioApp() {
|
|
|
2247
2313
|
|
|
2248
2314
|
const handleTimelineFileDrop = useCallback(
|
|
2249
2315
|
async (files: File[], placement?: Pick<TimelineElement, "start" | "track">) => {
|
|
2316
|
+
const pid = projectIdRef.current;
|
|
2317
|
+
if (!pid) return;
|
|
2250
2318
|
const uploaded = await uploadProjectFiles(files);
|
|
2251
2319
|
if (uploaded.length === 0) return;
|
|
2320
|
+
const durations: number[] = [];
|
|
2321
|
+
for (const assetPath of uploaded) {
|
|
2322
|
+
const kind = getTimelineAssetKind(assetPath);
|
|
2323
|
+
const duration = kind ? await resolveDroppedAssetDuration(pid, assetPath, kind) : 0;
|
|
2324
|
+
durations.push(Number(formatTimelineAttributeNumber(duration)));
|
|
2325
|
+
}
|
|
2252
2326
|
const placements = buildTimelineFileDropPlacements(
|
|
2253
2327
|
placement ?? { start: 0, track: 0 },
|
|
2254
|
-
|
|
2328
|
+
durations,
|
|
2329
|
+
timelineElements
|
|
2330
|
+
.filter(
|
|
2331
|
+
(timelineElement) =>
|
|
2332
|
+
(timelineElement.sourceFile || activeCompPath || "index.html") ===
|
|
2333
|
+
(activeCompPath || "index.html"),
|
|
2334
|
+
)
|
|
2335
|
+
.map((timelineElement) => ({
|
|
2336
|
+
start: timelineElement.start,
|
|
2337
|
+
duration: timelineElement.duration,
|
|
2338
|
+
track: timelineElement.track,
|
|
2339
|
+
})),
|
|
2255
2340
|
);
|
|
2256
2341
|
for (const [index, assetPath] of uploaded.entries()) {
|
|
2257
|
-
await handleTimelineAssetDrop(
|
|
2342
|
+
await handleTimelineAssetDrop(
|
|
2343
|
+
assetPath,
|
|
2344
|
+
placements[index] ?? placements[0],
|
|
2345
|
+
durations[index],
|
|
2346
|
+
);
|
|
2258
2347
|
}
|
|
2259
2348
|
},
|
|
2260
|
-
[handleTimelineAssetDrop, uploadProjectFiles],
|
|
2349
|
+
[activeCompPath, handleTimelineAssetDrop, timelineElements, uploadProjectFiles],
|
|
2261
2350
|
);
|
|
2262
2351
|
|
|
2263
2352
|
// ── File Management Handlers ──
|
|
@@ -2708,6 +2797,7 @@ export function StudioApp() {
|
|
|
2708
2797
|
selection={
|
|
2709
2798
|
!rightCollapsed && rightPanelTab === "design" ? domEditSelection : null
|
|
2710
2799
|
}
|
|
2800
|
+
allowCanvasMovement={false}
|
|
2711
2801
|
onCanvasMouseDown={handlePreviewCanvasMouseDown}
|
|
2712
2802
|
onCanvasDoubleClick={handlePreviewCanvasDoubleClick}
|
|
2713
2803
|
onSelectedDoubleClick={handleSelectedOverlayDoubleClick}
|
|
@@ -2802,6 +2892,7 @@ export function StudioApp() {
|
|
|
2802
2892
|
onImportAssets={handleImportFiles}
|
|
2803
2893
|
fontAssets={fontAssets}
|
|
2804
2894
|
onImportFonts={handleImportFonts}
|
|
2895
|
+
allowLayoutDetach={false}
|
|
2805
2896
|
/>
|
|
2806
2897
|
) : (
|
|
2807
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,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">
|