@hyperframes/studio 0.5.7 → 0.6.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 (75) hide show
  1. package/dist/assets/index-14zH9lqh.css +1 -0
  2. package/dist/assets/index-B-16fRnH.js +108 -0
  3. package/dist/index.html +2 -2
  4. package/package.json +4 -4
  5. package/src/App.tsx +2965 -186
  6. package/src/components/LintModal.tsx +3 -4
  7. package/src/components/editor/DomEditOverlay.test.ts +241 -0
  8. package/src/components/editor/DomEditOverlay.tsx +1300 -0
  9. package/src/components/editor/MotionPanel.tsx +651 -0
  10. package/src/components/editor/PropertyPanel.test.ts +116 -0
  11. package/src/components/editor/PropertyPanel.tsx +2829 -205
  12. package/src/components/editor/TimelineLayerPanel.test.ts +42 -0
  13. package/src/components/editor/TimelineLayerPanel.tsx +113 -0
  14. package/src/components/editor/colorValue.test.ts +82 -0
  15. package/src/components/editor/colorValue.ts +175 -0
  16. package/src/components/editor/domEditing.test.ts +1120 -0
  17. package/src/components/editor/domEditing.ts +1117 -0
  18. package/src/components/editor/floatingPanel.test.ts +34 -0
  19. package/src/components/editor/floatingPanel.ts +54 -0
  20. package/src/components/editor/fontAssets.ts +32 -0
  21. package/src/components/editor/fontCatalog.ts +126 -0
  22. package/src/components/editor/gradientValue.test.ts +89 -0
  23. package/src/components/editor/gradientValue.ts +445 -0
  24. package/src/components/editor/manualEditingAvailability.test.ts +131 -0
  25. package/src/components/editor/manualEditingAvailability.ts +62 -0
  26. package/src/components/editor/manualEdits.test.ts +945 -0
  27. package/src/components/editor/manualEdits.ts +1409 -0
  28. package/src/components/editor/manualOffsetDrag.test.ts +140 -0
  29. package/src/components/editor/manualOffsetDrag.ts +307 -0
  30. package/src/components/editor/studioMotion.test.ts +355 -0
  31. package/src/components/editor/studioMotion.ts +632 -0
  32. package/src/components/nle/NLELayout.test.ts +12 -0
  33. package/src/components/nle/NLELayout.tsx +84 -22
  34. package/src/components/nle/NLEPreview.tsx +56 -5
  35. package/src/components/renders/RenderQueue.tsx +24 -11
  36. package/src/components/sidebar/AssetsTab.tsx +3 -4
  37. package/src/components/sidebar/CompositionsTab.test.ts +16 -1
  38. package/src/components/sidebar/CompositionsTab.tsx +117 -45
  39. package/src/components/sidebar/LeftSidebar.tsx +194 -179
  40. package/src/hooks/usePersistentEditHistory.test.ts +256 -0
  41. package/src/hooks/usePersistentEditHistory.ts +337 -0
  42. package/src/icons/SystemIcons.tsx +2 -0
  43. package/src/player/components/CompositionThumbnail.test.ts +19 -0
  44. package/src/player/components/CompositionThumbnail.tsx +50 -13
  45. package/src/player/components/EditModal.tsx +5 -20
  46. package/src/player/components/Player.test.ts +58 -0
  47. package/src/player/components/Player.tsx +88 -5
  48. package/src/player/components/PlayerControls.tsx +20 -7
  49. package/src/player/components/Timeline.test.ts +20 -0
  50. package/src/player/components/Timeline.tsx +147 -40
  51. package/src/player/components/TimelineClip.test.ts +92 -0
  52. package/src/player/components/TimelineClip.tsx +241 -7
  53. package/src/player/components/timelineEditing.test.ts +16 -3
  54. package/src/player/components/timelineEditing.ts +10 -3
  55. package/src/player/hooks/useTimelinePlayer.test.ts +148 -19
  56. package/src/player/hooks/useTimelinePlayer.ts +287 -16
  57. package/src/player/store/playerStore.ts +2 -0
  58. package/src/utils/clipboard.test.ts +89 -0
  59. package/src/utils/clipboard.ts +57 -0
  60. package/src/utils/editHistory.test.ts +244 -0
  61. package/src/utils/editHistory.ts +218 -0
  62. package/src/utils/editHistoryStorage.test.ts +37 -0
  63. package/src/utils/editHistoryStorage.ts +99 -0
  64. package/src/utils/mediaTypes.ts +1 -1
  65. package/src/utils/sourcePatcher.test.ts +128 -1
  66. package/src/utils/sourcePatcher.ts +130 -18
  67. package/src/utils/studioFileHistory.test.ts +156 -0
  68. package/src/utils/studioFileHistory.ts +61 -0
  69. package/src/utils/timelineAssetDrop.test.ts +31 -11
  70. package/src/utils/timelineAssetDrop.ts +22 -2
  71. package/src/utils/timelineDiscovery.ts +1 -1
  72. package/src/utils/timelineInspector.test.ts +79 -0
  73. package/src/utils/timelineInspector.ts +116 -0
  74. package/dist/assets/index-04Mp2wOn.css +0 -1
  75. package/dist/assets/index-Dcw3BoVw.js +0 -93
