@hyperframes/studio 0.6.0-alpha.9 → 0.6.0
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-DjsVzYFP.js → hyperframes-player-DOFETgjy.js} +1 -1
- package/dist/assets/index-D1JDq7Gg.css +1 -0
- package/dist/assets/index-DUqUmaoH.js +117 -0
- package/dist/favicon.svg +14 -0
- package/dist/index.html +3 -2
- package/package.json +9 -9
- package/src/App.tsx +428 -4299
- package/src/components/AskAgentModal.tsx +120 -0
- package/src/components/StudioHeader.tsx +133 -0
- package/src/components/StudioLeftSidebar.tsx +125 -0
- package/src/components/StudioPreviewArea.tsx +163 -0
- package/src/components/StudioRightPanel.tsx +198 -0
- package/src/components/TimelineToolbar.tsx +89 -0
- package/src/components/editor/DomEditOverlay.tsx +15 -1
- package/src/components/editor/PropertyPanel.test.ts +0 -49
- package/src/components/editor/PropertyPanel.tsx +132 -2763
- package/src/components/editor/domEditing.ts +38 -5
- package/src/components/editor/manualEditingAvailability.test.ts +2 -2
- package/src/components/editor/manualEditingAvailability.ts +1 -1
- package/src/components/editor/manualEdits.ts +32 -0
- package/src/components/editor/propertyPanelColor.tsx +371 -0
- package/src/components/editor/propertyPanelFill.tsx +421 -0
- package/src/components/editor/propertyPanelFont.tsx +455 -0
- package/src/components/editor/propertyPanelHelpers.ts +401 -0
- package/src/components/editor/propertyPanelPrimitives.tsx +357 -0
- package/src/components/editor/propertyPanelSections.tsx +453 -0
- package/src/components/editor/propertyPanelStyleSections.tsx +411 -0
- package/src/components/nle/NLELayout.tsx +8 -11
- package/src/components/nle/NLEPreview.tsx +3 -0
- package/src/components/renders/RenderQueue.tsx +102 -31
- package/src/components/renders/useRenderQueue.ts +8 -2
- package/src/components/sidebar/LeftSidebar.tsx +186 -186
- package/src/contexts/DomEditContext.tsx +137 -0
- package/src/contexts/FileManagerContext.tsx +110 -0
- package/src/contexts/PanelLayoutContext.tsx +68 -0
- package/src/contexts/StudioContext.tsx +135 -0
- package/src/hooks/useAppHotkeys.ts +326 -0
- package/src/hooks/useAskAgentModal.ts +162 -0
- package/src/hooks/useCaptionDetection.ts +132 -0
- package/src/hooks/useCompositionDimensions.ts +25 -0
- package/src/hooks/useConsoleErrorCapture.ts +60 -0
- package/src/hooks/useDomEditCommits.ts +437 -0
- package/src/hooks/useDomEditSession.ts +342 -0
- package/src/hooks/useDomEditTextCommits.ts +330 -0
- package/src/hooks/useDomSelection.ts +398 -0
- package/src/hooks/useFileManager.ts +431 -0
- package/src/hooks/useFrameCapture.ts +77 -0
- package/src/hooks/useLintModal.ts +35 -0
- package/src/hooks/useManifestPersistence.ts +492 -0
- package/src/hooks/usePanelLayout.ts +68 -0
- package/src/hooks/usePreviewInteraction.ts +153 -0
- package/src/hooks/useRenderClipContent.ts +124 -0
- package/src/hooks/useTimelineEditing.ts +472 -0
- package/src/player/components/Player.tsx +33 -2
- package/src/player/components/Timeline.test.ts +0 -8
- package/src/player/components/Timeline.tsx +10 -103
- package/src/player/components/TimelineClip.tsx +9 -244
- package/src/player/hooks/useTimelinePlayer.ts +140 -103
- package/src/utils/domEditHelpers.ts +50 -0
- package/src/utils/studioFontHelpers.ts +83 -0
- package/src/utils/studioHelpers.ts +214 -0
- package/src/utils/studioPreviewHelpers.ts +185 -0
- package/src/utils/timelineDiscovery.ts +1 -1
- package/dist/assets/index-14zH9lqh.css +0 -1
- package/dist/assets/index-DYCiFGWQ.js +0 -108
- package/src/player/components/TimelineClip.test.ts +0 -92
|
@@ -906,6 +906,7 @@ export function useTimelinePlayer() {
|
|
|
906
906
|
const iframeShortcutCleanupRef = useRef<(() => void) | null>(null);
|
|
907
907
|
const playbackKeyDownRef = useRef<(e: KeyboardEvent) => void>(() => {});
|
|
908
908
|
const playbackKeyUpRef = useRef<(e: KeyboardEvent) => void>(() => {});
|
|
909
|
+
const lastTimelineMessageRef = useRef<number>(0);
|
|
909
910
|
const staticSeekAdapterRef = useRef<{
|
|
910
911
|
player: RuntimePlaybackAdapter;
|
|
911
912
|
duration: number;
|
|
@@ -920,17 +921,40 @@ export function useTimelinePlayer() {
|
|
|
920
921
|
const syncTimelineElements = useCallback(
|
|
921
922
|
(elements: TimelineElement[], nextDuration?: number) => {
|
|
922
923
|
const state = usePlayerStore.getState();
|
|
924
|
+
const resolvedDuration = nextDuration ?? state.duration;
|
|
923
925
|
const mergedElements = mergeTimelineElementsPreservingDowngrades(
|
|
924
926
|
state.elements,
|
|
925
927
|
elements,
|
|
926
928
|
state.duration,
|
|
927
|
-
|
|
929
|
+
resolvedDuration,
|
|
928
930
|
);
|
|
929
|
-
|
|
930
|
-
|
|
931
|
+
|
|
932
|
+
const elementsChanged =
|
|
933
|
+
mergedElements.length !== state.elements.length ||
|
|
934
|
+
mergedElements.some((el, i) => {
|
|
935
|
+
const prev = state.elements[i];
|
|
936
|
+
return (
|
|
937
|
+
!prev ||
|
|
938
|
+
el.id !== prev.id ||
|
|
939
|
+
el.start !== prev.start ||
|
|
940
|
+
el.duration !== prev.duration ||
|
|
941
|
+
el.track !== prev.track
|
|
942
|
+
);
|
|
943
|
+
});
|
|
944
|
+
|
|
945
|
+
if (elementsChanged) {
|
|
946
|
+
setElements(mergedElements);
|
|
947
|
+
}
|
|
948
|
+
if (
|
|
949
|
+
Number.isFinite(nextDuration) &&
|
|
950
|
+
(nextDuration ?? 0) > 0 &&
|
|
951
|
+
nextDuration !== state.duration
|
|
952
|
+
) {
|
|
931
953
|
setDuration(nextDuration ?? 0);
|
|
932
954
|
}
|
|
933
|
-
|
|
955
|
+
if (!state.timelineReady) {
|
|
956
|
+
setTimelineReady(true);
|
|
957
|
+
}
|
|
934
958
|
},
|
|
935
959
|
[setElements, setTimelineReady, setDuration],
|
|
936
960
|
);
|
|
@@ -1501,102 +1525,70 @@ export function useTimelinePlayer() {
|
|
|
1501
1525
|
}
|
|
1502
1526
|
}, [syncTimelineElements]);
|
|
1503
1527
|
|
|
1504
|
-
const
|
|
1505
|
-
|
|
1506
|
-
|
|
1507
|
-
let attempts = 0;
|
|
1508
|
-
const maxAttempts = 25;
|
|
1509
|
-
|
|
1510
|
-
if (probeIntervalRef.current) clearInterval(probeIntervalRef.current);
|
|
1511
|
-
|
|
1512
|
-
probeIntervalRef.current = setInterval(() => {
|
|
1513
|
-
attempts++;
|
|
1514
|
-
const adapter = getAdapter();
|
|
1515
|
-
if (adapter && adapter.getDuration() > 0) {
|
|
1516
|
-
clearInterval(probeIntervalRef.current);
|
|
1517
|
-
adapter.pause();
|
|
1518
|
-
|
|
1519
|
-
const seekTo = pendingSeekRef.current;
|
|
1520
|
-
pendingSeekRef.current = null;
|
|
1521
|
-
const startTime = seekTo != null ? Math.min(seekTo, adapter.getDuration()) : 0;
|
|
1522
|
-
|
|
1523
|
-
adapter.seek(startTime);
|
|
1524
|
-
const adapterDur = adapter.getDuration();
|
|
1525
|
-
// Cap at 7200s (2h) to guard against loop-inflated GSAP timelines
|
|
1526
|
-
if (Number.isFinite(adapterDur) && adapterDur > 0 && adapterDur < 7200)
|
|
1527
|
-
setDuration(adapterDur);
|
|
1528
|
-
setCurrentTime(startTime);
|
|
1529
|
-
if (!isRefreshingRef.current) {
|
|
1530
|
-
setTimelineReady(true);
|
|
1531
|
-
}
|
|
1532
|
-
isRefreshingRef.current = false;
|
|
1533
|
-
setIsPlaying(false);
|
|
1528
|
+
const initializeAdapter = useCallback(() => {
|
|
1529
|
+
const adapter = getAdapter();
|
|
1530
|
+
if (!adapter || adapter.getDuration() <= 0) return false;
|
|
1534
1531
|
|
|
1535
|
-
|
|
1536
|
-
|
|
1537
|
-
|
|
1538
|
-
|
|
1539
|
-
|
|
1540
|
-
|
|
1541
|
-
|
|
1542
|
-
|
|
1543
|
-
|
|
1532
|
+
adapter.pause();
|
|
1533
|
+
const seekTo = pendingSeekRef.current;
|
|
1534
|
+
pendingSeekRef.current = null;
|
|
1535
|
+
const startTime = seekTo != null ? Math.min(seekTo, adapter.getDuration()) : 0;
|
|
1536
|
+
|
|
1537
|
+
adapter.seek(startTime);
|
|
1538
|
+
const adapterDur = adapter.getDuration();
|
|
1539
|
+
if (
|
|
1540
|
+
Number.isFinite(adapterDur) &&
|
|
1541
|
+
adapterDur > 0 &&
|
|
1542
|
+
adapterDur < 7200 &&
|
|
1543
|
+
adapterDur !== usePlayerStore.getState().duration
|
|
1544
|
+
) {
|
|
1545
|
+
setDuration(adapterDur);
|
|
1546
|
+
}
|
|
1547
|
+
setCurrentTime(startTime);
|
|
1548
|
+
if (!isRefreshingRef.current) {
|
|
1549
|
+
setTimelineReady(true);
|
|
1550
|
+
}
|
|
1551
|
+
isRefreshingRef.current = false;
|
|
1552
|
+
setIsPlaying(false);
|
|
1544
1553
|
|
|
1545
|
-
|
|
1546
|
-
|
|
1547
|
-
|
|
1548
|
-
|
|
1549
|
-
|
|
1550
|
-
|
|
1551
|
-
|
|
1552
|
-
|
|
1553
|
-
|
|
1554
|
-
// (manifest may exist but all clips filtered out by parentCompositionId logic)
|
|
1555
|
-
if (usePlayerStore.getState().elements.length === 0 && doc) {
|
|
1556
|
-
// Fallback: parse data-start elements directly from DOM (raw HTML without runtime)
|
|
1557
|
-
const els = parseTimelineFromDOM(doc, adapter.getDuration());
|
|
1558
|
-
if (els.length > 0) {
|
|
1559
|
-
syncTimelineElements(els);
|
|
1560
|
-
}
|
|
1561
|
-
}
|
|
1554
|
+
try {
|
|
1555
|
+
const iframe = iframeRef.current;
|
|
1556
|
+
const doc = iframe?.contentDocument;
|
|
1557
|
+
const iframeWin = iframe?.contentWindow as IframeWindow | null;
|
|
1558
|
+
if (doc && iframeWin) {
|
|
1559
|
+
normalizePreviewViewport(doc, iframeWin);
|
|
1560
|
+
autoHealMissingCompositionIds(doc);
|
|
1561
|
+
attachIframeShortcutListeners();
|
|
1562
|
+
}
|
|
1562
1563
|
|
|
1563
|
-
|
|
1564
|
-
|
|
1565
|
-
|
|
1566
|
-
|
|
1567
|
-
|
|
1568
|
-
const rootComp = doc.querySelector("[data-composition-id]");
|
|
1569
|
-
const rootDuration = adapter.getDuration();
|
|
1570
|
-
if (rootComp && rootDuration > 0) {
|
|
1571
|
-
const fallbackElement = buildStandaloneRootTimelineElement({
|
|
1572
|
-
compositionId: rootComp.getAttribute("data-composition-id") || "composition",
|
|
1573
|
-
tagName: (rootComp as HTMLElement).tagName || "div",
|
|
1574
|
-
rootDuration,
|
|
1575
|
-
iframeSrc: iframe?.src || "",
|
|
1576
|
-
selector: getTimelineElementSelector(rootComp),
|
|
1577
|
-
});
|
|
1578
|
-
if (fallbackElement) {
|
|
1579
|
-
// Always show the root composition as a single clip — guarantees
|
|
1580
|
-
// the timeline is never empty when a valid composition is loaded.
|
|
1581
|
-
syncTimelineElements([fallbackElement]);
|
|
1582
|
-
}
|
|
1583
|
-
}
|
|
1584
|
-
}
|
|
1585
|
-
// The runtime will also postMessage the full timeline after all compositions load.
|
|
1586
|
-
// That message is handled by the window listener below, which will update elements
|
|
1587
|
-
// with the complete data (including async-loaded compositions).
|
|
1588
|
-
} catch (err) {
|
|
1589
|
-
console.warn("[useTimelinePlayer] Could not read timeline elements from iframe", err);
|
|
1590
|
-
}
|
|
1564
|
+
const manifest = iframeWin?.__clipManifest;
|
|
1565
|
+
if (manifest && manifest.clips.length > 0) {
|
|
1566
|
+
processTimelineMessage(manifest);
|
|
1567
|
+
}
|
|
1568
|
+
enrichMissingCompositions();
|
|
1591
1569
|
|
|
1592
|
-
|
|
1570
|
+
if (usePlayerStore.getState().elements.length === 0 && doc) {
|
|
1571
|
+
const els = parseTimelineFromDOM(doc, adapter.getDuration());
|
|
1572
|
+
if (els.length > 0) syncTimelineElements(els);
|
|
1593
1573
|
}
|
|
1594
|
-
if (
|
|
1595
|
-
|
|
1596
|
-
|
|
1574
|
+
if (usePlayerStore.getState().elements.length === 0 && doc) {
|
|
1575
|
+
const rootComp = doc.querySelector("[data-composition-id]");
|
|
1576
|
+
const rootDuration = adapter.getDuration();
|
|
1577
|
+
if (rootComp && rootDuration > 0) {
|
|
1578
|
+
const fallbackElement = buildStandaloneRootTimelineElement({
|
|
1579
|
+
compositionId: rootComp.getAttribute("data-composition-id") || "composition",
|
|
1580
|
+
tagName: (rootComp as HTMLElement).tagName || "div",
|
|
1581
|
+
rootDuration,
|
|
1582
|
+
iframeSrc: iframe?.src || "",
|
|
1583
|
+
selector: getTimelineElementSelector(rootComp),
|
|
1584
|
+
});
|
|
1585
|
+
if (fallbackElement) syncTimelineElements([fallbackElement]);
|
|
1586
|
+
}
|
|
1597
1587
|
}
|
|
1598
|
-
}
|
|
1599
|
-
|
|
1588
|
+
} catch (err) {
|
|
1589
|
+
console.warn("[useTimelinePlayer] Could not read timeline elements from iframe", err);
|
|
1590
|
+
}
|
|
1591
|
+
return true;
|
|
1600
1592
|
}, [
|
|
1601
1593
|
getAdapter,
|
|
1602
1594
|
setDuration,
|
|
@@ -1609,6 +1601,50 @@ export function useTimelinePlayer() {
|
|
|
1609
1601
|
attachIframeShortcutListeners,
|
|
1610
1602
|
]);
|
|
1611
1603
|
|
|
1604
|
+
const onIframeLoad = useCallback(() => {
|
|
1605
|
+
unmutePreviewMedia(iframeRef.current);
|
|
1606
|
+
if (probeIntervalRef.current) clearInterval(probeIntervalRef.current);
|
|
1607
|
+
|
|
1608
|
+
// Fast path: adapter already available (in-place reloads, cached compositions)
|
|
1609
|
+
if (initializeAdapter()) return;
|
|
1610
|
+
|
|
1611
|
+
// The runtime posts "state" or "timeline" messages once ready.
|
|
1612
|
+
// Listen for those instead of polling. Use a short-lived message
|
|
1613
|
+
// listener that fires initializeAdapter on the first signal.
|
|
1614
|
+
const iframe = iframeRef.current;
|
|
1615
|
+
let settled = false;
|
|
1616
|
+
|
|
1617
|
+
const trySettle = () => {
|
|
1618
|
+
if (settled) return;
|
|
1619
|
+
if (initializeAdapter()) {
|
|
1620
|
+
settled = true;
|
|
1621
|
+
window.removeEventListener("message", onMessage);
|
|
1622
|
+
if (probeIntervalRef.current) clearInterval(probeIntervalRef.current);
|
|
1623
|
+
}
|
|
1624
|
+
};
|
|
1625
|
+
|
|
1626
|
+
const onMessage = (e: MessageEvent) => {
|
|
1627
|
+
if (e.source && iframe && e.source !== iframe.contentWindow) return;
|
|
1628
|
+
const data = e.data;
|
|
1629
|
+
if (data?.source === "hf-preview" && (data?.type === "state" || data?.type === "timeline")) {
|
|
1630
|
+
trySettle();
|
|
1631
|
+
}
|
|
1632
|
+
};
|
|
1633
|
+
window.addEventListener("message", onMessage);
|
|
1634
|
+
|
|
1635
|
+
// Safety net: if no message arrives within 5s, try one last time then give up.
|
|
1636
|
+
// This replaces the old 25×200ms polling loop with a single delayed check.
|
|
1637
|
+
probeIntervalRef.current = setTimeout(() => {
|
|
1638
|
+
if (!settled) {
|
|
1639
|
+
trySettle();
|
|
1640
|
+
if (!settled) {
|
|
1641
|
+
console.warn("[useTimelinePlayer] Runtime did not signal readiness within 5s");
|
|
1642
|
+
}
|
|
1643
|
+
}
|
|
1644
|
+
window.removeEventListener("message", onMessage);
|
|
1645
|
+
}, 5000) as unknown as ReturnType<typeof setInterval>;
|
|
1646
|
+
}, [initializeAdapter]);
|
|
1647
|
+
|
|
1612
1648
|
/** Save the current playback time so the next onIframeLoad restores it. */
|
|
1613
1649
|
const saveSeekPosition = useCallback(() => {
|
|
1614
1650
|
const adapter = getAdapter();
|
|
@@ -1668,23 +1704,24 @@ export function useTimelinePlayer() {
|
|
|
1668
1704
|
processTimelineMessageRef.current(manifest);
|
|
1669
1705
|
}
|
|
1670
1706
|
}
|
|
1671
|
-
//
|
|
1672
|
-
|
|
1707
|
+
// Enrich only when the timeline has settled — skip during the window
|
|
1708
|
+
// right after a "timeline" message to avoid the enrichment adding
|
|
1709
|
+
// elements that fight with the manifest's authoritative element list,
|
|
1710
|
+
// causing duration oscillation (the merge function alternates between
|
|
1711
|
+
// REPLACE and PRESERVE when element counts fluctuate).
|
|
1712
|
+
const msSinceTimeline = Date.now() - lastTimelineMessageRef.current;
|
|
1713
|
+
if (msSinceTimeline > 500) {
|
|
1714
|
+
enrichMissingCompositionsRef.current();
|
|
1715
|
+
}
|
|
1673
1716
|
} catch (err) {
|
|
1674
1717
|
console.warn("[useTimelinePlayer] Could not read clip manifest from iframe", err);
|
|
1675
1718
|
}
|
|
1676
1719
|
}
|
|
1677
1720
|
if (data?.source === "hf-preview" && data?.type === "timeline" && Array.isArray(data.clips)) {
|
|
1721
|
+
lastTimelineMessageRef.current = Date.now();
|
|
1678
1722
|
processTimelineMessageRef.current(data);
|
|
1679
1723
|
// Fill in composition hosts the manifest missed (element-reference starts)
|
|
1680
1724
|
enrichMissingCompositionsRef.current();
|
|
1681
|
-
if (data.durationInFrames > 0 && Number.isFinite(data.durationInFrames)) {
|
|
1682
|
-
const fps = 30;
|
|
1683
|
-
const dur = data.durationInFrames / fps;
|
|
1684
|
-
if (dur > 0 && dur < 7200) {
|
|
1685
|
-
usePlayerStore.getState().setDuration(dur);
|
|
1686
|
-
}
|
|
1687
|
-
}
|
|
1688
1725
|
// If manifest produced 0 elements after filtering, try DOM fallback
|
|
1689
1726
|
if (usePlayerStore.getState().elements.length === 0) {
|
|
1690
1727
|
try {
|
|
@@ -0,0 +1,50 @@
|
|
|
1
|
+
import type { DomEditSelection } from "../components/editor/domEditing";
|
|
2
|
+
import { getDomEditTargetKey } from "../components/editor/domEditing";
|
|
3
|
+
|
|
4
|
+
export function domEditSelectionsTargetSame(
|
|
5
|
+
a: DomEditSelection | null,
|
|
6
|
+
b: DomEditSelection | null,
|
|
7
|
+
): boolean {
|
|
8
|
+
if (a === b) return true;
|
|
9
|
+
if (!a || !b) return false;
|
|
10
|
+
return getDomEditTargetKey(a) === getDomEditTargetKey(b);
|
|
11
|
+
}
|
|
12
|
+
|
|
13
|
+
export function domEditSelectionInGroup(
|
|
14
|
+
group: DomEditSelection[],
|
|
15
|
+
selection: DomEditSelection | null,
|
|
16
|
+
): boolean {
|
|
17
|
+
if (!selection) return false;
|
|
18
|
+
return group.some((entry) => domEditSelectionsTargetSame(entry, selection));
|
|
19
|
+
}
|
|
20
|
+
|
|
21
|
+
export function toggleDomEditGroupSelection(
|
|
22
|
+
group: DomEditSelection[],
|
|
23
|
+
selection: DomEditSelection,
|
|
24
|
+
): DomEditSelection[] {
|
|
25
|
+
if (domEditSelectionInGroup(group, selection)) {
|
|
26
|
+
return group.filter((entry) => !domEditSelectionsTargetSame(entry, selection));
|
|
27
|
+
}
|
|
28
|
+
return [...group, selection];
|
|
29
|
+
}
|
|
30
|
+
|
|
31
|
+
export function replaceDomEditGroupSelection(
|
|
32
|
+
group: DomEditSelection[],
|
|
33
|
+
selection: DomEditSelection,
|
|
34
|
+
): DomEditSelection[] {
|
|
35
|
+
let replaced = false;
|
|
36
|
+
const nextGroup = group.map((entry) => {
|
|
37
|
+
if (!domEditSelectionsTargetSame(entry, selection)) return entry;
|
|
38
|
+
replaced = true;
|
|
39
|
+
return selection;
|
|
40
|
+
});
|
|
41
|
+
return replaced ? nextGroup : [...group, selection];
|
|
42
|
+
}
|
|
43
|
+
|
|
44
|
+
export function seedDomEditGroupWithSelection(
|
|
45
|
+
group: DomEditSelection[],
|
|
46
|
+
selection: DomEditSelection | null,
|
|
47
|
+
): DomEditSelection[] {
|
|
48
|
+
if (!selection || domEditSelectionInGroup(group, selection)) return group;
|
|
49
|
+
return [selection, ...group];
|
|
50
|
+
}
|
|
@@ -0,0 +1,83 @@
|
|
|
1
|
+
import { googleFontStylesheetUrl } from "../components/editor/fontCatalog";
|
|
2
|
+
import { importedFontFaceCss, type ImportedFontAsset } from "../components/editor/fontAssets";
|
|
3
|
+
import { toRelativeProjectAssetPath } from "./studioHelpers";
|
|
4
|
+
|
|
5
|
+
const GENERIC_FONT_FAMILIES = new Set([
|
|
6
|
+
"inherit",
|
|
7
|
+
"initial",
|
|
8
|
+
"revert",
|
|
9
|
+
"revert-layer",
|
|
10
|
+
"serif",
|
|
11
|
+
"sans-serif",
|
|
12
|
+
"monospace",
|
|
13
|
+
"cursive",
|
|
14
|
+
"fantasy",
|
|
15
|
+
"system-ui",
|
|
16
|
+
"ui-sans-serif",
|
|
17
|
+
"ui-serif",
|
|
18
|
+
"ui-monospace",
|
|
19
|
+
"ui-rounded",
|
|
20
|
+
"emoji",
|
|
21
|
+
"math",
|
|
22
|
+
"fangsong",
|
|
23
|
+
]);
|
|
24
|
+
|
|
25
|
+
export function primaryFontFamilyFromCss(value: string): string {
|
|
26
|
+
const first = value.split(",")[0] ?? "";
|
|
27
|
+
return first.trim().replace(/^["']|["']$/g, "");
|
|
28
|
+
}
|
|
29
|
+
|
|
30
|
+
export function primaryFontFamilyValue(value: string): string {
|
|
31
|
+
return (
|
|
32
|
+
value
|
|
33
|
+
.split(",")[0]
|
|
34
|
+
?.trim()
|
|
35
|
+
.replace(/^["']|["']$/g, "")
|
|
36
|
+
.trim() ?? ""
|
|
37
|
+
);
|
|
38
|
+
}
|
|
39
|
+
|
|
40
|
+
export function injectPreviewGoogleFont(doc: Document, fontFamilyValue: string): void {
|
|
41
|
+
const family = primaryFontFamilyFromCss(fontFamilyValue);
|
|
42
|
+
if (!family || GENERIC_FONT_FAMILIES.has(family.toLowerCase())) return;
|
|
43
|
+
|
|
44
|
+
const id = `studio-preview-google-font-${family.toLowerCase().replace(/[^a-z0-9]+/g, "-")}`;
|
|
45
|
+
if (doc.getElementById(id)) return;
|
|
46
|
+
|
|
47
|
+
const link = doc.createElement("link");
|
|
48
|
+
link.id = id;
|
|
49
|
+
link.rel = "stylesheet";
|
|
50
|
+
link.href = googleFontStylesheetUrl(family);
|
|
51
|
+
doc.head.appendChild(link);
|
|
52
|
+
}
|
|
53
|
+
|
|
54
|
+
export function injectPreviewImportedFont(doc: Document, asset: ImportedFontAsset): void {
|
|
55
|
+
const id = `studio-imported-font-${asset.family.toLowerCase().replace(/[^a-z0-9]+/g, "-")}`;
|
|
56
|
+
if (doc.getElementById(id)) return;
|
|
57
|
+
const style = doc.createElement("style");
|
|
58
|
+
style.id = id;
|
|
59
|
+
style.textContent = importedFontFaceCss(asset);
|
|
60
|
+
doc.head.appendChild(style);
|
|
61
|
+
}
|
|
62
|
+
|
|
63
|
+
export function ensureImportedFontFace(
|
|
64
|
+
html: string,
|
|
65
|
+
asset: ImportedFontAsset,
|
|
66
|
+
sourceFile: string,
|
|
67
|
+
): string {
|
|
68
|
+
const css = importedFontFaceCss(asset, toRelativeProjectAssetPath(sourceFile, asset.path));
|
|
69
|
+
if (html.includes(css)) return html;
|
|
70
|
+
|
|
71
|
+
const styleRe = /<style\b[^>]*data-hf-studio-fonts=(["'])true\1[^>]*>([\s\S]*?)<\/style>/i;
|
|
72
|
+
const styleMatch = styleRe.exec(html);
|
|
73
|
+
if (styleMatch) {
|
|
74
|
+
const nextCss = `${styleMatch[2].trim()}\n${css}`.trim();
|
|
75
|
+
return html.replace(styleMatch[0], `<style data-hf-studio-fonts="true">\n${nextCss}\n</style>`);
|
|
76
|
+
}
|
|
77
|
+
|
|
78
|
+
const styleTag = `<style data-hf-studio-fonts="true">\n${css}\n</style>`;
|
|
79
|
+
if (/<\/head>/i.test(html)) {
|
|
80
|
+
return html.replace(/<\/head>/i, ` ${styleTag}\n </head>`);
|
|
81
|
+
}
|
|
82
|
+
return `${styleTag}\n${html}`;
|
|
83
|
+
}
|
|
@@ -0,0 +1,214 @@
|
|
|
1
|
+
import type { TimelineElement } from "../player";
|
|
2
|
+
import type { DomEditSelection } from "../components/editor/domEditing";
|
|
3
|
+
import type { TimelineAssetKind } from "./timelineAssetDrop";
|
|
4
|
+
|
|
5
|
+
export interface EditingFile {
|
|
6
|
+
path: string;
|
|
7
|
+
content: string | null;
|
|
8
|
+
}
|
|
9
|
+
|
|
10
|
+
export interface AppToast {
|
|
11
|
+
message: string;
|
|
12
|
+
tone: "error" | "info";
|
|
13
|
+
}
|
|
14
|
+
|
|
15
|
+
export type RightPanelTab = "design" | "motion" | "renders";
|
|
16
|
+
|
|
17
|
+
export interface AgentModalAnchorPoint {
|
|
18
|
+
x: number;
|
|
19
|
+
y: number;
|
|
20
|
+
}
|
|
21
|
+
|
|
22
|
+
export function getTimelineElementLabel(element: TimelineElement): string {
|
|
23
|
+
return element.label || element.id || element.tag;
|
|
24
|
+
}
|
|
25
|
+
|
|
26
|
+
export function confirmElementDelete(label: string, kind: "timeline clip" | "element"): boolean {
|
|
27
|
+
return window.confirm(
|
|
28
|
+
`Delete ${kind} "${label}"?\n\nThis removes it from the project source. You can use Undo to restore it.`,
|
|
29
|
+
);
|
|
30
|
+
}
|
|
31
|
+
|
|
32
|
+
export function normalizeProjectAssetPath(value: string): string {
|
|
33
|
+
const trimmed = value.trim();
|
|
34
|
+
const maybeUrl = /^[a-z]+:\/\//i.test(trimmed) ? new URL(trimmed).pathname : trimmed;
|
|
35
|
+
return decodeURIComponent(maybeUrl)
|
|
36
|
+
.replace(/\\/g, "/")
|
|
37
|
+
.replace(/^\.?\//, "");
|
|
38
|
+
}
|
|
39
|
+
|
|
40
|
+
export function toRelativeProjectAssetPath(sourceFile: string, assetPath: string): string {
|
|
41
|
+
const fromParts = normalizeProjectAssetPath(sourceFile).split("/").filter(Boolean);
|
|
42
|
+
const targetParts = normalizeProjectAssetPath(assetPath).split("/").filter(Boolean);
|
|
43
|
+
|
|
44
|
+
fromParts.pop();
|
|
45
|
+
|
|
46
|
+
while (fromParts.length > 0 && targetParts.length > 0 && fromParts[0] === targetParts[0]) {
|
|
47
|
+
fromParts.shift();
|
|
48
|
+
targetParts.shift();
|
|
49
|
+
}
|
|
50
|
+
|
|
51
|
+
return [...fromParts.map(() => ".."), ...targetParts].join("/") || assetPath;
|
|
52
|
+
}
|
|
53
|
+
|
|
54
|
+
export function isAbsoluteFilePath(value: string): boolean {
|
|
55
|
+
return /^(?:\/|[A-Za-z]:[\\/]|\\\\)/.test(value);
|
|
56
|
+
}
|
|
57
|
+
|
|
58
|
+
export function toProjectAbsolutePath(
|
|
59
|
+
projectDir: string | null,
|
|
60
|
+
sourceFile: string,
|
|
61
|
+
): string | undefined {
|
|
62
|
+
const trimmedSource = sourceFile.trim();
|
|
63
|
+
if (!trimmedSource) return undefined;
|
|
64
|
+
|
|
65
|
+
const normalizedSource = trimmedSource.replace(/\\/g, "/");
|
|
66
|
+
if (isAbsoluteFilePath(normalizedSource)) return normalizedSource;
|
|
67
|
+
|
|
68
|
+
const normalizedRoot = projectDir?.trim().replace(/\\/g, "/").replace(/\/+$/, "");
|
|
69
|
+
if (!normalizedRoot) return undefined;
|
|
70
|
+
|
|
71
|
+
return `${normalizedRoot}/${normalizedSource.replace(/^\.?\//, "")}`;
|
|
72
|
+
}
|
|
73
|
+
|
|
74
|
+
export function normalizeDomEditStyleValue(property: string, value: string): string {
|
|
75
|
+
const trimmed = value.trim();
|
|
76
|
+
if (!trimmed) return trimmed;
|
|
77
|
+
|
|
78
|
+
if (
|
|
79
|
+
["border-radius", "border-width", "font-size", "letter-spacing"].includes(property) &&
|
|
80
|
+
/^-?\d+(\.\d+)?$/.test(trimmed)
|
|
81
|
+
) {
|
|
82
|
+
return `${trimmed}px`;
|
|
83
|
+
}
|
|
84
|
+
|
|
85
|
+
return trimmed;
|
|
86
|
+
}
|
|
87
|
+
|
|
88
|
+
export function isImageBackgroundValue(value: string): boolean {
|
|
89
|
+
return /^url\(/i.test(value.trim());
|
|
90
|
+
}
|
|
91
|
+
|
|
92
|
+
export function isManualGeometryStyleProperty(property: string): boolean {
|
|
93
|
+
return property === "left" || property === "top" || property === "width" || property === "height";
|
|
94
|
+
}
|
|
95
|
+
|
|
96
|
+
export function getEventTargetElement(target: EventTarget | null): HTMLElement | null {
|
|
97
|
+
if (!target || typeof target !== "object") return null;
|
|
98
|
+
const maybeNode = target as {
|
|
99
|
+
nodeType?: number;
|
|
100
|
+
parentElement?: Element | null;
|
|
101
|
+
};
|
|
102
|
+
if (maybeNode.nodeType === 1) return target as HTMLElement;
|
|
103
|
+
if (maybeNode.nodeType === 3 && maybeNode.parentElement) {
|
|
104
|
+
return maybeNode.parentElement as HTMLElement;
|
|
105
|
+
}
|
|
106
|
+
return null;
|
|
107
|
+
}
|
|
108
|
+
|
|
109
|
+
export function shouldIgnoreHistoryShortcut(target: EventTarget | null): boolean {
|
|
110
|
+
const el = getEventTargetElement(target);
|
|
111
|
+
if (!el) return false;
|
|
112
|
+
return Boolean(
|
|
113
|
+
el.closest("input, textarea, select, [contenteditable='true'], [role='textbox'], .cm-editor"),
|
|
114
|
+
);
|
|
115
|
+
}
|
|
116
|
+
|
|
117
|
+
export function getHistoryShortcutLabel(action: "undo" | "redo"): string {
|
|
118
|
+
const isMac =
|
|
119
|
+
typeof navigator !== "undefined" && /Mac|iPhone|iPad|iPod/i.test(navigator.platform);
|
|
120
|
+
const modifier = isMac ? "Cmd" : "Ctrl";
|
|
121
|
+
return action === "undo" ? `${modifier}+Z` : `${modifier}+Shift+Z`;
|
|
122
|
+
}
|
|
123
|
+
|
|
124
|
+
export function findMatchingTimelineElementId(
|
|
125
|
+
selection: Pick<
|
|
126
|
+
DomEditSelection,
|
|
127
|
+
"id" | "selector" | "selectorIndex" | "sourceFile" | "compositionSrc" | "isCompositionHost"
|
|
128
|
+
>,
|
|
129
|
+
elements: TimelineElement[],
|
|
130
|
+
): string | null {
|
|
131
|
+
const selectionSourceFile = selection.sourceFile || "index.html";
|
|
132
|
+
for (const element of elements) {
|
|
133
|
+
const elementSourceFile = element.sourceFile || "index.html";
|
|
134
|
+
if (
|
|
135
|
+
selection.id &&
|
|
136
|
+
element.domId === selection.id &&
|
|
137
|
+
elementSourceFile === selectionSourceFile
|
|
138
|
+
) {
|
|
139
|
+
return element.key ?? element.id;
|
|
140
|
+
}
|
|
141
|
+
if (
|
|
142
|
+
selection.isCompositionHost &&
|
|
143
|
+
selection.compositionSrc &&
|
|
144
|
+
element.compositionSrc === selection.compositionSrc
|
|
145
|
+
) {
|
|
146
|
+
return element.key ?? element.id;
|
|
147
|
+
}
|
|
148
|
+
if (
|
|
149
|
+
selection.selector &&
|
|
150
|
+
element.selector === selection.selector &&
|
|
151
|
+
(element.selectorIndex ?? 0) === (selection.selectorIndex ?? 0) &&
|
|
152
|
+
(element.sourceFile ?? "index.html") === selection.sourceFile
|
|
153
|
+
) {
|
|
154
|
+
return element.key ?? element.id;
|
|
155
|
+
}
|
|
156
|
+
}
|
|
157
|
+
|
|
158
|
+
return null;
|
|
159
|
+
}
|
|
160
|
+
|
|
161
|
+
export function clampNumber(value: number, min: number, max: number): number {
|
|
162
|
+
if (max < min) return min;
|
|
163
|
+
return Math.min(Math.max(value, min), max);
|
|
164
|
+
}
|
|
165
|
+
|
|
166
|
+
export function collectHtmlIds(source: string): string[] {
|
|
167
|
+
return Array.from(source.matchAll(/\bid="([^"]+)"/g), (match) => match[1] ?? "");
|
|
168
|
+
}
|
|
169
|
+
|
|
170
|
+
export const DEFAULT_TIMELINE_ASSET_DURATION: Record<TimelineAssetKind, number> = {
|
|
171
|
+
image: 3,
|
|
172
|
+
video: 5,
|
|
173
|
+
audio: 5,
|
|
174
|
+
};
|
|
175
|
+
|
|
176
|
+
export async function resolveDroppedAssetDuration(
|
|
177
|
+
projectId: string,
|
|
178
|
+
assetPath: string,
|
|
179
|
+
kind: TimelineAssetKind,
|
|
180
|
+
): Promise<number> {
|
|
181
|
+
if (kind === "image") return DEFAULT_TIMELINE_ASSET_DURATION.image;
|
|
182
|
+
|
|
183
|
+
const media = document.createElement(kind === "video" ? "video" : "audio");
|
|
184
|
+
media.preload = "metadata";
|
|
185
|
+
media.src = `/api/projects/${projectId}/preview/${assetPath}`;
|
|
186
|
+
|
|
187
|
+
const duration = await new Promise<number>((resolve) => {
|
|
188
|
+
const timeout = window.setTimeout(() => resolve(DEFAULT_TIMELINE_ASSET_DURATION[kind]), 3000);
|
|
189
|
+
const finalize = (value: number) => {
|
|
190
|
+
window.clearTimeout(timeout);
|
|
191
|
+
resolve(value);
|
|
192
|
+
};
|
|
193
|
+
|
|
194
|
+
media.addEventListener(
|
|
195
|
+
"loadedmetadata",
|
|
196
|
+
() => {
|
|
197
|
+
const raw = Number(media.duration);
|
|
198
|
+
finalize(
|
|
199
|
+
Number.isFinite(raw) && raw > 0
|
|
200
|
+
? Math.round(raw * 100) / 100
|
|
201
|
+
: DEFAULT_TIMELINE_ASSET_DURATION[kind],
|
|
202
|
+
);
|
|
203
|
+
},
|
|
204
|
+
{ once: true },
|
|
205
|
+
);
|
|
206
|
+
media.addEventListener("error", () => finalize(DEFAULT_TIMELINE_ASSET_DURATION[kind]), {
|
|
207
|
+
once: true,
|
|
208
|
+
});
|
|
209
|
+
});
|
|
210
|
+
|
|
211
|
+
media.src = "";
|
|
212
|
+
media.load();
|
|
213
|
+
return duration;
|
|
214
|
+
}
|