@hyperframes/studio 0.7.46 → 0.7.47
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/index-Dq7FEg0K.css +1 -0
- package/dist/assets/{index-BGVoOXdg.js → index-DsckwbdW.js} +1 -1
- package/dist/assets/index-VsAbY3rl.js +423 -0
- package/dist/assets/{index-DO2MAFSG.js → index-uvB30_yI.js} +1 -1
- package/dist/index.d.ts +96 -29
- package/dist/index.html +2 -2
- package/dist/index.js +8443 -4305
- package/dist/index.js.map +1 -1
- package/package.json +7 -7
- package/src/App.tsx +26 -32
- package/src/components/DesignPanelPromoteProvider.tsx +45 -0
- package/src/components/PanelTabButton.tsx +31 -0
- package/src/components/StudioPreviewArea.tsx +75 -3
- package/src/components/StudioRightPanel.tsx +111 -113
- package/src/components/editor/LayersPanel.tsx +2 -22
- package/src/components/editor/PromotableControl.tsx +105 -0
- package/src/components/editor/propertyPanelSections.tsx +42 -21
- package/src/components/panels/VariablesBindElement.tsx +266 -0
- package/src/components/panels/VariablesDeclarationForm.test.ts +135 -0
- package/src/components/panels/VariablesDeclarationForm.tsx +321 -0
- package/src/components/panels/VariablesOtherCompositions.tsx +159 -0
- package/src/components/panels/VariablesPanel.tsx +554 -0
- package/src/components/panels/VariablesRowAction.tsx +25 -0
- package/src/components/panels/VariablesValueControls.tsx +215 -0
- package/src/components/renders/useRenderQueue.ts +12 -0
- package/src/contexts/TimelineEditContext.tsx +7 -2
- package/src/contexts/VariablePromoteContext.tsx +138 -0
- package/src/contexts/variablePromoteHelpers.test.ts +124 -0
- package/src/contexts/variablePromoteHelpers.ts +53 -0
- package/src/contexts/variablePromoteIntegration.test.ts +80 -0
- package/src/hooks/previewVariablesStore.ts +34 -0
- package/src/hooks/timelineEditingHelpers.test.ts +104 -0
- package/src/hooks/timelineEditingHelpers.ts +386 -10
- package/src/hooks/useDomEditWiring.ts +9 -7
- package/src/hooks/useDomSelection.test.ts +36 -2
- package/src/hooks/useDomSelection.ts +56 -75
- package/src/hooks/useElementLifecycleOps.ts +57 -17
- package/src/hooks/usePreviewDocumentVersion.ts +27 -0
- package/src/hooks/useProjectCompositionVariables.ts +131 -0
- package/src/hooks/useStudioContextValue.ts +6 -1
- package/src/hooks/useStudioSdkSessions.ts +37 -0
- package/src/hooks/useTimelineEditing.test.tsx +963 -0
- package/src/hooks/useTimelineEditing.ts +122 -100
- package/src/hooks/useTimelineEditingTypes.ts +15 -0
- package/src/hooks/useTimelineGroupEditing.ts +370 -0
- package/src/hooks/useTimelineSelectionPreviewSync.test.tsx +129 -0
- package/src/hooks/useTimelineSelectionPreviewSync.ts +130 -0
- package/src/hooks/useVariablesPersist.ts +61 -0
- package/src/player/components/BeatStrip.tsx +19 -3
- package/src/player/components/Player.tsx +7 -1
- package/src/player/components/Timeline.test.ts +38 -0
- package/src/player/components/Timeline.tsx +102 -80
- package/src/player/components/TimelineCanvas.tsx +340 -296
- package/src/player/components/TimelineClip.test.tsx +11 -0
- package/src/player/components/TimelineClipDiamonds.tsx +40 -9
- package/src/player/components/TimelineDragGhost.tsx +59 -0
- package/src/player/components/TimelineDropInsertionLine.tsx +32 -0
- package/src/player/components/TimelineLayerGroupHeader.tsx +97 -0
- package/src/player/components/TimelineLayerGutter.tsx +61 -0
- package/src/player/components/TimelineRuler.tsx +4 -2
- package/src/player/components/TimelineSelectionOverlays.tsx +55 -0
- package/src/player/components/timelineCallbacks.ts +20 -2
- package/src/player/components/timelineClipDragPreview.ts +52 -0
- package/src/player/components/timelineDragDrop.ts +19 -3
- package/src/player/components/timelineDropIndicator.test.ts +48 -0
- package/src/player/components/timelineDropIndicator.ts +33 -0
- package/src/player/components/timelineEditing.test.ts +251 -1
- package/src/player/components/timelineEditing.ts +178 -19
- package/src/player/components/timelineGroupEditing.ts +159 -0
- package/src/player/components/timelineLayerDrag.test.ts +200 -0
- package/src/player/components/timelineLayerDrag.ts +383 -0
- package/src/player/components/timelineLayout.test.ts +34 -0
- package/src/player/components/timelineLayout.ts +47 -0
- package/src/player/components/timelineMarqueeSelection.test.ts +87 -0
- package/src/player/components/timelineSnapTargets.test.ts +144 -0
- package/src/player/components/timelineSnapTargets.ts +164 -0
- package/src/player/components/timelineStacking.ts +62 -0
- package/src/player/components/timelineTrackOrder.test.ts +144 -0
- package/src/player/components/timelineTrackOrder.ts +173 -0
- package/src/player/components/useTimelineActiveClips.test.ts +20 -0
- package/src/player/components/useTimelineActiveClips.ts +15 -4
- package/src/player/components/useTimelineClipDrag.test.tsx +506 -0
- package/src/player/components/useTimelineClipDrag.ts +172 -126
- package/src/player/components/useTimelineClipGroupDrag.ts +417 -0
- package/src/player/components/useTimelineKeyframeHandlers.ts +73 -0
- package/src/player/components/useTimelineMarqueeSelection.test.tsx +231 -0
- package/src/player/components/useTimelineMarqueeSelection.ts +276 -0
- package/src/player/hooks/useExpandedTimelineElements.ts +3 -0
- package/src/player/hooks/useTimelinePlayer.test.ts +4 -1
- package/src/player/hooks/useTimelinePlayer.ts +7 -0
- package/src/player/lib/layerOrdering.test.ts +77 -0
- package/src/player/lib/layerOrdering.ts +98 -0
- package/src/player/lib/playbackTypes.ts +3 -0
- package/src/player/lib/timelineDOM.test.ts +103 -0
- package/src/player/lib/timelineDOM.ts +80 -0
- package/src/player/lib/timelineElementHelpers.ts +1 -1
- package/src/player/store/playerStore.test.ts +92 -0
- package/src/player/store/playerStore.ts +69 -9
- package/src/utils/blockInstaller.ts +2 -14
- package/src/utils/editHistory.test.ts +61 -0
- package/src/utils/editHistory.ts +7 -1
- package/src/utils/rootDuration.test.ts +34 -0
- package/src/utils/rootDuration.ts +17 -0
- package/src/utils/sdkCutover.ts +45 -0
- package/src/utils/studioHelpers.test.ts +31 -0
- package/src/utils/studioHelpers.ts +29 -1
- package/src/utils/studioUrlState.test.ts +29 -0
- package/src/utils/studioUrlState.ts +16 -1
- package/dist/assets/index-BsLQ2qGn.js +0 -420
- package/dist/assets/index-DoVLXke0.css +0 -1
|
@@ -0,0 +1,34 @@
|
|
|
1
|
+
import { create } from "zustand";
|
|
2
|
+
|
|
3
|
+
/**
|
|
4
|
+
* Ephemeral composition-variable overrides for the preview iframe.
|
|
5
|
+
*
|
|
6
|
+
* Values here are NEVER persisted to the composition — they ride the preview
|
|
7
|
+
* URL as `?variables=<json>` (see the studio-server preview routes), which the
|
|
8
|
+
* server injects as `window.__hfVariables` exactly like render-time injection,
|
|
9
|
+
* so what the user previews is what `hyperframes render --variables` produces.
|
|
10
|
+
* `null` means "preview with declared defaults".
|
|
11
|
+
*/
|
|
12
|
+
interface PreviewVariablesState {
|
|
13
|
+
values: Record<string, unknown> | null;
|
|
14
|
+
setValues: (values: Record<string, unknown> | null) => void;
|
|
15
|
+
}
|
|
16
|
+
|
|
17
|
+
export const usePreviewVariablesStore = create<PreviewVariablesState>((set) => ({
|
|
18
|
+
values: null,
|
|
19
|
+
setValues: (values) => set({ values: values && Object.keys(values).length > 0 ? values : null }),
|
|
20
|
+
}));
|
|
21
|
+
|
|
22
|
+
/**
|
|
23
|
+
* Apply the current preview-variable overrides to a preview URL (both the
|
|
24
|
+
* Player's initial mount and refreshPlayer's soft reload route through this,
|
|
25
|
+
* so a hard remount can't silently drop the active overrides).
|
|
26
|
+
*/
|
|
27
|
+
export function applyPreviewVariablesToUrl(url: URL): void {
|
|
28
|
+
const values = usePreviewVariablesStore.getState().values;
|
|
29
|
+
if (values) {
|
|
30
|
+
url.searchParams.set("variables", JSON.stringify(values));
|
|
31
|
+
} else {
|
|
32
|
+
url.searchParams.delete("variables");
|
|
33
|
+
}
|
|
34
|
+
}
|
|
@@ -0,0 +1,104 @@
|
|
|
1
|
+
// @vitest-environment jsdom
|
|
2
|
+
import { afterEach, describe, expect, it, vi } from "vitest";
|
|
3
|
+
import { applyTimelineStackingReorder, extendRootDurationIfNeeded } from "./timelineEditingHelpers";
|
|
4
|
+
import type { TimelineElement } from "../player/store/playerStore";
|
|
5
|
+
import { usePlayerStore } from "../player/store/playerStore";
|
|
6
|
+
|
|
7
|
+
afterEach(() => {
|
|
8
|
+
usePlayerStore.getState().reset();
|
|
9
|
+
});
|
|
10
|
+
|
|
11
|
+
function makeIframeWith(html: string): HTMLIFrameElement {
|
|
12
|
+
const iframe = document.createElement("iframe");
|
|
13
|
+
document.body.append(iframe);
|
|
14
|
+
const doc = iframe.contentDocument;
|
|
15
|
+
if (!doc) throw new Error("expected iframe document");
|
|
16
|
+
doc.body.innerHTML = html;
|
|
17
|
+
return iframe;
|
|
18
|
+
}
|
|
19
|
+
|
|
20
|
+
function el(input: Partial<TimelineElement> & { id: string; tag: string }): TimelineElement {
|
|
21
|
+
return {
|
|
22
|
+
label: input.id,
|
|
23
|
+
start: 0,
|
|
24
|
+
duration: 5,
|
|
25
|
+
track: 0,
|
|
26
|
+
zIndex: 0,
|
|
27
|
+
hasExplicitZIndex: false,
|
|
28
|
+
stackingContextId: null,
|
|
29
|
+
...input,
|
|
30
|
+
};
|
|
31
|
+
}
|
|
32
|
+
|
|
33
|
+
describe("applyTimelineStackingReorder", () => {
|
|
34
|
+
it("commits via the change's own locator even when the element is not in timelineElements", () => {
|
|
35
|
+
// Sub-comp children live in the preview iframe but NOT in the top-level
|
|
36
|
+
// timelineElements list — the intent must be self-contained.
|
|
37
|
+
const iframe = makeIframeWith(`<div id="chip" style="z-index: 1"></div>`);
|
|
38
|
+
const commit = vi.fn<(entries: unknown[]) => void>();
|
|
39
|
+
|
|
40
|
+
applyTimelineStackingReorder({
|
|
41
|
+
element: el({ id: "chip", tag: "div" }),
|
|
42
|
+
stackingReorder: {
|
|
43
|
+
contextKey: "scene",
|
|
44
|
+
placement: { type: "above", layerId: "layer:scene:x" },
|
|
45
|
+
zIndexChanges: [
|
|
46
|
+
{
|
|
47
|
+
key: "scenes/scene.html#chip",
|
|
48
|
+
zIndex: 5,
|
|
49
|
+
domId: "chip",
|
|
50
|
+
sourceFile: "scenes/scene.html",
|
|
51
|
+
},
|
|
52
|
+
],
|
|
53
|
+
},
|
|
54
|
+
timelineElements: [], // element intentionally absent from the top-level list
|
|
55
|
+
iframe,
|
|
56
|
+
activeCompPath: "index.html",
|
|
57
|
+
commit,
|
|
58
|
+
});
|
|
59
|
+
|
|
60
|
+
expect(commit).toHaveBeenCalledTimes(1);
|
|
61
|
+
const entries = commit.mock.calls[0]![0] as Array<{
|
|
62
|
+
zIndex: number;
|
|
63
|
+
id?: string;
|
|
64
|
+
sourceFile: string;
|
|
65
|
+
}>;
|
|
66
|
+
expect(entries).toHaveLength(1);
|
|
67
|
+
expect(entries[0]!.zIndex).toBe(5);
|
|
68
|
+
expect(entries[0]!.id).toBe("chip");
|
|
69
|
+
expect(entries[0]!.sourceFile).toBe("scenes/scene.html");
|
|
70
|
+
});
|
|
71
|
+
|
|
72
|
+
it("never commits when the dragged clip is audio", () => {
|
|
73
|
+
const iframe = makeIframeWith(`<audio id="track"></audio>`);
|
|
74
|
+
const commit = vi.fn<(entries: unknown[]) => void>();
|
|
75
|
+
|
|
76
|
+
applyTimelineStackingReorder({
|
|
77
|
+
element: el({ id: "track", tag: "audio" }),
|
|
78
|
+
stackingReorder: {
|
|
79
|
+
contextKey: "main",
|
|
80
|
+
placement: { type: "above", layerId: "layer:main:x" },
|
|
81
|
+
zIndexChanges: [{ key: "track", zIndex: 5, domId: "track" }],
|
|
82
|
+
},
|
|
83
|
+
timelineElements: [],
|
|
84
|
+
iframe,
|
|
85
|
+
activeCompPath: "index.html",
|
|
86
|
+
commit,
|
|
87
|
+
});
|
|
88
|
+
|
|
89
|
+
expect(commit).not.toHaveBeenCalled();
|
|
90
|
+
});
|
|
91
|
+
});
|
|
92
|
+
|
|
93
|
+
describe("extendRootDurationIfNeeded", () => {
|
|
94
|
+
it("extends the player duration only when the new end is larger", () => {
|
|
95
|
+
usePlayerStore.getState().setDuration(4);
|
|
96
|
+
|
|
97
|
+
expect(extendRootDurationIfNeeded(5)).toBe(true);
|
|
98
|
+
expect(usePlayerStore.getState().duration).toBe(5);
|
|
99
|
+
|
|
100
|
+
expect(extendRootDurationIfNeeded(5)).toBe(false);
|
|
101
|
+
expect(extendRootDurationIfNeeded(3)).toBe(false);
|
|
102
|
+
expect(usePlayerStore.getState().duration).toBe(5);
|
|
103
|
+
});
|
|
104
|
+
});
|
|
@@ -1,8 +1,121 @@
|
|
|
1
|
-
import type
|
|
1
|
+
import { type TimelineElement, usePlayerStore } from "../player/store/playerStore";
|
|
2
2
|
import { applyPatchByTarget, readAttributeByTarget } from "../utils/sourcePatcher";
|
|
3
|
-
import {
|
|
3
|
+
import {
|
|
4
|
+
formatTimelineAttributeNumber,
|
|
5
|
+
type TimelineStackingReorderIntent,
|
|
6
|
+
} from "../player/components/timelineEditing";
|
|
7
|
+
import { getElementZIndex } from "../player/lib/layerOrdering";
|
|
8
|
+
import { getTimelineElementIdentity } from "../player/lib/timelineElementHelpers";
|
|
4
9
|
import { saveProjectFilesWithHistory } from "../utils/studioFileHistory";
|
|
10
|
+
import { selectedKeyframePercentagesForElement } from "../utils/keyframeSelection";
|
|
5
11
|
import type { EditHistoryKind } from "../utils/editHistory";
|
|
12
|
+
import type { TimelineZIndexReorderCommit } from "./useTimelineEditingTypes";
|
|
13
|
+
import { extendRootDurationInSource } from "../utils/rootDuration";
|
|
14
|
+
|
|
15
|
+
function isHTMLElement(element: Element | null): element is HTMLElement {
|
|
16
|
+
if (!element) return false;
|
|
17
|
+
// Use the element's OWN realm's HTMLElement: timeline clips live in the preview
|
|
18
|
+
// iframe, and cross-realm `element instanceof HTMLElement` (main window) is
|
|
19
|
+
// always false — which silently dropped every timeline z-index commit.
|
|
20
|
+
const Ctor = element.ownerDocument?.defaultView?.HTMLElement ?? globalThis.HTMLElement;
|
|
21
|
+
return element instanceof Ctor;
|
|
22
|
+
}
|
|
23
|
+
|
|
24
|
+
/**
|
|
25
|
+
* Resolve a timeline vertical move to a z-index stacking reorder and commit it
|
|
26
|
+
* through the shared layers-panel reorder path. Reads live sibling z-index from
|
|
27
|
+
* the preview DOM, remaps with the dup-preserving reorder math, and writes only
|
|
28
|
+
* z-index (never data-track-index). No-op when the move isn't a reorder, the
|
|
29
|
+
* dragged clip is audio (no visual layer to restack), or the live siblings can't
|
|
30
|
+
* be resolved. Extracted from StudioApp's timeline hook to keep it under the
|
|
31
|
+
* studio 600-LOC cap.
|
|
32
|
+
*/
|
|
33
|
+
// fallow-ignore-next-line complexity
|
|
34
|
+
export function applyTimelineStackingReorder(input: {
|
|
35
|
+
element: TimelineElement;
|
|
36
|
+
stackingReorder: TimelineStackingReorderIntent | null | undefined;
|
|
37
|
+
timelineElements: readonly TimelineElement[];
|
|
38
|
+
iframe: HTMLIFrameElement | null;
|
|
39
|
+
activeCompPath: string | null;
|
|
40
|
+
commit: TimelineZIndexReorderCommit | null | undefined;
|
|
41
|
+
}): Promise<void> {
|
|
42
|
+
// Audio has no visual stacking; a vertical drag on it must never write z-index.
|
|
43
|
+
if (input.element.tag === "audio") return Promise.resolve();
|
|
44
|
+
|
|
45
|
+
const intent = input.stackingReorder ?? null;
|
|
46
|
+
if (intent == null || intent.zIndexChanges.length === 0) return Promise.resolve();
|
|
47
|
+
|
|
48
|
+
// Resolve each change's live element from the change's OWN locator (the intent
|
|
49
|
+
// is self-contained), falling back to the top-level element list. Sub-comp
|
|
50
|
+
// children aren't in `timelineElements`, so a list-only lookup would miss them.
|
|
51
|
+
const siblingByKey = new Map(
|
|
52
|
+
input.timelineElements.map((el) => [getTimelineElementIdentity(el), el]),
|
|
53
|
+
);
|
|
54
|
+
const doc = input.iframe?.contentDocument ?? null;
|
|
55
|
+
const findLive = (domId?: string, selector?: string, selectorIndex?: number): Element | null => {
|
|
56
|
+
if (!doc) return null;
|
|
57
|
+
if (domId) return doc.getElementById(domId);
|
|
58
|
+
if (selector) return doc.querySelectorAll(selector)[selectorIndex ?? 0] ?? null;
|
|
59
|
+
return null;
|
|
60
|
+
};
|
|
61
|
+
const commitEntries: Array<{
|
|
62
|
+
element: HTMLElement;
|
|
63
|
+
zIndex: number;
|
|
64
|
+
id?: string;
|
|
65
|
+
selector?: string;
|
|
66
|
+
selectorIndex?: number;
|
|
67
|
+
sourceFile: string;
|
|
68
|
+
key: string;
|
|
69
|
+
}> = [];
|
|
70
|
+
|
|
71
|
+
for (const change of intent.zIndexChanges) {
|
|
72
|
+
const sibling = siblingByKey.get(change.key);
|
|
73
|
+
const domId = change.domId ?? sibling?.domId;
|
|
74
|
+
const selector = change.selector ?? sibling?.selector;
|
|
75
|
+
const selectorIndex = change.selectorIndex ?? sibling?.selectorIndex;
|
|
76
|
+
const element = findLive(domId, selector, selectorIndex);
|
|
77
|
+
if (!isHTMLElement(element)) return Promise.resolve();
|
|
78
|
+
if (getElementZIndex(element) === change.zIndex) continue;
|
|
79
|
+
commitEntries.push({
|
|
80
|
+
element,
|
|
81
|
+
zIndex: change.zIndex,
|
|
82
|
+
id: domId ?? sibling?.id ?? change.key,
|
|
83
|
+
selector,
|
|
84
|
+
selectorIndex,
|
|
85
|
+
sourceFile: change.sourceFile ?? sibling?.sourceFile ?? input.activeCompPath ?? "index.html",
|
|
86
|
+
key: change.key,
|
|
87
|
+
});
|
|
88
|
+
}
|
|
89
|
+
|
|
90
|
+
if (commitEntries.length === 0) return Promise.resolve();
|
|
91
|
+
return input.commit?.(commitEntries) ?? Promise.resolve();
|
|
92
|
+
}
|
|
93
|
+
|
|
94
|
+
/**
|
|
95
|
+
* Remove the keyframes currently selected in the player store from the active
|
|
96
|
+
* element's GSAP animation. Reads selection lazily so it stays correct when
|
|
97
|
+
* invoked from a ref callback. Extracted from StudioApp to keep it under the
|
|
98
|
+
* studio 600-LOC cap.
|
|
99
|
+
*/
|
|
100
|
+
export function deleteSelectedKeyframes(session: {
|
|
101
|
+
selectedGsapAnimations: readonly { id: string; keyframes?: unknown }[];
|
|
102
|
+
handleGsapRemoveKeyframe: (animId: string, pct: number) => void;
|
|
103
|
+
}): void {
|
|
104
|
+
const { selectedKeyframes, selectedElementId } = usePlayerStore.getState();
|
|
105
|
+
const animation = session.selectedGsapAnimations.find((anim) => anim.keyframes);
|
|
106
|
+
if (!animation) return;
|
|
107
|
+
// Only the active element's keyframes; a stale cross-element selection must not delete here.
|
|
108
|
+
for (const pct of selectedKeyframePercentagesForElement(selectedKeyframes, selectedElementId)) {
|
|
109
|
+
session.handleGsapRemoveKeyframe(animation.id, pct);
|
|
110
|
+
}
|
|
111
|
+
}
|
|
112
|
+
|
|
113
|
+
export function extendRootDurationIfNeeded(newEnd: number): boolean {
|
|
114
|
+
const store = usePlayerStore.getState();
|
|
115
|
+
if (newEnd <= store.duration) return false;
|
|
116
|
+
store.setDuration(newEnd);
|
|
117
|
+
return true;
|
|
118
|
+
}
|
|
6
119
|
|
|
7
120
|
// ── Types ──
|
|
8
121
|
|
|
@@ -10,6 +123,8 @@ export interface RecordEditInput {
|
|
|
10
123
|
label: string;
|
|
11
124
|
kind: EditHistoryKind;
|
|
12
125
|
coalesceKey?: string;
|
|
126
|
+
/** Per-entry coalesce window override (ms); lets a slow follow-up still merge. */
|
|
127
|
+
coalesceMs?: number;
|
|
13
128
|
files: Record<string, { before: string; after: string }>;
|
|
14
129
|
}
|
|
15
130
|
|
|
@@ -72,8 +187,25 @@ export function patchIframeDomTiming(
|
|
|
72
187
|
}
|
|
73
188
|
}
|
|
74
189
|
|
|
190
|
+
function postRootDurationToPreview(
|
|
191
|
+
iframe: HTMLIFrameElement | null,
|
|
192
|
+
durationSeconds: number,
|
|
193
|
+
): void {
|
|
194
|
+
const duration = Number(durationSeconds);
|
|
195
|
+
if (!Number.isFinite(duration) || duration <= 0) return;
|
|
196
|
+
iframe?.contentWindow?.postMessage(
|
|
197
|
+
{
|
|
198
|
+
source: "hf-parent",
|
|
199
|
+
type: "control",
|
|
200
|
+
action: "set-root-duration",
|
|
201
|
+
durationSeconds: duration,
|
|
202
|
+
},
|
|
203
|
+
"*",
|
|
204
|
+
);
|
|
205
|
+
}
|
|
206
|
+
|
|
75
207
|
// fallow-ignore-next-line complexity
|
|
76
|
-
|
|
208
|
+
function resolveResizePlaybackStart(
|
|
77
209
|
original: string,
|
|
78
210
|
target: PatchTarget,
|
|
79
211
|
element: TimelineElement,
|
|
@@ -99,6 +231,47 @@ export function resolveResizePlaybackStart(
|
|
|
99
231
|
};
|
|
100
232
|
}
|
|
101
233
|
|
|
234
|
+
export function buildTimelineMoveTimingPatch(
|
|
235
|
+
original: string,
|
|
236
|
+
target: PatchTarget,
|
|
237
|
+
start: number,
|
|
238
|
+
duration: number,
|
|
239
|
+
): string {
|
|
240
|
+
const patched = applyPatchByTarget(original, target, {
|
|
241
|
+
type: "attribute",
|
|
242
|
+
property: "start",
|
|
243
|
+
value: formatTimelineAttributeNumber(start),
|
|
244
|
+
});
|
|
245
|
+
return extendRootDurationInSource(patched, start + duration);
|
|
246
|
+
}
|
|
247
|
+
|
|
248
|
+
export function buildTimelineResizeTimingPatch(
|
|
249
|
+
original: string,
|
|
250
|
+
target: PatchTarget,
|
|
251
|
+
element: TimelineElement,
|
|
252
|
+
updates: Pick<TimelineElement, "start" | "duration" | "playbackStart">,
|
|
253
|
+
): string {
|
|
254
|
+
const pbs = resolveResizePlaybackStart(original, target, element, updates);
|
|
255
|
+
let patched = applyPatchByTarget(original, target, {
|
|
256
|
+
type: "attribute",
|
|
257
|
+
property: "start",
|
|
258
|
+
value: formatTimelineAttributeNumber(updates.start),
|
|
259
|
+
});
|
|
260
|
+
patched = applyPatchByTarget(patched, target, {
|
|
261
|
+
type: "attribute",
|
|
262
|
+
property: "duration",
|
|
263
|
+
value: formatTimelineAttributeNumber(updates.duration),
|
|
264
|
+
});
|
|
265
|
+
if (pbs) {
|
|
266
|
+
patched = applyPatchByTarget(patched, target, {
|
|
267
|
+
type: "attribute",
|
|
268
|
+
property: pbs.attrName,
|
|
269
|
+
value: formatTimelineAttributeNumber(pbs.value),
|
|
270
|
+
});
|
|
271
|
+
}
|
|
272
|
+
return extendRootDurationInSource(patched, updates.start + updates.duration);
|
|
273
|
+
}
|
|
274
|
+
|
|
102
275
|
export interface PersistTimelineEditInput {
|
|
103
276
|
projectId: string;
|
|
104
277
|
element: TimelineElement;
|
|
@@ -141,6 +314,66 @@ export async function persistTimelineEdit(input: PersistTimelineEditInput): Prom
|
|
|
141
314
|
input.domEditSaveTimestampRef.current = Date.now();
|
|
142
315
|
}
|
|
143
316
|
|
|
317
|
+
export interface PersistTimelineBatchChange {
|
|
318
|
+
element: TimelineElement;
|
|
319
|
+
buildPatches: (original: string, target: PatchTarget) => string;
|
|
320
|
+
}
|
|
321
|
+
|
|
322
|
+
export interface PersistTimelineBatchEditInput {
|
|
323
|
+
projectId: string;
|
|
324
|
+
activeCompPath: string | null;
|
|
325
|
+
label: string;
|
|
326
|
+
changes: PersistTimelineBatchChange[];
|
|
327
|
+
writeProjectFile: (path: string, content: string) => Promise<void>;
|
|
328
|
+
recordEdit: (input: RecordEditInput) => Promise<void>;
|
|
329
|
+
domEditSaveTimestampRef: React.MutableRefObject<number>;
|
|
330
|
+
pendingTimelineEditPathRef: React.MutableRefObject<Set<string>>;
|
|
331
|
+
coalesceKey?: string;
|
|
332
|
+
}
|
|
333
|
+
|
|
334
|
+
export async function persistTimelineBatchEdit(
|
|
335
|
+
input: PersistTimelineBatchEditInput,
|
|
336
|
+
): Promise<void> {
|
|
337
|
+
const originals = new Map<string, string>();
|
|
338
|
+
const patchedByPath = new Map<string, string>();
|
|
339
|
+
|
|
340
|
+
for (const change of input.changes) {
|
|
341
|
+
const targetPath = change.element.sourceFile || input.activeCompPath || "index.html";
|
|
342
|
+
const original =
|
|
343
|
+
originals.get(targetPath) ?? (await readFileContent(input.projectId, targetPath));
|
|
344
|
+
originals.set(targetPath, original);
|
|
345
|
+
|
|
346
|
+
const patchTarget = buildPatchTarget(change.element);
|
|
347
|
+
if (!patchTarget) {
|
|
348
|
+
throw new Error(`Timeline element ${change.element.id} is missing a patchable target`);
|
|
349
|
+
}
|
|
350
|
+
|
|
351
|
+
const current = patchedByPath.get(targetPath) ?? original;
|
|
352
|
+
const patched = change.buildPatches(current, patchTarget);
|
|
353
|
+
if (patched === current) {
|
|
354
|
+
throw new Error(`Unable to patch timeline element ${change.element.id} in ${targetPath}`);
|
|
355
|
+
}
|
|
356
|
+
patchedByPath.set(targetPath, patched);
|
|
357
|
+
}
|
|
358
|
+
|
|
359
|
+
const files = Object.fromEntries(patchedByPath);
|
|
360
|
+
for (const targetPath of Object.keys(files)) {
|
|
361
|
+
input.pendingTimelineEditPathRef.current.add(targetPath);
|
|
362
|
+
}
|
|
363
|
+
input.domEditSaveTimestampRef.current = Date.now();
|
|
364
|
+
await saveProjectFilesWithHistory({
|
|
365
|
+
projectId: input.projectId,
|
|
366
|
+
label: input.label,
|
|
367
|
+
kind: "timeline",
|
|
368
|
+
coalesceKey: input.coalesceKey,
|
|
369
|
+
files,
|
|
370
|
+
readFile: async (path) => originals.get(path) ?? readFileContent(input.projectId, path),
|
|
371
|
+
writeFile: input.writeProjectFile,
|
|
372
|
+
recordEdit: input.recordEdit,
|
|
373
|
+
});
|
|
374
|
+
input.domEditSaveTimestampRef.current = Date.now();
|
|
375
|
+
}
|
|
376
|
+
|
|
144
377
|
export async function readFileContent(projectId: string, targetPath: string): Promise<string> {
|
|
145
378
|
if (targetPath.includes("\0") || targetPath.includes("..")) {
|
|
146
379
|
throw new Error(`Unsafe path: ${targetPath}`);
|
|
@@ -158,6 +391,94 @@ export async function readFileContent(projectId: string, targetPath: string): Pr
|
|
|
158
391
|
return data.content;
|
|
159
392
|
}
|
|
160
393
|
|
|
394
|
+
export type GsapMutationStatus = { mutated: boolean };
|
|
395
|
+
|
|
396
|
+
function isRecord(value: unknown): value is Record<string, unknown> {
|
|
397
|
+
return typeof value === "object" && value !== null;
|
|
398
|
+
}
|
|
399
|
+
|
|
400
|
+
function readMutationStatus(value: unknown): GsapMutationStatus {
|
|
401
|
+
if (!isRecord(value)) return { mutated: false };
|
|
402
|
+
return { mutated: value.mutated === true || value.changed === true };
|
|
403
|
+
}
|
|
404
|
+
|
|
405
|
+
function readMutationError(value: unknown, fallback: string): string {
|
|
406
|
+
if (isRecord(value) && typeof value.error === "string") return value.error;
|
|
407
|
+
return fallback;
|
|
408
|
+
}
|
|
409
|
+
|
|
410
|
+
export async function finishTimelineTimingFallback(input: {
|
|
411
|
+
iframe: HTMLIFrameElement | null;
|
|
412
|
+
needsExtension: boolean;
|
|
413
|
+
rootDurationSeconds: number;
|
|
414
|
+
reloadPreview: () => void;
|
|
415
|
+
gsapMutation?: () => Promise<GsapMutationStatus>;
|
|
416
|
+
onGsapError: (error: unknown) => void;
|
|
417
|
+
}): Promise<void> {
|
|
418
|
+
let gsapMutated = false;
|
|
419
|
+
if (input.gsapMutation) {
|
|
420
|
+
try {
|
|
421
|
+
gsapMutated = (await input.gsapMutation()).mutated;
|
|
422
|
+
} catch (error) {
|
|
423
|
+
input.onGsapError(error);
|
|
424
|
+
return;
|
|
425
|
+
}
|
|
426
|
+
}
|
|
427
|
+
if (input.needsExtension) {
|
|
428
|
+
postRootDurationToPreview(input.iframe, input.rootDurationSeconds);
|
|
429
|
+
if (gsapMutated) input.reloadPreview();
|
|
430
|
+
return;
|
|
431
|
+
}
|
|
432
|
+
input.reloadPreview();
|
|
433
|
+
}
|
|
434
|
+
|
|
435
|
+
// Coalesce window for folding a GSAP mutation into the preceding timing edit; only has to
|
|
436
|
+
// outlast one GSAP server round-trip, never a real second edit.
|
|
437
|
+
const GSAP_HISTORY_COALESCE_MS = 10_000;
|
|
438
|
+
|
|
439
|
+
/**
|
|
440
|
+
* A server GSAP rewrite mutates the same file the timing patch just wrote, but AFTER the
|
|
441
|
+
* timing edit was recorded, leaving the recorded `after` stale so an undo hits a hash
|
|
442
|
+
* conflict. This snapshots every touched file, runs the mutation, then records a follow-up
|
|
443
|
+
* edit under the same coalesceKey with a window wide enough to survive the GSAP round-trip,
|
|
444
|
+
* folding both writes into one undo step. Returns the mutation status for caller reloads.
|
|
445
|
+
*/
|
|
446
|
+
export async function foldGsapMutationIntoHistory(input: {
|
|
447
|
+
projectId: string;
|
|
448
|
+
paths: string[];
|
|
449
|
+
label: string;
|
|
450
|
+
coalesceKey?: string;
|
|
451
|
+
recordEdit: (edit: RecordEditInput) => Promise<void>;
|
|
452
|
+
gsapMutation: () => Promise<GsapMutationStatus>;
|
|
453
|
+
}): Promise<GsapMutationStatus> {
|
|
454
|
+
const uniquePaths = [...new Set(input.paths)];
|
|
455
|
+
const before = new Map<string, string>();
|
|
456
|
+
for (const path of uniquePaths) {
|
|
457
|
+
before.set(path, await readFileContent(input.projectId, path));
|
|
458
|
+
}
|
|
459
|
+
const status = await input.gsapMutation();
|
|
460
|
+
if (status.mutated) {
|
|
461
|
+
const files: Record<string, { before: string; after: string }> = {};
|
|
462
|
+
for (const path of uniquePaths) {
|
|
463
|
+
const priorContent = before.get(path);
|
|
464
|
+
const finalContent = await readFileContent(input.projectId, path);
|
|
465
|
+
if (priorContent !== undefined && finalContent !== priorContent) {
|
|
466
|
+
files[path] = { before: priorContent, after: finalContent };
|
|
467
|
+
}
|
|
468
|
+
}
|
|
469
|
+
if (Object.keys(files).length > 0) {
|
|
470
|
+
await input.recordEdit({
|
|
471
|
+
label: input.label,
|
|
472
|
+
kind: "timeline",
|
|
473
|
+
coalesceKey: input.coalesceKey,
|
|
474
|
+
coalesceMs: GSAP_HISTORY_COALESCE_MS,
|
|
475
|
+
files,
|
|
476
|
+
});
|
|
477
|
+
}
|
|
478
|
+
}
|
|
479
|
+
return status;
|
|
480
|
+
}
|
|
481
|
+
|
|
161
482
|
/**
|
|
162
483
|
* Shift all GSAP animation positions targeting a given element by a time delta.
|
|
163
484
|
* Calls the server-side GSAP mutation endpoint which uses the AST-based parser.
|
|
@@ -167,8 +488,8 @@ export async function shiftGsapPositions(
|
|
|
167
488
|
filePath: string,
|
|
168
489
|
elementId: string,
|
|
169
490
|
delta: number,
|
|
170
|
-
): Promise<
|
|
171
|
-
if (delta === 0 || !elementId) return;
|
|
491
|
+
): Promise<GsapMutationStatus> {
|
|
492
|
+
if (delta === 0 || !elementId) return { mutated: false };
|
|
172
493
|
const res = await fetch(
|
|
173
494
|
`/api/projects/${projectId}/gsap-mutations/${encodeURIComponent(filePath)}`,
|
|
174
495
|
{
|
|
@@ -183,8 +504,9 @@ export async function shiftGsapPositions(
|
|
|
183
504
|
);
|
|
184
505
|
if (!res.ok) {
|
|
185
506
|
const err = await res.json().catch(() => null);
|
|
186
|
-
throw new Error((err
|
|
507
|
+
throw new Error(readMutationError(err, "shift-positions failed"));
|
|
187
508
|
}
|
|
509
|
+
return readMutationStatus(await res.json().catch(() => null));
|
|
188
510
|
}
|
|
189
511
|
|
|
190
512
|
export async function scaleGsapPositions(
|
|
@@ -195,9 +517,9 @@ export async function scaleGsapPositions(
|
|
|
195
517
|
oldDuration: number,
|
|
196
518
|
newStart: number,
|
|
197
519
|
newDuration: number,
|
|
198
|
-
): Promise<
|
|
199
|
-
if (!elementId || oldDuration <= 0 || newDuration <= 0) return;
|
|
200
|
-
if (oldStart === newStart && oldDuration === newDuration) return;
|
|
520
|
+
): Promise<GsapMutationStatus> {
|
|
521
|
+
if (!elementId || oldDuration <= 0 || newDuration <= 0) return { mutated: false };
|
|
522
|
+
if (oldStart === newStart && oldDuration === newDuration) return { mutated: false };
|
|
201
523
|
const res = await fetch(
|
|
202
524
|
`/api/projects/${projectId}/gsap-mutations/${encodeURIComponent(filePath)}`,
|
|
203
525
|
{
|
|
@@ -215,8 +537,62 @@ export async function scaleGsapPositions(
|
|
|
215
537
|
);
|
|
216
538
|
if (!res.ok) {
|
|
217
539
|
const err = await res.json().catch(() => null);
|
|
218
|
-
throw new Error((err
|
|
540
|
+
throw new Error(readMutationError(err, "scale-positions failed"));
|
|
219
541
|
}
|
|
542
|
+
return readMutationStatus(await res.json().catch(() => null));
|
|
543
|
+
}
|
|
544
|
+
|
|
545
|
+
/** Single-clip move GSAP shift, folded into the timing edit's history entry (see above). */
|
|
546
|
+
export function foldedShiftGsapMutation(input: {
|
|
547
|
+
projectId: string;
|
|
548
|
+
targetPath: string;
|
|
549
|
+
domId: string;
|
|
550
|
+
delta: number;
|
|
551
|
+
label: string;
|
|
552
|
+
coalesceKey?: string;
|
|
553
|
+
recordEdit: (edit: RecordEditInput) => Promise<void>;
|
|
554
|
+
}): () => Promise<GsapMutationStatus> {
|
|
555
|
+
return () =>
|
|
556
|
+
foldGsapMutationIntoHistory({
|
|
557
|
+
projectId: input.projectId,
|
|
558
|
+
paths: [input.targetPath],
|
|
559
|
+
label: input.label,
|
|
560
|
+
coalesceKey: input.coalesceKey,
|
|
561
|
+
recordEdit: input.recordEdit,
|
|
562
|
+
gsapMutation: () =>
|
|
563
|
+
shiftGsapPositions(input.projectId, input.targetPath, input.domId, input.delta),
|
|
564
|
+
});
|
|
565
|
+
}
|
|
566
|
+
|
|
567
|
+
/** Single-clip resize GSAP scale, folded into the timing edit's history entry (see above). */
|
|
568
|
+
export function foldedScaleGsapMutation(input: {
|
|
569
|
+
projectId: string;
|
|
570
|
+
targetPath: string;
|
|
571
|
+
domId: string;
|
|
572
|
+
from: { start: number; duration: number };
|
|
573
|
+
to: { start: number; duration: number };
|
|
574
|
+
label: string;
|
|
575
|
+
coalesceKey?: string;
|
|
576
|
+
recordEdit: (edit: RecordEditInput) => Promise<void>;
|
|
577
|
+
}): () => Promise<GsapMutationStatus> {
|
|
578
|
+
return () =>
|
|
579
|
+
foldGsapMutationIntoHistory({
|
|
580
|
+
projectId: input.projectId,
|
|
581
|
+
paths: [input.targetPath],
|
|
582
|
+
label: input.label,
|
|
583
|
+
coalesceKey: input.coalesceKey,
|
|
584
|
+
recordEdit: input.recordEdit,
|
|
585
|
+
gsapMutation: () =>
|
|
586
|
+
scaleGsapPositions(
|
|
587
|
+
input.projectId,
|
|
588
|
+
input.targetPath,
|
|
589
|
+
input.domId,
|
|
590
|
+
input.from.start,
|
|
591
|
+
input.from.duration,
|
|
592
|
+
input.to.start,
|
|
593
|
+
input.to.duration,
|
|
594
|
+
),
|
|
595
|
+
});
|
|
220
596
|
}
|
|
221
597
|
|
|
222
598
|
// Re-export applyPatchByTarget for use in the hook (avoids double import in callers)
|
|
@@ -11,6 +11,7 @@ import { useCallback, useEffect, useRef } from "react";
|
|
|
11
11
|
import type { DomEditSelection } from "../components/editor/domEditingTypes";
|
|
12
12
|
import { STUDIO_GSAP_PANEL_ENABLED } from "../components/editor/manualEditingAvailability";
|
|
13
13
|
import { usePlayerStore } from "../player";
|
|
14
|
+
import { resolveTimelineIdForSelection } from "../utils/studioHelpers";
|
|
14
15
|
import { useDomEditPreviewSync } from "./useDomEditPreviewSync";
|
|
15
16
|
import { useGsapAnimationsForElement, usePopulateKeyframeCacheForFile } from "./useGsapTweenCache";
|
|
16
17
|
import { useGsapAnimationFetchFallback } from "./useGsapAnimationFetchFallback";
|
|
@@ -170,13 +171,14 @@ export function useDomEditWiring({
|
|
|
170
171
|
|
|
171
172
|
useEffect(() => {
|
|
172
173
|
if (!domEditSelection?.id) return;
|
|
173
|
-
const { selectedElementId, elements,
|
|
174
|
-
|
|
175
|
-
|
|
176
|
-
|
|
177
|
-
|
|
178
|
-
|
|
179
|
-
|
|
174
|
+
const { selectedElementId, elements, setSelectionAnchor } = usePlayerStore.getState();
|
|
175
|
+
// Resolve through the canonical resolver (source-file + ancestor + active-comp
|
|
176
|
+
// fallback) rather than a narrow domId/id match, so a sub-composition selection
|
|
177
|
+
// maps to the same clip the rest of the selection pipeline picks. Use the
|
|
178
|
+
// anchor-only setter: this is a DOM->store echo and must not collapse a group.
|
|
179
|
+
const key = resolveTimelineIdForSelection(domEditSelection, elements, activeCompPath);
|
|
180
|
+
if (key && key !== selectedElementId) setSelectionAnchor(key);
|
|
181
|
+
}, [domEditSelection, activeCompPath]);
|
|
180
182
|
|
|
181
183
|
// ── GSAP cache sync ──
|
|
182
184
|
|