@hyperframes/studio 0.8.53-desktop.0 → 0.8.53

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 (38) hide show
  1. package/dist/assets/{hyperframes-player-PTEnAHhg.js → hyperframes-player-DKWM-Ons.js} +1 -1
  2. package/dist/assets/{index-BnXZuIJD.css → index-DrHQAuGO.css} +1 -1
  3. package/dist/assets/{index-CNyviK_l.js → index-HfP3sap2.js} +200 -200
  4. package/dist/assets/{index-BjslOS9g.js → index-UvW6BEwf.js} +1 -1
  5. package/dist/assets/{index--myXp8ui.js → index-Y4OnpTEM.js} +1 -1
  6. package/dist/index.d.ts +11 -406
  7. package/dist/index.html +2 -2
  8. package/dist/index.js +7570 -7709
  9. package/dist/index.js.map +1 -1
  10. package/dist/styles.css +1 -0
  11. package/package.json +8 -7
  12. package/src/components/StudioHeader.tsx +1 -1
  13. package/src/components/nle/TimelinePane.tsx +0 -7
  14. package/src/components/renders/RenderQueue.test.tsx +133 -25
  15. package/src/components/renders/RenderQueue.tsx +99 -92
  16. package/src/components/renders/RenderQueueItem.tsx +69 -64
  17. package/src/components/ui/Select.tsx +4 -0
  18. package/src/headerExports.test.tsx +23 -0
  19. package/src/hooks/useFileManager.ts +123 -21
  20. package/src/hooks/usePersistentEditHistory.ts +1 -1
  21. package/src/hooks/useRightPanelIntents.ts +2 -1
  22. package/src/hooks/useTimelineDeleteOps.test.tsx +0 -111
  23. package/src/hooks/useTimelineEditing.test.tsx +3 -322
  24. package/src/hooks/useTimelineEditing.ts +16 -103
  25. package/src/hooks/useTimelineEditingTypes.ts +0 -3
  26. package/src/index.ts +2 -37
  27. package/src/main.tsx +26 -0
  28. package/src/player/components/TimelineTrackHeader.test.tsx +5 -26
  29. package/src/player/components/TimelineTrackHeader.tsx +1 -1
  30. package/src/player/components/timelineTheme.test.ts +4 -4
  31. package/src/player/components/timelineTheme.ts +30 -30
  32. package/src/styles/hex-baseline.json +2 -2
  33. package/src/styles/theme.css +0 -27
  34. package/src/hooks/timelineEditPermission.ts +0 -31
  35. package/src/hooks/useProjectFileWriter.ts +0 -150
  36. package/src/hooks/useTrackPendingTimelineEdit.test.tsx +0 -62
  37. package/src/hooks/useTrackPendingTimelineEdit.ts +0 -22
  38. package/src/hostMount.test.tsx +0 -222
@@ -5,7 +5,7 @@ import { createRoot, type Root } from "react-dom/client";
5
5
  import type { GsapAnimation, PropertyGroupName } from "@hyperframes/core/gsap-parser";
6
6
  import { afterEach, describe, expect, it, vi } from "vitest";
7
7
  import { TimelinePropertyLanes } from "./TimelinePropertyLanes";
8
- import { TimelineTrackHeader, gutterFill } from "./TimelineTrackHeader";
8
+ import { TimelineTrackHeader } from "./TimelineTrackHeader";
9
9
  import { defaultTimelineTheme } from "./timelineTheme";
10
10
  import { type TimelineElement } from "../store/playerStore";
11
11
  import type { TimelineEditCallbacks } from "./timelineCallbacks";
@@ -794,6 +794,10 @@ describe("TimelineTrackHeader", () => {
794
794
  });
795
795
  expect(header()?.style.paddingLeft).toBe("14px");
796
796
  expect(header()?.style.borderLeft).toContain("2px");
797
+ // And a lighter gutter, so the row reads as sitting INSIDE its group
798
+ // rather than beside it. Overlaid on the theme's own fill rather than a
799
+ // hard-coded colour, so it follows whatever the gutter is.
800
+ expect(header()?.style.background).toContain("linear-gradient");
797
801
  act(() => view.root.unmount());
798
802
  });
799
803
 
@@ -908,28 +912,3 @@ describe("TimelineTrackHeader", () => {
908
912
  });
909
913
  });
910
914
  });
