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
+29
View File
@@ -867,4 +867,33 @@ export const gsapRules: LintRule<LintContext>[] = [
}
return findings;
},
// gsap_group_selector_keyframes
({ scripts }) => {
const findings: HyperframeLintFinding[] = [];
for (const script of scripts) {
const content = stripJsComments(script.content);
const pattern = /\.(?:to|from|fromTo)\(\s*["']([^"']+,\s*[^"']+)["']\s*,\s*\{[^}]*keyframes/g;
let match: RegExpExecArray | null;
while ((match = pattern.exec(content)) !== null) {
const selector = match[1]!;
const count = selector.split(",").length;
const contextStart = Math.max(0, match.index - 20);
const contextEnd = Math.min(content.length, match.index + match[0].length + 40);
findings.push({
code: "gsap_group_selector_keyframes",
severity: "warning",
message:
`GSAP tween targets ${count} elements with shared keyframes ("${truncateSnippet(selector, 60)}"). ` +
`Editing one element's keyframes in Studio will affect all ${count} elements. ` +
`Split into individual tweens for per-element keyframe control.`,
fixHint:
`Replace the group selector with individual tl.to() calls per element, ` +
`each with their own keyframes object.`,
snippet: truncateSnippet(content.slice(contextStart, contextEnd)),
});
}
}
return findings;
},
];
@@ -1383,6 +1383,95 @@ describe("keyframe mutations", () => {
expect(kfs[1].properties.x).toBe(999);
});
// ── _auto endpoint updates ────────────────────────────────────────────
const AUTO_SCRIPT = `
const tl = gsap.timeline({ paused: true });
tl.to("#hero", {
keyframes: { "0%": { x: 0, y: 0, _auto: 1 }, "100%": { x: 200, y: 100, _auto: 1 } },
duration: 2
}, 0);
`;
const AUTO_5KF_SCRIPT = `
const tl = gsap.timeline({ paused: true });
tl.to("#hero", {
keyframes: {
"0%": { x: 0, y: 0, _auto: 1 },
"25%": { x: 50, y: 25 },
"50%": { x: 100, y: 50 },
"75%": { x: 150, y: 75 },
"100%": { x: 200, y: 100, _auto: 1 }
},
duration: 2
}, 0);
`;
it("addKeyframe adjacent to auto 100% — updates 100%", () => {
const id = getAnimId(AUTO_SCRIPT);
const updated = addKeyframeToScript(AUTO_SCRIPT, id, 50, { x: 300, y: 200 });
const kfs = parseGsapScript(updated).animations[0].keyframes!.keyframes;
const kf100 = kfs.find((k) => k.percentage === 100)!;
expect(kf100.properties.x).toBe(300);
expect(kf100.properties.y).toBe(200);
});
it("addKeyframe adjacent to auto 0% — updates 0%", () => {
const id = getAnimId(AUTO_SCRIPT);
const updated = addKeyframeToScript(AUTO_SCRIPT, id, 50, { x: 300, y: 200 });
const kfs = parseGsapScript(updated).animations[0].keyframes!.keyframes;
const kf0 = kfs.find((k) => k.percentage === 0)!;
expect(kf0.properties.x).toBe(300);
expect(kf0.properties.y).toBe(200);
});
it("addKeyframe NOT adjacent to auto 100% — leaves 100% untouched", () => {
const id = getAnimId(AUTO_5KF_SCRIPT);
const updated = addKeyframeToScript(AUTO_5KF_SCRIPT, id, 74, { x: 999, y: 888 });
const kfs = parseGsapScript(updated).animations[0].keyframes!.keyframes;
const kf100 = kfs.find((k) => k.percentage === 100)!;
expect(kf100.properties.x).toBe(200);
expect(kf100.properties.y).toBe(100);
});
it("addKeyframe NOT adjacent to auto 0% — leaves 0% untouched", () => {
const id = getAnimId(AUTO_5KF_SCRIPT);
const updated = addKeyframeToScript(AUTO_5KF_SCRIPT, id, 30, { x: 999, y: 888 });
const kfs = parseGsapScript(updated).animations[0].keyframes!.keyframes;
const kf0 = kfs.find((k) => k.percentage === 0)!;
expect(kf0.properties.x).toBe(0);
expect(kf0.properties.y).toBe(0);
});
it("addKeyframe at 88% in 5-keyframe set — updates adjacent 100% only", () => {
const id = getAnimId(AUTO_5KF_SCRIPT);
const updated = addKeyframeToScript(AUTO_5KF_SCRIPT, id, 88, { x: 500, y: 400 });
const kfs = parseGsapScript(updated).animations[0].keyframes!.keyframes;
const kf100 = kfs.find((k) => k.percentage === 100)!;
const kf0 = kfs.find((k) => k.percentage === 0)!;
expect(kf100.properties.x).toBe(500);
expect(kf0.properties.x).toBe(0);
});
it("addKeyframe at 12% in 5-keyframe set — updates adjacent 0% only", () => {
const id = getAnimId(AUTO_5KF_SCRIPT);
const updated = addKeyframeToScript(AUTO_5KF_SCRIPT, id, 12, { x: 500, y: 400 });
const kfs = parseGsapScript(updated).animations[0].keyframes!.keyframes;
const kf0 = kfs.find((k) => k.percentage === 0)!;
const kf100 = kfs.find((k) => k.percentage === 100)!;
expect(kf0.properties.x).toBe(500);
expect(kf100.properties.x).toBe(200);
});
it("non-auto 100% is never modified", () => {
const id = getAnimId(KF_SCRIPT);
const updated = addKeyframeToScript(KF_SCRIPT, id, 50, { x: 999 });
const kfs = parseGsapScript(updated).animations[0].keyframes!.keyframes;
const kf100 = kfs.find((k) => k.percentage === 100)!;
expect(kf100.properties.x).toBe(200);
expect(kf100.properties.opacity).toBe(1);
});
// ── removeKeyframeFromScript ────────────────────────────────────────────
it("removeKeyframeFromScript — removes one keyframe", () => {
+27 -18
View File
@@ -1404,21 +1404,30 @@ export function addKeyframeToScript(
kfNode.properties.splice(insertIdx, 0, newProp);
}
// Auto-update 100%: if the 100% keyframe still has `_auto: 1` (never
// explicitly edited by the user), update it to match the new keyframe's
// values so the element holds its final position instead of snapping back.
// Once the user drags at 100%, `_auto` is gone and we stop touching it.
if (percentage < 100 && percentage !== 0) {
// Auto-update adjacent endpoints: only update an `_auto` 0% or 100%
// keyframe when the new keyframe is directly next to it (no other keyframe
// between them). This prevents a keyframe at 74% from clobbering 100% when
// 75% already exists, and a keyframe at 30% from clobbering 0% when 25%
// already exists.
if (percentage > 0 && percentage < 100) {
const pctProps = filterPercentageProps(kfNode);
const hundredProp = pctProps.find((p: any) => percentageFromKey(propKeyName(p) ?? "") === 100);
if (hundredProp?.value?.type === "ObjectExpression") {
const hasAuto = hundredProp.value.properties.some(
const allPcts = pctProps
.map((p: any) => percentageFromKey(propKeyName(p) ?? ""))
.filter((n: number) => !Number.isNaN(n) && n !== percentage)
.sort((a: number, b: number) => a - b);
const leftNeighbor = allPcts.filter((p: number) => p < percentage).pop();
const rightNeighbor = allPcts.find((p: number) => p > percentage);
for (const endPct of [0, 100]) {
const isNeighbor = endPct === 0 ? leftNeighbor === 0 : rightNeighbor === 100;
if (!isNeighbor) continue;
const endProp = pctProps.find((p: any) => percentageFromKey(propKeyName(p) ?? "") === endPct);
if (!endProp?.value || endProp.value.type !== "ObjectExpression") continue;
const hasAuto = endProp.value.properties.some(
(p: any) => isObjectProperty(p) && propKeyName(p) === "_auto",
);
if (hasAuto) {
const updatedProps = { ...properties, _auto: 1 as number | string };
hundredProp.value = buildKeyframeValueNode(updatedProps, undefined);
}
if (!hasAuto) continue;
const updatedProps = { ...properties, _auto: 1 as number | string };
endProp.value = buildKeyframeValueNode(updatedProps, undefined);
}
}
@@ -1623,18 +1632,18 @@ export function removeAllKeyframesFromScript(script: string, animationId: string
const kfNode = findKeyframesObjectNode(loc.target.call.varsArg);
if (!kfNode) return script;
// Collect all percentage keyframe entries, sorted
const kfEntries = filterPercentageProps(kfNode)
.map((p: any) => ({ pct: percentageFromKey(propKeyName(p)!), prop: p }))
.filter((e) => !Number.isNaN(e.pct))
.sort((a, b) => a.pct - b.pct);
if (kfEntries.length === 0) return script;
const lastRecord = objectExpressionToRecord(
kfEntries[kfEntries.length - 1]!.prop.value,
loc.parsed.scope,
);
collapseKeyframesToFlat(loc.target.call.varsArg, lastRecord);
// For to()/set(): collapse to last keyframe (the destination = visible state).
// For from(): collapse to first keyframe (the starting state).
const method = loc.target.call.method;
const collapseEntry = method === "from" ? kfEntries[0]! : kfEntries[kfEntries.length - 1]!;
const record = objectExpressionToRecord(collapseEntry.prop.value, loc.parsed.scope);
collapseKeyframesToFlat(loc.target.call.varsArg, record);
return recast.print(loc.parsed.ast).code;
}
+501 -398
View File
@@ -197,15 +197,12 @@ function updateReferences(projectDir: string, oldPath: string, newPath: string):
* contains GSAP timeline code, and return both its text content and a
* function that replaces that script block and serialises back to HTML.
*/
function extractGsapScriptBlock(
html: string,
): { scriptText: string; replaceScript: (newText: string) => string } | null {
function extractGsapScriptBlock(html: string): {
scriptText: string;
document: Document;
replaceScript: (newText: string) => string;
} | null {
const { document } = parseHTML(html);
// linkedom's querySelectorAll doesn't descend into <template> content, but
// sub-compositions wrap their markup (and the GSAP <script>) in a <template>.
// Search top-level scripts first, then each template's own scripts. Operate
// on the template element directly (NOT .content) so textContent writes are
// reflected in document.toString().
const scripts = [
...document.querySelectorAll("script:not([src])"),
...Array.from(document.querySelectorAll("template")).flatMap((tmpl) =>
@@ -221,6 +218,7 @@ function extractGsapScriptBlock(
) {
return {
scriptText: content,
document,
replaceScript(newText: string): string {
script.textContent = newText;
return document.toString();
@@ -231,11 +229,473 @@ function extractGsapScriptBlock(
return null;
}
function stripStudioEditsFromTarget(document: Document, selector: string): number {
if (!selector) return 0;
let stripped = 0;
try {
for (const el of document.querySelectorAll(selector)) {
if (!el.getAttribute("data-hf-studio-path-offset")) continue;
const htmlEl = el as unknown as HTMLElement;
const originalTranslate = el.getAttribute("data-hf-studio-original-inline-translate");
htmlEl.style.removeProperty("--hf-studio-offset-x");
htmlEl.style.removeProperty("--hf-studio-offset-y");
if (originalTranslate) {
htmlEl.style.setProperty("translate", originalTranslate);
} else {
htmlEl.style.removeProperty("translate");
}
el.removeAttribute("data-hf-studio-path-offset");
el.removeAttribute("data-hf-studio-original-translate");
el.removeAttribute("data-hf-studio-original-inline-translate");
stripped++;
}
} catch {
// Invalid selector — skip silently.
}
return stripped;
}
function bakeVisibilityOnDelete(document: Document, anim: GsapAnimation): void {
let finalOpacity: number | string | undefined;
if (anim.method === "from") {
return;
}
if (anim.keyframes) {
const kfs = anim.keyframes.keyframes;
for (let i = kfs.length - 1; i >= 0; i--) {
if ("opacity" in kfs[i]!.properties) {
finalOpacity = kfs[i]!.properties.opacity;
break;
}
}
} else if ("opacity" in anim.properties) {
finalOpacity = anim.properties.opacity;
}
if (finalOpacity == null) {
return;
}
if (typeof finalOpacity === "string" && /^[+\-*]=/.test(finalOpacity)) {
return;
}
const numOpacity = Number(finalOpacity);
if (!Number.isFinite(numOpacity) || numOpacity === 0) return;
try {
for (const el of document.querySelectorAll(anim.targetSelector)) {
(el as unknown as HTMLElement).style.setProperty("opacity", String(numOpacity));
}
} catch {
// Invalid selector — skip silently.
}
}
/** Lazy-load gsapParser to avoid pulling recast into every file-route import. */
async function loadGsapParser() {
return import("../../parsers/gsapParser.js");
}
// ── GSAP mutation types ─────────────────────────────────────────────────────
type GsapMutationRequest =
| {
type: "update-property";
animationId: string;
property: string;
value: number | string;
}
| {
type: "update-from-property";
animationId: string;
property: string;
value: number | string;
}
| {
type: "update-meta";
animationId: string;
updates: { duration?: number; ease?: string; position?: number };
}
| {
type: "add";
targetSelector: string;
method: "to" | "from" | "set" | "fromTo";
position: number;
duration?: number;
ease?: string;
properties: Record<string, number | string>;
fromProperties?: Record<string, number | string>;
}
| { type: "delete"; animationId: string; stripStudioEdits?: boolean }
| {
type: "add-property";
animationId: string;
property: string;
defaultValue: number | string;
}
| {
type: "add-from-property";
animationId: string;
property: string;
defaultValue: number | string;
}
| { type: "remove-property"; animationId: string; property: string }
| { type: "remove-from-property"; animationId: string; property: string }
| {
type: "add-keyframe";
animationId: string;
percentage: number;
properties: Record<string, number | string>;
ease?: string;
backfillDefaults?: Record<string, number | string>;
}
| { type: "remove-keyframe"; animationId: string; percentage: number }
| {
type: "update-keyframe";
animationId: string;
percentage: number;
properties: Record<string, number | string>;
ease?: string;
}
| {
type: "convert-to-keyframes";
animationId: string;
resolvedFromValues?: Record<string, number | string>;
}
| { type: "remove-all-keyframes"; animationId: string }
| {
type: "materialize-keyframes";
animationId: string;
keyframes: Array<{
percentage: number;
properties: Record<string, number | string>;
ease?: string;
}>;
easeEach?: string;
resolvedSelector?: string;
allElements?: Array<{
selector: string;
keyframes: Array<{ percentage: number; properties: Record<string, number | string> }>;
easeEach?: string;
}>;
}
| {
type: "set-arc-path";
animationId: string;
enabled: boolean;
autoRotate?: boolean | number;
segments?: Array<{
curviness: number;
cp1?: { x: number; y: number };
cp2?: { x: number; y: number };
}>;
}
| {
type: "update-arc-segment";
animationId: string;
segmentIndex: number;
curviness?: number;
cp1?: { x: number; y: number };
cp2?: { x: number; y: number };
}
| { type: "remove-arc-path"; animationId: string }
| {
type: "add-with-keyframes";
targetSelector: string;
position: number;
duration: number;
keyframes: Array<{
percentage: number;
properties: Record<string, number | string>;
ease?: string;
auto?: boolean;
}>;
ease?: string;
};
// ── GSAP mutation executor ──────────────────────────────────────────────────
async function executeGsapMutation(
body: GsapMutationRequest,
block: NonNullable<ReturnType<typeof extractGsapScriptBlock>>,
respond: (data: unknown, status?: number) => Response,
): Promise<string | Response> {
const parser = await loadGsapParser();
const {
parseGsapScript,
updateAnimationInScript,
addAnimationToScript,
removeAnimationFromScript,
addKeyframeToScript,
removeKeyframeFromScript,
updateKeyframeInScript,
convertToKeyframesInScript,
removeAllKeyframesFromScript,
materializeKeyframesInScript,
unrollDynamicAnimations,
setArcPathInScript,
updateArcSegmentInScript,
removeArcPathFromScript,
addAnimationWithKeyframesToScript,
} = parser;
function requireAnimation(
scriptText: string,
animationId: string,
): { anim: GsapAnimation } | { err: Response } {
const parsed = parseGsapScript(scriptText);
const anim = parsed.animations.find((a) => a.id === animationId);
if (!anim) return { err: respond({ error: "animation not found" }, 404) };
return { anim };
}
function requireFromToAnimation(
scriptText: string,
animationId: string,
): { anim: GsapAnimation } | { err: Response } {
const result = requireAnimation(scriptText, animationId);
if ("err" in result) return result;
if (result.anim.method !== "fromTo")
return { err: respond({ error: "animation is not a fromTo" }, 400) };
return result;
}
switch (body.type) {
case "update-property": {
const r = requireAnimation(block.scriptText, body.animationId);
if ("err" in r) return r.err;
return updateAnimationInScript(block.scriptText, body.animationId, {
properties: { ...r.anim.properties, [body.property]: body.value },
});
}
case "update-from-property": {
const r = requireFromToAnimation(block.scriptText, body.animationId);
if ("err" in r) return r.err;
return updateAnimationInScript(block.scriptText, body.animationId, {
fromProperties: { ...(r.anim.fromProperties ?? {}), [body.property]: body.value },
});
}
case "update-meta": {
return updateAnimationInScript(block.scriptText, body.animationId, body.updates);
}
case "add": {
if (body.fromProperties && body.method !== "fromTo") {
return respond({ error: "fromProperties is only valid for method=fromTo" }, 400);
}
const result = addAnimationToScript(block.scriptText, {
targetSelector: body.targetSelector,
method: body.method,
position: body.position,
duration: body.duration,
ease: body.ease,
properties: body.properties,
fromProperties: body.fromProperties,
});
return result.script;
}
case "delete": {
const delTarget = requireAnimation(block.scriptText, body.animationId);
if (!("err" in delTarget) && body.stripStudioEdits) {
stripStudioEditsFromTarget(block.document, delTarget.anim.targetSelector);
bakeVisibilityOnDelete(block.document, delTarget.anim);
}
return removeAnimationFromScript(block.scriptText, body.animationId);
}
case "add-property": {
const r = requireAnimation(block.scriptText, body.animationId);
if ("err" in r) return r.err;
return updateAnimationInScript(block.scriptText, body.animationId, {
properties: { ...r.anim.properties, [body.property]: body.defaultValue },
});
}
case "add-from-property": {
const r = requireFromToAnimation(block.scriptText, body.animationId);
if ("err" in r) return r.err;
return updateAnimationInScript(block.scriptText, body.animationId, {
fromProperties: { ...(r.anim.fromProperties ?? {}), [body.property]: body.defaultValue },
});
}
case "remove-property": {
const r = requireAnimation(block.scriptText, body.animationId);
if ("err" in r) return r.err;
const filtered = { ...r.anim.properties };
delete filtered[body.property];
return updateAnimationInScript(block.scriptText, body.animationId, {
properties: filtered,
});
}
case "remove-from-property": {
const r = requireFromToAnimation(block.scriptText, body.animationId);
if ("err" in r) return r.err;
const filtered = { ...(r.anim.fromProperties ?? {}) };
delete filtered[body.property];
return updateAnimationInScript(block.scriptText, body.animationId, {
fromProperties: filtered,
});
}
case "add-keyframe": {
return addKeyframeToScript(
block.scriptText,
body.animationId,
body.percentage,
body.properties,
body.ease,
body.backfillDefaults,
);
}
case "remove-keyframe": {
return removeKeyframeFromScript(block.scriptText, body.animationId, body.percentage);
}
case "update-keyframe": {
return updateKeyframeInScript(
block.scriptText,
body.animationId,
body.percentage,
body.properties,
body.ease,
);
}
case "convert-to-keyframes": {
return convertToKeyframesInScript(
block.scriptText,
body.animationId,
body.resolvedFromValues,
);
}
case "remove-all-keyframes": {
const preCollapse = requireAnimation(block.scriptText, body.animationId);
if (!("err" in preCollapse)) {
bakeVisibilityOnDelete(block.document, preCollapse.anim);
}
return removeAllKeyframesFromScript(block.scriptText, body.animationId);
}
case "materialize-keyframes": {
if (body.allElements && body.allElements.length > 0) {
return unrollDynamicAnimations(block.scriptText, body.animationId, body.allElements);
}
return materializeKeyframesInScript(
block.scriptText,
body.animationId,
body.keyframes,
body.easeEach,
body.resolvedSelector,
);
}
case "set-arc-path": {
return setArcPathInScript(block.scriptText, body.animationId, {
enabled: body.enabled,
autoRotate: body.autoRotate ?? false,
segments: body.segments ?? [],
});
}
case "update-arc-segment": {
return updateArcSegmentInScript(block.scriptText, body.animationId, body.segmentIndex, {
...(body.curviness !== undefined ? { curviness: body.curviness } : {}),
...(body.cp1 ? { cp1: body.cp1 } : {}),
...(body.cp2 ? { cp2: body.cp2 } : {}),
});
}
case "remove-arc-path": {
return removeArcPathFromScript(block.scriptText, body.animationId);
}
case "add-with-keyframes": {
const result = addAnimationWithKeyframesToScript(
block.scriptText,
body.targetSelector,
body.position,
body.duration,
body.keyframes,
body.ease,
);
return result.script;
}
default:
return respond({ error: `unknown mutation type: ${(body as { type: string }).type}` }, 400);
}
}
// ── Upload file processing ──────────────────────────────────────────────────
async function processUploadedFiles(
formData: FormData,
targetDir: string,
projectDir: string,
): Promise<{
uploaded: string[];
skipped: string[];
invalid: Array<{ name: string; reason: string }>;
}> {
const MAX_UPLOAD_BYTES = 500 * 1024 * 1024; // 500 MB per file
const uploaded: string[] = [];
const skipped: string[] = [];
const invalid: Array<{ name: string; reason: string }> = [];
// @types/node v25 narrows the ambient `FormData.entries()` to
// `[string, string]` in workspaces where another dep declares an
// `onmessage` global (it trips the worker branch of v25's conditional
// File type). At runtime the value is still `File | string` — cast the
// iterator so the rest of this block keeps type-checking on every
// bun-install layout (hoisted on Windows surfaces this; isolated on
// Linux happens to keep v24 in scope).
type FileLike = {
readonly name: string;
readonly size: number;
arrayBuffer(): Promise<ArrayBuffer>;
};
const entries = formData.entries() as unknown as Iterable<[string, FileLike | string]>;
// Derive the subdirectory prefix from targetDir relative to projectDir
const subDir = targetDir === projectDir ? "" : targetDir.slice(projectDir.length + 1);
for (const [, value] of entries) {
if (typeof value === "string") continue;
// Strip path separators — browsers may include directory components
const name = value.name.split("/").pop()?.split("\\").pop() ?? "";
if (!name || name.includes("\0") || name.includes("..")) continue;
// Reject individual files that exceed the size limit
if (value.size > MAX_UPLOAD_BYTES) {
skipped.push(name);
continue;
}
const destPath = resolve(targetDir, name);
if (!isSafePath(projectDir, destPath)) continue;
// Don't overwrite — append (2), (3), etc.
let finalPath = destPath;
let finalName = name;
if (existsSync(finalPath)) {
// Handle dotfiles correctly: .gitignore → ext="", base=".gitignore"
const dotIdx = name.indexOf(".", name.startsWith(".") ? 1 : 0);
const ext = dotIdx > 0 ? name.slice(dotIdx) : "";
const base = dotIdx > 0 ? name.slice(0, dotIdx) : name;
let n = 2;
while (n < 10000 && existsSync(resolve(targetDir, `${base} (${n})${ext}`))) n++;
if (n >= 10000) {
skipped.push(name);
continue;
}
finalName = `${base} (${n})${ext}`;
finalPath = resolve(targetDir, finalName);
}
const buffer = Buffer.from(await value.arrayBuffer());
const validation = validateUploadedMediaBuffer(finalName, buffer);
if (!validation.ok) {
invalid.push({ name: finalName, reason: validation.reason });
continue;
}
writeFileSync(finalPath, buffer);
const relativePath = subDir ? join(subDir, finalName) : finalName;
uploaded.push(relativePath);
if (isAudioFile(finalName)) {
generateWaveformCache(projectDir, relativePath).catch(() => {});
}
}
return { uploaded, skipped, invalid };
}
// ── Route registration ──────────────────────────────────────────────────────
export function registerFileRoutes(api: Hono, adapter: StudioApiAdapter): void {
@@ -481,72 +941,12 @@ export function registerFileRoutes(api: Hono, adapter: StudioApiAdapter): void {
if (subDir && !existsSync(targetDir)) mkdirSync(targetDir, { recursive: true });
const formData = await c.req.formData();
const uploaded: string[] = [];
const skipped: string[] = [];
const invalid: Array<{ name: string; reason: string }> = [];
const result = await processUploadedFiles(formData, targetDir, project.dir);
// @types/node v25 narrows the ambient `FormData.entries()` to
// `[string, string]` in workspaces where another dep declares an
// `onmessage` global (it trips the worker branch of v25's conditional
// File type). At runtime the value is still `File | string` — cast the
// iterator so the rest of this block keeps type-checking on every
// bun-install layout (hoisted on Windows surfaces this; isolated on
// Linux happens to keep v24 in scope).
type FileLike = {
readonly name: string;
readonly size: number;
arrayBuffer(): Promise<ArrayBuffer>;
};
const entries = formData.entries() as unknown as Iterable<[string, FileLike | string]>;
for (const [, value] of entries) {
if (typeof value === "string") continue;
// Strip path separators — browsers may include directory components
const name = value.name.split("/").pop()?.split("\\").pop() ?? "";
if (!name || name.includes("\0") || name.includes("..")) continue;
// Reject individual files that exceed the size limit
if (value.size > MAX_UPLOAD_BYTES) {
skipped.push(name);
continue;
}
const destPath = resolve(targetDir, name);
if (!isSafePath(project.dir, destPath)) continue;
// Don't overwrite — append (2), (3), etc.
let finalPath = destPath;
let finalName = name;
if (existsSync(finalPath)) {
// Handle dotfiles correctly: .gitignore → ext="", base=".gitignore"
const dotIdx = name.indexOf(".", name.startsWith(".") ? 1 : 0);
const ext = dotIdx > 0 ? name.slice(dotIdx) : "";
const base = dotIdx > 0 ? name.slice(0, dotIdx) : name;
let n = 2;
while (n < 10000 && existsSync(resolve(targetDir, `${base} (${n})${ext}`))) n++;
if (n >= 10000) {
skipped.push(name);
continue;
}
finalName = `${base} (${n})${ext}`;
finalPath = resolve(targetDir, finalName);
}
const buffer = Buffer.from(await value.arrayBuffer());
const validation = validateUploadedMediaBuffer(finalName, buffer);
if (!validation.ok) {
invalid.push({ name: finalName, reason: validation.reason });
continue;
}
writeFileSync(finalPath, buffer);
const relativePath = subDir ? join(subDir, finalName) : finalName;
uploaded.push(relativePath);
if (isAudioFile(finalName)) {
generateWaveformCache(project.dir, relativePath).catch(() => {});
}
}
return c.json({ ok: true, files: uploaded, skipped, invalid }, 201);
return c.json(
{ ok: true, files: result.uploaded, skipped: result.skipped, invalid: result.invalid },
201,
);
},
);
@@ -576,121 +976,6 @@ export function registerFileRoutes(api: Hono, adapter: StudioApiAdapter): void {
// ── GSAP Mutations ──
type GsapMutationRequest =
| {
type: "update-property";
animationId: string;
property: string;
value: number | string;
}
| {
type: "update-from-property";
animationId: string;
property: string;
value: number | string;
}
| {
type: "update-meta";
animationId: string;
updates: { duration?: number; ease?: string; position?: number };
}
| {
type: "add";
targetSelector: string;
method: "to" | "from" | "set" | "fromTo";
position: number;
duration?: number;
ease?: string;
properties: Record<string, number | string>;
fromProperties?: Record<string, number | string>;
}
| { type: "delete"; animationId: string }
| {
type: "add-property";
animationId: string;
property: string;
defaultValue: number | string;
}
| {
type: "add-from-property";
animationId: string;
property: string;
defaultValue: number | string;
}
| { type: "remove-property"; animationId: string; property: string }
| { type: "remove-from-property"; animationId: string; property: string }
| {
type: "add-keyframe";
animationId: string;
percentage: number;
properties: Record<string, number | string>;
ease?: string;
backfillDefaults?: Record<string, number | string>;
}
| { type: "remove-keyframe"; animationId: string; percentage: number }
| {
type: "update-keyframe";
animationId: string;
percentage: number;
properties: Record<string, number | string>;
ease?: string;
}
| {
type: "convert-to-keyframes";
animationId: string;
resolvedFromValues?: Record<string, number | string>;
}
| { type: "remove-all-keyframes"; animationId: string }
| {
type: "materialize-keyframes";
animationId: string;
keyframes: Array<{
percentage: number;
properties: Record<string, number | string>;
ease?: string;
}>;
easeEach?: string;
resolvedSelector?: string;
allElements?: Array<{
selector: string;
keyframes: Array<{ percentage: number; properties: Record<string, number | string> }>;
easeEach?: string;
}>;
}
| {
type: "set-arc-path";
animationId: string;
enabled: boolean;
autoRotate?: boolean | number;
segments?: Array<{
curviness: number;
cp1?: { x: number; y: number };
cp2?: { x: number; y: number };
}>;
}
| {
type: "update-arc-segment";
animationId: string;
segmentIndex: number;
curviness?: number;
cp1?: { x: number; y: number };
cp2?: { x: number; y: number };
}
| { type: "remove-arc-path"; animationId: string }
| {
type: "add-with-keyframes";
targetSelector: string;
position: number;
duration: number;
keyframes: Array<{
percentage: number;
properties: Record<string, number | string>;
ease?: string;
auto?: boolean;
}>;
ease?: string;
};
api.post("/projects/:id/gsap-mutations/*", async (c) => {
const res = await resolveProjectPath(c, adapter, (id) => `/projects/${id}/gsap-mutations/`, {
mustExist: true,
@@ -702,228 +987,46 @@ export function registerFileRoutes(api: Hono, adapter: StudioApiAdapter): void {
return c.json({ error: "mutation type required" }, 400);
}
const html = readFileSync(res.absPath, "utf-8");
const block = extractGsapScriptBlock(html);
let html = readFileSync(res.absPath, "utf-8");
let block = extractGsapScriptBlock(html);
if (!block && (body.type === "add" || body.type === "add-with-keyframes")) {
const compId = html.match(/data-composition-id="([^"]+)"/)?.[1] ?? "main";
const { GSAP_CDN } = await import("../../templates/constants.js");
const gsapCdn = `<script src="${GSAP_CDN}"></script>`;
const bootstrap = [
gsapCdn,
"<script>",
"window.__timelines = window.__timelines || {};",
`const tl = gsap.timeline({ paused: true });`,
`window.__timelines["${compId}"] = tl;`,
"</script>",
].join("\n");
if (html.includes("</body>")) {
html = html.replace("</body>", `${bootstrap}\n</body>`);
} else {
html += `\n${bootstrap}`;
}
block = extractGsapScriptBlock(html);
}
if (!block) {
return c.json({ error: "no GSAP script found in file" }, 400);
}
const {
parseGsapScript,
updateAnimationInScript,
addAnimationToScript,
removeAnimationFromScript,
} = await loadGsapParser();
const respond = (data: unknown, status?: number) =>
// eslint-disable-next-line @typescript-eslint/no-explicit-any -- bridge between generic status and Hono's literal union
status ? c.json(data, status as any) : c.json(data);
function requireAnimation(
scriptText: string,
animationId: string,
): { anim: GsapAnimation } | { err: Response } {
const parsed = parseGsapScript(scriptText);
const anim = parsed.animations.find((a) => a.id === animationId);
if (!anim) return { err: c.json({ error: "animation not found" }, 404) };
return { anim };
}
function requireFromToAnimation(
scriptText: string,
animationId: string,
): { anim: GsapAnimation } | { err: Response } {
const result = requireAnimation(scriptText, animationId);
if ("err" in result) return result;
if (result.anim.method !== "fromTo")
return { err: c.json({ error: "animation is not a fromTo" }, 400) };
return result;
}
let newScript: string;
// fallow-ignore-next-line complexity
switch (body.type) {
case "update-property": {
const r = requireAnimation(block.scriptText, body.animationId);
if ("err" in r) return r.err;
newScript = updateAnimationInScript(block.scriptText, body.animationId, {
properties: { ...r.anim.properties, [body.property]: body.value },
});
break;
}
case "update-from-property": {
const r = requireFromToAnimation(block.scriptText, body.animationId);
if ("err" in r) return r.err;
newScript = updateAnimationInScript(block.scriptText, body.animationId, {
fromProperties: { ...(r.anim.fromProperties ?? {}), [body.property]: body.value },
});
break;
}
case "update-meta": {
newScript = updateAnimationInScript(block.scriptText, body.animationId, body.updates);
break;
}
case "add": {
if (body.fromProperties && body.method !== "fromTo") {
return c.json({ error: "fromProperties is only valid for method=fromTo" }, 400);
}
const result = addAnimationToScript(block.scriptText, {
targetSelector: body.targetSelector,
method: body.method,
position: body.position,
duration: body.duration,
ease: body.ease,
properties: body.properties,
fromProperties: body.fromProperties,
});
newScript = result.script;
break;
}
case "delete": {
newScript = removeAnimationFromScript(block.scriptText, body.animationId);
break;
}
case "add-property": {
const r = requireAnimation(block.scriptText, body.animationId);
if ("err" in r) return r.err;
newScript = updateAnimationInScript(block.scriptText, body.animationId, {
properties: { ...r.anim.properties, [body.property]: body.defaultValue },
});
break;
}
case "add-from-property": {
const r = requireFromToAnimation(block.scriptText, body.animationId);
if ("err" in r) return r.err;
newScript = updateAnimationInScript(block.scriptText, body.animationId, {
fromProperties: { ...(r.anim.fromProperties ?? {}), [body.property]: body.defaultValue },
});
break;
}
case "remove-property": {
const r = requireAnimation(block.scriptText, body.animationId);
if ("err" in r) return r.err;
const filtered = { ...r.anim.properties };
delete filtered[body.property];
newScript = updateAnimationInScript(block.scriptText, body.animationId, {
properties: filtered,
});
break;
}
case "remove-from-property": {
const r = requireFromToAnimation(block.scriptText, body.animationId);
if ("err" in r) return r.err;
const filtered = { ...(r.anim.fromProperties ?? {}) };
delete filtered[body.property];
newScript = updateAnimationInScript(block.scriptText, body.animationId, {
fromProperties: filtered,
});
break;
}
case "add-keyframe": {
const { addKeyframeToScript } = await loadGsapParser();
newScript = addKeyframeToScript(
block.scriptText,
body.animationId,
body.percentage,
body.properties,
body.ease,
body.backfillDefaults,
);
break;
}
case "remove-keyframe": {
const { removeKeyframeFromScript } = await loadGsapParser();
newScript = removeKeyframeFromScript(block.scriptText, body.animationId, body.percentage);
break;
}
case "update-keyframe": {
const { updateKeyframeInScript } = await loadGsapParser();
newScript = updateKeyframeInScript(
block.scriptText,
body.animationId,
body.percentage,
body.properties,
body.ease,
);
break;
}
case "convert-to-keyframes": {
const { convertToKeyframesInScript } = await loadGsapParser();
newScript = convertToKeyframesInScript(
block.scriptText,
body.animationId,
body.resolvedFromValues,
);
break;
}
case "remove-all-keyframes": {
const { removeAllKeyframesFromScript } = await loadGsapParser();
newScript = removeAllKeyframesFromScript(block.scriptText, body.animationId);
break;
}
case "materialize-keyframes": {
const { materializeKeyframesInScript, unrollDynamicAnimations } = await loadGsapParser();
if (body.allElements && body.allElements.length > 0) {
newScript = unrollDynamicAnimations(block.scriptText, body.animationId, body.allElements);
} else {
newScript = materializeKeyframesInScript(
block.scriptText,
body.animationId,
body.keyframes,
body.easeEach,
body.resolvedSelector,
);
}
break;
}
case "set-arc-path": {
const { setArcPathInScript } = await loadGsapParser();
newScript = setArcPathInScript(block.scriptText, body.animationId, {
enabled: body.enabled,
autoRotate: body.autoRotate ?? false,
segments: body.segments ?? [],
});
break;
}
case "update-arc-segment": {
const { updateArcSegmentInScript } = await loadGsapParser();
newScript = updateArcSegmentInScript(
block.scriptText,
body.animationId,
body.segmentIndex,
{
...(body.curviness !== undefined ? { curviness: body.curviness } : {}),
...(body.cp1 ? { cp1: body.cp1 } : {}),
...(body.cp2 ? { cp2: body.cp2 } : {}),
},
);
break;
}
case "remove-arc-path": {
const { removeArcPathFromScript } = await loadGsapParser();
newScript = removeArcPathFromScript(block.scriptText, body.animationId);
break;
}
case "add-with-keyframes": {
const { addAnimationWithKeyframesToScript } = await loadGsapParser();
const result = addAnimationWithKeyframesToScript(
block.scriptText,
body.targetSelector,
body.position,
body.duration,
body.keyframes,
body.ease,
);
newScript = result.script;
break;
}
default:
return c.json({ error: `unknown mutation type: ${(body as { type: string }).type}` }, 400);
}
const result = await executeGsapMutation(body, block, respond);
if (result instanceof Response) return result;
const newScript = result;
const newHtml = block.replaceScript(newScript);
if (newHtml !== html) {
writeFileSync(res.absPath, newHtml, "utf-8");
}
// Re-parse the mutated script so the UI gets fresh state
const { parseGsapScript } = await loadGsapParser();
const freshParsed = parseGsapScript(newScript);
return c.json({
ok: true,
@@ -67,12 +67,14 @@ function injectScriptTagIntoHead(html: string, scriptTag: string): string {
}
function htmlHasGsap(html: string): boolean {
// Keep this heuristic conservative: if user source already loads GSAP, Studio does not add another copy.
// Only match GSAP references outside <template> elements — scripts inside
// templates are inert when cloned and don't make GSAP globally available.
const outsideTemplates = html.replace(/<template\b[^>]*>[\s\S]*?<\/template>/gi, "");
return (
/<script\b[^>]*src=["'][^"']*gsap/i.test(html) ||
/\/\*\s*inlined:.*gsap/i.test(html) ||
/\b(GreenSock|_gsScope)\b/.test(html) ||
/\bgsap\.(config|defaults|registerPlugin|version)\b/.test(html)
/<script\b[^>]*src=["'][^"']*gsap/i.test(outsideTemplates) ||
/\/\*\s*inlined:.*gsap/i.test(outsideTemplates) ||
/\b(GreenSock|_gsScope)\b/.test(outsideTemplates) ||
/\bgsap\.(config|defaults|registerPlugin|version)\b/.test(outsideTemplates)
);
}
+12 -3
View File
@@ -35,6 +35,7 @@ import { AskAgentModal } from "./components/AskAgentModal";
import { StudioGlobalDragOverlay } from "./components/StudioGlobalDragOverlay";
import { StudioHeader } from "./components/StudioHeader";
import { useGestureCommit } from "./hooks/useGestureCommit";
import { STUDIO_KEYFRAMES_ENABLED } from "./components/editor/manualEditingAvailability";
import { GestureTrailOverlay } from "./components/editor/GestureTrailOverlay";
import { StudioLeftSidebar } from "./components/StudioLeftSidebar";
@@ -251,7 +252,9 @@ export function StudioApp() {
onResetKeyframes: () => resetKeyframesRef.current(),
onDeleteSelectedKeyframes: () => deleteSelectedKeyframesRef.current(),
onAfterUndoRedo: () => invalidateGsapCacheRef.current(),
onToggleRecording: () => handleToggleRecordingRef.current(),
onToggleRecording: STUDIO_KEYFRAMES_ENABLED
? () => handleToggleRecordingRef.current()
: undefined,
});
const selectSidebarTabStable = useCallback(
(tab: SidebarTab) => leftSidebarRef.current?.selectTab(tab),
@@ -330,7 +333,11 @@ export function StudioApp() {
});
const compositionDimensions = useCompositionDimensions();
const { lintModal, linting, handleLint, closeLintModal } = useLintModal(projectId);
const { lintModal, linting, handleLint, closeLintModal, findingsByElement, findingsByFile } =
useLintModal(projectId, refreshKey);
useEffect(() => {
usePlayerStore.getState().setLintFindingsByElement(findingsByElement);
}, [findingsByElement]);
const frameCapture = useFrameCapture({
projectId,
activeCompPath,
@@ -482,6 +489,8 @@ export function StudioApp() {
onPreviewBlock={setBlockPreview}
onLint={handleLint}
linting={linting}
lintFindingCount={lintModal?.length ?? findingsByFile.size}
lintFindingsByFile={findingsByFile}
/>
<StudioPreviewArea
timelineToolbar={timelineToolbar}
@@ -529,7 +538,7 @@ export function StudioApp() {
}}
recordingState={gestureState}
recordingDuration={gestureRecording.recordingDuration}
onToggleRecording={handleToggleRecording}
onToggleRecording={STUDIO_KEYFRAMES_ENABLED ? handleToggleRecording : undefined}
/>
)}
</div>
@@ -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>
)}
+7 -7
View File
@@ -100,7 +100,7 @@ export async function materializeIfDynamic(
* keyframe percentages to preserve their absolute positions, then add
* a new keyframe at the target time.
*/
export async function extendTweenAndAddKeyframe(
async function extendTweenAndAddKeyframe(
selection: DomEditSelection,
anim: GsapAnimation,
properties: Record<string, number>,
@@ -149,7 +149,7 @@ export async function extendTweenAndAddKeyframe(
}
// fallow-ignore-next-line complexity
export async function commitKeyframedPosition(
async function commitKeyframedPosition(
selection: DomEditSelection,
anim: GsapAnimation,
properties: Record<string, number>,
@@ -175,7 +175,7 @@ export async function commitKeyframedPosition(
* drag position at the current percentage.
*/
// fallow-ignore-next-line complexity
export async function commitFlatViaKeyframes(
async function commitFlatViaKeyframes(
selection: DomEditSelection,
anim: GsapAnimation,
properties: Record<string, number>,
@@ -230,10 +230,10 @@ export async function commitGsapPositionFromDrag(
const deltaY = studioOffset.y - origY;
const adjX = deltaX * cos - deltaY * sin;
const adjY = deltaX * sin + deltaY * cos;
const baseGsapX =
Number.parseFloat(el.getAttribute("data-hf-drag-gsap-base-x") ?? "") || gsapPos.x;
const baseGsapY =
Number.parseFloat(el.getAttribute("data-hf-drag-gsap-base-y") ?? "") || gsapPos.y;
const parsedBaseX = Number.parseFloat(el.getAttribute("data-hf-drag-gsap-base-x") ?? "");
const parsedBaseY = Number.parseFloat(el.getAttribute("data-hf-drag-gsap-base-y") ?? "");
const baseGsapX = Number.isFinite(parsedBaseX) ? parsedBaseX : gsapPos.x;
const baseGsapY = Number.isFinite(parsedBaseY) ? parsedBaseY : gsapPos.y;
const newX = Math.round(baseGsapX + adjX);
const newY = Math.round(baseGsapY + adjY);
const restoreOffset = () => {
+42 -28
View File
@@ -10,6 +10,7 @@
*/
import type { GsapAnimation } from "@hyperframes/core/gsap-parser";
import type { DomEditSelection } from "../components/editor/domEditingTypes";
import { usePlayerStore } from "../player/store/playerStore";
import { readAllAnimatedProperties, readGsapProperty } from "./gsapRuntimeReaders";
import {
@@ -59,31 +60,40 @@ function readGsapPositionFromIframe(
// ── Animation matching ─────────────────────────────────────────────────────
// fallow-ignore-next-line complexity
function findGsapPositionAnimation(animations: GsapAnimation[]): GsapAnimation | null {
// Prefer animations that already have x/y
for (const anim of animations) {
if (anim.keyframes) {
const hasPos = anim.keyframes.keyframes.some(
(kf) => "x" in kf.properties || "y" in kf.properties,
);
if (hasPos) return anim;
}
const props = anim.properties;
const fromProps = anim.fromProperties;
if (anim.method === "fromTo") {
if ("x" in props || "y" in props || (fromProps && ("x" in fromProps || "y" in fromProps))) {
return anim;
}
} else if ("x" in props || "y" in props) {
return anim;
}
function animHasPosition(anim: GsapAnimation): boolean {
if (anim.keyframes?.keyframes.some((kf) => "x" in kf.properties || "y" in kf.properties))
return true;
if (anim.method === "fromTo") {
const from = anim.fromProperties;
return (
"x" in anim.properties || "y" in anim.properties || !!(from && ("x" in from || "y" in from))
);
}
// Fall back to any keyframed animation — drag will add x/y to it
for (const anim of animations) {
if (anim.keyframes) return anim;
}
// Fall back to any animation — will be converted to keyframes
return animations[0] ?? null;
return "x" in anim.properties || "y" in anim.properties;
}
function findGsapPositionAnimation(
animations: GsapAnimation[],
selector?: string,
): GsapAnimation | null {
if (animations.length === 0) return null;
const currentTime = usePlayerStore.getState().currentTime;
const scored = animations
.filter((a) => animHasPosition(a) || a.keyframes || animations.length === 1)
.map((a) => {
let score = 0;
if (animHasPosition(a)) score += 10;
if (a.keyframes) score += 5;
if (selector && a.targetSelector === selector) score += 8;
else if (a.targetSelector.includes(",")) score -= 5;
const pos = typeof a.position === "number" ? a.position : 0;
const dur = a.duration ?? 0;
if (currentTime >= pos - 0.05 && currentTime <= pos + dur + 0.05) score += 4;
return { anim: a, score };
});
scored.sort((a, b) => b.score - a.score);
return scored[0]?.anim ?? animations[0];
}
// ── Selector resolution ────────────────────────────────────────────────────
@@ -114,15 +124,19 @@ export async function tryGsapDragIntercept(
commitMutation: GsapDragCommitCallbacks["commitMutation"],
fetchFallbackAnimations?: () => Promise<GsapAnimation[]>,
): Promise<boolean> {
let posAnim = findGsapPositionAnimation(animations);
const selector = selectorForSelection(selection);
if (!selector) return false;
let posAnim = findGsapPositionAnimation(animations, selector);
if (!posAnim && fetchFallbackAnimations) {
const fresh = await fetchFallbackAnimations();
posAnim = findGsapPositionAnimation(fresh);
posAnim = findGsapPositionAnimation(fresh, selector);
}
if (!posAnim) return false;
const selector = selectorForSelection(selection);
if (!selector) return false;
// Keyframe writes at 0%/100% when outside the tween range. Acceptable
// trade-off — CSS path must NEVER touch GSAP-targeted elements because
// changing the CSS offset corrupts all existing keyframes (baked mismatch).
const gsapPos = readGsapPositionFromIframe(iframe, selector);
if (!gsapPos) return false;
+135 -1
View File
@@ -25,6 +25,28 @@ export function readGsapProperty(
}
}
const POSITION_PROPS = new Set(["x", "y", "xPercent", "yPercent"]);
const GSAP_CONFIG_KEYS = new Set([
"duration",
"ease",
"delay",
"stagger",
"id",
"onComplete",
"onUpdate",
"onStart",
"onRepeat",
"repeat",
"yoyo",
"repeatDelay",
"paused",
"immediateRender",
"lazy",
"overwrite",
"keyframes",
"parent",
]);
export function readAllAnimatedProperties(
iframe: HTMLIFrameElement | null,
selector: string,
@@ -61,7 +83,119 @@ export function readAllAnimatedProperties(
for (const prop of propKeys) {
const val = Number(gsap.getProperty(el, prop));
if (Number.isFinite(val)) result[prop] = Math.round(val);
if (Number.isFinite(val)) {
result[prop] = POSITION_PROPS.has(prop) ? Math.round(val) : Math.round(val * 1000) / 1000;
}
}
const otherTweenProps = new Set<string>();
try {
const win = iframe.contentWindow as unknown as { __timelines?: Record<string, unknown> };
const timelines = win.__timelines;
if (timelines) {
for (const tl of Object.values(timelines)) {
const tlObj = tl as {
getChildren?: (
deep: boolean,
) => Array<{ targets?: () => Element[]; vars?: Record<string, unknown> }>;
};
if (!tlObj?.getChildren) continue;
for (const child of tlObj.getChildren(true)) {
if (typeof child.targets !== "function") continue;
const targets = child.targets();
if (!targets.includes(el)) continue;
const vars = child.vars;
if (!vars) continue;
for (const k of Object.keys(vars)) {
if (!GSAP_CONFIG_KEYS.has(k)) otherTweenProps.add(k);
}
}
}
}
} catch (e) {
console.warn(
"Cross-tween guard failed — baseline capture may include values from other tweens",
e,
);
}
for (const p of propKeys) otherTweenProps.delete(p);
// Tier 1: Transform + visual properties with universal CSS defaults.
// Safe to compare against hardcoded values — these are always 0 or 1
// regardless of the element's stylesheet.
const UNIVERSAL_BASELINE: Record<string, number> = {
opacity: 1,
scale: 1,
scaleX: 1,
scaleY: 1,
scaleZ: 1,
rotation: 0,
rotationX: 0,
rotationY: 0,
skewX: 0,
skewY: 0,
z: 0,
xPercent: 0,
yPercent: 0,
transformPerspective: 0,
blur: 0,
brightness: 1,
contrast: 1,
saturate: 1,
hueRotate: 0,
grayscale: 0,
sepia: 0,
invert: 0,
};
for (const [prop, defaultVal] of Object.entries(UNIVERSAL_BASELINE)) {
if (prop in result) continue;
if (otherTweenProps.has(prop)) continue;
const val = Number(gsap.getProperty(el, prop));
if (Number.isFinite(val) && Math.round(val * 1000) !== Math.round(defaultVal * 1000)) {
result[prop] = Math.round(val * 1000) / 1000;
}
}
// Tier 2: Element-dependent properties — their "default" depends on the
// stylesheet, so we compare GSAP's runtime value against the element's
// computed CSS value. Only capture if GSAP has actively changed it.
const COMPUTED_BASELINE = [
"borderRadius",
"borderTopLeftRadius",
"borderTopRightRadius",
"borderBottomLeftRadius",
"borderBottomRightRadius",
"letterSpacing",
"wordSpacing",
"lineHeight",
"fontSize",
"outlineOffset",
"outlineWidth",
"strokeDashoffset",
"strokeWidth",
"backgroundPositionX",
"backgroundPositionY",
];
let computedStyle: CSSStyleDeclaration | null = null;
try {
computedStyle = doc?.defaultView?.getComputedStyle(el) ?? null;
} catch {}
for (const prop of COMPUTED_BASELINE) {
if (prop in result) continue;
if (otherTweenProps.has(prop)) continue;
const gsapVal = Number(gsap.getProperty(el, prop));
if (!Number.isFinite(gsapVal)) continue;
let cssVal = NaN;
if (computedStyle) {
const raw = computedStyle.getPropertyValue(
prop.replace(/[A-Z]/g, (m) => `-${m.toLowerCase()}`),
);
cssVal = parseFloat(raw);
}
if (Number.isFinite(cssVal) && Math.round(gsapVal * 1000) === Math.round(cssVal * 1000))
continue;
result[prop] = Math.round(gsapVal * 1000) / 1000;
}
return result;
}
@@ -128,6 +128,9 @@ export async function persistTimelineEdit(input: PersistTimelineEditInput): Prom
}
export async function readFileContent(projectId: string, targetPath: string): Promise<string> {
if (targetPath.includes("\0") || targetPath.includes("..")) {
throw new Error(`Unsafe path: ${targetPath}`);
}
const response = await fetch(
`/api/projects/${projectId}/files/${encodeURIComponent(targetPath)}`,
);
@@ -36,15 +36,46 @@ interface CommitAnimatedPropertyDeps {
bumpGsapCache: () => void;
}
function computePercentage(selection: DomEditSelection): number {
function computePercentage(selection: DomEditSelection, anim?: GsapAnimation): number {
const currentTime = usePlayerStore.getState().currentTime;
const tweenPos = typeof anim?.position === "number" ? anim.position : 0;
const tweenDur = anim?.duration ?? 0;
if (tweenDur > 0) {
return Math.max(
0,
Math.min(100, Math.round(((currentTime - tweenPos) / tweenDur) * 1000) / 10),
);
}
const elStart = Number.parseFloat(selection.dataAttributes?.start ?? "0") || 0;
const elDuration = Number.parseFloat(selection.dataAttributes?.duration ?? "1") || 1;
const currentTime = usePlayerStore.getState().currentTime;
return elDuration > 0
? Math.max(0, Math.min(100, Math.round(((currentTime - elStart) / elDuration) * 1000) / 10))
: 0;
}
function pickBestAnimation(
animations: GsapAnimation[],
selector: string | null,
): GsapAnimation | undefined {
if (animations.length <= 1) return animations[0];
const currentTime = usePlayerStore.getState().currentTime;
const scored = animations.map((a) => {
let score = 0;
if (a.keyframes) score += 10;
// Prefer single-element selectors over comma-separated groups
if (selector && a.targetSelector === selector) score += 5;
else if (a.targetSelector.includes(",")) score -= 3;
// Prefer tweens active at the current time
const pos = typeof a.position === "number" ? a.position : 0;
const dur = a.duration ?? 0;
if (currentTime >= pos - 0.05 && currentTime <= pos + dur + 0.05) score += 8;
return { anim: a, score };
});
scored.sort((a, b) => b.score - a.score);
return scored[0]?.anim;
}
function selectorFor(selection: DomEditSelection): string | null {
if (selection.id) return `#${selection.id}`;
if (selection.selector) return selection.selector;
@@ -70,10 +101,8 @@ export function useAnimatedPropertyCommit(deps: CommitAnimatedPropertyDeps) {
const iframe = previewIframeRef.current;
const selector = selectorFor(selection);
const pct = computePercentage(selection);
let anim: GsapAnimation | undefined =
selectedGsapAnimations.find((a) => a.keyframes) ?? selectedGsapAnimations[0];
let anim: GsapAnimation | undefined = pickBestAnimation(selectedGsapAnimations, selector);
// Case 3: No animation — create one first
if (!anim) {
@@ -97,6 +126,8 @@ export function useAnimatedPropertyCommit(deps: CommitAnimatedPropertyDeps) {
);
}
const pct = computePercentage(selection, anim);
// Read all currently animated properties from runtime for backfill
const runtimeProps = selector ? readAllAnimatedProperties(iframe, selector, anim) : {};
@@ -112,15 +143,26 @@ export function useAnimatedPropertyCommit(deps: CommitAnimatedPropertyDeps) {
}
backfillDefaults[property] = typeof value === "number" ? value : value;
const existingKf = anim.keyframes?.keyframes.some(
(kf) => Math.abs(kf.percentage - pct) < 0.05,
);
await gsapCommitMutation(
selection,
{
type: "add-keyframe",
animationId: anim.id,
percentage: pct,
properties,
backfillDefaults,
},
existingKf
? {
type: "update-keyframe",
animationId: anim.id,
percentage: pct,
properties,
}
: {
type: "add-keyframe",
animationId: anim.id,
percentage: pct,
properties,
backfillDefaults,
},
{ label: `Edit ${property} (keyframe ${pct}%)`, softReload: true },
);
},
+1 -10
View File
@@ -8,6 +8,7 @@ import { insertTimelineAssetIntoSource } from "../utils/timelineAssetDrop";
import { saveProjectFilesWithHistory } from "../utils/studioFileHistory";
import type { EditHistoryKind } from "../utils/editHistory";
import { formatTimelineAttributeNumber } from "../player/components/timelineEditing";
import { readFileContent } from "./timelineEditingHelpers";
interface RecordEditInput {
label: string;
@@ -30,16 +31,6 @@ interface UseClipboardOptions {
previewIframeRef: React.MutableRefObject<HTMLIFrameElement | null>;
}
async function readFileContent(projectId: string, targetPath: string): Promise<string> {
const response = await fetch(
`/api/projects/${projectId}/files/${encodeURIComponent(targetPath)}`,
);
if (!response.ok) throw new Error(`Failed to read ${targetPath}`);
const data = (await response.json()) as { content?: string };
if (typeof data.content !== "string") throw new Error(`Missing file contents for ${targetPath}`);
return data.content;
}
function getElementOuterHtml(
iframeRef: React.MutableRefObject<HTMLIFrameElement | null>,
selection: DomEditSelection,
@@ -80,6 +80,12 @@ export function useGestureCommit({
const simplified = simplifyGestureSamples(frozenSamples, duration, 5);
const sortedPcts = Array.from(simplified.keys()).sort((a, b) => a - b);
// Ensure a 0% keyframe exists with the element's start-of-recording position
if (!simplified.has(0) && frozenSamples.length > 0) {
simplified.set(0, frozenSamples[0]!.properties);
if (!sortedPcts.includes(0)) sortedPcts.unshift(0);
}
const selector = sel.id ? `#${sel.id}` : sel.selector;
if (!selector) {
showToast("Cannot save — element has no selector", "error");
+267 -165
View File
@@ -18,6 +18,128 @@ interface AccumulatedState {
z: number;
}
interface BasePosition {
baseX: number;
baseY: number;
baseOpacity: number;
baseScale: number;
cssOffX: number;
cssOffY: number;
}
interface GsapRuntime {
seek: (t: number) => void;
set: (target: string, vars: Record<string, number>) => void;
selector: string;
element: HTMLElement;
startTime: number;
maxSeekTime: number;
savedVisibility: string;
savedTranslate: string;
}
// ---------------------------------------------------------------------------
// Extracted helpers — pure functions, no refs, no React.
// ---------------------------------------------------------------------------
function readBasePosition(element: HTMLElement, iframeEl: HTMLIFrameElement): BasePosition {
let baseOpacity = 1;
let baseScale = 1;
let baseX = 0;
let baseY = 0;
try {
const gsap = (
iframeEl.contentWindow as Window & {
gsap?: { getProperty: (el: Element, prop: string) => number };
}
).gsap;
if (gsap?.getProperty) {
baseOpacity = Number(gsap.getProperty(element, "opacity")) || 1;
baseScale = Number(gsap.getProperty(element, "scaleX")) || 1;
baseX = Number(gsap.getProperty(element, "x")) || 0;
baseY = Number(gsap.getProperty(element, "y")) || 0;
}
} catch {
/* cross-origin guard */
}
// Path-offset CSS vars live on the element regardless of whether
// translate is currently var-based or "none" (GSAP-baked).
const cssOffX = Number.parseFloat(element.style.getPropertyValue("--hf-studio-offset-x")) || 0;
const cssOffY = Number.parseFloat(element.style.getPropertyValue("--hf-studio-offset-y")) || 0;
const translateVal = element.style.translate ?? "";
if (translateVal.includes("var(")) {
baseX += cssOffX;
baseY += cssOffY;
}
return { baseX, baseY, baseOpacity, baseScale, cssOffX, cssOffY };
}
function connectGsapRuntime(
element: HTMLElement,
iframeEl: HTMLIFrameElement,
selector: string | null,
elementEndTime: number | undefined,
): GsapRuntime | null {
try {
const win = iframeEl.contentWindow as Window & {
gsap?: { set: (t: string, v: Record<string, number>) => void };
__timelines?: Record<string, { seek: (t: number) => void; duration: () => number }>;
__player?: { getTime: () => number };
};
const tl = win?.__timelines ? Object.values(win.__timelines)[0] : null;
if (win?.gsap?.set && tl?.seek && selector) {
const tlDuration = tl.duration();
return {
seek: tl.seek.bind(tl),
set: win.gsap.set.bind(win.gsap),
selector,
element,
startTime: win.__player?.getTime() ?? 0,
maxSeekTime:
elementEndTime != null && elementEndTime < tlDuration ? elementEndTime : tlDuration,
savedVisibility: element.style.visibility,
savedTranslate: element.style.getPropertyValue("translate"),
};
}
} catch {
/* cross-origin or missing runtime */
}
return null;
}
function applyRuntimePreview(
runtime: GsapRuntime,
time: number,
properties: Record<string, number>,
): void {
const seekTime = Math.min(runtime.startTime + time, runtime.maxSeekTime);
runtime.seek(seekTime);
runtime.element.style.setProperty("translate", "none");
runtime.set(runtime.selector, { ...properties });
runtime.element.style.visibility = "visible";
liveTime.notify(seekTime);
usePlayerStore.getState().setCurrentTime(seekTime);
}
function recordSample(r: RecordingRefs, time: number, properties: Record<string, number>): void {
const sampleProps = { ...properties };
if ("x" in sampleProps) sampleProps.x -= r.cssVarOffset.x;
if ("y" in sampleProps) sampleProps.y -= r.cssVarOffset.y;
r.samples.push({ time, properties: sampleProps });
r.trail.push({ x: r.pointer.x, y: r.pointer.y });
}
function computeIframeScale(iframeEl: HTMLIFrameElement): number {
const iframeRect = iframeEl.getBoundingClientRect();
const doc = iframeEl.contentDocument;
const root = doc?.querySelector<HTMLElement>("[data-composition-id]") ?? doc?.documentElement;
const declaredWidth = Number(root?.getAttribute("data-width")) || 1920;
return declaredWidth > 0 ? iframeRect.width / declaredWidth : 1;
}
function resolveGestureProperties(
dx: number,
dy: number,
@@ -63,6 +185,53 @@ function resolveGestureProperties(
};
}
// ---------------------------------------------------------------------------
// Grouped mutable state carried across the recording session.
// Replaces 14 individual useRef calls with a single ref object.
// ---------------------------------------------------------------------------
interface RecordingRefs {
pointer: { x: number; y: number };
startPointer: { x: number; y: number };
hasMoved: boolean;
scrollDelta: number;
modifiers: Modifiers;
accumulated: AccumulatedState;
basePosition: { x: number; y: number };
cssVarOffset: { x: number; y: number };
scale: number;
pointerElementOffset: { x: number; y: number };
runtime: GsapRuntime | null;
rafId: number;
samples: GestureSample[];
trail: Array<{ x: number; y: number }>;
cleanup: (() => void) | null;
}
function createRecordingRefs(): RecordingRefs {
return {
pointer: { x: 0, y: 0 },
startPointer: { x: 0, y: 0 },
hasMoved: false,
scrollDelta: 0,
modifiers: { shift: false, alt: false, meta: false },
accumulated: { opacity: 1, scale: 1, z: 0 },
basePosition: { x: 0, y: 0 },
cssVarOffset: { x: 0, y: 0 },
scale: 1,
pointerElementOffset: { x: 0, y: 0 },
runtime: null,
rafId: 0,
samples: [],
trail: [],
cleanup: null,
};
}
// ---------------------------------------------------------------------------
// Hook
// ---------------------------------------------------------------------------
export function useGestureRecording() {
const [isRecording, setIsRecording] = useState(false);
const [recordingDuration, setRecordingDuration] = useState(0);
@@ -71,34 +240,18 @@ export function useGestureRecording() {
// startRecording and stopRecording check this ref, not the useState value.
const isRecordingRef = useRef(false);
const pointerRef = useRef({ x: 0, y: 0 });
const startPointerRef = useRef({ x: 0, y: 0 });
const scrollDeltaRef = useRef(0);
const modifiersRef = useRef<Modifiers>({ shift: false, alt: false, meta: false });
const accumulatedRef = useRef<AccumulatedState>({ opacity: 1, scale: 1, z: 0 });
const basePositionRef = useRef({ x: 0, y: 0 });
const scaleRef = useRef(1);
const hasMovedRef = useRef(false);
const pointerElementOffsetRef = useRef({ x: 0, y: 0 });
const runtimeRef = useRef<{
seek: (t: number) => void;
set: (target: string, vars: Record<string, number>) => void;
selector: string;
element: HTMLElement;
startTime: number;
maxSeekTime: number;
} | null>(null);
const refs = useRef<RecordingRefs>(createRecordingRefs());
const rafIdRef = useRef(0);
const samplesRef = useRef<GestureSample[]>([]);
const trailRef = useRef<Array<{ x: number; y: number }>>([]);
const cleanupRef = useRef<(() => void) | null>(null);
// Stable reference aliases for the return value — consumers read these directly.
const samplesRef = useRef<GestureSample[]>(refs.current.samples);
const trailRef = useRef<Array<{ x: number; y: number }>>(refs.current.trail);
// Unmount safety: cancel RAF + remove listeners if component tears down mid-recording.
useEffect(() => {
const r = refs.current;
return () => {
cleanupRef.current?.();
cleanupRef.current = null;
r.cleanup?.();
r.cleanup = null;
isRecordingRef.current = false;
};
}, []);
@@ -108,109 +261,58 @@ export function useGestureRecording() {
if (isRecordingRef.current) return;
isRecordingRef.current = true;
samplesRef.current = [];
trailRef.current = [];
hasMovedRef.current = false;
const r = refs.current;
r.samples = [];
r.trail = [];
r.hasMoved = false;
r.scrollDelta = 0;
samplesRef.current = r.samples;
trailRef.current = r.trail;
setRecordingDuration(0);
scrollDeltaRef.current = 0;
let baseOpacity = 1;
let baseScaleVal = 1;
let baseX = 0;
let baseY = 0;
try {
const gsap = (
iframeEl.contentWindow as Window & {
gsap?: { getProperty: (el: Element, prop: string) => number };
}
).gsap;
if (gsap?.getProperty) {
baseOpacity = Number(gsap.getProperty(element, "opacity")) || 1;
baseScaleVal = Number(gsap.getProperty(element, "scaleX")) || 1;
baseX = Number(gsap.getProperty(element, "x")) || 0;
baseY = Number(gsap.getProperty(element, "y")) || 0;
}
} catch {
/* cross-origin guard */
}
// When reapplyPathOffsets has run (translate restored to var-based),
// GSAP's cache was stripped — gsapX is 0 but the element is visually
// at CSSLeft + translate(offset). gsap.set wipes translate, so we need
// baseX to include the offset. When translate is "none" (GSAP owns it),
// gsapX already includes the baked offset — don't add.
const translateVal = element.style.translate ?? "";
if (translateVal.includes("var(")) {
const offX = Number.parseFloat(element.style.getPropertyValue("--hf-studio-offset-x")) || 0;
const offY = Number.parseFloat(element.style.getPropertyValue("--hf-studio-offset-y")) || 0;
baseX += offX;
baseY += offY;
}
accumulatedRef.current = { opacity: baseOpacity, scale: baseScaleVal, z: 0 };
basePositionRef.current = { x: baseX, y: baseY };
// --- Phase 1: Read base position from GSAP + CSS vars ---
const base = readBasePosition(element, iframeEl);
r.cssVarOffset = { x: base.cssOffX, y: base.cssOffY };
r.accumulated = { opacity: base.baseOpacity, scale: base.baseScale, z: 0 };
r.basePosition = { x: base.baseX, y: base.baseY };
if (base.cssOffX || base.cssOffY) {
element.style.setProperty("--hf-studio-offset-x", "0px");
element.style.setProperty("--hf-studio-offset-y", "0px");
}
// --- Phase 2: Connect to the iframe GSAP runtime ---
const selector = element.id ? `#${element.id}` : null;
try {
const win = iframeEl.contentWindow as Window & {
gsap?: { set: (t: string, v: Record<string, number>) => void };
__timelines?: Record<string, { seek: (t: number) => void; duration: () => number }>;
__player?: { getTime: () => number };
};
const tl = win?.__timelines ? Object.values(win.__timelines)[0] : null;
if (win?.gsap?.set && tl?.seek && selector) {
const tlDuration = tl.duration();
runtimeRef.current = {
seek: tl.seek.bind(tl),
set: win.gsap.set.bind(win.gsap),
selector,
element,
startTime: win.__player?.getTime() ?? 0,
maxSeekTime:
elementEndTime != null && elementEndTime < tlDuration ? elementEndTime : tlDuration,
};
}
} catch {
runtimeRef.current = null;
}
r.runtime = connectGsapRuntime(element, iframeEl, selector, elementEndTime);
// --- Phase 3: Compute iframe viewport → composition scale ---
r.scale = computeIframeScale(iframeEl);
// --- Phase 4: Element center for pointer-element offset ---
// element.getBoundingClientRect() is in the iframe's viewport.
// Convert to the studio (parent) viewport using the iframe's position and scale.
const iframeRect = iframeEl.getBoundingClientRect();
const doc = iframeEl.contentDocument;
const root = doc?.querySelector<HTMLElement>("[data-composition-id]") ?? doc?.documentElement;
const declaredWidth = Number(root?.getAttribute("data-width")) || 1920;
scaleRef.current = declaredWidth > 0 ? iframeRect.width / declaredWidth : 1;
// Compute the offset between the element's visual center and the pointer
// so the element tracks the pointer exactly during recording (no jump).
const elRect = element.getBoundingClientRect();
const iframeScale = r.scale || 1;
const elCenterViewport = {
x: elRect.left + elRect.width / 2,
y: elRect.top + elRect.height / 2,
x: iframeRect.left + (elRect.left + elRect.width / 2) * iframeScale,
y: iframeRect.top + (elRect.top + elRect.height / 2) * iframeScale,
};
pointerElementOffsetRef.current = { x: 0, y: 0 }; // reset; set on first move
r.pointerElementOffset = { x: 0, y: 0 };
// --- Phase 5: Attach event listeners ---
const handlePointerMove = (e: PointerEvent) => {
pointerRef.current = { x: e.clientX, y: e.clientY };
modifiersRef.current = {
shift: e.shiftKey,
alt: e.altKey,
meta: e.metaKey || e.ctrlKey,
};
r.pointer = { x: e.clientX, y: e.clientY };
r.modifiers = { shift: e.shiftKey, alt: e.altKey, meta: e.metaKey || e.ctrlKey };
};
const handleWheel = (e: WheelEvent) => {
scrollDeltaRef.current += e.deltaY;
modifiersRef.current = {
shift: e.shiftKey,
alt: e.altKey,
meta: e.metaKey || e.ctrlKey,
};
r.scrollDelta += e.deltaY;
r.modifiers = { shift: e.shiftKey, alt: e.altKey, meta: e.metaKey || e.ctrlKey };
};
const handleKeyChange = (e: KeyboardEvent) => {
modifiersRef.current = {
shift: e.shiftKey,
alt: e.altKey,
meta: e.metaKey || e.ctrlKey,
};
r.modifiers = { shift: e.shiftKey, alt: e.altKey, meta: e.metaKey || e.ctrlKey };
};
document.addEventListener("pointermove", handlePointerMove, { passive: true });
@@ -218,86 +320,70 @@ export function useGestureRecording() {
document.addEventListener("keydown", handleKeyChange, { passive: true });
document.addEventListener("keyup", handleKeyChange, { passive: true });
startPointerRef.current = { ...pointerRef.current };
const startMs = performance.now();
let startCaptured = false;
r.startPointer = { ...r.pointer };
const captureStart = (e: PointerEvent) => {
if (!startCaptured) {
startPointerRef.current = { x: e.clientX, y: e.clientY };
// Compute the offset between the pointer and the element center
// so the element follows the pointer without jumping.
pointerElementOffsetRef.current = {
x: e.clientX - elCenterViewport.x,
y: e.clientY - elCenterViewport.y,
};
startCaptured = true;
hasMovedRef.current = true;
if (!r.hasMoved) {
r.startPointer = { x: e.clientX, y: e.clientY };
const offX = e.clientX - elCenterViewport.x;
const offY = e.clientY - elCenterViewport.y;
r.pointerElementOffset = { x: offX, y: offY };
r.basePosition.x += offX / iframeScale;
r.basePosition.y += offY / iframeScale;
r.hasMoved = true;
}
};
document.addEventListener("pointermove", captureStart, { passive: true, once: true });
// --- Phase 6: RAF tick loop ---
const tick = () => {
if (!isRecordingRef.current) return;
const now = performance.now();
const time = (now - startMs) / 1000;
const scale = scaleRef.current || 1;
const dx = (pointerRef.current.x - startPointerRef.current.x) / scale;
const dy = (pointerRef.current.y - startPointerRef.current.y) / scale;
const scrollDelta = scrollDeltaRef.current;
// Skip zero-displacement samples before the pointer has moved.
if (!hasMovedRef.current && dx === 0 && dy === 0 && scrollDelta === 0) {
rafIdRef.current = requestAnimationFrame(tick);
const scale = r.scale || 1;
const dx = (r.pointer.x - r.startPointer.x) / scale;
const dy = (r.pointer.y - r.startPointer.y) / scale;
const scrollDelta = r.scrollDelta;
if (!r.hasMoved && dx === 0 && dy === 0 && scrollDelta === 0) {
r.rafId = requestAnimationFrame(tick);
return;
}
hasMovedRef.current = true;
r.hasMoved = true;
const { properties, nextState } = resolveGestureProperties(
dx,
dy,
scrollDelta,
modifiersRef.current,
accumulatedRef.current,
r.modifiers,
r.accumulated,
);
if ("x" in properties) properties.x = Math.round(basePositionRef.current.x + properties.x);
if ("y" in properties) properties.y = Math.round(basePositionRef.current.y + properties.y);
if ("x" in properties) properties.x = Math.round(r.basePosition.x + properties.x);
if ("y" in properties) properties.y = Math.round(r.basePosition.y + properties.y);
accumulatedRef.current = nextState;
scrollDeltaRef.current = 0;
r.accumulated = nextState;
r.scrollDelta = 0;
// Manual seek on the raw GSAP timeline (not the Studio player wrapper,
// which triggers React state updates). After seek renders all elements
// at the correct time, gsap.set overrides the recorded element so it
// follows the pointer. The browser paints the set values on this frame;
// next tick's seek will overwrite, but we re-apply immediately.
if (runtimeRef.current) {
if (r.runtime) {
try {
const seekTime = Math.min(
runtimeRef.current.startTime + time,
runtimeRef.current.maxSeekTime,
);
runtimeRef.current.seek(seekTime);
runtimeRef.current.set(runtimeRef.current.selector, { ...properties });
runtimeRef.current.element.style.visibility = "visible";
liveTime.notify(seekTime);
usePlayerStore.getState().setCurrentTime(seekTime);
applyRuntimePreview(r.runtime, time, properties);
} catch {
runtimeRef.current = null;
r.runtime = null;
}
}
samplesRef.current.push({ time, properties });
trailRef.current.push({ x: pointerRef.current.x, y: pointerRef.current.y });
recordSample(r, time, properties);
setRecordingDuration(time);
rafIdRef.current = requestAnimationFrame(tick);
r.rafId = requestAnimationFrame(tick);
};
setIsRecording(true);
rafIdRef.current = requestAnimationFrame(tick);
r.rafId = requestAnimationFrame(tick);
cleanupRef.current = () => {
cancelAnimationFrame(rafIdRef.current);
r.cleanup = () => {
cancelAnimationFrame(r.rafId);
document.removeEventListener("pointermove", handlePointerMove);
document.removeEventListener("wheel", handleWheel);
document.removeEventListener("keydown", handleKeyChange);
@@ -311,21 +397,37 @@ export function useGestureRecording() {
const stopRecording = useCallback((): GestureSample[] => {
if (!isRecordingRef.current) return [];
isRecordingRef.current = false;
runtimeRef.current = null;
cleanupRef.current?.();
cleanupRef.current = null;
const frozen = samplesRef.current.slice();
const r = refs.current;
if (r.runtime) {
const { element: el, savedVisibility, savedTranslate } = r.runtime;
el.style.visibility = savedVisibility;
el.style.setProperty("translate", savedTranslate || "");
}
if (r.cssVarOffset.x || r.cssVarOffset.y) {
const el = r.runtime?.element;
if (el) {
el.style.setProperty("--hf-studio-offset-x", `${r.cssVarOffset.x}px`);
el.style.setProperty("--hf-studio-offset-y", `${r.cssVarOffset.y}px`);
}
}
r.runtime = null;
r.cleanup?.();
r.cleanup = null;
const frozen = r.samples.slice();
setRecordingDuration(frozen.length > 0 ? frozen[frozen.length - 1]!.time : 0);
setIsRecording(false);
return frozen;
}, []); // No deps — uses refs only
const clearSamples = useCallback(() => {
samplesRef.current = [];
trailRef.current = [];
const r = refs.current;
r.samples = [];
r.trail = [];
samplesRef.current = r.samples;
trailRef.current = r.trail;
setRecordingDuration(0);
accumulatedRef.current = { opacity: 1, scale: 1, z: 0 };
scrollDeltaRef.current = 0;
r.accumulated = { opacity: 1, scale: 1, z: 0 };
r.scrollDelta = 0;
}, []);
return {
@@ -246,7 +246,7 @@ export function useGsapScriptCommits({
(selection: DomEditSelection, animationId: string) => {
void commitMutation(
selection,
{ type: "delete", animationId },
{ type: "delete", animationId, stripStudioEdits: true },
{ label: "Delete GSAP animation" },
);
},
+97 -25
View File
@@ -1,35 +1,107 @@
import { useState, useCallback } from "react";
import { useState, useCallback, useEffect, useRef, useMemo } from "react";
import type { LintFinding } from "../components/LintModal";
export function useLintModal(projectId: string | null) {
interface RawFinding {
severity?: string;
message?: string;
file?: string;
fixHint?: string;
elementId?: string;
selector?: string;
code?: string;
}
function parseFinding(f: RawFinding): LintFinding & { elementId?: string; file?: string } {
return {
severity: f.severity === "error" ? ("error" as const) : ("warning" as const),
message: f.message ?? "",
file: f.file,
fixHint: f.fixHint,
elementId: f.elementId,
};
}
export function useLintModal(projectId: string | null, refreshKey?: number) {
const [lintModal, setLintModal] = useState<LintFinding[] | null>(null);
const [linting, setLinting] = useState(false);
const [backgroundFindings, setBackgroundFindings] = useState<
Array<LintFinding & { elementId?: string; file?: string }>
>([]);
const autoLintRanRef = useRef(false);
const handleLint = useCallback(async () => {
if (!projectId) return;
setLinting(true);
try {
const res = await fetch(`/api/projects/${projectId}/lint`);
const data = await res.json();
setLintModal(
(data.findings ?? []).map(
(f: { severity?: string; message?: string; file?: string; fixHint?: string }) => ({
severity: f.severity === "error" ? ("error" as const) : ("warning" as const),
message: f.message ?? "",
file: f.file,
fixHint: f.fixHint,
}),
),
);
} catch (err) {
const msg = err instanceof Error ? err.message : String(err);
setLintModal([{ severity: "error", message: `Failed to run lint: ${msg}` }]);
} finally {
setLinting(false);
const runLint = useCallback(
async (opts?: { background?: boolean }) => {
if (!projectId) return;
if (!opts?.background) setLinting(true);
try {
const res = await fetch(`/api/projects/${projectId}/lint`);
const data = await res.json();
const parsed = ((data.findings ?? []) as RawFinding[]).map(parseFinding);
if (opts?.background) {
setBackgroundFindings(parsed);
} else {
setLintModal(parsed);
setBackgroundFindings(parsed);
}
} catch (err) {
if (!opts?.background) {
const msg = err instanceof Error ? err.message : String(err);
setLintModal([{ severity: "error", message: `Failed to run lint: ${msg}` }]);
}
} finally {
if (!opts?.background) setLinting(false);
}
},
[projectId],
);
const handleLint = useCallback(() => runLint(), [runLint]);
const prevProjectIdRef = useRef(projectId);
useEffect(() => {
if (projectId !== prevProjectIdRef.current) {
autoLintRanRef.current = false;
prevProjectIdRef.current = projectId;
}
}, [projectId]);
if (!projectId || autoLintRanRef.current) return;
autoLintRanRef.current = true;
void runLint({ background: true });
}, [projectId, runLint]);
useEffect(() => {
if (!projectId || !refreshKey) return;
const timer = setTimeout(() => void runLint({ background: true }), 1000);
return () => clearTimeout(timer);
}, [projectId, refreshKey, runLint]);
const closeLintModal = useCallback(() => setLintModal(null), []);
return { lintModal, linting, handleLint, closeLintModal };
const groupFindings = useCallback(
(keyFn: (f: (typeof backgroundFindings)[0]) => string | undefined) => {
const map = new Map<string, { count: number; messages: string[] }>();
for (const f of backgroundFindings) {
const key = keyFn(f);
if (!key) continue;
const prev = map.get(key) ?? { count: 0, messages: [] };
prev.count += 1;
prev.messages.push(f.message);
map.set(key, prev);
}
return map;
},
[backgroundFindings],
);
const findingsByElement = useMemo(() => groupFindings((f) => f.elementId), [groupFindings]);
const findingsByFile = useMemo(() => groupFindings((f) => f.file), [groupFindings]);
return {
lintModal,
linting,
handleLint,
closeLintModal,
backgroundFindings,
findingsByElement,
findingsByFile,
};
}
@@ -9,11 +9,33 @@ import {
} from "./timelineEditing";
import { getRenderedTimelineElement, type TimelineTheme } from "./timelineTheme";
import { GUTTER, TRACK_H, RULER_H, CLIP_Y, CLIP_HANDLE_W } from "./timelineLayout";
import type { TimelineElement, KeyframeCacheEntry } from "../store/playerStore";
import {
usePlayerStore,
type TimelineElement,
type KeyframeCacheEntry,
} from "../store/playerStore";
import type { DraggedClipState, ResizingClipState, BlockedClipState } from "./useTimelineClipDrag";
import type { TrackVisualStyle } from "./timelineIcons";
import { STUDIO_KEYFRAMES_ENABLED } from "../../components/editor/manualEditingAvailability";
function ClipLabel({ element, color }: { element: TimelineElement; color: string }) {
const lint = usePlayerStore((s) => s.lintFindingsByElement.get(element.key ?? element.id));
return (
<span
className="flex items-center gap-1 truncate text-[10px] font-medium leading-none"
style={{ color }}
>
{element.label || element.id || element.tag}
{lint && lint.count > 0 && (
<span
className="flex-shrink-0 w-1.5 h-1.5 rounded-full bg-amber-400"
title={lint.messages.join("\n")}
/>
)}
</span>
);
}
interface TimelineCanvasProps {
major: number[];
minor: number[];
@@ -157,12 +179,7 @@ export const TimelineCanvas = memo(function TimelineCanvas({
}
>
{renderClipContent?.(element, clipStyle) ?? (
<span
className="truncate text-[10px] font-medium leading-none"
style={{ color: clipStyle.label }}
>
{element.label || element.id || element.tag}
</span>
<ClipLabel element={element} color={clipStyle.label} />
)}
</div>
</>
@@ -1,5 +1,5 @@
import { useRef, useCallback, useEffect } from "react";
import { liveTime, type ZoomMode } from "../store/playerStore";
import { liveTime, usePlayerStore, type ZoomMode } from "../store/playerStore";
import { useMountEffect } from "../../hooks/useMountEffect";
import { getPinchTimelineZoomPercent } from "./timelineZoom";
import {
@@ -90,6 +90,7 @@ export function useTimelinePlayhead({
if (
scroll &&
!isDragging.current &&
usePlayerStore.getState().isPlaying &&
shouldAutoScrollTimeline(zoomModeRef.current, scroll.scrollWidth, scroll.clientWidth)
) {
const edgeMargin = scroll.clientWidth * 0.12;
@@ -117,6 +117,12 @@ interface PlayerState {
requestedSeekTime: number | null;
requestSeek: (time: number) => void;
clearSeekRequest: () => void;
autoKeyframeEnabled: boolean;
setAutoKeyframeEnabled: (enabled: boolean) => void;
lintFindingsByElement: Map<string, { count: number; messages: string[] }>;
setLintFindingsByElement: (map: Map<string, { count: number; messages: string[] }>) => void;
}
// Lightweight pub-sub for current time during playback.
@@ -192,6 +198,12 @@ export const usePlayerStore = create<PlayerState>((set) => ({
requestSeek: (time) => set({ requestedSeekTime: time }),
clearSeekRequest: () => set({ requestedSeekTime: null }),
autoKeyframeEnabled: true,
setAutoKeyframeEnabled: (enabled) => set({ autoKeyframeEnabled: enabled }),
lintFindingsByElement: new Map(),
setLintFindingsByElement: (map) => set({ lintFindingsByElement: map }),
setIsPlaying: (playing) => set({ isPlaying: playing }),
setPlaybackRate: (rate) => {
writeStudioUiPreferences({ playbackRate: rate });
+18 -1
View File
@@ -61,7 +61,24 @@ export function applySoftReload(iframe: HTMLIFrameElement | null, scriptText: st
if (timelines) {
for (const key of Object.keys(timelines)) {
try {
timelines[key]?.kill?.();
const tl = timelines[key] as {
kill?: () => void;
getChildren?: (deep: boolean) => Array<{ targets?: () => Element[] }>;
};
const allTargets: Element[] = [];
if (tl?.getChildren) {
try {
for (const child of tl.getChildren(true)) {
if (typeof child.targets === "function") {
for (const t of child.targets()) {
allTargets.push(t);
delete (t as unknown as Record<string, unknown>)._gsap;
}
}
}
} catch {}
}
tl?.kill?.();
} catch {}
delete timelines[key];
}