@hyperframes/studio 0.8.105 → 0.8.106
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-tnfCiVdR.js → hyperframes-player-dl6K6ABp.js} +1 -1
- package/dist/assets/{index-DEDhPmPD.js → index-CMiYVGv5.js} +202 -202
- package/dist/assets/{index-zpe332hb.js → index-DKT5NF1Y.js} +1 -1
- package/dist/assets/index-lrT-gfHa.css +1 -0
- package/dist/assets/{index-Ca6LZlVb.js → index-ooaZZhKf.js} +1 -1
- package/dist/{chunk-QADHJFDF.js → chunk-ICTZJPQQ.js} +2 -1
- package/dist/chunk-ICTZJPQQ.js.map +1 -0
- package/dist/index.d.ts +15 -4
- package/dist/index.html +11 -2
- package/dist/index.js +6601 -6022
- package/dist/index.js.map +1 -1
- package/dist/player.js +1 -1
- package/dist/styles.css +1 -1
- package/package.json +9 -9
- package/src/captions/keyboard.test.ts +6 -0
- package/src/captions/keyboard.ts +2 -1
- package/src/components/StudioHeader.dom.test.tsx +31 -9
- package/src/components/StudioHeader.tsx +4 -1
- package/src/components/ThemeToggle.tsx +99 -0
- package/src/components/editor/DomEditOverlay.tsx +37 -5
- package/src/components/editor/SourceEditor.tsx +2 -6
- package/src/components/editor/domEditOverlayStartGesture.ts +127 -115
- package/src/components/editor/gestureHoldsPreviewReload.test.tsx +275 -0
- package/src/components/editor/manualEditsDom.ts +33 -0
- package/src/components/editor/marqueeCommit.test.tsx +69 -1
- package/src/components/editor/marqueeCommit.ts +6 -1
- package/src/components/editor/overlayFrameLoop.test.ts +27 -0
- package/src/components/editor/overlayFrameLoop.ts +21 -0
- package/src/components/editor/propertyPanelFont.test.tsx +80 -3
- package/src/components/editor/propertyPanelFont.tsx +69 -46
- package/src/components/editor/propertyPanelHelpers.ts +1 -2
- package/src/components/editor/studioCodeTheme.test.ts +20 -0
- package/src/components/editor/studioCodeTheme.ts +99 -0
- package/src/components/editor/useDomEditNudge.test.tsx +31 -0
- package/src/components/editor/useDomEditNudge.ts +2 -2
- package/src/components/editor/useInlineTextEditing.tsx +1 -1
- package/src/components/editor/useMotionPathData.test.tsx +64 -0
- package/src/components/editor/useMotionPathData.ts +5 -1
- package/src/hooks/appHotkeysDispatch.ts +2 -1
- package/src/hooks/previewPromotionSelection.test.tsx +180 -0
- package/src/hooks/useAppHotkeys.test.ts +32 -0
- package/src/hooks/useAppHotkeys.ts +1 -2
- package/src/hooks/useClipboard.source.test.ts +99 -19
- package/src/hooks/useClipboard.ts +145 -124
- package/src/hooks/useDomEditSession.test.tsx +76 -72
- package/src/hooks/useDomEditSession.ts +7 -43
- package/src/hooks/useGsapTweenCache.ts +1 -2
- package/src/hooks/useInlineTextEdit.ts +16 -2
- package/src/hooks/useLivePreviewIframe.ts +18 -0
- package/src/hooks/usePreviewPersistence.history.test.tsx +47 -0
- package/src/hooks/usePreviewPersistence.ts +2 -5
- package/src/hooks/useRenderClipContent.ts +3 -3
- package/src/index.ts +2 -0
- package/src/livePreviewIframeExport.test.tsx +33 -0
- package/src/main.tsx +4 -1
- package/src/player/components/AudioWaveform.test.tsx +36 -15
- package/src/player/components/AudioWaveform.tsx +24 -7
- package/src/player/components/TimelineClip.test.tsx +38 -0
- package/src/player/components/TimelineClip.tsx +14 -6
- package/src/player/components/TimelineClipFades.test.tsx +376 -74
- package/src/player/components/TimelineClipFades.tsx +368 -137
- package/src/player/components/useTimelineKeyboardActor.ts +1 -1
- package/src/player/hooks/timelinePlayerTestHarness.ts +27 -0
- package/src/player/hooks/useShadowPreviewReload.ts +72 -5
- package/src/player/lib/automationStoreSync.test.ts +18 -1
- package/src/player/lib/automationStoreSync.ts +29 -17
- package/src/player/sceneSwap.ts +18 -0
- package/src/styles/components.css +26 -0
- package/src/styles/studio.css +6 -0
- package/src/styles/theme.css +10 -0
- package/src/utils/clipboardPayload.ts +10 -1
- package/src/utils/studioPendingEdits.ts +21 -0
- package/src/utils/studioTheme.test.tsx +141 -0
- package/src/utils/studioTheme.ts +34 -0
- package/src/utils/studioUiPreferences.ts +3 -0
- package/src/utils/typingTarget.test.ts +8 -1
- package/src/utils/typingTarget.ts +5 -0
- package/dist/assets/index-Cre9X4RS.css +0 -1
- package/dist/chunk-QADHJFDF.js.map +0 -1
|
@@ -9,6 +9,7 @@ import type { GsapAnimation } from "@hyperframes/core/gsap-parser";
|
|
|
9
9
|
import type { DomEditSelection } from "../components/editor/domEditingTypes";
|
|
10
10
|
import type { TimelineElement } from "../player";
|
|
11
11
|
import type { UseDomEditSessionParams } from "./useDomEditSession";
|
|
12
|
+
import { makeSelection } from "./domSelectionTestHarness";
|
|
12
13
|
|
|
13
14
|
const styleOp = (property: string, value: string): PatchOperation => ({
|
|
14
15
|
type: "inline-style",
|
|
@@ -132,26 +133,30 @@ vi.mock("./useDomEditCommits", () => ({
|
|
|
132
133
|
};
|
|
133
134
|
},
|
|
134
135
|
}));
|
|
136
|
+
const iframesHandedOn: Array<{ hook: string; iframe: unknown }> = [];
|
|
135
137
|
vi.mock("./useDomSelection", () => ({
|
|
136
|
-
useDomSelection: () =>
|
|
137
|
-
|
|
138
|
-
|
|
139
|
-
|
|
140
|
-
|
|
141
|
-
|
|
142
|
-
|
|
143
|
-
|
|
144
|
-
|
|
145
|
-
|
|
146
|
-
|
|
147
|
-
|
|
148
|
-
|
|
149
|
-
|
|
150
|
-
|
|
151
|
-
|
|
152
|
-
|
|
153
|
-
|
|
154
|
-
|
|
138
|
+
useDomSelection: ({ previewIframe }: { previewIframe: unknown }) => {
|
|
139
|
+
iframesHandedOn.push({ hook: "selection", iframe: previewIframe });
|
|
140
|
+
return {
|
|
141
|
+
domEditSelection: domEditSelectionRef.current,
|
|
142
|
+
domEditGroupSelections: [],
|
|
143
|
+
domEditHoverSelection: null,
|
|
144
|
+
activeGroupElement: null,
|
|
145
|
+
domEditSelectionRef,
|
|
146
|
+
domEditGroupSelectionsRef,
|
|
147
|
+
setActiveGroupElement: vi.fn(),
|
|
148
|
+
applyDomSelection: vi.fn(),
|
|
149
|
+
clearDomSelection: vi.fn(),
|
|
150
|
+
buildDomSelectionFromTarget: vi.fn(),
|
|
151
|
+
resolveDomSelectionFromPreviewPoint: vi.fn(),
|
|
152
|
+
resolveAllDomSelectionsFromPreviewPoint: vi.fn(),
|
|
153
|
+
updateDomEditHoverSelection: vi.fn(),
|
|
154
|
+
buildDomSelectionForTimelineElement: vi.fn(),
|
|
155
|
+
handleTimelineElementSelect: vi.fn(),
|
|
156
|
+
refreshDomEditSelectionFromPreview: vi.fn(),
|
|
157
|
+
applyMarqueeSelection: vi.fn(),
|
|
158
|
+
};
|
|
159
|
+
},
|
|
155
160
|
}));
|
|
156
161
|
vi.mock("./useAskAgentModal", () => ({
|
|
157
162
|
useAskAgentModal: () => ({
|
|
@@ -208,33 +213,36 @@ vi.mock("./useGroupCommits", () => ({
|
|
|
208
213
|
}),
|
|
209
214
|
}));
|
|
210
215
|
vi.mock("./useDomEditWiring", () => ({
|
|
211
|
-
useDomEditWiring: () =>
|
|
212
|
-
|
|
213
|
-
|
|
214
|
-
|
|
215
|
-
|
|
216
|
-
|
|
217
|
-
|
|
218
|
-
|
|
219
|
-
|
|
220
|
-
|
|
221
|
-
|
|
222
|
-
|
|
223
|
-
|
|
224
|
-
|
|
225
|
-
|
|
226
|
-
|
|
227
|
-
|
|
228
|
-
|
|
229
|
-
|
|
230
|
-
|
|
231
|
-
|
|
232
|
-
|
|
233
|
-
|
|
234
|
-
|
|
235
|
-
|
|
236
|
-
|
|
237
|
-
|
|
216
|
+
useDomEditWiring: ({ previewIframe }: { previewIframe: unknown }) => {
|
|
217
|
+
iframesHandedOn.push({ hook: "wiring", iframe: previewIframe });
|
|
218
|
+
return {
|
|
219
|
+
onClickToSource: vi.fn(),
|
|
220
|
+
selectedGsapAnimations: [],
|
|
221
|
+
gsapMultipleTimelines: false,
|
|
222
|
+
gsapUnsupportedTimelinePattern: false,
|
|
223
|
+
trackGsapInteractionFailure: vi.fn(),
|
|
224
|
+
makeFetchFallback: vi.fn(),
|
|
225
|
+
handleGsapUpdateProperty: vi.fn(),
|
|
226
|
+
handleGsapUpdateMeta: vi.fn(),
|
|
227
|
+
handleGsapDeleteAnimation: vi.fn(),
|
|
228
|
+
handleGsapDeleteAllForElement: vi.fn(),
|
|
229
|
+
handleGsapAddAnimation: vi.fn(),
|
|
230
|
+
handleGsapAddProperty: vi.fn(),
|
|
231
|
+
handleGsapRemoveProperty: vi.fn(),
|
|
232
|
+
handleGsapUpdateFromProperty: vi.fn(),
|
|
233
|
+
handleGsapAddFromProperty: vi.fn(),
|
|
234
|
+
handleGsapRemoveFromProperty: vi.fn(),
|
|
235
|
+
handleGsapAddKeyframe: vi.fn(),
|
|
236
|
+
handleGsapAddKeyframeBatch: vi.fn(),
|
|
237
|
+
handleGsapRemoveKeyframe: vi.fn(),
|
|
238
|
+
handleGsapMoveKeyframeToPlayhead: vi.fn(),
|
|
239
|
+
handleGsapMoveKeyframe: vi.fn(),
|
|
240
|
+
handleGsapResizeKeyframedTween: vi.fn(),
|
|
241
|
+
handleGsapConvertToKeyframes: vi.fn(),
|
|
242
|
+
handleGsapRemoveAllKeyframes: vi.fn(),
|
|
243
|
+
handleResetSelectedElementKeyframes: vi.fn(),
|
|
244
|
+
};
|
|
245
|
+
},
|
|
238
246
|
}));
|
|
239
247
|
vi.mock("./usePreviewInteraction", () => ({
|
|
240
248
|
usePreviewInteraction: () => ({
|
|
@@ -310,32 +318,9 @@ describe("onReorderShadow source filter", () => {
|
|
|
310
318
|
describe("bulk segment ease commits", () => {
|
|
311
319
|
it("uses one ordered batch for many ids and sane paths for one or no ids", async () => {
|
|
312
320
|
const { useDomEditSession } = await import("./useDomEditSession");
|
|
313
|
-
const
|
|
314
|
-
|
|
315
|
-
|
|
316
|
-
label: "Hero",
|
|
317
|
-
tagName: "DIV",
|
|
318
|
-
sourceFile: "index.html",
|
|
319
|
-
compositionPath: "index.html",
|
|
320
|
-
isCompositionHost: false,
|
|
321
|
-
isInsideLockedComposition: false,
|
|
322
|
-
boundingBox: { x: 0, y: 0, width: 100, height: 100 },
|
|
323
|
-
textContent: null,
|
|
324
|
-
dataAttributes: {},
|
|
325
|
-
inlineStyles: {},
|
|
326
|
-
computedStyles: {},
|
|
327
|
-
textFields: [],
|
|
328
|
-
capabilities: {
|
|
329
|
-
canSelect: true,
|
|
330
|
-
canEditStyles: true,
|
|
331
|
-
canCrop: true,
|
|
332
|
-
canMove: true,
|
|
333
|
-
canResize: true,
|
|
334
|
-
canApplyManualOffset: true,
|
|
335
|
-
canApplyManualSize: true,
|
|
336
|
-
canApplyManualRotation: true,
|
|
337
|
-
},
|
|
338
|
-
};
|
|
321
|
+
const hero = document.createElement("div");
|
|
322
|
+
hero.id = "hero";
|
|
323
|
+
const selection = makeSelection("Hero", hero);
|
|
339
324
|
domEditSelectionRef.current = selection;
|
|
340
325
|
gsapCommitMutation.mockClear();
|
|
341
326
|
gsapCommitMutation.batch.mockClear();
|
|
@@ -601,3 +586,22 @@ describe("handleDomEditElementDelete routing", () => {
|
|
|
601
586
|
expect(handleDomEditElementsDeleteMock).not.toHaveBeenCalled();
|
|
602
587
|
});
|
|
603
588
|
});
|
|
589
|
+
|
|
590
|
+
describe("a shadow reload promoted without a host re-render", () => {
|
|
591
|
+
it("hands the promoted iframe to the session's selection and preview sync", async () => {
|
|
592
|
+
const { useDomEditSession } = await import("./useDomEditSession");
|
|
593
|
+
const { announcePreviewPromoted } = await import("../player/sceneSwap");
|
|
594
|
+
const [host, promoted] = [0, 1].map(() => document.createElement("iframe"));
|
|
595
|
+
function Probe() {
|
|
596
|
+
useDomEditSession(createSessionParams({ previewIframe: host! }));
|
|
597
|
+
return null;
|
|
598
|
+
}
|
|
599
|
+
const root = createRoot(document.createElement("div"));
|
|
600
|
+
act(() => root.render(<Probe />));
|
|
601
|
+
act(() => announcePreviewPromoted({ retired: host!, live: promoted! }));
|
|
602
|
+
const last = (hook: string) => iframesHandedOn.findLast((seen) => seen.hook === hook)?.iframe;
|
|
603
|
+
expect(last("selection")).toBe(promoted);
|
|
604
|
+
expect(last("wiring")).toBe(promoted);
|
|
605
|
+
act(() => root.unmount());
|
|
606
|
+
});
|
|
607
|
+
});
|
|
@@ -24,6 +24,7 @@ import type { DomEditSelection } from "../components/editor/domEditingTypes";
|
|
|
24
24
|
import { membersForDelete, timelineElementsForDelete } from "./domEditDeleteMembers";
|
|
25
25
|
import type { RecordEditInput } from "./domEditDeleteMembers";
|
|
26
26
|
import type { DomEditTimelineParams } from "./useDomSelectionTypes";
|
|
27
|
+
import { useLivePreviewIframe } from "./useLivePreviewIframe";
|
|
27
28
|
// Re-exported: the delete rule lives in its own module; callers and its test import it from here.
|
|
28
29
|
export { membersForDelete };
|
|
29
30
|
|
|
@@ -86,14 +87,13 @@ export function useDomEditSession({
|
|
|
86
87
|
importedFontAssetsRef,
|
|
87
88
|
projectDir,
|
|
88
89
|
projectIdRef,
|
|
89
|
-
previewIframe,
|
|
90
|
+
previewIframe: hostPreviewIframe,
|
|
90
91
|
refreshKey,
|
|
91
92
|
previewDocumentVersion,
|
|
92
93
|
rightPanelTab,
|
|
93
94
|
applyStudioManualEditsToPreviewRef,
|
|
94
95
|
syncPreviewHotkeys,
|
|
95
96
|
reloadPreview,
|
|
96
|
-
setRefreshKey: _setRefreshKey,
|
|
97
97
|
openSourceForSelection,
|
|
98
98
|
sdkSession,
|
|
99
99
|
publishSdkSession,
|
|
@@ -103,7 +103,7 @@ export function useDomEditSession({
|
|
|
103
103
|
}: UseDomEditSessionParams) {
|
|
104
104
|
const isMasterView = !activeCompPath || activeCompPath === "index.html";
|
|
105
105
|
const previewCaptionEditMode = captionEditMode && !readOnlyPreview;
|
|
106
|
-
|
|
106
|
+
const previewIframe = useLivePreviewIframe(hostPreviewIframe);
|
|
107
107
|
const {
|
|
108
108
|
domEditSelection,
|
|
109
109
|
domEditGroupSelections,
|
|
@@ -173,29 +173,11 @@ export function useDomEditSession({
|
|
|
173
173
|
|
|
174
174
|
const { version: gsapCacheVersion, bump: bumpGsapCache } = useGsapCacheVersion();
|
|
175
175
|
|
|
176
|
-
// ── GSAP script commits ──
|
|
177
|
-
|
|
178
176
|
const {
|
|
179
177
|
commitMutation: gsapCommitMutation,
|
|
180
|
-
updateGsapProperty,
|
|
181
|
-
updateGsapMeta,
|
|
182
|
-
deleteGsapAnimation,
|
|
183
|
-
deleteAllForSelector,
|
|
184
|
-
addGsapAnimation,
|
|
185
|
-
addGsapProperty,
|
|
186
|
-
removeGsapProperty,
|
|
187
|
-
updateGsapFromProperty,
|
|
188
|
-
addGsapFromProperty,
|
|
189
|
-
removeGsapFromProperty,
|
|
190
|
-
addKeyframe,
|
|
191
|
-
addKeyframeBatch,
|
|
192
|
-
removeKeyframe,
|
|
193
|
-
moveKeyframe,
|
|
194
|
-
resizeKeyframedTween,
|
|
195
|
-
convertToKeyframes,
|
|
196
|
-
removeAllKeyframes,
|
|
197
178
|
setArcPath,
|
|
198
179
|
updateArcSegment,
|
|
180
|
+
...gsapScriptEdits
|
|
199
181
|
} = useGsapScriptCommits({
|
|
200
182
|
projectIdRef,
|
|
201
183
|
activeCompPath,
|
|
@@ -211,8 +193,6 @@ export function useDomEditSession({
|
|
|
211
193
|
forceReloadSdkSession,
|
|
212
194
|
});
|
|
213
195
|
|
|
214
|
-
// ── DOM commit handlers ──
|
|
215
|
-
|
|
216
196
|
const {
|
|
217
197
|
resolveImportedFontAsset,
|
|
218
198
|
handleDomStyleCommit,
|
|
@@ -430,23 +410,7 @@ export function useDomEditSession({
|
|
|
430
410
|
applyDomSelection,
|
|
431
411
|
buildDomSelectionFromTarget,
|
|
432
412
|
openSourceForSelection,
|
|
433
|
-
|
|
434
|
-
updateGsapMeta,
|
|
435
|
-
deleteGsapAnimation,
|
|
436
|
-
deleteAllForSelector,
|
|
437
|
-
addGsapAnimation,
|
|
438
|
-
addGsapProperty,
|
|
439
|
-
removeGsapProperty,
|
|
440
|
-
updateGsapFromProperty,
|
|
441
|
-
addGsapFromProperty,
|
|
442
|
-
removeGsapFromProperty,
|
|
443
|
-
addKeyframe,
|
|
444
|
-
addKeyframeBatch,
|
|
445
|
-
removeKeyframe,
|
|
446
|
-
moveKeyframe,
|
|
447
|
-
resizeKeyframedTween,
|
|
448
|
-
convertToKeyframes,
|
|
449
|
-
removeAllKeyframes,
|
|
413
|
+
...gsapScriptEdits,
|
|
450
414
|
handleDomManualEditsReset,
|
|
451
415
|
});
|
|
452
416
|
const {
|
|
@@ -492,8 +456,8 @@ export function useDomEditSession({
|
|
|
492
456
|
handleDomBoxSizeCommit,
|
|
493
457
|
handleDomRotationCommit: handleDomCssRotationCommit,
|
|
494
458
|
commitPositionPatchToHtml,
|
|
495
|
-
addGsapAnimation,
|
|
496
|
-
convertToKeyframes,
|
|
459
|
+
addGsapAnimation: gsapScriptEdits.addGsapAnimation,
|
|
460
|
+
convertToKeyframes: gsapScriptEdits.convertToKeyframes,
|
|
497
461
|
setArcPath,
|
|
498
462
|
updateArcSegment,
|
|
499
463
|
});
|
|
@@ -336,8 +336,7 @@ export function usePopulateKeyframeCacheForFile(
|
|
|
336
336
|
// fallow-ignore-next-line complexity
|
|
337
337
|
const tryRuntimeScan = () => {
|
|
338
338
|
if (runtimeScanDoneRef.current === `kf-cache:${projectId}:${sf}:${version}`) return true;
|
|
339
|
-
const iframe =
|
|
340
|
-
iframeRef?.current ?? document.querySelector<HTMLIFrameElement>("iframe[src*='/preview/']");
|
|
339
|
+
const iframe = iframeRef?.current;
|
|
341
340
|
if (!iframe) return false;
|
|
342
341
|
// Clip dims per element so the scan converts tween-relative keyframes to
|
|
343
342
|
// clip-relative (matching the static path) instead of timeline-relative.
|
|
@@ -1,6 +1,12 @@
|
|
|
1
1
|
import { useCallback, useEffect, useRef, useState } from "react";
|
|
2
2
|
import { sanitizeRichTextChildren } from "@hyperframes/core/rich-text-sanitize";
|
|
3
3
|
import { usePreviewReadOnly } from "../components/editor/previewReadOnlyContext";
|
|
4
|
+
import { trackStudioPendingEdit } from "../utils/studioPendingEdits";
|
|
5
|
+
import {
|
|
6
|
+
beginStudioManualEditGesture,
|
|
7
|
+
endStudioManualEditGesture,
|
|
8
|
+
noteStudioManualEditSave,
|
|
9
|
+
} from "../components/editor/manualEditsDom";
|
|
4
10
|
|
|
5
11
|
/**
|
|
6
12
|
* Editing an element's text where it sits, in the composition itself.
|
|
@@ -43,6 +49,8 @@ export interface InlineTextEditSession {
|
|
|
43
49
|
outline: string;
|
|
44
50
|
/** The element's own outline offset, restored with the outline. */
|
|
45
51
|
outlineOffset: string;
|
|
52
|
+
/** Holds the preview on screen while the edit is open. */
|
|
53
|
+
gesture?: string;
|
|
46
54
|
}
|
|
47
55
|
|
|
48
56
|
/** The live markup to persist and the session snapshot to restore on failure. */
|
|
@@ -86,7 +94,7 @@ export function useInlineTextEdit({
|
|
|
86
94
|
onPause,
|
|
87
95
|
}: {
|
|
88
96
|
/** Where the edited text goes. The caller owns persistence. */
|
|
89
|
-
onCommit: (commit: InlineTextEditCommit) =>
|
|
97
|
+
onCommit: (commit: InlineTextEditCommit) => unknown;
|
|
90
98
|
/** Stop playback, so the element is not animating under the caret. */
|
|
91
99
|
onPause?: () => void;
|
|
92
100
|
}): InlineTextEditControls {
|
|
@@ -106,6 +114,7 @@ export function useInlineTextEdit({
|
|
|
106
114
|
framesRef.current = null;
|
|
107
115
|
}
|
|
108
116
|
openRef.current = null;
|
|
117
|
+
endStudioManualEditGesture(open.element, open.gesture);
|
|
109
118
|
setSession(null);
|
|
110
119
|
// An element removed from the document mid-session is not an error, it is
|
|
111
120
|
// just nothing left to clean up.
|
|
@@ -132,6 +141,7 @@ export function useInlineTextEdit({
|
|
|
132
141
|
original: element.innerHTML,
|
|
133
142
|
outline: element.style.outline,
|
|
134
143
|
outlineOffset: element.style.outlineOffset,
|
|
144
|
+
gesture: beginStudioManualEditGesture(element),
|
|
135
145
|
};
|
|
136
146
|
// Drawn on the element itself, not in Studio's overlay above it. This is
|
|
137
147
|
// the only mark that says the caret is in the TEXT rather than the
|
|
@@ -168,10 +178,14 @@ export function useInlineTextEdit({
|
|
|
168
178
|
// saved rather than something the server will quietly cut down.
|
|
169
179
|
sanitizeRichTextChildren(open.element);
|
|
170
180
|
const html = open.element.innerHTML;
|
|
181
|
+
// Counted and pending before the edit closes, so a reload waits for the save, not the mark.
|
|
182
|
+
let save: (saving: unknown) => void = () => {};
|
|
183
|
+
trackStudioPendingEdit(new Promise((resolve) => (save = resolve)));
|
|
184
|
+
noteStudioManualEditSave(open.element);
|
|
171
185
|
teardown();
|
|
172
186
|
// After teardown, so the commit path's own resync does not fight an
|
|
173
187
|
// element that is still editable.
|
|
174
|
-
onCommit({ element: open.element, html, previousHtml: open.original });
|
|
188
|
+
save(onCommit({ element: open.element, html, previousHtml: open.original }));
|
|
175
189
|
}, [onCommit, teardown]);
|
|
176
190
|
|
|
177
191
|
const cancel = useCallback(() => {
|
|
@@ -0,0 +1,18 @@
|
|
|
1
|
+
import { useEffect, useRef, useState } from "react";
|
|
2
|
+
import { onPreviewPromoted } from "../player/sceneSwap";
|
|
3
|
+
|
|
4
|
+
/** The one source for which preview is on screen: the host's iframe, then each reload promoted in its place. */
|
|
5
|
+
export function useLivePreviewIframe(host: HTMLIFrameElement | null): HTMLIFrameElement | null {
|
|
6
|
+
const [promoted, setPromoted] = useState<{ host: HTMLIFrameElement; live: HTMLIFrameElement }>();
|
|
7
|
+
const live = host && promoted?.host === host ? promoted.live : host;
|
|
8
|
+
const liveRef = useRef(live);
|
|
9
|
+
liveRef.current = live;
|
|
10
|
+
// eslint-disable-next-line no-restricted-syntax
|
|
11
|
+
useEffect(() => {
|
|
12
|
+
if (!host) return;
|
|
13
|
+
return onPreviewPromoted(host.ownerDocument, ({ retired, live: next }) => {
|
|
14
|
+
if (retired === liveRef.current) setPromoted({ host, live: next });
|
|
15
|
+
});
|
|
16
|
+
}, [host]);
|
|
17
|
+
return live;
|
|
18
|
+
}
|
|
@@ -0,0 +1,47 @@
|
|
|
1
|
+
// @vitest-environment happy-dom
|
|
2
|
+
|
|
3
|
+
import React, { act } from "react";
|
|
4
|
+
import { afterEach, describe, expect, it, vi } from "vitest";
|
|
5
|
+
import { usePlayerStore } from "../player/store/playerStore";
|
|
6
|
+
import type { TimelineElement } from "../player/store/timelineElement";
|
|
7
|
+
import { mountReactHarness } from "./domSelectionTestHarness";
|
|
8
|
+
import { usePreviewPersistence } from "./usePreviewPersistence";
|
|
9
|
+
|
|
10
|
+
(globalThis as unknown as { IS_REACT_ACT_ENVIRONMENT: boolean }).IS_REACT_ACT_ENVIRONMENT = true;
|
|
11
|
+
|
|
12
|
+
afterEach(() => {
|
|
13
|
+
document.body.innerHTML = "";
|
|
14
|
+
usePlayerStore.getState().reset();
|
|
15
|
+
});
|
|
16
|
+
|
|
17
|
+
describe("undo that reloads the preview", () => {
|
|
18
|
+
it("keeps the timeline's clips until the reloaded preview reports its own", async () => {
|
|
19
|
+
const clips = [{ id: "a" }, { id: "b" }] as unknown as TimelineElement[];
|
|
20
|
+
usePlayerStore.getState().setElements(clips);
|
|
21
|
+
usePlayerStore.getState().setTimelineReady(true);
|
|
22
|
+
const reloadPreview = vi.fn();
|
|
23
|
+
let sync: ReturnType<typeof usePreviewPersistence>["syncHistoryPreviewAfterApply"] | null =
|
|
24
|
+
null;
|
|
25
|
+
function Harness() {
|
|
26
|
+
sync = usePreviewPersistence({
|
|
27
|
+
showToast: () => {},
|
|
28
|
+
readOptionalProjectFile: async () => "",
|
|
29
|
+
writeProjectFile: async () => {},
|
|
30
|
+
recordEdit: async () => {},
|
|
31
|
+
previewIframeRef: { current: null },
|
|
32
|
+
activeCompPathRef: { current: "index.html" },
|
|
33
|
+
reloadPreview,
|
|
34
|
+
}).syncHistoryPreviewAfterApply;
|
|
35
|
+
return null;
|
|
36
|
+
}
|
|
37
|
+
mountReactHarness(<Harness />);
|
|
38
|
+
|
|
39
|
+
// A nested composition's undo is not the active file's, so it reloads the preview.
|
|
40
|
+
const files = { "compositions/sub.html": { previous: "<p>1</p>", restored: "<p>2</p>" } };
|
|
41
|
+
await act(async () => sync!({ paths: Object.keys(files), files }));
|
|
42
|
+
|
|
43
|
+
expect(reloadPreview).toHaveBeenCalledTimes(1);
|
|
44
|
+
const player = usePlayerStore.getState();
|
|
45
|
+
expect([player.elements, player.timelineReady]).toEqual([clips, true]);
|
|
46
|
+
});
|
|
47
|
+
});
|
|
@@ -207,16 +207,13 @@ export function usePreviewPersistence({
|
|
|
207
207
|
reloadPreview,
|
|
208
208
|
);
|
|
209
209
|
if (strategy === "full") {
|
|
210
|
-
|
|
211
|
-
player.setElements([]);
|
|
212
|
-
player.setSelectedElementId(null);
|
|
213
|
-
player.setTimelineReady(false);
|
|
210
|
+
usePlayerStore.getState().setSelectedElementId(null);
|
|
214
211
|
return;
|
|
215
212
|
}
|
|
216
213
|
// A soft restore patched the reverted attributes onto the live preview, but the
|
|
217
214
|
// player store keeps its own copy and that copy is what the automation lanes
|
|
218
215
|
// draw — so without this an undone envelope edit stayed invisible until a
|
|
219
|
-
// reload. The full path above
|
|
216
|
+
// reload. The full path above waits for the reloaded preview to report instead.
|
|
220
217
|
syncStoredAutomationFromPreview(previewIframeRef.current?.contentDocument ?? null);
|
|
221
218
|
},
|
|
222
219
|
[previewIframeRef, activeCompPathRef, reloadPreview],
|
|
@@ -9,7 +9,7 @@ import {
|
|
|
9
9
|
import type { TimelineElement } from "../player";
|
|
10
10
|
import type { TimelineClipRenderContext } from "../player/components/TimelineTypes";
|
|
11
11
|
import { audioPillFlags } from "../player/components/audioClipLink";
|
|
12
|
-
import { AudioWaveform } from "../player/components/AudioWaveform";
|
|
12
|
+
import { AudioWaveform, rendersWaveform } from "../player/components/AudioWaveform";
|
|
13
13
|
import { ImageThumbnail } from "../player/components/ImageThumbnail";
|
|
14
14
|
import { encodePreviewPath, resolveMediaPreviewUrl } from "../player/components/thumbnailUtils";
|
|
15
15
|
import { usePlayerStore } from "../player/store/playerStore";
|
|
@@ -135,7 +135,7 @@ export function useRenderClipContent({
|
|
|
135
135
|
// Thumbnail generation disabled (perf) -> plain clip bars. Audio still shows
|
|
136
136
|
// its waveform (cheap, not a frame thumbnail). Toggle: timeline toolbar.
|
|
137
137
|
if (effectiveMode === "hidden") {
|
|
138
|
-
return el
|
|
138
|
+
return rendersWaveform(el)
|
|
139
139
|
? renderAudioClip(el, pid, sessionEpoch, style.label, context, elements)
|
|
140
140
|
: null;
|
|
141
141
|
}
|
|
@@ -173,7 +173,7 @@ export function useRenderClipContent({
|
|
|
173
173
|
// Audio clips — waveform visualization. Resolve these before the generic
|
|
174
174
|
// activePreviewUrl thumbnail branch; audio rows need waveform data, not a
|
|
175
175
|
// captured frame from the currently drilled composition preview.
|
|
176
|
-
if (el
|
|
176
|
+
if (rendersWaveform(el)) {
|
|
177
177
|
return renderAudioClip(el, pid, sessionEpoch, style.label, context, elements);
|
|
178
178
|
}
|
|
179
179
|
|
package/src/index.ts
CHANGED
|
@@ -181,6 +181,8 @@ export type { UseClipboardOptions } from "./hooks/useClipboard";
|
|
|
181
181
|
// DOM editing for a host outside EditorShell; the Commit hooks save without useDomEditSession.
|
|
182
182
|
export { useDomEditSession } from "./hooks/useDomEditSession";
|
|
183
183
|
export type { UseDomEditSessionParams } from "./hooks/useDomEditSession";
|
|
184
|
+
export { useLivePreviewIframe } from "./hooks/useLivePreviewIframe";
|
|
185
|
+
export type { PreviewPromotion } from "./player/sceneSwap";
|
|
184
186
|
export { usePreviewPersistence } from "./hooks/usePreviewPersistence";
|
|
185
187
|
export type { UsePreviewPersistenceParams } from "./hooks/usePreviewPersistence";
|
|
186
188
|
export { DomEditProvider, useDomEditSelectionContext } from "./contexts/DomEditContext";
|
|
@@ -0,0 +1,33 @@
|
|
|
1
|
+
// @vitest-environment happy-dom
|
|
2
|
+
// Reads the preview on screen the way a host app does: by package name.
|
|
3
|
+
import { act } from "react";
|
|
4
|
+
import { createRoot } from "react-dom/client";
|
|
5
|
+
import { expect, expectTypeOf, it } from "vitest";
|
|
6
|
+
import { useLivePreviewIframe, type PreviewPromotion } from "@hyperframes/studio";
|
|
7
|
+
import {
|
|
8
|
+
makePreview,
|
|
9
|
+
mountPlayerWithPreview,
|
|
10
|
+
paintShadow,
|
|
11
|
+
} from "./player/hooks/timelinePlayerTestHarness";
|
|
12
|
+
|
|
13
|
+
Object.assign(globalThis, { IS_REACT_ACT_ENVIRONMENT: true });
|
|
14
|
+
|
|
15
|
+
it("gives a host the preview on screen after a real shadow reload is promoted", async () => {
|
|
16
|
+
expectTypeOf<PreviewPromotion>().toHaveProperty("live");
|
|
17
|
+
const host = makePreview("<h1>Title</h1>");
|
|
18
|
+
const player = mountPlayerWithPreview(host);
|
|
19
|
+
let live: HTMLIFrameElement | null = null;
|
|
20
|
+
function Probe() {
|
|
21
|
+
live = useLivePreviewIframe(host);
|
|
22
|
+
return null;
|
|
23
|
+
}
|
|
24
|
+
const root = createRoot(document.body.appendChild(document.createElement("div")));
|
|
25
|
+
act(() => root.render(<Probe />));
|
|
26
|
+
act(() => player.getApi().refreshPlayer());
|
|
27
|
+
const shadow = makePreview("<h1>Title</h1>", "?_t=1");
|
|
28
|
+
await paintShadow(player.getApi, shadow);
|
|
29
|
+
expect(player.getApi().iframeRef.current).toBe(shadow);
|
|
30
|
+
expect(live).toBe(shadow);
|
|
31
|
+
act(() => root.unmount());
|
|
32
|
+
act(() => player.root.unmount());
|
|
33
|
+
});
|
package/src/main.tsx
CHANGED
|
@@ -2,6 +2,7 @@ import { StrictMode } from "react";
|
|
|
2
2
|
import { createRoot } from "react-dom/client";
|
|
3
3
|
import { IconContext } from "@phosphor-icons/react";
|
|
4
4
|
import { StudioApp } from "./App";
|
|
5
|
+
import { ShowThemeToggle } from "./components/ThemeToggle";
|
|
5
6
|
import { StudioErrorBoundary } from "./components/StudioErrorBoundary";
|
|
6
7
|
import { readIconTokens } from "./styles/iconTokens";
|
|
7
8
|
import { trackStudioEvent } from "./utils/studioTelemetry";
|
|
@@ -128,7 +129,9 @@ createRoot(document.getElementById("root")!).render(
|
|
|
128
129
|
Icons that pass their own size or weight still win. */}
|
|
129
130
|
<IconContext.Provider value={readIconTokens()}>
|
|
130
131
|
<StudioErrorBoundary>
|
|
131
|
-
<
|
|
132
|
+
<ShowThemeToggle.Provider value>
|
|
133
|
+
<StudioApp />
|
|
134
|
+
</ShowThemeToggle.Provider>
|
|
132
135
|
</StudioErrorBoundary>
|
|
133
136
|
</IconContext.Provider>
|
|
134
137
|
</StrictMode>,
|
|
@@ -20,24 +20,30 @@ afterEach(() => {
|
|
|
20
20
|
document.body.innerHTML = "";
|
|
21
21
|
});
|
|
22
22
|
|
|
23
|
+
/** A 6 x 20 canvas whose 2D context records every fill as [style, x, y, width, height]. */
|
|
24
|
+
function recordingCanvas() {
|
|
25
|
+
const fills: Array<[string, number, number, number, number]> = [];
|
|
26
|
+
const context = {
|
|
27
|
+
scale: vi.fn(),
|
|
28
|
+
clearRect: vi.fn(),
|
|
29
|
+
fillStyle: "",
|
|
30
|
+
fillRect: (x: number, y: number, width: number, height: number) =>
|
|
31
|
+
fills.push([String(context.fillStyle), x, y, width, height]),
|
|
32
|
+
} as unknown as CanvasRenderingContext2D;
|
|
33
|
+
const canvas = document.createElement("canvas");
|
|
34
|
+
Object.defineProperties(canvas, {
|
|
35
|
+
clientWidth: { value: 6 },
|
|
36
|
+
clientHeight: { value: 20 },
|
|
37
|
+
});
|
|
38
|
+
vi.spyOn(canvas, "getContext").mockReturnValue(context);
|
|
39
|
+
return { canvas, fills };
|
|
40
|
+
}
|
|
41
|
+
|
|
23
42
|
describe("AudioWaveform", () => {
|
|
24
43
|
it("paints a baseline and peak bar for every mapped waveform bin", () => {
|
|
25
|
-
const
|
|
26
|
-
const context = {
|
|
27
|
-
scale: vi.fn(),
|
|
28
|
-
clearRect: vi.fn(),
|
|
29
|
-
fillRect,
|
|
30
|
-
fillStyle: "",
|
|
31
|
-
} as unknown as CanvasRenderingContext2D;
|
|
32
|
-
const canvas = document.createElement("canvas");
|
|
33
|
-
Object.defineProperties(canvas, {
|
|
34
|
-
clientWidth: { value: 6 },
|
|
35
|
-
clientHeight: { value: 20 },
|
|
36
|
-
});
|
|
37
|
-
vi.spyOn(canvas, "getContext").mockReturnValue(context);
|
|
44
|
+
const { canvas, fills } = recordingCanvas();
|
|
38
45
|
drawWaveformCanvas(canvas, [0.25, 1], false, 0, 1);
|
|
39
|
-
expect(
|
|
40
|
-
expect(fillRect.mock.calls.map(([x, y, width, height]) => [x, y, width, height])).toEqual([
|
|
46
|
+
expect(fills.map(([, x, y, width, height]) => [x, y, width, height])).toEqual([
|
|
41
47
|
[0, 18, 3, 2],
|
|
42
48
|
[0, 15, 3, 5],
|
|
43
49
|
[3, 18, 3, 2],
|
|
@@ -45,6 +51,21 @@ describe("AudioWaveform", () => {
|
|
|
45
51
|
]);
|
|
46
52
|
});
|
|
47
53
|
|
|
54
|
+
it("shrinks each bar to the fade's gain and keeps the cut-away part as a ghost", () => {
|
|
55
|
+
const { canvas, fills } = recordingCanvas();
|
|
56
|
+
// A 1 s fade-in on a 2 s clip: the first bar's centre (0.5 s) plays at half gain.
|
|
57
|
+
drawWaveformCanvas(canvas, [1, 1], false, 0, 1, { fadeIn: 1, fadeOut: 0, duration: 2 });
|
|
58
|
+
const bars = fills.filter(([, , y, , height]) => !(y === 18 && height === 2));
|
|
59
|
+
// The heard half, then the ghost only above it, so the two never stack.
|
|
60
|
+
expect(bars.map(([, x, y, width, height]) => [x, y, width, height])).toEqual([
|
|
61
|
+
[0, 10, 3, 10],
|
|
62
|
+
[0, 0, 3, 10],
|
|
63
|
+
[3, 0, 3, 20],
|
|
64
|
+
]);
|
|
65
|
+
const alpha = (style: string) => Number(style.split(",").at(-1)?.replace(")", ""));
|
|
66
|
+
expect(alpha(bars[1][0])).toBeCloseTo(alpha(bars[0][0]) * 0.27, 2);
|
|
67
|
+
});
|
|
68
|
+
|
|
48
69
|
it("leases waveform decoding with the clip's project, session, and viewport priority", () => {
|
|
49
70
|
const host = document.createElement("div");
|
|
50
71
|
document.body.append(host);
|