mirror of
https://github.com/heygen-com/hyperframes.git
synced 2026-09-08 19:06:04 +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:
@@ -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);
|
||||
}
|
||||
|
||||
|
||||
Reference in New Issue
Block a user