@@ -4,7 +4,7 @@ import { useMountEffect } from "../../hooks/useMountEffect";
4
4
  import { stepFrameTime, STUDIO_PREVIEW_FPS } from "../lib/time";
5
5
  import { useCaptionStore } from "../../captions/store";
6
6
 
7
- interface PlaybackAdapter {
7
+ export interface PlaybackAdapter {
8
8
  play: () => void;
9
9
  pause: () => void;
10
10
  seek: (time: number) => void;
@@ -13,6 +13,16 @@ interface PlaybackAdapter {
13
13
  isPlaying: () => boolean;
14
14
  }
15
15
 
16
+ type RuntimePlaybackAdapter = PlaybackAdapter & {
17
+ renderSeek?: (time: number) => void;
18
+ };
19
+
20
+ interface StaticSeekPlaybackClock {
21
+ now: () => number;
22
+ requestAnimationFrame: (callback: FrameRequestCallback) => number;
23
+ cancelAnimationFrame: (handle: number) => void;
24
+ }
25
+
16
26
  interface TimelineLike {
17
27
  play: () => void;
18
28
  pause: () => void;
@@ -22,7 +32,7 @@ interface TimelineLike {
22
32
  isActive: () => boolean;
23
33
  }
24
34
 
25
- interface ClipManifestClip {
35
+ export interface ClipManifestClip {
26
36
  id: string | null;
27
37
  label: string;
28
38
  start: number;
@@ -43,12 +53,133 @@ interface ClipManifest {
43
53
  }
44
54
 
45
55
  type IframeWindow = Window & {
46
- __player?: PlaybackAdapter;
56
+ __player?: RuntimePlaybackAdapter;
47
57
  __timeline?: TimelineLike;
48
58
  __timelines?: Record<string, TimelineLike>;
49
59
  __clipManifest?: ClipManifest;
50
60
  };
51
61
 
62
+ function isFinitePositive(value: number): boolean {
63
+ return Number.isFinite(value) && value > 0;
64
+ }
65
+
66
+ function clampTime(time: number, duration: number): number {
67
+ const safeDuration = Math.max(0, Number.isFinite(duration) ? duration : 0);
68
+ const safeTime = Math.max(0, Number.isFinite(time) ? time : 0);
69
+ return safeDuration > 0 ? Math.min(safeTime, safeDuration) : safeTime;
70
+ }
71
+
72
+ function readDurationAttribute(el: Element | null | undefined): number {
73
+ if (!el) return 0;
74
+ const duration =
75
+ Number.parseFloat(el.getAttribute("data-duration") ?? "") ||
76
+ Number.parseFloat(el.getAttribute("data-hf-authored-duration") ?? "");
77
+ return isFinitePositive(duration) ? duration : 0;
78
+ }
79
+
80
+ export function readTimelineDurationFromDocument(doc: Document | null | undefined): number {
81
+ if (!doc) return 0;
82
+ const rootDuration = readDurationAttribute(doc.querySelector("[data-composition-id]"));
83
+ if (rootDuration > 0) return rootDuration;
84
+
85
+ let maxEnd = 0;
86
+ for (const node of Array.from(doc.querySelectorAll("[data-start]"))) {
87
+ const start = Number.parseFloat(node.getAttribute("data-start") ?? "");
88
+ const duration = readDurationAttribute(node);
89
+ if (!Number.isFinite(start) || start < 0 || duration <= 0) continue;
90
+ maxEnd = Math.max(maxEnd, start + duration);
91
+ }
92
+ return maxEnd;
93
+ }
94
+
95
+ function getAdapterDuration(adapter: PlaybackAdapter | null | undefined): number {
96
+ if (!adapter) return 0;
97
+ try {
98
+ const duration = Number(adapter.getDuration());
99
+ return isFinitePositive(duration) ? duration : 0;
100
+ } catch {
101
+ return 0;
102
+ }
103
+ }
104
+
105
+ function getDefaultStaticSeekPlaybackClock(win: Window): StaticSeekPlaybackClock {
106
+ return {
107
+ now: () => win.performance.now(),
108
+ requestAnimationFrame: (callback) => win.requestAnimationFrame(callback),
109
+ cancelAnimationFrame: (handle) => win.cancelAnimationFrame(handle),
110
+ };
111
+ }
112
+
113
+ export function createStaticSeekPlaybackAdapter(
114
+ player: Pick<RuntimePlaybackAdapter, "getTime"> &
115
+ Partial<Pick<RuntimePlaybackAdapter, "renderSeek" | "seek">>,
116
+ duration: number,
117
+ clock: StaticSeekPlaybackClock,
118
+ getPlaybackRate: () => number = () => 1,
119
+ ): PlaybackAdapter {
120
+ const safeDuration = Math.max(0, Number.isFinite(duration) ? duration : 0);
121
+ let currentTime = clampTime(Number(player.getTime?.() ?? 0), safeDuration);
122
+ let playing = false;
123
+ let rafId = 0;
124
+ let playStartTime = currentTime;
125
+ let playStartNow = clock.now();
126
+
127
+ const renderSeek = (time: number) => {
128
+ currentTime = clampTime(time, safeDuration);
129
+ if (typeof player.renderSeek === "function") {
130
+ player.renderSeek(currentTime);
131
+ return;
132
+ }
133
+ player.seek?.(currentTime);
134
+ };
135
+
136
+ const stopTicker = () => {
137
+ if (rafId) {
138
+ clock.cancelAnimationFrame(rafId);
139
+ rafId = 0;
140
+ }
141
+ };
142
+
143
+ const tick: FrameRequestCallback = (now) => {
144
+ if (!playing) return;
145
+ const playbackRate = Math.max(0.1, Number(getPlaybackRate()) || 1);
146
+ const elapsed = ((now - playStartNow) / 1000) * playbackRate;
147
+ renderSeek(playStartTime + elapsed);
148
+ if (currentTime >= safeDuration) {
149
+ playing = false;
150
+ rafId = 0;
151
+ return;
152
+ }
153
+ rafId = clock.requestAnimationFrame(tick);
154
+ };
155
+
156
+ return {
157
+ play: () => {
158
+ if (playing || safeDuration <= 0) return;
159
+ if (currentTime >= safeDuration) renderSeek(0);
160
+ playing = true;
161
+ playStartTime = currentTime;
162
+ playStartNow = clock.now();
163
+ stopTicker();
164
+ rafId = clock.requestAnimationFrame(tick);
165
+ },
166
+ pause: () => {
167
+ playing = false;
168
+ stopTicker();
169
+ },
170
+ seek: (time) => {
171
+ renderSeek(time);
172
+ if (playing) {
173
+ playStartTime = currentTime;
174
+ playStartNow = clock.now();
175
+ }
176
+ },
177
+ getTime: () => currentTime,
178
+ getDuration: () => safeDuration,
179
+ isPlaying: () => playing,
180
+ };
181
+ }
182
+
52
183
  function wrapTimeline(tl: TimelineLike): PlaybackAdapter {
53
184
  return {
54
185
  play: () => tl.play(),
@@ -183,6 +314,100 @@ function getTimelineElementDisplayLabel(input: {
183
314
  return tag ? `${tag} clip` : "Timeline clip";
184
315
  }
185
316
 
317
+ const IMPLICIT_TIMELINE_LAYER_SKIP_TAGS = new Set([
318
+ "base",
319
+ "link",
320
+ "meta",
321
+ "noscript",
322
+ "script",
323
+ "style",
324
+ "template",
325
+ ]);
326
+
327
+ function humanizeTimelineIdentifier(value: string): string {
328
+ return value
329
+ .trim()
330
+ .replace(/[_-]+/g, " ")
331
+ .replace(/\s+/g, " ")
332
+ .replace(/\b\w/g, (match) => match.toUpperCase());
333
+ }
334
+
335
+ function getImplicitTimelineLayerLabel(el: HTMLElement): string {
336
+ const explicitLabel =
337
+ el.getAttribute("data-timeline-label") ??
338
+ el.getAttribute("data-label") ??
339
+ el.getAttribute("aria-label");
340
+ if (explicitLabel?.trim()) return explicitLabel.trim();
341
+ if (el.id.trim()) return humanizeTimelineIdentifier(el.id);
342
+ const classes = el.className.split(/\s+/).filter(Boolean);
343
+ const className = classes.find((value) => value !== "clip") ?? classes[0];
344
+ if (className) return humanizeTimelineIdentifier(className);
345
+ return getTimelineElementDisplayLabel({ tag: el.tagName });
346
+ }
347
+
348
+ function isImplicitTimelineLayerCandidate(root: Element, el: Element): el is HTMLElement {
349
+ if (!isHtmlElement(el)) return false;
350
+ if (el.parentElement !== root) return false;
351
+ const tagName = el.tagName.toLowerCase();
352
+ if (IMPLICIT_TIMELINE_LAYER_SKIP_TAGS.has(tagName)) return false;
353
+ if (el.hasAttribute("data-start") || el.hasAttribute("data-track-index")) return false;
354
+ return Boolean(getTimelineElementSelector(el));
355
+ }
356
+
357
+ export function createImplicitTimelineLayersFromDOM(
358
+ doc: Document,
359
+ rootDuration: number,
360
+ existingElements: readonly TimelineElement[] = [],
361
+ ): TimelineElement[] {
362
+ if (!Number.isFinite(rootDuration) || rootDuration <= 0) return [];
363
+ const rootComp = doc.querySelector("[data-composition-id]");
364
+ if (!rootComp) return [];
365
+
366
+ const existingKeys = new Set(existingElements.map(getTimelineElementIdentity));
367
+ const maxTrack = existingElements.reduce(
368
+ (max, element) => Math.max(max, Number.isFinite(element.track) ? element.track : 0),
369
+ -1,
370
+ );
371
+ const layers: TimelineElement[] = [];
372
+
373
+ for (const child of Array.from(rootComp.children)) {
374
+ if (!isImplicitTimelineLayerCandidate(rootComp, child)) continue;
375
+
376
+ const selector = getTimelineElementSelector(child);
377
+ if (!selector) continue;
378
+ const selectorIndex = getTimelineElementSelectorIndex(doc, child, selector);
379
+ const sourceFile = getTimelineElementSourceFile(child);
380
+ const label = getImplicitTimelineLayerLabel(child);
381
+ const identity = buildTimelineElementIdentity({
382
+ preferredId: child.id || null,
383
+ label,
384
+ fallbackIndex: existingElements.length + layers.length,
385
+ domId: child.id || undefined,
386
+ selector,
387
+ selectorIndex,
388
+ sourceFile,
389
+ });
390
+ if (existingKeys.has(identity.key) || existingKeys.has(identity.id)) continue;
391
+
392
+ layers.push({
393
+ domId: child.id || undefined,
394
+ duration: rootDuration,
395
+ id: identity.id,
396
+ key: identity.key,
397
+ label,
398
+ selector,
399
+ selectorIndex,
400
+ sourceFile,
401
+ start: 0,
402
+ tag: child.tagName.toLowerCase(),
403
+ timingSource: "implicit",
404
+ track: maxTrack + 1 + layers.length,
405
+ });
406
+ }
407
+
408
+ return layers;
409
+ }
410
+
186
411
  /**
187
412
  * Parse [data-start] elements from a Document into TimelineElement[].
188
413
  * Shared helper — used by onIframeLoad fallback, handleMessage, and enrichMissingCompositions.
@@ -244,6 +469,7 @@ export function parseTimelineFromDOM(doc: Document, rootDuration: number): Timel
244
469
  selector,
245
470
  selectorIndex,
246
471
  sourceFile,
472
+ timingSource: "authored",
247
473
  };
248
474
 
249
475
  const mediaEl = resolveMediaElement(el);
@@ -275,7 +501,7 @@ export function parseTimelineFromDOM(doc: Document, rootDuration: number): Timel
275
501
  els.push(entry);
276
502
  });
277
503
 
278
- return els;
504
+ return [...els, ...createImplicitTimelineLayersFromDOM(doc, rootDuration, els)];
279
505
  }
280
506
 
281
507
  function isHtmlElement(el: Element): el is HTMLElement {
@@ -335,7 +561,6 @@ function buildTimelineElementKey(params: {
335
561
  if (params.selector) return `${scope}:${params.selector}:${params.selectorIndex ?? 0}`;
336
562
  return `${scope}:${params.id}:${params.fallbackIndex}`;
337
563
  }
338
-
339
564
  function buildTimelineElementIdentity(params: {
340
565
  preferredId?: string | null;
341
566
  label: string;
@@ -557,7 +782,6 @@ export function buildStandaloneRootTimelineElement(params: {
557
782
  sourceFile: compositionSrc,
558
783
  };
559
784
  }
560
-
561
785
  function normalizePreviewViewport(doc: Document, win: Window): void {
562
786
  if (doc.documentElement) {
563
787
  doc.documentElement.style.overflow = "hidden";
@@ -682,6 +906,11 @@ export function useTimelinePlayer() {
682
906
  const iframeShortcutCleanupRef = useRef<(() => void) | null>(null);
683
907
  const playbackKeyDownRef = useRef<(e: KeyboardEvent) => void>(() => {});
684
908
  const playbackKeyUpRef = useRef<(e: KeyboardEvent) => void>(() => {});
909
+ const staticSeekAdapterRef = useRef<{
910
+ player: RuntimePlaybackAdapter;
911
+ duration: number;
912
+ adapter: PlaybackAdapter;
913
+ } | null>(null);
685
914
 
686
915
  // ZERO store subscriptions — this hook never causes re-renders.
687
916
  // All reads use getState() (point-in-time), all writes use the stable setters.
@@ -710,13 +939,18 @@ export function useTimelinePlayer() {
710
939
  try {
711
940
  const iframe = iframeRef.current;
712
941
  const win = iframe?.contentWindow as IframeWindow | null;
713
- if (!win) return null;
942
+ if (!iframe || !win) return null;
714
943
 
715
- if (win.__player && typeof win.__player.play === "function") {
716
- return win.__player;
944
+ const playerAdapter =
945
+ win.__player && typeof win.__player.play === "function" ? win.__player : null;
946
+ if (getAdapterDuration(playerAdapter) > 0) {
947
+ return playerAdapter;
717
948
  }
718
949
 
719
- if (win.__timeline) return wrapTimeline(win.__timeline);
950
+ if (win.__timeline) {
951
+ const adapter = wrapTimeline(win.__timeline);
952
+ if (getAdapterDuration(adapter) > 0) return adapter;
953
+ }
720
954
 
721
955
  if (win.__timelines) {
722
956
  const keys = Object.keys(win.__timelines);
@@ -729,11 +963,40 @@ export function useTimelinePlayer() {
729
963
  ?.querySelector("[data-composition-id]")
730
964
  ?.getAttribute("data-composition-id");
731
965
  const key = rootId && rootId in win.__timelines ? rootId : keys[keys.length - 1];
732
- return wrapTimeline(win.__timelines[key]);
966
+ const adapter = wrapTimeline(win.__timelines[key]);
967
+ if (getAdapterDuration(adapter) > 0) return adapter;
733
968
  }
734
969
  }
735
970
 
736
- return null;
971
+ const fallbackDuration = Math.max(
972
+ usePlayerStore.getState().duration,
973
+ readTimelineDurationFromDocument(iframe.contentDocument),
974
+ );
975
+ if (
976
+ playerAdapter &&
977
+ fallbackDuration > 0 &&
978
+ (typeof playerAdapter.renderSeek === "function" || typeof playerAdapter.seek === "function")
979
+ ) {
980
+ const cached = staticSeekAdapterRef.current;
981
+ if (cached?.player === playerAdapter && cached.duration === fallbackDuration) {
982
+ return cached.adapter;
983
+ }
984
+ cached?.adapter.pause();
985
+ const adapter = createStaticSeekPlaybackAdapter(
986
+ playerAdapter,
987
+ fallbackDuration,
988
+ getDefaultStaticSeekPlaybackClock(win),
989
+ () => usePlayerStore.getState().playbackRate,
990
+ );
991
+ staticSeekAdapterRef.current = {
992
+ player: playerAdapter,
993
+ duration: fallbackDuration,
994
+ adapter,
995
+ };
996
+ return adapter;
997
+ }
998
+
999
+ return playerAdapter;
737
1000
  } catch (err) {
738
1001
  console.warn("[useTimelinePlayer] Could not get playback adapter (cross-origin)", err);
739
1002
  return null;
@@ -1066,8 +1329,15 @@ export function useTimelinePlayer() {
1066
1329
  }))
1067
1330
  .filter((element) => element.duration > 0)
1068
1331
  : els;
1069
- if (clampedEls.length > 0) {
1070
- syncTimelineElements(clampedEls, newDuration > 0 ? newDuration : undefined);
1332
+ const timelineEls =
1333
+ iframeDoc && effectiveDuration > 0
1334
+ ? [
1335
+ ...clampedEls,
1336
+ ...createImplicitTimelineLayersFromDOM(iframeDoc, effectiveDuration, clampedEls),
1337
+ ]
1338
+ : clampedEls;
1339
+ if (timelineEls.length > 0) {
1340
+ syncTimelineElements(timelineEls, newDuration > 0 ? newDuration : undefined);
1071
1341
  }
1072
1342
  },
1073
1343
  [syncTimelineElements],
@@ -1351,6 +1621,9 @@ export function useTimelinePlayer() {
1351
1621
  setIsPlaying(false);
1352
1622
  }, [getAdapter, stopRAFLoop, setIsPlaying, stopReverseLoop]);
1353
1623
 
1624
+ const togglePlayRef = useRef(togglePlay);
1625
+ togglePlayRef.current = togglePlay;
1626
+
1354
1627
  const refreshPlayer = useCallback(() => {
1355
1628
  const iframe = iframeRef.current;
1356
1629
  if (!iframe) return;
@@ -1459,8 +1732,6 @@ export function useTimelinePlayer() {
1459
1732
  stopRAFLoop();
1460
1733
  stopReverseLoop();
1461
1734
  if (probeIntervalRef.current) clearInterval(probeIntervalRef.current);
1462
- // Don't reset() on cleanup — preserve timeline elements across iframe refreshes
1463
- // to prevent blink. New data will replace old when the iframe reloads.
1464
1735
  };
1465
1736
  });
1466
1737
 
@@ -23,6 +23,8 @@ export interface TimelineElement {
23
23
  volume?: number;
24
24
  /** Path from data-composition-src — identifies sub-composition elements */
25
25
  compositionSrc?: string;
26
+ /** Whether this row came from authored clip timing or Studio's full-duration layer fallback. */
27
+ timingSource?: "authored" | "implicit";
26
28
  }
27
29
 
28
30
  export type ZoomMode = "fit" | "manual";
@@ -0,0 +1,89 @@
1
+ import { afterEach, describe, expect, it, vi } from "vitest";
2
+ import { Window } from "happy-dom";
3
+ import { copyTextToClipboard } from "./clipboard";
4
+
5
+ function installDocument(execCommand: (command: string) => boolean): void {
6
+ const window = new Window();
7
+ Object.assign(window, { SyntaxError });
8
+ Object.defineProperty(window.document, "execCommand", {
9
+ configurable: true,
10
+ value: execCommand,
11
+ });
12
+ vi.stubGlobal("document", window.document);
13
+ }
14
+
15
+ function installNavigator(
16
+ writeText: (text: string) => Promise<void>,
17
+ userAgent = "Mozilla/5.0 Chrome/120.0.0.0 Safari/537.36",
18
+ ): void {
19
+ vi.stubGlobal("navigator", {
20
+ clipboard: { writeText },
21
+ userAgent,
22
+ });
23
+ }
24
+
25
+ describe("copyTextToClipboard", () => {
26
+ afterEach(() => {
27
+ vi.unstubAllGlobals();
28
+ });
29
+
30
+ it("uses the synchronous selection copy path first in Safari", async () => {
31
+ const execCommand = vi.fn((command: string) => command === "copy");
32
+ const writeText = vi.fn((_text: string) => Promise.resolve());
33
+
34
+ installDocument(execCommand);
35
+ installNavigator(
36
+ writeText,
37
+ "Mozilla/5.0 (Macintosh; Intel Mac OS X 10_15_7) AppleWebKit/605.1.15 (KHTML, like Gecko) Version/26.0 Safari/605.1.15",
38
+ );
39
+
40
+ await expect(copyTextToClipboard("copy me")).resolves.toBe(true);
41
+
42
+ expect(execCommand).toHaveBeenCalledWith("copy");
43
+ expect(writeText).not.toHaveBeenCalled();
44
+ expect(document.querySelector("textarea")).toBeNull();
45
+ });
46
+
47
+ it("uses navigator.clipboard first outside Safari", async () => {
48
+ const execCommand = vi.fn((command: string) => command === "copy");
49
+ const writeText = vi.fn((_text: string) => Promise.resolve());
50
+
51
+ installDocument(execCommand);
52
+ installNavigator(writeText);
53
+
54
+ await expect(copyTextToClipboard("copy me")).resolves.toBe(true);
55
+
56
+ expect(writeText).toHaveBeenCalledWith("copy me");
57
+ expect(execCommand).not.toHaveBeenCalled();
58
+ });
59
+
60
+ it("falls back to selection copy outside Safari when navigator.clipboard fails", async () => {
61
+ const execCommand = vi.fn((command: string) => command === "copy");
62
+ const writeText = vi.fn((_text: string) => Promise.reject(new Error("blocked")));
63
+
64
+ installDocument(execCommand);
65
+ installNavigator(writeText);
66
+
67
+ await expect(copyTextToClipboard("copy me")).resolves.toBe(true);
68
+
69
+ expect(writeText).toHaveBeenCalledWith("copy me");
70
+ expect(execCommand).toHaveBeenCalledWith("copy");
71
+ });
72
+
73
+ it("reports failure when both copy paths fail", async () => {
74
+ const execCommand = vi.fn(() => false);
75
+ const writeText = vi.fn((_text: string) => Promise.reject(new Error("blocked")));
76
+
77
+ installDocument(execCommand);
78
+ installNavigator(
79
+ writeText,
80
+ "Mozilla/5.0 (Macintosh; Intel Mac OS X 10_15_7) AppleWebKit/605.1.15 (KHTML, like Gecko) Version/26.0 Safari/605.1.15",
81
+ );
82
+
83
+ await expect(copyTextToClipboard("copy me")).resolves.toBe(false);
84
+
85
+ expect(execCommand).toHaveBeenCalledWith("copy");
86
+ expect(writeText).toHaveBeenCalledWith("copy me");
87
+ expect(document.querySelector("textarea")).toBeNull();
88
+ });
89
+ });
@@ -0,0 +1,57 @@
1
+ function copyWithSelection(text: string): boolean {
2
+ if (typeof document === "undefined" || !document.body || !document.execCommand) {
3
+ return false;
4
+ }
5
+
6
+ const textarea = document.createElement("textarea");
7
+ textarea.value = text;
8
+ textarea.setAttribute("readonly", "true");
9
+ textarea.style.position = "fixed";
10
+ textarea.style.top = "0";
11
+ textarea.style.left = "0";
12
+ textarea.style.width = "1px";
13
+ textarea.style.height = "1px";
14
+ textarea.style.padding = "0";
15
+ textarea.style.border = "0";
16
+ textarea.style.opacity = "0";
17
+ textarea.style.pointerEvents = "none";
18
+
19
+ document.body.appendChild(textarea);
20
+ textarea.focus({ preventScroll: true });
21
+ textarea.select();
22
+ textarea.setSelectionRange(0, text.length);
23
+
24
+ try {
25
+ return document.execCommand("copy");
26
+ } catch {
27
+ return false;
28
+ } finally {
29
+ document.body.removeChild(textarea);
30
+ }
31
+ }
32
+
33
+ function shouldCopyWithSelectionFirst(): boolean {
34
+ if (typeof navigator === "undefined") return false;
35
+
36
+ const userAgent = navigator.userAgent;
37
+ return /Safari/i.test(userAgent) && !/Chrome|Chromium|CriOS|FxiOS|Edg|OPR/i.test(userAgent);
38
+ }
39
+
40
+ export async function copyTextToClipboard(text: string): Promise<boolean> {
41
+ const useSelectionFirst = shouldCopyWithSelectionFirst();
42
+ if (useSelectionFirst && copyWithSelection(text)) {
43
+ return true;
44
+ }
45
+
46
+ const clipboard = typeof navigator !== "undefined" ? navigator.clipboard : undefined;
47
+ if (clipboard?.writeText) {
48
+ try {
49
+ await clipboard.writeText(text);
50
+ return true;
51
+ } catch {
52
+ // Fall back below when the browser still allows synchronous copy.
53
+ }
54
+ }
55
+
56
+ return !useSelectionFirst && copyWithSelection(text);
57
+ }