mirror of
https://github.com/heygen-com/hyperframes.git
synced 2026-09-03 04:38:33 +00:00
fix(studio): keyframe bug fixes — gate delete hooks, fix value corruption, gesture recording (#1314)
- Gate stripStudioEditsFromTarget/bakeVisibilityOnDelete behind a stripStudioEdits flag on the delete mutation type so they only fire on user-initiated deletes, not on internal delete-then-recreate drags. - Add bakeVisibilityOnDelete to the remove-all-keyframes handler so elements with CSS opacity:0 stay visible after collapsing keyframes. - Fix integer rounding in readAllAnimatedProperties: use 3-decimal precision for visual properties (opacity, scale, rotation) instead of Math.round which corrupted mid-fade values to 0. - Guard VISUAL_BASELINE against cross-tween contamination by querying __timelines for properties animated by other tweens on the same element. - Harden bakeVisibilityOnDelete: reverse-scan keyframes for the last one containing opacity, guard against relative values (+=/-=/*=), and add Number.isFinite check. - Fix falsy-zero doubling in drag commit: replace || fallback with Number.isFinite so a base GSAP position of 0 is correctly preserved. - Fix gesture recording sign inversion: remove pointerElementOffset subtraction from dx/dy formula and instead apply it once to basePosition so the element center tracks the pointer. - Fix TypeScript build errors in gsapSoftReload.ts (6 double-casts). - Strip all diagnostic logs from production code.
This commit is contained in:
@@ -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", () => {
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
|
||||
@@ -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)
|
||||
);
|
||||
}
|
||||
|
||||
|
||||
@@ -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>
|
||||
)}
|
||||
|
||||
@@ -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 = () => {
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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 },
|
||||
);
|
||||
},
|
||||
|
||||
@@ -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");
|
||||
|
||||
@@ -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" },
|
||||
);
|
||||
},
|
||||
|
||||
@@ -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 });
|
||||
|
||||
@@ -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];
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user