@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.
Files changed (39) hide show
  1. package/dist/assets/index-DKaNgV2Z.css +1 -0
  2. package/dist/assets/index-peNJzL-4.js +105 -0
  3. package/dist/index.html +2 -2
  4. package/package.json +4 -4
  5. package/src/App.tsx +132 -41
  6. package/src/captions/components/CaptionOverlay.tsx +2 -1
  7. package/src/captions/keyboard.test.ts +38 -0
  8. package/src/captions/keyboard.ts +8 -0
  9. package/src/components/LintModal.tsx +3 -4
  10. package/src/components/editor/DomEditOverlay.tsx +41 -6
  11. package/src/components/editor/PropertyPanel.tsx +7 -3
  12. package/src/components/editor/domEditing.test.ts +110 -0
  13. package/src/components/editor/domEditing.ts +33 -4
  14. package/src/components/nle/NLEPreview.tsx +5 -1
  15. package/src/components/sidebar/AssetsTab.tsx +3 -4
  16. package/src/player/components/AudioWaveform.tsx +44 -29
  17. package/src/player/components/CompositionThumbnail.test.ts +19 -0
  18. package/src/player/components/CompositionThumbnail.tsx +42 -10
  19. package/src/player/components/EditModal.tsx +5 -20
  20. package/src/player/components/PlayerControls.tsx +120 -11
  21. package/src/player/components/Timeline.test.ts +84 -0
  22. package/src/player/components/Timeline.tsx +198 -27
  23. package/src/player/components/timelineEditing.test.ts +2 -2
  24. package/src/player/components/timelineEditing.ts +1 -1
  25. package/src/player/components/timelineZoom.test.ts +21 -0
  26. package/src/player/components/timelineZoom.ts +11 -0
  27. package/src/player/hooks/useTimelinePlayer.test.ts +138 -0
  28. package/src/player/hooks/useTimelinePlayer.ts +354 -43
  29. package/src/player/lib/time.test.ts +19 -1
  30. package/src/player/lib/time.ts +20 -0
  31. package/src/player/store/playerStore.test.ts +11 -1
  32. package/src/player/store/playerStore.ts +5 -1
  33. package/src/styles/studio.css +9 -0
  34. package/src/utils/clipboard.test.ts +88 -0
  35. package/src/utils/clipboard.ts +57 -0
  36. package/src/utils/timelineAssetDrop.test.ts +64 -4
  37. package/src/utils/timelineAssetDrop.ts +27 -5
  38. package/dist/assets/index-Bi30tos-.js +0 -105
  39. 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-Bi30tos-.js"></script>
8
- <link rel="stylesheet" crossorigin href="/assets/index-Dm9VsShj.css">
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.1",
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.1",
36
- "@hyperframes/player": "0.5.0-alpha.1"
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.1"
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 audioUrl = el.src
856
- ? el.src.startsWith("http")
857
- ? el.src
858
- : `/api/projects/${pid}/preview/${el.src}`
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 audioUrl={audioUrl} label={el.id || el.tag} labelColor={style.label} />
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 response = await fetch(`/api/projects/${pid}/files/${encodeURIComponent(targetPath)}`);
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
- try {
1899
- await navigator.clipboard.writeText(prompt);
1900
- } catch {
1901
- const textarea = document.createElement("textarea");
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 (assetPath: string, placement: Pick<TimelineElement, "start" | "track">) => {
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 normalizedDuration = Number(
2169
- formatTimelineAttributeNumber(await resolveDroppedAssetDuration(pid, assetPath, kind)),
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
- uploaded.length,
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(assetPath, placements[index] ?? placements[0]);
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 (!["ArrowUp", "ArrowDown", "ArrowLeft", "ArrowRight"].includes(arrow)) return;
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
- try {
34
- await navigator.clipboard.writeText(text);
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
- onCanvasMouseDown: (event: React.MouseEvent<HTMLDivElement>) => void;
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 Paper-style grouping.
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
- element.capabilities.reasonIfDisabled && !element.capabilities.canDetachFromLayout
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">