@hyperframes/studio 0.7.55 → 0.7.56
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-BXaqaVKt.js → index-BWnOxAiH.js} +1 -1
- package/dist/assets/{index-CPetwHFV.js → index-C4csZims.js} +1 -1
- package/dist/assets/index-CmVCjZjp.js +423 -0
- package/dist/assets/index-D-GyYi2d.css +1 -0
- package/dist/{chunk-SOTCF4DF.js → chunk-5QSIMBEJ.js} +2 -1
- package/dist/chunk-5QSIMBEJ.js.map +1 -0
- package/dist/{domEditingLayers-2ECJK24D.js → domEditingLayers-6LQGKPOI.js} +2 -2
- package/dist/index.d.ts +146 -144
- package/dist/index.html +2 -2
- package/dist/index.js +40414 -37680
- package/dist/index.js.map +1 -1
- package/package.json +7 -7
- package/src/App.tsx +106 -96
- package/src/components/EditorShell.tsx +253 -0
- package/src/components/StudioGlobalDragOverlay.tsx +1 -3
- package/src/components/StudioLeftSidebar.tsx +13 -3
- package/src/components/StudioRightPanel.tsx +8 -3
- package/src/components/TimelineToolbar.test.tsx +2 -2
- package/src/components/TimelineToolbar.tsx +179 -121
- package/src/components/editor/DomEditCropHandles.test.tsx +74 -3
- package/src/components/editor/DomEditCropHandles.tsx +64 -29
- package/src/components/editor/DomEditOverlay.test.ts +245 -279
- package/src/components/editor/DomEditOverlay.tsx +158 -126
- package/src/components/editor/DomEditOverlayHover.test.tsx +90 -0
- package/src/components/editor/DomEditRotateHandle.tsx +38 -18
- package/src/components/editor/DomEditSelectionChrome.test.tsx +80 -0
- package/src/components/editor/DomEditSelectionChrome.tsx +257 -0
- package/src/components/editor/LayersPanel.test.ts +66 -2
- package/src/components/editor/LayersPanel.tsx +43 -1
- package/src/components/editor/OffCanvasIndicators.test.tsx +34 -0
- package/src/components/editor/OffCanvasIndicators.tsx +49 -10
- package/src/components/editor/SnapGuideOverlay.tsx +28 -23
- package/src/components/editor/SnapToolbar.test.tsx +0 -1
- package/src/components/editor/anchoredResizeCommitFeedsOffset.test.ts +214 -0
- package/src/components/editor/anchoredResizeReleaseShift.test.ts +106 -10
- package/src/components/editor/domEditNudge.test.ts +80 -0
- package/src/components/editor/domEditNudge.ts +44 -0
- package/src/components/editor/domEditOverlayGeometry.test.ts +167 -1
- package/src/components/editor/domEditOverlayGeometry.ts +280 -20
- package/src/components/editor/domEditOverlayGestures.ts +44 -41
- package/src/components/editor/domEditOverlayStartGesture.ts +62 -2
- package/src/components/editor/domEditResizeLocal.test.ts +131 -0
- package/src/components/editor/domEditResizeLocal.ts +125 -0
- package/src/components/editor/domEditingDom.ts +1 -1
- package/src/components/editor/manualEditsDomPatches.test.ts +47 -0
- package/src/components/editor/manualOffsetDrag.ts +55 -12
- package/src/components/editor/offCanvasIndicatorGeometry.ts +31 -10
- package/src/components/editor/offCanvasIndicatorRefresh.test.tsx +25 -1
- package/src/components/editor/resizeDraft.ts +108 -0
- package/src/components/editor/snapEngine.test.ts +12 -78
- package/src/components/editor/snapEngine.ts +13 -53
- package/src/components/editor/useDomEditNudge.test.tsx +228 -0
- package/src/components/editor/useDomEditNudge.ts +254 -0
- package/src/components/editor/useDomEditOverlayGestures.ts +115 -116
- package/src/components/editor/useDomEditOverlayRects.ts +9 -2
- package/src/components/nle/AssetPreviewOverlay.tsx +147 -0
- package/src/components/nle/NLEContext.test.ts +144 -0
- package/src/components/nle/NLEContext.tsx +328 -0
- package/src/components/nle/NLEPreview.tsx +1 -1
- package/src/components/nle/PreviewOverlays.tsx +240 -0
- package/src/components/nle/PreviewPane.tsx +163 -0
- package/src/components/nle/TimelinePane.test.ts +60 -0
- package/src/components/nle/TimelinePane.tsx +293 -0
- package/src/components/nle/TimelineResizeDivider.tsx +8 -2
- package/src/components/nle/useCompositionStack.test.tsx +44 -0
- package/src/components/nle/useCompositionStack.ts +17 -9
- package/src/components/nle/useTimelineEditCallbacks.ts +217 -0
- package/src/components/sidebar/AssetCard.tsx +326 -0
- package/src/components/sidebar/AssetContextMenu.tsx +15 -30
- package/src/components/sidebar/AssetsTab.test.ts +96 -0
- package/src/components/sidebar/AssetsTab.tsx +71 -206
- package/src/components/sidebar/AudioRow.tsx +39 -2
- package/src/components/sidebar/BlocksTab.tsx +8 -1
- package/src/components/sidebar/LeftSidebar.tsx +4 -1
- package/src/components/sidebar/assetHelpers.ts +29 -0
- package/src/contexts/DomEditContext.tsx +9 -0
- package/src/contexts/StudioContext.tsx +9 -8
- package/src/contexts/TimelineEditContext.tsx +3 -7
- package/src/hooks/deleteSelectedKeyframes.ts +35 -0
- package/src/hooks/domEditCommitTypes.ts +12 -1
- package/src/hooks/domSelectionTestHarness.ts +14 -0
- package/src/hooks/gestureTransaction.test.ts +311 -0
- package/src/hooks/gestureTransaction.ts +199 -0
- package/src/hooks/gsapDragCommit.test.ts +141 -41
- package/src/hooks/gsapDragCommit.ts +64 -74
- package/src/hooks/gsapDragStaticSetHelpers.ts +9 -21
- package/src/hooks/gsapResizeIntercept.test.ts +69 -0
- package/src/hooks/gsapResizeIntercept.ts +38 -10
- package/src/hooks/gsapRuntimeBridge.test.ts +49 -13
- package/src/hooks/gsapRuntimeBridge.ts +4 -4
- package/src/hooks/gsapScriptCommitTypes.ts +16 -5
- package/src/hooks/gsapShared.test.ts +19 -1
- package/src/hooks/gsapShared.ts +14 -0
- package/src/hooks/patchDocumentRootDuration.test.ts +71 -0
- package/src/hooks/timelineEditingGsap.ts +14 -0
- package/src/hooks/timelineEditingHelpers.test.ts +34 -1
- package/src/hooks/timelineEditingHelpers.ts +22 -48
- package/src/hooks/timelineMoveAdapter.test.ts +77 -0
- package/src/hooks/timelineMoveAdapter.ts +37 -0
- package/src/hooks/useAnimatedPropertyCommit.test.tsx +36 -8
- package/src/hooks/useAnimatedPropertyCommit.ts +31 -26
- package/src/hooks/useAppHotkeys.ts +16 -25
- package/src/hooks/useBlockHandlers.ts +55 -27
- package/src/hooks/useContextMenuDismiss.ts +31 -7
- package/src/hooks/useDomEditCommits.test.tsx +154 -1
- package/src/hooks/useDomEditCommits.ts +100 -3
- package/src/hooks/useDomEditPositionPatchCommit.ts +2 -0
- package/src/hooks/useDomEditSession.ts +2 -2
- package/src/hooks/useDomEditTextCommits.test.tsx +135 -0
- package/src/hooks/useDomEditTextCommits.ts +8 -3
- package/src/hooks/useDomEditWiring.ts +7 -9
- package/src/hooks/useDomGeometryCommits.ts +17 -2
- package/src/hooks/useDomSelection.test.ts +2 -36
- package/src/hooks/useDomSelection.ts +81 -51
- package/src/hooks/useDomSelectionSelectionGuards.test.ts +276 -0
- package/src/hooks/useElementLifecycleOps.test.tsx +258 -0
- package/src/hooks/useElementLifecycleOps.ts +40 -71
- package/src/hooks/useEnableKeyframes.test.ts +142 -1
- package/src/hooks/useEnableKeyframes.ts +73 -29
- package/src/hooks/useGestureCommit.test.tsx +130 -0
- package/src/hooks/useGestureCommit.ts +32 -8
- package/src/hooks/useGroupCommits.ts +7 -28
- package/src/hooks/useGsapAwareEditing.test.tsx +239 -0
- package/src/hooks/useGsapAwareEditing.ts +115 -20
- package/src/hooks/useGsapKeyframeOps.test.tsx +59 -0
- package/src/hooks/useGsapKeyframeOps.ts +38 -10
- package/src/hooks/useGsapScriptCommits.test.tsx +40 -1
- package/src/hooks/useGsapScriptCommits.ts +134 -50
- package/src/hooks/useGsapSelectionHandlers.ts +49 -16
- package/src/hooks/useMusicBeatAnalysis.ts +72 -36
- package/src/hooks/usePersistentEditHistory.test.ts +33 -0
- package/src/hooks/usePersistentEditHistory.ts +92 -58
- package/src/hooks/usePreviewPersistence.ts +25 -7
- package/src/hooks/useRazorSplit.history.test.tsx +148 -18
- package/src/hooks/useRazorSplit.test.ts +269 -0
- package/src/hooks/useRazorSplit.testHelpers.ts +64 -0
- package/src/hooks/useRazorSplit.ts +107 -36
- package/src/hooks/useRenderClipContent.ts +24 -6
- package/src/hooks/useStudioContextValue.ts +15 -5
- package/src/hooks/useStudioUrlState.ts +2 -4
- package/src/hooks/useTimelineEditing.test.tsx +20 -29
- package/src/hooks/useTimelineEditingTypes.ts +6 -0
- package/src/hooks/useTimelineGroupEditing.ts +18 -4
- package/src/index.ts +1 -1
- package/src/player/components/ImageThumbnail.test.tsx +173 -0
- package/src/player/components/ImageThumbnail.tsx +160 -0
- package/src/player/components/PlayheadIndicator.tsx +50 -5
- package/src/player/components/Timeline.test.ts +102 -19
- package/src/player/components/Timeline.tsx +194 -194
- package/src/player/components/TimelineCanvas.tsx +207 -521
- package/src/player/components/TimelineClip.tsx +4 -1
- package/src/player/components/TimelineClipDiamonds.tsx +9 -40
- package/src/player/components/TimelineEmptyState.tsx +3 -1
- package/src/player/components/TimelineLanes.tsx +487 -0
- package/src/player/components/TimelineOverlays.tsx +122 -0
- package/src/player/components/VideoThumbnail.test.tsx +152 -0
- package/src/player/components/VideoThumbnail.tsx +28 -7
- package/src/player/components/thumbnailUtils.test.ts +127 -0
- package/src/player/components/thumbnailUtils.ts +54 -0
- package/src/player/components/timelineCallbacks.ts +18 -18
- package/src/player/components/timelineClipChildren.tsx +39 -0
- package/src/player/components/timelineClipDragCommit.test.ts +943 -0
- package/src/player/components/timelineClipDragCommit.ts +401 -0
- package/src/player/components/timelineClipDragPreview.test.ts +144 -0
- package/src/player/components/timelineClipDragPreview.ts +323 -38
- package/src/player/components/timelineClipDragTypes.ts +65 -0
- package/src/player/components/timelineCollision.test.ts +42 -2
- package/src/player/components/timelineDragDrop.ts +69 -60
- package/src/player/components/timelineEditCapabilities.ts +52 -0
- package/src/player/components/timelineEditing.test.ts +1 -1
- package/src/player/components/timelineEditing.ts +76 -59
- package/src/player/components/timelineGroupEditing.test.ts +126 -0
- package/src/player/components/timelineGroupEditing.ts +133 -0
- package/src/player/components/timelineGroupResizeCommit.ts +54 -0
- package/src/player/components/timelineLayout.test.ts +95 -23
- package/src/player/components/timelineLayout.ts +120 -61
- package/src/player/components/timelineMarquee.test.ts +197 -0
- package/src/player/components/timelineMarquee.ts +112 -0
- package/src/player/components/timelineOptimisticRevision.ts +42 -0
- package/src/player/components/timelineTheme.ts +6 -1
- package/src/player/components/useResolvedTimelineEditCallbacks.ts +25 -3
- package/src/player/components/useTimelineClipDrag.resize.test.tsx +241 -0
- package/src/player/components/useTimelineClipDrag.ts +380 -386
- package/src/player/components/useTimelineEditPinning.ts +121 -0
- package/src/player/components/useTimelineGeometry.ts +129 -0
- package/src/player/components/useTimelinePlayhead.ts +4 -16
- package/src/player/components/useTimelineRangeSelection.ts +341 -50
- package/src/player/components/useTimelineStackingSync.test.tsx +67 -0
- package/src/player/components/useTimelineStackingSync.ts +83 -0
- package/src/player/hooks/useExpandedTimelineElements.ts +0 -3
- package/src/player/hooks/usePlaybackKeyboard.ts +5 -0
- package/src/player/hooks/useTimelinePlayer.ts +48 -53
- package/src/player/hooks/useTimelinePlayerLoop.ts +85 -0
- package/src/player/index.ts +3 -2
- package/src/player/lib/time.test.ts +19 -19
- package/src/player/lib/time.ts +6 -2
- package/src/player/lib/timelineDOM.test.ts +25 -98
- package/src/player/lib/timelineDOM.ts +12 -81
- package/src/player/lib/timelineElementHelpers.ts +54 -5
- package/src/player/lib/timelineIframeHelpers.ts +2 -0
- package/src/player/store/playerStore.ts +62 -3
- package/src/styles/studio.css +27 -0
- package/src/utils/assetPreviewStore.ts +33 -0
- package/src/utils/blockInstaller.ts +10 -2
- package/src/utils/editHistory.test.ts +35 -0
- package/src/utils/gsapSoftReload.test.ts +119 -1
- package/src/utils/gsapSoftReload.ts +153 -0
- package/src/utils/mediaTypes.ts +3 -2
- package/src/utils/resizeDebug.ts +55 -0
- package/src/utils/rootDuration.test.ts +90 -1
- package/src/utils/rootDuration.ts +76 -13
- package/src/utils/sdkCutover.ts +4 -0
- package/src/utils/studioFileHistory.ts +37 -7
- package/src/utils/studioHelpers.ts +62 -0
- package/src/utils/studioPreviewHelpers.test.ts +65 -8
- package/src/utils/studioPreviewHelpers.ts +10 -0
- package/src/utils/studioTelemetry.ts +4 -1
- package/src/utils/studioUrlState.test.ts +0 -1
- package/src/utils/timelineAssetDrop.ts +62 -0
- package/src/utils/timelineDiscovery.ts +0 -17
- package/dist/assets/index-BRwkMj0w.js +0 -423
- package/dist/assets/index-Dq7FEg0K.css +0 -1
- package/dist/chunk-SOTCF4DF.js.map +0 -1
- package/src/components/StudioPreviewArea.tsx +0 -500
- package/src/components/nle/NLELayout.test.ts +0 -12
- package/src/components/nle/NLELayout.tsx +0 -591
- package/src/player/components/TimelineLayerGutter.tsx +0 -61
- package/src/player/components/TimelineSelectionOverlays.tsx +0 -55
- package/src/player/components/timelineMarqueeSelection.test.ts +0 -87
- package/src/player/components/timelineSnapTargets.test.ts +0 -144
- package/src/player/components/timelineSnapTargets.ts +0 -164
- package/src/player/components/useTimelineClipDrag.test.tsx +0 -506
- package/src/player/components/useTimelineClipGroupDrag.ts +0 -417
- package/src/player/components/useTimelineMarqueeSelection.test.tsx +0 -231
- package/src/player/components/useTimelineMarqueeSelection.ts +0 -276
- package/src/utils/timelineDiscovery.test.ts +0 -90
- /package/dist/{domEditingLayers-2ECJK24D.js.map → domEditingLayers-6LQGKPOI.js.map} +0 -0
|
@@ -1,7 +1,12 @@
|
|
|
1
1
|
// @vitest-environment happy-dom
|
|
2
2
|
|
|
3
3
|
import { describe, it, expect, vi } from "vitest";
|
|
4
|
-
import {
|
|
4
|
+
import {
|
|
5
|
+
applySoftReload,
|
|
6
|
+
ensureMotionPathPluginLoaded,
|
|
7
|
+
diffSoftReloadableRestore,
|
|
8
|
+
applyUndoRestoreToPreview,
|
|
9
|
+
} from "./gsapSoftReload";
|
|
5
10
|
|
|
6
11
|
const SCRIPT_TEXT = `
|
|
7
12
|
window.__timelines = window.__timelines || {};
|
|
@@ -447,3 +452,116 @@ describe("applySoftReload authored-opacity restore", () => {
|
|
|
447
452
|
expect(restoreOpacity(el)).toBe("");
|
|
448
453
|
});
|
|
449
454
|
});
|
|
455
|
+
|
|
456
|
+
// ── Bug 2: undo/redo restore soft-apply ──────────────────────────────────────
|
|
457
|
+
|
|
458
|
+
const wrap = (body: string) => `<html><body>${body}</body></html>`;
|
|
459
|
+
|
|
460
|
+
describe("diffSoftReloadableRestore", () => {
|
|
461
|
+
it("reports the changed id for an attribute/inline-style-only diff", () => {
|
|
462
|
+
const prev = wrap(`<div id="a" style="translate: 10px 10px">t</div>`);
|
|
463
|
+
const next = wrap(`<div id="a" style="translate: 0px 0px">t</div>`);
|
|
464
|
+
expect(diffSoftReloadableRestore(prev, next)).toEqual({ changedElementIds: ["a"] });
|
|
465
|
+
});
|
|
466
|
+
|
|
467
|
+
it("treats a structural change (added element) as NOT soft-reloadable", () => {
|
|
468
|
+
const prev = wrap(`<div id="a">t</div>`);
|
|
469
|
+
const next = wrap(`<div id="a">t</div><div id="a-split">t</div>`);
|
|
470
|
+
expect(diffSoftReloadableRestore(prev, next)).toBeNull();
|
|
471
|
+
});
|
|
472
|
+
|
|
473
|
+
it("treats an element text/child change as NOT soft-reloadable", () => {
|
|
474
|
+
const prev = wrap(`<div id="a">one</div>`);
|
|
475
|
+
const next = wrap(`<div id="a">two</div>`);
|
|
476
|
+
expect(diffSoftReloadableRestore(prev, next)).toBeNull();
|
|
477
|
+
});
|
|
478
|
+
|
|
479
|
+
it("allows a GSAP-script-only change (no id'd-attribute diff)", () => {
|
|
480
|
+
const prev = wrap(
|
|
481
|
+
`<div id="a">t</div><script>window.__timelines["root"]=gsap.timeline().to("#a",{x:1});</script>`,
|
|
482
|
+
);
|
|
483
|
+
const next = wrap(
|
|
484
|
+
`<div id="a">t</div><script>window.__timelines["root"]=gsap.timeline().to("#a",{x:9});</script>`,
|
|
485
|
+
);
|
|
486
|
+
expect(diffSoftReloadableRestore(prev, next)).toEqual({ changedElementIds: [] });
|
|
487
|
+
});
|
|
488
|
+
});
|
|
489
|
+
|
|
490
|
+
function buildLiveIframe(bodyHtml: string) {
|
|
491
|
+
const doc = document.implementation.createHTMLDocument("");
|
|
492
|
+
doc.body.innerHTML = bodyHtml;
|
|
493
|
+
const contentWindow = {
|
|
494
|
+
gsap: { timeline: () => {} },
|
|
495
|
+
__hfForceTimelineRebind: () => {},
|
|
496
|
+
__timelines: {} as Record<string, unknown>,
|
|
497
|
+
__player: { getTime: () => 3, seek: vi.fn() },
|
|
498
|
+
__hfStudioManualEditsApply: vi.fn(),
|
|
499
|
+
};
|
|
500
|
+
return {
|
|
501
|
+
iframe: { contentWindow, contentDocument: doc } as unknown as HTMLIFrameElement,
|
|
502
|
+
contentWindow,
|
|
503
|
+
doc,
|
|
504
|
+
};
|
|
505
|
+
}
|
|
506
|
+
|
|
507
|
+
describe("applyUndoRestoreToPreview", () => {
|
|
508
|
+
const ROOT = "index.html";
|
|
509
|
+
|
|
510
|
+
it("soft-applies an attribute/style-only restore: syncs the live element, no full reload", () => {
|
|
511
|
+
const { iframe, contentWindow, doc } = buildLiveIframe(
|
|
512
|
+
`<div id="a" style="translate: 10px 10px" data-hf-path-offset="true">t</div>`,
|
|
513
|
+
);
|
|
514
|
+
const reloadPreview = vi.fn();
|
|
515
|
+
const files = {
|
|
516
|
+
[ROOT]: {
|
|
517
|
+
previous: wrap(
|
|
518
|
+
`<div id="a" style="translate: 10px 10px" data-hf-path-offset="true">t</div>`,
|
|
519
|
+
),
|
|
520
|
+
restored: wrap(`<div id="a" style="translate: 0px 0px" data-hf-path-offset="true">t</div>`),
|
|
521
|
+
},
|
|
522
|
+
};
|
|
523
|
+
const outcome = applyUndoRestoreToPreview(iframe, ROOT, files, 3, reloadPreview);
|
|
524
|
+
expect(outcome).toBe("soft");
|
|
525
|
+
expect(reloadPreview).not.toHaveBeenCalled();
|
|
526
|
+
// Live element reverted to the restored inline style.
|
|
527
|
+
expect(doc.getElementById("a")!.getAttribute("style")).toBe("translate: 0px 0px");
|
|
528
|
+
// No GSAP script in the restore → the manual-edit reapply runs, playhead held.
|
|
529
|
+
expect(contentWindow.__player.seek).toHaveBeenCalledWith(3);
|
|
530
|
+
expect(contentWindow.__hfStudioManualEditsApply).toHaveBeenCalled();
|
|
531
|
+
});
|
|
532
|
+
|
|
533
|
+
it("full-reloads a multi-file restore", () => {
|
|
534
|
+
const { iframe } = buildLiveIframe(`<div id="a">t</div>`);
|
|
535
|
+
const reloadPreview = vi.fn();
|
|
536
|
+
const files = {
|
|
537
|
+
[ROOT]: {
|
|
538
|
+
previous: wrap(`<div id="a" style="x">t</div>`),
|
|
539
|
+
restored: wrap(`<div id="a">t</div>`),
|
|
540
|
+
},
|
|
541
|
+
"scenes/intro.html": { previous: "a", restored: "b" },
|
|
542
|
+
};
|
|
543
|
+
expect(applyUndoRestoreToPreview(iframe, ROOT, files, 3, reloadPreview)).toBe("full");
|
|
544
|
+
expect(reloadPreview).toHaveBeenCalledTimes(1);
|
|
545
|
+
});
|
|
546
|
+
|
|
547
|
+
it("full-reloads a structural restore (split/delete undo)", () => {
|
|
548
|
+
const { iframe } = buildLiveIframe(`<div id="a">t</div><div id="a-split">t</div>`);
|
|
549
|
+
const reloadPreview = vi.fn();
|
|
550
|
+
const files = {
|
|
551
|
+
[ROOT]: {
|
|
552
|
+
previous: wrap(`<div id="a">t</div><div id="a-split">t</div>`),
|
|
553
|
+
restored: wrap(`<div id="a">t</div>`),
|
|
554
|
+
},
|
|
555
|
+
};
|
|
556
|
+
expect(applyUndoRestoreToPreview(iframe, ROOT, files, 3, reloadPreview)).toBe("full");
|
|
557
|
+
expect(reloadPreview).toHaveBeenCalledTimes(1);
|
|
558
|
+
});
|
|
559
|
+
|
|
560
|
+
it("full-reloads when the restore touches a sub-comp, not the active comp", () => {
|
|
561
|
+
const { iframe } = buildLiveIframe(`<div id="a">t</div>`);
|
|
562
|
+
const reloadPreview = vi.fn();
|
|
563
|
+
const files = { "scenes/intro.html": { previous: "a", restored: "b" } };
|
|
564
|
+
expect(applyUndoRestoreToPreview(iframe, ROOT, files, 3, reloadPreview)).toBe("full");
|
|
565
|
+
expect(reloadPreview).toHaveBeenCalledTimes(1);
|
|
566
|
+
});
|
|
567
|
+
});
|
|
@@ -183,6 +183,159 @@ export interface SoftReloadOptions {
|
|
|
183
183
|
authoredHtml?: string;
|
|
184
184
|
}
|
|
185
185
|
|
|
186
|
+
/** One file's restore from the edit-history store: before (live) / after (target) bytes. */
|
|
187
|
+
export interface UndoRestoreFile {
|
|
188
|
+
previous: string;
|
|
189
|
+
restored: string;
|
|
190
|
+
}
|
|
191
|
+
function idElementMap(doc: Document): Map<string, Element> {
|
|
192
|
+
const map = new Map<string, Element>();
|
|
193
|
+
for (const el of doc.querySelectorAll("[id]")) {
|
|
194
|
+
const id = el.getAttribute("id");
|
|
195
|
+
if (id) map.set(id, el);
|
|
196
|
+
}
|
|
197
|
+
return map;
|
|
198
|
+
}
|
|
199
|
+
|
|
200
|
+
// Strip id'd elements to bare `id` and blank GSAP scripts, in place: docs that
|
|
201
|
+
// differ only in id'd attributes/inline-style/script text normalize equal; any
|
|
202
|
+
// residual difference is beyond soft-reload's reach → caller full-reloads.
|
|
203
|
+
function normalizeSoftResidual(doc: Document): void {
|
|
204
|
+
for (const el of doc.querySelectorAll("[id]")) {
|
|
205
|
+
const id = el.getAttribute("id");
|
|
206
|
+
for (const name of [...el.getAttributeNames()]) {
|
|
207
|
+
if (name !== "id") el.removeAttribute(name);
|
|
208
|
+
}
|
|
209
|
+
if (id) el.setAttribute("id", id);
|
|
210
|
+
}
|
|
211
|
+
for (const script of findGsapScriptElements(doc)) script.textContent = "";
|
|
212
|
+
}
|
|
213
|
+
|
|
214
|
+
// Soft-reloadable iff the docs differ SOLELY in id'd-element attributes/inline
|
|
215
|
+
// style and/or the GSAP script; returns the changed ids to sync onto the live
|
|
216
|
+
// DOM. Structural/text diffs → null → the caller full-reloads. Pure.
|
|
217
|
+
export function diffSoftReloadableRestore(
|
|
218
|
+
previous: string,
|
|
219
|
+
restored: string,
|
|
220
|
+
): { changedElementIds: string[] } | null {
|
|
221
|
+
let prevDoc: Document;
|
|
222
|
+
let nextDoc: Document;
|
|
223
|
+
try {
|
|
224
|
+
prevDoc = new DOMParser().parseFromString(previous, "text/html");
|
|
225
|
+
nextDoc = new DOMParser().parseFromString(restored, "text/html");
|
|
226
|
+
} catch {
|
|
227
|
+
return null;
|
|
228
|
+
}
|
|
229
|
+
const prevById = idElementMap(prevDoc);
|
|
230
|
+
const nextById = idElementMap(nextDoc);
|
|
231
|
+
// A different id set means an element was added or removed (e.g. a split, a
|
|
232
|
+
// delete) — structural, so soft-reload can't express it.
|
|
233
|
+
if (prevById.size !== nextById.size) return null;
|
|
234
|
+
const changedElementIds: string[] = [];
|
|
235
|
+
for (const [id, nextEl] of nextById) {
|
|
236
|
+
const prevEl = prevById.get(id);
|
|
237
|
+
if (!prevEl || prevEl.tagName !== nextEl.tagName) return null;
|
|
238
|
+
// A change inside the element (text / children) is out of soft scope; only
|
|
239
|
+
// its own attributes may differ. (GSAP scripts are handled via re-run.)
|
|
240
|
+
if (prevEl.innerHTML !== nextEl.innerHTML) return null;
|
|
241
|
+
if (prevEl.outerHTML !== nextEl.outerHTML) changedElementIds.push(id);
|
|
242
|
+
}
|
|
243
|
+
// Confirm nothing OUTSIDE id'd-element attributes and GSAP scripts changed.
|
|
244
|
+
normalizeSoftResidual(prevDoc);
|
|
245
|
+
normalizeSoftResidual(nextDoc);
|
|
246
|
+
if (prevDoc.documentElement.outerHTML !== nextDoc.documentElement.outerHTML) return null;
|
|
247
|
+
return { changedElementIds };
|
|
248
|
+
}
|
|
249
|
+
|
|
250
|
+
/** Copy every attribute from `source` onto the live `target`, dropping extras. */
|
|
251
|
+
function syncElementAttributes(target: Element, source: Element): void {
|
|
252
|
+
for (const name of [...target.getAttributeNames()]) {
|
|
253
|
+
if (!source.hasAttribute(name)) target.removeAttribute(name);
|
|
254
|
+
}
|
|
255
|
+
for (const name of source.getAttributeNames()) {
|
|
256
|
+
target.setAttribute(name, source.getAttribute(name) ?? "");
|
|
257
|
+
}
|
|
258
|
+
}
|
|
259
|
+
|
|
260
|
+
/**
|
|
261
|
+
* Soft-apply an undo/redo restore to the live preview WITHOUT a full iframe
|
|
262
|
+
* remount (which blanks the frame black and re-flashes the WebGL context). Only
|
|
263
|
+
* the active composition — the document living in the root iframe — is eligible;
|
|
264
|
+
* a sub-comp or multi-file restore falls back to `reloadPreview`.
|
|
265
|
+
*
|
|
266
|
+
* The restore is soft-applied when its only differences are id'd-element
|
|
267
|
+
* attributes / inline-style and/or the GSAP script (see diffSoftReloadableRestore):
|
|
268
|
+
* 1. Each changed element's attribute surface (inline style, data-start /
|
|
269
|
+
* -duration, the studio manual-offset props + flags) is synced onto the live
|
|
270
|
+
* element — so a canvas-position revert lands on the live DOM the runtime's
|
|
271
|
+
* seek-reapply reads from, not just on disk.
|
|
272
|
+
* 2. The restored GSAP script is re-run in place via applySoftReload, which
|
|
273
|
+
* re-seeks to `currentTime` (playhead-invariant) and re-folds manual edits.
|
|
274
|
+
* With no single script, the manual-edit reapply is invoked directly.
|
|
275
|
+
*
|
|
276
|
+
* Returns "soft" when applied in place, "full" when it escalated to reloadPreview
|
|
277
|
+
* (ineligible restore, missing target, or a permanent soft-reload failure).
|
|
278
|
+
*/
|
|
279
|
+
export function applyUndoRestoreToPreview(
|
|
280
|
+
iframe: HTMLIFrameElement | null,
|
|
281
|
+
activeCompPath: string | null,
|
|
282
|
+
files: Record<string, UndoRestoreFile> | undefined,
|
|
283
|
+
currentTime: number,
|
|
284
|
+
reloadPreview: () => void,
|
|
285
|
+
): "soft" | "full" {
|
|
286
|
+
const paths = files ? Object.keys(files) : [];
|
|
287
|
+
// Soft path only covers the single active-comp document in the root iframe.
|
|
288
|
+
if (!iframe || !activeCompPath || !files || paths.length !== 1 || paths[0] !== activeCompPath) {
|
|
289
|
+
reloadPreview();
|
|
290
|
+
return "full";
|
|
291
|
+
}
|
|
292
|
+
const doc = iframe.contentDocument;
|
|
293
|
+
const win = iframe.contentWindow as IframeWindow | null;
|
|
294
|
+
if (!doc || !win) {
|
|
295
|
+
reloadPreview();
|
|
296
|
+
return "full";
|
|
297
|
+
}
|
|
298
|
+
const { previous, restored } = files[activeCompPath]!;
|
|
299
|
+
const diff = diffSoftReloadableRestore(previous, restored);
|
|
300
|
+
if (!diff) {
|
|
301
|
+
reloadPreview();
|
|
302
|
+
return "full";
|
|
303
|
+
}
|
|
304
|
+
|
|
305
|
+
// Sync each changed element's attributes onto the live DOM from the restored
|
|
306
|
+
// markup, so the runtime's seek-reapply (which reads inline offset props off
|
|
307
|
+
// the live element) folds the REVERTED values, not the stale current ones.
|
|
308
|
+
const restoredById = idElementMap(new DOMParser().parseFromString(restored, "text/html"));
|
|
309
|
+
for (const id of diff.changedElementIds) {
|
|
310
|
+
const liveEl = doc.getElementById(id);
|
|
311
|
+
const restoredEl = restoredById.get(id);
|
|
312
|
+
if (liveEl && restoredEl) syncElementAttributes(liveEl, restoredEl);
|
|
313
|
+
}
|
|
314
|
+
|
|
315
|
+
const script = extractGsapScriptText(restored);
|
|
316
|
+
if (script) {
|
|
317
|
+
const result = applySoftReload(iframe, script, {
|
|
318
|
+
onAsyncFailure: reloadPreview,
|
|
319
|
+
currentTimeOverride: currentTime,
|
|
320
|
+
});
|
|
321
|
+
if (result === "cannot-soft-reload") {
|
|
322
|
+
reloadPreview();
|
|
323
|
+
return "full";
|
|
324
|
+
}
|
|
325
|
+
return "soft";
|
|
326
|
+
}
|
|
327
|
+
// No single GSAP script to re-run — the change was pure attribute/style. Re-fold
|
|
328
|
+
// manual edits and hold the playhead so the synced attributes take visible effect.
|
|
329
|
+
try {
|
|
330
|
+
win.__player?.seek?.(currentTime);
|
|
331
|
+
win.__hfStudioManualEditsApply?.();
|
|
332
|
+
} catch {
|
|
333
|
+
reloadPreview();
|
|
334
|
+
return "full";
|
|
335
|
+
}
|
|
336
|
+
return "soft";
|
|
337
|
+
}
|
|
338
|
+
|
|
186
339
|
export function applySoftReload(
|
|
187
340
|
iframe: HTMLIFrameElement | null,
|
|
188
341
|
scriptText: string,
|
package/src/utils/mediaTypes.ts
CHANGED
|
@@ -1,9 +1,10 @@
|
|
|
1
|
-
export const IMAGE_EXT = /\.(jpg|jpeg|png|gif|webp|svg|ico)$/i;
|
|
1
|
+
export const IMAGE_EXT = /\.(jpg|jpeg|png|gif|webp|avif|svg|ico)$/i;
|
|
2
2
|
export const VIDEO_EXT = /\.(mp4|webm|mov)$/i;
|
|
3
3
|
export const AUDIO_EXT = /\.(mp3|wav|ogg|m4a|aac)$/i;
|
|
4
4
|
export const FONT_EXT = /\.(woff|woff2|ttf|ttc|otf|eot)$/i;
|
|
5
5
|
export const LUT_EXT = /\.cube$/i;
|
|
6
|
-
export const MEDIA_EXT =
|
|
6
|
+
export const MEDIA_EXT =
|
|
7
|
+
/\.(mp4|webm|mov|mp3|wav|ogg|m4a|aac|jpg|jpeg|png|gif|webp|avif|svg|ico)$/i;
|
|
7
8
|
|
|
8
9
|
export function isMediaFile(path: string): boolean {
|
|
9
10
|
return MEDIA_EXT.test(path);
|
|
@@ -0,0 +1,55 @@
|
|
|
1
|
+
// Resize/gesture diagnostics — grep [hf-resize]. Off by default; opt in per
|
|
2
|
+
// session with `localStorage.setItem("hf-resize-debug", "1")` (then reload).
|
|
3
|
+
// Granular per-move/per-gesture tracing that complements the always-on
|
|
4
|
+
// [hf-commit] transaction telemetry in gestureTransaction.ts.
|
|
5
|
+
let moveN = 0;
|
|
6
|
+
let enabled: boolean | null = null;
|
|
7
|
+
|
|
8
|
+
function isEnabled(): boolean {
|
|
9
|
+
if (enabled === null) {
|
|
10
|
+
try {
|
|
11
|
+
enabled = localStorage.getItem("hf-resize-debug") === "1";
|
|
12
|
+
} catch {
|
|
13
|
+
enabled = false;
|
|
14
|
+
}
|
|
15
|
+
}
|
|
16
|
+
return enabled;
|
|
17
|
+
}
|
|
18
|
+
|
|
19
|
+
export function logResize(stage: string, data: Record<string, unknown>): void {
|
|
20
|
+
if (!isEnabled()) return;
|
|
21
|
+
console.log(
|
|
22
|
+
`[hf-resize] ${JSON.stringify({ stage, t: Math.round(performance.now()), ...data })}`,
|
|
23
|
+
);
|
|
24
|
+
}
|
|
25
|
+
|
|
26
|
+
/** Per-pointermove logging, throttled: first move then every 8th. */
|
|
27
|
+
export function logResizeMove(data: Record<string, unknown>): void {
|
|
28
|
+
if (!isEnabled()) return;
|
|
29
|
+
moveN += 1;
|
|
30
|
+
if (moveN % 8 === 1) logResize("move", { n: moveN, ...data });
|
|
31
|
+
}
|
|
32
|
+
|
|
33
|
+
export function resetResizeMoveLog(): void {
|
|
34
|
+
moveN = 0;
|
|
35
|
+
}
|
|
36
|
+
|
|
37
|
+
/** Snapshot the element's live geometry now and again after 200ms (jump detector). */
|
|
38
|
+
export function logResizeSettle(el: HTMLElement, tag: string): void {
|
|
39
|
+
if (!isEnabled()) return;
|
|
40
|
+
const snap = (phase: string) => {
|
|
41
|
+
const r = el.getBoundingClientRect();
|
|
42
|
+
const cs = el.ownerDocument.defaultView?.getComputedStyle(el);
|
|
43
|
+
logResize("settle", {
|
|
44
|
+
tag,
|
|
45
|
+
phase,
|
|
46
|
+
rect: { x: r.x, y: r.y, w: r.width, h: r.height },
|
|
47
|
+
cssW: cs?.width,
|
|
48
|
+
cssH: cs?.height,
|
|
49
|
+
transform: cs?.transform,
|
|
50
|
+
inlineStyle: el.getAttribute("style"),
|
|
51
|
+
});
|
|
52
|
+
};
|
|
53
|
+
snap("t0");
|
|
54
|
+
setTimeout(() => snap("t200"), 200);
|
|
55
|
+
}
|
|
@@ -1,5 +1,10 @@
|
|
|
1
|
+
// @vitest-environment jsdom
|
|
1
2
|
import { describe, expect, it } from "vitest";
|
|
2
|
-
import {
|
|
3
|
+
import {
|
|
4
|
+
extendRootDurationInSource,
|
|
5
|
+
patchRootCompositionDuration,
|
|
6
|
+
readRootCompositionDuration,
|
|
7
|
+
} from "./rootDuration";
|
|
3
8
|
|
|
4
9
|
describe("extendRootDurationInSource", () => {
|
|
5
10
|
it("extends data-duration when the new end is bigger than the root duration", () => {
|
|
@@ -31,4 +36,88 @@ describe("extendRootDurationInSource", () => {
|
|
|
31
36
|
expect(patched).toContain(`<div data-duration="3"></div>`);
|
|
32
37
|
expect(patched).toContain(`<div data-composition-id="main" data-duration="7"></div>`);
|
|
33
38
|
});
|
|
39
|
+
|
|
40
|
+
// Reviewer round-2 finding #3: the old regex was attribute-ORDER-dependent and
|
|
41
|
+
// double-quotes-only, so these hand-authored variants silently no-op'd.
|
|
42
|
+
it("extends when data-duration is declared BEFORE data-composition-id", () => {
|
|
43
|
+
const source = `<div data-duration="4" data-composition-id="main"></div>`;
|
|
44
|
+
expect(extendRootDurationInSource(source, 9)).toBe(
|
|
45
|
+
`<div data-duration="9" data-composition-id="main"></div>`,
|
|
46
|
+
);
|
|
47
|
+
});
|
|
48
|
+
|
|
49
|
+
it("extends when attributes use single quotes", () => {
|
|
50
|
+
const source = `<div data-composition-id='main' data-duration='4'></div>`;
|
|
51
|
+
expect(extendRootDurationInSource(source, 9)).toBe(
|
|
52
|
+
`<div data-composition-id='main' data-duration='9'></div>`,
|
|
53
|
+
);
|
|
54
|
+
});
|
|
55
|
+
|
|
56
|
+
it("extends with swapped order AND single quotes AND extra whitespace", () => {
|
|
57
|
+
const source = `<div data-duration = '4' data-composition-id = 'main' >x</div>`;
|
|
58
|
+
expect(extendRootDurationInSource(source, 9)).toBe(
|
|
59
|
+
`<div data-duration = '9' data-composition-id = 'main' >x</div>`,
|
|
60
|
+
);
|
|
61
|
+
});
|
|
62
|
+
});
|
|
63
|
+
|
|
64
|
+
describe("readRootCompositionDuration", () => {
|
|
65
|
+
it("reads the root duration regardless of attribute order or quote style", () => {
|
|
66
|
+
expect(
|
|
67
|
+
readRootCompositionDuration(`<div data-composition-id="main" data-duration="4"></div>`),
|
|
68
|
+
).toBe(4);
|
|
69
|
+
expect(
|
|
70
|
+
readRootCompositionDuration(`<div data-duration="4" data-composition-id="main"></div>`),
|
|
71
|
+
).toBe(4);
|
|
72
|
+
expect(
|
|
73
|
+
readRootCompositionDuration(`<div data-composition-id='main' data-duration='4.5'></div>`),
|
|
74
|
+
).toBe(4.5);
|
|
75
|
+
});
|
|
76
|
+
|
|
77
|
+
it("reads the FIRST composition when several are present", () => {
|
|
78
|
+
const source = [
|
|
79
|
+
`<div data-composition-id="root" data-duration="10"></div>`,
|
|
80
|
+
`<div data-composition-id="nested" data-duration="2"></div>`,
|
|
81
|
+
].join("\n");
|
|
82
|
+
expect(readRootCompositionDuration(source)).toBe(10);
|
|
83
|
+
});
|
|
84
|
+
|
|
85
|
+
it("returns null when there is no composition root", () => {
|
|
86
|
+
expect(readRootCompositionDuration(`<div data-duration="4"></div>`)).toBeNull();
|
|
87
|
+
});
|
|
88
|
+
|
|
89
|
+
it("returns null when the root has no data-duration attribute", () => {
|
|
90
|
+
expect(readRootCompositionDuration(`<div data-composition-id="main"></div>`)).toBeNull();
|
|
91
|
+
});
|
|
92
|
+
});
|
|
93
|
+
|
|
94
|
+
describe("patchRootCompositionDuration", () => {
|
|
95
|
+
it("rewrites only the root's data-duration value, preserving surrounding bytes", () => {
|
|
96
|
+
const source = [
|
|
97
|
+
`<!doctype html>`,
|
|
98
|
+
`<div data-composition-id="main" data-duration="4" data-width="640">`,
|
|
99
|
+
` <img src="a.png" data-duration="3" />`,
|
|
100
|
+
`</div>`,
|
|
101
|
+
].join("\n");
|
|
102
|
+
const patched = patchRootCompositionDuration(source, "8");
|
|
103
|
+
expect(patched).toBe(
|
|
104
|
+
[
|
|
105
|
+
`<!doctype html>`,
|
|
106
|
+
`<div data-composition-id="main" data-duration="8" data-width="640">`,
|
|
107
|
+
` <img src="a.png" data-duration="3" />`,
|
|
108
|
+
`</div>`,
|
|
109
|
+
].join("\n"),
|
|
110
|
+
);
|
|
111
|
+
});
|
|
112
|
+
|
|
113
|
+
it("keeps single-quote style when rewriting", () => {
|
|
114
|
+
expect(
|
|
115
|
+
patchRootCompositionDuration(`<div data-composition-id='main' data-duration='4'></div>`, "8"),
|
|
116
|
+
).toBe(`<div data-composition-id='main' data-duration='8'></div>`);
|
|
117
|
+
});
|
|
118
|
+
|
|
119
|
+
it("is a no-op when the root has no data-duration attribute", () => {
|
|
120
|
+
const source = `<div data-composition-id="main"></div>`;
|
|
121
|
+
expect(patchRootCompositionDuration(source, "8")).toBe(source);
|
|
122
|
+
});
|
|
34
123
|
});
|
|
@@ -1,17 +1,80 @@
|
|
|
1
1
|
import { formatTimelineAttributeNumber } from "../player/components/timelineEditing";
|
|
2
2
|
|
|
3
|
-
|
|
4
|
-
|
|
5
|
-
|
|
3
|
+
/**
|
|
4
|
+
* Matches the opening tag of the ROOT composition element — the first tag that
|
|
5
|
+
* carries a `data-composition-id` attribute, regardless of where the attribute
|
|
6
|
+
* sits in the tag or how its value is quoted. `[^>]*` keeps the match inside a
|
|
7
|
+
* single tag, so the first hit is the first composition in document order (the
|
|
8
|
+
* same element `doc.querySelector("[data-composition-id]")` resolves to).
|
|
9
|
+
*/
|
|
10
|
+
const ROOT_COMPOSITION_OPEN_TAG_RE = /<[^>]*\bdata-composition-id(?=[\s=/>])[^>]*>/i;
|
|
11
|
+
|
|
12
|
+
/**
|
|
13
|
+
* Matches a `data-duration="..."` attribute inside a single opening tag. Quote
|
|
14
|
+
* style is captured (backreferenced), so both `"` and `'` round-trip, and the
|
|
15
|
+
* `\s*` around `=` tolerates author whitespace.
|
|
16
|
+
*/
|
|
17
|
+
const DATA_DURATION_ATTR_RE = /(\bdata-duration\s*=\s*)(["'])[^"']*\2/i;
|
|
18
|
+
|
|
19
|
+
/**
|
|
20
|
+
* Read the ROOT composition's raw `data-duration`.
|
|
21
|
+
*
|
|
22
|
+
* Parses the source with DOMParser and locates the root the same way the rest of
|
|
23
|
+
* the timeline code does — the first `[data-composition-id]` element in document
|
|
24
|
+
* order — then reads its `data-duration`. Because it works on the parsed tree,
|
|
25
|
+
* attribute order and quote style are irrelevant, unlike the previous
|
|
26
|
+
* order-dependent, double-quotes-only regex.
|
|
27
|
+
*
|
|
28
|
+
* Returns `null` when there is no root composition or the root has no
|
|
29
|
+
* `data-duration` attribute at all. When the attribute is present but not a
|
|
30
|
+
* number the parsed `NaN` is returned as-is, so callers reproduce the old
|
|
31
|
+
* regex's "attribute matched but value unusable" behavior.
|
|
32
|
+
*
|
|
33
|
+
* Deterministic and render-safe: DOMParser is the only DOM global used.
|
|
34
|
+
*/
|
|
35
|
+
export function readRootCompositionDuration(source: string): number | null {
|
|
36
|
+
const root = new DOMParser()
|
|
37
|
+
.parseFromString(source, "text/html")
|
|
38
|
+
.querySelector("[data-composition-id]");
|
|
39
|
+
const raw = root?.getAttribute("data-duration");
|
|
40
|
+
if (raw == null) return null;
|
|
41
|
+
return Number.parseFloat(raw);
|
|
42
|
+
}
|
|
43
|
+
|
|
44
|
+
/**
|
|
45
|
+
* Rewrite the ROOT composition's `data-duration` value, preserving the rest of
|
|
46
|
+
* the document byte-for-byte.
|
|
47
|
+
*
|
|
48
|
+
* We deliberately do NOT re-serialize the parsed Document: a DOMParser round-trip
|
|
49
|
+
* injects `<html>/<head>/<body>`, forces double quotes, self-closes void
|
|
50
|
+
* elements, and drops the original indentation — it reformats unrelated markup.
|
|
51
|
+
* Instead we locate the root opening tag and rewrite only its `data-duration`
|
|
52
|
+
* value in place, keeping the author's quote style. The targeted splice is
|
|
53
|
+
* attribute-order-, quote-, and whitespace-agnostic.
|
|
54
|
+
*
|
|
55
|
+
* No-op (returns `source` unchanged) when there is no root composition tag or the
|
|
56
|
+
* root tag has no `data-duration` attribute to replace.
|
|
57
|
+
*/
|
|
58
|
+
export function patchRootCompositionDuration(source: string, newValue: string): string {
|
|
59
|
+
const rootTag = ROOT_COMPOSITION_OPEN_TAG_RE.exec(source);
|
|
60
|
+
if (!rootTag) return source;
|
|
61
|
+
const patchedTag = rootTag[0].replace(
|
|
62
|
+
DATA_DURATION_ATTR_RE,
|
|
63
|
+
(_full, prefix: string, quote: string) => `${prefix}${quote}${newValue}${quote}`,
|
|
6
64
|
);
|
|
7
|
-
if (
|
|
8
|
-
|
|
9
|
-
|
|
10
|
-
|
|
11
|
-
|
|
12
|
-
|
|
13
|
-
|
|
14
|
-
|
|
15
|
-
|
|
16
|
-
|
|
65
|
+
if (patchedTag === rootTag[0]) return source;
|
|
66
|
+
return (
|
|
67
|
+
source.slice(0, rootTag.index) + patchedTag + source.slice(rootTag.index + rootTag[0].length)
|
|
68
|
+
);
|
|
69
|
+
}
|
|
70
|
+
|
|
71
|
+
/**
|
|
72
|
+
* Grow-only ratchet: extend the root composition's `data-duration` to `newEnd`
|
|
73
|
+
* when `newEnd` is larger than the current root duration. No-op otherwise (and
|
|
74
|
+
* when there is no root duration to compare against).
|
|
75
|
+
*/
|
|
76
|
+
export function extendRootDurationInSource(source: string, newEnd: number): string {
|
|
77
|
+
const current = readRootCompositionDuration(source);
|
|
78
|
+
if (current == null || !(newEnd > current)) return source;
|
|
79
|
+
return patchRootCompositionDuration(source, formatTimelineAttributeNumber(newEnd));
|
|
17
80
|
}
|
package/src/utils/sdkCutover.ts
CHANGED
|
@@ -18,6 +18,7 @@ export interface CutoverDeps {
|
|
|
18
18
|
label: string;
|
|
19
19
|
kind: EditHistoryKind;
|
|
20
20
|
coalesceKey?: string;
|
|
21
|
+
coalesceMs?: number;
|
|
21
22
|
files: Record<string, { before: string; after: string }>;
|
|
22
23
|
}) => Promise<void>;
|
|
23
24
|
};
|
|
@@ -84,6 +85,8 @@ function wrongCompositionFile(deps: CutoverDeps, targetPath: string): boolean {
|
|
|
84
85
|
interface CutoverOptions {
|
|
85
86
|
label?: string;
|
|
86
87
|
coalesceKey?: string;
|
|
88
|
+
/** Coalesce window (ms); Infinity folds across a slow round-trip. */
|
|
89
|
+
coalesceMs?: number;
|
|
87
90
|
/** Skip the preview reload (mirrors the server path's skipRefresh). */
|
|
88
91
|
skipRefresh?: boolean;
|
|
89
92
|
}
|
|
@@ -110,6 +113,7 @@ export async function persistSdkSerialize(
|
|
|
110
113
|
label: options?.label ?? "Edit layer",
|
|
111
114
|
kind: "manual",
|
|
112
115
|
...(options?.coalesceKey ? { coalesceKey: options.coalesceKey } : {}),
|
|
116
|
+
...(options?.coalesceMs != null ? { coalesceMs: options.coalesceMs } : {}),
|
|
113
117
|
files: { [targetPath]: { before: originalContent, after } },
|
|
114
118
|
});
|
|
115
119
|
if (deps.refresh) deps.refresh(after);
|
|
@@ -1,25 +1,55 @@
|
|
|
1
|
+
import type { MutableRefObject } from "react";
|
|
1
2
|
import type { EditHistoryKind } from "./editHistory";
|
|
3
|
+
import { createStudioSaveHttpError } from "./studioSaveDiagnostics";
|
|
4
|
+
|
|
5
|
+
export interface RecordEditInput {
|
|
6
|
+
label: string;
|
|
7
|
+
kind: EditHistoryKind;
|
|
8
|
+
coalesceKey?: string;
|
|
9
|
+
coalesceMs?: number;
|
|
10
|
+
files: Record<string, { before: string; after: string }>;
|
|
11
|
+
}
|
|
12
|
+
|
|
13
|
+
export interface DomEditCommitBaseParams {
|
|
14
|
+
activeCompPath: string | null;
|
|
15
|
+
showToast: (message: string, tone?: "error" | "info") => void;
|
|
16
|
+
writeProjectFile: (path: string, content: string) => Promise<void>;
|
|
17
|
+
domEditSaveTimestampRef: MutableRefObject<number>;
|
|
18
|
+
editHistory: { recordEdit: (entry: RecordEditInput) => Promise<void> };
|
|
19
|
+
projectIdRef: MutableRefObject<string | null>;
|
|
20
|
+
reloadPreview: () => void;
|
|
21
|
+
clearDomSelection: () => void;
|
|
22
|
+
}
|
|
2
23
|
|
|
3
24
|
interface SaveProjectFilesWithHistoryInput {
|
|
4
25
|
projectId: string;
|
|
5
26
|
label: string;
|
|
6
27
|
kind: EditHistoryKind;
|
|
7
28
|
coalesceKey?: string;
|
|
29
|
+
coalesceMs?: number;
|
|
8
30
|
files: Record<string, string>;
|
|
9
31
|
readFile: (path: string) => Promise<string>;
|
|
10
32
|
writeFile: (path: string, content: string) => Promise<void>;
|
|
11
|
-
recordEdit: (entry:
|
|
12
|
-
|
|
13
|
-
|
|
14
|
-
|
|
15
|
-
|
|
16
|
-
|
|
33
|
+
recordEdit: (entry: RecordEditInput) => Promise<void>;
|
|
34
|
+
}
|
|
35
|
+
|
|
36
|
+
export async function readProjectFileContent(pid: string, path: string): Promise<string> {
|
|
37
|
+
const response = await fetch(`/api/projects/${pid}/files/${encodeURIComponent(path)}`);
|
|
38
|
+
if (!response.ok) {
|
|
39
|
+
throw await createStudioSaveHttpError(response, `Failed to read ${path}`);
|
|
40
|
+
}
|
|
41
|
+
const data = (await response.json()) as { content?: string };
|
|
42
|
+
if (typeof data.content !== "string") {
|
|
43
|
+
throw new Error(`Missing file contents for ${path}`);
|
|
44
|
+
}
|
|
45
|
+
return data.content;
|
|
17
46
|
}
|
|
18
47
|
|
|
19
48
|
export async function saveProjectFilesWithHistory({
|
|
20
49
|
label,
|
|
21
50
|
kind,
|
|
22
51
|
coalesceKey,
|
|
52
|
+
coalesceMs,
|
|
23
53
|
files,
|
|
24
54
|
readFile,
|
|
25
55
|
writeFile,
|
|
@@ -43,7 +73,7 @@ export async function saveProjectFilesWithHistory({
|
|
|
43
73
|
writtenPaths.push(path);
|
|
44
74
|
}
|
|
45
75
|
|
|
46
|
-
await recordEdit({ label, kind, coalesceKey, files: snapshots });
|
|
76
|
+
await recordEdit({ label, kind, coalesceKey, coalesceMs, files: snapshots });
|
|
47
77
|
} catch (error) {
|
|
48
78
|
try {
|
|
49
79
|
for (const path of writtenPaths.reverse()) {
|