diff --git a/packages/core/src/parsers/gsapParser.inline.test.ts b/packages/core/src/parsers/gsapParser.inline.test.ts new file mode 100644 index 0000000000..e610638cba --- /dev/null +++ b/packages/core/src/parsers/gsapParser.inline.test.ts @@ -0,0 +1,92 @@ +import { describe, it, expect } from "vitest"; +import { + parseGsapScript, + updateAnimationInScript, + addAnimationToScript, + removeAnimationFromScript, + addKeyframeToScript, + removeAllKeyframesFromScript, +} from "./gsapParser.js"; + +// U4: recast parser/writer parity for the inline form +// `window.__timelines["scene"] = gsap.timeline()` (the default server write path). + +const inlineSrc = `window.__timelines = window.__timelines || {}; +window.__timelines["scene"] = gsap.timeline({ paused: true }); +window.__timelines["scene"].to("#a", { x: 100, duration: 1 }, 0); +window.__timelines["scene"].to("#b", { y: 50, duration: 1 }, 0.5);`; + +describe("recast — inline timeline read", () => { + it("reads inline tweens (double quote)", () => { + const p = parseGsapScript(inlineSrc); + expect(p.unsupportedTimelinePattern).toBeFalsy(); + expect(p.animations).toHaveLength(2); + expect(p.animations[0]!.targetSelector).toBe("#a"); + }); + + it("reads single-quote + dot access", () => { + const sq = `window.__timelines['s'] = gsap.timeline();\nwindow.__timelines['s'].to('#a', { x: 1, duration: 1 }, 0);`; + const dot = `window.__timelines.s = gsap.timeline();\nwindow.__timelines.s.to("#a", { x: 1, duration: 1 }, 0);`; + expect(parseGsapScript(sq).animations).toHaveLength(1); + expect(parseGsapScript(dot).animations).toHaveLength(1); + }); + + it("flags computed key as unsupported", () => { + const c = `const id = "s";\nwindow.__timelines[id] = gsap.timeline();\nwindow.__timelines[id].to("#a", { x: 1, duration: 1 }, 0);`; + expect(parseGsapScript(c).unsupportedTimelinePattern).toBe(true); + }); + + it("keeps the canonical const form unchanged", () => { + const c = `const tl = gsap.timeline();\nwindow.__timelines["s"] = tl;\ntl.to("#a", { x: 5, duration: 1 }, 0);`; + const p = parseGsapScript(c); + expect(p.timelineVar).toBe("tl"); + expect(p.animations).toHaveLength(1); + }); +}); + +describe("recast — inline timeline write", () => { + it("edits an inline tween in place", () => { + const id = parseGsapScript(inlineSrc).animations[0]!.id; + const out = updateAnimationInScript(inlineSrc, id, { properties: { x: 200 } }); + expect(out).toContain('window.__timelines["scene"].to("#a"'); + expect(out).toContain("200"); + expect(parseGsapScript(out).animations).toHaveLength(2); + }); + + it("adds a tween in member form", () => { + const out = addAnimationToScript(inlineSrc, { + method: "to", + targetSelector: "#c", + properties: { opacity: 1 }, + position: 1, + duration: 1, + }); + const script = typeof out === "string" ? out : out.script; + expect(script).toContain('window.__timelines["scene"].to("#c"'); + expect(parseGsapScript(script).animations).toHaveLength(3); + }); + + it("removes an inline tween", () => { + const id = parseGsapScript(inlineSrc).animations[1]!.id; + const out = removeAnimationFromScript(inlineSrc, id); + expect(out).not.toContain('"#b"'); + expect(parseGsapScript(out).animations).toHaveLength(1); + }); + + it("adds + removes keyframes on an inline tween", () => { + const id = parseGsapScript(inlineSrc).animations[0]!.id; + const withKf = addKeyframeToScript(inlineSrc, id, 50, { x: 150 }); + expect(withKf).toContain("keyframes"); + expect(parseGsapScript(withKf).unsupportedTimelinePattern).toBeFalsy(); + const kfId = parseGsapScript(withKf).animations[0]!.id; + const cleared = removeAllKeyframesFromScript(withKf, kfId); + expect(cleared).not.toContain("keyframes"); + }); + + it("preserves single-quote member form on write", () => { + const sq = `window.__timelines['s'] = gsap.timeline();\nwindow.__timelines['s'].to('#a', { x: 1, duration: 1 }, 0);`; + const id = parseGsapScript(sq).animations[0]!.id; + const out = updateAnimationInScript(sq, id, { properties: { x: 9 } }); + expect(out).toContain("window.__timelines['s']"); + }); +}); diff --git a/packages/core/src/parsers/gsapParser.ts b/packages/core/src/parsers/gsapParser.ts index ac40a8adf0..96ffd43e4f 100644 --- a/packages/core/src/parsers/gsapParser.ts +++ b/packages/core/src/parsers/gsapParser.ts @@ -376,12 +376,54 @@ interface TimelineDefaults { duration?: number; } +// `identifier` is the canonical `const tl = …` form; `member` is the inline +// `window.__timelines["scene"] = …` form (the timeline IS the member expression). +type TimelineRef = { kind: "identifier"; name: string } | { kind: "member"; node: AstNode }; + interface TimelineDetection { timelineVar: string | null; + ref: TimelineRef | null; timelineCount: number; defaults?: TimelineDefaults; } +/** The static string key of a member access (`window.__timelines["scene"]` → "scene"), else null. */ +function staticMemberKey(node: AstNode): string | null { + if (!node || node.type !== "MemberExpression") return null; + if (node.computed) { + const p = node.property; + if (p?.type === "StringLiteral") return p.value; + if (p?.type === "Literal" && typeof p.value === "string") return p.value; + return null; + } + return node.property?.type === "Identifier" ? node.property.name : null; +} + +function isStaticMemberRef(node: AstNode): boolean { + return node?.type === "MemberExpression" && staticMemberKey(node) !== null; +} + +/** Structural equality of two member accesses (object chain + static key), quote-insensitive. */ +function sameMemberAccess(a: AstNode, b: AstNode): boolean { + if (a?.type !== "MemberExpression" || b?.type !== "MemberExpression") return false; + if (staticMemberKey(a) !== staticMemberKey(b) || staticMemberKey(a) === null) return false; + const ao = a.object; + const bo = b.object; + if (ao?.type === "Identifier" && bo?.type === "Identifier") return ao.name === bo.name; + if (ao?.type === "MemberExpression" && bo?.type === "MemberExpression") + return sameMemberAccess(ao, bo); + return false; +} + +/** The source string a tween call roots at: identifier name, or the member source as written. */ +function timelineRootSource(ref: TimelineRef): string { + return ref.kind === "identifier" ? ref.name : recast.print(ref.node).code; +} + +function escapeRegExp(s: string): string { + return s.replace(/[.*+?^${}()|[\]\\]/g, "\\$&"); +} + function extractTimelineDefaults( callNode: AstNode, scope: ScopeBindings, @@ -401,6 +443,7 @@ function extractTimelineDefaults( function findTimelineVar(ast: AstNode, scope?: ScopeBindings): TimelineDetection { let timelineVar: string | null = null; + let ref: TimelineRef | null = null; let timelineCount = 0; let defaults: TimelineDefaults | undefined; const emptyScope: ScopeBindings = scope ?? new Map(); @@ -408,8 +451,9 @@ function findTimelineVar(ast: AstNode, scope?: ScopeBindings): TimelineDetection visitVariableDeclarator(path: AstPath) { if (isGsapTimelineCall(path.node.init)) { timelineCount += 1; - if (!timelineVar) { - timelineVar = path.node.id?.name ?? null; + if (!ref && path.node.id?.type === "Identifier") { + timelineVar = path.node.id.name; + ref = { kind: "identifier", name: path.node.id.name }; defaults = extractTimelineDefaults(path.node.init, emptyScope); } } @@ -418,16 +462,22 @@ function findTimelineVar(ast: AstNode, scope?: ScopeBindings): TimelineDetection visitAssignmentExpression(path: AstPath) { if (isGsapTimelineCall(path.node.right)) { timelineCount += 1; - if (!timelineVar) { + if (!ref) { const left = path.node.left; - if (left?.type === "Identifier") timelineVar = left.name; - defaults = extractTimelineDefaults(path.node.right, emptyScope); + if (left?.type === "Identifier") { + timelineVar = left.name; + ref = { kind: "identifier", name: left.name }; + defaults = extractTimelineDefaults(path.node.right, emptyScope); + } else if (isStaticMemberRef(left)) { + ref = { kind: "member", node: left }; + defaults = extractTimelineDefaults(path.node.right, emptyScope); + } } } this.traverse(path); }, }); - return { timelineVar, timelineCount, defaults }; + return { timelineVar, ref, timelineCount, defaults }; } // ── Find All Tween Calls ──────────────────────────────────────────────────── @@ -448,17 +498,18 @@ interface TweenCallInfo { * True when the member chain of `callNode.callee` is rooted at the timeline * variable — `tl.to(...)` and every link of a chain `tl.to(...).to(...)`. */ -function isTimelineRootedCall(callNode: AstNode, timelineVar: string): boolean { +function isTimelineRootedCall(callNode: AstNode, ref: TimelineRef): boolean { let obj = callNode.callee?.object; while (obj?.type === "CallExpression") { obj = obj.callee?.object; } - return obj?.type === "Identifier" && obj.name === timelineVar; + if (ref.kind === "identifier") return obj?.type === "Identifier" && obj.name === ref.name; + return sameMemberAccess(obj, ref.node); } function findAllTweenCalls( ast: AstNode, - timelineVar: string, + ref: TimelineRef, scope: ScopeBindings, targetBindings: TargetBindings, ): TweenCallInfo[] { @@ -484,7 +535,7 @@ function findAllTweenCalls( if ( callee?.type === "MemberExpression" && callee.property?.type === "Identifier" && - (isTimelineRootedCall(node, timelineVar) || isGlobalSet) + (isTimelineRootedCall(node, ref) || isGlobalSet) ) { const method = callee.property.name; if (!GSAP_METHODS.has(method)) { @@ -1131,8 +1182,9 @@ function parseGsapAst(script: string): ParsedGsapAst { const scope = collectScopeBindings(ast); const targetBindings = collectTargetBindings(ast, scope); const detection = findTimelineVar(ast, scope); - const timelineVar = detection.timelineVar ?? "tl"; - const calls = findAllTweenCalls(ast, timelineVar, scope, targetBindings); + const ref: TimelineRef = detection.ref ?? { kind: "identifier", name: "tl" }; + const timelineVar = timelineRootSource(ref); + const calls = findAllTweenCalls(ast, ref, scope, targetBindings); sortBySourcePosition(calls); const rawAnims = calls.map((call) => tweenCallToAnimation(call, scope)); applyTimelineDefaults(rawAnims, detection.defaults); @@ -1151,15 +1203,19 @@ function parseGsapAst(script: string): ParsedGsapAst { export function parseGsapScript(script: string): ParsedGsap { try { const { detection, timelineVar, located } = parseGsapAst(script); + const ref: TimelineRef = detection.ref ?? { kind: "identifier", name: "tl" }; const animations = located.map((l) => l.animation); - const timelineMatch = script.match( - new RegExp( - `^[\\s\\S]*?(?:const|let|var)\\s+${timelineVar}\\s*=\\s*gsap\\.timeline\\s*\\([^)]*\\)\\s*;?`, - ), - ); - const preamble = - timelineMatch?.[0] ?? `const ${timelineVar} = gsap.timeline({ paused: true });`; + const declPattern = + ref.kind === "identifier" + ? `(?:const|let|var)\\s+${timelineVar}\\s*=\\s*gsap\\.timeline\\s*\\([^)]*\\)\\s*;?` + : `${escapeRegExp(timelineVar)}\\s*=\\s*gsap\\.timeline\\s*\\([^)]*\\)\\s*;?`; + const timelineMatch = script.match(new RegExp(`^[\\s\\S]*?${declPattern}`)); + const fallbackPreamble = + ref.kind === "identifier" + ? `const ${timelineVar} = gsap.timeline({ paused: true });` + : `${timelineVar} = gsap.timeline({ paused: true });`; + const preamble = timelineMatch?.[0] ?? fallbackPreamble; const lastCallIdx = script.lastIndexOf(`${timelineVar}.`); let postamble = ""; @@ -1173,7 +1229,7 @@ export function parseGsapScript(script: string): ParsedGsap { const result: ParsedGsap = { animations, timelineVar, preamble, postamble }; if (detection.timelineCount > 1) result.multipleTimelines = true; - if (detection.timelineCount > 0 && detection.timelineVar === null) + if (detection.timelineCount > 0 && detection.ref === null) result.unsupportedTimelinePattern = true; return result; } catch { @@ -1468,7 +1524,7 @@ export function addAnimationToScript( return { script, id: "" }; } // Nothing to anchor against and no timeline to target — treat as parse failure. - if (parsed.located.length === 0 && parsed.detection.timelineVar === null) { + if (parsed.located.length === 0 && parsed.detection.ref === null) { return { script, id: "" }; } @@ -1500,7 +1556,7 @@ export function addAnimationWithKeyframesToScript( console.warn("[gsap-parser] addAnimationWithKeyframesToScript parse failed:", e); return { script, id: "" }; } - if (parsed.located.length === 0 && parsed.detection.timelineVar === null) { + if (parsed.located.length === 0 && parsed.detection.ref === null) { return { script, id: "" }; } @@ -2796,7 +2852,7 @@ export function addMotionPathToScript( console.warn("[gsap-parser] addMotionPathToScript parse failed:", e); return { script, id: null }; } - if (parsed.located.length === 0 && parsed.detection.timelineVar === null) { + if (parsed.located.length === 0 && parsed.detection.ref === null) { return { script, id: null }; } diff --git a/packages/core/src/parsers/gsapParserAcorn.inline.test.ts b/packages/core/src/parsers/gsapParserAcorn.inline.test.ts new file mode 100644 index 0000000000..11170a1164 --- /dev/null +++ b/packages/core/src/parsers/gsapParserAcorn.inline.test.ts @@ -0,0 +1,75 @@ +import { describe, it, expect } from "vitest"; +import { parseGsapScriptAcorn } from "./gsapParserAcorn.js"; + +// U1+U2: the editor must read timelines authored inline as +// `window.__timelines["id"] = gsap.timeline()` — not just the canonical +// `const tl = gsap.timeline(); window.__timelines[id] = tl` form. + +const wrap = (decl: string, tweens: string) => + `window.__timelines = window.__timelines || {};\n${decl}\n${tweens}`; + +describe("inline timeline assignment — read", () => { + it("reads tweens from a double-quoted inline timeline", () => { + const src = wrap( + `window.__timelines["scene"] = gsap.timeline({ paused: true });`, + `window.__timelines["scene"].to("#a", { x: 100, duration: 1 }, 0);\n` + + `window.__timelines["scene"].to("#b", { y: 50, duration: 1 }, 0.5);`, + ); + const parsed = parseGsapScriptAcorn(src); + expect(parsed.unsupportedTimelinePattern).toBeFalsy(); + expect(parsed.animations).toHaveLength(2); + expect(parsed.animations[0]!.targetSelector).toBe("#a"); + expect(parsed.animations[1]!.targetSelector).toBe("#b"); + }); + + it("reads a single-quoted inline timeline", () => { + const src = wrap( + `window.__timelines['scene'] = gsap.timeline();`, + `window.__timelines['scene'].to('#a', { x: 10, duration: 1 }, 0);`, + ); + const parsed = parseGsapScriptAcorn(src); + expect(parsed.unsupportedTimelinePattern).toBeFalsy(); + expect(parsed.animations).toHaveLength(1); + expect(parsed.animations[0]!.targetSelector).toBe("#a"); + }); + + it("reads a static dot-access inline timeline", () => { + const src = wrap( + `window.__timelines.scene = gsap.timeline();`, + `window.__timelines.scene.to("#a", { x: 10, duration: 1 }, 0);`, + ); + const parsed = parseGsapScriptAcorn(src); + expect(parsed.unsupportedTimelinePattern).toBeFalsy(); + expect(parsed.animations).toHaveLength(1); + }); + + it("flags a computed-key timeline as unsupported (cannot statically resolve)", () => { + const src = wrap( + `const id = "scene";\nwindow.__timelines[id] = gsap.timeline();`, + `window.__timelines[id].to("#a", { x: 10, duration: 1 }, 0);`, + ); + const parsed = parseGsapScriptAcorn(src); + expect(parsed.unsupportedTimelinePattern).toBe(true); + }); + + it("does not cross-attribute tweens of a different member slot", () => { + const src = wrap( + `window.__timelines["a"] = gsap.timeline();\nwindow.__timelines["b"] = gsap.timeline();`, + `window.__timelines["a"].to("#a", { x: 1, duration: 1 }, 0);\n` + + `window.__timelines["b"].to("#b", { x: 2, duration: 1 }, 0);`, + ); + const parsed = parseGsapScriptAcorn(src); + // First detected timeline is "a"; only its tween should be attributed here. + expect(parsed.multipleTimelines).toBe(true); + expect(parsed.animations.some((a) => a.targetSelector === "#a")).toBe(true); + expect(parsed.animations.every((a) => a.targetSelector !== "#b")).toBe(true); + }); + + it("leaves the canonical const form working", () => { + const src = `const tl = gsap.timeline();\nwindow.__timelines["scene"] = tl;\ntl.to("#a", { x: 5, duration: 1 }, 0);`; + const parsed = parseGsapScriptAcorn(src); + expect(parsed.unsupportedTimelinePattern).toBeFalsy(); + expect(parsed.animations).toHaveLength(1); + expect(parsed.timelineVar).toBe("tl"); + }); +}); diff --git a/packages/core/src/parsers/gsapParserAcorn.ts b/packages/core/src/parsers/gsapParserAcorn.ts index 38ca4cb8c0..94c88e887c 100644 --- a/packages/core/src/parsers/gsapParserAcorn.ts +++ b/packages/core/src/parsers/gsapParserAcorn.ts @@ -358,12 +358,57 @@ interface TimelineDefaults { duration?: number; } +// How the timeline is referred to in source. `identifier` is the canonical +// `const tl = …` form; `member` is the inline `window.__timelines["scene"] = …` +// form, where the timeline IS the member expression (no variable name). +type TimelineRef = { kind: "identifier"; name: string } | { kind: "member"; node: any }; + interface TimelineDetection { + /** Identifier name for the canonical form, else null (member or none). */ timelineVar: string | null; + /** Structural reference: identifier OR member expression. Null when none found. */ + ref: TimelineRef | null; timelineCount: number; defaults?: TimelineDefaults; } +/** The static string key of a member access (`window.__timelines["scene"]` → "scene"), else null. */ +function staticMemberKey(node: any): string | null { + if (!node || node.type !== "MemberExpression") return null; + if (node.computed) { + const p = node.property; + if (p?.type === "Literal" && typeof p.value === "string") return p.value; + return null; // computed non-string-literal key → not statically resolvable + } + return node.property?.type === "Identifier" ? node.property.name : null; +} + +/** True when a member expression refers to a statically-resolvable timeline slot. */ +function isStaticMemberRef(node: any): boolean { + return node?.type === "MemberExpression" && staticMemberKey(node) !== null; +} + +/** Structural equality of two member-access nodes (object chain + static key), quote-insensitive. */ +function sameMemberAccess(a: any, b: any): boolean { + if (a?.type !== "MemberExpression" || b?.type !== "MemberExpression") return false; + if (staticMemberKey(a) !== staticMemberKey(b) || staticMemberKey(a) === null) return false; + const ao = a.object; + const bo = b.object; + if (ao?.type === "Identifier" && bo?.type === "Identifier") return ao.name === bo.name; + if (ao?.type === "MemberExpression" && bo?.type === "MemberExpression") + return sameMemberAccess(ao, bo); + return false; +} + +/** The source string a tween call is rooted at: identifier name, or the member source as written. */ +function timelineRootSource(ref: TimelineRef, script: string): string { + return ref.kind === "identifier" ? ref.name : script.slice(ref.node.start, ref.node.end); +} + +function escapeRegExp(s: string): string { + return s.replace(/[.*+?^${}()|[\]\\]/g, "\\$&"); +} + // fallow-ignore-next-line complexity function extractTimelineDefaults( callNode: any, @@ -388,6 +433,7 @@ function extractTimelineDefaults( function findTimelineVar(ast: any, scope?: ScopeBindings): TimelineDetection { let timelineVar: string | null = null; + let ref: TimelineRef | null = null; let timelineCount = 0; let defaults: TimelineDefaults | undefined; const emptyScope: ScopeBindings = scope ?? new Map(); @@ -396,8 +442,9 @@ function findTimelineVar(ast: any, scope?: ScopeBindings): TimelineDetection { VariableDeclarator(node: any) { if (isGsapTimelineCall(node.init)) { timelineCount += 1; - if (!timelineVar) { - timelineVar = node.id?.name ?? null; + if (!ref && node.id?.type === "Identifier") { + timelineVar = node.id.name; + ref = { kind: "identifier", name: node.id.name }; defaults = extractTimelineDefaults(node.init, emptyScope); } } @@ -405,16 +452,23 @@ function findTimelineVar(ast: any, scope?: ScopeBindings): TimelineDetection { AssignmentExpression(node: any) { if (isGsapTimelineCall(node.right)) { timelineCount += 1; - if (!timelineVar) { + if (!ref) { const left = node.left; - if (left?.type === "Identifier") timelineVar = left.name; - defaults = extractTimelineDefaults(node.right, emptyScope); + if (left?.type === "Identifier") { + timelineVar = left.name; + ref = { kind: "identifier", name: left.name }; + defaults = extractTimelineDefaults(node.right, emptyScope); + } else if (isStaticMemberRef(left)) { + // Inline form: `window.__timelines["scene"] = gsap.timeline(...)`. + ref = { kind: "member", node: left }; + defaults = extractTimelineDefaults(node.right, emptyScope); + } } } }, }); - return { timelineVar, timelineCount, defaults }; + return { timelineVar, ref, timelineCount, defaults }; } // ── Tween call collection ───────────────────────────────────────────────────── @@ -447,13 +501,14 @@ export interface TweenCallInfo { global?: boolean; } -/** True when callee chain is rooted at the timeline variable. */ -function isTimelineRootedCall(callNode: any, timelineVar: string): boolean { +/** True when the callee chain is rooted at the timeline reference (identifier or member). */ +function isTimelineRootedCall(callNode: any, ref: TimelineRef): boolean { let obj = callNode.callee?.object; while (obj?.type === "CallExpression") { obj = obj.callee?.object; } - return obj?.type === "Identifier" && obj.name === timelineVar; + if (ref.kind === "identifier") return obj?.type === "Identifier" && obj.name === ref.name; + return sameMemberAccess(obj, ref.node); } /** @@ -465,7 +520,7 @@ function isTimelineRootedCall(callNode: any, timelineVar: string): boolean { */ function findAllTweenCalls( ast: any, - timelineVar: string, + ref: TimelineRef, scope: ScopeBindings, targetBindings: TargetBindings, ): TweenCallInfo[] { @@ -494,7 +549,7 @@ function findAllTweenCalls( if ( callee?.type === "MemberExpression" && callee.property?.type === "Identifier" && - (isTimelineRootedCall(node, timelineVar) || isGlobalSet) && + (isTimelineRootedCall(node, ref) || isGlobalSet) && GSAP_METHODS.has(callee.property.name) ) { const method = callee.property.name; @@ -1092,8 +1147,9 @@ export function parseGsapScriptAcornForWrite(script: string): ParsedGsapAcornFor const scope = collectScopeBindings(ast); const targetBindings = collectTargetBindings(ast, scope); const detection = findTimelineVar(ast, scope); - const timelineVar = detection.timelineVar ?? "tl"; - const calls = findAllTweenCalls(ast, timelineVar, scope, targetBindings); + const ref: TimelineRef = detection.ref ?? { kind: "identifier", name: "tl" }; + const timelineVar = timelineRootSource(ref, script); + const calls = findAllTweenCalls(ast, ref, scope, targetBindings); sortBySourcePosition(calls); const rawAnims = calls.map((call) => tweenCallToAnimation(call, scope, script)); applyTimelineDefaults(rawAnims, detection.defaults); @@ -1104,7 +1160,7 @@ export function parseGsapScriptAcornForWrite(script: string): ParsedGsapAcornFor call, animation: animations[i]!, })); - return { ast, timelineVar, hasTimeline: detection.timelineVar !== null, located }; + return { ast, timelineVar, hasTimeline: detection.ref !== null, located }; } catch { return null; } @@ -1125,30 +1181,41 @@ export function parseGsapScriptAcorn(script: string): ParsedGsap { }); const scope = collectScopeBindings(ast); const detection = findTimelineVar(ast, scope); - const timelineVar = detection.timelineVar ?? "tl"; + const ref: TimelineRef = detection.ref ?? { kind: "identifier", name: "tl" }; + const timelineVar = timelineRootSource(ref, script); // Expand helper-built / bounded-loop timelines before analysis so their // tweens resolve at true positions (read path only — the write path keeps // original source nodes). Degrades to the un-inlined AST on any failure. - try { - inlineComputedTimelines(ast, timelineVar, (node) => resolveNode(node, scope)); - } catch { - /* fall back to current behavior */ + // Only the identifier form uses the helper-built pattern; inline member + // timelines have nothing to inline, so skip (avoids mis-rooting on the member). + if (ref.kind === "identifier") { + try { + inlineComputedTimelines(ast, timelineVar, (node) => resolveNode(node, scope)); + } catch { + /* fall back to current behavior */ + } } const targetBindings = collectTargetBindings(ast, scope); - const calls = findAllTweenCalls(ast, timelineVar, scope, targetBindings); + const calls = findAllTweenCalls(ast, ref, scope, targetBindings); sortBySourcePosition(calls); const rawAnims = calls.map((call) => tweenCallToAnimation(call, scope, script)); applyTimelineDefaults(rawAnims, detection.defaults); resolveTimelinePositions(rawAnims); const animations = assignStableIds(rawAnims); - const timelineMatch = script.match( - new RegExp( - `^[\\s\\S]*?(?:const|let|var)\\s+${timelineVar}\\s*=\\s*gsap\\.timeline\\s*\\([^)]*\\)\\s*;?`, - ), - ); - const preamble = - timelineMatch?.[0] ?? `const ${timelineVar} = gsap.timeline({ paused: true });`; + // Preamble = source up to and including the timeline declaration/assignment. + // Identifier keeps the original `const|let|var = …` regex (byte-stable); + // member matches ` = …`. + const declPattern = + ref.kind === "identifier" + ? `(?:const|let|var)\\s+${timelineVar}\\s*=\\s*gsap\\.timeline\\s*\\([^)]*\\)\\s*;?` + : `${escapeRegExp(timelineVar)}\\s*=\\s*gsap\\.timeline\\s*\\([^)]*\\)\\s*;?`; + const timelineMatch = script.match(new RegExp(`^[\\s\\S]*?${declPattern}`)); + const fallbackPreamble = + ref.kind === "identifier" + ? `const ${timelineVar} = gsap.timeline({ paused: true });` + : `${timelineVar} = gsap.timeline({ paused: true });`; + const preamble = timelineMatch?.[0] ?? fallbackPreamble; const lastCallIdx = script.lastIndexOf(`${timelineVar}.`); let postamble = ""; @@ -1162,7 +1229,7 @@ export function parseGsapScriptAcorn(script: string): ParsedGsap { const result: ParsedGsap = { animations, timelineVar, preamble, postamble }; if (detection.timelineCount > 1) result.multipleTimelines = true; - if (detection.timelineCount > 0 && detection.timelineVar === null) + if (detection.timelineCount > 0 && detection.ref === null) result.unsupportedTimelinePattern = true; return result; } catch { @@ -1194,7 +1261,7 @@ export function extractGsapLabels(script: string): GsapLabelEntry[] { }); const scope = collectScopeBindings(ast); const detection = findTimelineVar(ast, scope); - const timelineVar = detection.timelineVar ?? "tl"; + const ref: TimelineRef = detection.ref ?? { kind: "identifier", name: "tl" }; const labels: GsapLabelEntry[] = []; @@ -1204,10 +1271,14 @@ export function extractGsapLabels(script: string): GsapLabelEntry[] { const expr = node.expression; if (!expr || expr.type !== "CallExpression") return; const callee = expr.callee; - // Match tl.addLabel(...) + // Match .addLabel(...) for identifier or member timeline refs. + const objMatches = + ref.kind === "identifier" + ? callee.object?.type === "Identifier" && callee.object.name === ref.name + : sameMemberAccess(callee.object, ref.node); if ( callee?.type !== "MemberExpression" || - callee.object?.name !== timelineVar || + !objMatches || callee.property?.name !== "addLabel" ) return; diff --git a/packages/core/src/parsers/gsapWriterAcorn.inline.test.ts b/packages/core/src/parsers/gsapWriterAcorn.inline.test.ts new file mode 100644 index 0000000000..a193a23a0b --- /dev/null +++ b/packages/core/src/parsers/gsapWriterAcorn.inline.test.ts @@ -0,0 +1,98 @@ +import { describe, it, expect } from "vitest"; +import { parseGsapScriptAcorn } from "./gsapParserAcorn.js"; +import { + updateAnimationInScript, + addAnimationToScript, + removeAnimationFromScript, + addKeyframeToScript, + removeAllKeyframesFromScript, +} from "./gsapWriterAcorn.js"; + +// U3: edit/add/delete tweens on a timeline authored inline as +// `window.__timelines["scene"] = gsap.timeline()`, emitting the member form. + +const inlineSrc = `window.__timelines = window.__timelines || {}; +window.__timelines["scene"] = gsap.timeline({ paused: true }); +window.__timelines["scene"].to("#a", { x: 100, duration: 1 }, 0); +window.__timelines["scene"].to("#b", { y: 50, duration: 1 }, 0.5);`; + +describe("inline timeline assignment — write", () => { + it("edits an existing inline tween's value in place", () => { + const id = parseGsapScriptAcorn(inlineSrc).animations[0]!.id; + const out = updateAnimationInScript(inlineSrc, id, { properties: { x: 200 } }); + expect(out).toContain('window.__timelines["scene"].to("#a"'); + expect(out).toContain("200"); + const reread = parseGsapScriptAcorn(out); + expect(reread.animations).toHaveLength(2); + expect(reread.unsupportedTimelinePattern).toBeFalsy(); + }); + + it("adds a new tween in the member form", () => { + const { script: out } = addAnimationToScript(inlineSrc, { + method: "to", + targetSelector: "#c", + properties: { opacity: 1 }, + position: 1, + duration: 1, + }); + expect(out).toContain('window.__timelines["scene"].to("#c"'); + expect(parseGsapScriptAcorn(out).animations).toHaveLength(3); + }); + + it("removes an inline tween, leaving the rest", () => { + const id = parseGsapScriptAcorn(inlineSrc).animations[1]!.id; + const out = removeAnimationFromScript(inlineSrc, id); + expect(out).not.toContain('"#b"'); + expect(parseGsapScriptAcorn(out).animations).toHaveLength(1); + }); + + it("preserves single-quote member form on write", () => { + const sq = `window.__timelines = window.__timelines || {}; +window.__timelines['scene'] = gsap.timeline(); +window.__timelines['scene'].to('#a', { x: 1, duration: 1 }, 0);`; + const id = parseGsapScriptAcorn(sq).animations[0]!.id; + const out = updateAnimationInScript(sq, id, { properties: { x: 9 } }); + expect(out).toContain("window.__timelines['scene']"); + expect(parseGsapScriptAcorn(out).animations).toHaveLength(1); + }); + + it("converts an inline tween to keyframes by adding one (the delete-all-keyframes bug area)", () => { + const id = parseGsapScriptAcorn(inlineSrc).animations[0]!.id; + const out = addKeyframeToScript(inlineSrc, id, 50, { x: 150 }); + expect(out).toContain("keyframes"); + expect(out).toContain('window.__timelines["scene"]'); + expect(parseGsapScriptAcorn(out).unsupportedTimelinePattern).toBeFalsy(); + }); + + it("removes all keyframes from an inline keyframed tween", () => { + const kf = `window.__timelines = window.__timelines || {}; +window.__timelines["scene"] = gsap.timeline(); +window.__timelines["scene"].to("#a", { keyframes: { "0%": { x: 0 }, "100%": { x: 100 } }, duration: 1 }, 0);`; + const id = parseGsapScriptAcorn(kf).animations[0]!.id; + const out = removeAllKeyframesFromScript(kf, id); + expect(out).not.toContain("keyframes"); + }); + + it("adds the first tween to an empty inline timeline", () => { + const empty = `window.__timelines = window.__timelines || {}; +window.__timelines["scene"] = gsap.timeline({ paused: true });`; + const { script: out } = addAnimationToScript(empty, { + method: "to", + targetSelector: "#a", + properties: { x: 10 }, + position: 0, + duration: 1, + }); + expect(out).toContain('window.__timelines["scene"].to("#a"'); + expect(parseGsapScriptAcorn(out).animations).toHaveLength(1); + }); + + it("no-op write is stable (read → re-emit same → re-read equal count)", () => { + const parsed = parseGsapScriptAcorn(inlineSrc); + const id = parsed.animations[0]!.id; + const out = updateAnimationInScript(inlineSrc, id, { + properties: parsed.animations[0]!.properties, + }); + expect(parseGsapScriptAcorn(out).animations).toHaveLength(2); + }); +}); diff --git a/packages/core/src/parsers/gsapWriterAcorn.ts b/packages/core/src/parsers/gsapWriterAcorn.ts index 3d9bf70027..96da5d0781 100644 --- a/packages/core/src/parsers/gsapWriterAcorn.ts +++ b/packages/core/src/parsers/gsapWriterAcorn.ts @@ -776,10 +776,22 @@ export function updateKeyframeInScript( const match = findKfPropByPct(kfPropNode.value, percentage); if (!match) return script; - const record: Record = { ...properties }; - if (ease) record.ease = ease; const ms = new MagicString(script); - ms.overwrite(match.prop.value.start, match.prop.value.end, recordToCode(record)); + // MERGE the edited props into the existing keyframe, preserving properties already + // keyframed at this percentage (z, transformPerspective, rotation, …). A whole-value + // overwrite DROPS every prop not in this edit — e.g. editing rotationY at the 0% + // keyframe would strip z / transformPerspective, so the lens then animates from 0 and + // the element pops. Mirrors addKeyframeToScript's merge-into-existing branch. + if (match.prop.value?.type === "ObjectExpression") { + for (const [k, v] of Object.entries(properties)) { + upsertProp(ms, match.prop.value, k, v); + } + if (ease !== undefined) upsertProp(ms, match.prop.value, "ease", ease); + } else { + const record: Record = { ...properties }; + if (ease) record.ease = ease; + ms.overwrite(match.prop.value.start, match.prop.value.end, recordToCode(record)); + } return ms.toString(); } diff --git a/packages/core/src/runtime/init.ts b/packages/core/src/runtime/init.ts index 1c5e5f82a1..defbab05c9 100644 --- a/packages/core/src/runtime/init.ts +++ b/packages/core/src/runtime/init.ts @@ -56,6 +56,25 @@ export function initSandboxRuntimeModular(): void { swallow("runtime.init.site1", err); } } + // `_auto` is a Studio-internal keyframe marker (an auto-tracked endpoint the + // parser reads back), NOT an animatable property. Register it as a no-op GSAP + // plugin so GSAP doesn't log "Invalid property _auto" on every tween build — + // that per-frame warning destabilizes the preview and makes the selection + // overlay stop tracking the pointer. Idempotent + best-effort. + const ensureAutoMarkerNoop = (): void => { + const g = window.gsap as + | { registerPlugin?: (plugin: unknown) => void } + | undefined; + const w = window as Window & { __hfAutoNoopRegistered?: boolean }; + if (!g?.registerPlugin || w.__hfAutoNoopRegistered) return; + try { + g.registerPlugin({ name: "_auto", init: () => false }); + w.__hfAutoNoopRegistered = true; + } catch { + // a stray warning is preferable to a broken runtime + } + }; + ensureAutoMarkerNoop(); // Normalize html/body so browser defaults (8px margin, white background) never // bleed into renders as white bars. Runs in both preview and render contexts, // eliminating the preview/render parity gap that existed when only the React diff --git a/packages/core/src/studio-api/helpers/sourceMutation.test.ts b/packages/core/src/studio-api/helpers/sourceMutation.test.ts index a0f3dedcb1..37145686af 100644 --- a/packages/core/src/studio-api/helpers/sourceMutation.test.ts +++ b/packages/core/src/studio-api/helpers/sourceMutation.test.ts @@ -1,9 +1,12 @@ +import { parseHTML } from "linkedom"; import { describe, expect, it } from "vitest"; import { removeElementFromHtml, patchElementInHtml, splitElementInHtml, probeElementInSource, + wrapElementsInHtml, + unwrapElementsFromHtml, } from "./sourceMutation.js"; describe("removeElementFromHtml", () => { @@ -539,3 +542,111 @@ describe("splitElementInHtml", () => { expect(result.html).toMatch(/id="box-split"[^>]*data-playback-start="2"/); }); }); + +describe("wrapElementsInHtml / unwrapElementsFromHtml", () => { + // Three positioning flavours the rebase must leave visually identical: + // plain inline left/top, a GSAP transform delta, and a --hf-studio-offset var. + const FIXTURE = `
+
Title
+ +
Badge
+
Outside
+
`; + + // bbox top-left = (min left, min top) over the three members. + const BBOX = { left: 260, top: 50, width: 300, height: 300 }; + const REBASES = [ + { target: { id: "title" }, left: 0, top: 50 }, // 260-260, 100-50 + { target: { id: "logo" }, left: 40, top: 150 }, // 300-260, 200-50 + { target: { id: "badge" }, left: 140, top: 0 }, // 400-260, 50-50 + ]; + const TARGETS = [{ id: "title" }, { id: "logo" }, { id: "badge" }]; + + function leftTop(el: Element): { left: number; top: number } { + const style = el.getAttribute("style") ?? ""; + const left = parseFloat(/(?:^|;)\s*left\s*:\s*([\d.]+)px/.exec(style)?.[1] ?? "NaN"); + const top = parseFloat(/(?:^|;)\s*top\s*:\s*([\d.]+)px/.exec(style)?.[1] ?? "NaN"); + return { left, top }; + } + + it("wraps members in a data-hf-group div, preserving order and rebasing left/top", () => { + const { html, matched, groupId } = wrapElementsInHtml( + FIXTURE, + TARGETS, + "Group 1", + BBOX, + REBASES, + ); + expect(matched).toBe(true); + expect(groupId).toBe("Group 1"); + + const { document } = parseHTML(html); + const group = document.querySelector('[data-hf-group="Group 1"]')!; + expect(group).not.toBeNull(); + + // Wrapper sits at the bbox top-left. + expect(leftTop(group)).toEqual({ left: 260, top: 50 }); + + // Members are inside the wrapper, in original DOM order (= z-order). + const childIds = Array.from(group.children).map((c) => c.id); + expect(childIds).toEqual(["title", "logo", "badge"]); + + // Non-member stays outside. + expect(document.querySelector("#outside")!.parentElement).toBe( + document.querySelector('[data-composition-id="main"]'), + ); + + // Each member rebased; transform + offset var untouched. + expect(leftTop(document.querySelector("#title")!)).toEqual({ left: 0, top: 50 }); + expect(leftTop(document.querySelector("#logo")!)).toEqual({ left: 40, top: 150 }); + expect(document.querySelector("#logo")!.getAttribute("style")).toContain( + "transform: translate(10px, 5px)", + ); + expect(leftTop(document.querySelector("#badge")!)).toEqual({ left: 140, top: 0 }); + expect(document.querySelector("#badge")!.getAttribute("style")).toContain( + "--hf-studio-offset: 12px", + ); + }); + + it("round-trips: unwrap restores original structure and coordinates", () => { + const wrapped = wrapElementsInHtml(FIXTURE, TARGETS, "Group 1", BBOX, REBASES).html; + const { html, unwrapped } = unwrapElementsFromHtml(wrapped, { + selector: '[data-hf-group="Group 1"]', + }); + expect(unwrapped).toBe(true); + + const { document } = parseHTML(html); + expect(document.querySelector("[data-hf-group]")).toBeNull(); + + const main = document.querySelector('[data-composition-id="main"]')!; + // Members back in the parent, original order relative to the outside sibling. + expect(Array.from(main.children).map((c) => c.id)).toEqual([ + "title", + "logo", + "badge", + "outside", + ]); + + // Coordinates restored; transform + offset var intact. + expect(leftTop(document.querySelector("#title")!)).toEqual({ left: 260, top: 100 }); + expect(leftTop(document.querySelector("#logo")!)).toEqual({ left: 300, top: 200 }); + expect(document.querySelector("#logo")!.getAttribute("style")).toContain( + "transform: translate(10px, 5px)", + ); + expect(leftTop(document.querySelector("#badge")!)).toEqual({ left: 400, top: 50 }); + expect(document.querySelector("#badge")!.getAttribute("style")).toContain( + "--hf-studio-offset: 12px", + ); + }); + + it("rejects members that do not share a single parent", () => { + const split = `
`; + const result = wrapElementsInHtml(split, [{ id: "a" }, { id: "b" }], "Group 1", BBOX, [ + { target: { id: "a" }, left: 0, top: 0 }, + { target: { id: "b" }, left: 0, top: 0 }, + ]); + expect(result.matched).toBe(false); + expect(result.error).toMatch(/single parent/); + expect(result.html).toBe(split); + }); +}); diff --git a/packages/core/src/studio-api/helpers/sourceMutation.ts b/packages/core/src/studio-api/helpers/sourceMutation.ts index 727cd326a2..69fb18c435 100644 --- a/packages/core/src/studio-api/helpers/sourceMutation.ts +++ b/packages/core/src/studio-api/helpers/sourceMutation.ts @@ -135,7 +135,7 @@ export interface PatchOperation { } // fallow-ignore-next-line complexity -function patchStyleAttrString(style: string, property: string, value: string | null): string { +function parseStyleDecls(style: string): { props: Map; order: string[] } { const props = new Map(); const order: string[] = []; // Tokenize declarations robustly: values can contain ';' inside quoted strings @@ -171,6 +171,18 @@ function patchStyleAttrString(style: string, property: string, value: string | n if (!props.has(key)) order.push(key); props.set(key, val); } + return { props, order }; +} + +function serializeStyleDecls(props: Map, order: string[]): string { + return order + .map((k) => `${k}: ${props.get(k) ?? ""}`) + .filter((d) => d.trim()) + .join("; "); +} + +function patchStyleAttrString(style: string, property: string, value: string | null): string { + const { props, order } = parseStyleDecls(style); if (value === null) { props.delete(property); const idx = order.indexOf(property); @@ -179,10 +191,7 @@ function patchStyleAttrString(style: string, property: string, value: string | n if (!props.has(property)) order.push(property); props.set(property, value); } - return order - .map((k) => `${k}: ${props.get(k) ?? ""}`) - .filter((d) => d.trim()) - .join("; "); + return serializeStyleDecls(props, order); } // fallow-ignore-next-line complexity @@ -376,3 +385,193 @@ export function splitElementInHtml( newId, }; } + +// --- Element grouping ------------------------------------------------------- +// A group is a real `
` wrapping its members in the DOM. +// Wrapping rebases each member's left/top so its absolute position is unchanged: +// the wrapper sits at the selection bbox top-left, and each child's new left/top +// is its old left/top minus the wrapper origin (computed client-side, where live +// layout is available, and passed in via `rebases`). GSAP x/y, CSS translate and +// --hf-studio-offset vars are deltas relative to flow position and stay untouched. + +export interface WrapElementsResult { + html: string; + matched: boolean; + groupId: string | null; + error?: string; +} + +export interface UnwrapElementsResult { + html: string; + unwrapped: boolean; + /** The unwrapped wrapper's id, so callers can strip GSAP that targeted it + * (the wrapper is gone; a leftover `gsap.set("#id")` would throw at runtime). */ + unwrappedGroupId?: string; + /** Members (id'd children) with their absolute layout centres (post un-rebase), + * so the caller can BAKE the group's GSAP transform into each member before + * stripping it — otherwise the group's moves are lost on ungroup. */ + members?: Array<{ id: string; cx: number; cy: number }>; + /** The wrapper's layout centre — the pivot for baking the group's rotation/scale. */ + groupCenter?: { cx: number; cy: number }; +} + +export interface ElementRebase { + target: SourceMutationTarget; + left: number; + top: number; +} + +function getInlineStylePx(el: Element, property: string): number { + const style = (isHTMLElement(el) ? el.getAttribute("style") : null) ?? ""; + const { props } = parseStyleDecls(style); + const raw = props.get(property); + if (!raw) return 0; + const n = parseFloat(raw); + return Number.isFinite(n) ? n : 0; +} + +// Slug the group name ("Group 1" → "group-1") into a unique, valid element id. +function uniqueGroupDomId(document: Document, groupId: string): string { + const base = + groupId + .trim() + .toLowerCase() + .replace(/[^a-z0-9]+/g, "-") + .replace(/^-+|-+$/g, "") || "group"; + let id = base; + let n = 2; + while (document.getElementById(id)) { + id = `${base}-${n}`; + n += 1; + } + return id; +} + +function setInlineLeftTop(el: HTMLElement, left: number, top: number): void { + let style = el.getAttribute("style") ?? ""; + style = patchStyleAttrString(style, "left", `${left}px`); + style = patchStyleAttrString(style, "top", `${top}px`); + el.setAttribute("style", style); +} + +// fallow-ignore-next-line complexity +export function wrapElementsInHtml( + source: string, + targets: SourceMutationTarget[], + groupId: string, + bbox: { left: number; top: number; width: number; height: number }, + rebases: ElementRebase[], +): WrapElementsResult { + const { document, wrappedFragment } = parseSourceDocument(source); + if (targets.length === 0) { + return { html: source, matched: false, groupId: null, error: "no targets" }; + } + + // Resolve + dedupe by element ref (two targets may point at the same node). + const els: HTMLElement[] = []; + const seen = new Set(); + for (const target of targets) { + const el = findTargetElement(document, target); + if (!el || !isHTMLElement(el) || seen.has(el)) continue; + seen.add(el); + els.push(el); + } + if (els.length === 0) { + return { html: source, matched: false, groupId: null, error: "no targets matched" }; + } + + // P1: require a single common parent (LCA multi-parent wrapping is P2). + const parent = els[0]?.parentElement; + if (!parent || els.some((el) => el.parentElement !== parent)) { + return { + html: source, + matched: false, + groupId: null, + error: "grouped elements must share a single parent", + }; + } + + // Order members by their position in the parent (= z-order / stacking order). + const memberSet = new Set(els); + const ordered = Array.from(parent.children).filter((c): c is HTMLElement => memberSet.has(c)); + + // Map each member to its rebased left/top (resolved against the same document). + const rebaseByEl = new Map(); + for (const rebase of rebases) { + const el = findTargetElement(document, rebase.target); + if (el) rebaseByEl.set(el, { left: rebase.left, top: rebase.top }); + } + + const wrapper = document.createElement("div"); + wrapper.setAttribute("data-hf-group", groupId); + // A real `id` (slug of the group name) makes the wrapper a first-class node in the + // clip manifest / timeline parent-map (both keyed by id) and a clean GSAP target — + // without it the wrapper is invisible to the timeline and breaks child enumeration. + wrapper.setAttribute("id", uniqueGroupDomId(document, groupId)); + wrapper.setAttribute( + "style", + `position: absolute; left: ${bbox.left}px; top: ${bbox.top}px; width: ${bbox.width}px; height: ${bbox.height}px`, + ); + + // Insert the wrapper at the first member's slot, then move members into it. + parent.insertBefore(wrapper, ordered[0] ?? null); + for (const el of ordered) { + const rebase = rebaseByEl.get(el); + if (rebase) setInlineLeftTop(el, rebase.left, rebase.top); + wrapper.appendChild(el); // appendChild moves the node, preserving order + } + + return { + html: wrappedFragment ? document.body.innerHTML || "" : document.toString(), + matched: true, + groupId, + }; +} + +export function unwrapElementsFromHtml( + source: string, + groupTarget: SourceMutationTarget, +): UnwrapElementsResult { + const { document, wrappedFragment } = parseSourceDocument(source); + const group = findTargetElement(document, groupTarget); + if (!group || !isHTMLElement(group)) return { html: source, unwrapped: false }; + + const parent = group.parentElement; + if (!parent) return { html: source, unwrapped: false }; + + // Undo the rebase: child absolute position = child (rebased) + wrapper origin. + const wLeft = getInlineStylePx(group, "left"); + const wTop = getInlineStylePx(group, "top"); + const groupCenter = { + cx: wLeft + getInlineStylePx(group, "width") / 2, + cy: wTop + getInlineStylePx(group, "height") / 2, + }; + + // Move children back to the wrapper's slot, preserving order. + const members: Array<{ id: string; cx: number; cy: number }> = []; + for (const child of Array.from(group.children)) { + if (isHTMLElement(child)) { + const newLeft = getInlineStylePx(child, "left") + wLeft; + const newTop = getInlineStylePx(child, "top") + wTop; + setInlineLeftTop(child, newLeft, newTop); + if (child.id) { + members.push({ + id: child.id, + cx: newLeft + getInlineStylePx(child, "width") / 2, + cy: newTop + getInlineStylePx(child, "height") / 2, + }); + } + } + parent.insertBefore(child, group); + } + const groupId = group.id || undefined; + group.remove(); + + return { + html: wrappedFragment ? document.body.innerHTML || "" : document.toString(), + unwrapped: true, + unwrappedGroupId: groupId, + members, + groupCenter, + }; +} diff --git a/packages/core/src/studio-api/routes/files.ts b/packages/core/src/studio-api/routes/files.ts index 08d4fdd202..81b37046bb 100644 --- a/packages/core/src/studio-api/routes/files.ts +++ b/packages/core/src/studio-api/routes/files.ts @@ -56,8 +56,11 @@ import { patchElementInHtml, probeElementInSource, splitElementInHtml, + wrapElementsInHtml, + unwrapElementsFromHtml, isHTMLElement, type PatchOperation, + type ElementRebase, } from "../helpers/sourceMutation.js"; import { parseHTML } from "linkedom"; @@ -311,6 +314,109 @@ function extractGsapScriptBlock(html: string): { return null; } +/** + * Remove every GSAP animation that targets `selector` from an HTML string's + * inline script. Used after unwrapping a group so its leftover `gsap.set("#id")` + * (the wrapper is gone) doesn't throw "target not found" on every preview run. + */ +function stripGsapAnimationsForSelector(html: string, selector: string): string { + const block = extractGsapScriptBlock(html); + if (!block) return html; + const parsed = parseGsapScriptAcorn(block.scriptText); + const matching = parsed.animations.filter((a) => a.targetSelector === selector); + if (matching.length === 0) return html; + let script = block.scriptText; + // Reverse so earlier removals don't shift the spans of later ones. + for (const anim of [...matching].reverse()) { + script = removeAnimationFromScript(script, anim.id); + } + return block.replaceScript(script); +} + +/** + * Bake a group's STATIC GSAP transform into each member BEFORE the group is + * stripped on ungroup. Moving a group is stored as `gsap.set("#group-1",{x,y,…})`; + * without distributing it to the members they snap back to their creation-time + * positions. Translation (x/y/z) is an exact per-axis add; rotation/scale are + * composed about the group's centre (the pivot) so off-centre members don't drift. + * Animated group transforms (keyframes/tweens) are NOT baked — left to be stripped. + */ +function bakeGroupTransformIntoMembers( + html: string, + groupId: string, + members: Array<{ id: string; cx: number; cy: number }>, + groupCenter: { cx: number; cy: number }, +): string { + const block = extractGsapScriptBlock(html); + if (!block) return html; + const parsed = parseGsapScriptAcorn(block.scriptText); + const groupSel = `#${groupId}`; + const groupSets = parsed.animations.filter( + (a) => a.targetSelector === groupSel && a.method === "set", + ); + if (groupSets.length === 0) return html; + // Merge the group's sets (later per-prop wins) → its effective static transform. + const gt: Record = {}; + for (const s of groupSets) { + for (const [k, v] of Object.entries(s.properties)) if (typeof v === "number") gt[k] = v; + } + const gx = gt.x ?? 0; + const gy = gt.y ?? 0; + const gz = gt.z ?? 0; + const grot = gt.rotation ?? 0; + const gscale = gt.scale ?? 1; + if (gx === 0 && gy === 0 && gz === 0 && grot === 0 && gscale === 1) return html; + + const rad = (grot * Math.PI) / 180; + const cos = Math.cos(rad); + const sin = Math.sin(rad); + const round3 = (n: number) => Math.round(n * 1000) / 1000; + + let script = block.scriptText; + for (const m of members) { + const memberSel = `#${m.id}`; + const sets = parsed.animations.filter( + (a) => a.targetSelector === memberSel && a.method === "set", + ); + // Effective member transform (merge its sets — last per-prop wins). + const mProps: Record = {}; + for (const s of sets) Object.assign(mProps, s.properties); + const mx = typeof mProps.x === "number" ? mProps.x : 0; + const my = typeof mProps.y === "number" ? mProps.y : 0; + // Compose the group transform onto the member's centre, then back to an offset. + const dx = m.cx + mx - groupCenter.cx; + const dy = m.cy + my - groupCenter.cy; + const visX = groupCenter.cx + gscale * (cos * dx - sin * dy) + gx; + const visY = groupCenter.cy + gscale * (sin * dx + cos * dy) + gy; + const newProps: Record = { + ...mProps, + x: round3(visX - m.cx), + y: round3(visY - m.cy), + }; + if (gz !== 0) newProps.z = (typeof mProps.z === "number" ? mProps.z : 0) + gz; + if (grot !== 0) { + newProps.rotation = round3((typeof mProps.rotation === "number" ? mProps.rotation : 0) + grot); + } + if (gscale !== 1) { + newProps.scale = round3((typeof mProps.scale === "number" ? mProps.scale : 1) * gscale); + } + + const last = sets[sets.length - 1]; + if (last) { + script = updateAnimationInScript(script, last.id, { properties: newProps }); + } else { + script = addAnimationToScript(script, { + targetSelector: memberSel, + method: "set", + position: 0, + properties: newProps, + global: true, + }).script; + } + } + return block.replaceScript(script); +} + function stripStudioEditsFromTarget(document: Document, selector: string): number { if (!selector) return 0; let stripped = 0; @@ -1560,6 +1666,111 @@ export function registerFileRoutes(api: Hono, adapter: StudioApiAdapter): void { }); }); + api.post("/projects/:id/file-mutations/wrap-elements/*", async (c) => { + const ctx = await resolveFileMutationContext(c, adapter, "wrap-elements"); + if ("error" in ctx) return ctx.error; + + const body = (await c.req.json().catch(() => null)) as { + targets?: MutationTarget[]; + groupId?: string; + bbox?: { left?: number; top?: number; width?: number; height?: number }; + rebases?: ElementRebase[]; + } | null; + if (!Array.isArray(body?.targets) || body.targets.length === 0 || !body.groupId) { + return c.json({ error: "targets and groupId required" }, 400); + } + // left/top/width/height are interpolated into inline style strings; reject + // anything non-numeric so a crafted value can't inject extra declarations. + const bbox = body.bbox ?? {}; + const bboxNums = [bbox.left, bbox.top, bbox.width, bbox.height]; + const rebases = body.rebases ?? []; + const allNumeric = + bboxNums.every((n) => typeof n === "number" && Number.isFinite(n)) && + rebases.every( + (r) => + typeof r?.left === "number" && + Number.isFinite(r.left) && + typeof r?.top === "number" && + Number.isFinite(r.top), + ); + if (!allNumeric) { + return c.json({ error: "bbox and rebase coordinates must be finite numbers" }, 400); + } + + let originalContent: string; + try { + originalContent = readFileSync(ctx.absPath, "utf-8"); + } catch { + return c.json({ error: "not found" }, 404); + } + const result = wrapElementsInHtml( + originalContent, + body.targets, + body.groupId, + { left: bbox.left!, top: bbox.top!, width: bbox.width!, height: bbox.height! }, + rebases, + ); + if (!result.matched) { + return c.json( + { + ok: false, + changed: false, + content: originalContent, + path: ctx.filePath, + error: result.error, + }, + result.error === "grouped elements must share a single parent" ? 422 : 400, + ); + } + const backup = snapshotBeforeWrite(ctx.project.dir, ctx.absPath); + if (backup.error) console.warn(`Failed to create backup for ${ctx.filePath}: ${backup.error}`); + writeFileSync(ctx.absPath, result.html, "utf-8"); + return c.json({ + ok: true, + changed: true, + groupId: result.groupId, + content: result.html, + path: ctx.filePath, + backupPath: backupPathForResponse(ctx.project.dir, backup.backupPath), + }); + }); + + api.post("/projects/:id/file-mutations/unwrap-elements/*", async (c) => { + const ctx = await resolveFileMutationContext(c, adapter, "unwrap-elements"); + if ("error" in ctx) return ctx.error; + + const parsed = await parseMutationBody<{ target?: MutationTarget }>(c); + if ("error" in parsed) return parsed.error; + + let originalContent: string; + try { + originalContent = readFileSync(ctx.absPath, "utf-8"); + } catch { + return c.json({ error: "not found" }, 404); + } + const result = unwrapElementsFromHtml(originalContent, parsed.target); + if (!result.unwrapped) { + return c.json({ ok: false, changed: false, content: originalContent, path: ctx.filePath }); + } + // BAKE the group's static transform into the members FIRST, so the group's + // accumulated moves are preserved (otherwise members snap back to their + // creation-time positions), THEN strip the group's GSAP — a leftover + // `gsap.set("#group-1")` throws "target not found" every preview run. + let cleaned = result.html; + if (result.unwrappedGroupId && result.members && result.groupCenter) { + cleaned = bakeGroupTransformIntoMembers( + cleaned, + result.unwrappedGroupId, + result.members, + result.groupCenter, + ); + } + if (result.unwrappedGroupId) { + cleaned = stripGsapAnimationsForSelector(cleaned, `#${result.unwrappedGroupId}`); + } + return writeIfChanged(c, ctx.project.dir, ctx.filePath, ctx.absPath, originalContent, cleaned); + }); + api.post("/projects/:id/file-mutations/probe-element/*", async (c) => { const ctx = await resolveFileMutationContext(c, adapter, "probe-element"); if ("error" in ctx) return ctx.error; diff --git a/packages/studio/src/App.tsx b/packages/studio/src/App.tsx index d3c761a7a9..fe98759701 100644 --- a/packages/studio/src/App.tsx +++ b/packages/studio/src/App.tsx @@ -258,6 +258,8 @@ export function StudioApp() { onResetKeyframes: () => resetKeyframesRef.current(), onDeleteSelectedKeyframes: () => deleteSelectedKeyframesRef.current(), onAfterUndoRedo: () => invalidateGsapCacheRef.current(), + onGroupSelection: () => domEditSessionRef.current.handleGroupSelection(), + onUngroupSelection: () => domEditSessionRef.current.handleUngroupSelection(), activeCompPath, forceReloadSdkSession: sdkHandle.forceReload, onToggleRecording: STUDIO_KEYFRAMES_ENABLED diff --git a/packages/studio/src/components/StudioPreviewArea.tsx b/packages/studio/src/components/StudioPreviewArea.tsx index 45419a4b14..be92a377a4 100644 --- a/packages/studio/src/components/StudioPreviewArea.tsx +++ b/packages/studio/src/components/StudioPreviewArea.tsx @@ -21,8 +21,6 @@ import { useDomEditActionsContext, useDomEditSelectionContext } from "../context import { TimelineEditProvider } from "../contexts/TimelineEditContext"; import type { BlockPreviewInfo } from "./sidebar/BlocksTab"; import { readStudioUiPreferences } from "../utils/studioUiPreferences"; -import { fetchParsedAnimations } from "../hooks/useGsapTweenCache"; -import { pickKeyframeTween, computeKeyframeMovePlan } from "./editor/keyframeMove"; import type { GestureRecordingState } from "./editor/GestureRecordControl"; export interface StudioPreviewAreaProps { @@ -182,45 +180,6 @@ export function StudioPreviewArea({ } }, // fallow-ignore-next-line complexity - onMoveKeyframe: async (_el: TimelineElement, oldPct: number, newPct: number) => { - // Resolve the dragged element's selection + parsed animations on demand - // (both awaited and cached) rather than relying on the async DOM-edit - // session being loaded for this element — that coupling made the commit - // intermittently no-op (revert) when dragging before the session caught up. - if (!projectId) return; - const sourceFile = _el.sourceFile || activeCompPath || "index.html"; - const [selection, parsed] = await Promise.all([ - buildDomSelectionForTimelineElement(_el), - fetchParsedAnimations(projectId, sourceFile), - ]); - if (!selection || !parsed) return; - - const cached = usePlayerStore.getState().keyframeCache.get(_el.key ?? _el.id); - const cachedKf = cached?.keyframes.find((k) => Math.abs(k.percentage - oldPct) < 0.2); - const origAbsTime = _el.start + (oldPct / 100) * _el.duration; - const anim = pickKeyframeTween( - parsed.animations, - _el, - origAbsTime, - cachedKf?.propertyGroup, - ); - if (!anim) return; - - const plan = computeKeyframeMovePlan( - anim, - cachedKf?.tweenPercentage ?? oldPct, - _el, - newPct, - ); - if (plan.meta) handleGsapUpdateMeta(anim.id, plan.meta, selection); - for (const pct of plan.removes) handleGsapRemoveKeyframe(anim.id, pct, selection); - for (const add of plan.adds) { - for (const [prop, val] of Object.entries(add.properties)) { - handleGsapAddKeyframe(anim.id, add.pct, prop, val, selection); - } - } - }, - // fallow-ignore-next-line complexity onToggleKeyframeAtPlayhead: (el: TimelineElement) => { const currentTime = usePlayerStore.getState().currentTime; const pct = diff --git a/packages/studio/src/components/StudioRightPanel.tsx b/packages/studio/src/components/StudioRightPanel.tsx index 07df9b84fa..5c0c8701e2 100644 --- a/packages/studio/src/components/StudioRightPanel.tsx +++ b/packages/studio/src/components/StudioRightPanel.tsx @@ -93,6 +93,7 @@ export function StudioRightPanel({ domEditGroupSelections, copiedAgentPrompt, clearDomSelection, + handleUngroupSelection, handleDomStyleCommit, handleDomAttributeCommit, handleDomAttributeLiveCommit, @@ -241,6 +242,7 @@ export function StudioRightPanel({ multiSelectCount={domEditGroupSelections.length} copiedAgentPrompt={copiedAgentPrompt} onClearSelection={clearDomSelection} + onUngroup={handleUngroupSelection} onSetStyle={handleDomStyleCommit} onSetAttribute={handleDomAttributeCommit} onSetAttributeLive={handleDomAttributeLiveCommit} diff --git a/packages/studio/src/components/TimelineToolbar.tsx b/packages/studio/src/components/TimelineToolbar.tsx index fd9d9ac134..63af38fa52 100644 --- a/packages/studio/src/components/TimelineToolbar.tsx +++ b/packages/studio/src/components/TimelineToolbar.tsx @@ -133,12 +133,12 @@ export function TimelineToolbar({
); diff --git a/packages/studio/src/components/editor/GsapAnimationSection.tsx b/packages/studio/src/components/editor/GsapAnimationSection.tsx index 4aff3d3fb1..a71f465dfe 100644 --- a/packages/studio/src/components/editor/GsapAnimationSection.tsx +++ b/packages/studio/src/components/editor/GsapAnimationSection.tsx @@ -46,9 +46,9 @@ export const GsapAnimationSection = memo(function GsapAnimationSection({ )} {unsupportedTimelinePattern && (

- This composition uses a timeline assignment pattern (window.__timelines[...]) that the - editor doesn't support. Use a variable declaration (const tl = gsap.timeline()) to - enable editing. + This timeline uses a computed key (window.__timelines[variable]) the editor can't + resolve statically. Use a string-literal key (window.__timelines["id"]) or a + variable declaration (const tl = gsap.timeline()) to enable editing.

)} {multipleTimelines || unsupportedTimelinePattern ? null : ( diff --git a/packages/studio/src/components/editor/InspectorHeaderActions.tsx b/packages/studio/src/components/editor/InspectorHeaderActions.tsx new file mode 100644 index 0000000000..a8ea815020 --- /dev/null +++ b/packages/studio/src/components/editor/InspectorHeaderActions.tsx @@ -0,0 +1,62 @@ +import { X } from "../../icons/SystemIcons"; +import type { DomEditSelection } from "./domEditingTypes"; + +/** The action buttons in the inspector header: Ungroup (groups only), copy, clear. */ +export function InspectorHeaderActions({ + element, + copied, + onCopy, + onClear, + onUngroup, +}: { + element: DomEditSelection; + copied: boolean; + onCopy: () => void; + onClear: () => void; + onUngroup?: () => void; +}) { + return ( +
+ {onUngroup && element.dataAttributes["hf-group"] != null && ( + + )} + + +
+ ); +} diff --git a/packages/studio/src/components/editor/LayersPanel.tsx b/packages/studio/src/components/editor/LayersPanel.tsx index 510421c796..15ffedca11 100644 --- a/packages/studio/src/components/editor/LayersPanel.tsx +++ b/packages/studio/src/components/editor/LayersPanel.tsx @@ -59,9 +59,11 @@ export const LayersPanel = memo(function LayersPanel() { const currentTime = usePlayerStore((s) => s.currentTime); const { domEditSelection, + activeGroupElement, applyDomSelection, updateDomEditHoverSelection, handleDomZIndexReorderCommit, + setActiveGroupElement, } = useDomEditContext(); const [layers, setLayers] = useState([]); @@ -86,12 +88,16 @@ export const LayersPanel = memo(function LayersPanel() { doc.querySelector("[data-composition-id]") ?? doc.documentElement ?? null; if (!root) return; + // A preview reload detaches the drilled-into wrapper; exit drill-in if so. + if (activeGroupElement && !activeGroupElement.isConnected) setActiveGroupElement(null); + const items = collectDomEditLayerItems(root, { activeCompositionPath: activeCompPath, isMasterView, + activeGroupElement, }); setLayers(sortLayersByZIndex(items)); - }, [previewIframeRef, activeCompPath, isMasterView]); + }, [previewIframeRef, activeCompPath, isMasterView, activeGroupElement, setActiveGroupElement]); useEffect(() => { collectLayers(); @@ -135,9 +141,10 @@ export const LayersPanel = memo(function LayersPanel() { activeCompositionPath: activeCompPath, isMasterView, preferClipAncestor: false, + activeGroupElement, }); }, - [activeCompPath, isMasterView, previewIframeRef], + [activeCompPath, isMasterView, previewIframeRef, activeGroupElement], ); const seekToLayer = useCallback( @@ -183,6 +190,19 @@ export const LayersPanel = memo(function LayersPanel() { [resolveSelection, applyDomSelection, seekToLayer], ); + // Double-click a group row → drill into it; any other row → select it. + const handleLayerDoubleClick = useCallback( + async (layer: DomEditLayerItem) => { + const selection = await resolveSelection(layer); + if (selection?.element.hasAttribute("data-hf-group")) { + setActiveGroupElement(selection.element); + } else { + await handleSelectLayer(layer); + } + }, + [resolveSelection, setActiveGroupElement, handleSelectLayer], + ); + const handleLayerHover = useCallback( async (layer: DomEditLayerItem | null) => { if (!layer) { @@ -271,6 +291,18 @@ export const LayersPanel = memo(function LayersPanel() { onPointerUp={handleContainerPointerUp} onPointerCancel={handleContainerPointerUp} > + {activeGroupElement && ( + + )} {visibleLayers.map((layer, index) => { const selected = layer.key === selectedKey; const isDragged = layer.key === dragKey; @@ -286,6 +318,7 @@ export const LayersPanel = memo(function LayersPanel() { role="button" tabIndex={0} onClick={() => !dragKey && handleSelectLayer(layer)} + onDoubleClick={() => !dragKey && handleLayerDoubleClick(layer)} onPointerDown={(e) => handleRowPointerDown(index, e)} onPointerEnter={() => !dragKey && handleLayerHover(layer)} onKeyDown={(e) => { diff --git a/packages/studio/src/components/editor/MotionPathOverlay.tsx b/packages/studio/src/components/editor/MotionPathOverlay.tsx index 4a27cfaca3..038538717c 100644 --- a/packages/studio/src/components/editor/MotionPathOverlay.tsx +++ b/packages/studio/src/components/editor/MotionPathOverlay.tsx @@ -21,6 +21,7 @@ import { elementHome, hasMotionPathPlugin, isPreviewHtmlElement, + transformWDivisor, useMotionPathData, } from "./useMotionPathData"; @@ -39,6 +40,7 @@ type DragState = { initX: number; initY: number; scale: number; + pScale: number; ref: MotionNodeRef; }; @@ -71,7 +73,7 @@ export const MotionPathOverlay = memo(function MotionPathOverlay({ handleGsapRemoveKeyframe, handleGsapDeleteAllForElement, } = useDomEditContext(); - const { rect, geometry, geometryResolved, visibleInPreview, home } = useMotionPathData( + const { rect, geometry, geometryResolved, visibleInPreview, home, pScale } = useMotionPathData( iframeRef, selectorFor(selection), ); @@ -156,8 +158,12 @@ export const MotionPathOverlay = memo(function MotionPathOverlay({ e.preventDefault(); const sc = r.width / compW; const elHome = elementHome(live); - const px = Math.round((e.clientX - r.left) / sc - elHome.x); - const py = Math.round((e.clientY - r.top) / sc - elHome.y); + // De-magnify: the click lands on the projected (1/m44-magnified) path, so + // divide the home-relative offset by the perspective factor to recover the + // stored composition offset (inverse of the `* pScale` applied at draw). + const ps = 1 / transformWDivisor(live); + const px = Math.round(((e.clientX - r.left) / sc - elHome.x) / ps); + const py = Math.round(((e.clientY - r.top) / sc - elHome.y) / ps); const t = Math.round(usePlayerStore.getState().currentTime * 100) / 100; void commitCreatePath(createSelector, t, px, py, commitMutation); setMotionPathArmed(false); @@ -232,7 +238,16 @@ export const MotionPathOverlay = memo(function MotionPathOverlay({ : geometry.nodes; // ax/ay = absolute composition position (home + offset) for drawing; n.x/n.y // stay offsets so the drag commit writes the right tween values. - const abs = nodes.map((n) => ({ ...n, ax: home.x + n.x, ay: home.y + n.y })); + // Magnify the animated offsets by the element's perspective factor (1/m44, via + // pScale) so the path tracks the *projected* element. `home` is the projection + // pivot (transform-origin), so it stays put; only the offsets foreshorten. 2D + // elements have pScale = 1 (no change). Inverse (de-magnify) applied wherever a + // pointer position is mapped back to a stored offset (create + node drag). + const abs = nodes.map((n) => ({ + ...n, + ax: home.x + n.x * pScale, + ay: home.y + n.y * pScale, + })); const points = abs.map((p) => `${p.ax},${p.ay}`).join(" "); // Map a VIEWPORT pointer to composition space. Use the iframe's LIVE viewport // rect, not `rect` — `rect.left/top` are stored pan-surface-relative (for the @@ -264,6 +279,7 @@ export const MotionPathOverlay = memo(function MotionPathOverlay({ initX: x, initY: y, scale, + pScale, ref, }; setDraft({ index, x, y }); @@ -273,8 +289,8 @@ export const MotionPathOverlay = memo(function MotionPathOverlay({ if (!d) return; setDraft({ index: d.index, - x: d.initX + (e.clientX - d.startX) / d.scale, - y: d.initY + (e.clientY - d.startY) / d.scale, + x: d.initX + (e.clientX - d.startX) / d.scale / d.pScale, + y: d.initY + (e.clientY - d.startY) / d.scale / d.pScale, }); }; // fallow-ignore-next-line complexity @@ -286,8 +302,8 @@ export const MotionPathOverlay = memo(function MotionPathOverlay({ if (!animId) return; const screenDx = e.clientX - d.startX; const screenDy = e.clientY - d.startY; - const x = Math.round(d.initX + screenDx / d.scale); - const y = Math.round(d.initY + screenDy / d.scale); + const x = Math.round(d.initX + screenDx / d.scale / d.pScale); + const y = Math.round(d.initY + screenDy / d.scale / d.pScale); // Click-vs-drag is decided in SCREEN space, not composition px: the old guard // compared rounded comp-px, which at high zoom (scale ≫ 1) swallowed real // multi-px screen drags whose sub-comp-px delta rounds to 0 → the node would diff --git a/packages/studio/src/components/editor/PropertyPanel.tsx b/packages/studio/src/components/editor/PropertyPanel.tsx index 17bb2c53e8..9aeba6a547 100644 --- a/packages/studio/src/components/editor/PropertyPanel.tsx +++ b/packages/studio/src/components/editor/PropertyPanel.tsx @@ -1,5 +1,6 @@ import { memo, useEffect, useMemo, useRef, useState } from "react"; -import { Eye, Layers, Move, X } from "../../icons/SystemIcons"; +import { Eye, Layers, Move } from "../../icons/SystemIcons"; +import { InspectorHeaderActions } from "./InspectorHeaderActions"; import { useStudioShellContext } from "../../contexts/StudioContext"; import { readStudioBoxSize, readStudioPathOffset, readStudioRotation } from "./manualEdits"; import { @@ -53,6 +54,7 @@ export const PropertyPanel = memo(function PropertyPanel({ multiSelectCount = 0, copiedAgentPrompt: _copiedAgentPrompt, onClearSelection, + onUngroup, onSetStyle, onSetAttribute, onSetAttributeLive, @@ -146,6 +148,19 @@ export const PropertyPanel = memo(function PropertyPanel({ // eslint-disable-next-line react-hooks/exhaustive-deps [gsapRuntimeValues, gsapAnimations, element, currentTime], ); + // The 3D Transform panel should be reachable on ANY element, not only ones GSAP is + // already animating — otherwise you can't add depth/rotation to a fresh static + // element (the panel never appears, the classic chicken-and-egg). Default to + // identity when there are no runtime values yet; the first edit creates the + // gsap.set via commitStaticSet, after which real runtime values flow in. + const gsap3dValues: Record = gsapRuntimeValues ?? { + rotationX: 0, + rotationY: 0, + rotationZ: 0, + z: 0, + scale: 1, + transformPerspective: 0, + }; if (!element) { return ( @@ -295,38 +310,13 @@ export const PropertyPanel = memo(function PropertyPanel({
{sourceLabel}
-
- - -
+
@@ -513,33 +503,31 @@ export const PropertyPanel = memo(function PropertyPanel({ )}
- {gsapRuntimeValues && ( - { - const iframe = iframeRef.current; - const win = iframe?.contentWindow as - | { gsap?: { set: (t: Element, v: Record) => void } } - | null - | undefined; - const sel = el.id ? `#${el.id}` : el.selector; - const node = sel ? iframe?.contentDocument?.querySelector(sel) : null; - if (win?.gsap && node) win.gsap.set(node, props); - }} - /> - )} + { + const iframe = iframeRef.current; + const win = iframe?.contentWindow as + | { gsap?: { set: (t: Element, v: Record) => void } } + | null + | undefined; + const sel = el.id ? `#${el.id}` : el.selector; + const node = sel ? iframe?.contentDocument?.querySelector(sel) : null; + if (win?.gsap && node) win.gsap.set(node, props); + }} + />
Stacking diff --git a/packages/studio/src/components/editor/Transform3DCube.tsx b/packages/studio/src/components/editor/Transform3DCube.tsx index 8475b0419c..d0bb4ffd9b 100644 --- a/packages/studio/src/components/editor/Transform3DCube.tsx +++ b/packages/studio/src/components/editor/Transform3DCube.tsx @@ -1,4 +1,4 @@ -import { useRef, useState } from "react"; +import { useEffect, useRef, useState } from "react"; import { projectAxes, projectCubeFaces, wrapDeg } from "./transform3dProjection"; export interface CubePose { @@ -22,80 +22,19 @@ const SENSITIVITY = 0.6; // degrees per pixel of drag * Presentational only: emits a live draft pose while dragging and a final pose * on release — the parent owns live-previewing and committing to GSAP props. */ -// transformPerspective (px) is inversely related to effect strength, with 0 = off. -// Map a 0..1 slider strength to px and to the cube's weak-perspective projection. -const STRONG_PX = 200; -const WEAK_PX = 1600; -const PX_RANGE = WEAK_PX - STRONG_PX; -const strengthToPx = (s: number) => (s <= 0.01 ? 0 : Math.round(WEAK_PX - s * PX_RANGE)); -const pxToStrength = (px: number) => - px <= 0 - ? 0 - : Math.max(0, Math.min(1, (WEAK_PX - Math.max(STRONG_PX, Math.min(WEAK_PX, px))) / PX_RANGE)); +// transformPerspective (px) drives the cube's weak-perspective projection; +// 0 = off → flattest (largest projection distance). const pxToProjPersp = (px: number) => (px > 0 ? Math.max(2.2, Math.min(14, px / 130)) : 14); -/** Horizontal "perspective strength" slider — left = none, right = dramatic. */ -function PerspectiveSlider({ - value, - onDraft, - onCommit, -}: { - value: number; - onDraft?: (px: number) => void; - onCommit: (px: number) => void; -}) { - const trackRef = useRef(null); - const draggingRef = useRef(false); - const strength = pxToStrength(value); - const fromEvent = (clientX: number) => { - const r = trackRef.current?.getBoundingClientRect(); - if (!r || r.width === 0) return 0; - return strengthToPx(Math.max(0, Math.min(1, (clientX - r.left) / r.width))); - }; - return ( -
- Persp -
{ - e.currentTarget.setPointerCapture(e.pointerId); - draggingRef.current = true; - onDraft?.(fromEvent(e.clientX)); - }} - onPointerMove={(e) => { - if (draggingRef.current) onDraft?.(fromEvent(e.clientX)); - }} - onPointerUp={(e) => { - if (!draggingRef.current) return; - draggingRef.current = false; - onCommit(fromEvent(e.clientX)); - }} - onPointerCancel={() => { - draggingRef.current = false; - }} - className="relative h-3 flex-1 cursor-ew-resize touch-none" - > -
-
-
-
-
- ); -} - export function Transform3DCube({ pose, perspective = 0, + defaultPerspective = 0, + z = 0, onPoseDraft, onPoseCommit, - onPerspectiveDraft, - onPerspectiveCommit, + onDepthDraft, + onDepthCommit, onRecenter, onKeyframe, keyframed, @@ -103,13 +42,18 @@ export function Transform3DCube({ pose: CubePose; /** Element's transformPerspective (px); drives the cube's foreshortening. */ perspective?: number; + /** Comp-derived lens used for depth feedback before a perspective is committed. */ + defaultPerspective?: number; + /** Element's translateZ (px) — "depth", adjusted by scrolling over the cube. */ + z?: number; /** Fires on every drag move with the in-progress pose (parent live-previews). */ onPoseDraft?: (pose: CubePose) => void; /** Fires once on pointer release with the final pose (commit). */ onPoseCommit: (pose: CubePose) => void; - /** Live + committed perspective (px) from the in-cube slider. */ - onPerspectiveDraft?: (px: number) => void; - onPerspectiveCommit?: (px: number) => void; + /** Live depth (translateZ px) during a scroll; parent live-previews it. */ + onDepthDraft?: (z: number) => void; + /** Committed depth (translateZ px) once a scroll burst settles. */ + onDepthCommit?: (z: number) => void; /** Reset to identity orientation. */ onRecenter?: () => void; /** Toggle keyframing the 3D transform (convert the static set → keyframes). */ @@ -118,16 +62,74 @@ export function Transform3DCube({ keyframed?: boolean; }) { const [draft, setDraft] = useState(null); + const [depthDraft, setDepthDraft] = useState(null); const dragRef = useRef<{ x: number; y: number; pose: CubePose } | null>(null); const shown = draft ?? pose; + const shownZ = depthDraft ?? z; + + // Scroll over the cube to push the element along Z (depth) — matches the + // studio's "scroll = z depth" gesture-recording convention. A non-passive + // listener is required so preventDefault can stop the panel from scrolling. + const svgRef = useRef(null); + // Perspective lens (committed, else the comp-derived default the panel will + // apply). Drives the cube's depth-scale feedback AND clamps the scroll so depth + // can't cross the lens. Defined here so the wheel handler can read it via the ref. + const lens = perspective > 0 ? perspective : defaultPerspective; + const depthRef = useRef({ z, onDepthDraft, onDepthCommit, lens }); + depthRef.current = { z, onDepthDraft, onDepthCommit, lens }; + useEffect(() => { + const el = svgRef.current; + if (!el) return; + let pending: number | null = null; + let timer: ReturnType | null = null; + const onWheel = (e: WheelEvent) => { + const { onDepthCommit: commit, onDepthDraft: draft } = depthRef.current; + if (!commit) return; + e.preventDefault(); + // ponytail: 0.25 px of Z per wheel-delta unit (~25px per notch); tune if + // it feels too fast/slow. Scroll up (deltaY < 0) pushes toward the viewer. + let next = Math.round((pending ?? depthRef.current.z) - e.deltaY * 0.25); + // Clamp depth in front of the perspective lens. At z ≥ lens the element sits + // at/behind the virtual camera and the projection lens/(lens−z) blows up or + // inverts — that's the runaway "Z = 3195px past a 1080 lens". Cap just short + // of the lens; allow pushing well back (smaller) but not absurdly far. + const L = depthRef.current.lens; + if (L > 0) next = Math.max(Math.min(next, Math.round(L * 0.85)), Math.round(-L * 4)); + pending = next; + draft?.(pending); + setDepthDraft(pending); // live-scale the cube while scrolling + if (timer) clearTimeout(timer); + timer = setTimeout(() => { + if (pending != null) commit(pending); + pending = null; + setDepthDraft(null); // fall back to the committed z prop + }, 160); + }; + el.addEventListener("wheel", onWheel, { passive: false }); + return () => { + el.removeEventListener("wheel", onWheel); + if (timer) clearTimeout(timer); + }; + }, []); + + // Depth feedback: the cube scales like the element would — translateZ(z) under + // a perspective lens P appears scaled by P/(P-z). Closer (z>0) reads bigger, + // farther (z<0) smaller. Use the committed perspective, else the comp-derived + // lens the panel is about to apply — same value in both, so the cube doesn't + // jump when the commit lands. If neither is known, skip the scale (no lens). + const depthScale = lens > 0 ? Math.max(0.4, Math.min(2.2, lens / (lens - shownZ))) : 1; const projOpts = { cx: CX, cy: CY, - r: RADIUS, - persp: pxToProjPersp(perspective), + r: RADIUS * depthScale, + persp: pxToProjPersp(lens), }; - const faces = projectCubeFaces(shown.rotationX, shown.rotationY, shown.rotationZ, projOpts); - const axes = projectAxes(shown.rotationX, shown.rotationY, shown.rotationZ, projOpts); + // The element lives in CSS's screen-Y-down space; the cube projects Y-up. RotateX + // and RotateZ act in planes that contain Y, so they read inverted in the gizmo + // unless their sign is flipped — RotateY (X-Z plane) matches as-is. This keeps the + // cube's orientation a true mirror of the element. + const faces = projectCubeFaces(-shown.rotationX, shown.rotationY, -shown.rotationZ, projOpts); + const axes = projectAxes(-shown.rotationX, shown.rotationY, -shown.rotationZ, projOpts); const onPointerDown = (e: React.PointerEvent) => { e.currentTarget.setPointerCapture(e.pointerId); @@ -140,10 +142,13 @@ export function Transform3DCube({ if (!d) return; const dx = e.clientX - d.x; const dy = e.clientY - d.y; + // dy→rotationX and shift dx→rotationZ are negated to match the projection's + // sign flip (above), so the cube's response to a drag is unchanged while the + // element now rotates in lock-step with it. const next: CubePose = e.shiftKey - ? { ...d.pose, rotationZ: wrapDeg(d.pose.rotationZ + dx * SENSITIVITY) } + ? { ...d.pose, rotationZ: wrapDeg(d.pose.rotationZ - dx * SENSITIVITY) } : { - rotationX: wrapDeg(d.pose.rotationX - dy * SENSITIVITY), + rotationX: wrapDeg(d.pose.rotationX + dy * SENSITIVITY), rotationY: wrapDeg(d.pose.rotationY + dx * SENSITIVITY), rotationZ: d.pose.rotationZ, }; @@ -161,6 +166,7 @@ export function Transform3DCube({ return (
)} - {onPerspectiveCommit && ( - - )}
); } diff --git a/packages/studio/src/components/editor/domEditOverlayGeometry.ts b/packages/studio/src/components/editor/domEditOverlayGeometry.ts index fa1a61107a..f1e73b7595 100644 --- a/packages/studio/src/components/editor/domEditOverlayGeometry.ts +++ b/packages/studio/src/components/editor/domEditOverlayGeometry.ts @@ -186,6 +186,34 @@ export function resolveDomEditGroupOverlayRect(rects: OverlayRect[]): OverlayRec }; } +// A group's overlay box encompasses its members' actual rendered bounds, not just +// the wrapper's own box — so members moved or transformed out of the wrapper still +// sit inside the box. Used by the selection, hover, and off-canvas overlays so they +// all agree on where a group is. +export function groupAwareOverlayRect( + overlayEl: HTMLDivElement, + iframe: HTMLIFrameElement, + el: HTMLElement, +): OverlayRect | null { + const rect = toOverlayRect(overlayEl, iframe, el); + if (!rect || !el.hasAttribute("data-hf-group")) return rect; + // Union the MEMBERS' rendered rects — where the content actually is — not the + // wrapper's own box. The wrapper is invisible and its box can sit apart from the + // members once they've been moved/transformed, which would otherwise drag the + // group's bounds (and its off-canvas marker) off to a stale position. + const rects: OverlayRect[] = []; + for (const child of Array.from(el.children)) { + const childRect = toOverlayRect(overlayEl, iframe, child as HTMLElement); + if (childRect) rects.push(childRect); + } + const union = rects.length > 0 ? resolveDomEditGroupOverlayRect(rects) : null; + if (!union) return rect; // empty group → fall back to the wrapper box + // resolveDomEditGroupOverlayRect hardcodes editScaleX/Y to 1; keep the wrapper's + // real edit (display) scale, which the drag uses to convert pointer→offset — a + // reset-to-1 makes the group move at ~display-scale speed and lag the cursor. + return { ...union, editScaleX: rect.editScaleX, editScaleY: rect.editScaleY }; +} + export function filterNestedDomEditGroupItems(items: T[]): T[] { return items.filter( (item) => !items.some((other) => other !== item && other.element.contains(item.element)), diff --git a/packages/studio/src/components/editor/domEditingDom.ts b/packages/studio/src/components/editor/domEditingDom.ts index b4ab83aa36..42505e3b12 100644 --- a/packages/studio/src/components/editor/domEditingDom.ts +++ b/packages/studio/src/components/editor/domEditingDom.ts @@ -250,7 +250,7 @@ export function querySelectorAllSafely(doc: Document, selector: string): Element } } -export function humanizeIdentifier(value: string): string { +function humanizeIdentifier(value: string): string { return ( value .replace(/\.html$/i, "") @@ -270,10 +270,16 @@ export function buildStableSelector(el: HTMLElement): string | undefined { const compositionId = el.getAttribute("data-composition-id"); if (compositionId) return `[data-composition-id="${escapeCssString(compositionId)}"]`; + // Group wrappers carry no id/class; their data-hf-group value is the unique, + // stable handle the source mutations write — use it so the wrapper is + // selectable, patchable (move/scale), and addressable for ungroup. + const group = el.getAttribute("data-hf-group"); + if (group) return `[data-hf-group="${escapeCssString(group)}"]`; + return getPreferredClassSelector(el); } -export function getPreferredClassSelector(el: HTMLElement): string | undefined { +function getPreferredClassSelector(el: HTMLElement): string | undefined { const classes = Array.from(el.classList) .map((value) => value.trim()) .filter(Boolean); @@ -283,6 +289,34 @@ export function getPreferredClassSelector(el: HTMLElement): string | undefined { return preferred ? `.${escapeCssIdentifier(preferred)}` : undefined; } +// fallow-ignore-next-line complexity +export function buildElementLabel(el: HTMLElement): string { + const compositionId = el.getAttribute("data-composition-id"); + if (compositionId && compositionId !== "main") { + return humanizeIdentifier(compositionId); + } + + const compositionSrc = + el.getAttribute("data-composition-src") ?? el.getAttribute("data-composition-file"); + if (compositionSrc) { + return humanizeIdentifier(compositionSrc); + } + + const group = el.getAttribute("data-hf-group"); + if (group) return group; + + if (el.id) return humanizeIdentifier(el.id); + + const preferredClass = getPreferredClassSelector(el); + if (preferredClass) { + return humanizeIdentifier(preferredClass.replace(/^\./, "")); + } + + const text = (el.textContent ?? "").trim().replace(/\s+/g, " "); + if (text) return text.length > 40 ? `${text.slice(0, 39)}…` : text; + return el.tagName.toLowerCase(); +} + export function getSelectorIndex( doc: Document, el: HTMLElement, diff --git a/packages/studio/src/components/editor/domEditingGroups.ts b/packages/studio/src/components/editor/domEditingGroups.ts new file mode 100644 index 0000000000..8b90f84c42 --- /dev/null +++ b/packages/studio/src/components/editor/domEditingGroups.ts @@ -0,0 +1,41 @@ +import { isHtmlElement } from "./domEditingDom"; + +// `data-hf-group` selection semantics: a group wrapper is selected as one unit +// until the user drills into it; once drilled in, clicks resolve to its children +// (or to the next nested group inside it). One level of drill-in at a time keeps +// nested groups navigable. + +export type GroupCapture = + | { kind: "unit"; element: HTMLElement } // select this group wrapper as one unit + | { kind: "child" } // resolve the clicked element normally + | { kind: "out-of-scope" }; // clicked outside the drilled-into group → select nothing + +// Layer-tree roots: the drilled-into group's element children, else the doc root. +export function groupScopedLayerRoots( + root: HTMLElement, + activeGroupElement: HTMLElement | null, +): HTMLElement[] { + const els = activeGroupElement?.isConnected ? Array.from(activeGroupElement.children) : [root]; + return els.filter(isHtmlElement); +} + +export function resolveGroupCapture( + startEl: HTMLElement, + activeGroupElement: HTMLElement | null, +): GroupCapture { + const groups: HTMLElement[] = []; + for (let n: HTMLElement | null = startEl; n; n = n.parentElement) { + if (n.hasAttribute("data-hf-group")) groups.push(n); + } + const result = ((): GroupCapture => { + if (!activeGroupElement) { + const outermost = groups[groups.length - 1]; + return outermost ? { kind: "unit", element: outermost } : { kind: "child" }; + } + const idx = groups.indexOf(activeGroupElement); + if (idx === -1) return { kind: "out-of-scope" }; + const nestedInside = groups[idx - 1]; + return nestedInside ? { kind: "unit", element: nestedInside } : { kind: "child" }; + })(); + return result; +} diff --git a/packages/studio/src/components/editor/domEditingLayers.test.ts b/packages/studio/src/components/editor/domEditingLayers.test.ts index beb7fb4808..03b83743b8 100644 --- a/packages/studio/src/components/editor/domEditingLayers.test.ts +++ b/packages/studio/src/components/editor/domEditingLayers.test.ts @@ -1,6 +1,11 @@ // @vitest-environment jsdom import { describe, expect, it } from "vitest"; -import { resolveDomEditSelection, buildDomEditPatchTarget, readHfId } from "./domEditingLayers"; +import { + collectDomEditLayerItems, + resolveDomEditSelection, + buildDomEditPatchTarget, + readHfId, +} from "./domEditingLayers"; const opts = { activeCompositionPath: "index.html", isMasterView: true, skipSourceProbe: true }; @@ -76,3 +81,92 @@ describe("resolveDomEditSelection — hfId from data-hf-id", () => { expect(selection?.hfId).toBeUndefined(); }); }); + +describe("resolveDomEditSelection — data-hf-group capture", () => { + //
+ //
+ function buildNestedGroups() { + const parent = document.createElement("div"); + parent.id = "parent"; + const outer = document.createElement("div"); + outer.setAttribute("data-hf-group", "Group 1"); + const inner = document.createElement("div"); + inner.setAttribute("data-hf-group", "Group 2"); + const child = document.createElement("span"); + child.id = "child"; + inner.appendChild(child); + outer.appendChild(inner); + parent.appendChild(outer); + document.body.appendChild(parent); + return { parent, outer, inner, child }; + } + + it("selects the outermost group as a unit when clicking a child (not drilled in)", async () => { + const { parent, outer, child } = buildNestedGroups(); + const selection = await resolveDomEditSelection(child, opts); + document.body.removeChild(parent); + + expect(selection?.element).toBe(outer); + expect(selection?.selector).toBe('[data-hf-group="Group 1"]'); + }); + + it("selects the next nested group when drilled into the outer group", async () => { + const { parent, outer, inner, child } = buildNestedGroups(); + const selection = await resolveDomEditSelection(child, { ...opts, activeGroupElement: outer }); + document.body.removeChild(parent); + + expect(selection?.element).toBe(inner); + expect(selection?.selector).toBe('[data-hf-group="Group 2"]'); + }); + + it("selects the child when drilled all the way into the innermost group", async () => { + const { parent, inner, child } = buildNestedGroups(); + const selection = await resolveDomEditSelection(child, { ...opts, activeGroupElement: inner }); + document.body.removeChild(parent); + + expect(selection?.element).toBe(child); + expect(selection?.id).toBe("child"); + }); + + it("layer tree is scoped to the group's members when drilled in", () => { + const root = document.createElement("div"); + root.setAttribute("data-composition-id", "main"); + const group = document.createElement("div"); + group.setAttribute("data-hf-group", "Group 1"); + const inside = document.createElement("div"); + inside.id = "inside"; + const outside = document.createElement("div"); + outside.id = "outside"; + group.appendChild(inside); + root.appendChild(group); + root.appendChild(outside); + document.body.appendChild(root); + + const opts2 = { activeCompositionPath: "index.html", isMasterView: true }; + const full = collectDomEditLayerItems(root, opts2).map((i) => i.id); + const scoped = collectDomEditLayerItems(root, { ...opts2, activeGroupElement: group }).map( + (i) => i.id, + ); + document.body.removeChild(root); + + expect(full).toContain("outside"); + expect(scoped).toContain("inside"); + expect(scoped).not.toContain("outside"); + }); + + it("returns null when clicking outside the group the user is drilled into", async () => { + const { parent, inner } = buildNestedGroups(); + const outside = document.createElement("div"); + outside.id = "outside"; + document.body.appendChild(outside); + + const selection = await resolveDomEditSelection(outside, { + ...opts, + activeGroupElement: inner, + }); + document.body.removeChild(parent); + document.body.removeChild(outside); + + expect(selection).toBeNull(); + }); +}); diff --git a/packages/studio/src/components/editor/domEditingLayers.ts b/packages/studio/src/components/editor/domEditingLayers.ts index 536abba8df..5037c78315 100644 --- a/packages/studio/src/components/editor/domEditingLayers.ts +++ b/packages/studio/src/components/editor/domEditingLayers.ts @@ -3,6 +3,7 @@ * for dom editing. */ import type { PatchOperation } from "../../utils/sourcePatcher"; +import { groupScopedLayerRoots, resolveGroupCapture } from "./domEditingGroups"; import type { DomEditCapabilities, DomEditContextOptions, @@ -11,15 +12,14 @@ import type { DomEditTextField, } from "./domEditingTypes"; import { + buildElementLabel, buildStableSelector, findClosestByAttribute, getCuratedComputedStyles, getDataAttributes, getInlineStyles, - getPreferredClassSelector, getSelectorIndex, getSourceFileForElement, - humanizeIdentifier, isHtmlElement, isIdentityTransform, isTextBearingTag, @@ -275,31 +275,6 @@ export function resolveDomEditCapabilities(args: { // ─── Element label ──────────────────────────────────────────────────────────── -// fallow-ignore-next-line complexity -export function buildElementLabel(el: HTMLElement): string { - const compositionId = el.getAttribute("data-composition-id"); - if (compositionId && compositionId !== "main") { - return humanizeIdentifier(compositionId); - } - - const compositionSrc = - el.getAttribute("data-composition-src") ?? el.getAttribute("data-composition-file"); - if (compositionSrc) { - return humanizeIdentifier(compositionSrc); - } - - if (el.id) return humanizeIdentifier(el.id); - - const preferredClass = getPreferredClassSelector(el); - if (preferredClass) { - return humanizeIdentifier(preferredClass.replace(/^\./, "")); - } - - const text = (el.textContent ?? "").trim().replace(/\s+/g, " "); - if (text) return text.length > 40 ? `${text.slice(0, 39)}…` : text; - return el.tagName.toLowerCase(); -} - // ─── Source probe ──────────────────────────────────────────────────────────── async function probeSourceElement( @@ -334,7 +309,15 @@ export async function resolveDomEditSelection( if (!startEl) return null; const doc = startEl.ownerDocument; - let current: HTMLElement | null = getSelectionCandidate(startEl, options); + let capture = resolveGroupCapture(startEl, options.activeGroupElement ?? null); + if (capture.kind === "out-of-scope") { + // Drill-in is non-sticky: clicking/hovering OUTSIDE the drilled-into group + // exits it and resolves the target normally, rather than selecting nothing + // (which felt like "can't select anything" once you'd drilled in). + capture = resolveGroupCapture(startEl, null); + } + let current: HTMLElement | null = + capture.kind === "unit" ? capture.element : getSelectionCandidate(startEl, options); while (current && current !== doc.body && current !== doc.documentElement) { const selector = buildStableSelector(current); const hfId = readHfId(current); @@ -501,7 +484,8 @@ export function collectDomEditLayerItems( } }; - visit(root, 0); + // Drilled into a group → show only its members; otherwise the whole tree. + for (const el of groupScopedLayerRoots(root, options.activeGroupElement ?? null)) visit(el, 0); return items; } diff --git a/packages/studio/src/components/editor/domEditingTypes.ts b/packages/studio/src/components/editor/domEditingTypes.ts index 50d82cafa1..70ea56f570 100644 --- a/packages/studio/src/components/editor/domEditingTypes.ts +++ b/packages/studio/src/components/editor/domEditingTypes.ts @@ -108,6 +108,9 @@ export interface DomEditContextOptions { activeCompositionPath: string | null; isMasterView: boolean; preferClipAncestor?: boolean; + /** The group wrapper the user has drilled into (null = top level). Selection + * resolution treats groups as a unit unless drilled into one. */ + activeGroupElement?: HTMLElement | null; } export interface DomEditViewport { diff --git a/packages/studio/src/components/editor/keyframeMove.test.ts b/packages/studio/src/components/editor/keyframeMove.test.ts deleted file mode 100644 index d7ee679e37..0000000000 --- a/packages/studio/src/components/editor/keyframeMove.test.ts +++ /dev/null @@ -1,101 +0,0 @@ -import { describe, it, expect } from "vitest"; -import { pickKeyframeTween, computeKeyframeMovePlan } from "./keyframeMove"; - -const flat = (id: string, target: string, position: number, duration: number, group?: string) => ({ - id, - targetSelector: target, - position, - duration, - resolvedStart: position, - propertyGroup: group, -}); - -const el = { start: 0, duration: 10, domId: "box", selector: "#box" }; - -describe("pickKeyframeTween", () => { - it("matches by the element's selector", () => { - const anims = [flat("a", "#other", 0, 5), flat("b", "#box", 2, 3)]; - expect(pickKeyframeTween(anims, el, 3, undefined)?.id).toBe("b"); - }); - - it("prefers the dragged keyframe's property group", () => { - const anims = [flat("pos", "#box", 0, 8, "position"), flat("vis", "#box", 0, 8, "visual")]; - expect(pickKeyframeTween(anims, el, 1, "visual")?.id).toBe("vis"); - }); - - it("among same-group tweens picks the one whose window contains the original time", () => { - const fadeIn = flat("in", "#box", 1, 1, "visual"); - const fadeOut = flat("out", "#box", 8, 1, "visual"); - expect(pickKeyframeTween([fadeIn, fadeOut], el, 8.5, "visual")?.id).toBe("out"); - expect(pickKeyframeTween([fadeIn, fadeOut], el, 1.2, "visual")?.id).toBe("in"); - }); - - it("returns undefined when there are no tweens", () => { - expect(pickKeyframeTween([], el, 1, undefined)).toBeUndefined(); - }); - - it("returns undefined rather than editing another element on a selector mismatch", () => { - const anims = [flat("a", "#other", 0, 5), flat("b", ".unrelated", 2, 3)]; - expect(pickKeyframeTween(anims, el, 3, undefined)).toBeUndefined(); - }); -}); - -describe("computeKeyframeMovePlan — flat tween", () => { - const anim = flat("t", "#box", 2, 4); // window [2, 6] - - it("start point trims the front, keeping the end fixed", () => { - // newPct 30% → abs 3 → start moves to 3, duration shrinks to 3. - const plan = computeKeyframeMovePlan(anim, 0, el, 30); - expect(plan.meta).toEqual({ position: 3, duration: 3 }); - expect(plan.removes).toEqual([]); - }); - - it("end point resizes, keeping the start", () => { - // tweenOldPct 100 (end) → newPct 80% → abs 8 → duration 6, start unchanged. - const plan = computeKeyframeMovePlan(anim, 100, el, 80); - expect(plan.meta).toEqual({ position: 2, duration: 6 }); - }); -}); - -describe("computeKeyframeMovePlan — keyframe-array tween", () => { - const anim = { - id: "k", - targetSelector: "#box", - position: 0, - duration: 10, - resolvedStart: 0, - keyframes: { - keyframes: [ - { percentage: 0, properties: { x: 0 } }, - { percentage: 50, properties: { x: 50 } }, - { percentage: 100, properties: { x: 100 } }, - ], - }, - }; - - it("moves an intermediate keyframe without touching the tween or others", () => { - // mid keyframe (tweenPct 50) → newPct 70% → abs 7 → 70% of the tween. - const plan = computeKeyframeMovePlan(anim, 50, el, 70); - expect(plan.meta).toBeUndefined(); - expect(plan.removes).toEqual([50]); - expect(plan.adds).toEqual([{ pct: 70, properties: { x: 50 } }]); - }); - - it("start move remaps intermediates to preserve their absolute times", () => { - // start (tweenPct 0) → newPct 20% → abs 2 → window [2,10]. The 50% keyframe - // was at abs 5 → now (5-2)/8 = 37.5%. - const plan = computeKeyframeMovePlan(anim, 0, el, 20); - expect(plan.meta).toEqual({ position: 2, duration: 8 }); - expect(plan.removes).toContain(50); - const mid = plan.adds.find((a) => a.properties.x === 50); - expect(mid?.pct).toBeCloseTo(37.5, 1); - }); - - it("is a no-op when the dragged keyframe can't be located (stale cache)", () => { - // tweenOldPct 33 matches no keyframe (0/50/100) → must NOT resize the tween. - const plan = computeKeyframeMovePlan(anim, 33, el, 70); - expect(plan.meta).toBeUndefined(); - expect(plan.removes).toEqual([]); - expect(plan.adds).toEqual([]); - }); -}); diff --git a/packages/studio/src/components/editor/keyframeMove.ts b/packages/studio/src/components/editor/keyframeMove.ts deleted file mode 100644 index 6b38ca7943..0000000000 --- a/packages/studio/src/components/editor/keyframeMove.ts +++ /dev/null @@ -1,151 +0,0 @@ -/** - * Pure helpers for committing a keyframe-diamond drag: pick the tween the - * dragged keyframe belongs to, and compute the GSAP mutations (tween - * position/duration and/or keyframe add/remove) for the move. Kept free of - * React/store so the timeline drag handler stays a thin orchestrator. - */ - -interface TweenLike { - id: string; - targetSelector: string; - position: number | string; - duration?: number; - resolvedStart?: number; - propertyGroup?: string; - keyframes?: { keyframes: { percentage: number; properties: Record }[] }; -} - -interface ElementWindow { - start: number; - duration: number; - domId?: string; - selector?: string; -} - -export interface KeyframeMovePlan { - /** Tween timing change (start/end point drags). */ - meta?: { position: number; duration: number }; - /** Keyframe percentages to remove, then re-add (intermediate move / remap). */ - removes: number[]; - adds: { pct: number; properties: Record }[]; -} - -const round3 = (n: number) => Math.round(n * 1000) / 1000; -const clampPct = (n: number) => Math.max(0, Math.min(100, Math.round(n * 100) / 100)); -const MIN_DUR = 0.05; - -function tweenWindow(a: TweenLike): { start: number; dur: number } { - return { - start: a.resolvedStart ?? (typeof a.position === "number" ? a.position : 0), - dur: a.duration ?? 0, - }; -} - -type Kf = { percentage: number; properties: Record }; - -/** - * Remap every keyframe except `keepIdx` from the old tween window to the new one - * so their absolute times stay fixed after a start/end resize. Returns the - * remove/add ops (empty for flat tweens, which have no intermediates). - */ -function remapKeyframes( - kfs: Kf[], - keepIdx: number, - oldStart: number, - oldDur: number, - newStart: number, - newDur: number, -): Pick { - const removes: number[] = []; - const adds: KeyframeMovePlan["adds"] = []; - if (newDur <= 0) return { removes, adds }; - for (let i = 0; i < kfs.length; i++) { - if (i === keepIdx) continue; - const k = kfs[i]!; - const absT = oldStart + (k.percentage / 100) * oldDur; - const remapped = clampPct(((absT - newStart) / newDur) * 100); - if (Math.abs(remapped - k.percentage) < 0.05) continue; - removes.push(k.percentage); - adds.push({ pct: remapped, properties: k.properties }); - } - return { removes, adds }; -} - -/** - * Pick the tween the dragged keyframe belongs to: restrict to the element's - * selector and (if known) the keyframe's property group, then choose the one - * whose time window contains — or is nearest — the keyframe's original time. - * An element can have several tweens in one group (e.g. fade-in + fade-out). - */ -export function pickKeyframeTween( - anims: T[], - el: ElementWindow, - origAbsTime: number, - group: string | undefined, -): T | undefined { - const selectors = [el.domId ? `#${el.domId}` : null, el.selector].filter(Boolean); - const forEl = anims.filter((a) => selectors.includes(a.targetSelector)); - // Only ever pick among THIS element's tweens. Don't fall back to all - // animations — a selector mismatch (e.g. a class/compound-selector tween) - // would otherwise edit a different element's keyframes. No match → no-op. - if (forEl.length === 0) return undefined; - const groupPool = group ? forEl.filter((a) => a.propertyGroup === group) : []; - const candidates = groupPool.length > 0 ? groupPool : forEl; - const dist = (a: T): number => { - const { start, dur } = tweenWindow(a); - if (origAbsTime >= start && origAbsTime <= start + dur) return 0; - return Math.min(Math.abs(origAbsTime - start), Math.abs(origAbsTime - (start + dur))); - }; - return candidates.reduce((best, a) => (dist(a) < dist(best) ? a : best), candidates[0]!); -} - -/** - * Compute the mutations for moving a keyframe to `newPct` (clip-relative): - * - start point → trim front (position moves, end fixed), - * - end point → resize (duration changes, start fixed), - * - intermediate → move only that keyframe; start/end moves remap the other - * keyframes so their absolute times stay put. - */ -// fallow-ignore-next-line complexity -export function computeKeyframeMovePlan( - anim: TweenLike, - tweenOldPct: number, - el: ElementWindow, - newPct: number, -): KeyframeMovePlan { - const newAbsTime = el.start + (newPct / 100) * el.duration; - const tweenStart = tweenWindow(anim).start; - const tweenDur = anim.duration ?? el.duration; - const kfs = anim.keyframes - ? anim.keyframes.keyframes.slice().sort((a, b) => a.percentage - b.percentage) - : null; - const idx = kfs ? kfs.findIndex((k) => Math.abs(k.percentage - tweenOldPct) < 0.5) : -1; - - // Keyframe-array tween but the dragged keyframe couldn't be located (stale - // cache / precision drift): no-op rather than falling through to an end-point - // resize that would silently rescale the whole tween and re-time every key. - if (kfs && idx === -1) return { removes: [], adds: [] }; - - if (kfs && idx > 0 && idx < kfs.length - 1) { - const movedPct = tweenDur > 0 ? clampPct(((newAbsTime - tweenStart) / tweenDur) * 100) : 0; - return { removes: [tweenOldPct], adds: [{ pct: movedPct, properties: kfs[idx]!.properties }] }; - } - - const isStartPoint = kfs ? idx === 0 : tweenOldPct <= 50; - let newStart = tweenStart; - let newDur = tweenDur; - if (isStartPoint) { - const end = tweenStart + tweenDur; - newStart = Math.max(0, Math.min(newAbsTime, end - MIN_DUR)); - newDur = end - newStart; - } else { - newDur = Math.max(MIN_DUR, newAbsTime - tweenStart); - } - - const windowChanged = newStart !== tweenStart || newDur !== tweenDur; - const remap = - kfs && windowChanged - ? remapKeyframes(kfs, idx, tweenStart, tweenDur, newStart, newDur) - : { removes: [], adds: [] }; - return { meta: { position: round3(newStart), duration: round3(newDur) }, ...remap }; -} diff --git a/packages/studio/src/components/editor/manualOffsetDrag.ts b/packages/studio/src/components/editor/manualOffsetDrag.ts index 1bd3599340..6151a09790 100644 --- a/packages/studio/src/components/editor/manualOffsetDrag.ts +++ b/packages/studio/src/components/editor/manualOffsetDrag.ts @@ -209,6 +209,22 @@ export function applyManualOffsetDragMatrix(matrix: ManualOffsetDragMatrix, poin }; } +/** + * The perspective w-divisor (matrix3d m44) of the element's current transform. + * For a plain `translateZ(z)` under `perspective(p)`, m44 = (p - z) / p, so the + * element renders 1/m44× larger and a translate of `d` composition px moves + * `d / m44` px on screen. Returns 1 for 2D transforms (no foreshortening). Used + * to keep the drag offset → screen-movement mapping correct for depth elements, + * which the flat-scale fast path below would otherwise get wrong by 1/m44. + */ +function readTransformWDivisor(element: HTMLElement): number { + const t = element.ownerDocument.defaultView?.getComputedStyle(element).transform; + if (!t || !t.startsWith("matrix3d(")) return 1; + const parts = t.slice("matrix3d(".length, -1).split(","); + const w = Number.parseFloat(parts[15] ?? ""); + return Number.isFinite(w) && w > 0 ? w : 1; +} + export function measureManualOffsetDragScreenToOffsetMatrix( element: HTMLElement, initialOffset: { x: number; y: number }, @@ -221,7 +237,11 @@ export function measureManualOffsetDragScreenToOffsetMatrix( ) { const sx = options.scaleX || 1; const sy = options.scaleY || 1; - return { ok: true, matrix: { a: 1 / sx, b: 0, c: 0, d: 1 / sy } }; + // Fold in the perspective foreshortening: a depth element (z≠0) moves + // 1/m44× faster on screen than its flat scale implies, so the screen→offset + // matrix must scale by m44 or the element outruns the pointer/overlay. + const w = readTransformWDivisor(element); + return { ok: true, matrix: { a: w / sx, b: 0, c: 0, d: w / sy } }; } const probeSize = options.probeSize ?? DEFAULT_OFFSET_PROBE_PX; @@ -360,6 +380,7 @@ export function createManualOffsetDragMember(input: { // drag is acceptable — the final committed position is always exact. const scaleX = input.rect.editScaleX || 1; const scaleY = input.rect.editScaleY || 1; + const w = readTransformWDivisor(input.element); return { ok: true, member: { @@ -370,7 +391,7 @@ export function createManualOffsetDragMember(input: { baseGsap, initialPathOffset, gestureToken, - screenToOffset: { a: 1 / scaleX, b: 0, c: 0, d: 1 / scaleY }, + screenToOffset: { a: w / scaleX, b: 0, c: 0, d: w / scaleY }, originRect: input.rect, }, }; diff --git a/packages/studio/src/components/editor/propertyPanel3dTransform.tsx b/packages/studio/src/components/editor/propertyPanel3dTransform.tsx index 6710dc9bfb..830992362c 100644 --- a/packages/studio/src/components/editor/propertyPanel3dTransform.tsx +++ b/packages/studio/src/components/editor/propertyPanel3dTransform.tsx @@ -6,6 +6,20 @@ import { KeyframeNavigation } from "./KeyframeNavigation"; import { formatPxMetricValue, parsePxMetricValue, RESPONSIVE_GRID } from "./propertyPanelHelpers"; import { Transform3DCube, type CubePose } from "./Transform3DCube"; +// translateZ only foreshortens under a perspective lens. Rather than hardcode one +// (an arbitrary px value reads wrong at different canvas sizes), derive it from the +// element's composition: perspective = composition height puts the virtual camera +// one comp-height back, a natural ~53° vertical FOV that looks the same whether the +// canvas is 720p or 4K. Falls back to the element's own height only if the comp size +// can't be read (detached/unmeasured), never to a fixed magic number. +function naturalDepthPerspective(el: HTMLElement | null | undefined): number { + if (!el) return 0; + const root = el.closest("[data-hf-inner-root],[data-composition-id]") as HTMLElement | null; + const compHeight = root?.offsetHeight || el.ownerDocument?.documentElement?.clientHeight || 0; + if (compHeight > 0) return Math.round(compHeight); + return Math.round((el.offsetHeight || 0) * 4) || 0; +} + type KeyframeEntry = Array<{ percentage: number; properties: Record; @@ -70,6 +84,15 @@ function Cube3dControl({ rotationY: gsapRuntimeValues.rotationY ?? 0, rotationZ: gsapRuntimeValues.rotationZ ?? 0, }; + // Comp-derived lens (see naturalDepthPerspective) applied the first time depth is + // set, so the scene's foreshortening scales with the canvas instead of a magic 800. + const depthPerspective = naturalDepthPerspective(element.element); + // A gentle, fixed "depth pose" tilt (degrees) dropped on a flat element the first + // time it gets depth, so translateZ reads as 3D foreshortening instead of a plain + // resize — small enough to look like a premium card, not a flip. + const DEPTH_POSE_X = 10; + const DEPTH_POSE_Y = -15; + const isFlat = Math.round(pose.rotationX) === 0 && Math.round(pose.rotationY) === 0; // Commit only the rotation axes the drag actually changed (each rounded to a // whole degree). Reuses the keyframe-aware animated-property commit, so a drag // at the playhead writes/updates a keyframe just like the numeric fields. @@ -123,18 +146,59 @@ function Cube3dControl({ onLivePreviewProps?.(element, { transformPerspective: px })} - onPerspectiveCommit={(px) => - void onCommitAnimatedProperty(element, "transformPerspective", px) - } + onDepthDraft={(z) => { + // Preview WITH a lens so depth is visible while scrolling — the same + // default the commit applies, so the element doesn't snap on release. + const preview: Record = gsapRuntimeValues.transformPerspective + ? { z } + : { z, transformPerspective: depthPerspective }; + // Depth-pose preview: a flat element only scales under Z, so mirror the + // commit and preview the gentle tilt that makes the depth read as 3D. + if (isFlat) { + preview.rotationX = DEPTH_POSE_X; + preview.rotationY = DEPTH_POSE_Y; + } + onLivePreviewProps?.(element, preview); + }} + onDepthCommit={(z) => { + // Best-UX depth: scroll moves Z, and a 3D transform always has a lens — + // like an After Effects camera. translateZ is invisible without a + // perspective, so the FIRST time depth is added (Perspective still 0) we + // set a sensible comp-derived lens ONCE. Every later scroll touches Z + // only, and Perspective stays an independent, editable field. The cube's + // scroll is clamped in front of the lens, so Z can't run away past it. + const props: Record = { z }; + if (!gsapRuntimeValues.transformPerspective && depthPerspective > 0) { + props.transformPerspective = depthPerspective; + } + // Depth-pose: a flat element (no tilt) only scales under Z — it can't read + // as depth. So the first time depth lands on a flat element, also drop a + // gentle fixed tilt; the foreshortening makes depth read as 3D IN PLACE + // (no screen travel, per-element lens unchanged). Once the element has any + // tilt, depth scrolls touch Z only. Reset tilt to 0 to go flat again. + if (isFlat) { + props.rotationX = DEPTH_POSE_X; + props.rotationY = DEPTH_POSE_Y; + } + // One commit for all props so the writes can't race read-modify-write on + // the same script (which dropped a prop and reverted after a seek). + if (onCommitAnimatedProperties) { + void onCommitAnimatedProperties(element, props); + } else { + for (const [p, v] of Object.entries(props)) + void onCommitAnimatedProperty(element, p, v); + } + }} onRecenter={recenter} onKeyframe={onKeyframe} keyframed={keyframed} />

- Drag to tilt · Shift-drag to roll + Drag to tilt · Shift-drag to roll · Scroll for depth

diff --git a/packages/studio/src/components/editor/propertyPanelHelpers.ts b/packages/studio/src/components/editor/propertyPanelHelpers.ts index b0944f2573..a8074e8556 100644 --- a/packages/studio/src/components/editor/propertyPanelHelpers.ts +++ b/packages/studio/src/components/editor/propertyPanelHelpers.ts @@ -13,6 +13,8 @@ export interface PropertyPanelProps { multiSelectCount?: number; copiedAgentPrompt: boolean; onClearSelection: () => void; + /** Dissolve the selected data-hf-group wrapper (shown only for group selections). */ + onUngroup?: () => void; onSetStyle: (prop: string, value: string) => void | Promise; onSetAttribute: (attr: string, value: string) => void | Promise; onSetAttributeLive: (attr: string, value: string | null) => void | Promise; diff --git a/packages/studio/src/components/editor/useDomEditOverlayRects.ts b/packages/studio/src/components/editor/useDomEditOverlayRects.ts index bf3819b40e..df7e12b5fb 100644 --- a/packages/studio/src/components/editor/useDomEditOverlayRects.ts +++ b/packages/studio/src/components/editor/useDomEditOverlayRects.ts @@ -11,6 +11,7 @@ import { type ResolvedElementRef, groupOverlayItemsEqual, isElementVisibleForOverlay, + groupAwareOverlayRect, rectsEqual, resolveElementForOverlay, selectionCacheKey, @@ -155,7 +156,7 @@ export function useDomEditOverlayRects({ // backgroundless full-bleed scene above a subcomposition), which would wrongly // hide the selection box. Occlusion stays for hover, where a false hide is cheap. if (el && isElementVisibleForOverlay(el)) { - const nextRect = toOverlayRect(overlayEl, iframe, el); + const nextRect = groupAwareOverlayRect(overlayEl, iframe, el); setOverlayRect(nextRect); const descendants = el.querySelectorAll("*"); if (descendants.length > 0 && descendants.length <= 60) { @@ -196,9 +197,13 @@ export function useDomEditOverlayRects({ const liveGroupKeys = new Set(); for (const groupSelection of group) { const key = selectionCacheKey(groupSelection); + // Members of the same group collapse to one selection under select-as-unit, + // so a multi-select can hold the same group twice — dedupe by key to avoid + // duplicate React keys (and a doubled overlay box). + if (liveGroupKeys.has(key)) continue; liveGroupKeys.add(key); const el = resolveGroupElement(doc, groupSelection); - const rect = el ? toOverlayRect(overlayEl, iframe, el) : null; + const rect = el ? groupAwareOverlayRect(overlayEl, iframe, el) : null; if (el && rect) nextGroupItems.push({ key, selection: groupSelection, element: el, rect }); } @@ -235,7 +240,7 @@ export function useDomEditOverlayRects({ return; } - setHoverRect(toOverlayRect(overlayEl, iframe, hoverEl)); + setHoverRect(groupAwareOverlayRect(overlayEl, iframe, hoverEl)); }; frame = requestAnimationFrame(update); diff --git a/packages/studio/src/components/editor/useMotionPathData.ts b/packages/studio/src/components/editor/useMotionPathData.ts index 96f170715b..1f5cd67ce5 100644 --- a/packages/studio/src/components/editor/useMotionPathData.ts +++ b/packages/studio/src/components/editor/useMotionPathData.ts @@ -5,6 +5,38 @@ import { buildMotionPathGeometry, type MotionPathGeometry } from "./motionPathGe type Rect = { left: number; top: number; width: number; height: number }; +// The translate (e/f) components of an element's computed transform, in comp px. +// A group wrapper dragged via GSAP carries its offset here, not in offsetLeft/Top. +function transformTranslate(el: HTMLElement): { x: number; y: number } { + const t = el.ownerDocument?.defaultView?.getComputedStyle(el).transform; + if (!t || t === "none") return { x: 0, y: 0 }; + const m3 = t.match(/matrix3d\(([^)]+)\)/); + if (m3) { + const v = m3[1].split(",").map(Number); + return { x: v[12] || 0, y: v[13] || 0 }; + } + const m = t.match(/matrix\(([^)]+)\)/); + if (m) { + const v = m[1].split(",").map(Number); + return { x: v[4] || 0, y: v[5] || 0 }; + } + return { x: 0, y: 0 }; +} + +// Perspective foreshortening of the element's OWN transform (matrix3d m44). A +// depth element (translateZ toward the viewer) renders 1/m44× larger, so its +// animated x/y offsets travel 1/m44× further on screen than the flat preview +// scale implies. Returns 1 for 2D transforms. The motion path magnifies its +// offset points by 1/m44 (and de-magnifies pointer→offset) so the drawn path and +// its draggable nodes track the projected element instead of drifting off it. +export function transformWDivisor(el: HTMLElement): number { + const t = el.ownerDocument?.defaultView?.getComputedStyle(el).transform; + if (!t || !t.startsWith("matrix3d(")) return 1; + const v = t.slice("matrix3d(".length, -1).split(","); + const w = Number.parseFloat(v[15] ?? ""); + return Number.isFinite(w) && w > 0 ? w : 1; +} + export function elementHome(el: HTMLElement): { x: number; y: number } { let left = 0; let top = 0; @@ -12,6 +44,14 @@ export function elementHome(el: HTMLElement): { x: number; y: number } { while (node) { left += node.offsetLeft; top += node.offsetTop; + // Ancestor transforms (e.g. a group wrapper moved via GSAP) shift where the + // element actually renders, so the path must anchor on top of them. The element's + // OWN transform is excluded — that's the animated offset the path itself draws. + if (node !== el) { + const t = transformTranslate(node); + left += t.x; + top += t.y; + } const parent = node.offsetParent as HTMLElement | null; if (!parent || parent.hasAttribute("data-composition-id")) break; node = parent; @@ -62,6 +102,7 @@ export function useMotionPathData( geometryResolved: boolean; visibleInPreview: boolean; home: { x: number; y: number } | null; + pScale: number; } { const [rect, setRect] = useState(null); const [geometry, setGeometry] = useState(null); @@ -69,6 +110,9 @@ export function useMotionPathData( const geometryResolved = resolvedForRef.current === selector; const [visibleInPreview, setVisibleInPreview] = useState(true); const [home, setHome] = useState<{ x: number; y: number } | null>(null); + // Perspective magnification (1/m44) of the selected element — applied to the + // path's offset points so depth (translateZ) elements' paths track on screen. + const [pScale, setPScale] = useState(1); useEffect(() => { if (!selector) { @@ -105,6 +149,8 @@ export function useMotionPathData( setHome((prev) => prev && Math.abs(prev.x - h.x) < 0.5 && Math.abs(prev.y - h.y) < 0.5 ? prev : h, ); + const ps = 1 / transformWDivisor(live); + setPScale((p) => (Math.abs(p - ps) < 0.001 ? p : ps)); } } raf = requestAnimationFrame(tick); @@ -132,5 +178,5 @@ export function useMotionPathData( return () => window.clearInterval(id); }, [selector, iframeRef]); - return { rect, geometry, geometryResolved, visibleInPreview, home }; + return { rect, geometry, geometryResolved, visibleInPreview, home, pScale }; } diff --git a/packages/studio/src/contexts/DomEditContext.tsx b/packages/studio/src/contexts/DomEditContext.tsx index 0222a9bffb..71206d145c 100644 --- a/packages/studio/src/contexts/DomEditContext.tsx +++ b/packages/studio/src/contexts/DomEditContext.tsx @@ -31,6 +31,9 @@ export interface DomEditActionsValue extends Pick< | "handleBlockedDomMove" | "handleDomManualDragStart" | "handleDomEditElementDelete" + | "handleGroupSelection" + | "handleUngroupSelection" + | "setActiveGroupElement" | "buildDomSelectionFromTarget" | "buildDomSelectionForTimelineElement" | "updateDomEditHoverSelection" @@ -72,6 +75,7 @@ export interface DomEditSelectionValue extends Pick< | "domEditSelection" | "domEditGroupSelections" | "domEditHoverSelection" + | "activeGroupElement" | "domEditSelectionRef" | "selectedGsapAnimations" | "gsapMultipleTimelines" @@ -138,6 +142,10 @@ export function DomEditProvider({ handleBlockedDomMove, handleDomManualDragStart, handleDomEditElementDelete, + handleGroupSelection, + handleUngroupSelection, + setActiveGroupElement, + activeGroupElement, buildDomSelectionFromTarget, buildDomSelectionForTimelineElement, updateDomEditHoverSelection, @@ -216,6 +224,9 @@ export function DomEditProvider({ handleBlockedDomMove, handleDomManualDragStart, handleDomEditElementDelete, + handleGroupSelection, + handleUngroupSelection, + setActiveGroupElement, buildDomSelectionFromTarget, buildDomSelectionForTimelineElement, updateDomEditHoverSelection, @@ -277,6 +288,9 @@ export function DomEditProvider({ handleBlockedDomMove, handleDomManualDragStart, handleDomEditElementDelete, + handleGroupSelection, + handleUngroupSelection, + setActiveGroupElement, buildDomSelectionFromTarget, buildDomSelectionForTimelineElement, updateDomEditHoverSelection, @@ -319,6 +333,7 @@ export function DomEditProvider({ domEditSelection, domEditGroupSelections, domEditHoverSelection, + activeGroupElement, domEditSelectionRef, selectedGsapAnimations, gsapMultipleTimelines, @@ -332,6 +347,7 @@ export function DomEditProvider({ domEditSelection, domEditGroupSelections, domEditHoverSelection, + activeGroupElement, domEditSelectionRef, selectedGsapAnimations, gsapMultipleTimelines, diff --git a/packages/studio/src/contexts/TimelineEditContext.tsx b/packages/studio/src/contexts/TimelineEditContext.tsx index 5ff84cf953..8481d2054a 100644 --- a/packages/studio/src/contexts/TimelineEditContext.tsx +++ b/packages/studio/src/contexts/TimelineEditContext.tsx @@ -39,7 +39,6 @@ export function TimelineEditProvider({ value.onDeleteKeyframe, value.onDeleteAllKeyframes, value.onChangeKeyframeEase, - value.onMoveKeyframe, value.onToggleKeyframeAtPlayhead, ], ); diff --git a/packages/studio/src/hooks/gsapRuntimeBridge.ts b/packages/studio/src/hooks/gsapRuntimeBridge.ts index 6efd2ce3f4..55c2e50804 100644 --- a/packages/studio/src/hooks/gsapRuntimeBridge.ts +++ b/packages/studio/src/hooks/gsapRuntimeBridge.ts @@ -241,8 +241,15 @@ export async function tryGsapDragIntercept( // place (idempotent), else add a new one. This also covers the stale-cache // phantom — committing a set is correct because the element genuinely has no live motion. const hasNonHold = hasNonHoldTweenForElement(iframe, selector); - - if (!hasNonHold) { + // A KEYFRAMED position tween — even one that's currently a flat constant ("hold", + // e.g. 0% and 100% identical) — is still an animation the user is building, so a + // drag must add/update a keyframe, NOT fall back to a static `set`. Without this, + // dragging an element whose position tween is constant writes a `gsap.set` that + // fights the tween (the "drag didn't create a keyframe / didn't persist" bug). The + // static path is only for elements with NO keyframed position tween (truly static, + // or just a leftover position-hold `set`). + const hasKeyframedPosTween = !!posAnim?.keyframes; + if (!hasNonHold && !hasKeyframedPosTween) { const existingSet = posAnim && posAnim.method === "set" && posAnim.targetSelector === selector ? posAnim diff --git a/packages/studio/src/hooks/useAnimatedPropertyCommit.ts b/packages/studio/src/hooks/useAnimatedPropertyCommit.ts index eb49b1eb94..28999ee6f3 100644 --- a/packages/studio/src/hooks/useAnimatedPropertyCommit.ts +++ b/packages/studio/src/hooks/useAnimatedPropertyCommit.ts @@ -15,6 +15,8 @@ import { usePlayerStore } from "../player/store/playerStore"; import { readAllAnimatedProperties, readGsapProperty } from "./gsapRuntimeBridge"; import type { SetPatchProps } from "./gsapRuntimePatch"; import { selectorFromSelection, computeElementPercentage } from "./gsapShared"; +import { resolveTweenStart, resolveTweenDuration } from "../utils/globalTimeCompiler"; +import { roundTo3 } from "../utils/rounding"; interface CommitAnimatedPropertyDeps { selectedGsapAnimations: GsapAnimation[]; @@ -45,14 +47,22 @@ function pickBestAnimation( selector: string | null, property?: string, ): GsapAnimation | undefined { - if (animations.length <= 1) return animations[0]; - const currentTime = usePlayerStore.getState().currentTime; const targetGroup = property ? classifyPropertyGroup(property) : undefined; - - // fallow-ignore-next-line complexity - const scored = animations.map((a) => { + // Group-aware: never hand back a tween from a DIFFERENT property group. The old + // `animations.length <= 1` early return merged a rotation/3D edit into the element's + // only tween even when that was a `position` tween — contaminating it and leaving the + // new property with no clean keyframe baseline. When a target group is known, only + // same-group tweens are candidates; if none exist we return undefined and the caller + // creates a fresh same-group tween. + const candidates = + targetGroup !== undefined + ? animations.filter((a) => a.propertyGroup === targetGroup) + : animations; + if (candidates.length === 0) return undefined; + if (candidates.length === 1) return candidates[0]; + const currentTime = usePlayerStore.getState().currentTime; + const scored = candidates.map((a) => { let score = 0; - if (targetGroup && a.propertyGroup === targetGroup) score += 20; if (a.keyframes) score += 10; if (selector && a.targetSelector === selector) score += 5; else if (a.targetSelector.includes(",")) score -= 3; @@ -196,14 +206,15 @@ async function commitKeyframeProps( iframe: HTMLIFrameElement | null, commit: Commit, ): Promise { - if (!anim.keyframes) { + const wasKeyframed = !!anim.keyframes; + if (!wasKeyframed) { await commit( selection, { type: "convert-to-keyframes", animationId: anim.id }, { label: "Convert to keyframes", skipReload: true }, ); } - const pct = computeElementPercentage(usePlayerStore.getState().currentTime, selection, anim); + const ct = usePlayerStore.getState().currentTime; const runtimeProps = selector ? readAllAnimatedProperties(iframe, selector, anim) : {}; const properties: Record = { ...runtimeProps, ...props }; @@ -216,6 +227,52 @@ async function commitKeyframeProps( backfillDefaults[property] = value; } + // Playhead OUTSIDE the keyframe tween's time range → EXTEND the tween to reach it + // and add a keyframe there, exactly like manual drag's extendTweenAndAddKeyframe. + // The add-keyframe below only writes WITHIN the existing range, so without this a + // depth edit past the tween end just overwrites the last keyframe (the bug: no new + // diamond appears at a playhead beyond the tween). Only for an already-keyframed + // tween — a freshly-converted set has no prior range worth remapping. + const kfs = anim.keyframes?.keyframes; + const ts = resolveTweenStart(anim); + const td = resolveTweenDuration(anim); + const hasSelectedKeyframe = usePlayerStore.getState().activeKeyframePct != null; + const playheadOutside = ts !== null && td > 0 && (ct < ts - 0.01 || ct > ts + td + 0.01); + const willExtend = wasKeyframed && !!kfs && playheadOutside && !hasSelectedKeyframe; + if (willExtend && kfs && ts !== null) { + const newStart = Math.min(ct, ts); + const newEnd = Math.max(ct, ts + td); + const newDuration = Math.max(0.01, newEnd - newStart); + const remapped = kfs.map((kf) => { + const absTime = ts + (kf.percentage / 100) * td; + const newPct = Math.round(((absTime - newStart) / newDuration) * 1000) / 10; + const p: Record = { ...kf.properties }; + for (const k of Object.keys(properties)) { + if (!(k in p) && backfillDefaults[k] != null) p[k] = backfillDefaults[k]; + } + return { percentage: newPct, properties: p }; + }); + remapped.push({ + percentage: Math.round(((ct - newStart) / newDuration) * 1000) / 10, + properties, + }); + remapped.sort((a, b) => a.percentage - b.percentage); + await commit( + selection, + { + type: "replace-with-keyframes", + animationId: anim.id, + targetSelector: anim.targetSelector, + position: roundTo3(newStart), + duration: roundTo3(newDuration), + keyframes: remapped, + }, + { label: `Edit ${primaryProp} (extended keyframe)`, softReload: true }, + ); + return; + } + + const pct = computeElementPercentage(ct, selection, anim); const existingKf = anim.keyframes?.keyframes.some((kf) => Math.abs(kf.percentage - pct) < 0.05); // Rebuild the live keyframe tween in place so the edit shows instantly (no flash); // rebuildKeyframeTween declines → soft reload if the tween can't be safely rebuilt. @@ -275,8 +332,30 @@ export function useAnimatedPropertyCommit(deps: CommitAnimatedPropertyDeps) { // so the rejection doesn't escape as an uncaught promise, and bump the cache // so selectedGsapAnimations re-syncs and the user's next edit self-heals. try { - // Existing static hold — merge the props into the `set`, then auto-keyframe - // ONLY if the element is already animated (maybeAutoKeyframeSet no-ops if not). + // Animated element → keyframe at the playhead, EXACTLY like manual drag / + // resize / rotate: if the picked anim is still a static `set`, + // commitKeyframeProps converts it to keyframes first, then writes the new + // value as a keyframe at the current time — so the 3D animates instead of + // holding a flat constant. This MUST come before the `set`-update path below, + // or a 3D `set` would short-circuit to an in-place update and the playhead + // keyframe would never land (the bug: scrolling depth on a keyframed element + // just changed the constant instead of dropping a keyframe). + if (elementHasKeyframes && anim) { + await commitKeyframeProps( + selection, + anim, + props, + propEntries, + primaryProp, + selector, + iframe, + gsapCommitMutation, + ); + return; + } + + // Existing static hold on a NON-animated element — merge the props into the + // `set` in place (maybeAutoKeyframeSet no-ops when nothing else is keyframed). if (anim?.method === "set") { await commitSetProps( selection, @@ -289,8 +368,8 @@ export function useAnimatedPropertyCommit(deps: CommitAnimatedPropertyDeps) { return; } - // Static element — persist as a `tl.set`, never keyframes (incl. the - // no-animation case, which now creates a set instead of a keyframed tween). + // Static element (no keyframes anywhere) — persist as a `tl.set`, never + // keyframes (incl. the no-animation case, which creates a fresh set). if (!elementHasKeyframes) { await commitStaticSet( selection, @@ -302,22 +381,43 @@ export function useAnimatedPropertyCommit(deps: CommitAnimatedPropertyDeps) { return; } - // Animated element — write ALL props into ONE keyframe so a multi-axis cube - // edit doesn't race into adjacent duplicates. - if (!anim) { - bumpGsapCache(); + // Animated element but NO same-group tween exists (e.g. the FIRST rotation/3D + // keyframe on an element that only has a position tween). Create a fresh + // same-group keyframed tween WITH a 0% baseline at the playhead, instead of + // contaminating a foreign-group tween. Mirror an existing keyframed tween's + // time range so the new group animates over the same span. The 0% baseline is + // an `_auto` endpoint so it tracks the nearest keyframe as you add more. + if (selector) { + const template = selectedGsapAnimations.find((a) => !!a.keyframes); + const tStart = template ? (resolveTweenStart(template) ?? 0) : 0; + const tDur = template ? resolveTweenDuration(template) || 1 : 1; + const ct = usePlayerStore.getState().currentTime; + const pct = + tDur > 0 + ? Math.max(0, Math.min(100, Math.round(((ct - tStart) / tDur) * 1000) / 10)) + : 0; + const newProps = Object.fromEntries(propEntries); + const keyframes = + pct <= 0.05 + ? [{ percentage: 0, properties: newProps }] + : [ + { percentage: 0, properties: { ...newProps, _auto: 1 } }, + { percentage: pct, properties: newProps }, + ]; + await gsapCommitMutation( + selection, + { + type: "add-with-keyframes", + targetSelector: selector, + position: roundTo3(tStart), + duration: roundTo3(tDur), + keyframes, + }, + { label: `Add ${primaryProp} keyframe`, softReload: true }, + ); return; } - await commitKeyframeProps( - selection, - anim, - props, - propEntries, - primaryProp, - selector, - iframe, - gsapCommitMutation, - ); + bumpGsapCache(); } catch { bumpGsapCache(); } diff --git a/packages/studio/src/hooks/useAppHotkeys.ts b/packages/studio/src/hooks/useAppHotkeys.ts index 56cfffd98f..2748bbbfce 100644 --- a/packages/studio/src/hooks/useAppHotkeys.ts +++ b/packages/studio/src/hooks/useAppHotkeys.ts @@ -8,6 +8,7 @@ import { shouldHandleTimelineToggleHotkey, isEditableTarget } from "../utils/tim import { shouldIgnoreHistoryShortcut } from "../utils/studioHelpers"; import { canSplitElement } from "../utils/timelineElementSplit"; import { STUDIO_RAZOR_TOOL_ENABLED } from "../components/editor/manualEditingAvailability"; +import { trackStudioEvent } from "../utils/studioTelemetry"; function iframeContentWindow(iframe: HTMLIFrameElement | null): Window | null { try { @@ -117,6 +118,10 @@ interface UseAppHotkeysParams { onDeleteSelectedKeyframes: () => void; onAfterUndoRedo?: () => void; onToggleRecording?: () => void; + /** Group the current multi-selection into a data-hf-group wrapper (⌘G). */ + onGroupSelection?: () => void; + /** Ungroup the selected group wrapper (⌘⇧G). */ + onUngroupSelection?: () => void; /** Active composition path — used to decide whether undo/redo must resync the SDK session. */ activeCompPath?: string | null; /** @@ -142,6 +147,8 @@ interface HotkeyCallbacks { onResetKeyframes: () => boolean; onDeleteSelectedKeyframes: () => void; onToggleRecording?: () => void; + onGroupSelection?: () => void; + onUngroupSelection?: () => void; leftSidebarRef: React.RefObject; domEditSelectionRef: React.MutableRefObject; showToast: (message: string, tone?: "error" | "info") => void; @@ -152,36 +159,56 @@ function dispatchModifierKey(event: KeyboardEvent, key: string, cb: HotkeyCallba !shouldIgnoreHistoryShortcut(event.target) && handleUndoRedoKey( event, - () => void cb.handleUndo(), - () => void cb.handleRedo(), + () => { + trackStudioEvent("keyboard_shortcut", { action: "undo" }); + void cb.handleUndo(); + }, + () => { + trackStudioEvent("keyboard_shortcut", { action: "redo" }); + void cb.handleRedo(); + }, ) ) return true; if (event.key === "1") { event.preventDefault(); + trackStudioEvent("keyboard_shortcut", { action: "tab_compositions" }); cb.leftSidebarRef.current?.selectTab("compositions"); return true; } if (event.key === "2") { event.preventDefault(); + trackStudioEvent("keyboard_shortcut", { action: "tab_assets" }); cb.leftSidebarRef.current?.selectTab("assets"); return true; } + if (key === "g" && !event.altKey && !isEditableTarget(event.target)) { + event.preventDefault(); + if (event.shiftKey) cb.onUngroupSelection?.(); + else cb.onGroupSelection?.(); + return true; + } + if (!event.shiftKey && !event.altKey && !isEditableTarget(event.target)) { if (key === "c") { - if (cb.handleCopy()) event.preventDefault(); + if (cb.handleCopy()) { + event.preventDefault(); + trackStudioEvent("keyboard_shortcut", { action: "copy" }); + } return true; } if (key === "v") { event.preventDefault(); + trackStudioEvent("keyboard_shortcut", { action: "paste" }); void cb.handlePaste(); return true; } if (key === "x") { if (usePlayerStore.getState().selectedElementId || cb.domEditSelectionRef.current) { event.preventDefault(); + trackStudioEvent("keyboard_shortcut", { action: "cut" }); void cb.handleCut(); } return true; @@ -310,6 +337,8 @@ export function useAppHotkeys({ onDeleteSelectedKeyframes, onAfterUndoRedo, onToggleRecording, + onGroupSelection, + onUngroupSelection, activeCompPath, forceReloadSdkSession, }: UseAppHotkeysParams) { @@ -403,6 +432,8 @@ export function useAppHotkeys({ onResetKeyframes, onDeleteSelectedKeyframes, onToggleRecording, + onGroupSelection, + onUngroupSelection, leftSidebarRef, domEditSelectionRef, showToast, diff --git a/packages/studio/src/hooks/useDomEditSession.ts b/packages/studio/src/hooks/useDomEditSession.ts index 96ffdbc09d..fbdba55ce9 100644 --- a/packages/studio/src/hooks/useDomEditSession.ts +++ b/packages/studio/src/hooks/useDomEditSession.ts @@ -1,4 +1,5 @@ import { useCallback } from "react"; +import { trackStudioEvent } from "../utils/studioTelemetry"; import type { TimelineElement } from "../player"; import type { ImportedFontAsset } from "../components/editor/fontAssets"; import type { EditHistoryKind } from "../utils/editHistory"; @@ -12,6 +13,7 @@ import { useAskAgentModal } from "./useAskAgentModal"; import { useDomSelection } from "./useDomSelection"; import { usePreviewInteraction } from "./usePreviewInteraction"; import { useDomEditCommits } from "./useDomEditCommits"; +import { useGroupCommits } from "./useGroupCommits"; import { useGsapScriptCommits } from "./useGsapScriptCommits"; import { useGsapCacheVersion } from "./useGsapTweenCache"; import { useDomEditWiring } from "./useDomEditWiring"; @@ -114,7 +116,10 @@ export function useDomEditSession({ domEditSelection, domEditGroupSelections, domEditHoverSelection, + activeGroupElement, domEditSelectionRef, + domEditGroupSelectionsRef, + setActiveGroupElement, applyDomSelection, clearDomSelection, buildDomSelectionFromTarget, @@ -279,6 +284,44 @@ export function useDomEditSession({ : undefined, }); + // ── Element groups (wrap selected elements in a data-hf-group div) ── + + const { groupSelection, ungroupSelection } = useGroupCommits({ + activeCompPath, + showToast, + writeProjectFile, + domEditSaveTimestampRef, + editHistory, + projectIdRef, + reloadPreview, + clearDomSelection, + forceReloadSdkSession, + }); + + const handleGroupSelection = useCallback(() => { + const group = domEditGroupSelectionsRef.current; + const single = domEditSelectionRef.current; + const members = group.length > 0 ? group : single ? [single] : []; + if (members.length < 2) { + showToast("Select at least 2 elements to group", "info"); + return; + } + trackStudioEvent("group", { action: "create", count: members.length }); + void groupSelection(members); + }, [domEditGroupSelectionsRef, domEditSelectionRef, groupSelection, showToast]); + + const handleUngroupSelection = useCallback(() => { + const sel = domEditSelectionRef.current; + if (!sel?.element.hasAttribute("data-hf-group")) { + showToast("Select a group to ungroup", "info"); + return; + } + // Dissolving the group exits any drill-in (the wrapper is about to vanish). + trackStudioEvent("group", { action: "ungroup" }); + setActiveGroupElement(null); + void ungroupSelection(sel); + }, [domEditSelectionRef, ungroupSelection, setActiveGroupElement, showToast]); + // ── Wiring: selection sync, GSAP cache, preview sync, selection handlers ── const { @@ -360,6 +403,7 @@ export function useDomEditSession({ resolveDomSelectionFromPreviewPoint, resolveAllDomSelectionsFromPreviewPoint, updateDomEditHoverSelection, + setActiveGroupElement, onClickToSource, }); @@ -435,6 +479,7 @@ export function useDomEditSession({ domEditSelection, domEditGroupSelections, domEditHoverSelection, + activeGroupElement, agentModalOpen, agentModalAnchorPoint, copiedAgentPrompt, @@ -467,6 +512,9 @@ export function useDomEditSession({ handleBlockedDomMove, handleDomManualDragStart, handleDomEditElementDelete, + handleGroupSelection, + handleUngroupSelection, + setActiveGroupElement, buildDomSelectionFromTarget, buildDomSelectionForTimelineElement, updateDomEditHoverSelection, diff --git a/packages/studio/src/hooks/useDomSelection.ts b/packages/studio/src/hooks/useDomSelection.ts index 6c9fc36ab6..9dfccc9440 100644 --- a/packages/studio/src/hooks/useDomSelection.ts +++ b/packages/studio/src/hooks/useDomSelection.ts @@ -48,13 +48,16 @@ export interface UseDomSelectionReturn { domEditSelection: DomEditSelection | null; domEditGroupSelections: DomEditSelection[]; domEditHoverSelection: DomEditSelection | null; + activeGroupElement: HTMLElement | null; // Refs domEditSelectionRef: React.MutableRefObject; domEditGroupSelectionsRef: React.MutableRefObject; domEditHoverSelectionRef: React.MutableRefObject; + activeGroupElementRef: React.MutableRefObject; // State setters (needed by useDomEditSession for agent-prompt reset flows) setDomEditSelection: React.Dispatch>; setDomEditGroupSelections: React.Dispatch>; + setActiveGroupElement: (el: HTMLElement | null) => void; // Callbacks applyDomSelection: ( selection: DomEditSelection | null, @@ -67,12 +70,20 @@ export interface UseDomSelectionReturn { clearDomSelection: () => void; buildDomSelectionFromTarget: ( target: HTMLElement, - options?: { preferClipAncestor?: boolean }, + options?: { + preferClipAncestor?: boolean; + skipSourceProbe?: boolean; + activeGroupElement?: HTMLElement | null; + }, ) => Promise; resolveDomSelectionFromPreviewPoint: ( clientX: number, clientY: number, - options?: { preferClipAncestor?: boolean }, + options?: { + preferClipAncestor?: boolean; + skipSourceProbe?: boolean; + activeGroupElement?: HTMLElement | null; + }, ) => Promise; resolveAllDomSelectionsFromPreviewPoint: ( clientX: number, @@ -110,17 +121,21 @@ export function useDomSelection({ const [domEditSelection, setDomEditSelection] = useState(null); const [domEditGroupSelections, setDomEditGroupSelections] = useState([]); const [domEditHoverSelection, setDomEditHoverSelection] = useState(null); + // The data-hf-group wrapper the user has drilled into (null = top level). + const [activeGroupElement, setActiveGroupElementState] = useState(null); // ── Refs ── const domEditSelectionRef = useRef(domEditSelection); const domEditGroupSelectionsRef = useRef(domEditGroupSelections); const domEditHoverSelectionRef = useRef(domEditHoverSelection); + const activeGroupElementRef = useRef(activeGroupElement); // Keep refs in sync with state domEditSelectionRef.current = domEditSelection; domEditGroupSelectionsRef.current = domEditGroupSelections; domEditHoverSelectionRef.current = domEditHoverSelection; + activeGroupElementRef.current = activeGroupElement; // ── Callbacks ── @@ -178,6 +193,15 @@ export function useDomSelection({ setDomEditSelection(nextSelection); setDomEditGroupSelections(nextGroup); + // Selecting something outside the drilled-into group exits the drill-in, so + // a later click on the group selects it as a unit again (non-sticky drill-in). + const activeGroup = activeGroupElementRef.current; + if (activeGroup && nextSelection && !activeGroup.contains(nextSelection.element)) { + activeGroupElementRef.current = null; + setActiveGroupElementState(null); + } + + if (nextSelection) { if (options?.revealPanel !== false) { setRightCollapsed(false); @@ -203,16 +227,36 @@ export function useDomSelection({ applyDomSelection(null, { revealPanel: false }); }, [applyDomSelection]); + // Drill into / out of a group. Changing scope clears the current selection so + // the user isn't left with an out-of-scope element selected. + const setActiveGroupElement = useCallback( + (el: HTMLElement | null) => { + setActiveGroupElementState(el); + applyDomSelection(null, { revealPanel: false }); + }, + [applyDomSelection], + ); + const buildDomSelectionFromTarget = useCallback( ( target: HTMLElement, - options?: { preferClipAncestor?: boolean; skipSourceProbe?: boolean }, + options?: { + preferClipAncestor?: boolean; + skipSourceProbe?: boolean; + // Override the drill-in scope (used by canvas double-click to resolve the + // child inside a group before the activeGroupElement state has re-rendered). + activeGroupElement?: HTMLElement | null; + }, ) => { return resolveDomEditSelection(target, { activeCompositionPath: activeCompPath, isMasterView, preferClipAncestor: options?.preferClipAncestor, skipSourceProbe: options?.skipSourceProbe, + activeGroupElement: + options && "activeGroupElement" in options + ? options.activeGroupElement + : activeGroupElementRef.current, projectId, }); }, @@ -224,7 +268,11 @@ export function useDomSelection({ async ( clientX: number, clientY: number, - options?: { preferClipAncestor?: boolean; skipSourceProbe?: boolean }, + options?: { + preferClipAncestor?: boolean; + skipSourceProbe?: boolean; + activeGroupElement?: HTMLElement | null; + }, ) => { const iframe = previewIframeRef.current; if (!iframe || captionEditMode) return null; @@ -235,10 +283,19 @@ export function useDomSelection({ } const target = getPreviewTargetFromPointer(iframe, clientX, clientY, activeCompPath); if (!target) return null; - return buildDomSelectionFromTarget(target, { - preferClipAncestor: options?.preferClipAncestor, - skipSourceProbe: options?.skipSourceProbe, - }); + return buildDomSelectionFromTarget( + target, + options && "activeGroupElement" in options + ? { + preferClipAncestor: options.preferClipAncestor, + skipSourceProbe: options.skipSourceProbe, + activeGroupElement: options.activeGroupElement, + } + : { + preferClipAncestor: options?.preferClipAncestor, + skipSourceProbe: options?.skipSourceProbe, + }, + ); }, [activeCompPath, buildDomSelectionFromTarget, captionEditMode, previewIframeRef], ); @@ -445,7 +502,12 @@ export function useDomSelection({ if (!domEditSelectionInGroup(nextGroup, s)) nextGroup = [...nextGroup, s]; } } else { - nextGroup = selections; + // Dedupe by target: under select-as-unit several marquee'd members collapse + // to the same group, which must count as one selection, not many duplicates. + nextGroup = []; + for (const s of selections) { + if (!domEditSelectionInGroup(nextGroup, s)) nextGroup.push(s); + } } const nextSelection = additive && current ? current : selections[0]; domEditSelectionRef.current = nextSelection; @@ -478,13 +540,16 @@ export function useDomSelection({ domEditSelection, domEditGroupSelections, domEditHoverSelection, + activeGroupElement, // Refs domEditSelectionRef, domEditGroupSelectionsRef, domEditHoverSelectionRef, + activeGroupElementRef, // State setters setDomEditSelection, setDomEditGroupSelections, + setActiveGroupElement, // Callbacks applyDomSelection, clearDomSelection, diff --git a/packages/studio/src/hooks/useEnableKeyframes.test.ts b/packages/studio/src/hooks/useEnableKeyframes.test.ts index 3594a57983..47826780ac 100644 --- a/packages/studio/src/hooks/useEnableKeyframes.test.ts +++ b/packages/studio/src/hooks/useEnableKeyframes.test.ts @@ -167,4 +167,39 @@ describe("promoteSetToKeyframes — auto endpoint", () => { expect(kfs[1].percentage).toBe(100); expect(kfs[1].auto).toBeUndefined(); }); + + it("playhead AT the set (t <= setStart) drops a single 0% keyframe, not a no-op", async () => { + // Regression: enabling keyframes on a `gsap.set` element at t=0 (set start 0) + // returned early (`t <= setStart`) → nothing created. Must give a 0% keyframe. + let committed: Record | undefined; + const session = { + commitMutation: async (mutation: Record) => { + committed = mutation; + }, + } as unknown as EnableKeyframesSession; + const sel = { + id: "box", + selector: "#box", + sourceFile: "index.html", + element: { isConnected: true } as unknown as HTMLElement, + } as unknown as DomEditSelection; + const iframe = { + contentWindow: { gsap: { getProperty: () => -1091 } }, + } as unknown as HTMLIFrameElement; + const setAnim = anim({ + id: "#box-set-0-position", + targetSelector: "#box", + method: "set", + global: true, + resolvedStart: 0, + properties: { x: -1091, y: 280 }, + }); + + await promoteSetToKeyframes(session, sel, setAnim, 0, iframe); + + const kfs = committed?.keyframes as Array<{ percentage: number }>; + expect(committed?.type).toBe("replace-with-keyframes"); + expect(kfs).toHaveLength(1); + expect(kfs[0].percentage).toBe(0); + }); }); diff --git a/packages/studio/src/hooks/useEnableKeyframes.ts b/packages/studio/src/hooks/useEnableKeyframes.ts index 634f545e8a..3c9558ab1a 100644 --- a/packages/studio/src/hooks/useEnableKeyframes.ts +++ b/packages/studio/src/hooks/useEnableKeyframes.ts @@ -253,7 +253,35 @@ export async function promoteSetToKeyframes( ): Promise { const selector = selectorFromSelection(sel); const setStart = resolveTweenStart(setAnim) ?? 0; - if (!selector || !session.commitMutation || t <= setStart) return; + if (!selector || !session.commitMutation) return; + // Playhead at or before the set → there's no forward range to promote into. + // Instead of doing nothing (which read as "can't add a keyframe at 0"), replace + // the set with a single keyframe at the playhead holding its value, matching the + // no-animation branch: one diamond the user can build motion from. + if (t <= setStart) { + const position = readElementPosition(iframe, sel, setAnim); + if (Object.keys(position).length === 0) { + for (const key of Object.keys(setAnim.properties ?? {})) { + const held = setAnim.properties?.[key]; + if (typeof held === "number") position[key] = held; + } + } + if (Object.keys(position).length === 0) return; + const range = resolveNewTweenRange(sel.dataAttributes?.start, sel.dataAttributes?.duration, t); + await session.commitMutation( + { + type: "replace-with-keyframes", + animationId: setAnim.id, + targetSelector: selector, + position: roundTo3(range.start), + duration: roundTo3(range.duration), + keyframes: [{ percentage: 0, properties: position }], + ease: setAnim.ease, + }, + { label: "Enable keyframes", softReload: true }, + ); + return; + } const endPosition = readElementPosition(iframe, sel, setAnim); if (Object.keys(endPosition).length === 0) return; const startPosition: Record = {}; diff --git a/packages/studio/src/hooks/useGroupCommits.ts b/packages/studio/src/hooks/useGroupCommits.ts new file mode 100644 index 0000000000..144117eede --- /dev/null +++ b/packages/studio/src/hooks/useGroupCommits.ts @@ -0,0 +1,188 @@ +import { useCallback } from "react"; +import { saveProjectFilesWithHistory } from "../utils/studioFileHistory"; +import { createStudioSaveHttpError } from "../utils/studioSaveDiagnostics"; +import { buildDomEditPatchTarget, type DomEditSelection } from "../components/editor/domEditing"; +import type { EditHistoryKind } from "../utils/editHistory"; + +interface RecordEditInput { + label: string; + kind: EditHistoryKind; + coalesceKey?: string; + files: Record; +} + +interface UseGroupCommitsParams { + activeCompPath: string | null; + showToast: (message: string, tone?: "error" | "info") => void; + writeProjectFile: (path: string, content: string) => Promise; + domEditSaveTimestampRef: React.MutableRefObject; + editHistory: { recordEdit: (entry: RecordEditInput) => Promise }; + projectIdRef: React.MutableRefObject; + reloadPreview: () => void; + clearDomSelection: () => void; + /** Resync the SDK session after a server-side write (the wrapper/unwrap changes + * structure the in-memory doc doesn't know about). */ + forceReloadSdkSession?: () => void; +} + +interface PatchTarget { + id?: string | null; + hfId?: string; + selector?: string; + selectorIndex?: number; +} + +interface GroupGeometry { + bbox: { left: number; top: number; width: number; height: number }; + targets: PatchTarget[]; + rebases: Array<{ target: PatchTarget; left: number; top: number }>; +} + +// Wrapper sits at the members' bounding box top-left; each member is rebased so +// its absolute position is unchanged. offsetLeft/Top are layout coordinates in +// composition space (transforms excluded), exactly the space the rebase formula +// `left_new = left_old - W.left` operates in — GSAP x/y and offset vars are +// transform deltas and stay correct without adjustment. +function computeGroupGeometry(members: DomEditSelection[]): GroupGeometry { + const boxes = members.map((m) => ({ + target: buildDomEditPatchTarget(m), + left: m.element.offsetLeft, + top: m.element.offsetTop, + right: m.element.offsetLeft + m.element.offsetWidth, + bottom: m.element.offsetTop + m.element.offsetHeight, + })); + const left = Math.min(...boxes.map((b) => b.left)); + const top = Math.min(...boxes.map((b) => b.top)); + const width = Math.max(...boxes.map((b) => b.right)) - left; + const height = Math.max(...boxes.map((b) => b.bottom)) - top; + return { + bbox: { left, top, width, height }, + targets: boxes.map((b) => b.target), + rebases: boxes.map((b) => ({ target: b.target, left: b.left - left, top: b.top - top })), + }; +} + +// Shared read → mutate-route → save-with-history → reload pipeline for both +// wrap (group) and unwrap (ungroup). Mirrors the structural-mutation pattern in +// useElementLifecycleOps (delete). Returns the route's JSON, or throws. +async function commitStructuralMutation( + pid: string, + targetPath: string, + route: "wrap-elements" | "unwrap-elements", + body: unknown, + label: string, + deps: Pick< + UseGroupCommitsParams, + | "writeProjectFile" + | "editHistory" + | "domEditSaveTimestampRef" + | "clearDomSelection" + | "forceReloadSdkSession" + | "reloadPreview" + >, +): Promise<{ content?: string; groupId?: string }> { + const response = await fetch(`/api/projects/${pid}/files/${encodeURIComponent(targetPath)}`); + if (!response.ok) { + throw await createStudioSaveHttpError(response, `Failed to read ${targetPath}`); + } + const data = (await response.json()) as { content?: string }; + const originalContent = data.content; + if (typeof originalContent !== "string") { + throw new Error(`Missing file contents for ${targetPath}`); + } + + deps.domEditSaveTimestampRef.current = Date.now(); + const mutateResponse = await fetch( + `/api/projects/${pid}/file-mutations/${route}/${encodeURIComponent(targetPath)}`, + { + method: "POST", + headers: { "Content-Type": "application/json" }, + body: JSON.stringify(body), + }, + ); + if (!mutateResponse.ok) { + const errBody = (await mutateResponse.json().catch(() => null)) as { error?: string } | null; + throw new Error(errBody?.error ?? `Failed to ${label.toLowerCase()} in ${targetPath}`); + } + const mutateData = (await mutateResponse.json()) as { content?: string; groupId?: string }; + const patchedContent = + typeof mutateData.content === "string" ? mutateData.content : originalContent; + + await saveProjectFilesWithHistory({ + projectId: pid, + label, + kind: "manual", + files: { [targetPath]: patchedContent }, + readFile: async () => originalContent, + writeFile: deps.writeProjectFile, + recordEdit: deps.editHistory.recordEdit, + }); + deps.clearDomSelection(); + deps.forceReloadSdkSession?.(); + deps.reloadPreview(); + return mutateData; +} + +export function useGroupCommits(params: UseGroupCommitsParams) { + const { activeCompPath, showToast, projectIdRef } = params; + + const groupSelection = useCallback( + async (members: DomEditSelection[]): Promise => { + const pid = projectIdRef.current; + if (!pid || members.length === 0) return null; + + // All members must live in the same source file — the wrapper is one node + // in one document. (Cross-file grouping is out of scope.) + const targetPath = members[0].sourceFile || activeCompPath || "index.html"; + if (members.some((m) => (m.sourceFile || activeCompPath || "index.html") !== targetPath)) { + showToast("Can't group elements from different files", "error"); + return null; + } + + // Auto-name "Group N" by the count of existing groups in the document. + const doc = members[0].element.ownerDocument; + const groupId = `Group ${doc.querySelectorAll("[data-hf-group]").length + 1}`; + const { bbox, targets, rebases } = computeGroupGeometry(members); + + try { + const data = await commitStructuralMutation( + pid, + targetPath, + "wrap-elements", + { targets, groupId, bbox, rebases }, + "Group elements", + params, + ); + return data.groupId ?? groupId; + } catch (error) { + showToast(error instanceof Error ? error.message : "Failed to group elements", "error"); + return null; + } + }, + [activeCompPath, projectIdRef, showToast, params], + ); + + const ungroupSelection = useCallback( + async (group: DomEditSelection): Promise => { + const pid = projectIdRef.current; + if (!pid) return; + const targetPath = group.sourceFile || activeCompPath || "index.html"; + + try { + await commitStructuralMutation( + pid, + targetPath, + "unwrap-elements", + { target: buildDomEditPatchTarget(group) }, + "Ungroup elements", + params, + ); + } catch (error) { + showToast(error instanceof Error ? error.message : "Failed to ungroup elements", "error"); + } + }, + [activeCompPath, projectIdRef, showToast, params], + ); + + return { groupSelection, ungroupSelection }; +} diff --git a/packages/studio/src/hooks/useGsapSelectionHandlers.ts b/packages/studio/src/hooks/useGsapSelectionHandlers.ts index d3d795e37b..2ac7698f71 100644 --- a/packages/studio/src/hooks/useGsapSelectionHandlers.ts +++ b/packages/studio/src/hooks/useGsapSelectionHandlers.ts @@ -2,6 +2,7 @@ import { useCallback, useRef } from "react"; import type { DomEditSelection } from "../components/editor/domEditing"; import { usePlayerStore } from "../player"; import { trackStudioSaveFailure } from "../utils/studioSaveDiagnostics"; +import { trackStudioEvent } from "../utils/studioTelemetry"; /** * Thin useCallback wrappers that guard on `domEditSelection` before @@ -136,6 +137,7 @@ export function useGsapSelectionHandlers({ (targetSelector: string) => { const sel = domEditSelection ?? lastSelectionRef.current; if (!sel) return; + trackStudioEvent("keyframe", { action: "delete_all" }); deleteAllForSelector(sel, targetSelector); }, [domEditSelection, deleteAllForSelector], @@ -206,6 +208,7 @@ export function useGsapSelectionHandlers({ ) => { const sel = selectionOverride ?? domEditSelection ?? lastSelectionRef.current; if (!sel) return; + trackStudioEvent("keyframe", { action: "add", property }); addKeyframe(sel, animId, percentage, property, value); }, [domEditSelection, addKeyframe], @@ -224,6 +227,7 @@ export function useGsapSelectionHandlers({ (animId: string, percentage: number, selectionOverride?: DomEditSelection | null) => { const sel = selectionOverride ?? domEditSelection ?? lastSelectionRef.current; if (!sel) return; + trackStudioEvent("keyframe", { action: "remove" }); removeKeyframe(sel, animId, percentage); }, [domEditSelection, removeKeyframe], diff --git a/packages/studio/src/hooks/useGsapTweenCache.ts b/packages/studio/src/hooks/useGsapTweenCache.ts index 985511271c..97053f6376 100644 --- a/packages/studio/src/hooks/useGsapTweenCache.ts +++ b/packages/studio/src/hooks/useGsapTweenCache.ts @@ -176,6 +176,37 @@ export async function fetchParsedAnimations( } } +/** + * Clip-relative timing basis for an element. Sub-composition internals (e.g. pills + * inside a scene) aren't timeline clips themselves — they're derived at expand time + * — so they're absent from `elements`. Without a basis, elDuration defaulted to 1 + * and clip-relative keyframe percentages blew past 100% (rendering off the clip). + * Fall back to the sub-comp HOST's bounds, resolved via domClipChildren (the host's + * data-composition-src is stripped in the rendered DOM, so we can't query it). + */ +function resolveClipTimingBasis( + elementId: string, + sourceFile: string, + elements: ReadonlyArray<{ + domId?: string; + key?: string; + id: string; + start: number; + duration: number; + }>, + domClipChildren: ReadonlyArray<{ id: string; hostId: string }>, +): { elStart: number; elDuration: number } { + const direct = elements.find( + (el) => el.domId === elementId || (el.key ?? el.id) === `${sourceFile}#${elementId}`, + ); + if (direct) return { elStart: direct.start, elDuration: direct.duration }; + const hostId = domClipChildren.find((c) => c.id === elementId)?.hostId; + const host = hostId + ? elements.find((el) => el.domId === hostId || (el.key ?? el.id) === `index.html#${hostId}`) + : undefined; + return { elStart: host?.start ?? 0, elDuration: host?.duration ?? 1 }; +} + export function useGsapAnimationsForElement( projectId: string | null, sourceFile: string, @@ -192,6 +223,11 @@ export function useGsapAnimationsForElement( const [unsupportedTimelinePattern, setUnsupportedTimelinePattern] = useState(false); const lastFetchKeyRef = useRef(""); const retryTimerRef = useRef | null>(null); + // Re-run the per-element cache populate when sub-comp DOM children appear, so a + // sub-comp element gets its host-relative keyframe percentages (not elDuration=1). + const domClipChildrenKey = usePlayerStore((s) => + s.domClipChildren.map((c) => `${c.id}<${c.hostId}`).join("|"), + ); useEffect(() => { const targetKey = target?.id ?? target?.selector ?? ""; @@ -351,12 +387,13 @@ export function useGsapAnimationsForElement( // Resolve the element's time range from the player store so we can // convert tween-relative keyframe percentages to clip-relative ones. - const { elements } = usePlayerStore.getState(); - const timelineEl = elements.find( - (el) => el.domId === elementId || (el.key ?? el.id) === `${sourceFile}#${elementId}`, + const { elements, domClipChildren } = usePlayerStore.getState(); + const { elStart, elDuration } = resolveClipTimingBasis( + elementId, + sourceFile, + elements, + domClipChildren, ); - const elStart = timelineEl?.start ?? 0; - const elDuration = timelineEl?.duration ?? 1; const allKeyframes: Array< GsapKeyframesData["keyframes"][0] & { tweenPercentage?: number; propertyGroup?: string } @@ -419,7 +456,8 @@ export function useGsapAnimationsForElement( // PropertyPanel reads the cache by bare elementId (without sourceFile prefix), // so write a duplicate entry under the bare key for cross-component lookups. setKeyframeCache(elementId, merged); - }, [elementId, sourceFile, animations]); + // eslint-disable-next-line react-hooks/exhaustive-deps + }, [elementId, sourceFile, animations, domClipChildrenKey]); return { animations, multipleTimelines, unsupportedTimelinePattern }; } @@ -442,13 +480,19 @@ export function usePopulateKeyframeCacheForFile( iframeRef?: React.RefObject, ): void { const elementCount = usePlayerStore((s) => s.elements.length); + // Re-run when sub-comp DOM children appear (they supply the host bounds the + // clip-relative keyframe percentages are computed against; without this the + // cache is computed once before they exist and the percentages stay wrong). + const domClipChildrenKey = usePlayerStore((s) => + s.domClipChildren.map((c) => `${c.id}<${c.hostId}`).join("|"), + ); const lastFetchKeyRef = useRef(""); const runtimeScanDoneRef = useRef(""); const astFetchDoneRef = useRef(""); useEffect(() => { - const fetchKey = `kf-cache:${projectId}:${sourceFile}:${version}:${elementCount}`; + const fetchKey = `kf-cache:${projectId}:${sourceFile}:${version}:${elementCount}:${domClipChildrenKey}`; if (fetchKey === lastFetchKeyRef.current) return; lastFetchKeyRef.current = fetchKey; runtimeScanDoneRef.current = ""; @@ -461,7 +505,7 @@ export function usePopulateKeyframeCacheForFile( if (!parsed) return; const { setKeyframeCache } = usePlayerStore.getState(); clearKeyframeCacheForFile(sf); - const { elements } = usePlayerStore.getState(); + const { elements, domClipChildren } = usePlayerStore.getState(); const doc = iframeRef?.current?.contentDocument; const mergedByElement = new Map(); for (const anim of parsed.animations) { @@ -482,11 +526,7 @@ export function usePopulateKeyframeCacheForFile( // Attribute the tween to every element it animates (handles class / // group / descendant selectors, not just `#id`). for (const id of resolveSelectorElementIds(anim.targetSelector, doc)) { - const timelineEl = elements.find( - (el) => el.domId === id || (el.key ?? el.id) === `${sf}#${id}`, - ); - const elStart = timelineEl?.start ?? 0; - const elDuration = timelineEl?.duration ?? 1; + const { elStart, elDuration } = resolveClipTimingBasis(id, sf, elements, domClipChildren); const clipKeyframes = kfData.keyframes.map((kf) => { const absTime = toAbsoluteTime(tweenPos, tweenDur, kf.percentage); // 0.001% precision (matching useGsapAnimationsForElement above) so a @@ -524,7 +564,7 @@ export function usePopulateKeyframeCacheForFile( // iframeRef is read for DOM selector resolution but intentionally not a dep // (it's a stable ref; the separate runtime-scan effect owns iframe timing). // eslint-disable-next-line react-hooks/exhaustive-deps - }, [projectId, sourceFile, version, elementCount]); + }, [projectId, sourceFile, version, elementCount, domClipChildrenKey]); // Separate effect for runtime keyframe discovery — polls until the iframe // has loaded GSAP timelines, independent of the AST fetch lifecycle. diff --git a/packages/studio/src/hooks/usePreviewInteraction.ts b/packages/studio/src/hooks/usePreviewInteraction.ts index d3887ff713..b11b6b80a8 100644 --- a/packages/studio/src/hooks/usePreviewInteraction.ts +++ b/packages/studio/src/hooks/usePreviewInteraction.ts @@ -3,6 +3,7 @@ import { liveTime, usePlayerStore } from "../player"; import { pauseStudioPreviewPlayback } from "../utils/studioPreviewHelpers"; import { STUDIO_PREVIEW_SELECTION_ENABLED } from "../components/editor/manualEditingAvailability"; import { type DomEditSelection } from "../components/editor/domEditing"; +import { trackStudioEvent } from "../utils/studioTelemetry"; // ── Types ── @@ -20,13 +21,19 @@ export interface UsePreviewInteractionParams { resolveDomSelectionFromPreviewPoint: ( clientX: number, clientY: number, - options?: { preferClipAncestor?: boolean; skipSourceProbe?: boolean }, + options?: { + preferClipAncestor?: boolean; + skipSourceProbe?: boolean; + activeGroupElement?: HTMLElement | null; + }, ) => Promise; resolveAllDomSelectionsFromPreviewPoint: ( clientX: number, clientY: number, ) => Promise; updateDomEditHoverSelection: (selection: DomEditSelection | null) => void; + /** Drill into a group (double-click on the canvas) so its children become selectable. */ + setActiveGroupElement: (el: HTMLElement | null) => void; onClickToSource?: (selection: DomEditSelection) => void; } @@ -41,6 +48,12 @@ interface ClickCycleState { const CYCLE_RADIUS_PX = 6; const CYCLE_WINDOW_MS = 600; +// Manual double-click window. `e.detail` can't be trusted here: the first click +// selects the group and re-renders the overlay, so the second click lands on a +// fresh element and the browser's native click-counter resets to 1 — drill-in +// (which keyed off `e.detail >= 2`) never fired. We track time+position instead. +const DOUBLE_CLICK_MS = 400; +const DOUBLE_CLICK_RADIUS_PX = 6; // ── Hook ── @@ -53,15 +66,47 @@ export function usePreviewInteraction({ resolveDomSelectionFromPreviewPoint, resolveAllDomSelectionsFromPreviewPoint, updateDomEditHoverSelection, + setActiveGroupElement, onClickToSource, }: UsePreviewInteractionParams) { const cycleRef = useRef(null); + const lastDownRef = useRef<{ t: number; x: number; y: number } | null>(null); const handlePreviewCanvasMouseDown = useCallback( // fallow-ignore-next-line complexity async (e: React.MouseEvent, options?: { preferClipAncestor?: boolean }) => { if (!STUDIO_PREVIEW_SELECTION_ENABLED || captionEditMode || compositionLoading) return; + // Manual double-click detection (see DOUBLE_CLICK_MS): the first click + // re-renders the overlay so `e.detail` never reaches 2 on the canvas. + const downTs = Date.now(); + const lastDown = lastDownRef.current; + const isDoubleClick = + e.detail >= 2 || + (lastDown != null && + downTs - lastDown.t < DOUBLE_CLICK_MS && + Math.hypot(e.clientX - lastDown.x, e.clientY - lastDown.y) < DOUBLE_CLICK_RADIUS_PX); + lastDownRef.current = { t: downTs, x: e.clientX, y: e.clientY }; + + // Double-click a group → drill into it and select the child under the + // pointer (resolve with the group as the explicit drill-in scope, since the + // activeGroupElement state hasn't re-rendered yet within this handler). + if (isDoubleClick && !e.shiftKey) { + const hit = await resolveDomSelectionFromPreviewPoint(e.clientX, e.clientY); + if (hit?.element.hasAttribute("data-hf-group")) { + e.preventDefault(); + e.stopPropagation(); + cycleRef.current = null; + trackStudioEvent("group", { action: "drill_in" }); + setActiveGroupElement(hit.element); + const child = await resolveDomSelectionFromPreviewPoint(e.clientX, e.clientY, { + activeGroupElement: hit.element, + }); + applyDomSelection(child ?? hit); + return; + } + } + const now = Date.now(); const prev = cycleRef.current; const dx = prev ? e.clientX - prev.x : Infinity; @@ -96,9 +141,21 @@ export function usePreviewInteraction({ } // Fresh click — resolve topmost element - const nextSelection = await resolveDomSelectionFromPreviewPoint(e.clientX, e.clientY, { + let nextSelection = await resolveDomSelectionFromPreviewPoint(e.clientX, e.clientY, { preferClipAncestor: options?.preferClipAncestor ?? false, }); + // A null result while drilled into a group means the click landed OUTSIDE that + // group (resolveGroupCapture → out-of-scope). Drill-in isn't sticky: exit it and + // re-resolve at the top level so this click selects whatever's there (or the + // group as a unit). Without this, a stale drill-in keeps selecting children and + // the "first click selects the group" expectation breaks. + if (!nextSelection) { + setActiveGroupElement(null); + nextSelection = await resolveDomSelectionFromPreviewPoint(e.clientX, e.clientY, { + preferClipAncestor: options?.preferClipAncestor ?? false, + activeGroupElement: null, + }); + } if (!nextSelection) { cycleRef.current = null; applyDomSelection(null, { revealPanel: false }); @@ -125,6 +182,7 @@ export function usePreviewInteraction({ onClickToSource, resolveAllDomSelectionsFromPreviewPoint, resolveDomSelectionFromPreviewPoint, + setActiveGroupElement, ], ); diff --git a/packages/studio/src/player/components/ShortcutsPanel.tsx b/packages/studio/src/player/components/ShortcutsPanel.tsx index 88c21eb370..52b39e7506 100644 --- a/packages/studio/src/player/components/ShortcutsPanel.tsx +++ b/packages/studio/src/player/components/ShortcutsPanel.tsx @@ -36,6 +36,8 @@ const SHORTCUT_SECTIONS = [ { key: "⌘V", label: "Paste element" }, { key: "⌘X", label: "Cut element" }, { key: "S", label: "Split clip at playhead" }, + { key: "⌘G", label: "Group elements" }, + { key: "⌘⇧G", label: "Ungroup" }, { key: "Del", label: "Delete selected element" }, ], }, diff --git a/packages/studio/src/player/components/Timeline.tsx b/packages/studio/src/player/components/Timeline.tsx index b63589912d..2e2bc2cb3a 100644 --- a/packages/studio/src/player/components/Timeline.tsx +++ b/packages/studio/src/player/components/Timeline.tsx @@ -20,7 +20,6 @@ import { type KeyframeDiamondContextMenuState, } from "./KeyframeDiamondContextMenu"; import { useTimelineClipDrag } from "./useTimelineClipDrag"; -import { snapKeyframePctToBeat } from "./timelineEditing"; import { ClipContextMenu } from "./ClipContextMenu"; import { GUTTER, @@ -87,7 +86,6 @@ export const Timeline = memo(function Timeline({ onDeleteKeyframe, onDeleteAllKeyframes, onChangeKeyframeEase, - onMoveKeyframe, } = useResolvedTimelineEditCallbacks({ onMoveElement: onMoveElementOverride, onResizeElement: onResizeElementOverride, @@ -481,19 +479,6 @@ export const Timeline = memo(function Timeline({ onShiftClickKeyframe={(elId, pct) => { toggleSelectedKeyframe(`${elId}:${pct}`); }} - onDragKeyframe={(el, oldPct, newPct) => { - onMoveKeyframe?.(el, oldPct, newPct); - }} - onSnapKeyframePct={(el, pct) => - snapKeyframePctToBeat(el, pct, adjustedBeatAnalysis?.beatTimes, pps) - } - onPickKeyframeElement={(el) => { - const elKey = el.key ?? el.id; - if (selectedElementId !== elKey) { - setSelectedElementId(elKey); - onSelectElement?.(el); - } - }} onContextMenuKeyframe={(e, elId, pct) => { const el = expandedElements.find((x) => (x.key ?? x.id) === elId); if (el) { diff --git a/packages/studio/src/player/components/TimelineCanvas.tsx b/packages/studio/src/player/components/TimelineCanvas.tsx index fa12a9f1e1..9bdab10aee 100644 --- a/packages/studio/src/player/components/TimelineCanvas.tsx +++ b/packages/studio/src/player/components/TimelineCanvas.tsx @@ -91,11 +91,6 @@ interface TimelineCanvasProps { currentTime: number; onClickKeyframe?: (element: TimelineElement, percentage: number) => void; onShiftClickKeyframe?: (elementId: string, percentage: number) => void; - onDragKeyframe?: (element: TimelineElement, oldPct: number, newPct: number) => void; - /** Snap a keyframe's clip-relative % to the nearest beat (returns unchanged when none in range). */ - onSnapKeyframePct?: (element: TimelineElement, pct: number) => number; - /** Select the element when a keyframe drag starts (loads its GSAP session). */ - onPickKeyframeElement?: (element: TimelineElement) => void; onContextMenuKeyframe?: (e: React.MouseEvent, elementId: string, percentage: number) => void; onContextMenuClip?: (e: React.MouseEvent, element: TimelineElement) => void; beatAnalysis?: MusicBeatAnalysis | null; @@ -143,9 +138,6 @@ export const TimelineCanvas = memo(function TimelineCanvas({ currentTime, onClickKeyframe, onShiftClickKeyframe, - onDragKeyframe, - onSnapKeyframePct, - onPickKeyframeElement, onContextMenuKeyframe, onContextMenuClip, beatAnalysis, @@ -446,11 +438,6 @@ export const TimelineCanvas = memo(function TimelineCanvas({ selectedKeyframes={selectedKeyframes} onClickKeyframe={(pct) => onClickKeyframe?.(previewElement, pct)} onShiftClickKeyframe={onShiftClickKeyframe} - onDragKeyframe={(oldPct, newPct) => - onDragKeyframe?.(previewElement, oldPct, newPct) - } - snapPct={(pct) => onSnapKeyframePct?.(previewElement, pct) ?? pct} - onPickForDrag={() => onPickKeyframeElement?.(previewElement)} onContextMenuKeyframe={onContextMenuKeyframe} /> )} diff --git a/packages/studio/src/player/components/TimelineClipDiamonds.tsx b/packages/studio/src/player/components/TimelineClipDiamonds.tsx index d396e54f8a..84885f754a 100644 --- a/packages/studio/src/player/components/TimelineClipDiamonds.tsx +++ b/packages/studio/src/player/components/TimelineClipDiamonds.tsx @@ -1,4 +1,4 @@ -import { memo, useEffect, useRef, useState } from "react"; +import { memo } from "react"; import { BEAT_BAND_H } from "./BeatStrip"; interface KeyframeEntry { @@ -28,15 +28,7 @@ interface TimelineClipDiamondsProps { selectedKeyframes: Set; onClickKeyframe?: (percentage: number) => void; onShiftClickKeyframe?: (elementId: string, percentage: number) => void; - onDragKeyframe?: (percentage: number, newPercentage: number) => void; onContextMenuKeyframe?: (e: React.MouseEvent, elementId: string, percentage: number) => void; - /** Snap a clip-relative percentage to the nearest beat (returns it unchanged - * when no beat is within range). Drives live beat-snapping while dragging. */ - snapPct?: (percentage: number) => number; - /** Select this element when a keyframe drag begins, so its GSAP session is - * loaded by the time the move commits (diamonds render on unselected clips - * too, and a drag suppresses the selecting click). */ - onPickForDrag?: () => void; } const DIAMOND_RATIO = 0.8; @@ -59,54 +51,8 @@ export const TimelineClipDiamonds = memo(function TimelineClipDiamonds({ selectedKeyframes, onClickKeyframe, onShiftClickKeyframe, - onDragKeyframe, onContextMenuKeyframe, - snapPct, - onPickForDrag, }: TimelineClipDiamondsProps) { - // Live drag: which keyframe (by original %) is being dragged and its current - // (beat-snapped) %, so the diamond + its connecting lines follow the cursor. - const dragRef = useRef<{ origPct: number; pct: number; moved: boolean } | null>(null); - const [drag, setDrag] = useState<{ origPct: number; pct: number } | null>(null); - // Commit through the latest callback, not the one captured at pointer-down: - // selecting the element on drag-start loads its GSAP session asynchronously, - // and the commit must use the closure that sees the loaded session. - const onDragKeyframeRef = useRef(onDragKeyframe); - onDragKeyframeRef.current = onDragKeyframe; - // Optimistic hold: after a commit, keep the diamond at the dropped position - // until the cache reflects the change (the file round-trip rewrites - // keyframesData), so it doesn't flash back to the old spot in between. - const pendingRef = useRef(false); - const pendingHeldPctRef = useRef(null); - const pendingTimerRef = useRef | null>(null); - // Cleanup for an in-flight drag's document listeners, so an unmount mid-drag - // (clip deleted, comp switch, zoom-out → early return) doesn't leak them. - const dragCleanupRef = useRef<(() => void) | null>(null); - - useEffect(() => { - if (!pendingRef.current) return; - // Only release the optimistic hold once the cache actually reflects the - // committed position (a keyframe near the held %). An unrelated cache - // rebuild (e.g. elementCount change) rebuilds keyframesData with the SAME - // percentages — releasing then would flash the diamond back to the old spot. - const held = pendingHeldPctRef.current; - if (held != null && !keyframesData.keyframes.some((k) => Math.abs(k.percentage - held) < 0.3)) { - return; - } - pendingRef.current = false; - pendingHeldPctRef.current = null; - if (pendingTimerRef.current) clearTimeout(pendingTimerRef.current); - setDrag(null); - }, [keyframesData]); - - useEffect( - () => () => { - clearTimeout(pendingTimerRef.current ?? undefined); - dragCleanupRef.current?.(); - }, - [], - ); - if (clipWidthPx < 20) return null; // When the beat strip occupies the top band, shrink the diamonds and center @@ -129,79 +75,13 @@ export const TimelineClipDiamonds = memo(function TimelineClipDiamonds({ } }; - const handlePointerDown = (e: React.PointerEvent, pct: number) => { - if (e.button !== 0) return; - e.stopPropagation(); - // Ignore a new drag while a prior drop is still settling: `pct` comes from - // props (the pre-drop position) but the diamond is held at its dropped spot - // via effPct(), so a re-grab would track from a stale origin and commit - // against the wrong tween. The hold clears on the cache round-trip (≤2s). - if (pendingRef.current) return; - // Select the element up front so its GSAP session loads during the drag and - // the commit (which resolves the animation from the selection) isn't a no-op. - onPickForDrag?.(); - const startX = e.clientX; - dragRef.current = { origPct: pct, pct, moved: false }; - - const handleMove = (me: PointerEvent) => { - const d = dragRef.current; - if (!d) return; - const dx = me.clientX - startX; - // 4px dead zone so a click doesn't register as a drag. - if (!d.moved && Math.abs(dx) <= 4) return; - d.moved = true; - const rawPct = Math.max(0, Math.min(100, pct + (dx / clipWidthPx) * 100)); - const snapped = snapPct ? snapPct(rawPct) : rawPct; - d.pct = snapped; - setDrag({ origPct: pct, pct: snapped }); - }; - - const handleUp = () => { - document.removeEventListener("pointermove", handleMove); - document.removeEventListener("pointerup", handleUp); - dragCleanupRef.current = null; - const d = dragRef.current; - dragRef.current = null; - const willCommit = !!(d && d.moved && Math.abs(d.pct - d.origPct) > 0.5); - if (willCommit && d) { - // Hold the dropped position optimistically; the effect clears it once the - // cache round-trip lands (fallback timeout in case it never does). - pendingRef.current = true; - pendingHeldPctRef.current = d.pct; - setDrag({ origPct: d.origPct, pct: d.pct }); - if (pendingTimerRef.current) clearTimeout(pendingTimerRef.current); - pendingTimerRef.current = setTimeout(() => { - pendingRef.current = false; - pendingHeldPctRef.current = null; - setDrag(null); - }, 2000); - onDragKeyframeRef.current?.(d.origPct, d.pct); - } else { - setDrag(null); - } - }; - - dragCleanupRef.current = () => { - document.removeEventListener("pointermove", handleMove); - document.removeEventListener("pointerup", handleUp); - }; - - document.addEventListener("pointermove", handleMove); - document.addEventListener("pointerup", handleUp); - }; - - const effPct = (p: number): number => (drag && drag.origPct === p ? drag.pct : p); - return (
{sorted.map((kf, i) => { if (i === 0) return null; const prev = sorted[i - 1]!; - const x1 = Math.max( - 0, - Math.min(clipWidthPx, (effPct(prev.percentage) / 100) * clipWidthPx), - ); - const x2 = Math.max(0, Math.min(clipWidthPx, (effPct(kf.percentage) / 100) * clipWidthPx)); + const x1 = Math.max(0, Math.min(clipWidthPx, (prev.percentage / 100) * clipWidthPx)); + const x2 = Math.max(0, Math.min(clipWidthPx, (kf.percentage / 100) * clipWidthPx)); if (x2 - x1 < 1) return null; return (
handleClick(e, kf.percentage)} - onPointerDown={(e) => handlePointerDown(e, kf.percentage)} onContextMenu={(e) => { e.preventDefault(); e.stopPropagation(); diff --git a/packages/studio/src/player/components/timelineCallbacks.ts b/packages/studio/src/player/components/timelineCallbacks.ts index 9202acc304..a219e2a710 100644 --- a/packages/studio/src/player/components/timelineCallbacks.ts +++ b/packages/studio/src/player/components/timelineCallbacks.ts @@ -39,6 +39,5 @@ export interface TimelineEditCallbacks { onDeleteKeyframe?: (elementId: string, percentage: number) => void; onDeleteAllKeyframes?: (elementId: string) => void; onChangeKeyframeEase?: (elementId: string, percentage: number, ease: string) => void; - onMoveKeyframe?: (element: TimelineElement, oldPct: number, newPct: number) => void; onToggleKeyframeAtPlayhead?: (element: TimelineElement) => void; } diff --git a/packages/studio/src/player/hooks/useExpandedTimelineElements.test.ts b/packages/studio/src/player/hooks/useExpandedTimelineElements.test.ts index a8399dee8e..9afeb90a3f 100644 --- a/packages/studio/src/player/hooks/useExpandedTimelineElements.test.ts +++ b/packages/studio/src/player/hooks/useExpandedTimelineElements.test.ts @@ -122,4 +122,43 @@ describe("buildExpandedElements", () => { expect(child.key).toBe("index.html#eyebrow"); expect(child.key).toBe(expectedStoreKey); }); + + // Sub-comp internals (group + pills) have no data-start, so they're not in the + // manifest. They arrive as DOM children and must still expand under their host. + it("expands DOM-only sub-comp children (no manifest clip) under the host", () => { + const elements = [el({ id: "scene-host", start: 5, duration: 6, compositionSrc: "scene.html" })]; + const manifest = [clip({ id: "scene-host", start: 5, duration: 6, compositionSrc: "scene.html" })]; + // pill-3 selected → parent group-1 → host scene-host. None of group-1/pills + // are in the manifest; they're DOM children with parent links. + const parentMap = new Map([ + ["group-1", "scene-host"], + ["pill-1", "group-1"], + ["pill-2", "group-1"], + ["pill-3", "group-1"], + ]); + const domClipChildren = [ + { id: "group-1", parentId: "scene-host", hostId: "scene-host", label: "Group 1" }, + { id: "pill-1", parentId: "group-1", hostId: "scene-host", label: "pill-1" }, + { id: "pill-2", parentId: "group-1", hostId: "scene-host", label: "pill-2" }, + { id: "pill-3", parentId: "group-1", hostId: "scene-host", label: "pill-3" }, + ]; + + // Expanding pill-3's siblings: topLevel scene-host, immediate parent group-1. + const out = buildExpandedElements( + elements, + manifest, + parentMap, + "scene-host", + "group-1", + domClipChildren, + ); + const pills = out.filter((e) => e.domId?.startsWith("pill-")); + expect(pills).toHaveLength(3); + // Children span the host's bounds and rebase onto the host's file. + expect(pills[0]!.start).toBe(5); + expect(pills[0]!.duration).toBe(6); + expect(pills[0]!.sourceFile).toBe("scene.html"); + // The host row is replaced by its children. + expect(out.some((e) => e.domId === "scene-host")).toBe(false); + }); }); diff --git a/packages/studio/src/player/hooks/useExpandedTimelineElements.ts b/packages/studio/src/player/hooks/useExpandedTimelineElements.ts index 9649b2af74..e657c3b988 100644 --- a/packages/studio/src/player/hooks/useExpandedTimelineElements.ts +++ b/packages/studio/src/player/hooks/useExpandedTimelineElements.ts @@ -1,5 +1,5 @@ import { useMemo } from "react"; -import { usePlayerStore, type TimelineElement } from "../store/playerStore"; +import { usePlayerStore, type TimelineElement, type DomClipChild } from "../store/playerStore"; import type { ClipManifestClip } from "../lib/playbackTypes"; import { createTimelineElementFromManifestClip } from "../lib/timelineDOM"; import { buildTimelineElementKey } from "../lib/timelineElementHelpers"; @@ -111,6 +111,32 @@ function buildChildElements( return result; } +// Sub-comp DOM children (groups/pills) aren't manifest clips and have no timing +// of their own — they're "always on" within their sub-comp host, so synthesize +// clips spanning the host's full bounds. The host element supplies start/duration +// and the composition file edits write to. +function domSiblingClips( + domClipChildren: DomClipChild[], + siblingParentId: string, + host: TimelineElement, +): ClipManifestClip[] { + return domClipChildren + .filter((c) => c.parentId === siblingParentId) + .map((c) => ({ + id: c.id, + label: c.label, + start: host.start, + duration: host.duration, + track: host.track, + kind: "element" as const, + tagName: null, + compositionId: null, + parentCompositionId: host.id ?? null, + compositionSrc: host.compositionSrc ?? null, + assetUrl: null, + })); +} + // Exported for tests. export function buildExpandedElements( elements: TimelineElement[], @@ -118,11 +144,20 @@ export function buildExpandedElements( parentMap: Map, topLevelId: string, siblingParentId: string, + domClipChildren: DomClipChild[] = [], ): TimelineElement[] { const topLevelElement = elements.find((el) => el.id === topLevelId || el.domId === topLevelId); if (!topLevelElement) return filterToTopLevel(elements, parentMap); - const siblings = manifest.filter((c) => c.id != null && parentMap.get(c.id) === siblingParentId); + // Prefer real manifest children; fall back to DOM-only sub-comp children + // (groups/pills) that have no data-start and thus never enter the manifest. + const siblings = (() => { + const fromManifest = manifest.filter( + (c) => c.id != null && parentMap.get(c.id) === siblingParentId, + ); + if (fromManifest.length > 0) return fromManifest; + return domSiblingClips(domClipChildren, siblingParentId, topLevelElement); + })(); if (siblings.length === 0) return filterToTopLevel(elements, parentMap); // The sub-comp host the children actually live in: top-level host for 1-level @@ -154,6 +189,7 @@ export function useExpandedTimelineElements(): TimelineElement[] { const elements = usePlayerStore((s) => s.elements); const clipManifest = usePlayerStore((s) => s.clipManifest); const clipParentMap = usePlayerStore((s) => s.clipParentMap); + const domClipChildren = usePlayerStore((s) => s.domClipChildren); const selectedElementId = usePlayerStore((s) => s.selectedElementId); return useMemo(() => { @@ -166,6 +202,13 @@ export function useExpandedTimelineElements(): TimelineElement[] { const immediateParent = clipParentMap.get(rawId)!; const topLevel = findTopLevelAncestor(rawId, clipParentMap) ?? immediateParent; - return buildExpandedElements(elements, clipManifest, clipParentMap, topLevel, immediateParent); - }, [elements, clipManifest, clipParentMap, selectedElementId]); + return buildExpandedElements( + elements, + clipManifest, + clipParentMap, + topLevel, + immediateParent, + domClipChildren, + ); + }, [elements, clipManifest, clipParentMap, domClipChildren, selectedElementId]); } diff --git a/packages/studio/src/player/hooks/useTimelineSyncCallbacks.ts b/packages/studio/src/player/hooks/useTimelineSyncCallbacks.ts index 5abbc32445..c943680b09 100644 --- a/packages/studio/src/player/hooks/useTimelineSyncCallbacks.ts +++ b/packages/studio/src/player/hooks/useTimelineSyncCallbacks.ts @@ -10,7 +10,7 @@ import { useCallback } from "react"; import { liveTime, usePlayerStore } from "../store/playerStore"; -import type { TimelineElement } from "../store/playerStore"; +import type { TimelineElement, DomClipChild } from "../store/playerStore"; import type { PlaybackAdapter, ClipManifestClip, IframeWindow } from "../lib/playbackTypes"; import { parseTimelineFromDOM, @@ -85,8 +85,8 @@ export function useTimelineSyncCallbacks({ | (Window & { __clipTree?: import("@hyperframes/core/runtime/clipTree").ClipTree }) | null; const clipTree = iframeWin?.__clipTree; + const parentMap = new Map(); if (clipTree) { - const parentMap = new Map(); const walk = (nodes: typeof clipTree.roots) => { for (const node of nodes) { if (node.id && node.parentId) parentMap.set(node.id, node.parentId); @@ -94,11 +94,50 @@ export function useTimelineSyncCallbacks({ } }; walk(clipTree.roots); - usePlayerStore.getState().setClipParentMap(parentMap); } + + // Descend into each sub-composition host: its internal elements (group + // wrappers + their children) carry no `data-start`, so the clip + // tree/manifest never enumerate them. Surface them studio-side as DOM + // children + parent links so the timeline can expand a sub-comp/group + // row to show them. Manifest stays lean (timed clips only). + const domClipChildren: DomClipChild[] = []; + if (iframeDoc) { + for (const clip of data.clips) { + if (clip.kind !== "composition" || !clip.id) continue; + const hostEl = iframeDoc.getElementById(clip.id); + if (!hostEl) continue; + const hostId = clip.id; + const innerRoot = hostEl.querySelector("[data-hf-inner-root]") ?? hostEl; + // Collect the sub-comp's id'd descendants (grouped OR ungrouped) so they + // expand into timeline rows. Descends through id-less structural wrappers + // (the inlined sub-comp body), and one level into groups for drill-in. + const collect = (parentEl: Element, parentId: string) => { + for (const child of Array.from(parentEl.children)) { + if (!child.id) { + collect(child, parentId); // unwrap id-less structural containers + continue; + } + const isGroup = child.hasAttribute("data-hf-group"); + domClipChildren.push({ + id: child.id, + parentId, + hostId, + label: isGroup ? child.getAttribute("data-hf-group") || child.id : child.id, + }); + parentMap.set(child.id, parentId); + if (isGroup) collect(child, child.id); + } + }; + collect(innerRoot, hostId); + } + } + usePlayerStore.getState().setClipParentMap(parentMap); + usePlayerStore.getState().setDomClipChildren(domClipChildren); } catch { - // cross-origin or __clipTree not available — parentMap stays empty + // cross-origin or __clipTree not available — maps stay empty } + const usedHostEls = new Set(); const els: TimelineElement[] = filtered.map((clip, index) => { const hostEl = iframeDoc diff --git a/packages/studio/src/player/store/playerStore.ts b/packages/studio/src/player/store/playerStore.ts index 3003283d7e..4f108b2650 100644 --- a/packages/studio/src/player/store/playerStore.ts +++ b/packages/studio/src/player/store/playerStore.ts @@ -165,6 +165,23 @@ interface PlayerState { setClipManifest: (clips: ClipManifestClip[] | null) => void; clipParentMap: Map; setClipParentMap: (map: Map) => void; + /** + * Sub-composition DOM descendants (groups + their children) that have no + * `data-start`, so they're absent from the clip manifest/tree. Collected + * studio-side from the live preview so the timeline can expand a sub-comp row + * to show its DOM-only children. Keeps the manifest lean (timed clips only). + */ + domClipChildren: DomClipChild[]; + setDomClipChildren: (children: DomClipChild[]) => void; +} + +/** A sub-comp DOM-only timeline child (no data-start) and its nesting context. */ +export interface DomClipChild { + id: string; + parentId: string; + /** The manifest sub-comp host clip id this descendant ultimately lives under. */ + hostId: string; + label: string; } interface BeatHistoryEntry { @@ -296,6 +313,8 @@ export const usePlayerStore = create((set, get) => ({ setClipManifest: (clips) => set({ clipManifest: clips }), clipParentMap: new Map(), setClipParentMap: (map) => set({ clipParentMap: map }), + domClipChildren: [], + setDomClipChildren: (children) => set({ domClipChildren: children }), setIsPlaying: (playing) => { if (get().isPlaying === playing) return; @@ -380,6 +399,7 @@ export const usePlayerStore = create((set, get) => ({ beatPersist: null, clipManifest: null, clipParentMap: new Map(), + domClipChildren: [], }), })); diff --git a/packages/studio/src/utils/studioPreviewHelpers.ts b/packages/studio/src/utils/studioPreviewHelpers.ts index 2ec9911bc5..455844b8bc 100644 --- a/packages/studio/src/utils/studioPreviewHelpers.ts +++ b/packages/studio/src/utils/studioPreviewHelpers.ts @@ -81,6 +81,37 @@ function removePointerEventsOverride(style: HTMLStyleElement | null): void { } } +// Animated group members can move outside their wrapper's static layout box, so +// the empty space inside a group's *visual* bounds (the member-union the overlay +// draws) doesn't hit-test to the group via elementsFromPoint. Recover it: if the +// point falls within a group's live member-union rect, return that wrapper. +// Innermost (smallest-area) group wins for nested groups. +function findGroupAtPoint(doc: Document, x: number, y: number): HTMLElement | null { + let best: HTMLElement | null = null; + let bestArea = Infinity; + for (const group of Array.from(doc.querySelectorAll("[data-hf-group]"))) { + let left = Infinity; + let top = Infinity; + let right = -Infinity; + let bottom = -Infinity; + for (const member of Array.from(group.children)) { + const r = member.getBoundingClientRect(); + if (r.width === 0 && r.height === 0) continue; + left = Math.min(left, r.left); + top = Math.min(top, r.top); + right = Math.max(right, r.right); + bottom = Math.max(bottom, r.bottom); + } + if (right < left || x < left || x > right || y < top || y > bottom) continue; + const area = (right - left) * (bottom - top); + if (area < bestArea) { + bestArea = area; + best = group; + } + } + return best; +} + // fallow-ignore-next-line complexity export function getPreviewTargetFromPointer( iframe: HTMLIFrameElement, @@ -113,6 +144,12 @@ export function getPreviewTargetFromPointer( if (visualTarget) return visualTarget; } + // No element hit (e.g. empty space inside an animated group's overlay) — fall + // back to the group whose member-union contains the point, so the whole group + // area is hoverable/selectable, not just where a member currently sits. + const groupHit = findGroupAtPoint(doc, localPointer.x, localPointer.y); + if (groupHit && getDomLayerPatchTarget(groupHit, activeCompositionPath)) return groupHit; + const fallback = getEventTargetElement(doc.elementFromPoint(localPointer.x, localPointer.y)); if (!fallback || !getDomLayerPatchTarget(fallback, activeCompositionPath)) return null; if (!isElementComputedVisible(fallback)) return null;