911
-
912
- // AD147: a host themes the timeline by overriding --timeline-* on theme.css,
913
- // so the rendered gutter must carry the CSS variable, not a baked-in colour.
914
- describe("theming", () => {
915
- it("reads the gutter background and border from timeline theme tokens", () => {
916
- const view = renderHeader({});
917
- const header = view.host.querySelector<HTMLElement>('[role="rowheader"]');
918
- expect(header?.style.background).toBe(defaultTimelineTheme.gutterBackground);
919
- expect(header?.style.background).toContain("var(--timeline-gutter-bg)");
920
- expect(header?.style.borderRight).toContain(defaultTimelineTheme.gutterBorder);
921
- act(() => view.root.unmount());
922
- });
923
-
924
- // happy-dom's `background` shorthand garbles a `var()` layer next to
925
- // `linear-gradient(...)` (verified), so this checks the same value through
926
- // the pure function instead of the broken CSSOM roundtrip.
927
- it("overlays the group-member tint on the themed gutter, not a hard-coded colour", () => {
928
- const filled = gutterFill(defaultTimelineTheme.gutterBackground, true);
929
- expect(filled).toContain("linear-gradient");
930
- expect(filled.endsWith(defaultTimelineTheme.gutterBackground)).toBe(true);
931
- expect(gutterFill(defaultTimelineTheme.gutterBackground, false)).toBe(
932
- defaultTimelineTheme.gutterBackground,
933
- );
934
- });
935
- });
@@ -34,7 +34,7 @@ const GROUP_MEMBER_INDENT = 14;
34
34
  const GROUP_MEMBER_TINT = "rgba(255,255,255,0.035)";
35
35
 
36
36
  /** The gutter fill for a row, tinted when it belongs to a group. */
