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:
Miguel Ángel
2026-06-10 18:53:06 -04:00
committed by GitHub
parent 3a72aa528d
commit f0b499b582
38 changed files with 1641 additions and 821 deletions
@@ -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>
)}