mirror of
https://github.com/heygen-com/hyperframes.git
synced 2026-09-04 07:19:52 +00:00
fix(studio): persist studio state in project URLs (#836)
This commit is contained in:
+51
-27
@@ -1,4 +1,4 @@
|
||||
import { useState, useCallback, useRef, useMemo } from "react";
|
||||
import { useState, useCallback, useRef, useMemo, useEffect } from "react";
|
||||
import type { LeftSidebarHandle } from "./components/sidebar/LeftSidebar";
|
||||
import { useRenderQueue } from "./components/renders/useRenderQueue";
|
||||
import { usePlayerStore } from "./player";
|
||||
@@ -20,6 +20,7 @@ import { useFrameCapture } from "./hooks/useFrameCapture";
|
||||
import { useLintModal } from "./hooks/useLintModal";
|
||||
import { useCompositionDimensions } from "./hooks/useCompositionDimensions";
|
||||
import { useToast } from "./hooks/useToast";
|
||||
import { useStudioUrlState } from "./hooks/useStudioUrlState";
|
||||
import {
|
||||
STUDIO_INSPECTOR_PANELS_ENABLED,
|
||||
STUDIO_MOTION_PANEL_ENABLED,
|
||||
@@ -27,6 +28,7 @@ import {
|
||||
import { getStudioMotionForSelection } from "./components/editor/studioMotion";
|
||||
import type { DomEditSelection } from "./components/editor/domEditing";
|
||||
import { AskAgentModal } from "./components/AskAgentModal";
|
||||
import { StudioGlobalDragOverlay } from "./components/StudioGlobalDragOverlay";
|
||||
import { StudioHeader } from "./components/StudioHeader";
|
||||
import { StudioLeftSidebar } from "./components/StudioLeftSidebar";
|
||||
import { StudioPreviewArea } from "./components/StudioPreviewArea";
|
||||
@@ -38,11 +40,19 @@ import { FileManagerProvider } from "./contexts/FileManagerContext";
|
||||
import { DomEditProvider } from "./contexts/DomEditContext";
|
||||
import { StudioSplash } from "./components/StudioSplash";
|
||||
import { useServerConnection } from "./hooks/useServerConnection";
|
||||
import {
|
||||
normalizeStudioCompositionPath,
|
||||
readStudioUrlStateFromWindow,
|
||||
} from "./utils/studioUrlState";
|
||||
|
||||
export function StudioApp() {
|
||||
const { projectId, resolving, waitingForServer } = useServerConnection();
|
||||
const initialUrlStateRef = useRef(readStudioUrlStateFromWindow());
|
||||
|
||||
const [activeCompPath, setActiveCompPath] = useState<string | null>(null);
|
||||
const [activeCompPathHydrated, setActiveCompPathHydrated] = useState(
|
||||
() => initialUrlStateRef.current.activeCompPath == null,
|
||||
);
|
||||
const [compIdToSrc, setCompIdToSrc] = useState<Map<string, string>>(new Map());
|
||||
const [previewIframe, setPreviewIframe] = useState<HTMLIFrameElement | null>(null);
|
||||
const [compositionLoading, setCompositionLoading] = useState(true);
|
||||
@@ -80,7 +90,10 @@ export function StudioApp() {
|
||||
}, []);
|
||||
|
||||
const [timelineVisible, setTimelineVisible] = useState(
|
||||
() => readStudioUiPreferences().timelineVisible ?? true,
|
||||
() =>
|
||||
initialUrlStateRef.current.timelineVisible ??
|
||||
readStudioUiPreferences().timelineVisible ??
|
||||
true,
|
||||
);
|
||||
const toggleTimelineVisibility = useCallback(() => {
|
||||
setTimelineVisible((v) => {
|
||||
@@ -89,7 +102,10 @@ export function StudioApp() {
|
||||
});
|
||||
}, []);
|
||||
const { appToast, showToast } = useToast();
|
||||
const panelLayout = usePanelLayout();
|
||||
const panelLayout = usePanelLayout({
|
||||
rightCollapsed: initialUrlStateRef.current.rightCollapsed,
|
||||
rightPanelTab: initialUrlStateRef.current.rightPanelTab,
|
||||
});
|
||||
const editHistory = usePersistentEditHistory({ projectId });
|
||||
const domEditSaveTimestampRef = useRef(0);
|
||||
const reloadPreview = useCallback(() => {
|
||||
@@ -108,6 +124,18 @@ export function StudioApp() {
|
||||
setRefreshKey,
|
||||
});
|
||||
|
||||
useEffect(() => {
|
||||
if (activeCompPathHydrated) return;
|
||||
if (!fileManager.fileTreeLoaded) return;
|
||||
|
||||
const nextCompPath = normalizeStudioCompositionPath(
|
||||
initialUrlStateRef.current.activeCompPath,
|
||||
fileManager.fileTree,
|
||||
);
|
||||
setActiveCompPath((current) => (current === nextCompPath ? current : nextCompPath));
|
||||
setActiveCompPathHydrated(true);
|
||||
}, [activeCompPathHydrated, fileManager.fileTree, fileManager.fileTreeLoaded]);
|
||||
|
||||
const manifestPersistence = useManifestPersistence({
|
||||
projectId,
|
||||
showToast,
|
||||
@@ -284,6 +312,25 @@ export function StudioApp() {
|
||||
const inspectorButtonActive =
|
||||
STUDIO_INSPECTOR_PANELS_ENABLED && !panelLayout.rightCollapsed && inspectorPanelActive;
|
||||
|
||||
useStudioUrlState({
|
||||
projectId,
|
||||
activeCompPath,
|
||||
currentTime,
|
||||
duration: effectiveTimelineDuration,
|
||||
isPlaying,
|
||||
compositionLoading,
|
||||
refreshKey,
|
||||
previewIframeRef,
|
||||
rightPanelTab: panelLayout.rightPanelTab,
|
||||
rightCollapsed: panelLayout.rightCollapsed,
|
||||
timelineVisible,
|
||||
activeCompPathHydrated,
|
||||
domEditSelection: domEditSession.domEditSelection,
|
||||
buildDomSelectionFromTarget: domEditSession.buildDomSelectionFromTarget,
|
||||
applyDomSelection: domEditSession.applyDomSelection,
|
||||
initialState: initialUrlStateRef.current,
|
||||
});
|
||||
|
||||
// StudioProvider performs its own useMemo — no need for a second memo here.
|
||||
const studioCtxValue: StudioContextValue = {
|
||||
projectId: projectId!,
|
||||
@@ -420,30 +467,7 @@ export function StudioApp() {
|
||||
/>
|
||||
)}
|
||||
|
||||
{globalDragOver && (
|
||||
<div className="absolute inset-0 z-[90] flex items-center justify-center bg-black/50 backdrop-blur-sm pointer-events-none">
|
||||
<div className="flex flex-col items-center gap-3 px-8 py-6 rounded-xl border-2 border-dashed border-studio-accent/60 bg-studio-accent/[0.06]">
|
||||
<svg
|
||||
width="32"
|
||||
height="32"
|
||||
viewBox="0 0 24 24"
|
||||
fill="none"
|
||||
stroke="currentColor"
|
||||
strokeWidth="1.5"
|
||||
strokeLinecap="round"
|
||||
strokeLinejoin="round"
|
||||
className="text-studio-accent"
|
||||
>
|
||||
<path d="M21 15v4a2 2 0 0 1-2 2H5a2 2 0 0 1-2-2v-4" />
|
||||
<polyline points="7 10 12 15 17 10" />
|
||||
<line x1="12" y1="15" x2="12" y2="3" />
|
||||
</svg>
|
||||
<span className="text-sm font-medium text-studio-accent">
|
||||
Drop files to import into project
|
||||
</span>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
{globalDragOver && <StudioGlobalDragOverlay />}
|
||||
|
||||
{appToast && (
|
||||
<div
|
||||
|
||||
@@ -0,0 +1,26 @@
|
||||
export function StudioGlobalDragOverlay() {
|
||||
return (
|
||||
<div className="absolute inset-0 z-[90] flex items-center justify-center bg-black/50 backdrop-blur-sm pointer-events-none">
|
||||
<div className="flex flex-col items-center gap-3 px-8 py-6 rounded-xl border-2 border-dashed border-studio-accent/60 bg-studio-accent/[0.06]">
|
||||
<svg
|
||||
width="32"
|
||||
height="32"
|
||||
viewBox="0 0 24 24"
|
||||
fill="none"
|
||||
stroke="currentColor"
|
||||
strokeWidth="1.5"
|
||||
strokeLinecap="round"
|
||||
strokeLinejoin="round"
|
||||
className="text-studio-accent"
|
||||
>
|
||||
<path d="M21 15v4a2 2 0 0 1-2 2H5a2 2 0 0 1-2-2v-4" />
|
||||
<polyline points="7 10 12 15 17 10" />
|
||||
<line x1="12" y1="15" x2="12" y2="3" />
|
||||
</svg>
|
||||
<span className="text-sm font-medium text-studio-accent">
|
||||
Drop files to import into project
|
||||
</span>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -44,6 +44,7 @@ export function DomEditProvider({
|
||||
handleBlockedDomMove,
|
||||
handleDomManualDragStart,
|
||||
handleDomEditElementDelete,
|
||||
buildDomSelectionFromTarget,
|
||||
buildDomSelectionForTimelineElement,
|
||||
updateDomEditHoverSelection,
|
||||
resolveImportedFontAsset,
|
||||
@@ -89,6 +90,7 @@ export function DomEditProvider({
|
||||
handleBlockedDomMove,
|
||||
handleDomManualDragStart,
|
||||
handleDomEditElementDelete,
|
||||
buildDomSelectionFromTarget,
|
||||
buildDomSelectionForTimelineElement,
|
||||
updateDomEditHoverSelection,
|
||||
resolveImportedFontAsset,
|
||||
@@ -128,6 +130,7 @@ export function DomEditProvider({
|
||||
handleBlockedDomMove,
|
||||
handleDomManualDragStart,
|
||||
handleDomEditElementDelete,
|
||||
buildDomSelectionFromTarget,
|
||||
buildDomSelectionForTimelineElement,
|
||||
updateDomEditHoverSelection,
|
||||
resolveImportedFontAsset,
|
||||
|
||||
@@ -17,6 +17,7 @@ export function FileManagerProvider({
|
||||
setEditingFile,
|
||||
projectDir,
|
||||
fileTree,
|
||||
fileTreeLoaded,
|
||||
setFileTree,
|
||||
editingPathRef,
|
||||
projectIdRef,
|
||||
@@ -52,6 +53,7 @@ export function FileManagerProvider({
|
||||
setEditingFile,
|
||||
projectDir,
|
||||
fileTree,
|
||||
fileTreeLoaded,
|
||||
setFileTree,
|
||||
editingPathRef,
|
||||
projectIdRef,
|
||||
@@ -81,6 +83,7 @@ export function FileManagerProvider({
|
||||
setEditingFile,
|
||||
projectDir,
|
||||
fileTree,
|
||||
fileTreeLoaded,
|
||||
setFileTree,
|
||||
editingPathRef,
|
||||
projectIdRef,
|
||||
|
||||
@@ -333,6 +333,7 @@ export function useDomEditSession({
|
||||
handleBlockedDomMove,
|
||||
handleDomManualDragStart,
|
||||
handleDomEditElementDelete,
|
||||
buildDomSelectionFromTarget,
|
||||
buildDomSelectionForTimelineElement,
|
||||
updateDomEditHoverSelection,
|
||||
resolveImportedFontAsset,
|
||||
|
||||
@@ -36,6 +36,7 @@ export function useFileManager({
|
||||
const [editingFile, setEditingFile] = useState<EditingFile | null>(null);
|
||||
const [projectDir, setProjectDir] = useState<string | null>(null);
|
||||
const [fileTree, setFileTree] = useState<string[]>([]);
|
||||
const [fileTreeLoaded, setFileTreeLoaded] = useState(false);
|
||||
|
||||
// ── Refs ──
|
||||
|
||||
@@ -53,8 +54,12 @@ export function useFileManager({
|
||||
|
||||
// eslint-disable-next-line no-restricted-syntax
|
||||
useEffect(() => {
|
||||
if (!projectId) return;
|
||||
if (!projectId) {
|
||||
setFileTreeLoaded(false);
|
||||
return;
|
||||
}
|
||||
let cancelled = false;
|
||||
setFileTreeLoaded(false);
|
||||
fetch(`/api/projects/${projectId}`)
|
||||
.then((r) => r.json())
|
||||
.then((data: { files?: string[]; dir?: string }) => {
|
||||
@@ -63,6 +68,9 @@ export function useFileManager({
|
||||
})
|
||||
.catch(() => {
|
||||
if (!cancelled) setProjectDir(null);
|
||||
})
|
||||
.finally(() => {
|
||||
if (!cancelled) setFileTreeLoaded(true);
|
||||
});
|
||||
return () => {
|
||||
cancelled = true;
|
||||
@@ -396,6 +404,7 @@ export function useFileManager({
|
||||
setEditingFile,
|
||||
projectDir,
|
||||
fileTree,
|
||||
fileTreeLoaded,
|
||||
setFileTree,
|
||||
|
||||
// Refs
|
||||
|
||||
@@ -2,14 +2,21 @@ import { useState, useCallback, useRef } from "react";
|
||||
import type { RightPanelTab } from "../utils/studioHelpers";
|
||||
import { readStudioUiPreferences, writeStudioUiPreferences } from "../utils/studioUiPreferences";
|
||||
|
||||
export function usePanelLayout() {
|
||||
export interface InitialPanelLayoutState {
|
||||
rightCollapsed?: boolean | null;
|
||||
rightPanelTab?: RightPanelTab | null;
|
||||
}
|
||||
|
||||
export function usePanelLayout(initialState?: InitialPanelLayoutState) {
|
||||
const [leftWidth, setLeftWidth] = useState(240);
|
||||
const [rightWidth, setRightWidth] = useState(400);
|
||||
const [leftCollapsed, setLeftCollapsed] = useState(
|
||||
() => readStudioUiPreferences().leftCollapsed ?? false,
|
||||
);
|
||||
const [rightCollapsed, setRightCollapsed] = useState(true);
|
||||
const [rightPanelTab, setRightPanelTab] = useState<RightPanelTab>("renders");
|
||||
const [rightCollapsed, setRightCollapsed] = useState(initialState?.rightCollapsed ?? true);
|
||||
const [rightPanelTab, setRightPanelTab] = useState<RightPanelTab>(
|
||||
initialState?.rightPanelTab ?? "renders",
|
||||
);
|
||||
const panelDragRef = useRef<{
|
||||
side: "left" | "right";
|
||||
startX: number;
|
||||
|
||||
@@ -0,0 +1,188 @@
|
||||
import { useCallback, useEffect, useRef } from "react";
|
||||
import { usePlayerStore } from "../player";
|
||||
import { findElementForSelection, type DomEditSelection } from "../components/editor/domEditing";
|
||||
import { clampNumber, type RightPanelTab } from "../utils/studioHelpers";
|
||||
import {
|
||||
buildStudioHash,
|
||||
type StudioUrlSelectionState,
|
||||
type StudioUrlState,
|
||||
} from "../utils/studioUrlState";
|
||||
|
||||
interface UseStudioUrlStateParams {
|
||||
projectId: string | null;
|
||||
activeCompPath: string | null;
|
||||
currentTime: number;
|
||||
duration: number;
|
||||
isPlaying: boolean;
|
||||
compositionLoading: boolean;
|
||||
refreshKey: number;
|
||||
previewIframeRef: React.MutableRefObject<HTMLIFrameElement | null>;
|
||||
rightPanelTab: RightPanelTab;
|
||||
rightCollapsed: boolean;
|
||||
timelineVisible: boolean;
|
||||
activeCompPathHydrated: boolean;
|
||||
domEditSelection: DomEditSelection | null;
|
||||
buildDomSelectionFromTarget: (
|
||||
target: HTMLElement,
|
||||
options?: { preferClipAncestor?: boolean },
|
||||
) => DomEditSelection | null;
|
||||
applyDomSelection: (
|
||||
selection: DomEditSelection | null,
|
||||
options?: {
|
||||
revealPanel?: boolean;
|
||||
additive?: boolean;
|
||||
preserveGroup?: boolean;
|
||||
},
|
||||
) => void;
|
||||
initialState: StudioUrlState;
|
||||
}
|
||||
|
||||
function toPersistedSelection(selection: DomEditSelection | null): StudioUrlSelectionState | null {
|
||||
if (!selection) return null;
|
||||
if (!selection.id && !selection.selector) return null;
|
||||
return {
|
||||
sourceFile: selection.sourceFile || undefined,
|
||||
id: selection.id || undefined,
|
||||
selector: selection.selector || undefined,
|
||||
selectorIndex: selection.selectorIndex ?? undefined,
|
||||
};
|
||||
}
|
||||
|
||||
function replaceHash(nextHash: string) {
|
||||
if (typeof window === "undefined") return;
|
||||
if (window.location.hash === nextHash) return;
|
||||
window.history.replaceState(null, "", nextHash);
|
||||
}
|
||||
|
||||
export function useStudioUrlState({
|
||||
projectId,
|
||||
activeCompPath,
|
||||
currentTime,
|
||||
duration,
|
||||
isPlaying,
|
||||
compositionLoading,
|
||||
refreshKey,
|
||||
previewIframeRef,
|
||||
rightPanelTab,
|
||||
rightCollapsed,
|
||||
timelineVisible,
|
||||
activeCompPathHydrated,
|
||||
domEditSelection,
|
||||
buildDomSelectionFromTarget,
|
||||
applyDomSelection,
|
||||
initialState,
|
||||
}: UseStudioUrlStateParams) {
|
||||
const hydratedSeekRef = useRef(initialState.currentTime == null);
|
||||
const hydratedInitialTimeRef = useRef(initialState.currentTime == null);
|
||||
const hydratedSelectionRef = useRef(initialState.selection == null);
|
||||
const pendingSelectionRef = useRef(initialState.selection);
|
||||
const stableTimeRef = useRef<number | null>(initialState.currentTime);
|
||||
|
||||
const buildUrlState = useCallback(
|
||||
(): StudioUrlState => ({
|
||||
activeCompPath,
|
||||
currentTime: stableTimeRef.current,
|
||||
rightPanelTab,
|
||||
rightCollapsed,
|
||||
timelineVisible,
|
||||
selection: hydratedSelectionRef.current
|
||||
? toPersistedSelection(domEditSelection)
|
||||
: pendingSelectionRef.current,
|
||||
}),
|
||||
[activeCompPath, domEditSelection, rightCollapsed, rightPanelTab, timelineVisible],
|
||||
);
|
||||
|
||||
useEffect(() => {
|
||||
if (!projectId || hydratedSeekRef.current || compositionLoading) return;
|
||||
const nextTime =
|
||||
duration > 0
|
||||
? clampNumber(initialState.currentTime ?? 0, 0, duration)
|
||||
: Math.max(0, initialState.currentTime ?? 0);
|
||||
usePlayerStore.getState().requestSeek(nextTime);
|
||||
stableTimeRef.current = nextTime;
|
||||
hydratedSeekRef.current = true;
|
||||
}, [projectId, compositionLoading, duration, initialState.currentTime]);
|
||||
|
||||
useEffect(() => {
|
||||
if (!projectId || hydratedSelectionRef.current || compositionLoading) return;
|
||||
if (!hydratedSeekRef.current) return;
|
||||
const targetTime = initialState.currentTime;
|
||||
if (targetTime != null && Math.abs(currentTime - stableTimeRef.current!) > 0.05) return;
|
||||
|
||||
const pendingSelection = pendingSelectionRef.current;
|
||||
if (!pendingSelection) {
|
||||
hydratedSelectionRef.current = true;
|
||||
return;
|
||||
}
|
||||
|
||||
let doc: Document | null = null;
|
||||
try {
|
||||
doc = previewIframeRef.current?.contentDocument ?? null;
|
||||
} catch {
|
||||
return;
|
||||
}
|
||||
if (!doc) return;
|
||||
|
||||
const element = findElementForSelection(
|
||||
doc,
|
||||
{
|
||||
sourceFile: pendingSelection.sourceFile ?? "",
|
||||
id: pendingSelection.id,
|
||||
selector: pendingSelection.selector,
|
||||
selectorIndex: pendingSelection.selectorIndex,
|
||||
},
|
||||
activeCompPath,
|
||||
);
|
||||
if (!element) {
|
||||
applyDomSelection(null, { revealPanel: false });
|
||||
hydratedSelectionRef.current = true;
|
||||
pendingSelectionRef.current = null;
|
||||
return;
|
||||
}
|
||||
|
||||
const selection = buildDomSelectionFromTarget(element, { preferClipAncestor: false });
|
||||
applyDomSelection(selection, { revealPanel: false });
|
||||
hydratedSelectionRef.current = true;
|
||||
pendingSelectionRef.current = null;
|
||||
}, [
|
||||
activeCompPath,
|
||||
applyDomSelection,
|
||||
buildDomSelectionFromTarget,
|
||||
compositionLoading,
|
||||
currentTime,
|
||||
initialState.currentTime,
|
||||
previewIframeRef,
|
||||
projectId,
|
||||
refreshKey,
|
||||
]);
|
||||
|
||||
useEffect(() => {
|
||||
if (hydratedInitialTimeRef.current) return;
|
||||
const targetTime = stableTimeRef.current;
|
||||
if (targetTime == null) {
|
||||
hydratedInitialTimeRef.current = true;
|
||||
return;
|
||||
}
|
||||
if (Math.abs(currentTime - targetTime) > 0.05) return;
|
||||
hydratedInitialTimeRef.current = true;
|
||||
}, [currentTime]);
|
||||
|
||||
useEffect(() => {
|
||||
if (!activeCompPathHydrated) return;
|
||||
if (!hydratedSeekRef.current) return;
|
||||
if (!hydratedInitialTimeRef.current) return;
|
||||
if (!projectId || isPlaying) return;
|
||||
const handle = window.setTimeout(() => {
|
||||
stableTimeRef.current = clampNumber(currentTime, 0, Math.max(0, duration));
|
||||
replaceHash(buildStudioHash(projectId, buildUrlState()));
|
||||
}, 200);
|
||||
|
||||
return () => window.clearTimeout(handle);
|
||||
}, [activeCompPathHydrated, buildUrlState, currentTime, duration, isPlaying, projectId]);
|
||||
|
||||
useEffect(() => {
|
||||
if (!activeCompPathHydrated) return;
|
||||
if (!projectId) return;
|
||||
replaceHash(buildStudioHash(projectId, buildUrlState()));
|
||||
}, [activeCompPathHydrated, buildUrlState, projectId]);
|
||||
}
|
||||
@@ -0,0 +1,100 @@
|
||||
// @vitest-environment happy-dom
|
||||
|
||||
import React, { act, useEffect } from "react";
|
||||
import { createRoot } from "react-dom/client";
|
||||
import { afterEach, describe, expect, it } from "vitest";
|
||||
import { useTimelinePlayer } from "./useTimelinePlayer";
|
||||
import { liveTime, usePlayerStore } from "../store/playerStore";
|
||||
|
||||
globalThis.IS_REACT_ACT_ENVIRONMENT = true;
|
||||
|
||||
function resetPlayerStore() {
|
||||
usePlayerStore.getState().reset();
|
||||
usePlayerStore.setState({ requestedSeekTime: null });
|
||||
}
|
||||
|
||||
function TimelinePlayerHarness({
|
||||
onValue,
|
||||
}: {
|
||||
onValue: (value: ReturnType<typeof useTimelinePlayer>) => void;
|
||||
}) {
|
||||
const value = useTimelinePlayer();
|
||||
useEffect(() => {
|
||||
onValue(value);
|
||||
}, [onValue, value]);
|
||||
return null;
|
||||
}
|
||||
|
||||
afterEach(() => {
|
||||
document.body.innerHTML = "";
|
||||
resetPlayerStore();
|
||||
});
|
||||
|
||||
describe("useTimelinePlayer seek hydration", () => {
|
||||
it("keeps an external seek request until the iframe adapter is ready", () => {
|
||||
let api: ReturnType<typeof useTimelinePlayer> | null = null;
|
||||
const observedTimes: number[] = [];
|
||||
const unsubscribe = liveTime.subscribe((time) => {
|
||||
observedTimes.push(time);
|
||||
});
|
||||
const host = document.createElement("div");
|
||||
document.body.append(host);
|
||||
const root = createRoot(host);
|
||||
|
||||
act(() => {
|
||||
root.render(
|
||||
React.createElement(TimelinePlayerHarness, { onValue: (value) => (api = value) }),
|
||||
);
|
||||
});
|
||||
|
||||
act(() => {
|
||||
usePlayerStore.getState().requestSeek(4.2);
|
||||
});
|
||||
|
||||
expect(api).not.toBeNull();
|
||||
expect(usePlayerStore.getState().currentTime).toBe(0);
|
||||
expect(usePlayerStore.getState().requestedSeekTime).toBeNull();
|
||||
|
||||
const iframe = document.createElement("iframe");
|
||||
let currentTime = 0;
|
||||
const adapter = {
|
||||
play: () => {},
|
||||
pause: () => {},
|
||||
seek: (time: number) => {
|
||||
currentTime = time;
|
||||
},
|
||||
getTime: () => currentTime,
|
||||
getDuration: () => 30,
|
||||
isPlaying: () => false,
|
||||
};
|
||||
Object.defineProperty(iframe, "contentWindow", {
|
||||
value: {
|
||||
__player: adapter,
|
||||
postMessage: () => {},
|
||||
scrollTo: () => {},
|
||||
addEventListener: () => {},
|
||||
removeEventListener: () => {},
|
||||
},
|
||||
configurable: true,
|
||||
});
|
||||
Object.defineProperty(iframe, "contentDocument", {
|
||||
value: document.implementation.createHTMLDocument("preview"),
|
||||
configurable: true,
|
||||
});
|
||||
|
||||
act(() => {
|
||||
api!.iframeRef.current = iframe;
|
||||
api!.onIframeLoad();
|
||||
});
|
||||
|
||||
expect(currentTime).toBe(4.2);
|
||||
expect(usePlayerStore.getState().currentTime).toBe(4.2);
|
||||
expect(usePlayerStore.getState().timelineReady).toBe(true);
|
||||
expect(observedTimes).toContain(4.2);
|
||||
|
||||
act(() => {
|
||||
root.unmount();
|
||||
});
|
||||
unsubscribe();
|
||||
});
|
||||
});
|
||||
@@ -324,7 +324,10 @@ export function useTimelinePlayer() {
|
||||
(time: number) => {
|
||||
stopReverseLoop();
|
||||
const adapter = getAdapter();
|
||||
if (!adapter) return;
|
||||
if (!adapter) {
|
||||
pendingSeekRef.current = Math.max(0, time);
|
||||
return false;
|
||||
}
|
||||
const duration = Math.max(0, adapter.getDuration());
|
||||
const nextTime = Math.max(0, duration > 0 ? Math.min(duration, time) : time);
|
||||
adapter.seek(nextTime);
|
||||
@@ -334,8 +337,9 @@ export function useTimelinePlayer() {
|
||||
if (usePlayerStore.getState().isPlaying) setIsPlaying(false);
|
||||
shuttleDirectionRef.current = null;
|
||||
shuttleSpeedIndexRef.current = 0;
|
||||
return true;
|
||||
},
|
||||
[getAdapter, setCurrentTime, setIsPlaying, stopRAFLoop, stopReverseLoop],
|
||||
[getAdapter, pendingSeekRef, setCurrentTime, setIsPlaying, stopRAFLoop, stopReverseLoop],
|
||||
);
|
||||
|
||||
// Handle seek requests from outside the player loop (e.g. LayersPanel).
|
||||
|
||||
@@ -9,7 +9,7 @@
|
||||
*/
|
||||
|
||||
import { useCallback } from "react";
|
||||
import { usePlayerStore } from "../store/playerStore";
|
||||
import { liveTime, usePlayerStore } from "../store/playerStore";
|
||||
import type { TimelineElement } from "../store/playerStore";
|
||||
import type { PlaybackAdapter, ClipManifestClip, IframeWindow } from "../lib/playbackTypes";
|
||||
import {
|
||||
@@ -158,6 +158,9 @@ export function useTimelineSyncCallbacks({
|
||||
const startTime = seekTo != null ? Math.min(seekTo, adapter.getDuration()) : 0;
|
||||
|
||||
adapter.seek(startTime);
|
||||
// Keep non-React listeners such as the capture link and time display in sync
|
||||
// with the initial adapter seek on iframe load.
|
||||
liveTime.notify(startTime);
|
||||
const adapterDur = adapter.getDuration();
|
||||
if (
|
||||
Number.isFinite(adapterDur) &&
|
||||
|
||||
@@ -4,6 +4,7 @@ import {
|
||||
buildProjectApiPath,
|
||||
buildProjectHash,
|
||||
encodeProjectId,
|
||||
parseProjectHashRoute,
|
||||
parseProjectIdFromHash,
|
||||
} from "./projectRouting";
|
||||
|
||||
@@ -61,6 +62,20 @@ describe("project routing utilities", () => {
|
||||
expect(parseProjectIdFromHash(hash)).toBe("Mañana demo");
|
||||
});
|
||||
|
||||
it("parses project hash routes with query params", () => {
|
||||
const route = parseProjectHashRoute("#project/Notion%20Showcase?tab=design&t=4.2");
|
||||
|
||||
expect(route?.projectId).toBe("Notion Showcase");
|
||||
expect(route?.params.get("tab")).toBe("design");
|
||||
expect(route?.params.get("t")).toBe("4.2");
|
||||
});
|
||||
|
||||
it("builds hash routes with query params", () => {
|
||||
expect(buildProjectHash("Notion Showcase", { tab: "design", t: "4.2" })).toBe(
|
||||
"#project/Notion%20Showcase?tab=design&t=4.2",
|
||||
);
|
||||
});
|
||||
|
||||
it("encodes project ids as one API path segment", () => {
|
||||
expect(encodeProjectId("Notion Showcase")).toBe("Notion%20Showcase");
|
||||
expect(encodeProjectId("Notion%20Showcase")).toBe("Notion%2520Showcase");
|
||||
|
||||
@@ -1,24 +1,61 @@
|
||||
const PROJECT_HASH_PREFIX = "#project/";
|
||||
|
||||
export interface ProjectHashRoute {
|
||||
projectId: string;
|
||||
params: URLSearchParams;
|
||||
}
|
||||
|
||||
function decodeHashProjectId(value: string): string {
|
||||
try {
|
||||
return decodeURIComponent(value);
|
||||
} catch {
|
||||
return value;
|
||||
}
|
||||
}
|
||||
|
||||
function normalizeHashParams(
|
||||
params?: URLSearchParams | Record<string, string | null | undefined>,
|
||||
): URLSearchParams {
|
||||
if (!params) return new URLSearchParams();
|
||||
if (params instanceof URLSearchParams) return params;
|
||||
|
||||
const next = new URLSearchParams();
|
||||
for (const [key, value] of Object.entries(params)) {
|
||||
if (!key || value == null || value === "") continue;
|
||||
next.set(key, value);
|
||||
}
|
||||
return next;
|
||||
}
|
||||
|
||||
export function encodeProjectId(projectId: string): string {
|
||||
return encodeURIComponent(projectId);
|
||||
}
|
||||
|
||||
export function buildProjectHash(projectId: string): string {
|
||||
return `${PROJECT_HASH_PREFIX}${encodeProjectId(projectId)}`;
|
||||
export function buildProjectHash(
|
||||
projectId: string,
|
||||
params?: URLSearchParams | Record<string, string | null | undefined>,
|
||||
): string {
|
||||
const search = normalizeHashParams(params).toString();
|
||||
return `${PROJECT_HASH_PREFIX}${encodeProjectId(projectId)}${search ? `?${search}` : ""}`;
|
||||
}
|
||||
|
||||
export function parseProjectHashRoute(hash: string): ProjectHashRoute | null {
|
||||
if (!hash.startsWith(PROJECT_HASH_PREFIX)) return null;
|
||||
|
||||
const route = hash.slice(PROJECT_HASH_PREFIX.length);
|
||||
const queryIndex = route.indexOf("?");
|
||||
const encodedProjectId = queryIndex >= 0 ? route.slice(0, queryIndex) : route;
|
||||
if (!encodedProjectId || encodedProjectId.includes("/")) return null;
|
||||
|
||||
const rawParams = queryIndex >= 0 ? route.slice(queryIndex + 1) : "";
|
||||
return {
|
||||
projectId: decodeHashProjectId(encodedProjectId),
|
||||
params: new URLSearchParams(rawParams),
|
||||
};
|
||||
}
|
||||
|
||||
export function parseProjectIdFromHash(hash: string): string | null {
|
||||
if (!hash.startsWith(PROJECT_HASH_PREFIX)) return null;
|
||||
|
||||
const encodedProjectId = hash.slice(PROJECT_HASH_PREFIX.length);
|
||||
if (!encodedProjectId || encodedProjectId.includes("/")) return null;
|
||||
|
||||
try {
|
||||
return decodeURIComponent(encodedProjectId);
|
||||
} catch {
|
||||
return encodedProjectId;
|
||||
}
|
||||
return parseProjectHashRoute(hash)?.projectId ?? null;
|
||||
}
|
||||
|
||||
export function buildProjectApiPath(projectId: string, suffix = ""): string {
|
||||
|
||||
@@ -0,0 +1,249 @@
|
||||
// @vitest-environment happy-dom
|
||||
|
||||
import React, { act } from "react";
|
||||
import { createRoot } from "react-dom/client";
|
||||
import { afterEach, describe, expect, it, vi } from "vitest";
|
||||
import {
|
||||
buildStudioHash,
|
||||
normalizeStudioCompositionPath,
|
||||
normalizeStudioUrlPanelTab,
|
||||
parseStudioUrlStateFromHash,
|
||||
} from "./studioUrlState";
|
||||
import { useStudioUrlState } from "../hooks/useStudioUrlState";
|
||||
import { usePlayerStore } from "../player";
|
||||
|
||||
globalThis.IS_REACT_ACT_ENVIRONMENT = true;
|
||||
|
||||
function resetPlayerStore() {
|
||||
usePlayerStore.setState({
|
||||
isPlaying: false,
|
||||
currentTime: 0,
|
||||
duration: 0,
|
||||
timelineReady: false,
|
||||
elements: [],
|
||||
selectedElementId: null,
|
||||
requestedSeekTime: null,
|
||||
});
|
||||
}
|
||||
|
||||
afterEach(() => {
|
||||
vi.useRealTimers();
|
||||
document.body.innerHTML = "";
|
||||
window.history.replaceState(null, "", "/");
|
||||
resetPlayerStore();
|
||||
});
|
||||
|
||||
function renderStudioUrlStateHarness(
|
||||
props: Partial<React.ComponentProps<typeof StudioUrlStateHarness>> = {},
|
||||
) {
|
||||
const host = document.createElement("div");
|
||||
document.body.append(host);
|
||||
const root = createRoot(host);
|
||||
const baseProps: React.ComponentProps<typeof StudioUrlStateHarness> = {
|
||||
projectId: "demo",
|
||||
activeCompPath: null,
|
||||
currentTime: 0,
|
||||
duration: 30,
|
||||
isPlaying: false,
|
||||
compositionLoading: false,
|
||||
refreshKey: 0,
|
||||
previewIframeRef: { current: null },
|
||||
rightPanelTab: "renders",
|
||||
rightCollapsed: true,
|
||||
timelineVisible: true,
|
||||
activeCompPathHydrated: true,
|
||||
domEditSelection: null,
|
||||
buildDomSelectionFromTarget: () => null,
|
||||
applyDomSelection: () => {},
|
||||
initialState: {
|
||||
activeCompPath: null,
|
||||
currentTime: 4.2,
|
||||
rightPanelTab: null,
|
||||
rightCollapsed: null,
|
||||
timelineVisible: null,
|
||||
selection: null,
|
||||
},
|
||||
};
|
||||
|
||||
const render = (nextProps: Partial<React.ComponentProps<typeof StudioUrlStateHarness>> = {}) => {
|
||||
act(() => {
|
||||
root.render(
|
||||
React.createElement(StudioUrlStateHarness, {
|
||||
...baseProps,
|
||||
...props,
|
||||
...nextProps,
|
||||
}),
|
||||
);
|
||||
});
|
||||
};
|
||||
|
||||
render();
|
||||
return {
|
||||
rerender: render,
|
||||
unmount: () =>
|
||||
act(() => {
|
||||
root.unmount();
|
||||
}),
|
||||
};
|
||||
}
|
||||
|
||||
function StudioUrlStateHarness(props: Parameters<typeof useStudioUrlState>[0]) {
|
||||
useStudioUrlState(props);
|
||||
return null;
|
||||
}
|
||||
|
||||
describe("studio url state", () => {
|
||||
it("parses persisted studio state from project hash", () => {
|
||||
const state = parseStudioUrlStateFromHash(
|
||||
"#project/demo?v=1&comp=compositions%2Ftitle.html&t=4.25&tab=design&rc=0&tv=1&selFile=index.html&selId=hero",
|
||||
);
|
||||
|
||||
expect(state.activeCompPath).toBe("compositions/title.html");
|
||||
expect(state.currentTime).toBe(4.25);
|
||||
expect(state.rightPanelTab).toBe("design");
|
||||
expect(state.rightCollapsed).toBe(false);
|
||||
expect(state.timelineVisible).toBe(true);
|
||||
expect(state.selection).toEqual({
|
||||
sourceFile: "index.html",
|
||||
id: "hero",
|
||||
selector: undefined,
|
||||
selectorIndex: undefined,
|
||||
});
|
||||
});
|
||||
|
||||
it("builds a project hash with persisted studio state", () => {
|
||||
expect(
|
||||
buildStudioHash("demo", {
|
||||
activeCompPath: "compositions/title.html",
|
||||
currentTime: 4.2571,
|
||||
rightPanelTab: "layers",
|
||||
rightCollapsed: true,
|
||||
timelineVisible: false,
|
||||
selection: {
|
||||
sourceFile: "index.html",
|
||||
selector: ".card",
|
||||
selectorIndex: 2,
|
||||
},
|
||||
}),
|
||||
).toBe(
|
||||
"#project/demo?v=1&comp=compositions%2Ftitle.html&t=4.257&tab=layers&rc=1&tv=0&selFile=index.html&selSelector=.card&selIndex=2",
|
||||
);
|
||||
});
|
||||
|
||||
it("falls back cleanly on invalid values", () => {
|
||||
const state = parseStudioUrlStateFromHash("#project/demo?tab=nope&t=abc&rc=9&tv=7");
|
||||
|
||||
expect(state.activeCompPath).toBeNull();
|
||||
expect(state.currentTime).toBeNull();
|
||||
expect(state.rightPanelTab).toBeNull();
|
||||
expect(state.rightCollapsed).toBeNull();
|
||||
expect(state.timelineVisible).toBeNull();
|
||||
expect(state.selection).toBeNull();
|
||||
});
|
||||
|
||||
it("normalizes stale composition paths to the master composition", () => {
|
||||
expect(
|
||||
normalizeStudioCompositionPath("compositions/missing.html", [
|
||||
"index.html",
|
||||
"compositions/title.html",
|
||||
]),
|
||||
).toBeNull();
|
||||
expect(
|
||||
normalizeStudioCompositionPath("compositions/title.html", [
|
||||
"index.html",
|
||||
"compositions/title.html",
|
||||
]),
|
||||
).toBe("compositions/title.html");
|
||||
});
|
||||
|
||||
it("normalizes url tabs against feature flags", () => {
|
||||
expect(normalizeStudioUrlPanelTab("renders")).toBe("renders");
|
||||
expect(normalizeStudioUrlPanelTab("layers", { inspectorPanelsEnabled: false })).toBe("renders");
|
||||
expect(normalizeStudioUrlPanelTab("motion", { motionPanelEnabled: false })).toBe("design");
|
||||
});
|
||||
|
||||
it("hydrates seek first, preserves the initial url state, then restores selection", () => {
|
||||
vi.useFakeTimers();
|
||||
window.history.replaceState(null, "", "#project/demo?t=4.2&tab=design&selId=hero");
|
||||
const requestSeek = vi.fn();
|
||||
usePlayerStore.setState({ requestSeek });
|
||||
const selectedElement = document.createElement("div");
|
||||
selectedElement.id = "hero";
|
||||
document.body.append(selectedElement);
|
||||
const previewDoc = document.implementation.createHTMLDocument("preview");
|
||||
previewDoc.body.append(selectedElement);
|
||||
const applyDomSelection = vi.fn();
|
||||
const restoredSelection = {
|
||||
element: selectedElement,
|
||||
id: "hero",
|
||||
selector: "#hero",
|
||||
selectorIndex: 0,
|
||||
sourceFile: "index.html",
|
||||
tagName: "div",
|
||||
label: "Hero",
|
||||
textContent: "",
|
||||
textFields: [],
|
||||
capabilities: {
|
||||
canEditText: false,
|
||||
canEditLayout: true,
|
||||
canApplyManualOffset: true,
|
||||
canApplyManualSize: true,
|
||||
canApplyManualRotation: true,
|
||||
canAdjustOpacity: true,
|
||||
canAdjustFill: true,
|
||||
canAdjustBorderRadius: true,
|
||||
canAdjustStroke: true,
|
||||
canAdjustShadow: true,
|
||||
canAdjustZIndex: true,
|
||||
},
|
||||
computedStyle: {
|
||||
display: "block",
|
||||
position: "absolute",
|
||||
},
|
||||
};
|
||||
|
||||
const harness = renderStudioUrlStateHarness({
|
||||
previewIframeRef: {
|
||||
current: { contentDocument: previewDoc } as HTMLIFrameElement,
|
||||
},
|
||||
rightPanelTab: "design",
|
||||
rightCollapsed: false,
|
||||
applyDomSelection,
|
||||
buildDomSelectionFromTarget: () => restoredSelection,
|
||||
initialState: {
|
||||
activeCompPath: null,
|
||||
currentTime: 4.2,
|
||||
rightPanelTab: "design",
|
||||
rightCollapsed: false,
|
||||
timelineVisible: true,
|
||||
selection: { id: "hero" },
|
||||
},
|
||||
});
|
||||
|
||||
expect(requestSeek).toHaveBeenCalledWith(4.2);
|
||||
expect(applyDomSelection).not.toHaveBeenCalled();
|
||||
expect(window.location.hash).toContain("t=4.2");
|
||||
expect(window.location.hash).toContain("tab=design");
|
||||
|
||||
act(() => {
|
||||
vi.advanceTimersByTime(250);
|
||||
});
|
||||
expect(window.location.hash).toContain("t=4.2");
|
||||
expect(applyDomSelection).not.toHaveBeenCalled();
|
||||
|
||||
harness.rerender({ currentTime: 4.2 });
|
||||
act(() => {
|
||||
vi.advanceTimersByTime(250);
|
||||
});
|
||||
expect(applyDomSelection).toHaveBeenCalledWith(restoredSelection, { revealPanel: false });
|
||||
|
||||
harness.rerender({ currentTime: 4.2, domEditSelection: restoredSelection });
|
||||
act(() => {
|
||||
vi.advanceTimersByTime(250);
|
||||
});
|
||||
expect(window.location.hash).toContain("t=4.2");
|
||||
expect(window.location.hash).toContain("selId=hero");
|
||||
|
||||
harness.unmount();
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,135 @@
|
||||
import type { RightPanelTab } from "./studioHelpers";
|
||||
import { buildProjectHash, parseProjectHashRoute } from "./projectRouting";
|
||||
import {
|
||||
STUDIO_INSPECTOR_PANELS_ENABLED,
|
||||
STUDIO_MOTION_PANEL_ENABLED,
|
||||
} from "../components/editor/manualEditingAvailability";
|
||||
|
||||
export interface StudioUrlSelectionState {
|
||||
sourceFile?: string;
|
||||
id?: string;
|
||||
selector?: string;
|
||||
selectorIndex?: number;
|
||||
}
|
||||
|
||||
export interface StudioUrlState {
|
||||
activeCompPath: string | null;
|
||||
currentTime: number | null;
|
||||
rightPanelTab: RightPanelTab | null;
|
||||
rightCollapsed: boolean | null;
|
||||
timelineVisible: boolean | null;
|
||||
selection: StudioUrlSelectionState | null;
|
||||
}
|
||||
|
||||
const VALID_TABS: RightPanelTab[] = ["layers", "design", "motion", "renders"];
|
||||
|
||||
export function normalizeStudioUrlPanelTab(
|
||||
tab: RightPanelTab | null,
|
||||
options: {
|
||||
inspectorPanelsEnabled?: boolean;
|
||||
motionPanelEnabled?: boolean;
|
||||
} = {},
|
||||
): RightPanelTab | null {
|
||||
if (!tab) return null;
|
||||
if (!VALID_TABS.includes(tab)) return null;
|
||||
const inspectorPanelsEnabled = options.inspectorPanelsEnabled ?? STUDIO_INSPECTOR_PANELS_ENABLED;
|
||||
const motionPanelEnabled = options.motionPanelEnabled ?? STUDIO_MOTION_PANEL_ENABLED;
|
||||
|
||||
if (!inspectorPanelsEnabled && tab !== "renders") return "renders";
|
||||
if (tab === "motion" && !motionPanelEnabled) return "design";
|
||||
return tab;
|
||||
}
|
||||
|
||||
export function normalizeStudioCompositionPath(
|
||||
activeCompPath: string | null,
|
||||
fileTree: string[],
|
||||
): string | null {
|
||||
if (!activeCompPath || activeCompPath === "index.html") return null;
|
||||
return fileTree.includes(activeCompPath) ? activeCompPath : null;
|
||||
}
|
||||
|
||||
function parseBoolean(value: string | null): boolean | null {
|
||||
if (value === "1") return true;
|
||||
if (value === "0") return false;
|
||||
return null;
|
||||
}
|
||||
|
||||
function parseNumber(value: string | null): number | null {
|
||||
if (value == null || value === "") return null;
|
||||
const parsed = Number(value);
|
||||
return Number.isFinite(parsed) ? parsed : null;
|
||||
}
|
||||
|
||||
function parseTab(value: string | null): RightPanelTab | null {
|
||||
return VALID_TABS.includes(value as RightPanelTab) ? (value as RightPanelTab) : null;
|
||||
}
|
||||
|
||||
function normalizeSelection(params: URLSearchParams): StudioUrlSelectionState | null {
|
||||
const sourceFile = params.get("selFile") || undefined;
|
||||
const id = params.get("selId") || undefined;
|
||||
const selector = params.get("selSelector") || undefined;
|
||||
const selectorIndex = parseNumber(params.get("selIndex"));
|
||||
|
||||
if (!sourceFile && !id && !selector) return null;
|
||||
|
||||
return {
|
||||
sourceFile,
|
||||
id,
|
||||
selector,
|
||||
selectorIndex: selectorIndex != null ? Math.max(0, Math.floor(selectorIndex)) : undefined,
|
||||
};
|
||||
}
|
||||
|
||||
export function defaultStudioUrlState(): StudioUrlState {
|
||||
return {
|
||||
activeCompPath: null,
|
||||
currentTime: null,
|
||||
rightPanelTab: null,
|
||||
rightCollapsed: null,
|
||||
timelineVisible: null,
|
||||
selection: null,
|
||||
};
|
||||
}
|
||||
|
||||
export function parseStudioUrlStateFromHash(hash: string): StudioUrlState {
|
||||
const route = parseProjectHashRoute(hash);
|
||||
if (!route) return defaultStudioUrlState();
|
||||
|
||||
const { params } = route;
|
||||
return {
|
||||
activeCompPath: params.get("comp") || null,
|
||||
currentTime: parseNumber(params.get("t")),
|
||||
rightPanelTab: normalizeStudioUrlPanelTab(parseTab(params.get("tab"))),
|
||||
rightCollapsed: parseBoolean(params.get("rc")),
|
||||
timelineVisible: parseBoolean(params.get("tv")),
|
||||
selection: normalizeSelection(params),
|
||||
};
|
||||
}
|
||||
|
||||
export function readStudioUrlStateFromWindow(): StudioUrlState {
|
||||
if (typeof window === "undefined") return defaultStudioUrlState();
|
||||
return parseStudioUrlStateFromHash(window.location.hash);
|
||||
}
|
||||
|
||||
export function buildStudioHash(projectId: string, state: StudioUrlState): string {
|
||||
const params = new URLSearchParams();
|
||||
|
||||
params.set("v", "1");
|
||||
if (state.activeCompPath) params.set("comp", state.activeCompPath);
|
||||
if (state.currentTime != null && Number.isFinite(state.currentTime)) {
|
||||
params.set("t", String(Math.max(0, Math.round(state.currentTime * 1000) / 1000)));
|
||||
}
|
||||
if (state.rightPanelTab) params.set("tab", state.rightPanelTab);
|
||||
if (state.rightCollapsed != null) params.set("rc", state.rightCollapsed ? "1" : "0");
|
||||
if (state.timelineVisible != null) params.set("tv", state.timelineVisible ? "1" : "0");
|
||||
if (state.selection) {
|
||||
if (state.selection.sourceFile) params.set("selFile", state.selection.sourceFile);
|
||||
if (state.selection.id) params.set("selId", state.selection.id);
|
||||
if (state.selection.selector) params.set("selSelector", state.selection.selector);
|
||||
if (typeof state.selection.selectorIndex === "number") {
|
||||
params.set("selIndex", String(Math.max(0, Math.floor(state.selection.selectorIndex))));
|
||||
}
|
||||
}
|
||||
|
||||
return buildProjectHash(projectId, params);
|
||||
}
|
||||
Reference in New Issue
Block a user