@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/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-CDSQavT7.js"></script>
8
- <link rel="stylesheet" crossorigin href="/assets/index-0Zt0t13W.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.7",
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.7",
36
- "@hyperframes/player": "0.5.0-alpha.7"
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.7"
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 response = await fetch(`/api/projects/${pid}/files/${encodeURIComponent(targetPath)}`);
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
- try {
1921
- await navigator.clipboard.writeText(prompt);
1922
- } catch {
1923
- const textarea = document.createElement("textarea");
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 (!["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,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 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">
@@ -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: ${selection.sourceFile}`,
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 className="relative flex-1 flex items-center justify-center p-2 overflow-hidden min-h-0">
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
- try {
302
- await navigator.clipboard.writeText(path);
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
- try {
66
- await navigator.clipboard.writeText(buildClipboardText());
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
- try {
86
- await navigator.clipboard.writeText(promptText);
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);