37
- export function gutterFill(base: string, isGroupMember: boolean): string {
37
+ function gutterFill(base: string, isGroupMember: boolean): string {
38
38
  return isGroupMember
39
39
  ? `linear-gradient(${GROUP_MEMBER_TINT}, ${GROUP_MEMBER_TINT}), ${base}`
40
40
  : base;
@@ -9,10 +9,10 @@ import { getTrackStyle } from "./timelineIcons";
9
9
  describe("getTimelineTrackStyle", () => {
10
10
  it("uses one neutral clip style for every timeline tag", () => {
11
11
  const expectedStyle = {
12
- clip: "var(--timeline-track-clip-fill)",
13
- clipActive: "var(--timeline-track-clip-active)",
14
- accent: "var(--color-accent)",
15
- label: "var(--timeline-track-label)",
12
+ clip: "rgba(255,255,255,0.055)",
13
+ clipActive: "rgba(60,230,172,0.16)",
14
+ accent: "#3CE6AC",
15
+ label: "rgba(255,255,255,0.5)",
16
16
  };
17
17
 
18
18
  expect(getTimelineTrackStyle("video")).toEqual(expectedStyle);
@@ -36,43 +36,43 @@ export interface TimelineTheme {
36
36
  }
37
37
 
38
38
  const TRACK_STYLE: TimelineTrackStyle = {
39
- clip: "var(--timeline-track-clip-fill)",
40
- clipActive: "var(--timeline-track-clip-active)",
41
- accent: "var(--color-accent)",
42
- label: "var(--timeline-track-label)",
39
+ clip: "rgba(255,255,255,0.055)",
40
+ clipActive: "rgba(60,230,172,0.16)",
41
+ accent: "#3CE6AC",
42
+ label: "rgba(255,255,255,0.5)",
43
43
  };
44
44
 
45
- // Every field reads a CSS custom property (declared in styles/theme.css)
46
- // rather than a literal, so a host themes the timeline the same way it
47
- // themes the rest of Studio: by overriding the token, not this object.
48
45
  export const defaultTimelineTheme: TimelineTheme = {
49
- shellBackground: "var(--timeline-shell-bg)",
50
- shellBorder: "var(--timeline-shell-border)",
51
- rulerBorder: "var(--timeline-ruler-border)",
52
- rowBackground: "var(--timeline-row-bg)",
53
- rowBorder: "var(--timeline-row-border)",
54
- gutterBackground: "var(--timeline-gutter-bg)",
55
- gutterBorder: "var(--timeline-gutter-border)",
56
- textPrimary: "var(--timeline-text-primary)",
57
- textSecondary: "var(--timeline-text-secondary)",
58
- tickText: "var(--timeline-tick-text)",
59
- tickMajor: "var(--timeline-tick-major)",
60
- tickMinor: "var(--timeline-tick-minor)",
61
- clipBackground: "var(--timeline-clip-bg)",
62
- clipBackgroundActive: "var(--timeline-clip-bg-active)",
63
- clipBorder: "var(--timeline-clip-border)",
64
- clipBorderHover: "var(--timeline-clip-border-hover)",
65
- clipBorderActive: "var(--timeline-clip-border-active)",
66
- // Shadows stay literal: a light host still wants a dark contact shadow, so
67
- // these are not part of the surface/hairline/text/clip-colour theming ask.
46
+ // Near-black card surfaces: the panels sit dark while the shell canvas
47
+ // between them is a step LIGHTER (#18181B), so the gaps read as visible
48
+ // seams around dark cards (CapCut-style).
49
+ shellBackground: "#0A0A0B",
50
+ shellBorder: "rgba(255,255,255,0.05)",
51
+ rulerBorder: "rgba(255,255,255,0.16)",
52
+ // All track lanes use a single uniform color — one step lighter than the panel
53
+ // surface (#0A0A0B) so lanes are visibly distinct from the ruler/shell.
54
+ rowBackground: "#101014",
55
+ rowBorder: "rgba(255,255,255,0.06)",
56
+ gutterBackground: "#0E0F12",
57
+ gutterBorder: "rgba(255,255,255,0.10)",
58
+ textPrimary: "rgba(255,255,255,0.92)",
59
+ textSecondary: "rgba(255,255,255,0.62)",
60
+ tickText: "rgba(255,255,255,0.34)",
61
+ tickMajor: "rgba(255,255,255,0.10)",
62
+ tickMinor: "rgba(255,255,255,0.06)",
63
+ clipBackground: "#141922",
64
+ clipBackgroundActive: "#181e28",
65
+ clipBorder: "rgba(255,255,255,0.10)",
66
+ clipBorderHover: "rgba(255,255,255,0.18)",
67
+ clipBorderActive: "rgba(255,255,255,0.24)",
68
68
  clipShadow: "none",
69
69
  clipShadowHover: "0 2px 8px rgba(0,0,0,0.2)",
70
70
  clipShadowActive: "0 2px 8px rgba(0,0,0,0.2), 0 0 0 1px rgba(255,255,255,0.04)",
71
71
  clipShadowDragging: "0 8px 24px rgba(0,0,0,0.4), 0 0 0 1px rgba(255,255,255,0.06)",
72
- handleColor: "var(--timeline-handle)",
73
- panelResizeSeam: "var(--timeline-resize-seam)",
74
- panelResizeActive: "var(--timeline-resize-active)",
75
- clipRadius: "var(--radius-lg)",
72
+ handleColor: "rgba(255,255,255,0.2)",
73
+ panelResizeSeam: "rgba(255,255,255,0.12)",
74
+ panelResizeActive: "rgba(255,255,255,0.24)",
75
+ clipRadius: "8px",
76
76
  };
77
77
 
78
78
  export function getTimelineTrackStyle(_tag: string): TimelineTrackStyle {
@@ -1,5 +1,5 @@
1
1
  {
2
- "total": 394,
2
+ "total": 418,
3
3
  "files": {
4
4
  "packages/studio/src/captions/components/CaptionOverlay.tsx": 5,
5
5
  "packages/studio/src/captions/components/CaptionTimeline.tsx": 11,
@@ -75,7 +75,7 @@
75
75
  "packages/studio/src/player/components/TimelineTrackHeader.tsx": 2,
76
76
  "packages/studio/src/player/components/TimelineTrackPlainHeader.tsx": 2,
77
77
  "packages/studio/src/player/components/VideoThumbnail.tsx": 6,
78
- "packages/studio/src/player/components/timelineTheme.ts": 5,
78
+ "packages/studio/src/player/components/timelineTheme.ts": 29,
79
79
  "packages/studio/src/player/components/trackHeaderLabelRows.tsx": 6,
80
80
  "packages/studio/src/styles/studio.css": 70,
81
81
  "packages/studio/src/telemetry/client.ts": 1,
@@ -46,33 +46,6 @@
46
46
  --color-brand-gradient-from: #06e3fa;
47
47
  --color-brand-gradient-to: #4fdb5e;
48
48
 
49
- /* The timeline's own surfaces: darker and bluer than the shared scale, so they
50
- * get their own names instead of snapping to the nearest --color-bg/-surface
51
- * step (AD147: a host themes the timeline by overriding these). */
52
- --timeline-shell-bg: #0a0a0b;
53
- --timeline-shell-border: rgba(255, 255, 255, 0.05);
54
- --timeline-ruler-border: rgba(255, 255, 255, 0.16);
55
- --timeline-row-bg: #101014;
56
- --timeline-row-border: rgba(255, 255, 255, 0.06);
57
- --timeline-gutter-bg: #0e0f12;
58
- --timeline-gutter-border: rgba(255, 255, 255, 0.1);
59
- --timeline-text-primary: rgba(255, 255, 255, 0.92);
60
- --timeline-text-secondary: rgba(255, 255, 255, 0.62);
61
- --timeline-tick-text: rgba(255, 255, 255, 0.34);
62
- --timeline-tick-major: rgba(255, 255, 255, 0.1);
63
- --timeline-tick-minor: rgba(255, 255, 255, 0.06);
64
- --timeline-clip-bg: #141922;
65
- --timeline-clip-bg-active: #181e28;
66
- --timeline-clip-border: rgba(255, 255, 255, 0.1);
67
- --timeline-clip-border-hover: rgba(255, 255, 255, 0.18);
68
- --timeline-clip-border-active: rgba(255, 255, 255, 0.24);
69
- --timeline-handle: rgba(255, 255, 255, 0.2);
70
- --timeline-resize-seam: rgba(255, 255, 255, 0.12);
71
- --timeline-resize-active: rgba(255, 255, 255, 0.24);
72
- --timeline-track-clip-fill: rgba(255, 255, 255, 0.055);
73
- --timeline-track-clip-active: rgba(60, 230, 172, 0.16);
74
- --timeline-track-label: rgba(255, 255, 255, 0.5);
75
-
76
49
  /* -------------------------------------------------------------- geometry */
77
50
 
78
51
  --radius-sm: 0.25rem;
@@ -1,31 +0,0 @@
1
- import { useCallback } from "react";
2
- import type { TimelineElement } from "../player";
3
-
4
- /** A host's verdict on one element: editable, or blocked with a reason to show. */
5
- export type TimelineEditPermission = true | { blocked: true; reason: string };
6
-
7
- export type CanEditTimelineElement = (element: TimelineElement) => TimelineEditPermission;
8
-
9
- /**
10
- * Refuses a write when any target element is blocked, toasting the host's
11
- * reason. Absent `canEdit` never refuses, so Studio itself is unchanged.
12
- */
13
- export function useTimelineEditGate(
14
- canEdit: CanEditTimelineElement | undefined,
15
- showToast: (message: string, tone?: "error" | "info") => void,
16
- ) {
17
- return useCallback(
18
- (targets: readonly TimelineElement[]): boolean => {
19
- if (!canEdit) return true;
20
- for (const element of targets) {
21
- const verdict = canEdit(element);
22
- if (verdict !== true) {
23
- showToast(verdict.reason, "error");
24
- return false;
25
- }
26
- }
27
- return true;
28
- },
29
- [canEdit, showToast],
30
- );
31
- }
@@ -1,150 +0,0 @@
1
- import { useCallback, useMemo } from "react";
2
- import {
3
- createStudioSaveHttpError,
4
- retryStudioSave,
5
- StudioFileConflictError,
6
- StudioSaveNetworkError,
7
- } from "../utils/studioSaveDiagnostics";
8
- import { studioExpectedFileVersion, studioWriteHeaders } from "../utils/studioFileVersion";
9
-
10
- export interface UseProjectFileWriterOptions {
11
- projectId: string | null;
12
- }
13
-
14
- // The etag-guarded project-file read/write pair every Studio save path
15
- // shares. A host mounting hand editing outside Studio needs only this, not
16
- // the full file manager (file tree, editor tabs, uploads, font assets).
17
- export function useProjectFileWriter({ projectId }: UseProjectFileWriterOptions) {
18
- const fileVersionScope = useMemo(
19
- () => ({ projectId, versions: new Map<string, string | null>() }),
20
- [projectId],
21
- );
22
- const fileVersions = fileVersionScope.versions;
23
-
24
- const observeProjectFileVersion = useCallback(
25
- (path: string, version: string | null) => {
26
- fileVersions.set(path, version);
27
- },
28
- [fileVersions],
29
- );
30
-
31
- const readProjectFile = useCallback(
32
- async (path: string): Promise<string> => {
33
- if (!projectId) throw new Error("No active project");
34
- const response = await fetch(
35
- `/api/projects/${encodeURIComponent(projectId)}/files/${encodeURIComponent(path)}`,
36
- );
37
- if (!response.ok) throw new Error(`Failed to read ${path}`);
38
- const data = (await response.json()) as { content?: string; version?: string };
39
- if (typeof data.content !== "string") throw new Error(`Missing file contents for ${path}`);
40
- fileVersions.set(path, data.version ?? response.headers.get("etag"));
41
- return data.content;
42
- },
43
- [fileVersions, projectId],
44
- );
45
-
46
- const readOptionalProjectFile = useCallback(
47
- async (path: string): Promise<string> => {
48
- if (!projectId) throw new Error("No active project");
49
- const response = await fetch(
50
- `/api/projects/${encodeURIComponent(projectId)}/files/${encodeURIComponent(path)}?optional=1`,
51
- );
52
- if (!response.ok) throw new Error(`Failed to read ${path}`);
53
- const data = (await response.json()) as { content?: string; version?: string };
54
- fileVersions.set(path, data.version ?? response.headers.get("etag"));
55
- return typeof data.content === "string" ? data.content : "";
56
- },
57
- [fileVersions, projectId],
58
- );
59
-
60
- const writeProjectFile = useCallback(
61
- async (path: string, content: string, expectedContent?: string): Promise<void> => {
62
- if (!projectId) throw new Error("No active project");
63
- const writeProjectId = projectId;
64
- let expectedVersion = await studioExpectedFileVersion(fileVersions, path, expectedContent);
65
- if (expectedVersion === undefined) {
66
- const preflight = await fetch(
67
- `/api/projects/${encodeURIComponent(writeProjectId)}/files/${encodeURIComponent(path)}`,
68
- );
69
- if (preflight.ok) {
70
- const data = (await preflight.json()) as { content?: string; version?: string };
71
- throw new StudioFileConflictError({
72
- filePath: path,
73
- currentVersion: data.version ?? preflight.headers.get("etag"),
74
- currentContent: data.content ?? null,
75
- attemptedContent: content,
76
- });
77
- } else if (preflight.status === 404) {
78
- expectedVersion = null;
79
- } else {
80
- throw await createStudioSaveHttpError(preflight, `Failed to read ${path} before save`);
81
- }
82
- }
83
- // fallow-ignore-next-line complexity
84
- await retryStudioSave(async () => {
85
- // Each request gets its own receipt identity. If a committed request loses its response,
86
- // the retry can produce a second filesystem receipt that must be suppressed independently.
87
- let response: Response;
88
- try {
89
- response = await fetch(
90
- `/api/projects/${encodeURIComponent(writeProjectId)}/files/${encodeURIComponent(path)}`,
91
- {
92
- method: "PUT",
93
- headers: {
94
- "Content-Type": "text/plain",
95
- ...studioWriteHeaders(),
96
- ...(expectedVersion ? { "If-Match": expectedVersion } : { "If-None-Match": "*" }),
97
- },
98
- body: content,
99
- },
100
- );
101
- } catch (error) {
102
- throw new StudioSaveNetworkError(`Failed to save ${path}: network error`, {
103
- cause: error,
104
- });
105
- }
106
- if (response.status === 409) {
107
- const conflict = (await response.json().catch(() => null)) as {
108
- currentVersion?: string | null;
109
- currentContent?: string | null;
110
- } | null;
111
- const currentVersion = conflict?.currentVersion ?? null;
112
- if (currentVersion && conflict?.currentContent === content) {
113
- fileVersions.set(path, currentVersion);
114
- return;
115
- }
116
- throw new StudioFileConflictError({
117
- filePath: path,
118
- currentVersion,
119
- currentContent: conflict?.currentContent ?? null,
120
- attemptedContent: content,
121
- });
122
- }
123
- if (!response.ok) throw await createStudioSaveHttpError(response, `Failed to save ${path}`);
124
- const result = (await response.json()) as { version?: string };
125
- const version = result.version ?? response.headers.get("etag");
126
- if (!version)
127
- throw new Error(`Save response for ${path} did not include a content version`);
128
- fileVersions.set(path, version);
129
- });
130
- },
131
- [fileVersions, projectId],
132
- );
133
-
134
- return useMemo(
135
- () => ({
136
- fileVersions,
137
- readProjectFile,
138
- writeProjectFile,
139
- readOptionalProjectFile,
140
- observeProjectFileVersion,
141
- }),
142
- [
143
- fileVersions,
144
- readProjectFile,
145
- writeProjectFile,
146
- readOptionalProjectFile,
147
- observeProjectFileVersion,
148
- ],
149
- );
150
- }
@@ -1,62 +0,0 @@
1
- // @vitest-environment happy-dom
2
-
3
- import { act, createElement } from "react";
4
- import { createRoot, type Root } from "react-dom/client";
5
- import { afterEach, describe, expect, it } from "vitest";
6
- import { flushStudioPendingEdits } from "../utils/studioPendingEdits";
7
- import { useTrackPendingTimelineEdit } from "./useTrackPendingTimelineEdit";
8
-
9
- (globalThis as unknown as { IS_REACT_ACT_ENVIRONMENT: boolean }).IS_REACT_ACT_ENVIRONMENT = true;
10
-
11
- let root: Root | null = null;
12
- afterEach(() => act(() => root?.unmount()));
13
-
14
- function mount() {
15
- let track!: ReturnType<typeof useTrackPendingTimelineEdit>;
16
- function Probe() {
17
- track = useTrackPendingTimelineEdit();
18
- return null;
19
- }
20
- root = createRoot(document.createElement("div"));
21
- act(() => root!.render(createElement(Probe)));
22
- return track;
23
- }
24
-
25
- const deferred = <T,>() => {
26
- let resolve!: (value: T) => void;
27
- const promise = new Promise<T>((r) => (resolve = r));
28
- return { promise, resolve };
29
- };
30
-
31
- describe("useTrackPendingTimelineEdit", () => {
32
- it("feeds Studio's shared pending-edit registry, so the app-wide flush waits for it", async () => {
33
- const track = mount();
34
- const gate = deferred<void>();
35
- const order: string[] = [];
36
- const tracked = track(() => gate.promise.then(() => order.push("edit-settled")));
37
- const call = tracked();
38
- const flushPromise = flushStudioPendingEdits().then(() => order.push("flushed"));
39
- // Give the flush's own microtasks a turn: if tracking did nothing, it
40
- // would already show "flushed" here, before the edit ever settles.
41
- await Promise.resolve();
42
- await Promise.resolve();
43
- expect(order).toEqual([]);
44
- gate.resolve();
45
- await call;
46
- await flushPromise;
47
- expect(order).toEqual(["edit-settled", "flushed"]);
48
- });
49
-
50
- it("returns the same wrapper for the same handler across calls", () => {
51
- const track = mount();
52
- const handler = () => Promise.resolve();
53
- expect(track(handler)).toBe(track(handler));
54
- });
55
-
56
- it("returns a different wrapper for a different handler", () => {
57
- const track = mount();
58
- const a = () => Promise.resolve();
59
- const b = () => Promise.resolve();
60
- expect(track(a)).not.toBe(track(b));
61
- });
62
- });
@@ -1,22 +0,0 @@
1
- import { useCallback, useRef } from "react";
2
- import { trackStudioPendingEdit } from "../utils/studioPendingEdits";
3
-
4
- type AsyncFn = (...args: never[]) => Promise<unknown>;
5
-
6
- // Tracks a hand-edit handler's write so undo/redo (already draining this
7
- // registry) waits for it. Same `fn` in, same wrapper out, so memoized deps stay stable.
8
- export function useTrackPendingTimelineEdit() {
9
- const wrappedRef = useRef(new WeakMap<AsyncFn, AsyncFn>());
10
- return useCallback(<Args extends unknown[], R>(fn: (...args: Args) => Promise<R>) => {
11
- const key = fn as unknown as AsyncFn;
12
- const cached = wrappedRef.current.get(key);
13
- if (cached) return cached as unknown as (...args: Args) => Promise<R>;
14
- const wrapped = (...args: Args): Promise<R> => {
15
- const result = fn(...args);
16
- trackStudioPendingEdit(result);
17
- return result;
18
- };
19
- wrappedRef.current.set(key, wrapped as unknown as AsyncFn);
20
- return wrapped;
21
- }, []);
22
- }