mirror of
https://github.com/heygen-com/hyperframes.git
synced 2026-09-08 10:46:06 +00:00
fix(studio): keyframe bug fixes — gate delete hooks, fix value corruption, gesture recording (#1314)
- Gate stripStudioEditsFromTarget/bakeVisibilityOnDelete behind a stripStudioEdits flag on the delete mutation type so they only fire on user-initiated deletes, not on internal delete-then-recreate drags. - Add bakeVisibilityOnDelete to the remove-all-keyframes handler so elements with CSS opacity:0 stay visible after collapsing keyframes. - Fix integer rounding in readAllAnimatedProperties: use 3-decimal precision for visual properties (opacity, scale, rotation) instead of Math.round which corrupted mid-fade values to 0. - Guard VISUAL_BASELINE against cross-tween contamination by querying __timelines for properties animated by other tweens on the same element. - Harden bakeVisibilityOnDelete: reverse-scan keyframes for the last one containing opacity, guard against relative values (+=/-=/*=), and add Number.isFinite check. - Fix falsy-zero doubling in drag commit: replace || fallback with Number.isFinite so a base GSAP position of 0 is correctly preserved. - Fix gesture recording sign inversion: remove pointerElementOffset subtraction from dx/dy formula and instead apply it once to basePosition so the element center tracks the pointer. - Fix TypeScript build errors in gsapSoftReload.ts (6 double-casts). - Strip all diagnostic logs from production code.
This commit is contained in:
@@ -16,6 +16,8 @@ export interface StudioLeftSidebarProps {
|
||||
onPreviewBlock?: (preview: BlockPreviewInfo | null) => void;
|
||||
onLint: () => void;
|
||||
linting: boolean;
|
||||
lintFindingCount?: number;
|
||||
lintFindingsByFile?: Map<string, { count: number; messages: string[] }>;
|
||||
}
|
||||
|
||||
// fallow-ignore-next-line complexity
|
||||
@@ -26,6 +28,8 @@ export function StudioLeftSidebar({
|
||||
onPreviewBlock,
|
||||
onLint,
|
||||
linting,
|
||||
lintFindingCount,
|
||||
lintFindingsByFile,
|
||||
}: StudioLeftSidebarProps) {
|
||||
const {
|
||||
leftCollapsed,
|
||||
@@ -129,6 +133,8 @@ export function StudioLeftSidebar({
|
||||
isRendering={renderQueue.isRendering}
|
||||
onLint={onLint}
|
||||
linting={linting}
|
||||
lintFindingCount={lintFindingCount}
|
||||
lintFindingsByFile={lintFindingsByFile}
|
||||
onToggleCollapse={toggleLeftSidebar}
|
||||
onAddBlock={onAddBlock}
|
||||
onPreviewBlock={onPreviewBlock}
|
||||
|
||||
@@ -20,6 +20,7 @@ import { useStudioContext } from "../contexts/StudioContext";
|
||||
import { usePanelLayoutContext } from "../contexts/PanelLayoutContext";
|
||||
import { useFileManagerContext } from "../contexts/FileManagerContext";
|
||||
import { useDomEditContext } from "../contexts/DomEditContext";
|
||||
import { usePlayerStore } from "../player";
|
||||
|
||||
export interface StudioRightPanelProps {
|
||||
selectedStudioMotion: StudioMotionData | null;
|
||||
@@ -100,6 +101,9 @@ export function StudioRightPanel({
|
||||
commitAnimatedProperty,
|
||||
handleSetArcPath,
|
||||
handleUpdateArcSegment,
|
||||
handleGsapAddKeyframe,
|
||||
handleGsapRemoveKeyframe,
|
||||
handleGsapConvertToKeyframes,
|
||||
} = useDomEditContext();
|
||||
|
||||
const { assets, fontAssets, projectDir, handleImportFiles, handleImportFonts } =
|
||||
@@ -234,6 +238,10 @@ export function StudioRightPanel({
|
||||
onRemoveGsapFromProperty={handleGsapRemoveFromProperty}
|
||||
onAddGsapAnimation={handleGsapAddAnimation}
|
||||
onCommitAnimatedProperty={commitAnimatedProperty}
|
||||
onAddKeyframe={handleGsapAddKeyframe}
|
||||
onRemoveKeyframe={handleGsapRemoveKeyframe}
|
||||
onConvertToKeyframes={handleGsapConvertToKeyframes}
|
||||
onSeekToTime={(t) => usePlayerStore.getState().requestSeek(t)}
|
||||
onSetArcPath={handleSetArcPath}
|
||||
onUpdateArcSegment={handleUpdateArcSegment}
|
||||
recordingState={recordingState}
|
||||
|
||||
@@ -12,6 +12,26 @@ import { Scissors } from "../icons/SystemIcons";
|
||||
import type { GsapAnimation } from "@hyperframes/core/gsap-parser";
|
||||
import type { DomEditSelection } from "./editor/domEditingTypes";
|
||||
|
||||
function AutoKeyframeToggle() {
|
||||
const enabled = usePlayerStore((s) => s.autoKeyframeEnabled);
|
||||
return (
|
||||
<Tooltip label={enabled ? "Auto-keyframe ON" : "Auto-keyframe OFF"}>
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => usePlayerStore.getState().setAutoKeyframeEnabled(!enabled)}
|
||||
className={`flex h-7 w-7 items-center justify-center rounded transition-colors ${
|
||||
enabled ? "text-red-400" : "text-neutral-600 hover:text-neutral-400"
|
||||
}`}
|
||||
>
|
||||
<svg width="14" height="14" viewBox="0 0 14 14" fill="none">
|
||||
<circle cx="7" cy="7" r="5" stroke="currentColor" strokeWidth="1.5" />
|
||||
{enabled && <circle cx="7" cy="7" r="3" fill="currentColor" />}
|
||||
</svg>
|
||||
</button>
|
||||
</Tooltip>
|
||||
);
|
||||
}
|
||||
|
||||
interface DomEditSessionSlice extends EnableKeyframesSession {
|
||||
domEditSelection: DomEditSelection | null;
|
||||
selectedGsapAnimations: GsapAnimation[];
|
||||
@@ -74,40 +94,43 @@ export function TimelineToolbar({
|
||||
Timeline
|
||||
</div>
|
||||
{STUDIO_KEYFRAMES_ENABLED && onToggleKeyframe && (
|
||||
<Tooltip
|
||||
label={
|
||||
keyframeState === "active"
|
||||
? "Remove keyframe at playhead"
|
||||
: keyframeState === "inactive"
|
||||
? "Add keyframe at playhead"
|
||||
: "Enable keyframes"
|
||||
}
|
||||
>
|
||||
<button
|
||||
type="button"
|
||||
onClick={onToggleKeyframe}
|
||||
className={`flex h-7 w-7 items-center justify-center rounded transition-colors ${
|
||||
<>
|
||||
<Tooltip
|
||||
label={
|
||||
keyframeState === "active"
|
||||
? "text-studio-accent"
|
||||
? "Remove keyframe at playhead"
|
||||
: keyframeState === "inactive"
|
||||
? "text-neutral-400 hover:text-studio-accent"
|
||||
: "text-neutral-600 hover:text-neutral-400"
|
||||
}`}
|
||||
? "Add keyframe at playhead"
|
||||
: "Enable keyframes"
|
||||
}
|
||||
>
|
||||
<svg width="18" height="18" viewBox="0 0 10 10" fill="currentColor">
|
||||
{keyframeState === "active" ? (
|
||||
<path d="M5 0.5L9.5 5L5 9.5L0.5 5Z" />
|
||||
) : (
|
||||
<path
|
||||
d="M5 1.2L8.8 5L5 8.8L1.2 5Z"
|
||||
fill="none"
|
||||
stroke="currentColor"
|
||||
strokeWidth="1.2"
|
||||
/>
|
||||
)}
|
||||
</svg>
|
||||
</button>
|
||||
</Tooltip>
|
||||
<button
|
||||
type="button"
|
||||
onClick={onToggleKeyframe}
|
||||
className={`flex h-7 w-7 items-center justify-center rounded transition-colors ${
|
||||
keyframeState === "active"
|
||||
? "text-studio-accent"
|
||||
: keyframeState === "inactive"
|
||||
? "text-neutral-400 hover:text-studio-accent"
|
||||
: "text-neutral-600 hover:text-neutral-400"
|
||||
}`}
|
||||
>
|
||||
<svg width="18" height="18" viewBox="0 0 10 10" fill="currentColor">
|
||||
{keyframeState === "active" ? (
|
||||
<path d="M5 0.5L9.5 5L5 9.5L0.5 5Z" />
|
||||
) : (
|
||||
<path
|
||||
d="M5 1.2L8.8 5L5 8.8L1.2 5Z"
|
||||
fill="none"
|
||||
stroke="currentColor"
|
||||
strokeWidth="1.2"
|
||||
/>
|
||||
)}
|
||||
</svg>
|
||||
</button>
|
||||
</Tooltip>
|
||||
<AutoKeyframeToggle />
|
||||
</>
|
||||
)}
|
||||
{onSplitElement &&
|
||||
(() => {
|
||||
|
||||
@@ -398,9 +398,21 @@ export const AnimationCard = memo(function AnimationCard({
|
||||
<div className="pt-2">
|
||||
<div className="space-y-3">
|
||||
<div className="flex items-start gap-2">
|
||||
<p className="flex-1 text-[10px] leading-relaxed text-neutral-400 italic">
|
||||
{summary}
|
||||
</p>
|
||||
<div className="flex-1">
|
||||
<p className="text-[10px] leading-relaxed text-neutral-400 italic">{summary}</p>
|
||||
{animation.keyframes && (
|
||||
<p className="mt-1 text-[9px] text-neutral-500">
|
||||
<span
|
||||
className="inline-block w-2 h-2 mr-1 align-middle"
|
||||
style={{
|
||||
background: "currentColor",
|
||||
clipPath: "polygon(50% 0%, 100% 50%, 50% 100%, 0% 50%)",
|
||||
}}
|
||||
/>
|
||||
Keyframed — edit values in the Layout panel above
|
||||
</p>
|
||||
)}
|
||||
</div>
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => {
|
||||
|
||||
@@ -15,7 +15,7 @@ import {
|
||||
|
||||
// ── Types ──
|
||||
|
||||
export interface FileTreeProps {
|
||||
interface FileTreeProps {
|
||||
files: string[];
|
||||
activeFile: string | null;
|
||||
onSelectFile: (path: string) => void;
|
||||
@@ -26,6 +26,7 @@ export interface FileTreeProps {
|
||||
onDuplicateFile?: (path: string) => void;
|
||||
onMoveFile?: (oldPath: string, newPath: string) => void;
|
||||
onImportFiles?: (files: FileList, dir?: string) => void;
|
||||
lintFindingsByFile?: Map<string, { count: number; messages: string[] }>;
|
||||
}
|
||||
|
||||
// ── Main FileTree Component ──
|
||||
@@ -41,6 +42,7 @@ export const FileTree = memo(function FileTree({
|
||||
onDuplicateFile,
|
||||
onMoveFile,
|
||||
onImportFiles,
|
||||
lintFindingsByFile,
|
||||
}: FileTreeProps) {
|
||||
const tree = useMemo(() => buildTree(files), [files]);
|
||||
const children = useMemo(() => sortChildren(tree.children), [tree]);
|
||||
@@ -283,6 +285,7 @@ export const FileTree = memo(function FileTree({
|
||||
onContextMenu={handleContextMenu}
|
||||
inlineInput={inlineInput}
|
||||
onDragStart={handleDragStart}
|
||||
lintInfo={lintFindingsByFile?.get(child.fullPath)}
|
||||
/>
|
||||
) : (
|
||||
<TreeFolder
|
||||
@@ -299,6 +302,7 @@ export const FileTree = memo(function FileTree({
|
||||
onDrop={handleDrop}
|
||||
onDragLeave={handleDragLeave}
|
||||
dragOverFolder={dragOverFolder}
|
||||
lintFindingsByFile={lintFindingsByFile}
|
||||
/>
|
||||
),
|
||||
)}
|
||||
|
||||
@@ -18,8 +18,7 @@ import {
|
||||
type InlineInputState,
|
||||
} from "./FileTreeIcons";
|
||||
|
||||
// Re-export for FileTree.tsx consumers
|
||||
export type { TreeNode, ContextMenuState, InlineInputState };
|
||||
export type { ContextMenuState, InlineInputState };
|
||||
export { buildTree, sortChildren, isActiveInSubtree } from "./FileTreeIcons";
|
||||
|
||||
const SZ_ICON = 14;
|
||||
@@ -300,6 +299,7 @@ export const TreeFile = memo(function TreeFile({
|
||||
onContextMenu,
|
||||
inlineInput,
|
||||
onDragStart,
|
||||
lintInfo,
|
||||
}: {
|
||||
node: TreeNode;
|
||||
depth: number;
|
||||
@@ -308,6 +308,7 @@ export const TreeFile = memo(function TreeFile({
|
||||
onContextMenu: (e: React.MouseEvent, path: string, isFolder: boolean) => void;
|
||||
inlineInput: InlineInputState | null;
|
||||
onDragStart: (e: React.DragEvent, path: string) => void;
|
||||
lintInfo?: { count: number; messages: string[] };
|
||||
}) {
|
||||
const isActive = node.fullPath === activeFile;
|
||||
const isRenaming = inlineInput?.mode === "rename" && inlineInput.originalPath === node.fullPath;
|
||||
@@ -345,7 +346,15 @@ export const TreeFile = memo(function TreeFile({
|
||||
style={{ paddingLeft: `${8 + depth * 12 + 14}px` }}
|
||||
>
|
||||
<FileIcon path={node.name} />
|
||||
<span className="truncate">{node.name}</span>
|
||||
<span className="truncate flex-1">{node.name}</span>
|
||||
{lintInfo && lintInfo.count > 0 && (
|
||||
<span
|
||||
className="flex-shrink-0 min-w-[16px] rounded-full bg-amber-500/20 px-1 text-[8px] font-bold text-amber-400 text-center mr-1"
|
||||
title={lintInfo.messages.join("\n")}
|
||||
>
|
||||
{lintInfo.count}
|
||||
</span>
|
||||
)}
|
||||
</button>
|
||||
);
|
||||
});
|
||||
@@ -365,6 +374,7 @@ export const TreeFolder = memo(function TreeFolder({
|
||||
onDrop,
|
||||
onDragLeave,
|
||||
dragOverFolder,
|
||||
lintFindingsByFile,
|
||||
}: {
|
||||
node: TreeNode;
|
||||
depth: number;
|
||||
@@ -378,6 +388,7 @@ export const TreeFolder = memo(function TreeFolder({
|
||||
onDrop: (e: React.DragEvent, folderPath: string) => void;
|
||||
onDragLeave: () => void;
|
||||
dragOverFolder: string | null;
|
||||
lintFindingsByFile?: Map<string, { count: number; messages: string[] }>;
|
||||
}) {
|
||||
const [isOpen, setIsOpen] = useState(defaultOpen);
|
||||
const toggle = useCallback(() => setIsOpen((v) => !v), []);
|
||||
@@ -459,6 +470,7 @@ export const TreeFolder = memo(function TreeFolder({
|
||||
onContextMenu={onContextMenu}
|
||||
inlineInput={inlineInput}
|
||||
onDragStart={onDragStart}
|
||||
lintInfo={lintFindingsByFile?.get(child.fullPath)}
|
||||
/>
|
||||
) : child.children.size > 0 ? (
|
||||
<TreeFolder
|
||||
@@ -475,6 +487,7 @@ export const TreeFolder = memo(function TreeFolder({
|
||||
onDrop={onDrop}
|
||||
onDragLeave={onDragLeave}
|
||||
dragOverFolder={dragOverFolder}
|
||||
lintFindingsByFile={lintFindingsByFile}
|
||||
/>
|
||||
) : (
|
||||
<TreeFile
|
||||
@@ -486,6 +499,7 @@ export const TreeFolder = memo(function TreeFolder({
|
||||
onContextMenu={onContextMenu}
|
||||
inlineInput={inlineInput}
|
||||
onDragStart={onDragStart}
|
||||
lintInfo={lintFindingsByFile?.get(child.fullPath)}
|
||||
/>
|
||||
),
|
||||
)}
|
||||
|
||||
@@ -122,13 +122,28 @@ export const LayersPanel = memo(function LayersPanel() {
|
||||
}, [compositionLoading, collectLayers]);
|
||||
|
||||
const resolveSelection = useCallback(
|
||||
(layer: DomEditLayerItem) =>
|
||||
resolveDomEditSelection(layer.element, {
|
||||
(layer: DomEditLayerItem) => {
|
||||
// Re-find the element from the live DOM — layer.element may be stale
|
||||
// after soft reload (which replaces scripts without reloading the iframe).
|
||||
let el = layer.element;
|
||||
if (!el.isConnected) {
|
||||
const iframe = previewIframeRef.current;
|
||||
const doc = iframe?.contentDocument;
|
||||
if (doc) {
|
||||
const found =
|
||||
(layer.id ? doc.getElementById(layer.id) : null) ??
|
||||
(layer.hfId ? doc.querySelector(`[data-hf-id="${layer.hfId}"]`) : null) ??
|
||||
doc.getElementById(layer.key);
|
||||
if (found instanceof HTMLElement) el = found;
|
||||
}
|
||||
}
|
||||
return resolveDomEditSelection(el, {
|
||||
activeCompositionPath: activeCompPath,
|
||||
isMasterView,
|
||||
preferClipAncestor: false,
|
||||
}),
|
||||
[activeCompPath, isMasterView],
|
||||
});
|
||||
},
|
||||
[activeCompPath, isMasterView, previewIframeRef],
|
||||
);
|
||||
|
||||
const seekToLayer = useCallback(
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
import { memo, useRef, useState } from "react";
|
||||
import { memo, useEffect, useRef, useState } from "react";
|
||||
import { Eye, Layers, Move, X } from "../../icons/SystemIcons";
|
||||
import { useStudioContext } from "../../contexts/StudioContext";
|
||||
import { readStudioBoxSize, readStudioPathOffset, readStudioRotation } from "./manualEdits";
|
||||
@@ -17,7 +17,7 @@ import { GsapAnimationSection } from "./GsapAnimationSection";
|
||||
import { PropertyPanel3dTransform } from "./propertyPanel3dTransform";
|
||||
import { KeyframeNavigation } from "./KeyframeNavigation";
|
||||
import { STUDIO_GSAP_PANEL_ENABLED, STUDIO_KEYFRAMES_ENABLED } from "./manualEditingAvailability";
|
||||
import { usePlayerStore } from "../../player";
|
||||
import { usePlayerStore, liveTime } from "../../player";
|
||||
import { TimingSection } from "./propertyPanelTimingSection";
|
||||
import { type PropertyPanelProps } from "./propertyPanelHelpers";
|
||||
|
||||
@@ -88,7 +88,29 @@ export const PropertyPanel = memo(function PropertyPanel({
|
||||
const { showToast } = useStudioContext();
|
||||
const [clipboardCopied, setClipboardCopied] = useState(false);
|
||||
const clipboardTimerRef = useRef<ReturnType<typeof setTimeout>>(undefined);
|
||||
const currentTime = usePlayerStore((s) => s.currentTime);
|
||||
const storeTime = usePlayerStore((s) => s.currentTime);
|
||||
const isPlaying = usePlayerStore((s) => s.isPlaying);
|
||||
const liveTimeRef = useRef(storeTime);
|
||||
const [, forceRender] = useState(0);
|
||||
useEffect(() => {
|
||||
if (!isPlaying) return;
|
||||
let timerId: ReturnType<typeof setTimeout> | 0 = 0;
|
||||
const unsub = liveTime.subscribe((t) => {
|
||||
liveTimeRef.current = t;
|
||||
if (!timerId)
|
||||
timerId = setTimeout(() => {
|
||||
timerId = 0;
|
||||
forceRender((v) => v + 1);
|
||||
}, 33);
|
||||
});
|
||||
return () => {
|
||||
unsub();
|
||||
if (timerId) clearTimeout(timerId);
|
||||
};
|
||||
}, [isPlaying]);
|
||||
const currentTime = isPlaying ? liveTimeRef.current : storeTime;
|
||||
const cacheElementKey = element?.id ?? element?.selector ?? "";
|
||||
const cacheEntry = usePlayerStore((s) => s.keyframeCache.get(cacheElementKey));
|
||||
|
||||
if (!element) {
|
||||
return (
|
||||
@@ -140,7 +162,7 @@ export const PropertyPanel = memo(function PropertyPanel({
|
||||
const commitManualOffset = (axis: "x" | "y", nextValue: string) => {
|
||||
const parsed = parsePxMetricValue(nextValue);
|
||||
if (parsed == null) return;
|
||||
if (onCommitAnimatedProperty && (gsapAnimId || gsapAnimations.length > 0)) {
|
||||
if (onCommitAnimatedProperty && hasGsapAnimation) {
|
||||
void onCommitAnimatedProperty(element, axis, parsed);
|
||||
return;
|
||||
}
|
||||
@@ -149,6 +171,10 @@ export const PropertyPanel = memo(function PropertyPanel({
|
||||
onAddKeyframe(gsapAnimId, pct, axis, parsed);
|
||||
return;
|
||||
}
|
||||
if (hasGsapAnimation) {
|
||||
showToast?.("Cannot edit position — animation callbacks not available");
|
||||
return;
|
||||
}
|
||||
const current = readStudioPathOffset(element.element);
|
||||
onSetManualOffset(element, {
|
||||
x: axis === "x" ? parsed : current.x,
|
||||
@@ -160,6 +186,14 @@ export const PropertyPanel = memo(function PropertyPanel({
|
||||
const commitManualSize = (axis: "width" | "height", nextValue: string) => {
|
||||
const parsed = parsePxMetricValue(nextValue);
|
||||
if (parsed == null || parsed <= 0) return;
|
||||
if (onCommitAnimatedProperty && hasGsapAnimation) {
|
||||
void onCommitAnimatedProperty(element, axis, parsed);
|
||||
return;
|
||||
}
|
||||
if (hasGsapAnimation) {
|
||||
showToast?.("Cannot edit size — animation callbacks not available");
|
||||
return;
|
||||
}
|
||||
const current = readStudioBoxSize(element.element);
|
||||
const width =
|
||||
current.width > 0
|
||||
@@ -186,9 +220,12 @@ export const PropertyPanel = memo(function PropertyPanel({
|
||||
const elDuration = Number.parseFloat(element?.dataAttributes?.duration ?? "1") || 0;
|
||||
const currentPct = elDuration > 0 ? ((currentTime - elStart) / elDuration) * 100 : 0;
|
||||
|
||||
const gsapKeyframes = gsapAnimations?.find((a) => a.keyframes)?.keyframes?.keyframes ?? null;
|
||||
const gsapAnimId =
|
||||
gsapAnimations?.find((a) => a.keyframes)?.id ?? gsapAnimations?.[0]?.id ?? null;
|
||||
const gsapKfAnim = gsapAnimations?.find((a) => a.keyframes) ?? null;
|
||||
const gsapKeyframes = gsapKfAnim?.keyframes?.keyframes ?? null;
|
||||
const gsapAnimId = gsapKfAnim?.id ?? gsapAnimations?.[0]?.id ?? null;
|
||||
const hasGsapAnimation = !!(gsapAnimId || gsapAnimations.length > 0);
|
||||
const navKeyframes = cacheEntry?.keyframes ?? gsapKeyframes;
|
||||
const seekFromKfPct = (pct: number) => onSeekToTime?.(elStart + (pct / 100) * elDuration);
|
||||
|
||||
// Read ALL GSAP-interpolated values at the current seek time.
|
||||
const gsapRuntimeValues = readGsapRuntimeValuesForPanel(
|
||||
@@ -351,9 +388,9 @@ export const PropertyPanel = memo(function PropertyPanel({
|
||||
{STUDIO_KEYFRAMES_ENABLED && gsapAnimId && (
|
||||
<KeyframeNavigation
|
||||
property="x"
|
||||
keyframes={gsapKeyframes}
|
||||
keyframes={navKeyframes}
|
||||
currentPercentage={currentPct}
|
||||
onSeek={(pct) => onSeekToTime?.(elStart + (pct / 100) * elDuration)}
|
||||
onSeek={seekFromKfPct}
|
||||
onAddKeyframe={() =>
|
||||
onCommitAnimatedProperty &&
|
||||
void onCommitAnimatedProperty(element, "x", displayX)
|
||||
@@ -376,9 +413,9 @@ export const PropertyPanel = memo(function PropertyPanel({
|
||||
{STUDIO_KEYFRAMES_ENABLED && gsapAnimId && (
|
||||
<KeyframeNavigation
|
||||
property="y"
|
||||
keyframes={gsapKeyframes}
|
||||
keyframes={navKeyframes}
|
||||
currentPercentage={currentPct}
|
||||
onSeek={(pct) => onSeekToTime?.(elStart + (pct / 100) * elDuration)}
|
||||
onSeek={seekFromKfPct}
|
||||
onAddKeyframe={() =>
|
||||
onCommitAnimatedProperty &&
|
||||
void onCommitAnimatedProperty(element, "y", displayY)
|
||||
@@ -401,9 +438,9 @@ export const PropertyPanel = memo(function PropertyPanel({
|
||||
{STUDIO_KEYFRAMES_ENABLED && gsapAnimId && (
|
||||
<KeyframeNavigation
|
||||
property="width"
|
||||
keyframes={gsapKeyframes}
|
||||
keyframes={navKeyframes}
|
||||
currentPercentage={currentPct}
|
||||
onSeek={(pct) => onSeekToTime?.(elStart + (pct / 100) * elDuration)}
|
||||
onSeek={seekFromKfPct}
|
||||
onAddKeyframe={() =>
|
||||
onCommitAnimatedProperty &&
|
||||
void onCommitAnimatedProperty(element, "width", displayW)
|
||||
@@ -426,9 +463,9 @@ export const PropertyPanel = memo(function PropertyPanel({
|
||||
{STUDIO_KEYFRAMES_ENABLED && gsapAnimId && (
|
||||
<KeyframeNavigation
|
||||
property="height"
|
||||
keyframes={gsapKeyframes}
|
||||
keyframes={navKeyframes}
|
||||
currentPercentage={currentPct}
|
||||
onSeek={(pct) => onSeekToTime?.(elStart + (pct / 100) * elDuration)}
|
||||
onSeek={seekFromKfPct}
|
||||
onAddKeyframe={() =>
|
||||
onCommitAnimatedProperty &&
|
||||
void onCommitAnimatedProperty(element, "height", displayH)
|
||||
@@ -449,9 +486,9 @@ export const PropertyPanel = memo(function PropertyPanel({
|
||||
{STUDIO_KEYFRAMES_ENABLED && gsapAnimId && (
|
||||
<KeyframeNavigation
|
||||
property="rotation"
|
||||
keyframes={gsapKeyframes}
|
||||
keyframes={navKeyframes}
|
||||
currentPercentage={currentPct}
|
||||
onSeek={(pct) => onSeekToTime?.(elStart + (pct / 100) * elDuration)}
|
||||
onSeek={seekFromKfPct}
|
||||
onAddKeyframe={() =>
|
||||
onCommitAnimatedProperty &&
|
||||
void onCommitAnimatedProperty(element, "rotation", displayR)
|
||||
@@ -466,7 +503,7 @@ export const PropertyPanel = memo(function PropertyPanel({
|
||||
<PropertyPanel3dTransform
|
||||
gsapRuntimeValues={gsapRuntimeValues}
|
||||
gsapAnimId={gsapAnimId}
|
||||
gsapKeyframes={gsapKeyframes}
|
||||
gsapKeyframes={navKeyframes}
|
||||
currentPct={currentPct}
|
||||
elStart={elStart}
|
||||
elDuration={elDuration}
|
||||
|
||||
@@ -121,6 +121,7 @@ export function startGesture(
|
||||
|
||||
if (kind === "drag") {
|
||||
opts.onManualDragStartRef.current?.();
|
||||
opts.rafPausedRef.current = true;
|
||||
const result = createManualOffsetDragMember({
|
||||
key: selectionCacheKey(sel),
|
||||
selection: sel,
|
||||
|
||||
@@ -520,17 +520,19 @@ function queryStudioElements(doc: Document, attr: string): HTMLElement[] {
|
||||
|
||||
function reapplyPathOffsets(doc: Document): void {
|
||||
for (const el of queryStudioElements(doc, STUDIO_PATH_OFFSET_ATTR)) {
|
||||
// Skip elements where GSAP actively animates position — GSAP bakes the
|
||||
// CSS translate into its transform and sets translate: none every tick.
|
||||
// Stripping/restoring would oscillate against GSAP's rendering.
|
||||
if (gsapAnimatesProperty(el, "x", "y")) continue;
|
||||
const gsapSkip = gsapAnimatesProperty(el, "x", "y");
|
||||
const x = el.style.getPropertyValue(STUDIO_OFFSET_X_PROP);
|
||||
const y = el.style.getPropertyValue(STUDIO_OFFSET_Y_PROP);
|
||||
if (gsapSkip) continue;
|
||||
if (x || y) {
|
||||
applyStudioPathOffset(el, {
|
||||
x: Number.parseFloat(x) || 0,
|
||||
y: Number.parseFloat(y) || 0,
|
||||
});
|
||||
applyStudioPathOffset(
|
||||
el,
|
||||
{
|
||||
x: Number.parseFloat(x) || 0,
|
||||
y: Number.parseFloat(y) || 0,
|
||||
},
|
||||
{ updateBase: false },
|
||||
);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -66,6 +66,7 @@ describe("measureManualOffsetDragScreenToOffsetMatrix", () => {
|
||||
it("measures the element center response and restores probe styles", () => {
|
||||
const window = new Window();
|
||||
const element = window.document.createElement("div");
|
||||
element.setAttribute("data-hf-studio-path-offset", "true");
|
||||
window.document.body.append(element);
|
||||
|
||||
element.getBoundingClientRect = () => {
|
||||
@@ -109,6 +110,7 @@ describe("measureManualOffsetDragScreenToOffsetMatrix", () => {
|
||||
iframe.getBoundingClientRect = () => new window.DOMRect(50, 40, 100, 50);
|
||||
|
||||
const element = iframeDocument.createElement("div");
|
||||
element.setAttribute("data-hf-studio-path-offset", "true");
|
||||
iframeDocument.body.append(element);
|
||||
element.getBoundingClientRect = () => {
|
||||
const offsetX = Number.parseFloat(element.style.getPropertyValue(STUDIO_OFFSET_X_PROP)) || 0;
|
||||
@@ -130,7 +132,7 @@ describe("measureManualOffsetDragScreenToOffsetMatrix", () => {
|
||||
expect(nextOffset).toEqual({ x: 100, y: 50 });
|
||||
});
|
||||
|
||||
it("rejects elements whose movement response cannot be measured", () => {
|
||||
it("returns identity matrix for non-path-offset elements with zero initial offset", () => {
|
||||
const window = new Window();
|
||||
const element = window.document.createElement("div");
|
||||
window.document.body.append(element);
|
||||
@@ -138,6 +140,21 @@ describe("measureManualOffsetDragScreenToOffsetMatrix", () => {
|
||||
|
||||
const measured = measureManualOffsetDragScreenToOffsetMatrix(element, { x: 0, y: 0 });
|
||||
|
||||
expect(measured.ok).toBe(true);
|
||||
if (measured.ok) {
|
||||
expectMatrixClose(measured.matrix, { a: 1, b: 0, c: 0, d: 1 });
|
||||
}
|
||||
});
|
||||
|
||||
it("rejects path-offset elements whose movement response cannot be measured", () => {
|
||||
const window = new Window();
|
||||
const element = window.document.createElement("div");
|
||||
element.setAttribute("data-hf-studio-path-offset", "true");
|
||||
window.document.body.append(element);
|
||||
element.getBoundingClientRect = () => new window.DOMRect(10, 20, 12, 8);
|
||||
|
||||
const measured = measureManualOffsetDragScreenToOffsetMatrix(element, { x: 0, y: 0 });
|
||||
|
||||
expect(measured.ok).toBe(false);
|
||||
});
|
||||
});
|
||||
|
||||
@@ -142,8 +142,18 @@ export function applyManualOffsetDragMatrix(matrix: ManualOffsetDragMatrix, poin
|
||||
export function measureManualOffsetDragScreenToOffsetMatrix(
|
||||
element: HTMLElement,
|
||||
initialOffset: { x: number; y: number },
|
||||
options: { probeSize?: number } = {},
|
||||
options: { probeSize?: number; scaleX?: number; scaleY?: number } = {},
|
||||
): { ok: true; matrix: ManualOffsetDragMatrix } | { ok: false; reason: string } {
|
||||
if (
|
||||
!element.hasAttribute("data-hf-studio-path-offset") &&
|
||||
initialOffset.x === 0 &&
|
||||
initialOffset.y === 0
|
||||
) {
|
||||
const sx = options.scaleX || 1;
|
||||
const sy = options.scaleY || 1;
|
||||
return { ok: true, matrix: { a: 1 / sx, b: 0, c: 0, d: 1 / sy } };
|
||||
}
|
||||
|
||||
const probeSize = options.probeSize ?? DEFAULT_OFFSET_PROBE_PX;
|
||||
if (!Number.isFinite(probeSize) || probeSize <= 0) {
|
||||
return { ok: false, reason: "Invalid movement probe size." };
|
||||
@@ -235,8 +245,6 @@ export function createManualOffsetDragMember(input: {
|
||||
input.element.setAttribute("data-hf-drag-initial-offset-x", String(initialOffset.x));
|
||||
input.element.setAttribute("data-hf-drag-initial-offset-y", String(initialOffset.y));
|
||||
|
||||
// Capture GSAP's x/y BEFORE any draft applies gsap.set — the commit path
|
||||
// needs the original (uncorrupted) GSAP position to compute the new keyframe value.
|
||||
const win = input.element.ownerDocument.defaultView as
|
||||
| (Window & {
|
||||
gsap?: { getProperty?: (el: Element, prop: string) => number };
|
||||
@@ -248,8 +256,6 @@ export function createManualOffsetDragMember(input: {
|
||||
input.element.setAttribute("data-hf-drag-gsap-base-x", String(gsapX));
|
||||
input.element.setAttribute("data-hf-drag-gsap-base-y", String(gsapY));
|
||||
|
||||
// Pause GSAP timelines during drag to prevent the tween from overwriting
|
||||
// the draft's gsap.set on every tick. Track which we paused to resume later.
|
||||
if (win?.__timelines) {
|
||||
const paused: string[] = [];
|
||||
for (const [id, tl] of Object.entries(win.__timelines)) {
|
||||
@@ -269,7 +275,10 @@ export function createManualOffsetDragMember(input: {
|
||||
|
||||
const initialPathOffset = captureStudioPathOffset(input.element);
|
||||
const gestureToken = beginStudioManualEditGesture(input.element);
|
||||
const measured = measureManualOffsetDragScreenToOffsetMatrix(input.element, initialOffset);
|
||||
const measured = measureManualOffsetDragScreenToOffsetMatrix(input.element, initialOffset, {
|
||||
scaleX: input.rect.editScaleX,
|
||||
scaleY: input.rect.editScaleY,
|
||||
});
|
||||
if (!measured.ok) {
|
||||
// Fallback: when GSAP transforms interfere with probe measurement, use
|
||||
// the preview scale as an approximation. The commit path reads the actual
|
||||
@@ -363,7 +372,7 @@ export function endManualOffsetDragMembers(members: ManualOffsetDragMember[]): v
|
||||
}
|
||||
}
|
||||
|
||||
function resumeGsapTimelines(element: HTMLElement): void {
|
||||
export function resumeGsapTimelines(element: HTMLElement): void {
|
||||
const ids = element.getAttribute("data-hf-drag-paused-timelines");
|
||||
element.removeAttribute("data-hf-drag-paused-timelines");
|
||||
if (!ids) return;
|
||||
@@ -374,9 +383,6 @@ function resumeGsapTimelines(element: HTMLElement): void {
|
||||
})
|
||||
| null;
|
||||
if (!win) return;
|
||||
// Re-seek to the current time to restore the paused timeline's render state.
|
||||
// play() would start playback; pause() already stops. Seek re-renders at the
|
||||
// current position without starting playback.
|
||||
const t = win.__player?.getTime?.() ?? 0;
|
||||
win.__player?.seek?.(t);
|
||||
}
|
||||
|
||||
@@ -369,15 +369,7 @@ export function StyleSections({
|
||||
</div>
|
||||
</Section>
|
||||
|
||||
<Section
|
||||
title="Fill"
|
||||
icon={<Palette size={15} />}
|
||||
accessory={
|
||||
<div className="rounded-full border border-neutral-700 bg-neutral-900 px-2.5 py-1 text-[10px] font-medium uppercase tracking-[0.16em] text-neutral-400">
|
||||
{preferredFillMode}
|
||||
</div>
|
||||
}
|
||||
>
|
||||
<Section title="Fill" icon={<Palette size={15} />}>
|
||||
<div className="space-y-4">
|
||||
<SegmentedControl
|
||||
disabled={styleEditingDisabled}
|
||||
|
||||
@@ -11,6 +11,7 @@ import {
|
||||
applyManualOffsetDragDraft,
|
||||
endManualOffsetDragMembers,
|
||||
restoreManualOffsetDragMembers,
|
||||
resumeGsapTimelines,
|
||||
} from "./manualOffsetDrag";
|
||||
import {
|
||||
applyStudioBoxSize,
|
||||
@@ -401,6 +402,7 @@ export function createDomEditOverlayGestureHandlers(opts: UseDomEditOverlayGestu
|
||||
if (g.kind === "drag" && movedDistance < BLOCKED_MOVE_THRESHOLD_PX) {
|
||||
restoreStudioPathOffset(sel.element, g.initialPathOffset);
|
||||
endStudioManualEditGesture(sel.element, g.manualEditDragToken);
|
||||
resumeGsapTimelines(sel.element);
|
||||
if (box) {
|
||||
box.style.left = `${g.originLeft}px`;
|
||||
box.style.top = `${g.originTop}px`;
|
||||
@@ -507,6 +509,7 @@ export function createDomEditOverlayGestureHandlers(opts: UseDomEditOverlayGestu
|
||||
if (g?.mode === "path-offset" && sel) {
|
||||
restoreStudioPathOffset(sel.element, g.initialPathOffset);
|
||||
endStudioManualEditGesture(sel.element, g.manualEditDragToken);
|
||||
resumeGsapTimelines(sel.element);
|
||||
restoreGestureOverlayRect(g);
|
||||
}
|
||||
if (g?.mode === "box-size" && sel) {
|
||||
|
||||
@@ -138,9 +138,6 @@ export function useLayerDrag({
|
||||
const container = scrollContainerRef.current;
|
||||
if (!container) return;
|
||||
|
||||
e.preventDefault();
|
||||
container.setPointerCapture(e.pointerId);
|
||||
|
||||
dragRef.current = {
|
||||
pointerId: e.pointerId,
|
||||
startY: e.clientY,
|
||||
@@ -163,6 +160,12 @@ export function useLayerDrag({
|
||||
if (!drag.activated) {
|
||||
if (Math.abs(e.clientY - drag.startY) < DRAG_THRESHOLD_PX) return;
|
||||
drag.activated = true;
|
||||
const container = scrollContainerRef.current;
|
||||
if (container && drag.pointerId != null) {
|
||||
try {
|
||||
container.setPointerCapture(drag.pointerId);
|
||||
} catch {}
|
||||
}
|
||||
setDragKey(visibleLayers[drag.dragLayerIndex]?.key ?? null);
|
||||
}
|
||||
|
||||
|
||||
@@ -142,53 +142,54 @@ export const RenderQueueItem = memo(function RenderQueueItem({
|
||||
)}
|
||||
</div>
|
||||
|
||||
{/* Actions */}
|
||||
{hovered && (
|
||||
<div className="flex items-center gap-1 flex-shrink-0">
|
||||
{isComplete && (
|
||||
<button
|
||||
onClick={handleDownload}
|
||||
className="p-1 rounded text-panel-text-4 hover:text-panel-accent transition-colors"
|
||||
title="Download"
|
||||
>
|
||||
<svg
|
||||
width="12"
|
||||
height="12"
|
||||
viewBox="0 0 24 24"
|
||||
fill="none"
|
||||
stroke="currentColor"
|
||||
strokeWidth="2"
|
||||
strokeLinecap="round"
|
||||
strokeLinejoin="round"
|
||||
>
|
||||
<path d="M21 15v4a2 2 0 01-2 2H5a2 2 0 01-2-2v-4" />
|
||||
<polyline points="7 10 12 15 17 10" />
|
||||
<line x1="12" y1="15" x2="12" y2="3" />
|
||||
</svg>
|
||||
</button>
|
||||
)}
|
||||
<button
|
||||
onClick={(e) => {
|
||||
e.stopPropagation();
|
||||
onDelete();
|
||||
}}
|
||||
className="p-1 rounded text-panel-text-4 hover:text-red-400 transition-colors"
|
||||
title="Remove"
|
||||
{/* Actions — always visible to prevent layout shifts */}
|
||||
<div className="flex items-center gap-1 flex-shrink-0">
|
||||
<button
|
||||
onClick={isComplete ? handleDownload : undefined}
|
||||
className={`p-1 rounded transition-colors ${
|
||||
isComplete
|
||||
? "text-panel-text-5 hover:text-panel-accent"
|
||||
: "text-panel-text-5/30 pointer-events-none"
|
||||
}`}
|
||||
title={isComplete ? "Download" : "Rendering..."}
|
||||
disabled={!isComplete}
|
||||
>
|
||||
<svg
|
||||
width="12"
|
||||
height="12"
|
||||
viewBox="0 0 24 24"
|
||||
fill="none"
|
||||
stroke="currentColor"
|
||||
strokeWidth="2"
|
||||
strokeLinecap="round"
|
||||
strokeLinejoin="round"
|
||||
>
|
||||
<svg
|
||||
width="12"
|
||||
height="12"
|
||||
viewBox="0 0 24 24"
|
||||
fill="none"
|
||||
stroke="currentColor"
|
||||
strokeWidth="2"
|
||||
strokeLinecap="round"
|
||||
>
|
||||
<path d="M18 6L6 18M6 6l12 12" />
|
||||
</svg>
|
||||
</button>
|
||||
</div>
|
||||
)}
|
||||
<path d="M21 15v4a2 2 0 01-2 2H5a2 2 0 01-2-2v-4" />
|
||||
<polyline points="7 10 12 15 17 10" />
|
||||
<line x1="12" y1="15" x2="12" y2="3" />
|
||||
</svg>
|
||||
</button>
|
||||
<button
|
||||
onClick={(e) => {
|
||||
e.stopPropagation();
|
||||
onDelete();
|
||||
}}
|
||||
className="p-1 rounded text-panel-text-5 hover:text-red-400 transition-colors"
|
||||
title="Remove"
|
||||
>
|
||||
<svg
|
||||
width="12"
|
||||
height="12"
|
||||
viewBox="0 0 24 24"
|
||||
fill="none"
|
||||
stroke="currentColor"
|
||||
strokeWidth="2"
|
||||
strokeLinecap="round"
|
||||
>
|
||||
<path d="M18 6L6 18M6 6l12 12" />
|
||||
</svg>
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
|
||||
@@ -7,6 +7,7 @@ interface CompositionsTabProps {
|
||||
onSelect: (comp: string) => void;
|
||||
onRenderComposition?: (comp: string) => void;
|
||||
isRendering?: boolean;
|
||||
lintFindingsByFile?: Map<string, { count: number; messages: string[] }>;
|
||||
}
|
||||
|
||||
const DEFAULT_PREVIEW_STAGE = { width: 1920, height: 1080 };
|
||||
@@ -111,6 +112,7 @@ function CompCard({
|
||||
onSelect,
|
||||
onRender,
|
||||
isRendering,
|
||||
lintInfo,
|
||||
}: {
|
||||
projectId: string;
|
||||
comp: string;
|
||||
@@ -118,6 +120,7 @@ function CompCard({
|
||||
onSelect: () => void;
|
||||
onRender?: () => void;
|
||||
isRendering?: boolean;
|
||||
lintInfo?: { count: number; messages: string[] };
|
||||
}) {
|
||||
const [hovered, setHovered] = useState(false);
|
||||
const [stageSize, setStageSize] = useState(DEFAULT_PREVIEW_STAGE);
|
||||
@@ -215,8 +218,16 @@ function CompCard({
|
||||
tabIndex={-1}
|
||||
/>
|
||||
</div>
|
||||
<div className="min-w-0 flex-1">
|
||||
<span className="text-[11px] font-medium text-neutral-300 truncate block">{name}</span>
|
||||
<div
|
||||
className="min-w-0 flex-1"
|
||||
title={lintInfo && lintInfo.count > 0 ? lintInfo.messages.join("\n") : undefined}
|
||||
>
|
||||
<div className="flex items-center gap-1">
|
||||
<span className="text-[11px] font-medium text-neutral-300 truncate">{name}</span>
|
||||
{lintInfo && lintInfo.count > 0 && (
|
||||
<span className="flex-shrink-0 w-2 h-2 rounded-full bg-amber-400" />
|
||||
)}
|
||||
</div>
|
||||
<span className="text-[9px] text-neutral-600 truncate block">{comp}</span>
|
||||
</div>
|
||||
{onRender && (
|
||||
@@ -262,6 +273,7 @@ export const CompositionsTab = memo(function CompositionsTab({
|
||||
onSelect,
|
||||
onRenderComposition,
|
||||
isRendering,
|
||||
lintFindingsByFile,
|
||||
}: CompositionsTabProps) {
|
||||
if (compositions.length === 0) {
|
||||
return (
|
||||
@@ -282,6 +294,7 @@ export const CompositionsTab = memo(function CompositionsTab({
|
||||
onSelect={() => onSelect(comp)}
|
||||
onRender={onRenderComposition ? () => onRenderComposition(comp) : undefined}
|
||||
isRendering={isRendering}
|
||||
lintInfo={lintFindingsByFile?.get(comp)}
|
||||
/>
|
||||
))}
|
||||
</div>
|
||||
|
||||
@@ -54,6 +54,8 @@ interface LeftSidebarProps {
|
||||
isRendering?: boolean;
|
||||
onLint?: () => void;
|
||||
linting?: boolean;
|
||||
lintFindingCount?: number;
|
||||
lintFindingsByFile?: Map<string, { count: number; messages: string[] }>;
|
||||
onToggleCollapse?: () => void;
|
||||
onAddBlock?: (blockName: string) => void;
|
||||
onPreviewBlock?: (preview: BlockPreviewInfo | null) => void;
|
||||
@@ -84,6 +86,8 @@ export const LeftSidebar = memo(
|
||||
isRendering,
|
||||
onLint,
|
||||
linting,
|
||||
lintFindingCount,
|
||||
lintFindingsByFile,
|
||||
onToggleCollapse,
|
||||
onAddBlock,
|
||||
onPreviewBlock,
|
||||
@@ -216,6 +220,7 @@ export const LeftSidebar = memo(
|
||||
onSelect={onSelectComposition}
|
||||
onRenderComposition={onRenderComposition}
|
||||
isRendering={isRendering}
|
||||
lintFindingsByFile={lintFindingsByFile}
|
||||
/>
|
||||
)}
|
||||
{tab === "assets" && (
|
||||
@@ -242,6 +247,7 @@ export const LeftSidebar = memo(
|
||||
onDuplicateFile={onDuplicateFile}
|
||||
onMoveFile={onMoveFile}
|
||||
onImportFiles={onImportFiles}
|
||||
lintFindingsByFile={lintFindingsByFile}
|
||||
/>
|
||||
</div>
|
||||
)}
|
||||
@@ -279,6 +285,11 @@ export const LeftSidebar = memo(
|
||||
<path d="M21 12v7a2 2 0 01-2 2H5a2 2 0 01-2-2V5a2 2 0 012-2h11" />
|
||||
</svg>
|
||||
{linting ? "Linting…" : "Lint"}
|
||||
{!linting && lintFindingCount != null && lintFindingCount > 0 && (
|
||||
<span className="ml-1 min-w-[16px] rounded-full bg-amber-500/20 px-1 text-[9px] font-bold text-amber-400">
|
||||
{lintFindingCount}
|
||||
</span>
|
||||
)}
|
||||
</button>
|
||||
</div>
|
||||
)}
|
||||
|
||||
Reference in New Issue
Block a user