@hyperframes/studio 0.8.81 → 0.8.83
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/dist/assets/{hyperframes-player-C68UawR3.js → hyperframes-player-BAerMCex.js} +1 -1
- package/dist/assets/{index-CMzPGv9P.js → index-BUhcVhNy.js} +1 -1
- package/dist/assets/{index-hNVM9Pd2.js → index-BYhK_KMc.js} +187 -187
- package/dist/assets/{index-CS5SImRT.js → index-Iv0HI7eM.js} +1 -1
- package/dist/{chunk-I7XNAIOF.js → chunk-3IOCVWB6.js} +6 -97
- package/dist/chunk-3IOCVWB6.js.map +1 -0
- package/dist/chunk-62DDWOSH.js +189 -0
- package/dist/chunk-62DDWOSH.js.map +1 -0
- package/dist/chunk-LPZMAGS7.js +1871 -0
- package/dist/chunk-LPZMAGS7.js.map +1 -0
- package/dist/chunk-QZ6HNNFG.js +934 -0
- package/dist/chunk-QZ6HNNFG.js.map +1 -0
- package/dist/chunk-S65AQH6M.js +101 -0
- package/dist/chunk-S65AQH6M.js.map +1 -0
- package/dist/{domEditingLayers-MHFDKAQ7.js → domEditingLayers-ZOOMN7CQ.js} +3 -2
- package/dist/index.d.ts +834 -330
- package/dist/index.html +1 -1
- package/dist/index.js +26378 -28802
- package/dist/index.js.map +1 -1
- package/dist/player.d.ts +30 -0
- package/dist/player.js +10 -0
- package/dist/player.js.map +1 -0
- package/dist/ui.d.ts +294 -0
- package/dist/ui.js +66 -0
- package/dist/ui.js.map +1 -0
- package/package.json +16 -8
- package/src/components/StudioHeader.tsx +1 -121
- package/src/components/TimelineHistoryButtons.dom.test.tsx +38 -1
- package/src/components/TimelineHistoryButtons.tsx +26 -14
- package/src/components/TimelineToolbar.test.tsx +9 -1
- package/src/components/TimelineToolbar.tsx +13 -5
- package/src/components/editor/BlockParamsPanel.tsx +14 -10
- package/src/components/editor/CanvasContextMenu.test.tsx +3 -3
- package/src/components/editor/CanvasContextMenu.tsx +1 -1
- package/src/components/editor/ConnectedDomEditOverlay.test.tsx +163 -0
- package/src/components/editor/ConnectedDomEditOverlay.tsx +206 -0
- package/src/components/editor/DomEditOverlay.tsx +36 -21
- package/src/components/editor/LayersPanel.tsx +1 -1
- package/src/components/editor/MarqueeOverlay.tsx +1 -1
- package/src/components/editor/SnapGuideOverlay.tsx +1 -1
- package/src/components/editor/anchoredResizeReleaseShift.test.ts +2 -6
- package/src/components/editor/clipAudioClock.test.ts +61 -0
- package/src/components/editor/clipAudioClock.ts +78 -0
- package/src/components/editor/manualEdits.test.ts +24 -0
- package/src/components/editor/manualEdits.ts +6 -2
- package/src/components/editor/manualEditsDom.ts +0 -65
- package/src/components/editor/manualEditsSeekReapply.ts +136 -0
- package/src/components/editor/marqueeCommit.ts +100 -37
- package/src/components/editor/previewReadOnly.overlay.test.tsx +220 -17
- package/src/components/editor/propertyPanelAudioFxGroup.test.tsx +206 -32
- package/src/components/editor/propertyPanelFlatMediaSection.test.tsx +62 -0
- package/src/components/editor/propertyPanelFlatMediaSection.tsx +4 -6
- package/src/components/editor/propertyPanelHelpers.ts +15 -0
- package/src/components/editor/propertyPanelMediaSection.tsx +3 -5
- package/src/components/editor/reapplyBoxSizeAfterSeek.test.ts +1 -1
- package/src/components/editor/reapplyPositionEditsAfterSeek.test.ts +117 -0
- package/src/components/editor/useFxCarve.ts +11 -15
- package/src/components/editor/useFxCarveNodes.ts +27 -15
- package/src/components/editor/useFxLevelling.ts +20 -31
- package/src/components/editor/useInlineTextEditing.tsx +35 -19
- package/src/components/feedback/projectProvenance.ts +2 -3
- package/src/components/nle/AudioMeterStrip.test.tsx +26 -3
- package/src/components/nle/AudioMeterStrip.tsx +23 -12
- package/src/components/nle/PreviewOverlays.tsx +7 -188
- package/src/components/nle/PreviewOverlaysTestMocks.ts +2 -2
- package/src/components/nle/useCanvasZOrderTimelineMirror.ts +1 -1
- package/src/components/pointerTargetSize.test.tsx +1 -1
- package/src/components/studioColorGradingScope.ts +12 -36
- package/src/components/ui/HyperframesLogo.tsx +120 -0
- package/src/contexts/TimelineEditContext.test.tsx +49 -0
- package/src/contexts/TimelineEditContext.tsx +27 -23
- package/src/domEditExports.test.tsx +69 -0
- package/src/editorGesturesExports.test.tsx +236 -0
- package/src/hooks/canvasQuickSaves.test.tsx +195 -0
- package/src/hooks/gsapMutationClient.ts +116 -0
- package/src/hooks/resizeObserverTestUtils.ts +14 -0
- package/src/hooks/timelineAudioGroupCreate.ts +18 -40
- package/src/hooks/timelineEditingHelpers.test.ts +21 -0
- package/src/hooks/timelineEditingHelpers.ts +66 -61
- package/src/hooks/timelineQuickSaves.test.ts +195 -0
- package/src/hooks/timelineTimingSync.test.ts +12 -0
- package/src/hooks/timelineTimingSync.ts +31 -123
- package/src/hooks/timelineTrackVisibility.ts +15 -29
- package/src/hooks/useAskAgentModal.test.tsx +78 -0
- package/src/hooks/useAskAgentModal.ts +3 -1
- package/src/hooks/useClipboard.source.test.ts +32 -0
- package/src/hooks/useClipboard.ts +21 -20
- package/src/hooks/useDomEditCommits.test.tsx +13 -9
- package/src/hooks/useDomEditCommits.ts +88 -97
- package/src/hooks/useDomEditCommitsHelpers.ts +69 -6
- package/src/hooks/useEditorSave.ts +1 -1
- package/src/hooks/useElementLifecycleOps.ts +37 -38
- package/src/hooks/useFileManager.projectOwnership.test.tsx +51 -4
- package/src/hooks/useFileManager.ts +21 -12
- package/src/hooks/useGroupCommits.ts +23 -24
- package/src/hooks/useGsapAnimationOps.ts +26 -8
- package/src/hooks/useGsapScriptCommits.ts +1 -0
- package/src/hooks/useKeyframeKeyboard.test.tsx +99 -0
- package/src/hooks/useKeyframeKeyboard.ts +9 -71
- package/src/hooks/usePreviewPersistence.ts +1 -1
- package/src/hooks/useThumbnailStripSize.test.tsx +47 -0
- package/src/hooks/useThumbnailStripSize.ts +31 -0
- package/src/hooks/useTimelineAssetDropOps.ts +38 -35
- package/src/hooks/useTimelineDeleteOps.ts +53 -56
- package/src/hooks/useTimelineEditing.ts +3 -1
- package/src/hooks/useTimelineGroupEditing.ts +5 -1
- package/src/index.ts +53 -32
- package/src/lightEntries.test.ts +32 -0
- package/src/player/components/CompositionThumbnail.test.ts +40 -6
- package/src/player/components/CompositionThumbnail.tsx +6 -21
- package/src/player/components/ImageThumbnail.test.tsx +27 -6
- package/src/player/components/ImageThumbnail.tsx +4 -18
- package/src/player/components/PlayerControls.test.tsx +52 -0
- package/src/player/components/PlayerControls.tsx +2 -1
- package/src/player/components/Timeline.test.ts +28 -3
- package/src/player/components/TimelineCanvas.tsx +5 -3
- package/src/player/components/TimelineClipFades.test.tsx +31 -10
- package/src/player/components/TimelineClipFades.tsx +2 -1
- package/src/player/components/TimelineTypes.ts +4 -2
- package/src/player/components/VideoThumbnail.test.tsx +33 -11
- package/src/player/components/VideoThumbnail.tsx +7 -29
- package/src/player/components/studioShortcuts.ts +8 -5
- package/src/player/components/thumbnailUtils.test.ts +16 -20
- package/src/player/components/thumbnailUtils.ts +6 -7
- package/src/player/components/timelineClipChildren.test.ts +34 -1
- package/src/player/components/timelineClipChildren.tsx +7 -4
- package/src/player/components/timelineClipDragGestureLifecycle.test.ts +141 -85
- package/src/player/components/timelineClipDragGestureLifecycle.ts +2 -0
- package/src/player/components/timelineEditing.test.ts +55 -0
- package/src/player/components/timelineGroupEditing.ts +13 -6
- package/src/player/components/timelinePlayheadTransform.test.ts +33 -0
- package/src/player/components/timelinePlayheadTransform.ts +17 -0
- package/src/player/components/timelinePointerRelease.ts +4 -0
- package/src/player/components/useTimelinePlayhead.ts +26 -8
- package/src/player/components/useTimelineProviderState.tsx +4 -0
- package/src/player/components/useTimelineStackingSync.test.tsx +46 -0
- package/src/player/components/useTimelineStackingSync.ts +18 -11
- package/src/player/hooks/timelinePlayerSync.test.ts +20 -0
- package/src/player/hooks/timelinePlayerSync.ts +18 -8
- package/src/player/hooks/useShadowPreviewReload.ts +15 -4
- package/src/player/hooks/useTimelinePlayer.shadowReload.test.ts +19 -0
- package/src/player/hooks/useTimelinePlayer.test.ts +93 -1
- package/src/player/lib/playbackShortcuts.ts +18 -0
- package/src/player/lib/playbackTypes.ts +1 -0
- package/src/player/lib/timelineDOM.test.ts +92 -0
- package/src/player/lib/timelineDOM.ts +0 -2
- package/src/player/lib/timelineElementHelpers.ts +47 -10
- package/src/player/lib/timelineViewportBudgets.ts +1 -1
- package/src/player/store/timelineElement.ts +2 -1
- package/src/playerEntry.ts +1 -0
- package/src/timelineChromeExports.test.tsx +79 -0
- package/src/timelineStackingSyncExport.test.tsx +67 -0
- package/src/ui.ts +31 -0
- package/src/utils/authoredSource.ts +15 -0
- package/src/utils/blockInstaller.ts +32 -27
- package/src/utils/mediaTypes.test.ts +33 -0
- package/src/utils/mediaTypes.ts +12 -5
- package/src/utils/studioFileHistory.test.ts +14 -14
- package/src/utils/studioFileHistory.ts +31 -2
- package/dist/chunk-I7XNAIOF.js.map +0 -1
- /package/dist/{domEditingLayers-MHFDKAQ7.js.map → domEditingLayers-ZOOMN7CQ.js.map} +0 -0
|
@@ -7,10 +7,18 @@ import { applySoftReload, applySoftReloadFinalization } from "../utils/gsapSoftR
|
|
|
7
7
|
import { furthestClipEndFromDocument } from "../player/lib/timelineElementHelpers";
|
|
8
8
|
import type { RecordEditInput } from "../utils/studioFileHistory";
|
|
9
9
|
import { patchDocumentRootDuration } from "./timelineEditingGsap";
|
|
10
|
-
import {
|
|
11
|
-
|
|
12
|
-
|
|
13
|
-
|
|
10
|
+
import {
|
|
11
|
+
GsapOwnershipProtocolError,
|
|
12
|
+
GsapPreviewConvergenceError,
|
|
13
|
+
postGsapMutation,
|
|
14
|
+
requireGsapOwnershipProtocol,
|
|
15
|
+
rollbackOwnedMutation,
|
|
16
|
+
type GsapMutationStatus,
|
|
17
|
+
} from "./gsapMutationClient";
|
|
18
|
+
import {
|
|
19
|
+
serializeStudioFileMutations,
|
|
20
|
+
type StudioProjectFileWriter,
|
|
21
|
+
} from "../utils/studioFileMutationCoordinator";
|
|
14
22
|
|
|
15
23
|
export async function readFileContent(projectId: string, targetPath: string): Promise<string> {
|
|
16
24
|
if (targetPath.includes("\0") || targetPath.includes("..")) {
|
|
@@ -29,51 +37,6 @@ export async function readFileContent(projectId: string, targetPath: string): Pr
|
|
|
29
37
|
return data.content;
|
|
30
38
|
}
|
|
31
39
|
|
|
32
|
-
/** Verify rollback ownership support before any GSAP mutation can land. */
|
|
33
|
-
async function requireGsapOwnershipProtocol(projectId: string): Promise<void> {
|
|
34
|
-
const response = await fetch(
|
|
35
|
-
`/api/projects/${encodeURIComponent(projectId)}/gsap-mutation-capabilities`,
|
|
36
|
-
);
|
|
37
|
-
if (!response.ok) {
|
|
38
|
-
throw new GsapOwnershipProtocolError("Server does not support owned GSAP mutations");
|
|
39
|
-
}
|
|
40
|
-
const body = await response.json().catch(() => null);
|
|
41
|
-
if (!isRecord(body) || body.atomicOwnershipPairs !== true) {
|
|
42
|
-
throw new GsapOwnershipProtocolError("Invalid GSAP mutation capability response");
|
|
43
|
-
}
|
|
44
|
-
}
|
|
45
|
-
|
|
46
|
-
/** Atomically restore one GSAP mutation only while its exact output still owns
|
|
47
|
-
* the file. The server performs compare + write synchronously, eliminating the
|
|
48
|
-
* client GET→PUT window that could overwrite a successor edit. */
|
|
49
|
-
async function rollbackOwnedMutation(
|
|
50
|
-
projectId: string,
|
|
51
|
-
targetPath: string,
|
|
52
|
-
expected: string,
|
|
53
|
-
restore: string,
|
|
54
|
-
): Promise<"restored" | "conflict"> {
|
|
55
|
-
if (targetPath.includes("\0") || targetPath.includes("..")) {
|
|
56
|
-
throw new Error(`Unsafe path: ${targetPath}`);
|
|
57
|
-
}
|
|
58
|
-
const response = await fetch(
|
|
59
|
-
`/api/projects/${encodeURIComponent(projectId)}/gsap-mutation-rollback/${encodeURIComponent(targetPath)}`,
|
|
60
|
-
{
|
|
61
|
-
method: "POST",
|
|
62
|
-
// Deliberately unclaimed: a rollback runs because a mutation did not
|
|
63
|
-
// converge, so let the restored file reload the preview.
|
|
64
|
-
headers: { "Content-Type": "application/json" },
|
|
65
|
-
body: JSON.stringify({ expected, restore }),
|
|
66
|
-
},
|
|
67
|
-
);
|
|
68
|
-
if (!response.ok) {
|
|
69
|
-
throw new Error(`Failed to restore ${targetPath}`);
|
|
70
|
-
}
|
|
71
|
-
const result = (await response.json()) as { restored?: unknown; conflict?: unknown };
|
|
72
|
-
if (result.restored === true && result.conflict === false) return "restored";
|
|
73
|
-
if (result.restored === false && result.conflict === true) return "conflict";
|
|
74
|
-
throw new Error(`Invalid restore response for ${targetPath}`);
|
|
75
|
-
}
|
|
76
|
-
|
|
77
40
|
/** Best-effort live-iframe wrapper for patchDocumentRootDuration (see timelineEditingGsap). */
|
|
78
41
|
function patchIframeRootDuration(iframe: HTMLIFrameElement | null, contentEnd: number): void {
|
|
79
42
|
try {
|
|
@@ -102,79 +65,6 @@ export function captureDurationRollback(iframe: HTMLIFrameElement | null): () =>
|
|
|
102
65
|
};
|
|
103
66
|
}
|
|
104
67
|
|
|
105
|
-
/**
|
|
106
|
-
* The bits of the server GSAP-mutation response the timeline edit path needs.
|
|
107
|
-
* `scriptText` is the rewritten root GSAP script — feeding it to `applySoftReload`
|
|
108
|
-
* swaps the runtime timeline in place (no iframe reload = no all-clips flash). Null
|
|
109
|
-
* when the endpoint didn't return one (older server, or a multi-script comp the
|
|
110
|
-
* soft path can't scope) — the caller then full-reloads when `mutated`, or
|
|
111
|
-
* rebinds the runtime timing in place when nothing was rewritten (see
|
|
112
|
-
* syncTimingEditPreview).
|
|
113
|
-
*/
|
|
114
|
-
export type GsapMutationStatus = {
|
|
115
|
-
mutated: boolean;
|
|
116
|
-
scriptText: string | null;
|
|
117
|
-
/** Atomic whole-file ownership pair returned by the mutation endpoint. */
|
|
118
|
-
before?: string;
|
|
119
|
-
after?: string;
|
|
120
|
-
};
|
|
121
|
-
|
|
122
|
-
function isRecord(value: unknown): value is Record<string, unknown> {
|
|
123
|
-
return typeof value === "object" && value !== null;
|
|
124
|
-
}
|
|
125
|
-
|
|
126
|
-
function readMutationStatus(value: unknown): GsapMutationStatus {
|
|
127
|
-
if (
|
|
128
|
-
!isRecord(value) ||
|
|
129
|
-
typeof value.mutated !== "boolean" ||
|
|
130
|
-
typeof value.before !== "string" ||
|
|
131
|
-
typeof value.after !== "string" ||
|
|
132
|
-
value.mutated !== (value.before !== value.after) ||
|
|
133
|
-
("changed" in value && value.changed !== value.mutated)
|
|
134
|
-
) {
|
|
135
|
-
throw new GsapOwnershipProtocolError("Invalid owned GSAP mutation response");
|
|
136
|
-
}
|
|
137
|
-
return {
|
|
138
|
-
mutated: value.mutated,
|
|
139
|
-
scriptText: typeof value.scriptText === "string" ? value.scriptText : null,
|
|
140
|
-
before: value.before,
|
|
141
|
-
after: value.after,
|
|
142
|
-
};
|
|
143
|
-
}
|
|
144
|
-
|
|
145
|
-
function readMutationError(value: unknown, fallback: string): string {
|
|
146
|
-
if (isRecord(value) && typeof value.error === "string") return value.error;
|
|
147
|
-
return fallback;
|
|
148
|
-
}
|
|
149
|
-
|
|
150
|
-
async function postGsapMutation(
|
|
151
|
-
projectId: string,
|
|
152
|
-
filePath: string,
|
|
153
|
-
mutation: Record<string, unknown>,
|
|
154
|
-
fallback: string,
|
|
155
|
-
): Promise<GsapMutationStatus> {
|
|
156
|
-
let response: Response;
|
|
157
|
-
try {
|
|
158
|
-
response = await fetch(
|
|
159
|
-
`/api/projects/${encodeURIComponent(projectId)}/gsap-mutations/${encodeURIComponent(filePath)}`,
|
|
160
|
-
{
|
|
161
|
-
method: "POST",
|
|
162
|
-
headers: { "Content-Type": "application/json", ...studioWriteHeaders() },
|
|
163
|
-
body: JSON.stringify(mutation),
|
|
164
|
-
},
|
|
165
|
-
);
|
|
166
|
-
} catch (error) {
|
|
167
|
-
throw new GsapPreviewConvergenceError(`${fallback}: mutation outcome unknown`, {
|
|
168
|
-
cause: error,
|
|
169
|
-
});
|
|
170
|
-
}
|
|
171
|
-
const body: unknown = await response.json().catch(() => null);
|
|
172
|
-
if (!response.ok) {
|
|
173
|
-
throw new GsapPreviewConvergenceError(readMutationError(body, fallback));
|
|
174
|
-
}
|
|
175
|
-
return readMutationStatus(body);
|
|
176
|
-
}
|
|
177
|
-
|
|
178
68
|
/** Re-derive live timing windows without re-executing composition scripts.
|
|
179
69
|
* Works for zero-GSAP compositions; false asks the caller to full-reload. */
|
|
180
70
|
function rebindPreviewTiming(iframe: HTMLIFrameElement | null, currentTime: number): boolean {
|
|
@@ -341,7 +231,7 @@ async function rollbackAfterFailure(
|
|
|
341
231
|
// The ledger, reverse rollback, final ownership check, and history fold are one
|
|
342
232
|
// transaction; extracting phases would obscure which function owns convergence.
|
|
343
233
|
// fallow-ignore-next-line complexity
|
|
344
|
-
async function
|
|
234
|
+
async function foldGsapMutationInQueue(input: {
|
|
345
235
|
projectId: string;
|
|
346
236
|
label: string;
|
|
347
237
|
coalesceKey?: string;
|
|
@@ -411,6 +301,18 @@ async function foldGsapMutationIntoHistory(input: {
|
|
|
411
301
|
return status;
|
|
412
302
|
}
|
|
413
303
|
|
|
304
|
+
// The mutations, the ownership read and any rollback hold every touched file's queue.
|
|
305
|
+
function foldGsapMutationIntoHistory(
|
|
306
|
+
input: Parameters<typeof foldGsapMutationInQueue>[0] & {
|
|
307
|
+
writeFile: StudioProjectFileWriter;
|
|
308
|
+
paths: readonly string[];
|
|
309
|
+
},
|
|
310
|
+
): Promise<GsapMutationStatus> {
|
|
311
|
+
return serializeStudioFileMutations(input.writeFile, input.paths, () =>
|
|
312
|
+
foldGsapMutationInQueue(input),
|
|
313
|
+
);
|
|
314
|
+
}
|
|
315
|
+
|
|
414
316
|
/**
|
|
415
317
|
* Shift all GSAP animation positions targeting a given element by a time delta.
|
|
416
318
|
* Calls the server-side GSAP mutation endpoint which uses the AST-based parser.
|
|
@@ -491,6 +393,7 @@ export function finishClipTimingFallback(input: {
|
|
|
491
393
|
label: string;
|
|
492
394
|
coalesceKey?: string;
|
|
493
395
|
recordEdit: (edit: RecordEditInput) => Promise<void>;
|
|
396
|
+
writeProjectFile: StudioProjectFileWriter;
|
|
494
397
|
edit: SingleClipGsapEdit;
|
|
495
398
|
}): Promise<void> {
|
|
496
399
|
const { projectId, targetPath, domId, edit } = input;
|
|
@@ -519,6 +422,8 @@ export function finishClipTimingFallback(input: {
|
|
|
519
422
|
timingChanged && domId && projectId
|
|
520
423
|
? () =>
|
|
521
424
|
foldGsapMutationIntoHistory({
|
|
425
|
+
writeFile: input.writeProjectFile,
|
|
426
|
+
paths: [targetPath],
|
|
522
427
|
projectId,
|
|
523
428
|
label: input.label,
|
|
524
429
|
coalesceKey: input.coalesceKey,
|
|
@@ -552,6 +457,7 @@ export async function finishGroupTimingGsapFallback<C extends { element: Timelin
|
|
|
552
457
|
errorLabel: string;
|
|
553
458
|
coalesceKey?: string;
|
|
554
459
|
recordEdit: (edit: RecordEditInput) => Promise<void>;
|
|
460
|
+
writeProjectFile: StudioProjectFileWriter;
|
|
555
461
|
activeCompPath: string | null;
|
|
556
462
|
changes: readonly C[];
|
|
557
463
|
resolveChangePath: (element: TimelineElement) => string;
|
|
@@ -568,6 +474,8 @@ export async function finishGroupTimingGsapFallback<C extends { element: Timelin
|
|
|
568
474
|
reloadPreview: input.reloadPreview,
|
|
569
475
|
gsapMutation: () =>
|
|
570
476
|
foldGsapMutationIntoHistory({
|
|
477
|
+
writeFile: input.writeProjectFile,
|
|
478
|
+
paths: input.changes.map((change) => input.resolveChangePath(change.element)),
|
|
571
479
|
projectId: input.projectId,
|
|
572
480
|
label: input.label,
|
|
573
481
|
coalesceKey: input.coalesceKey,
|
|
@@ -9,11 +9,11 @@ import {
|
|
|
9
9
|
} from "../player/components/timelineTrackDisplay";
|
|
10
10
|
import { saveProjectFilesWithHistory } from "../utils/studioFileHistory";
|
|
11
11
|
import { isAudioTimelineElement } from "../utils/timelineInspector";
|
|
12
|
-
import {
|
|
12
|
+
import type { PatchOperation } from "../utils/sourcePatcher";
|
|
13
13
|
import {
|
|
14
|
-
applyPatchByTarget,
|
|
15
|
-
buildPatchTarget,
|
|
16
14
|
findTimelineElementInIframe,
|
|
15
|
+
operationChanges,
|
|
16
|
+
patchTimelineChangesInSource,
|
|
17
17
|
readFileContent,
|
|
18
18
|
type RecordEditInput,
|
|
19
19
|
} from "./timelineEditingHelpers";
|
|
@@ -149,38 +149,24 @@ async function setElementsHidden({
|
|
|
149
149
|
property: "hidden",
|
|
150
150
|
value: hidden ? "" : null,
|
|
151
151
|
};
|
|
152
|
-
const
|
|
153
|
-
const
|
|
154
|
-
|
|
155
|
-
try {
|
|
156
|
-
for (const [targetPath, fileElements] of groupElementsByTargetPath(elements, activeCompPath)) {
|
|
157
|
-
let patchedContent = await readFileContent(projectId, targetPath);
|
|
158
|
-
originalByPath.set(targetPath, patchedContent);
|
|
159
|
-
|
|
160
|
-
for (const element of fileElements) {
|
|
161
|
-
const patchTarget = buildPatchTarget(element);
|
|
162
|
-
if (!patchTarget) {
|
|
163
|
-
throw new Error(`Timeline element ${element.id} is missing a patchable target`);
|
|
164
|
-
}
|
|
165
|
-
if (readTagSnippetByTarget(patchedContent, patchTarget) === undefined) {
|
|
166
|
-
throw new Error(`Unable to patch timeline element ${element.id} in ${targetPath}`);
|
|
167
|
-
}
|
|
168
|
-
patchedContent = applyPatchByTarget(patchedContent, patchTarget, hiddenOperation);
|
|
169
|
-
}
|
|
170
|
-
|
|
171
|
-
files[targetPath] = patchedContent;
|
|
152
|
+
const files: Record<string, (current: string) => string> = {};
|
|
153
|
+
for (const [targetPath, fileElements] of groupElementsByTargetPath(elements, activeCompPath)) {
|
|
154
|
+
files[targetPath] = (current) => {
|
|
172
155
|
pendingTimelineEditPathRef.current.add(targetPath);
|
|
173
|
-
|
|
156
|
+
return patchTimelineChangesInSource(
|
|
157
|
+
current,
|
|
158
|
+
targetPath,
|
|
159
|
+
operationChanges(fileElements, hiddenOperation),
|
|
160
|
+
);
|
|
161
|
+
};
|
|
162
|
+
}
|
|
174
163
|
|
|
164
|
+
try {
|
|
175
165
|
const changedPaths = await saveProjectFilesWithHistory({
|
|
176
166
|
projectId,
|
|
177
167
|
label,
|
|
178
168
|
files,
|
|
179
|
-
readFile:
|
|
180
|
-
const original = originalByPath.get(path);
|
|
181
|
-
if (original !== undefined) return original;
|
|
182
|
-
return readFileContent(projectId, path);
|
|
183
|
-
},
|
|
169
|
+
readFile: (path) => readFileContent(projectId, path),
|
|
184
170
|
writeFile: writeProjectFile,
|
|
185
171
|
recordEdit,
|
|
186
172
|
});
|
|
@@ -0,0 +1,78 @@
|
|
|
1
|
+
// @vitest-environment happy-dom
|
|
2
|
+
|
|
3
|
+
import React, { act } from "react";
|
|
4
|
+
import { createRoot } from "react-dom/client";
|
|
5
|
+
import { afterEach, describe, expect, it, vi } from "vitest";
|
|
6
|
+
import type { DomEditSelection } from "../components/editor/domEditing";
|
|
7
|
+
import { useAskAgentModal } from "./useAskAgentModal";
|
|
8
|
+
|
|
9
|
+
const copied = vi.hoisted(() => [] as string[]);
|
|
10
|
+
vi.mock("../utils/clipboard", () => ({
|
|
11
|
+
copyTextToClipboard: async (text: string) => copied.push(text) > 0,
|
|
12
|
+
}));
|
|
13
|
+
|
|
14
|
+
(globalThis as unknown as { IS_REACT_ACT_ENVIRONMENT: boolean }).IS_REACT_ACT_ENVIRONMENT = true;
|
|
15
|
+
|
|
16
|
+
afterEach(() => {
|
|
17
|
+
document.body.innerHTML = "";
|
|
18
|
+
copied.length = 0;
|
|
19
|
+
vi.unstubAllGlobals();
|
|
20
|
+
});
|
|
21
|
+
|
|
22
|
+
describe("useAskAgentModal", () => {
|
|
23
|
+
it("sends the live element without the preview's lazy loading or look-ahead mark, keeping authored loading", async () => {
|
|
24
|
+
vi.stubGlobal("fetch", vi.fn().mockRejectedValue(new Error("offline")));
|
|
25
|
+
const element = document.createElement("div");
|
|
26
|
+
element.setAttribute("data-hf-upcoming", "");
|
|
27
|
+
element.innerHTML =
|
|
28
|
+
'<img src="plate.png" loading="lazy" data-hf-preview-lazy><img src="own.png" loading="eager">';
|
|
29
|
+
const selection: DomEditSelection = {
|
|
30
|
+
element,
|
|
31
|
+
label: "Plate",
|
|
32
|
+
tagName: "div",
|
|
33
|
+
sourceFile: "index.html",
|
|
34
|
+
compositionPath: "index.html",
|
|
35
|
+
isCompositionHost: false,
|
|
36
|
+
isInsideLockedComposition: false,
|
|
37
|
+
selector: "div",
|
|
38
|
+
selectorIndex: 0,
|
|
39
|
+
boundingBox: { x: 0, y: 0, width: 10, height: 10 },
|
|
40
|
+
textContent: "",
|
|
41
|
+
dataAttributes: {},
|
|
42
|
+
inlineStyles: {},
|
|
43
|
+
computedStyles: {},
|
|
44
|
+
textFields: [],
|
|
45
|
+
capabilities: {
|
|
46
|
+
canSelect: true,
|
|
47
|
+
canEditStyles: true,
|
|
48
|
+
canCrop: false,
|
|
49
|
+
canMove: true,
|
|
50
|
+
canResize: true,
|
|
51
|
+
canApplyManualOffset: false,
|
|
52
|
+
canApplyManualSize: false,
|
|
53
|
+
canApplyManualRotation: false,
|
|
54
|
+
},
|
|
55
|
+
};
|
|
56
|
+
let hook: ReturnType<typeof useAskAgentModal> | null = null;
|
|
57
|
+
function Harness() {
|
|
58
|
+
hook = useAskAgentModal({
|
|
59
|
+
projectId: "demo",
|
|
60
|
+
activeCompPath: "index.html",
|
|
61
|
+
projectDir: "/demo",
|
|
62
|
+
projectIdRef: { current: "demo" },
|
|
63
|
+
showToast: () => {},
|
|
64
|
+
domEditSelectionRef: { current: selection },
|
|
65
|
+
domEditSelection: selection,
|
|
66
|
+
});
|
|
67
|
+
return null;
|
|
68
|
+
}
|
|
69
|
+
const host = document.body.appendChild(document.createElement("div"));
|
|
70
|
+
await act(async () => createRoot(host).render(<Harness />));
|
|
71
|
+
|
|
72
|
+
await act(async () => hook!.handleAgentModalSubmit("Make it bigger"));
|
|
73
|
+
|
|
74
|
+
expect(copied[0]).toContain('<img src="plate.png"><img src="own.png" loading="eager">');
|
|
75
|
+
expect(copied[0]).not.toContain("data-hf-preview-lazy");
|
|
76
|
+
expect(copied[0]).not.toContain("data-hf-upcoming");
|
|
77
|
+
});
|
|
78
|
+
});
|
|
@@ -4,6 +4,7 @@ import { useState, useCallback, useRef, useEffect } from "react";
|
|
|
4
4
|
import { copyTextToClipboard } from "../utils/clipboard";
|
|
5
5
|
import { readTagSnippetByTarget } from "../utils/sourcePatcher";
|
|
6
6
|
import { toProjectAbsolutePath, type AgentModalAnchorPoint } from "../utils/studioHelpers";
|
|
7
|
+
import { liveMarkupWithoutPreviewMarks } from "../utils/authoredSource";
|
|
7
8
|
import { buildElementAgentPrompt, type DomEditSelection } from "../components/editor/domEditing";
|
|
8
9
|
import { usePlayerStore } from "../player";
|
|
9
10
|
|
|
@@ -89,7 +90,8 @@ export function useAskAgentModal({
|
|
|
89
90
|
if (!domEditSelection) return;
|
|
90
91
|
|
|
91
92
|
const targetPath = domEditSelection.sourceFile || activeCompPath || "index.html";
|
|
92
|
-
const tagSnippet =
|
|
93
|
+
const tagSnippet =
|
|
94
|
+
agentPromptTagSnippet ?? liveMarkupWithoutPreviewMarks(domEditSelection.element);
|
|
93
95
|
const prompt = buildElementAgentPrompt({
|
|
94
96
|
selection: domEditSelection,
|
|
95
97
|
currentTime: usePlayerStore.getState().currentTime,
|
|
@@ -220,6 +220,27 @@ describe("copy takes a clip's saved markup, not the runtime's live styling", ()
|
|
|
220
220
|
});
|
|
221
221
|
});
|
|
222
222
|
|
|
223
|
+
describe("copy of a clip missing from its saved file", () => {
|
|
224
|
+
it("takes the preview's markup without its lazy loading or look-ahead mark, keeping authored loading", async () => {
|
|
225
|
+
selectTitle();
|
|
226
|
+
const { clipboard, writes, files, iframe } = mountClipboard();
|
|
227
|
+
const title = iframe.contentDocument?.querySelector("h1") as Element;
|
|
228
|
+
title.setAttribute("data-hf-upcoming", "");
|
|
229
|
+
title.insertAdjacentHTML(
|
|
230
|
+
"beforeend",
|
|
231
|
+
'<img src="plate.png" loading="lazy" data-hf-preview-lazy><img src="own.png" loading="lazy">',
|
|
232
|
+
);
|
|
233
|
+
files["index.html"] = SAVED.replace(/<h1[\s\S]*<\/h1>/, "");
|
|
234
|
+
clipboard().handleCopy();
|
|
235
|
+
await clipboard().handlePaste();
|
|
236
|
+
expect(writes[0]).toMatch(
|
|
237
|
+
/<img src="plate.png" data-hf-id="[^"]+"><img src="own.png" loading="lazy"/,
|
|
238
|
+
);
|
|
239
|
+
expect(writes[0]).not.toContain("data-hf-preview-lazy");
|
|
240
|
+
expect(writes[0]).not.toContain("data-hf-upcoming");
|
|
241
|
+
});
|
|
242
|
+
});
|
|
243
|
+
|
|
223
244
|
const SUB_SELECTION = {
|
|
224
245
|
hfId: SUB_HF_ID,
|
|
225
246
|
selector: "h2",
|
|
@@ -338,3 +359,14 @@ describe("a copy that fails", () => {
|
|
|
338
359
|
expect(deleted).toEqual([]);
|
|
339
360
|
});
|
|
340
361
|
});
|
|
362
|
+
|
|
363
|
+
describe("with no DOM edit session", () => {
|
|
364
|
+
it("refuses copy, cut and duplicate when nothing is selected", async () => {
|
|
365
|
+
clearSelection();
|
|
366
|
+
const { clipboard, deleted } = mountClipboard();
|
|
367
|
+
expect(clipboard().handleCopy()).toBe(false);
|
|
368
|
+
expect(await clipboard().handleCut()).toBe(false);
|
|
369
|
+
expect(await clipboard().handleDuplicate()).toBe(false);
|
|
370
|
+
expect(deleted).toEqual([]);
|
|
371
|
+
});
|
|
372
|
+
});
|
|
@@ -23,6 +23,7 @@ import {
|
|
|
23
23
|
authoredMarkup,
|
|
24
24
|
findAuthoredElement,
|
|
25
25
|
findAuthoredElementById,
|
|
26
|
+
liveMarkupWithoutPreviewMarks,
|
|
26
27
|
parseSavedSource,
|
|
27
28
|
} from "../utils/authoredSource";
|
|
28
29
|
import { serializeStudioFileMutations } from "../utils/studioFileMutationCoordinator";
|
|
@@ -33,7 +34,7 @@ interface RecordEditInput {
|
|
|
33
34
|
files: Record<string, { before: string; after: string }>;
|
|
34
35
|
}
|
|
35
36
|
|
|
36
|
-
interface UseClipboardOptions {
|
|
37
|
+
export interface UseClipboardOptions {
|
|
37
38
|
projectId: string | null;
|
|
38
39
|
activeCompPath: string | null;
|
|
39
40
|
domEditSelectionRef: React.MutableRefObject<DomEditSelection | null>;
|
|
@@ -88,7 +89,9 @@ function getSelectedDomElement(
|
|
|
88
89
|
|
|
89
90
|
function savedMarkupElseLive(saved: Document, live: Element, sourceFile: string): string {
|
|
90
91
|
const authored = findAuthoredElement(saved, live) ?? findAuthoredElementById(saved, live);
|
|
91
|
-
return authored
|
|
92
|
+
return authored
|
|
93
|
+
? authoredMarkup(authored, live, sourceFile)
|
|
94
|
+
: liveMarkupWithoutPreviewMarks(live);
|
|
92
95
|
}
|
|
93
96
|
|
|
94
97
|
async function readSavedMarkup(
|
|
@@ -356,21 +359,17 @@ export function useClipboard({
|
|
|
356
359
|
|
|
357
360
|
const targetPath = activeCompPath || "index.html";
|
|
358
361
|
try {
|
|
359
|
-
const originalContent = await readFileContent(pid, targetPath);
|
|
360
|
-
let patchedContent: string;
|
|
361
362
|
let pastedIds: string[] = [];
|
|
362
|
-
|
|
363
|
-
|
|
363
|
+
const paste = (originalContent: string) => {
|
|
364
|
+
if (payload.kind !== "timeline-clip") return pasteElementHtml(originalContent, payload);
|
|
364
365
|
const { currentTime, elements } = usePlayerStore.getState();
|
|
365
366
|
const pasted = pasteTimelineClips(originalContent, payload.clips, currentTime, elements);
|
|
367
|
+
pastedIds = pasted.ids;
|
|
366
368
|
// A clip pasted past the current composition end would exist in the
|
|
367
369
|
// file but never appear on the timeline or in playback/export (the
|
|
368
370
|
// root's data-duration is what actually bounds the render).
|
|
369
|
-
|
|
370
|
-
|
|
371
|
-
} else {
|
|
372
|
-
patchedContent = pasteElementHtml(originalContent, payload);
|
|
373
|
-
}
|
|
371
|
+
return extendRootDurationInSource(pasted.content, pasted.requiredEnd);
|
|
372
|
+
};
|
|
374
373
|
|
|
375
374
|
const label =
|
|
376
375
|
payload.kind === "timeline-clip"
|
|
@@ -380,8 +379,8 @@ export function useClipboard({
|
|
|
380
379
|
await saveProjectFilesWithHistory({
|
|
381
380
|
projectId: pid,
|
|
382
381
|
label,
|
|
383
|
-
files: { [targetPath]:
|
|
384
|
-
readFile:
|
|
382
|
+
files: { [targetPath]: paste },
|
|
383
|
+
readFile: (path) => readFileContent(pid, path),
|
|
385
384
|
writeFile: writeProjectFile,
|
|
386
385
|
recordEdit,
|
|
387
386
|
});
|
|
@@ -422,17 +421,19 @@ export function useClipboard({
|
|
|
422
421
|
|
|
423
422
|
try {
|
|
424
423
|
const clips = await readClips(targets);
|
|
425
|
-
|
|
426
|
-
const
|
|
427
|
-
|
|
428
|
-
|
|
429
|
-
|
|
424
|
+
let ids: string[] = [];
|
|
425
|
+
const duplicate = (originalContent: string) => {
|
|
426
|
+
const liveElements = usePlayerStore.getState().elements;
|
|
427
|
+
const pasted = pasteTimelineClips(originalContent, clips, anchorTime, liveElements);
|
|
428
|
+
ids = pasted.ids;
|
|
429
|
+
return extendRootDurationInSource(pasted.content, pasted.requiredEnd);
|
|
430
|
+
};
|
|
430
431
|
|
|
431
432
|
await saveProjectFilesWithHistory({
|
|
432
433
|
projectId: pid,
|
|
433
434
|
label: clipLabel("Duplicate", clips.length),
|
|
434
|
-
files: { [targetPath]:
|
|
435
|
-
readFile:
|
|
435
|
+
files: { [targetPath]: duplicate },
|
|
436
|
+
readFile: (path) => readFileContent(pid, path),
|
|
436
437
|
writeFile: writeProjectFile,
|
|
437
438
|
recordEdit,
|
|
438
439
|
});
|
|
@@ -1252,8 +1252,15 @@ describe("useDomEditCommits style persist handling", () => {
|
|
|
1252
1252
|
|
|
1253
1253
|
const secondCommit = rendered.hook.handleDomStyleCommit("color", "green");
|
|
1254
1254
|
await flushAsyncWork();
|
|
1255
|
-
|
|
1255
|
+
// The newer commit waits for the older one in the file's queue.
|
|
1256
|
+
expect(patchCount).toBe(1);
|
|
1256
1257
|
|
|
1258
|
+
firstPatch.reject(new Error("server rejected blue"));
|
|
1259
|
+
await firstCommit;
|
|
1260
|
+
expect(element.style.getPropertyValue("color")).toBe("green");
|
|
1261
|
+
|
|
1262
|
+
await flushAsyncWork();
|
|
1263
|
+
expect(patchCount).toBe(2);
|
|
1257
1264
|
secondPatch.resolve(
|
|
1258
1265
|
jsonResponse({
|
|
1259
1266
|
ok: true,
|
|
@@ -1263,10 +1270,6 @@ describe("useDomEditCommits style persist handling", () => {
|
|
|
1263
1270
|
}),
|
|
1264
1271
|
);
|
|
1265
1272
|
await secondCommit;
|
|
1266
|
-
expect(element.style.getPropertyValue("color")).toBe("green");
|
|
1267
|
-
|
|
1268
|
-
firstPatch.reject(new Error("server rejected blue"));
|
|
1269
|
-
await firstCommit;
|
|
1270
1273
|
|
|
1271
1274
|
expect(element.style.getPropertyValue("color")).toBe("green");
|
|
1272
1275
|
} finally {
|
|
@@ -1698,15 +1701,16 @@ describe("useDomEditCommits attribute persist handling", () => {
|
|
|
1698
1701
|
try {
|
|
1699
1702
|
// Older commit's persist stays pending (captures previousValue=null); the
|
|
1700
1703
|
// newer commit captures previousValue="first-value" (the older commit's
|
|
1701
|
-
// optimistic apply) and
|
|
1704
|
+
// optimistic apply) and is still queued when the older one rejects. Without the
|
|
1702
1705
|
// per-key version guard, the stale rejection would revert to the older
|
|
1703
1706
|
// commit's own previousValue (null) and stomp the newer commit's value.
|
|
1704
1707
|
const firstCommit = rendered.hook.handleDomHtmlAttributeCommit("muted", "first-value");
|
|
1705
1708
|
await act(async () => {
|
|
1706
|
-
|
|
1709
|
+
const secondCommit = rendered.hook.handleDomHtmlAttributeCommit("muted", "second-value");
|
|
1710
|
+
first.reject(new Error("stale request failed"));
|
|
1711
|
+
await firstCommit;
|
|
1712
|
+
await secondCommit;
|
|
1707
1713
|
});
|
|
1708
|
-
first.reject(new Error("stale request failed"));
|
|
1709
|
-
await firstCommit;
|
|
1710
1714
|
|
|
1711
1715
|
expect(element.getAttribute("muted")).toBe("second-value");
|
|
1712
1716
|
} finally {
|