fix(studio): publish desktop compatibility exports

Expose range selection and the canonical loader from the built Studio package while retaining timeline editing seams.
This commit is contained in:
Miguel Angel Simon Sierra
2026-09-19 22:16:44 -04:00
parent 2d18150b22
commit dd4add54be
12 changed files with 147 additions and 48 deletions
+1 -1
View File
@@ -21,7 +21,7 @@
},
"./styles.css": {
"source": "./src/styles/studio.css",
"runtime": "./dist/styles.css",
"runtime": "./dist/assets/styles.css",
"types": null,
"environments": ["browser"]
},
+2 -2
View File
@@ -1,6 +1,6 @@
{
"name": "@hyperframes/studio",
"version": "0.8.52",
"version": "0.8.53-desktop.0",
"description": "",
"repository": {
"type": "git",
@@ -42,7 +42,7 @@
"types": "./dist/styles/tailwind-preset.d.ts"
},
"./theme.css": "./src/styles/theme.css",
"./styles.css": "./dist/styles.css",
"./styles.css": "./dist/assets/styles.css",
"./package.json": "./package.json"
},
"main": "./dist/index.js",
@@ -1,6 +1,6 @@
import { useCallback, type ReactNode } from "react";
import { Timeline } from "../../player";
import type { TimelineElement } from "../../player";
import type { TimelineElement, TimelineTimeRange } from "../../player";
import type { BlockedTimelineEditIntent } from "../../player/components/timelineEditing";
import { useTimelineEditContext } from "../../contexts/TimelineEditContext";
import { trackStudioExpandedClipEdit } from "../../telemetry/events";
@@ -104,6 +104,7 @@ export interface TimelinePaneProps {
) => Promise<void> | void;
onBlockedEditAttempt?: (element: TimelineElement, intent: BlockedTimelineEditIntent) => void;
onSelectTimelineElement?: (element: TimelineElement | null) => void;
onRangeSelect?: (range: TimelineTimeRange | null) => void;
/** Copy/paste/duplicate act on the store's own selection, not a passed
* element, so unlike onDeleteElement they need no composition-basis wrapper. */
onCopyClip?: () => boolean;
@@ -125,6 +126,7 @@ export function TimelinePane({
onCompositionDrop,
onBlockedEditAttempt,
onSelectTimelineElement,
onRangeSelect,
onCopyClip,
onPasteClip,
onDuplicateClip,
@@ -282,6 +284,7 @@ export function TimelinePane({
onBlockedEditAttempt={onBlockedEditAttempt}
onSplitElement={handleSplitElement}
onSelectElement={onSelectTimelineElement}
onRangeSelect={onRangeSelect}
onCopyClip={onCopyClip}
onPasteClip={onPasteClip}
onDuplicateClip={onDuplicateClip}
+4 -2
View File
@@ -1,10 +1,12 @@
// UI primitives
export { Button, buttonBase, buttonSizes, buttonVariants } from "./components/ui/Button";
export type { ButtonSize, ButtonVariant, PreviewState } from "./components/ui/Button";
export { HyperframesLogo } from "./components/StudioHeader";
export { IconButton } from "./components/ui/IconButton";
export { Tab, TabPanel, Tabs, TabsList } from "./components/ui/Tabs";
export { HyperframesLoader } from "./components/ui/HyperframesLoader";
export type { HyperframesLoaderProps } from "./components/ui/HyperframesLoader";
export { Tooltip } from "./components/ui/Tooltip";
export { HyperframesLogo } from "./components/StudioHeader";
export { cn } from "./components/ui/cn";
export {
ContextMenu,
@@ -49,7 +51,7 @@ export {
liveTime,
formatTime,
} from "./player";
export type { TimelineElement } from "./player";
export type { TimelineElement, TimelineTimeRange } from "./player";
// Host overlays: draw over the preview in composition coordinates (see EditorShellProps.gestureOverlay)
export { usePreviewCompositionRect } from "./components/editor/usePreviewCompositionRect";
@@ -4,6 +4,7 @@ import { usePlayerStore, type TimelineElement } from "../store/playerStore";
import { useTimelineRowElements } from "../hooks/useTimelineRowElements";
import { defaultTimelineTheme } from "./timelineTheme";
import { useTimelineRangeSelection } from "./useTimelineRangeSelection";
import { usePublishRangeSelection } from "./usePublishRangeSelection";
import { useTimelinePlayhead } from "./useTimelinePlayhead";
import { useTimelineZoom } from "./useTimelineZoom";
import { useTimelineAssetDrop } from "./timelineDragDrop";
@@ -80,6 +81,7 @@ export const Timeline = memo(function Timeline({
onBlockedEditAttempt: onBlockedEditAttemptOverride,
onSplitElement: onSplitElementOverride,
onSelectElement,
onRangeSelect,
onCopyClip,
onPasteClip,
onDuplicateClip,
@@ -167,10 +169,10 @@ export const Timeline = memo(function Timeline({
selectedElementIds,
);
const expandedElementsRef = useRef(expandedElements);
expandedElementsRef.current = expandedElements;
expandedElementsRef.current = expandedElements; // oxlint-disable-line react/refs -- event handlers read the latest elements
const ppsRef = useRef(100);
const durationRef = useRef(effectiveDuration);
durationRef.current = effectiveDuration;
durationRef.current = effectiveDuration; // oxlint-disable-line react/refs -- event handlers read the latest duration
const fitPpsRef = useRef(100);
const {
pinZoomBeforeEdit,
@@ -214,7 +216,6 @@ export const Timeline = memo(function Timeline({
onMoveElements: pinnedOnMoveElements,
});
const onContextMenuClip = useClipContextMenu(onSelectElement, dismissGapMenu, setClipContextMenu);
const {
draggedClip,
setDraggedClip,
@@ -241,7 +242,6 @@ export const Timeline = memo(function Timeline({
refreshAfterLaneMove,
sessionEpoch,
});
const assetDrop = useTimelineAssetDrop({
scrollRef,
ppsRef,
@@ -320,7 +320,6 @@ export const Timeline = memo(function Timeline({
setKfContextMenu,
toggleSelectedKeyframe,
});
const { clipIndex, renderTimeRange, visibleTimeRange, pinnedClipIdentities } =
useTimelineClipRenderWindow({
tracks,
@@ -415,7 +414,8 @@ export const Timeline = memo(function Timeline({
contentOrigin,
sessionEpoch,
});
setRangeSelectionRef.current = setRangeSelection; // stable ref consumed by useTimelineClipDrag
usePublishRangeSelection(rangeSelection, onRangeSelect);
setRangeSelectionRef.current = setRangeSelection; // oxlint-disable-line react/refs -- stable ref consumed by useTimelineClipDrag
useTimelineSelectionLifecycle(expandedElements, selectedElementId, setShowPopover, () =>
setRangeSelection(null),
@@ -1,5 +1,6 @@
import type { ReactNode } from "react";
import type { TimelineElement } from "../store/playerStore";
import type { TimelineTimeRange } from "../store/rangeSelectionSlice";
import type { TimelineDropCallbacks } from "./timelineCallbacks";
import type { TimelineTheme } from "./timelineTheme";
import type { TimelineEditOverrides } from "./useResolvedTimelineEditCallbacks";
@@ -23,6 +24,8 @@ export interface TimelineProps extends TimelineDropCallbacks, TimelineEditOverri
renderClipOverlay?: (element: TimelineElement) => ReactNode;
onDeleteElement?: (element: TimelineElement) => Promise<void> | void;
onSelectElement?: (element: TimelineElement | null) => void;
/** Notification only; null when cleared. The value lives in usePlayerStore.rangeSelection. */
onRangeSelect?: (range: TimelineTimeRange | null) => void;
onCopyClip?: () => boolean;
onPasteClip?: () => Promise<void>;
onDuplicateClip?: () => Promise<boolean>;
@@ -0,0 +1,60 @@
// @vitest-environment happy-dom
import React, { act } from "react";
import { createRoot } from "react-dom/client";
import { afterEach, describe, expect, it, vi } from "vitest";
import { usePlayerStore } from "../store/playerStore";
import type { TimelineTimeRange } from "../store/rangeSelectionSlice";
import type { TimelineRangeSelection } from "./timelineEditing";
import { usePublishRangeSelection } from "./usePublishRangeSelection";
(globalThis as { IS_REACT_ACT_ENVIRONMENT?: boolean }).IS_REACT_ACT_ENVIRONMENT = true;
function mount(onRangeSelect: (range: TimelineTimeRange | null) => void) {
const host = document.createElement("div");
const root = createRoot(host);
const Harness = ({ selection }: { selection: TimelineRangeSelection | null }) => {
usePublishRangeSelection(selection, onRangeSelect);
return null;
};
const render = (selection: TimelineRangeSelection | null) =>
act(() => root.render(<Harness selection={selection} />));
return { render, unmount: () => act(() => root.unmount()) };
}
const selection = (start: number, end: number): TimelineRangeSelection => ({
start,
end,
anchorX: 0,
anchorY: 0,
});
describe("usePublishRangeSelection", () => {
afterEach(() => usePlayerStore.setState({ rangeSelection: null }));
it("publishes an ordered range to the store and the host, and clears with null", () => {
const onRangeSelect = vi.fn();
const view = mount(onRangeSelect);
view.render(selection(3, 1));
expect(usePlayerStore.getState().rangeSelection).toEqual({ t0: 1, t1: 3 });
expect(onRangeSelect).toHaveBeenLastCalledWith({ t0: 1, t1: 3 });
view.render(null);
expect(usePlayerStore.getState().rangeSelection).toBeNull();
expect(onRangeSelect).toHaveBeenLastCalledWith(null);
view.unmount();
});
it("does not re-notify for an unchanged range and preserves a newer store value on unmount", () => {
const onRangeSelect = vi.fn();
const view = mount(onRangeSelect);
view.render(selection(1, 3));
view.render(selection(3, 1));
expect(onRangeSelect).toHaveBeenCalledTimes(1);
usePlayerStore.setState({ rangeSelection: { t0: 9, t1: 10 } });
view.unmount();
expect(usePlayerStore.getState().rangeSelection).toEqual({ t0: 9, t1: 10 });
});
});
@@ -0,0 +1,42 @@
import { useEffect, useRef } from "react";
import { usePlayerStore } from "../store/playerStore";
import type { TimelineTimeRange } from "../store/rangeSelectionSlice";
import type { TimelineRangeSelection } from "./timelineEditing";
/** Mirrors the gesture's range into the store and notifies the host. */
export function usePublishRangeSelection(
rangeSelection: TimelineRangeSelection | null,
onRangeSelect?: (range: TimelineTimeRange | null) => void,
) {
const onRangeSelectRef = useRef(onRangeSelect);
const publishedRangeRef = useRef<TimelineTimeRange | null>(null);
useEffect(() => {
onRangeSelectRef.current = onRangeSelect;
}, [onRangeSelect]);
useEffect(() => {
const range = rangeSelection
? {
t0: Math.min(rangeSelection.start, rangeSelection.end),
t1: Math.max(rangeSelection.start, rangeSelection.end),
}
: null;
const store = usePlayerStore.getState();
const current = store.rangeSelection;
if (current?.t0 === range?.t0 && current?.t1 === range?.t1) return;
publishedRangeRef.current = range;
usePlayerStore.setState({ rangeSelection: range });
onRangeSelectRef.current?.(range);
}, [rangeSelection]);
useEffect(
() => () => {
const current = usePlayerStore.getState().rangeSelection;
const published = publishedRangeRef.current;
if (current?.t0 !== published?.t0 || current?.t1 !== published?.t1) return;
usePlayerStore.setState({ rangeSelection: null });
},
[],
);
}
+1
View File
@@ -12,6 +12,7 @@ export { resolveIframe } from "./lib/timelineDOM";
// Store
export { usePlayerStore, liveTime } from "./store/playerStore";
export type { SelectElementOptions, TimelineElement, ZoomMode } from "./store/playerStore";
export type { TimelineTimeRange } from "./store/rangeSelectionSlice";
// Utils
export { formatTime } from "./lib/time";
@@ -20,6 +20,7 @@ import { createEditingModeSlice, type EditingModeSlice } from "./editingModeSlic
import { createTimelineFocusRequest, type TimelineFocusRequest } from "./timelineFocusState";
import { createThumbnailSlice, type ThumbnailSlice } from "./thumbnailSlice";
import { createPlaybackReadinessSlice, resetPlaybackReadinessState } from "./readinessSlice";
import { createRangeSelectionSlice, type RangeSelectionSlice } from "./rangeSelectionSlice";
export type { KeyframeCacheEntry } from "./keyframeSlice";
export { liveTime } from "./liveTime";
@@ -59,6 +60,7 @@ type PlayerStoreSlices = KeyframeSlice &
AutomationSelectionSlice &
ThumbnailSlice &
EditingModeSlice &
RangeSelectionSlice &
ReturnType<typeof createPlaybackReadinessSlice>;
interface PlayerState extends PlayerStoreSlices {
isPlaying: boolean;
@@ -262,6 +264,7 @@ export function createTimelineResetState() {
zEditVersion: 0,
inPoint: null,
outPoint: null,
rangeSelection: null,
activeTool: "select" as const,
activeKeyframePct: null,
motionPathArmed: false,
@@ -328,6 +331,7 @@ export const usePlayerStore = create<PlayerState>((set, get) => ({
...createAutomationSelectionSlice(set),
...createEditingModeSlice(set),
...createRangeSelectionSlice(),
...createPlaybackReadinessSlice(set),
activeKeyframePct: null,
@@ -0,0 +1,13 @@
export interface TimelineTimeRange {
t0: number;
t1: number;
}
export interface RangeSelectionSlice {
/** Shift-drag ruler range in seconds, normalized so t0 <= t1. */
rangeSelection: TimelineTimeRange | null;
}
export function createRangeSelectionSlice(): RangeSelectionSlice {
return { rangeSelection: null };
}
+7 -36
View File
@@ -1,14 +1,7 @@
import { defineConfig, type Plugin } from "vite";
import react from "@vitejs/plugin-react";
import tailwindcss from "@tailwindcss/vite";
import {
copyFileSync,
readFileSync,
readdirSync,
existsSync,
lstatSync,
realpathSync,
} from "node:fs";
import { readFileSync, readdirSync, existsSync, lstatSync, realpathSync } from "node:fs";
import { join, resolve } from "node:path";
import { readNodeRequestBody } from "./vite.request-body.js";
import { watch } from "chokidar";
@@ -34,30 +27,6 @@ async function loadRuntimeSourceForDev(
const studioPkg = JSON.parse(readFileSync(resolve(__dirname, "package.json"), "utf-8"));
/**
* Copies the build's one CSS asset, unhashed, to `dist/styles.css` for the
* `./styles.css` export. Throws if the build ever emits more than one.
*/
export function stableStylesCssPlugin(): Plugin {
return {
name: "studio-stable-styles-css",
writeBundle(options, bundle) {
const cssAssets = Object.values(bundle).filter(
(item) => item.type === "asset" && item.fileName.endsWith(".css"),
);
if (cssAssets.length !== 1) {
throw new Error(
`stableStylesCssPlugin: expected exactly one CSS asset for the ./styles.css ` +
`export, found ${cssAssets.length} (${cssAssets.map((a) => a.fileName).join(", ") || "none"}). ` +
`Scope this plugin to the entry stylesheet instead of assuming a single emit.`,
);
}
const outDir = options.dir ?? "dist";
copyFileSync(join(outDir, cssAssets[0]!.fileName), join(outDir, "styles.css"));
},
};
}
// ── Bridge Hono fetch → Node http response ───────────────────────────────────
async function bridgeHonoResponse(
@@ -294,10 +263,12 @@ export default defineConfig({
outDir: "dist",
emptyOutDir: true,
rollupOptions: {
// /assets/* caches by filename alone, immutably, for a year
// (studioServer.ts). Keep every hash; copy one CSS file, unhashed,
// to the dist ROOT instead for the ./styles.css export.
plugins: [stableStylesCssPlugin()],
output: {
// The compiled stylesheet is a package export, so its path must stay
// stable across builds instead of following Vite's content hash.
assetFileNames: (assetInfo) =>
assetInfo.name?.endsWith(".css") ? "assets/styles.css" : "assets/[name]-[hash][extname]",
},
},
},
optimizeDeps: {