From 90b9a18b70a2681a1c7982cd03c60e9cf025ab00 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Miguel=20=C3=81ngel?= Date: Thu, 18 Jun 2026 02:08:23 -0400 Subject: [PATCH] feat(studio,cli): on-canvas GSAP motion-path editing + agent keyframes surfacing MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit STUDIO — edit a selected element's GSAP motion path directly on the canvas: a dashed polyline through its x/y keyframes (or motionPath waypoints) with draggable diamond nodes, in composition coordinates so it tracks GSAP transforms. - Drag a node to rewrite its keyframe/waypoint; click to select a keyframe and park the playhead on it; double-click empty canvas to author a new motionPath; add/remove waypoints inline; right-click a keyframe node for the timeline's delete actions (remove keyframe / remove all). - The overlay spills into the canvas margin without bleeding into the side panels. - Keyframe add/remove key off the tween-relative percentage the GSAP writer expects, so edits from the Design panel, timeline, and motion path all persist and refresh consistently. Array-form keyframes (`keyframes: [...]`) are removable in both the acorn and recast writers. - Dragging a GSAP-animated element folds the offset through gsap.set instead of a conflicting CSS translate; the drag intercept trusts the live runtime so a deleted animation can't be resurrected from a stale parse cache. Gated behind the keyframes flag. CLI — `npx hyperframes keyframes [dir|file]` surfaces every GSAP tween, its keyframes (with absolute times), and an ASCII motion-path drawing so agents can read and edit motion as data instead of guessing at raw numbers. `--selector`, `--json` (machine-readable), filters internal hold markers, draws the path only for genuine multi-point/2-axis motion. Ships the `hyperframes-keyframes` skill (surface → read → edit → verify loop). PRODUCER — esbuild ESM banner also shims `__filename`/`__dirname` so bundled CJS deps (e.g. wawoff2) don't throw "__dirname is not defined" during render. --- .zed/settings.json | 222 +++++++ packages/cli/src/cli.ts | 1 + packages/cli/src/commands/keyframes.ts | 385 ++++++++++++ packages/core/src/parsers/gsapConstants.ts | 5 +- packages/core/src/parsers/gsapParser.test.ts | 264 +++++++++ packages/core/src/parsers/gsapParser.ts | 277 ++++++++- .../src/parsers/gsapWriter.parity.test.ts | 51 ++ packages/core/src/parsers/gsapWriterAcorn.ts | 58 +- packages/core/src/runtime/init.ts | 56 +- .../studio-api/helpers/sourceMutation.test.ts | 22 + .../src/studio-api/helpers/sourceMutation.ts | 20 +- packages/core/src/studio-api/routes/files.ts | 103 +++- packages/producer/build.mjs | 18 +- packages/studio/index.html | 10 +- .../studio/src/components/StudioHeader.tsx | 5 +- .../src/components/StudioPreviewArea.tsx | 12 + .../src/components/editor/DomEditOverlay.tsx | 1 + .../editor/KeyframeNavigation.test.ts | 36 ++ .../components/editor/KeyframeNavigation.tsx | 38 +- .../src/components/editor/MotionPathNode.tsx | 98 +++ .../components/editor/MotionPathOverlay.tsx | 559 ++++++++++++++++++ .../editor/domEditOverlayGeometry.ts | 60 ++ .../src/components/editor/manualEditsDom.ts | 94 +-- .../editor/manualEditsDomGsap.test.ts | 82 +++ .../editor/motionPathCommit.test.ts | 118 ++++ .../src/components/editor/motionPathCommit.ts | 67 +++ .../editor/motionPathGeometry.test.ts | 122 ++++ .../components/editor/motionPathGeometry.ts | 102 ++++ .../components/editor/motionPathSelection.ts | 33 ++ .../editor/useDomEditOverlayRects.ts | 4 +- .../studio/src/components/nle/NLELayout.tsx | 2 +- .../src/components/renders/RenderQueue.tsx | 2 +- .../studio/src/hooks/gsapDragCommit.test.ts | 258 ++++++++ packages/studio/src/hooks/gsapDragCommit.ts | 171 ++++-- .../src/hooks/gsapRuntimeBridge.test.ts | 94 +++ .../studio/src/hooks/gsapRuntimeBridge.ts | 35 +- .../src/hooks/gsapRuntimeKeyframes.test.ts | 46 +- .../studio/src/hooks/gsapRuntimeKeyframes.ts | 19 +- packages/studio/src/hooks/gsapShared.test.ts | 30 + packages/studio/src/hooks/gsapShared.ts | 30 +- packages/studio/src/hooks/useDomSelection.ts | 17 +- .../src/hooks/useEnableKeyframes.test.ts | 29 + .../studio/src/hooks/useEnableKeyframes.ts | 50 +- .../studio/src/hooks/useGestureRecording.ts | 78 ++- .../useGsapAnimationFetchFallback.test.ts | 27 + .../hooks/useGsapAnimationFetchFallback.ts | 43 +- .../studio/src/hooks/useGsapAwareEditing.ts | 30 +- .../studio/src/hooks/useGsapScriptCommits.ts | 4 +- .../studio/src/hooks/useGsapTweenCache.ts | 8 +- packages/studio/src/hooks/useRazorSplit.ts | 20 +- .../studio/src/hooks/useStudioContextValue.ts | 5 +- .../hooks/useExpandedTimelineElements.test.ts | 34 ++ .../hooks/useExpandedTimelineElements.ts | 22 +- .../studio/src/player/store/playerStore.ts | 18 +- .../studio/src/utils/studioHelpers.test.ts | 32 +- packages/studio/src/utils/studioHelpers.ts | 26 +- .../src/utils/studioPreviewHelpers.test.ts | 27 +- .../studio/src/utils/studioPreviewHelpers.ts | 36 +- skills/hyperframes-keyframes/SKILL.md | 68 +++ 59 files changed, 3920 insertions(+), 264 deletions(-) create mode 100644 .zed/settings.json create mode 100644 packages/cli/src/commands/keyframes.ts create mode 100644 packages/studio/src/components/editor/KeyframeNavigation.test.ts create mode 100644 packages/studio/src/components/editor/MotionPathNode.tsx create mode 100644 packages/studio/src/components/editor/MotionPathOverlay.tsx create mode 100644 packages/studio/src/components/editor/manualEditsDomGsap.test.ts create mode 100644 packages/studio/src/components/editor/motionPathCommit.test.ts create mode 100644 packages/studio/src/components/editor/motionPathCommit.ts create mode 100644 packages/studio/src/components/editor/motionPathGeometry.test.ts create mode 100644 packages/studio/src/components/editor/motionPathGeometry.ts create mode 100644 packages/studio/src/components/editor/motionPathSelection.ts create mode 100644 packages/studio/src/hooks/gsapDragCommit.test.ts create mode 100644 packages/studio/src/hooks/gsapRuntimeBridge.test.ts create mode 100644 packages/studio/src/hooks/gsapShared.test.ts create mode 100644 packages/studio/src/hooks/useEnableKeyframes.test.ts create mode 100644 packages/studio/src/hooks/useGsapAnimationFetchFallback.test.ts create mode 100644 skills/hyperframes-keyframes/SKILL.md diff --git a/.zed/settings.json b/.zed/settings.json new file mode 100644 index 0000000000..5c9d876433 --- /dev/null +++ b/.zed/settings.json @@ -0,0 +1,222 @@ +// HyperFrames — Zed workspace settings. +// Reference: https://zed.dev/docs/reference/all-settings +// +// House rules baked in (see CLAUDE.md): +// • Formatting/linting is oxfmt + oxlint — NOT prettier, eslint, or biome. +// • Package manager is bun. Indent is 2 spaces. Conventional commits. +// oxfmt runs via the project-local binary in stdin→stdout mode, so format-on-save +// uses the exact same formatter as CI (`bunx oxfmt`), no global install needed. +{ + "$schema": "https://zed.dev/schema/settings/v0.json", + + // ── Appearance ────────────────────────────────────────────────────────── + "theme": { + "mode": "system", + "light": "One Light", + "dark": "One Dark" + }, + "icon_theme": "Zed (Default)", + "buffer_font_family": "Zed Plex Mono", + "buffer_font_size": 14, + "buffer_font_features": { "calt": true, "liga": true }, + "buffer_line_height": "comfortable", + "ui_font_family": "Zed Plex Sans", + "ui_font_size": 15, + "cursor_blink": false, + "current_line_highlight": "all", + "selection_highlight": true, + "unnecessary_code_fade": 0.5, + + // ── Editor behaviour ──────────────────────────────────────────────────── + "tab_size": 2, + "hard_tabs": false, + "soft_wrap": "none", + "preferred_line_length": 100, + "wrap_guides": [100], + "show_wrap_guides": true, + "show_whitespaces": "selection", + "use_autoclose": true, + "auto_save": "off", + "format_on_save": "on", + "remove_trailing_whitespace_on_save": true, + "ensure_final_newline_on_save": true, + "indent_guides": { "enabled": true, "coloring": "indent_aware" }, + "scrollbar": { + "show": "auto", + "git_diff": true, + "selected_text": true, + "selected_symbol": true, + "diagnostics": "all" + }, + "minimap": { + "show": "auto", + "thumb": "hover", + "max_width_columns": 80 + }, + "inlay_hints": { + "enabled": true, + "show_type_hints": true, + "show_parameter_hints": true, + "show_other_hints": true, + "edit_debounce_ms": 700, + "scroll_debounce_ms": 50 + }, + + // ── Formatter: oxfmt (project-local, stdin mode) ──────────────────────── + // Applied per-language to the JS/TS/JSON family only — never prettier. + // The command path is relative to the worktree root. + "formatter": { + "external": { + "command": "node_modules/.bin/oxfmt", + "arguments": ["--stdin-filepath", "{buffer_path}"] + } + }, + + // ── Per-language ──────────────────────────────────────────────────────── + "languages": { + "TypeScript": { + "tab_size": 2, + "formatter": { + "external": { + "command": "node_modules/.bin/oxfmt", + "arguments": ["--stdin-filepath", "{buffer_path}"] + } + } + }, + "TSX": { + "tab_size": 2, + "formatter": { + "external": { + "command": "node_modules/.bin/oxfmt", + "arguments": ["--stdin-filepath", "{buffer_path}"] + } + } + }, + "JavaScript": { + "tab_size": 2, + "formatter": { + "external": { + "command": "node_modules/.bin/oxfmt", + "arguments": ["--stdin-filepath", "{buffer_path}"] + } + } + }, + "JSON": { + "tab_size": 2, + "formatter": { + "external": { + "command": "node_modules/.bin/oxfmt", + "arguments": ["--stdin-filepath", "{buffer_path}"] + } + } + }, + "JSONC": { + "tab_size": 2, + "formatter": { + "external": { + "command": "node_modules/.bin/oxfmt", + "arguments": ["--stdin-filepath", "{buffer_path}"] + } + } + }, + // HyperFrames compositions are hand-authored / exported HTML with inlined + // runtime — never auto-format them (it would mangle the export). Tailwind + // class completion still works via the LSP below. + "HTML": { "tab_size": 2, "format_on_save": "off", "formatter": "language_server" }, + "CSS": { "tab_size": 2, "format_on_save": "off" }, + "Markdown": { + "format_on_save": "off", + "soft_wrap": "editor_width", + "remove_trailing_whitespace_on_save": false + }, + "YAML": { "tab_size": 2, "format_on_save": "off" } + }, + + // ── Language servers ──────────────────────────────────────────────────── + "lsp": { + // TypeScript (vtsls) — richer inlay hints; bump server memory for the monorepo. + "vtsls": { + "settings": { + "typescript": { + "tsserver": { "maxTsServerMemory": 8192 }, + "preferences": { "importModuleSpecifier": "shortest" }, + "inlayHints": { + "parameterNames": { "enabled": "literals" }, + "variableTypes": { "enabled": false }, + "propertyDeclarationTypes": { "enabled": true }, + "functionLikeReturnTypes": { "enabled": true }, + "enumMemberValues": { "enabled": true } + } + } + } + }, + // Tailwind v4 (browser runtime) — recognise class names inside cva/clsx/cn + // helpers and template strings used across the studio UI. + "tailwindcss-language-server": { + "settings": { + "tailwindCSS": { + "experimental": { + "classRegex": [ + ["cva\\(([^)]*)\\)", "[\"'`]([^\"'`]*).*?[\"'`]"], + ["cx\\(([^)]*)\\)", "(?:'|\"|`)([^']*)(?:'|\"|`)"], + ["(?:cn|clsx|twMerge)\\(([^)]*)\\)", "[\"'`]([^\"'`]*)[\"'`]"] + ] + } + } + } + } + }, + + // ── Panels & tabs ─────────────────────────────────────────────────────── + "tabs": { "git_status": true, "file_icons": true, "close_position": "right" }, + "tab_bar": { "show_nav_history_buttons": false }, + "project_panel": { + "dock": "left", + "default_width": 260, + "auto_reveal_entries": true, + "indent_size": 16, + "scrollbar": { "show": "auto" } + }, + "outline_panel": { "dock": "right" }, + "git_panel": { "dock": "left" }, + "collaboration_panel": { "dock": "left" }, + + // ── Git ───────────────────────────────────────────────────────────────── + "git": { + "git_gutter": "tracked_files", + "inline_blame": { "enabled": true, "delay_ms": 600 } + }, + + // ── Terminal (bun-first) ──────────────────────────────────────────────── + "terminal": { + "font_family": "Zed Plex Mono", + "font_size": 13, + "blinking": "off", + "copy_on_select": true, + "env": { "FORCE_COLOR": "1" } + }, + + // ── Search / indexing: skip generated & heavy dirs ────────────────────── + "file_scan_exclusions": [ + "**/.git", + "**/node_modules", + "**/dist", + "**/build", + "**/.turbo", + "**/.next", + "**/coverage", + "**/renders", + "**/*.mp4", + "**/*.webm", + "**/.DS_Store", + "**/Thumbs.db" + ], + "file_types": { + "JSONC": ["tsconfig*.json", ".zed/*.json", ".vscode/*.json", "*.jsonc"] + }, + + // ── Misc ──────────────────────────────────────────────────────────────── + "telemetry": { "metrics": false }, + "auto_update": true, + "restore_on_startup": "last_session" +} diff --git a/packages/cli/src/cli.ts b/packages/cli/src/cli.ts index ffe3c65215..8ac7326099 100644 --- a/packages/cli/src/cli.ts +++ b/packages/cli/src/cli.ts @@ -120,6 +120,7 @@ const commandLoaders = { lint: () => import("./commands/lint.js").then((m) => m.default), beats: () => import("./commands/beats.js").then((m) => m.default), inspect: () => import("./commands/inspect.js").then((m) => m.default), + keyframes: () => import("./commands/keyframes.js").then((m) => m.default), layout: () => import("./commands/layout.js").then((m) => m.default), info: () => import("./commands/info.js").then((m) => m.default), compositions: () => import("./commands/compositions.js").then((m) => m.default), diff --git a/packages/cli/src/commands/keyframes.ts b/packages/cli/src/commands/keyframes.ts new file mode 100644 index 0000000000..c346d265f3 --- /dev/null +++ b/packages/cli/src/commands/keyframes.ts @@ -0,0 +1,385 @@ +import { defineCommand } from "citty"; +import { existsSync, readFileSync, statSync } from "node:fs"; +import { resolve, dirname, basename } from "node:path"; +import { parseGsapScript, type GsapAnimation } from "@hyperframes/core/gsap-parser"; +import type { Example } from "./_examples.js"; +import { c } from "../ui/colors.js"; +import { ensureDOMParser } from "../utils/dom.js"; +import { resolveProject } from "../utils/project.js"; +import { withMeta } from "../utils/updateCheck.js"; + +export const examples: Example[] = [ + ["Surface every keyframe + motion path in the project", "hyperframes keyframes"], + ["Inspect one composition file", "hyperframes keyframes compositions/scene.html"], + ["Machine-readable output for an agent", "hyperframes keyframes --json"], + ["Only one element's tweens", "hyperframes keyframes --selector '#puck-a'"], +]; + +// ── Surfaced shapes ────────────────────────────────────────────────────────── + +interface KeyframePoint { + /** Tween-relative percentage (0–100). */ + pct: number; + /** Absolute timeline time (seconds) = tweenStart + pct/100 * duration. */ + time: number; + properties: Record; +} + +interface SurfacedTween { + id: string; + target: string; + method: string; + group?: string; + start: number; + duration: number; + end: number; + /** "keyframes" (array/object form), "flat" (to/from), or "motionPath". */ + shape: "keyframes" | "flat" | "motionPath"; + keyframes: KeyframePoint[]; + /** x/y position points (gsap offsets) when this tween animates position. */ + path: Array<{ x: number; y: number }> | null; +} + +interface SurfacedComposition { + composition: string; + source: string; + tweens: SurfacedTween[]; +} + +// ── GSAP extraction ────────────────────────────────────────────────────────── + +function inlineScriptText(html: string): string { + const doc = new DOMParser().parseFromString(html, "text/html"); + return Array.from(doc.querySelectorAll("script")) + .filter((s) => !s.getAttribute("src")) + .map((s) => s.textContent ?? "") + .join("\n"); +} + +function num(v: number | string | undefined): number | null { + if (typeof v === "number") return v; + if (typeof v === "string") { + const n = Number.parseFloat(v); + return Number.isFinite(n) ? n : null; + } + return null; +} + +function isPositionTween(anim: GsapAnimation): boolean { + if (anim.propertyGroup === "position") return true; + const has = (p: Record | undefined) => !!p && ("x" in p || "y" in p); + if (has(anim.properties) || has(anim.fromProperties)) return true; + return (anim.keyframes?.keyframes ?? []).some( + (kf) => "x" in kf.properties || "y" in kf.properties, + ); +} + +// The rest-state value for an animated property (what GSAP animates to/from when +// the other endpoint is the element's natural pose): 1 for scale/opacity, 0 for +// translate/rotation. +function baseProps(props: Record): Record { + const base: Record = {}; + for (const k of Object.keys(props)) { + if (k === "ease") continue; + base[k] = k === "opacity" || k.startsWith("scale") ? 1 : 0; + } + return base; +} + +// Flat tweens carry no explicit keyframes — synthesize a 0%/100% pair against the +// element's rest pose so the surface (and ASCII path) is uniform. `from()` goes +// fromProperties → base; `to()` goes base → properties. +function flatKeyframes(anim: GsapAnimation): KeyframePoint[] { + if (anim.method === "fromTo") { + return [ + { pct: 0, time: 0, properties: anim.fromProperties ?? {} }, + { pct: 100, time: 0, properties: anim.properties ?? {} }, + ]; + } + // to()/from() vars both live in anim.properties; from() plays them in reverse + // against the element's rest pose. + const vars = anim.properties ?? {}; + const base = baseProps(vars); + return anim.method === "from" + ? [ + { pct: 0, time: 0, properties: vars }, + { pct: 100, time: 0, properties: base }, + ] + : [ + { pct: 0, time: 0, properties: base }, + { pct: 100, time: 0, properties: vars }, + ]; +} + +// Studio-internal markers that aren't user motion: the position-hold `set` GSAP +// runs before a keyframed position tween (`data: "hf-hold"`). +function isHoldMarker(anim: GsapAnimation): boolean { + return anim.properties?.data === "hf-hold" || anim.fromProperties?.data === "hf-hold"; +} + +// Drop internal / non-visual keys so they don't pollute the surfaced keyframes. +function cleanProps(props: Record): Record { + const out: Record = {}; + for (const [k, v] of Object.entries(props)) { + if (k === "data" || k === "ease") continue; + out[k] = v; + } + return out; +} + +function surfaceTween(anim: GsapAnimation): SurfacedTween { + const start = + typeof anim.resolvedStart === "number" ? anim.resolvedStart : (num(anim.position) ?? 0); + const duration = anim.duration ?? 0; + + let shape: SurfacedTween["shape"]; + let rawKfs: Array<{ percentage: number; properties: Record }>; + if (anim.keyframes?.keyframes?.length) { + shape = "keyframes"; + rawKfs = anim.keyframes.keyframes; + } else if (anim.arcPath?.enabled) { + shape = "motionPath"; + rawKfs = []; + } else { + shape = "flat"; + rawKfs = flatKeyframes(anim).map((k) => ({ percentage: k.pct, properties: k.properties })); + } + + const keyframes: KeyframePoint[] = rawKfs.map((kf) => ({ + pct: kf.percentage, + time: Math.round((start + (kf.percentage / 100) * duration) * 1000) / 1000, + properties: cleanProps(kf.properties), + })); + + // Carry x/y forward across keyframes that only set one axis, so the path is + // continuous (GSAP holds the last value for an unspecified property). + let path: Array<{ x: number; y: number }> | null = null; + if (isPositionTween(anim) && keyframes.length > 0) { + let lastX = 0; + let lastY = 0; + path = keyframes.map((kf) => { + const x = num(kf.properties.x); + const y = num(kf.properties.y); + if (x !== null) lastX = x; + if (y !== null) lastY = y; + return { x: lastX, y: lastY }; + }); + } + + return { + id: anim.id, + target: anim.targetSelector, + method: anim.method, + group: anim.propertyGroup, + start: Math.round(start * 1000) / 1000, + duration, + end: Math.round((start + duration) * 1000) / 1000, + shape, + keyframes, + path, + }; +} + +// ── ASCII motion path ──────────────────────────────────────────────────────── + +/** Plot position points into a compact grid so an agent can SEE the motion + * shape. Each keyframe is marked with its index (0–9, then a–z); the path is + * traced with light dots. Coordinates are GSAP x/y offsets (px). */ +function asciiPath(points: Array<{ x: number; y: number }>, width = 48, height = 11): string[] { + if (points.length === 0) return []; + const xs = points.map((p) => p.x); + const ys = points.map((p) => p.y); + let minX = Math.min(...xs); + let maxX = Math.max(...xs); + let minY = Math.min(...ys); + let maxY = Math.max(...ys); + if (maxX - minX < 1) { + minX -= 1; + maxX += 1; + } + if (maxY - minY < 1) { + minY -= 1; + maxY += 1; + } + const cols = width; + const rows = height; + const toCol = (x: number) => Math.round(((x - minX) / (maxX - minX)) * (cols - 1)); + // Screen y grows downward — invert so up on screen = smaller gsap y. + const toRow = (y: number) => Math.round(((y - minY) / (maxY - minY)) * (rows - 1)); + + const grid: string[][] = Array.from({ length: rows }, () => + Array.from({ length: cols }, () => " "), + ); + // Sparse paths (≤36 pts) index each keyframe (0–9, a–z) so an agent can map a + // mark to a keyframe to edit. Dense paths (gestures) only mark Start/End — the + // shape is the signal; per-point exact values live in the keyframe list / JSON. + const dense = points.length > 36; + const mark = (i: number) => { + if (dense) return i === 0 ? "S" : i === points.length - 1 ? "E" : "·"; + return i < 10 ? String(i) : String.fromCharCode(97 + (i - 10)); + }; + + // Trace segments with dots first, then overwrite endpoints with index marks. + for (let i = 0; i < points.length - 1; i++) { + const c0 = toCol(points[i]!.x); + const r0 = toRow(points[i]!.y); + const c1 = toCol(points[i + 1]!.x); + const r1 = toRow(points[i + 1]!.y); + const steps = Math.max(Math.abs(c1 - c0), Math.abs(r1 - r0), 1); + for (let s = 1; s < steps; s++) { + const cc = Math.round(c0 + ((c1 - c0) * s) / steps); + const rr = Math.round(r0 + ((r1 - r0) * s) / steps); + if (grid[rr]![cc] === " ") grid[rr]![cc] = "·"; + } + } + points.forEach((p, i) => { + grid[toRow(p.y)]![toCol(p.x)] = mark(i); + }); + + const top = ` ┌${"─".repeat(cols)}┐`; + const body = grid.map((row) => ` │${row.join("")}│`); + const bottom = ` └${"─".repeat(cols)}┘`; + const legend = dense ? "S→E, · path" : "marks 0..n = keyframe order"; + const axis = ` x ${Math.round(minX)}..${Math.round(maxX)} y ${Math.round(minY)}..${Math.round(maxY)} (gsap px; ${legend})`; + return [top, ...body, bottom, c.dim(axis)]; +} + +// ── Composition surfacing ──────────────────────────────────────────────────── + +function surfaceComposition(html: string, label: string, source: string): SurfacedComposition { + const script = inlineScriptText(html); + let animations: GsapAnimation[] = []; + try { + animations = parseGsapScript(script).animations; + } catch { + animations = []; + } + return { + composition: label, + source, + tweens: animations.filter((a) => !isHoldMarker(a)).map(surfaceTween), + }; +} + +function collectCompositions(indexPath: string): SurfacedComposition[] { + const html = readFileSync(indexPath, "utf-8"); + const baseDir = dirname(indexPath); + const out: SurfacedComposition[] = [ + surfaceComposition(html, basename(indexPath), basename(indexPath)), + ]; + + const doc = new DOMParser().parseFromString(html, "text/html"); + for (const div of Array.from(doc.querySelectorAll("[data-composition-src]"))) { + const src = div.getAttribute("data-composition-src"); + if (!src) continue; + const subPath = resolve(baseDir, src); + if (!existsSync(subPath)) continue; + const id = div.getAttribute("data-composition-id") ?? src; + out.push(surfaceComposition(readFileSync(subPath, "utf-8"), id, src)); + } + return out; +} + +// ── Render (human) ─────────────────────────────────────────────────────────── + +// Plot the ASCII grid only for genuine motion paths — multi-keyframe, or a path +// that moves on BOTH axes. Simple 2-point single-axis slides (entrances, a flat +// `to(x)`) are clear enough from the keyframe line alone. +function shouldPlotPath(path: Array<{ x: number; y: number }>): boolean { + const xs = path.map((p) => p.x); + const ys = path.map((p) => p.y); + const xVaries = Math.max(...xs) - Math.min(...xs) > 0.5; + const yVaries = Math.max(...ys) - Math.min(...ys) > 0.5; + const distinct = new Set(path.map((p) => `${p.x},${p.y}`)).size; + return distinct > 2 || (xVaries && yVaries); +} + +function fmtProps(props: Record): string { + return Object.entries(props) + .filter(([k]) => k !== "ease") + .map(([k, v]) => `${k}:${v}`) + .join(" "); +} + +function printTween(t: SurfacedTween): void { + const timing = c.dim(`@${t.start}s→${t.end}s (${t.duration}s)`); + const group = t.group ? c.dim(` ${t.group}`) : ""; + console.log(` ${c.accent(t.target)}${group} ${c.dim(t.method)}/${t.shape} ${timing}`); + if (t.shape === "motionPath") { + console.log(c.dim(` motionPath arc (${t.keyframes.length} stops)`)); + } else { + const kfLine = t.keyframes.map((k) => `${k.pct}% {${fmtProps(k.properties)}}`).join(" "); + console.log(` ${c.dim(kfLine)}`); + } + if (t.path && shouldPlotPath(t.path)) { + for (const line of asciiPath(t.path)) console.log(line); + } + console.log(); +} + +// ── Command ────────────────────────────────────────────────────────────────── + +export default defineCommand({ + meta: { + name: "keyframes", + description: "Surface every GSAP tween, keyframe, and motion path for agent-driven editing", + }, + args: { + target: { + type: "positional", + description: "Project dir or composition .html", + required: false, + }, + selector: { type: "string", description: "Only tweens matching this CSS selector" }, + json: { type: "boolean", description: "Machine-readable JSON (for agents)", default: false }, + }, + async run({ args }) { + ensureDOMParser(); + + // Accept either a project directory or a single .html file. + const raw = args.target?.trim(); + let comps: SurfacedComposition[]; + let projectName: string; + if (raw && raw.endsWith(".html") && existsSync(raw) && statSync(raw).isFile()) { + comps = [surfaceComposition(readFileSync(raw, "utf-8"), basename(raw), raw)]; + projectName = basename(raw); + } else { + const project = resolveProject(raw); + comps = collectCompositions(project.indexPath); + projectName = project.name; + } + + if (args.selector) { + const sel = args.selector; + comps = comps + .map((cmp) => ({ + ...cmp, + tweens: cmp.tweens.filter((t) => t.target.split(",").some((s) => s.trim() === sel)), + })) + .filter((cmp) => cmp.tweens.length > 0); + } + + if (args.json) { + console.log(JSON.stringify(withMeta({ project: projectName, compositions: comps }), null, 2)); + return; + } + + const total = comps.reduce((n, cmp) => n + cmp.tweens.length, 0); + if (total === 0) { + console.log(`${c.success("◇")} ${c.accent(projectName)} ${c.dim("— no GSAP tweens found")}`); + return; + } + console.log( + `${c.success("◇")} ${c.accent(projectName)} ${c.dim("—")} ${c.dim(`${total} tween${total === 1 ? "" : "s"}`)}`, + ); + console.log(); + for (const cmp of comps) { + if (cmp.tweens.length === 0) continue; + console.log(c.bold(`${cmp.composition}`) + c.dim(` (${cmp.source})`)); + for (const t of cmp.tweens) printTween(t); + } + console.log( + c.dim("Tip: edit the keyframes: [...] / x/y values in source, then re-run to verify."), + ); + }, +}); diff --git a/packages/core/src/parsers/gsapConstants.ts b/packages/core/src/parsers/gsapConstants.ts index 0153623956..5976e4b664 100644 --- a/packages/core/src/parsers/gsapConstants.ts +++ b/packages/core/src/parsers/gsapConstants.ts @@ -77,7 +77,10 @@ export function classifyTweenPropertyGroup( ): PropertyGroupName | undefined { const groups = new Set(); for (const key of Object.keys(properties)) { - if (key === "transformOrigin") continue; + // transformOrigin is a modifier; `_auto` is Studio's internal endpoint marker; + // `data` is GSAP-reserved (carries the Studio hold-set tag). None is an animated + // property, so none should affect the group. + if (key === "transformOrigin" || key === "_auto" || key === "data") continue; const g = classifyPropertyGroup(key); groups.add(g); } diff --git a/packages/core/src/parsers/gsapParser.test.ts b/packages/core/src/parsers/gsapParser.test.ts index 7f56fd0a71..b940c661f0 100644 --- a/packages/core/src/parsers/gsapParser.test.ts +++ b/packages/core/src/parsers/gsapParser.test.ts @@ -14,11 +14,16 @@ import { addKeyframeToScript, removeKeyframeFromScript, updateKeyframeInScript, + updateMotionPathPointInScript, + addMotionPathPointInScript, + removeMotionPathPointInScript, + addMotionPathToScript, convertToKeyframesInScript, removeAllKeyframesFromScript, addAnimationWithKeyframesToScript, splitAnimationsInScript, splitIntoPropertyGroups, + syncPositionHoldsBeforeKeyframes, shiftPositionsInScript, scalePositionsInScript, } from "./gsapParser.js"; @@ -483,6 +488,12 @@ describe("property group classification", () => { ); }); + it("ignores the internal `_auto` endpoint marker when classifying", () => { + // Regression: the `_auto: 1` sentinel on auto-generated endpoint keyframes must + // not pull a position tween into a mixed group, or drag-intercept can't resolve it. + expect(classifyTweenPropertyGroup({ x: 100, y: 50, _auto: 1 })).toBe("position"); + }); + it("returns undefined for mixed-group tweens", () => { expect(classifyTweenPropertyGroup({ x: 100, scale: 0.5 })).toBeUndefined(); expect(classifyTweenPropertyGroup({ x: 100, opacity: 0 })).toBeUndefined(); @@ -1560,6 +1571,98 @@ describe("keyframe mutations", () => { expect(kfs[1].properties.x).toBe(999); }); + // ── backfillDefaults: editing one keyframe must not move the others ────── + // UX invariant (CapCut/AE): keyframes are independent. Introducing a property + // to one keyframe (e.g. `y` on an x-only tween) must backfill the other + // keyframes at the element's base value — otherwise GSAP holds the new prop's + // value across keyframes that omit it, dragging them to the same position. + const X_ONLY_SCRIPT = ` + const tl = gsap.timeline({ paused: true }); + tl.to("#puck", { keyframes: { "0%": { x: 0 }, "100%": { x: -260 } }, duration: 2.2 }, 1.2); + `; + + it("addKeyframeToScript — WITHOUT backfill, the other keyframe omits the new prop (GSAP would hold it)", () => { + const id = getAnimId(X_ONLY_SCRIPT); + const updated = addKeyframeToScript(X_ONLY_SCRIPT, id, 0, { x: 240, y: 780 }); + const kfs = parseGsapScript(updated).animations[0].keyframes!.keyframes; + const kf100 = kfs.find((k) => k.percentage === 100)!; + expect(kf100.properties.x).toBe(-260); + expect("y" in kf100.properties).toBe(false); // <- the bug surface + }); + + it("addKeyframeToScript — WITH backfill, the new prop is added to the other keyframe at base (it stays put)", () => { + const id = getAnimId(X_ONLY_SCRIPT); + const updated = addKeyframeToScript(X_ONLY_SCRIPT, id, 0, { x: 240, y: 780 }, undefined, { + x: 0, + y: 0, + }); + const kfs = parseGsapScript(updated).animations[0].keyframes!.keyframes; + const kf0 = kfs.find((k) => k.percentage === 0)!; + const kf100 = kfs.find((k) => k.percentage === 100)!; + // edited keyframe holds the drag + expect(kf0.properties).toMatchObject({ x: 240, y: 780 }); + // other keyframe keeps its own x and gets y at base (0) — not 780 + expect(kf100.properties.x).toBe(-260); + expect(kf100.properties.y).toBe(0); + }); + + // ── syncPositionHoldsBeforeKeyframes (hold before first keyframe) ──────── + // UX invariant (every NLE): before the first keyframe, the element holds that + // keyframe's value — it must NOT snap to its CSS base then jump when the tween + // starts. Implemented as a tagged `tl.set(...,0)` kept in sync with the tween. + describe("syncPositionHoldsBeforeKeyframes", () => { + const posTweenAt = (start: number) => + `const tl = gsap.timeline({ paused: true });\n` + + `tl.to("#p", { keyframes: { "0%": { x: -1500, y: 700 }, "100%": { x: -260, y: 0 } }, duration: 2.2 }, ${start});`; + + it("inserts a hold set holding the first keyframe's position at t=0", () => { + const out = syncPositionHoldsBeforeKeyframes(posTweenAt(1.2)); + const anims = parseGsapScript(out).animations; + const hold = anims.find((a) => a.method === "set"); + expect(hold).toBeDefined(); + expect(hold!.position).toBe(0); + expect(hold!.properties).toMatchObject({ x: -1500, y: 700 }); + }); + + it("is idempotent (re-running does not stack holds)", () => { + const once = syncPositionHoldsBeforeKeyframes(posTweenAt(1.2)); + expect(syncPositionHoldsBeforeKeyframes(once)).toBe(once); + expect((once.match(/hf-hold/g) ?? []).length).toBe(1); + }); + + it("re-syncs the hold value when the first keyframe changes", () => { + const out1 = syncPositionHoldsBeforeKeyframes(posTweenAt(1.2)); + const moved = updateKeyframeInScript( + out1, + parseGsapScript(out1).animations.find((a) => a.keyframes)!.id, + 0, + { x: 99, y: 88 }, + ); + const out2 = syncPositionHoldsBeforeKeyframes(moved); + const hold = parseGsapScript(out2).animations.find((a) => a.method === "set"); + expect(hold!.properties).toMatchObject({ x: 99, y: 88 }); + expect((out2.match(/hf-hold/g) ?? []).length).toBe(1); // still just one + }); + + it("adds no hold for a tween that already starts at t=0", () => { + expect(syncPositionHoldsBeforeKeyframes(posTweenAt(0))).not.toContain("hf-hold"); + }); + + it("adds no hold for an opacity-only keyframed tween (position-scoped)", () => { + const opacity = + `const tl = gsap.timeline({ paused: true });\n` + + `tl.to("#b", { keyframes: { "0%": { opacity: 0 }, "100%": { opacity: 1 } }, duration: 1 }, 2);`; + expect(syncPositionHoldsBeforeKeyframes(opacity)).not.toContain("hf-hold"); + }); + + it("removes an orphaned hold when its tween is gone", () => { + const withHold = syncPositionHoldsBeforeKeyframes(posTweenAt(1.2)); + const tweenId = parseGsapScript(withHold).animations.find((a) => a.keyframes)!.id; + const deleted = removeAnimationFromScript(withHold, tweenId); + expect(syncPositionHoldsBeforeKeyframes(deleted)).not.toContain("hf-hold"); + }); + }); + // ── _auto endpoint updates ──────────────────────────────────────────── const AUTO_SCRIPT = ` @@ -1681,6 +1784,154 @@ describe("keyframe mutations", () => { expect(kf100.properties.y).toBe(50); }); + // ── updateMotionPathPointInScript ─────────────────────────────────────── + + const MOTION_PATH_SCRIPT = ` + const tl = gsap.timeline({ paused: true }); + tl.to("#el", { + motionPath: { + path: [{x: 0, y: 0}, {x: 200, y: -100}, {x: 400, y: 50}], + curviness: 1.5 + }, + duration: 2 + }, 0); + `; + + it("updateMotionPathPointInScript — moves one waypoint, preserves the rest and curviness", () => { + const id = getAnimId(MOTION_PATH_SCRIPT); + const updated = updateMotionPathPointInScript(MOTION_PATH_SCRIPT, id, 1, { x: 250, y: -140 }); + const reparsed = parseGsapScript(updated); + const anim = reparsed.animations[0]; + const wp = anim.keyframes!.keyframes; + expect(wp.map((k) => [k.properties.x, k.properties.y])).toEqual([ + [0, 0], + [250, -140], + [400, 50], + ]); + expect(anim.arcPath!.segments[0].curviness).toBe(1.5); + expect(anim.arcPath!.segments[1].curviness).toBe(1.5); + }); + + it("updateMotionPathPointInScript — out-of-range index leaves the script unchanged", () => { + const id = getAnimId(MOTION_PATH_SCRIPT); + expect(updateMotionPathPointInScript(MOTION_PATH_SCRIPT, id, 9, { x: 1, y: 1 })).toBe( + MOTION_PATH_SCRIPT, + ); + }); + + it("updateMotionPathPointInScript — unknown animation id leaves the script unchanged", () => { + expect(updateMotionPathPointInScript(MOTION_PATH_SCRIPT, "nope", 0, { x: 1, y: 1 })).toBe( + MOTION_PATH_SCRIPT, + ); + }); + + it("updateMotionPathPointInScript — moves a cubic anchor, keeps control points", () => { + const script = ` + const tl = gsap.timeline({ paused: true }); + tl.to("#el", { + motionPath: { + path: [ + {x: 0, y: 0}, + {x: 50, y: -80}, {x: 150, y: -120}, + {x: 200, y: -100} + ], + type: "cubic" + }, + duration: 2 + }, 0); + `; + const id = getAnimId(script); + const updated = updateMotionPathPointInScript(script, id, 1, { x: 220, y: -130 }); + const reparsed = parseGsapScript(updated); + const anim = reparsed.animations[0]; + // anchor 1 moved; the segment's control points are untouched. + expect(anim.keyframes!.keyframes[1].properties).toMatchObject({ x: 220, y: -130 }); + expect(anim.arcPath!.segments[0].cp1).toEqual({ x: 50, y: -80 }); + expect(anim.arcPath!.segments[0].cp2).toEqual({ x: 150, y: -120 }); + }); + + // ── add/removeMotionPathPointInScript ─────────────────────────────────── + + it("addMotionPathPointInScript — inserts a waypoint between anchors, keeps curviness", () => { + const id = getAnimId(MOTION_PATH_SCRIPT); + const updated = addMotionPathPointInScript(MOTION_PATH_SCRIPT, id, 1, { x: 100, y: -50 }); + const reparsed = parseGsapScript(updated); + const anim = reparsed.animations[0]; + expect(anim.keyframes!.keyframes.map((k) => [k.properties.x, k.properties.y])).toEqual([ + [0, 0], + [100, -50], + [200, -100], + [400, 50], + ]); + // 4 anchors → 3 segments, all curviness 1.5 + expect(anim.arcPath!.segments).toHaveLength(3); + expect(anim.arcPath!.segments.every((s) => s.curviness === 1.5)).toBe(true); + }); + + it("addMotionPathPointInScript — refuses an index at the ends or out of range", () => { + const id = getAnimId(MOTION_PATH_SCRIPT); + expect(addMotionPathPointInScript(MOTION_PATH_SCRIPT, id, 0, { x: 1, y: 1 })).toBe( + MOTION_PATH_SCRIPT, + ); + expect(addMotionPathPointInScript(MOTION_PATH_SCRIPT, id, 3, { x: 1, y: 1 })).toBe( + MOTION_PATH_SCRIPT, + ); + }); + + it("removeMotionPathPointInScript — drops a waypoint, preserves the rest", () => { + const id = getAnimId(MOTION_PATH_SCRIPT); + const updated = removeMotionPathPointInScript(MOTION_PATH_SCRIPT, id, 1); + const reparsed = parseGsapScript(updated); + const anim = reparsed.animations[0]; + expect(anim.keyframes!.keyframes.map((k) => [k.properties.x, k.properties.y])).toEqual([ + [0, 0], + [400, 50], + ]); + expect(anim.arcPath!.segments).toHaveLength(1); + }); + + it("removeMotionPathPointInScript — refuses to drop below two anchors", () => { + const two = ` + const tl = gsap.timeline({ paused: true }); + tl.to("#el", { motionPath: { path: [{x: 0, y: 0}, {x: 400, y: 50}], curviness: 1 }, duration: 2 }, 0); + `; + const id = getAnimId(two); + expect(removeMotionPathPointInScript(two, id, 0)).toBe(two); + }); + + it("add/removeMotionPathPointInScript — leave cubic paths untouched", () => { + const cubic = ` + const tl = gsap.timeline({ paused: true }); + tl.to("#el", { motionPath: { path: [{x:0,y:0},{x:50,y:-80},{x:150,y:-120},{x:200,y:-100}], type: "cubic" }, duration: 2 }, 0); + `; + const id = getAnimId(cubic); + expect(addMotionPathPointInScript(cubic, id, 1, { x: 1, y: 1 })).toBe(cubic); + expect(removeMotionPathPointInScript(cubic, id, 1)).toBe(cubic); + }); + + // ── addMotionPathToScript ─────────────────────────────────────────────── + + it("addMotionPathToScript — authors a new 2-anchor motionPath tween", () => { + const script = ` + const tl = gsap.timeline({ paused: true }); + tl.from("#title", { opacity: 0, duration: 0.5 }, 0); + `; + const { script: updated, id } = addMotionPathToScript(script, "#el", 2.0, 1.5, { + x: 300, + y: -100, + }); + expect(id).not.toBe(""); + const reparsed = parseGsapScript(updated); + const anim = reparsed.animations.find((a) => a.targetSelector === "#el")!; + expect(anim).toBeDefined(); + expect(anim.arcPath!.enabled).toBe(true); + expect(anim.keyframes!.keyframes.map((k) => [k.properties.x, k.properties.y])).toEqual([ + [0, 0], + [300, -100], + ]); + expect(anim.duration).toBe(1.5); + }); + // ── convertToKeyframesInScript ────────────────────────────────────────── it("convertToKeyframesInScript — converts flat to() tween", () => { @@ -1984,6 +2235,19 @@ describe("splitAnimationsInScript", () => { expect(forNew[0]!.position).toBe(opts.splitTime); }); + it("does not pin the clone to from-values for a completed .from() before the split", () => { + // A .from() that finished before the split leaves the element at its natural + // state. Carrying its from-values (opacity:0) into the clone's `set` made the + // clone invisible. The clone should get NO inherited set for those props. + const script = `${baseScript}\ntl.from("#el1", { y: 70, opacity: 0, duration: 0.9 }, 0.4);`; + const result = split(script); + const parsed = parseGsapScript(result); + const forNew = parsed.animations.filter((a) => a.targetSelector === "#el1-split"); + const inheritedSet = forNew.find((a) => a.method === "set"); + expect(inheritedSet).toBeUndefined(); + expect(result).not.toContain("#el1-split"); + }); + it("retargets animation entirely in second half to new element", () => { const script = `${baseScript}\ntl.to("#el1", { x: 100, duration: 1 }, 3);`; const selectors = parseSplitAndAssert(script, (s) => split(s), 1); diff --git a/packages/core/src/parsers/gsapParser.ts b/packages/core/src/parsers/gsapParser.ts index 0f383bc673..82ef593a61 100644 --- a/packages/core/src/parsers/gsapParser.ts +++ b/packages/core/src/parsers/gsapParser.ts @@ -1563,6 +1563,67 @@ function insertInheritedStateSet( return recast.print(parsed.ast).code; } +/** Marker on Studio-emitted pre-keyframe hold `set`s. `data` is a GSAP-reserved + * config key (attached to the tween, never applied to the target), so it carries + * the tag without triggering GSAP's "Invalid property" warning. */ +export const STUDIO_HOLD_MARKER = "hf-hold"; + +/** True for a `tl.set(...)` this module emitted to hold a keyframe before its tween. + * The Studio filters these out so they never appear as user keyframes/diamonds. */ +export function isStudioHoldSet(anim: GsapAnimation): boolean { + return anim.method === "set" && anim.properties?.data === STUDIO_HOLD_MARKER; +} + +/** + * Keep a `tl.set(selector, {x,y}, 0)` "hold" in front of every position-keyframed + * tween that starts after t=0, so the element holds its first keyframe's position + * BEFORE the tween plays instead of snapping to its CSS base (the universal NLE + * "hold before first keyframe" behavior). The set is tagged with `data: "hf-hold"` + * so this pass owns it: every call wipes the prior holds and recomputes from the + * current keyframes, keeping them in sync as keyframes are added/moved/deleted. + * + * Idempotent. Only position props (x/y/xPercent/yPercent) are held — opacity/scale + * keep their authored pre-tween behavior. A tween already starting at 0 needs no + * hold (no gap before it). + */ +export function syncPositionHoldsBeforeKeyframes(script: string): string { + let parsed: ParsedGsap; + try { + parsed = parseGsapScript(script); + } catch { + return script; + } + // 1. Drop every hold this pass previously emitted, so we recompute fresh. + let result = script; + const staleHoldIds = parsed.animations.filter(isStudioHoldSet).map((a) => a.id); + for (const id of staleHoldIds) result = removeAnimationFromScript(result, id); + + // 2. Re-add a hold for each position-keyframed tween that starts after t=0. + let reparsed: ParsedGsap; + try { + reparsed = parseGsapScript(result); + } catch { + return result; + } + for (const anim of reparsed.animations) { + if (!anim.keyframes) continue; + const start = anim.resolvedStart ?? (typeof anim.position === "number" ? anim.position : 0); + if (!(start > 0.001)) continue; + const firstKf = [...anim.keyframes.keyframes].sort((a, b) => a.percentage - b.percentage)[0]; + if (!firstKf) continue; + const posProps: Record = {}; + for (const [k, v] of Object.entries(firstKf.properties)) { + if (classifyPropertyGroup(k) === "position" && typeof v === "number") posProps[k] = v; + } + if (Object.keys(posProps).length === 0) continue; + result = insertInheritedStateSet(result, anim.targetSelector, 0, { + ...posProps, + data: STUDIO_HOLD_MARKER, + }); + } + return result; +} + // ── Split Animation Functions ───────────────────────────────────────────── export interface SplitAnimationsOptions { @@ -1640,8 +1701,16 @@ export function splitAnimationsInScript( } if (animEnd <= opts.splitTime) { - for (const [k, v] of Object.entries(anim.properties)) { - inheritedProps[k] = v; + // A completed .from() reverts the element to its natural state, so its + // recorded properties are the HIDDEN start (e.g. opacity:0), not the + // resting state — clearing them keeps the clone at its natural value + // instead of pinning it to the from-values (which made it invisible). + if (anim.method === "from") { + for (const k of Object.keys(anim.properties)) delete inheritedProps[k]; + } else { + for (const [k, v] of Object.entries(anim.properties)) { + inheritedProps[k] = v; + } } continue; } @@ -1967,6 +2036,43 @@ export function removeKeyframeFromScript( animationId: string, percentage: number, ): string { + // Array-form keyframes (`keyframes: [{x,y}, …]`) have no explicit percentages — + // GSAP distributes them evenly. The object-form path below can't see them + // (findKeyframesObjectNode only matches ObjectExpression), so removing from an + // array-form tween silently no-op'd. Resolve the element by its implicit + // percentage and splice it; collapse to a flat tween when fewer than two remain. + const arrLoc = locateAnimationWithFallback(script, animationId); + // findPropertyNode here returns the property's VALUE node directly. + const arrVal = arrLoc && findPropertyNode(arrLoc.target.call.varsArg, "keyframes"); + if (arrLoc && arrVal?.type === "ArrayExpression") { + const elements: AstNode[] = (arrVal.elements ?? []).filter( + (e: AstNode | null): e is AstNode => !!e && e.type === "ObjectExpression", + ); + const n = elements.length; + if (n === 0) return script; + let matchIdx = -1; + let bestDist = Number.POSITIVE_INFINITY; + for (let i = 0; i < n; i++) { + const pct = n > 1 ? (i / (n - 1)) * 100 : 0; + const dist = Math.abs(pct - percentage); + if (dist <= PCT_TOLERANCE && dist < bestDist) { + matchIdx = i; + bestDist = dist; + } + } + if (matchIdx === -1) return script; + const remaining = elements.filter((_, i) => i !== matchIdx); + if (remaining.length < 2) { + const sole = remaining[0]; + const record = sole ? objectExpressionToRecord(sole, arrLoc.parsed.scope) : {}; + collapseKeyframesToFlat(arrLoc.target.call.varsArg, record); + } else { + const realIdx = arrVal.elements.indexOf(elements[matchIdx]); + arrVal.elements.splice(realIdx, 1); + } + return recast.print(arrLoc.parsed.ast).code; + } + const ctx = locateKeyframeCtx(script, animationId, percentage); if (!ctx) return script; const { loc, kfNode } = ctx; @@ -2346,6 +2452,173 @@ export function updateArcSegmentInScript( return recast.print(loc.parsed.ast).code; } +/** + * Move a single motionPath waypoint (anchor) to a new position. The waypoint + * list is normalized to anchors for both straight and cubic paths, so + * `pointIndex` matches the node order the studio overlay renders; cubic control + * points are preserved. No-op when the animation/arc is missing or the index is + * out of range. + */ +export function updateMotionPathPointInScript( + script: string, + animationId: string, + pointIndex: number, + point: { x: number; y: number }, +): string { + const loc = locateAnimation(script, animationId); + if (!loc) return script; + + const anim = loc.target.animation; + if (!anim.arcPath?.enabled) return script; + + const waypoints = extractArcWaypoints(anim); + if (pointIndex < 0 || pointIndex >= waypoints.length || waypoints.length < 2) return script; + + const nextWaypoints = waypoints.map((wp, i) => + i === pointIndex ? { x: point.x, y: point.y } : wp, + ); + + const motionPathCode = buildMotionPathObjectCode({ + waypoints: nextWaypoints, + segments: anim.arcPath.segments, + autoRotate: anim.arcPath.autoRotate, + }); + + const varsArg = loc.target.call.varsArg; + const existingProp = varsArg.properties.find( + (p: AstNode) => isObjectProperty(p) && propKeyName(p) === "motionPath", + ); + if (existingProp) { + existingProp.value = parseExpr(motionPathCode); + } + + return recast.print(loc.parsed.ast).code; +} + +/** True when any segment carries explicit cubic control points. Add/remove are + * restricted to curviness (non-cubic) paths — synthesizing control points for + * an inserted cubic anchor is out of scope. */ +function hasCubicSegments(segments: ArcPathSegment[]): boolean { + return segments.some((s) => s.cp1 != null || s.cp2 != null); +} + +function writeMotionPathValue( + loc: NonNullable>, + waypoints: Array<{ x: number; y: number }>, + segments: ArcPathSegment[], + autoRotate: boolean | number, +): string { + const motionPathCode = buildMotionPathObjectCode({ waypoints, segments, autoRotate }); + const varsArg = loc.target.call.varsArg; + const existingProp = varsArg.properties.find( + (p: AstNode) => isObjectProperty(p) && propKeyName(p) === "motionPath", + ); + if (existingProp) existingProp.value = parseExpr(motionPathCode); + return recast.print(loc.parsed.ast).code; +} + +/** + * Insert a waypoint at `index` (between existing anchors), splitting the segment + * it lands on so the new neighbor inherits its curviness. Non-cubic paths only. + * No-op for missing animation/arc, out-of-range index, or cubic paths. + */ +export function addMotionPathPointInScript( + script: string, + animationId: string, + index: number, + point: { x: number; y: number }, +): string { + const loc = locateAnimation(script, animationId); + if (!loc) return script; + const anim = loc.target.animation; + if (!anim.arcPath?.enabled || hasCubicSegments(anim.arcPath.segments)) return script; + + const waypoints = extractArcWaypoints(anim); + // Insert strictly between two anchors: index 1..length-1. + if (index < 1 || index > waypoints.length - 1) return script; + + const segments = [...anim.arcPath.segments]; + waypoints.splice(index, 0, { x: point.x, y: point.y }); + const splitCurviness = segments[index - 1]?.curviness ?? 1; + segments.splice(index - 1, 0, { curviness: splitCurviness }); + + return writeMotionPathValue(loc, waypoints, segments, anim.arcPath.autoRotate); +} + +/** + * Remove the waypoint at `index`. Refuses to drop below two anchors (a path + * can't have fewer). Non-cubic paths only. No-op for missing animation/arc, + * out-of-range index, cubic paths, or a 2-point path. + */ +export function removeMotionPathPointInScript( + script: string, + animationId: string, + index: number, +): string { + const loc = locateAnimation(script, animationId); + if (!loc) return script; + const anim = loc.target.animation; + if (!anim.arcPath?.enabled || hasCubicSegments(anim.arcPath.segments)) return script; + + const waypoints = extractArcWaypoints(anim); + if (waypoints.length <= 2 || index < 0 || index >= waypoints.length) return script; + + const segments = [...anim.arcPath.segments]; + waypoints.splice(index, 1); + // Drop the segment on the side that still exists (last anchor → preceding segment). + segments.splice(Math.min(index, segments.length - 1), 1); + + return writeMotionPathValue(loc, waypoints, segments, anim.arcPath.autoRotate); +} + +/** + * Author a fresh 2-anchor motionPath tween on a target element: a straight line + * from the element's home (0,0) to `point`, gentle ease, ready for waypoint + * editing. Mirrors `addAnimationWithKeyframesToScript`. + */ +export function addMotionPathToScript( + script: string, + targetSelector: string, + position: number, + duration: number, + point: { x: number; y: number }, + ease = "power1.inOut", +): { script: string; id: string } { + let parsed: ParsedGsapAst; + try { + parsed = parseGsapAst(script); + } catch (e) { + console.warn("[gsap-parser] addMotionPathToScript parse failed:", e); + return { script, id: "" }; + } + if (parsed.located.length === 0 && parsed.detection.timelineVar === null) { + return { script, id: "" }; + } + + const motionPathCode = buildMotionPathObjectCode({ + waypoints: [ + { x: 0, y: 0 }, + { x: point.x, y: point.y }, + ], + segments: [{ curviness: 1 }], + autoRotate: false, + }); + const selector = JSON.stringify(targetSelector); + const varEntries = [ + `motionPath: ${motionPathCode}`, + `duration: ${valueToCode(duration)}`, + `ease: ${JSON.stringify(ease)}`, + ]; + const stmtCode = `${parsed.timelineVar}.to(${selector}, { ${varEntries.join(", ")} }, ${valueToCode(position)});`; + const newStatement = parseScript(stmtCode).program.body[0]; + insertAfterAnchor(parsed, newStatement); + + const result = recast.print(parsed.ast).code; + const reParsed = parseGsapAst(result); + const newId = reParsed.located[reParsed.located.length - 1]?.id ?? ""; + return { script: result, id: newId }; +} + export function removeArcPathFromScript(script: string, animationId: string): string { return setArcPathInScript(script, animationId, { enabled: false, diff --git a/packages/core/src/parsers/gsapWriter.parity.test.ts b/packages/core/src/parsers/gsapWriter.parity.test.ts index 2a478bb0a5..7ee5537beb 100644 --- a/packages/core/src/parsers/gsapWriter.parity.test.ts +++ b/packages/core/src/parsers/gsapWriter.parity.test.ts @@ -157,6 +157,57 @@ describe("parity: removeAllKeyframesFromScript (recast vs acorn)", () => { }); }); +// Array-form keyframes (`keyframes: [{x,y}, …]`, no explicit %) used to no-op on +// removal in BOTH writers — the object-form path couldn't see the array, so the +// keyframe survived while downstream hold-sync stranded an `hf-hold`. +describe("removeKeyframeFromScript: array-form keyframes (recast + acorn parity)", () => { + const arrayScript = ` + const tl = gsap.timeline({ paused: true }); + tl.to("#p", { + keyframes: [ { x: 0, y: 0 }, { x: -180, y: -60 }, { x: -320, y: 40 }, { x: -460, y: -20 } ], + duration: 3.4, + ease: "power1.inOut" + }, 1.0); + `; + + it("removes the matched element (implicit %) — both writers, parity", () => { + const id = acornId(arrayScript); + expect(parseGsapScript(arrayScript).animations[0]!.id).toBe(id); + + const recastOut = removeKeyframeRecast(arrayScript, id, 67); + const acornOut = removeKeyframeAcorn(arrayScript, id, 67); + + expect(recastOut).not.toBe(arrayScript); + expect(acornOut).not.toBe(arrayScript); + + const recShape = shapeOf(recastOut); + expect(recShape.keyframes?.keyframes.length).toBe(3); + // the 67% element { x: -320, y: 40 } is the one removed + expect(JSON.stringify(recShape.keyframes)).not.toContain("-320"); + expect(modelOf(acornOut)).toEqual(modelOf(recastOut)); + }); + + it("collapses to a flat tween when fewer than two remain — both writers, parity", () => { + const twoScript = ` + const tl = gsap.timeline({ paused: true }); + tl.to("#p", { keyframes: [ { x: 0, y: 0 }, { x: 100, y: 50 } ], duration: 1 }, 0); + `; + const id = acornId(twoScript); + const recastOut = removeKeyframeRecast(twoScript, id, 100); + const acornOut = removeKeyframeAcorn(twoScript, id, 100); + + expect(shapeOf(recastOut).keyframes).toBeUndefined(); + expect(shapeOf(acornOut).keyframes).toBeUndefined(); + expect(modelOf(acornOut)).toEqual(modelOf(recastOut)); + }); + + it("no-op when the percentage matches no element", () => { + const id = acornId(arrayScript); + expect(removeKeyframeAcorn(arrayScript, id, 12)).toBe(arrayScript); + expect(removeKeyframeRecast(arrayScript, id, 12)).toBe(arrayScript); + }); +}); + const CONVERT_FIXTURES: Array<{ name: string; script: string; diff --git a/packages/core/src/parsers/gsapWriterAcorn.ts b/packages/core/src/parsers/gsapWriterAcorn.ts index cfd459cc77..2dc3a5b77b 100644 --- a/packages/core/src/parsers/gsapWriterAcorn.ts +++ b/packages/core/src/parsers/gsapWriterAcorn.ts @@ -963,6 +963,53 @@ function collapseKeyframesToFlat( ms.overwrite(varsNode.start, varsNode.end, `{ ${entries.join(", ")} }`); } +/** Implicit tween-relative percentage of array-form keyframe index `i` of `n` + * (GSAP distributes array keyframes evenly: 0%, 1/(n-1), …, 100%). */ +function arrayKeyframePct(i: number, n: number): number { + return n > 1 ? (i / (n - 1)) * 100 : 0; +} + +// Array-form keyframes (`keyframes: [{x,y}, …]`) carry no explicit percentages — +// GSAP distributes them evenly. removeKeyframeFromScript only handled the +// object-form (`keyframes: { "50%": {…} }`), so removing from an array-form tween +// was a silent no-op (and the downstream hold-sync then stranded an `hf-hold`). +// Resolve the element by its implicit percentage and splice it out; collapse to a +// flat tween when fewer than two remain (parity with the object-form path). +function removeArrayKeyframe( + ms: MagicString, + varsArg: Node, + arrNode: Node, + script: string, + percentage: number, +): boolean { + const elements: Node[] = (arrNode.elements ?? []).filter( + (e: Node | null): e is Node => !!e && e.type === "ObjectExpression", + ); + const n = elements.length; + if (n === 0) return false; + + let matchIdx = -1; + let bestDist = Number.POSITIVE_INFINITY; + for (let i = 0; i < n; i++) { + const dist = Math.abs(arrayKeyframePct(i, n) - percentage); + if (dist <= PCT_TOLERANCE && dist < bestDist) { + matchIdx = i; + bestDist = dist; + } + } + if (matchIdx === -1) return false; + + const remaining = elements.filter((_, i) => i !== matchIdx); + if (remaining.length < 2) { + const sole = remaining[0]; + const record = sole ? valueNodeToRecord(sole, script) : {}; + collapseKeyframesToFlat(ms, varsArg, script, record); + return true; + } + removeProp(ms, elements[matchIdx], elements); + return true; +} + export function removeKeyframeFromScript( script: string, animationId: string, @@ -974,7 +1021,16 @@ export function removeKeyframeFromScript( if (!target) return script; const kfPropNode = findPropertyNode(target.call.varsArg, "keyframes"); - if (!kfPropNode || kfPropNode.value?.type !== "ObjectExpression") return script; + if (!kfPropNode) return script; + + if (kfPropNode.value?.type === "ArrayExpression") { + const ms = new MagicString(script); + return removeArrayKeyframe(ms, target.call.varsArg, kfPropNode.value, script, percentage) + ? ms.toString() + : script; + } + + if (kfPropNode.value?.type !== "ObjectExpression") return script; const kfNode = kfPropNode.value; const match = findKfPropByPct(kfNode, percentage); diff --git a/packages/core/src/runtime/init.ts b/packages/core/src/runtime/init.ts index fb21ae723a..acfe3d1356 100644 --- a/packages/core/src/runtime/init.ts +++ b/packages/core/src/runtime/init.ts @@ -405,23 +405,6 @@ export function initSandboxRuntimeModular(): void { return resolveStartForElement(element, fallback); }; - const findTimedClipAncestor = ( - element: HTMLElement, - rootComp: HTMLElement | null, - ): HTMLElement | null => { - let node = element.parentElement; - while (node) { - // rootComp may be null when no composition is mounted; the walk still - // terminates via `while (node)` — node === null is never true here. - if (node === rootComp) break; - if (node.hasAttribute("data-start")) { - return node; - } - node = node.parentElement; - } - return null; - }; - const isTimedElementVisibleAt = (rawNode: HTMLElement, currentTime: number): boolean => { const tag = rawNode.tagName.toLowerCase(); if (tag === "script" || tag === "style" || tag === "link" || tag === "meta") { @@ -1068,6 +1051,21 @@ export function initSandboxRuntimeModular(): void { const dur = String(rootDuration > 0 ? rootDuration : 1); const seen = new Set(); + // Only an AUTHORED clip (data-start already in the source, captured before + // we stamp anything) should suppress stamping its descendants. An animated + // scene container we auto-stamp below (e.g. an opacity-crossfaded scene) + // must NOT suppress its own animated children — otherwise those children + // never become timeline clips and that scene can't inline-expand. + const authoredTimed = new Set(document.querySelectorAll("[data-start]")); + const hasAuthoredTimedAncestor = (element: HTMLElement): boolean => { + let node = element.parentElement; + while (node && node !== rootComp) { + if (authoredTimed.has(node)) return true; + node = node.parentElement; + } + return false; + }; + // Stamp GSAP-targeted elements if (state.capturedTimeline.getChildren) { try { @@ -1077,7 +1075,7 @@ export function initSandboxRuntimeModular(): void { if (!(target instanceof HTMLElement)) continue; if (target === rootComp) continue; if (target.hasAttribute("data-start")) continue; - if (findTimedClipAncestor(target, rootComp)) continue; + if (hasAuthoredTimedAncestor(target)) continue; if (seen.has(target)) continue; seen.add(target); target.setAttribute("data-start", "0"); @@ -1097,7 +1095,7 @@ export function initSandboxRuntimeModular(): void { if (!(el instanceof HTMLElement)) continue; if (el === rootComp) continue; if (el.hasAttribute("data-start")) continue; - if (findTimedClipAncestor(el, rootComp)) continue; + if (hasAuthoredTimedAncestor(el)) continue; if (seen.has(el)) continue; if (el.tagName === "SCRIPT" || el.tagName === "STYLE" || el.tagName === "LINK") continue; seen.add(el); @@ -1434,6 +1432,21 @@ export function initSandboxRuntimeModular(): void { }; // fallow-ignore-next-line complexity + // Whether a timed clip participates in normal flow (static/relative/sticky). + // In-flow clips must leave the flow when hidden — `visibility:hidden` reserves + // their layout box, so a split sibling would stack below the active half + // instead of overlapping it. Positioned clips keep `visibility:hidden` (cheaper, + // and avoids disturbing absolute media playback). Computed once per element. + const timedClipInFlow = new WeakMap(); + const isTimedClipInFlow = (el: HTMLElement): boolean => { + const cached = timedClipInFlow.get(el); + if (cached !== undefined) return cached; + const pos = window.getComputedStyle(el).position; + const inFlow = pos === "static" || pos === "relative" || pos === "sticky"; + timedClipInFlow.set(el, inFlow); + return inFlow; + }; + const syncMediaForCurrentState = () => { const resolveMediaCompositionContext = (element: HTMLVideoElement | HTMLAudioElement) => { const compositionRoot = element.closest("[data-composition-id]"); @@ -1539,6 +1552,11 @@ export function initSandboxRuntimeModular(): void { if (rawNode instanceof HTMLVideoElement || rawNode instanceof HTMLImageElement) { colorGradingRuntime?.setSourceVisibility(rawNode, isVisibleNow); } + if (isVisibleNow) { + if (timedClipInFlow.get(rawNode)) rawNode.style.removeProperty("display"); + } else if (isTimedClipInFlow(rawNode)) { + rawNode.style.display = "none"; + } } }; diff --git a/packages/core/src/studio-api/helpers/sourceMutation.test.ts b/packages/core/src/studio-api/helpers/sourceMutation.test.ts index 06bb7155e4..a0f3dedcb1 100644 --- a/packages/core/src/studio-api/helpers/sourceMutation.test.ts +++ b/packages/core/src/studio-api/helpers/sourceMutation.test.ts @@ -508,6 +508,28 @@ describe("splitElementInHtml", () => { expect(splitElementInHtml(source, { id: "box" }, 7.5, "box-split").matched).toBe(false); }); + it("splits a GSAP element with no authored timing using fallback timing", () => { + // #title has no data-start/data-duration (GSAP-driven); the store supplies the range. + const gsapSource = `

Hi

`; + const result = splitElementInHtml(gsapSource, { id: "title" }, 2, "title-split", { + start: 0, + duration: 6, + }); + expect(result.matched).toBe(true); + // original windowed to [0, 2], clone to [2, 4] (attribute order is serializer-defined) + const original = result.html.match(/]*\bid="title"[^>]*>/)![0]; + expect(original).toContain('data-start="0"'); + expect(original).toContain('data-duration="2"'); + const clone = result.html.match(/]*\bid="title-split"[^>]*>/)![0]; + expect(clone).toContain('data-start="2"'); + expect(clone).toContain('data-duration="4"'); + }); + + it("still rejects a no-timing element when no fallback timing is given", () => { + const gsapSource = `

Hi

`; + expect(splitElementInHtml(gsapSource, { id: "title" }, 2, "title-split").matched).toBe(false); + }); + it("adjusts media playback-start for the second half", () => { const mediaSource = source.replace( 'id="box" class="clip" data-start="1" data-duration="6"', diff --git a/packages/core/src/studio-api/helpers/sourceMutation.ts b/packages/core/src/studio-api/helpers/sourceMutation.ts index bfec5eb4cf..57cdc6cfc9 100644 --- a/packages/core/src/studio-api/helpers/sourceMutation.ts +++ b/packages/core/src/studio-api/helpers/sourceMutation.ts @@ -381,12 +381,23 @@ export function splitElementInHtml( target: SourceMutationTarget, splitTime: number, newId: string, + fallbackTiming?: { start: number; duration: number }, ): SplitElementResult { const { document, wrappedFragment } = parseSourceDocument(source); const el = findTargetElement(document, target); if (!el || !isHTMLElement(el)) return { html: source, matched: false, newId: null }; - const { start, duration, usesDataEnd } = resolveElementTiming(el); + const timing = resolveElementTiming(el); + const { usesDataEnd } = timing; + let { start, duration } = timing; + // GSAP-animated elements carry their timing in the script, not in data-* attrs, + // so the source has no authored duration. Fall back to the store's (GSAP-derived) + // range — the runtime windows visibility off data-start/data-duration regardless + // of class, so stamping both halves below makes each half show only in its window. + if (duration <= 0 && fallbackTiming && fallbackTiming.duration > 0) { + start = fallbackTiming.start; + duration = fallbackTiming.duration; + } if (duration <= 0 || splitTime <= start || splitTime >= start + duration) { return { html: source, matched: false, newId: null }; } @@ -405,6 +416,9 @@ export function splitElementInHtml( const clone = el.cloneNode(true) as HTMLElement; clone.setAttribute("id", newId); clone.removeAttribute("data-hf-id"); + // Descendants carry their own data-hf-id; leaving them duplicates the id of + // every nested node (e.g. an inner ), so strip them on the clone too. + for (const node of clone.querySelectorAll("[data-hf-id]")) node.removeAttribute("data-hf-id"); clone.setAttribute("data-start", String(Math.round(splitTime * 1000) / 1000)); setElementDuration(clone, splitTime, secondDuration, usesDataEnd); @@ -433,7 +447,9 @@ export function splitElementInHtml( duplicateCssRulesForId(document, originalId, newId); } - // Trim the original element's duration + // Trim the original element's duration. A GSAP element had no data-start; stamp + // it so the runtime windows the first half (visibility selects on [data-start]). + el.setAttribute("data-start", String(Math.round(start * 1000) / 1000)); setElementDuration(el, start, firstDuration, usesDataEnd); // Insert clone after original diff --git a/packages/core/src/studio-api/routes/files.ts b/packages/core/src/studio-api/routes/files.ts index 229ddc1ff0..a739fe36ef 100644 --- a/packages/core/src/studio-api/routes/files.ts +++ b/packages/core/src/studio-api/routes/files.ts @@ -24,6 +24,7 @@ import { type UnsafeMutationValue, } from "../helpers/finiteMutation.js"; import type { GsapAnimation } from "../../parsers/gsapSerialize.js"; +import { classifyPropertyGroup } from "../../parsers/gsapConstants.js"; import { parseGsapScriptAcorn } from "../../parsers/gsapParserAcorn.js"; import { unrollComputedTimeline } from "../../parsers/gsapUnroll.js"; import { @@ -289,6 +290,18 @@ function stripStudioEditsFromTarget(document: Document, selector: string): numbe return stripped; } +// A studio path-offset (--hf-studio-offset / data-hf-studio-path-offset) and a GSAP +// position tween both drive translate — keeping both stacks the offsets (a gesture or +// drag recorded over a stale offset plays shoved off-position). When a committed tween +// writes a position property, the tween owns position, so the stale offset must go. +function keyframesWritePosition( + keyframes: Array<{ properties: Record }>, +): boolean { + return keyframes.some((kf) => + Object.keys(kf.properties).some((k) => classifyPropertyGroup(k) === "position"), + ); +} + function lastKeyframeOpacity(kfs: GsapAnimation["keyframes"]): number | string | undefined { if (!kfs) return undefined; for (let i = kfs.keyframes.length - 1; i >= 0; i--) { @@ -431,6 +444,24 @@ type GsapMutationRequest = cp1?: { x: number; y: number }; cp2?: { x: number; y: number }; } + | { + type: "update-motion-path-point"; + animationId: string; + pointIndex: number; + x: number; + y: number; + } + | { type: "add-motion-path-point"; animationId: string; index: number; x: number; y: number } + | { type: "remove-motion-path-point"; animationId: string; index: number } + | { + type: "add-motion-path"; + targetSelector: string; + position: number; + duration: number; + x: number; + y: number; + ease?: string; + } | { type: "remove-arc-path"; animationId: string } | { type: "add-with-keyframes"; @@ -498,6 +529,24 @@ type GsapMutationRequest = type GsapMutationResult = string | { script: string; skippedSelectors: string[] }; +// Mutations that can change a position tween's first keyframe (value/existence/timing) +// and therefore require the pre-keyframe hold-`set`s to be re-synced afterwards. +const HOLD_SYNC_MUTATION_TYPES = new Set([ + "add-keyframe", + "update-keyframe", + "remove-keyframe", + "remove-all-keyframes", + "add-with-keyframes", + "replace-with-keyframes", + "convert-to-keyframes", + "materialize-keyframes", + "update-motion-path-point", + "add-motion-path-point", + "remove-motion-path-point", + "delete", + "delete-all-for-selector", +]); + async function executeGsapMutation( body: GsapMutationRequest, block: NonNullable>, @@ -517,6 +566,10 @@ async function executeGsapMutation( unrollDynamicAnimations, setArcPathInScript, updateArcSegmentInScript, + updateMotionPathPointInScript, + addMotionPathPointInScript, + removeMotionPathPointInScript, + addMotionPathToScript, removeArcPathFromScript, addAnimationWithKeyframesToScript, splitAnimationsInScript, @@ -680,10 +733,39 @@ async function executeGsapMutation( ...(body.cp2 ? { cp2: body.cp2 } : {}), }); } + case "update-motion-path-point": { + return updateMotionPathPointInScript(block.scriptText, body.animationId, body.pointIndex, { + x: body.x, + y: body.y, + }); + } + case "add-motion-path-point": { + return addMotionPathPointInScript(block.scriptText, body.animationId, body.index, { + x: body.x, + y: body.y, + }); + } + case "remove-motion-path-point": { + return removeMotionPathPointInScript(block.scriptText, body.animationId, body.index); + } + case "add-motion-path": { + const result = addMotionPathToScript( + block.scriptText, + body.targetSelector, + body.position, + body.duration, + { x: body.x, y: body.y }, + body.ease, + ); + return result.script; + } case "remove-arc-path": { return removeArcPathFromScript(block.scriptText, body.animationId); } case "add-with-keyframes": { + if (keyframesWritePosition(body.keyframes)) { + stripStudioEditsFromTarget(block.document, body.targetSelector); + } const result = addAnimationWithKeyframesToScript( block.scriptText, body.targetSelector, @@ -695,6 +777,9 @@ async function executeGsapMutation( return result.script; } case "replace-with-keyframes": { + if (keyframesWritePosition(body.keyframes)) { + stripStudioEditsFromTarget(block.document, body.targetSelector); + } const script = removeAnimationFromScript(block.scriptText, body.animationId); const added = addAnimationWithKeyframesToScript( script, @@ -970,11 +1055,18 @@ export function registerFileRoutes(api: Hono, adapter: StudioApiAdapter): void { target?: { id?: string; selector?: string; selectorIndex?: number }; splitTime?: number; newId?: string; + elementStart?: number; + elementDuration?: number; }>(c); if ("error" in parsed) return parsed.error; if (typeof parsed.body.splitTime !== "number" || !parsed.body.newId) { return c.json({ error: "target, splitTime, and newId required" }, 400); } + const fallbackTiming = + typeof parsed.body.elementStart === "number" && + typeof parsed.body.elementDuration === "number" + ? { start: parsed.body.elementStart, duration: parsed.body.elementDuration } + : undefined; let originalContent: string; try { @@ -987,6 +1079,7 @@ export function registerFileRoutes(api: Hono, adapter: StudioApiAdapter): void { parsed.target, parsed.body.splitTime, parsed.body.newId, + fallbackTiming, ); if (!result.matched) { return c.json({ ok: false, changed: false, content: originalContent, path: ctx.filePath }); @@ -1230,7 +1323,15 @@ export function registerFileRoutes(api: Hono, adapter: StudioApiAdapter): void { const result = await executeGsapMutation(body, block, respond); if (result instanceof Response) return result; - const newScript = typeof result === "string" ? result : result.script; + let newScript = typeof result === "string" ? result : result.script; + // Keep the "hold before first keyframe" sets in sync after any mutation that can + // change a position tween's first keyframe or its existence. Without it, an + // element snaps to its CSS base before the tween starts instead of holding its + // first keyframe (the universal NLE behavior). + if (HOLD_SYNC_MUTATION_TYPES.has(body.type)) { + const parser = await loadGsapParser(); + newScript = parser.syncPositionHoldsBeforeKeyframes(newScript); + } const changed = newScript !== block.scriptText; const newHtml = changed ? block.replaceScript(newScript) : html; let backupPath: string | null = null; diff --git a/packages/producer/build.mjs b/packages/producer/build.mjs index 22fceae09f..4965c45a33 100644 --- a/packages/producer/build.mjs +++ b/packages/producer/build.mjs @@ -15,12 +15,20 @@ mkdirSync("dist", { recursive: true }); const scriptDir = dirname(fileURLToPath(import.meta.url)); -// The banner provides a real `require` function via createRequire so that -// esbuild's CJS interop (__require) works correctly in ESM output. -// Without this, bundled CJS deps (recast, yauzl, etc.) that call -// require("fs") throw "Dynamic require of 'fs' is not supported". +// The banner provides a real `require` (via createRequire) plus the CJS-only +// `__filename`/`__dirname` globals so esbuild's CJS interop works in ESM output. +// Without `require`, bundled CJS deps (recast, yauzl, etc.) that call +// require("fs") throw "Dynamic require of 'fs' is not supported"; without the +// dirname shims, deps like wawoff2 throw "__dirname is not defined in ES module". const cjsBanner = { - js: "import { createRequire as __cjsRequire } from 'module'; const require = __cjsRequire(import.meta.url);", + js: [ + "import { createRequire as __cjsRequire } from 'module';", + "import { fileURLToPath as __cjsFileURLToPath } from 'url';", + "import { dirname as __cjsDirname } from 'path';", + "const require = __cjsRequire(import.meta.url);", + "const __filename = __cjsFileURLToPath(import.meta.url);", + "const __dirname = __cjsDirname(__filename);", + ].join(" "), }; const workspaceAliasPlugin = { diff --git a/packages/studio/index.html b/packages/studio/index.html index 7e8dc88ae3..2d93ac2e28 100644 --- a/packages/studio/index.html +++ b/packages/studio/index.html @@ -1,13 +1,13 @@ - + - - - + + + HyperFrames Studio -
+
diff --git a/packages/studio/src/components/StudioHeader.tsx b/packages/studio/src/components/StudioHeader.tsx index b6b60ec3d8..f998a8db25 100644 --- a/packages/studio/src/components/StudioHeader.tsx +++ b/packages/studio/src/components/StudioHeader.tsx @@ -8,7 +8,6 @@ import { import { getHistoryShortcutLabel } from "../utils/studioHelpers"; import { useStudioShellContext } from "../contexts/StudioContext"; import { usePanelLayoutContext } from "../contexts/PanelLayoutContext"; -import { useDomEditActionsContext } from "../contexts/DomEditContext"; import { useViewMode, type StudioViewMode } from "../contexts/ViewModeContext"; import { trackStudioEvent } from "../utils/studioTelemetry"; @@ -194,7 +193,6 @@ export function StudioHeader({ }: StudioHeaderProps) { const { projectId, editHistory, handleUndo, handleRedo } = useStudioShellContext(); const { rightCollapsed, setRightCollapsed, setRightPanelTab } = usePanelLayoutContext(); - const { clearDomSelection } = useDomEditActionsContext(); return (
@@ -279,7 +277,8 @@ export function StudioHeader({ return; } trackStudioEvent("panel_toggle", { panel: "inspector", collapsed: true }); - clearDomSelection(); + // Keep the current selection when collapsing the Inspector — closing + // the panel shouldn't deselect the element. setRightCollapsed(true); }} disabled={!STUDIO_INSPECTOR_PANELS_ENABLED} diff --git a/packages/studio/src/components/StudioPreviewArea.tsx b/packages/studio/src/components/StudioPreviewArea.tsx index cfa981a3e7..b42a06837d 100644 --- a/packages/studio/src/components/StudioPreviewArea.tsx +++ b/packages/studio/src/components/StudioPreviewArea.tsx @@ -3,6 +3,8 @@ import { NLELayout } from "./nle/NLELayout"; import { CaptionOverlay } from "../captions/components/CaptionOverlay"; import { CaptionTimeline } from "../captions/components/CaptionTimeline"; import { DomEditOverlay } from "./editor/DomEditOverlay"; +import { MotionPathOverlay } from "./editor/MotionPathOverlay"; +import { useCompositionDimensions } from "../hooks/useCompositionDimensions"; import { SnapToolbar } from "./editor/SnapToolbar"; import { StudioFeedbackBar } from "./StudioFeedbackBar"; import type { TimelineElement } from "../player"; @@ -10,6 +12,7 @@ import { usePlayerStore } from "../player/store/playerStore"; import type { BlockedTimelineEditIntent } from "../player/components/timelineEditing"; import { STUDIO_INSPECTOR_PANELS_ENABLED, + STUDIO_KEYFRAMES_ENABLED, STUDIO_PREVIEW_MANUAL_EDITING_ENABLED, STUDIO_PREVIEW_SELECTION_ENABLED, } from "./editor/manualEditingAvailability"; @@ -108,6 +111,7 @@ export function StudioPreviewArea({ isPlaying, refreshPreviewDocumentVersion, } = useStudioPlaybackContext(); + const compositionDimensions = useCompositionDimensions(); const { domEditHoverSelection, @@ -337,6 +341,14 @@ export function StudioPreviewArea({ onToggleRecording={onToggleRecording} /> + {STUDIO_KEYFRAMES_ENABLED && ( + + )} {gestureOverlay} ) : null diff --git a/packages/studio/src/components/editor/DomEditOverlay.tsx b/packages/studio/src/components/editor/DomEditOverlay.tsx index 12789407c6..64ca5147f0 100644 --- a/packages/studio/src/components/editor/DomEditOverlay.tsx +++ b/packages/studio/src/components/editor/DomEditOverlay.tsx @@ -243,6 +243,7 @@ export const DomEditOverlay = memo(function DomEditOverlay({ if (!selection) return "none"; return `${selection.sourceFile}:${selection.id ?? selection.selector ?? selection.label}:${selection.selectorIndex ?? 0}`; }, [selection]); + const groupBounds = useMemo( () => resolveDomEditGroupOverlayRect(groupOverlayItems.map((item) => item.rect)), [groupOverlayItems], diff --git a/packages/studio/src/components/editor/KeyframeNavigation.test.ts b/packages/studio/src/components/editor/KeyframeNavigation.test.ts new file mode 100644 index 0000000000..0ce9a694de --- /dev/null +++ b/packages/studio/src/components/editor/KeyframeNavigation.test.ts @@ -0,0 +1,36 @@ +import { describe, expect, it } from "vitest"; +import { clipToTweenPercentage } from "./KeyframeNavigation"; + +/** + * Regression: keyframe add/remove are keyed by TWEEN-relative percentage (what the + * GSAP writer + runtime use), NOT the clip-relative playhead used for display/seek. + * The Layout-panel diamond used to emit clip-relative %, so the mutation missed + * every keyframe (off by the tween's offset/scale) → a silent no-op on disk that + * the optimistic cache hid, so the motion path never refreshed. + */ + +// A tween that starts partway through the element's lifetime and is shorter than +// it: the clip→tween map is linear with tween% = (clip% - 20) * 2.5 over [20, 60]. +const KEYFRAMES = [ + { percentage: 20, tweenPercentage: 0, properties: { x: 0 } }, + { percentage: 30, tweenPercentage: 25, properties: { x: -180 } }, + { percentage: 50, tweenPercentage: 75, properties: { x: -320 } }, + { percentage: 60, tweenPercentage: 100, properties: { x: -460 } }, +]; + +describe("clipToTweenPercentage", () => { + it("maps anchor keyframes to their tween-relative percentages", () => { + expect(clipToTweenPercentage(KEYFRAMES, 20)).toBeCloseTo(0, 5); + expect(clipToTweenPercentage(KEYFRAMES, 60)).toBeCloseTo(100, 5); + }); + + it("linearly interpolates a clip-relative playhead into tween space", () => { + // clip 40% is the midpoint of the tween's clip span [20, 60] → tween 50%. + expect(clipToTweenPercentage(KEYFRAMES, 40)).toBeCloseTo(50, 5); + }); + + it("falls back to the input when there's no usable mapping", () => { + expect(clipToTweenPercentage([], 40)).toBe(40); + expect(clipToTweenPercentage([{ percentage: 10 }], 40)).toBe(40); + }); +}); diff --git a/packages/studio/src/components/editor/KeyframeNavigation.tsx b/packages/studio/src/components/editor/KeyframeNavigation.tsx index 48f2f51770..c54047c305 100644 --- a/packages/studio/src/components/editor/KeyframeNavigation.tsx +++ b/packages/studio/src/components/editor/KeyframeNavigation.tsx @@ -3,9 +3,12 @@ import { KeyframeDiamond, type DiamondState } from "./KeyframeDiamond"; interface KeyframeNavigationProps { property: string; - /** All keyframes for this element's tween, or null if no keyframes exist */ + /** All keyframes for this element's tween, or null if no keyframes exist. + * `percentage` is clip-relative (element lifetime) for display/seek; + * `tweenPercentage` is the tween-relative value the writer/runtime key on. */ keyframes: Array<{ percentage: number; + tweenPercentage?: number; properties: Record; ease?: string; }> | null; @@ -19,6 +22,26 @@ interface KeyframeNavigationProps { const TOLERANCE = 0.5; +/** + * Convert a clip-relative percentage (element lifetime, used for display/seek) to + * the TWEEN-relative percentage the GSAP writer/runtime key on. The clip→tween + * map is linear, recovered from the keyframes' own (percentage, tweenPercentage) + * pairs. Falls back to the input when there's no usable mapping (e.g. parser + * keyframes that are already tween-relative, or fewer than two anchors). + */ +export function clipToTweenPercentage( + keyframes: ReadonlyArray<{ percentage: number; tweenPercentage?: number }>, + clipPct: number, +): number { + const mapped = keyframes.filter((kf) => typeof kf.tweenPercentage === "number"); + if (mapped.length < 2) return clipPct; + const a = mapped[0]!; + const b = mapped[mapped.length - 1]!; + if (b.percentage === a.percentage) return a.tweenPercentage!; + const slope = (b.tweenPercentage! - a.tweenPercentage!) / (b.percentage - a.percentage); + return a.tweenPercentage! + (clipPct - a.percentage) * slope; +} + function ArrowLeft({ disabled }: { disabled: boolean }) { return ( { if (diamondState === "ghost") { onConvertToKeyframes(); - } else if (diamondState === "active") { - onRemoveKeyframe(currentPercentage); + } else if (diamondState === "active" && atCurrent) { + onRemoveKeyframe(atCurrent.tweenPercentage ?? atCurrent.percentage); } else { - onAddKeyframe(currentPercentage); + onAddKeyframe(clipToTweenPercentage(propertyKeyframes, currentPercentage)); } }; diff --git a/packages/studio/src/components/editor/MotionPathNode.tsx b/packages/studio/src/components/editor/MotionPathNode.tsx new file mode 100644 index 0000000000..c6d3ce43ca --- /dev/null +++ b/packages/studio/src/components/editor/MotionPathNode.tsx @@ -0,0 +1,98 @@ +import type React from "react"; + +// Editor primary color (themeable via --hf-accent). Applied through inline +// style because CSS var() isn't valid in SVG presentation attributes. +export const ACCENT = "var(--hf-accent, #3CE6AC)"; + +/** One path node: a diamond (matching the timeline keyframe), a wider transparent + * grab target (when editable), and a hover-revealed × delete badge (when removable). */ +export function MotionPathNode(props: { + cx: number; + cy: number; + r: number; + interactive: boolean; + removable: boolean; + grabbing: boolean; + selected: boolean; + onEnter: () => void; + onLeave: () => void; + onPointerDown: (e: React.PointerEvent) => void; + onPointerMove: (e: React.PointerEvent) => void; + onPointerUp: (e: React.PointerEvent) => void; + onRemove: (e: React.PointerEvent) => void; + onContextMenu?: (e: React.MouseEvent) => void; +}) { + const { cx, cy, r, interactive, removable, grabbing, selected } = props; + const bx = cx + r * 1.8; + const by = cy - r * 1.8; + const k = r * 0.55; + // Diamond matching the timeline keyframe (a 45°-rotated rounded square). + // `side` is chosen so the diamond's points reach ~`r` from center, matching the + // old dot's footprint; selection is shown by enlarging it (no extra shape). + const side = (selected ? r * 1.5 : r) * 1.414; + return ( + + + {interactive && ( + + )} + {removable && ( + + + + + + )} + + ); +} diff --git a/packages/studio/src/components/editor/MotionPathOverlay.tsx b/packages/studio/src/components/editor/MotionPathOverlay.tsx new file mode 100644 index 0000000000..b7ae0d7a44 --- /dev/null +++ b/packages/studio/src/components/editor/MotionPathOverlay.tsx @@ -0,0 +1,559 @@ +import { memo, useEffect, useRef, useState, type RefObject } from "react"; +import type { DomEditSelection } from "./domEditing"; +import { useDomEditContext } from "../../contexts/DomEditContext"; +import { usePlayerStore } from "../../player/store/playerStore"; +import { readRuntimeKeyframes } from "../../hooks/gsapRuntimeKeyframes"; +import { parkPlayheadOnKeyframe } from "../../hooks/gsapDragCommit"; +import { isElementVisibleInPreview } from "./domEditOverlayGeometry"; +import { + buildMotionPathGeometry, + nearestPointOnPath, + type MotionNodeRef, + type MotionPathGeometry, +} from "./motionPathGeometry"; +import { editableAnimationId, selectorFor } from "./motionPathSelection"; +import { ACCENT, MotionPathNode } from "./MotionPathNode"; +import { + KeyframeDiamondContextMenu, + type KeyframeDiamondContextMenuState, +} from "../../player/components/KeyframeDiamondContextMenu"; +import { + commitAddWaypoint, + commitCreatePath, + commitNode, + commitRemoveWaypoint, +} from "./motionPathCommit"; + +interface MotionPathOverlayProps { + iframeRef: RefObject; + selection: DomEditSelection | null; + compositionSize: { width: number; height: number } | null; + isPlaying: boolean; +} + +type Rect = { left: number; top: number; width: number; height: number }; +type Draft = { index: number; x: number; y: number }; +type DragState = { + index: number; + startX: number; + startY: number; + initX: number; + initY: number; + scale: number; + ref: MotionNodeRef; +}; + +const NODE_PX = 6; // node radius in screen pixels (kept constant across zoom) + +/** The element's layout-home center in composition coordinates. GSAP x/y (and + * motionPath coords) are offsets from this point, so the overlay adds it to + * each node to place the path on the element rather than the canvas origin. + * offsetLeft/Top are transform-excluded, so home is stable across the + * animation; walk up to (not including) the composition root. */ +function elementHome(el: HTMLElement): { x: number; y: number } { + let left = 0; + let top = 0; + let node: HTMLElement | null = el; + while (node) { + left += node.offsetLeft; + top += node.offsetTop; + const parent = node.offsetParent as HTMLElement | null; + if (!parent || parent.hasAttribute("data-composition-id")) break; + node = parent; + } + let x = left + el.offsetWidth / 2; + let y = top + el.offsetHeight / 2; + // Include the manual CSS path offset (`--hf-studio-offset`, applied via + // `translate`). offsetLeft excludes transforms, but this offset is a stable + // nudge (not animated) that shifts where the element — and thus its entire + // keyframe path — actually renders. Keyframe values stay in gsap space (the + // path offset is composed separately at runtime), so without this the whole + // path draws shifted by the offset (e.g. a gesture recorded on a dragged-down + // element drew its path above the element). + if ((el.style.translate ?? "").includes("var(")) { + x += Number.parseFloat(el.style.getPropertyValue("--hf-studio-offset-x")) || 0; + y += Number.parseFloat(el.style.getPropertyValue("--hf-studio-offset-y")) || 0; + } + return { x, y }; +} + +/** Cross-realm-safe HTMLElement check. An element queried from the preview + * iframe's document is an instance of the IFRAME window's `HTMLElement`, NOT the + * studio window's — so a plain `node instanceof HTMLElement` is always false for + * preview nodes. Check against the iframe realm's constructor instead. */ +function isPreviewHtmlElement( + node: Element | null | undefined, + iframe: HTMLIFrameElement | null, +): node is HTMLElement { + const Ctor = (iframe?.contentWindow as unknown as { HTMLElement?: typeof HTMLElement } | null) + ?.HTMLElement; + return Boolean(node && Ctor && node instanceof Ctor); +} + +function rectsClose(a: Rect, b: Rect): boolean { + return ( + Math.abs(a.left - b.left) < 0.5 && + Math.abs(a.top - b.top) < 0.5 && + Math.abs(a.width - b.width) < 0.5 && + Math.abs(a.height - b.height) < 0.5 + ); +} + +function hasMotionPathPlugin(iframe: HTMLIFrameElement | null): boolean { + try { + return Boolean( + (iframe?.contentWindow as unknown as { MotionPathPlugin?: unknown })?.MotionPathPlugin, + ); + } catch { + return false; + } +} + +/** Track the iframe rect (every frame) and the selected element's path geometry + * (polled lightly, so it stays fresh through seeks/edits/soft reloads). */ +function useMotionPathData( + iframeRef: RefObject, + selector: string | null, +): { + rect: Rect | null; + geometry: MotionPathGeometry | null; + visibleInPreview: boolean; + home: { x: number; y: number } | null; +} { + const [rect, setRect] = useState(null); + const [geometry, setGeometry] = useState(null); + // Whether the target element is actually painted on screen — the path hides when + // it isn't (e.g. covered by a later scene), matching the selection overlay. + const [visibleInPreview, setVisibleInPreview] = useState(true); + // The element's layout-home center, computed from the LIVE current-document + // element (see below). Path nodes are drawn at home + keyframe offset, so a + // stale home translates the whole path off the element. + const [home, setHome] = useState<{ x: number; y: number } | null>(null); + + useEffect(() => { + if (!selector) { + setRect(null); + setHome(null); + return; + } + // New selector → drop the previous element's anchor immediately; the first + // tick recomputes it for the new element. Avoids a 1-frame path at the old home. + setHome(null); + let raf = 0; + const tick = () => { + const el = iframeRef.current; + if (el) { + const r = el.getBoundingClientRect(); + // Position relative to the preview surface (the `relative overflow-hidden` + // wrapper) so the SVG is `absolute` inside it and gets clipped to the canvas + // — instead of `fixed`, which would paint over the side panels at zoom. + // NOTE: the composition iframe lives in the player's SHADOW DOM, so + // `el.closest()` can't reach the pan-surface (it stops at the shadow root) + // and would silently return null → the SVG falls back to raw viewport + // coords and is offset by the pan-surface's position (worsening with + // zoom/pan). Query the light DOM via the document instead. + const surface = el.ownerDocument?.querySelector("[data-preview-pan-surface]"); + const sRect = surface?.getBoundingClientRect(); + const next = { + left: sRect ? r.left - sRect.left : r.left, + top: sRect ? r.top - sRect.top : r.top, + width: r.width, + height: r.height, + }; + setRect((prev) => (prev && rectsClose(prev, next) ? prev : next)); + // Resolve the element in the CURRENT iframe document (same one the path + // geometry reads) — never trust a possibly-stale `selection.element` from a + // prior document. Soft-reloads (every commit) rebuild the iframe DOM, so a + // captured node detaches: its offsetLeft/offsetParent collapse and the + // computed home — hence the whole path — lands in the wrong place. + let target: Element | null = null; + try { + target = el.contentDocument?.querySelector(selector) ?? null; + } catch { + /* cross-origin guard */ + } + const live = isPreviewHtmlElement(target, el) ? target : null; + const vis = live ? isElementVisibleInPreview(live) : true; + setVisibleInPreview((prev) => (prev === vis ? prev : vis)); + if (live) { + const h = elementHome(live); + setHome((prev) => + prev && Math.abs(prev.x - h.x) < 0.5 && Math.abs(prev.y - h.y) < 0.5 ? prev : h, + ); + } + } + raf = requestAnimationFrame(tick); + }; + raf = requestAnimationFrame(tick); + return () => cancelAnimationFrame(raf); + }, [selector, iframeRef]); + + useEffect(() => { + if (!selector) { + setGeometry(null); + return; + } + // Poll the runtime: edits commit with an in-place soft reload (the timeline + // re-executes without an iframe load or a refresh-version bump), so there's + // no event to subscribe to. The read is cheap and the points-equality guard + // suppresses redundant re-renders. ponytail: a shared gsap-soft-reload + // version signal would let this (and future overlays) go event-driven — + // that's a cross-cutting change tracked with the soft-reload work, not here. + const recompute = () => { + const read = readRuntimeKeyframes(iframeRef.current, selector); + const next = buildMotionPathGeometry(read); + setGeometry((prev) => (prev?.points === next?.points ? prev : next)); + }; + recompute(); + const id = window.setInterval(recompute, 250); + return () => window.clearInterval(id); + }, [selector, iframeRef]); + + return { rect, geometry, visibleInPreview, home }; +} + +/** + * Draws the selected element's GSAP motion path over the canvas — a dashed + * polyline through its x/y keyframes (or motionPath waypoints) with a draggable + * node at each. Dragging an x/y node rewrites the keyframe; dragging a waypoint + * rewrites the motionPath point; both commit to source (undoable). Renders in + * declared composition coordinates so the path doesn't drift under GSAP + * transforms. Read-only (no drag) while playing or when the tween isn't + * statically editable. Nothing renders when the selection has no positional + * motion. + */ +// fallow-ignore-next-line complexity +export const MotionPathOverlay = memo(function MotionPathOverlay({ + iframeRef, + selection, + compositionSize, + isPlaying, +}: MotionPathOverlayProps) { + const { + commitMutation, + selectedGsapAnimations, + handleGsapRemoveKeyframe, + handleGsapDeleteAllForElement, + } = useDomEditContext(); + const { rect, geometry, visibleInPreview, home } = useMotionPathData( + iframeRef, + selectorFor(selection), + ); + const [draft, setDraft] = useState(null); + const [ghost, setGhost] = useState<{ x: number; y: number; segIndex: number } | null>(null); + const [hoverNode, setHoverNode] = useState(null); + // Right-click context menu on a keyframe node — same delete actions as the + // timeline keyframe diamond. + const [kfMenu, setKfMenu] = useState(null); + // The keyframe % selected by clicking its node — highlighted, and the next drag + // modifies it rather than adding a keyframe. + const activeKeyframePct = usePlayerStore((s) => s.activeKeyframePct); + const dragRef = useRef(null); + + // Create mode: a selected element with no positional motion. A double-click on + // the canvas authors a new motionPath from the element to that point. + const createMode = !geometry && Boolean(selection?.element) && !isPlaying; + // fallow-ignore-next-line complexity + useEffect(() => { + if (!createMode || !selection?.element || !compositionSize) return; + const targetSelector = selectorFor(selection); + if (!targetSelector) return; + // fallow-ignore-next-line complexity + const onDbl = (e: MouseEvent) => { + const iframe = iframeRef.current; + if (!iframe || !hasMotionPathPlugin(iframe)) return; + const r = iframe.getBoundingClientRect(); + if (e.clientX < r.left || e.clientX > r.right || e.clientY < r.top || e.clientY > r.bottom) { + return; + } + // Resolve the element LIVE from the current iframe document — the selected + // node may be detached after a soft-reload, which would skew home. + const live = iframe.contentDocument?.querySelector(targetSelector); + if (!isPreviewHtmlElement(live, iframe)) return; + const sc = r.width / compositionSize.width; + 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); + const t = Math.round(usePlayerStore.getState().currentTime * 100) / 100; + void commitCreatePath(targetSelector, t, px, py, commitMutation); + }; + window.addEventListener("dblclick", onDbl); + return () => window.removeEventListener("dblclick", onDbl); + }, [createMode, selection, compositionSize, iframeRef, commitMutation]); + + if (!rect || rect.width <= 0 || !compositionSize || compositionSize.width <= 0) return null; + // Hide the whole overlay (path + create hint) when the element isn't painted — + // same "what you see in the preview" rule as the selection box. + if (!visibleInPreview) return null; + // No live anchor (element not in the current document) → can't place the path. + if (!home) return null; + + if (!geometry) { + if (!createMode || !selection?.element || !hasMotionPathPlugin(iframeRef.current)) return null; + const sc = rect.width / compositionSize.width; + const hr = (NODE_PX / sc) * 1.6; + return ( + + + + double-click to set a destination + + + ); + } + + const scale = rect.width / compositionSize.width; + const nodeR = NODE_PX / scale; + const animId = editableAnimationId(selectedGsapAnimations ?? [], geometry.kind); + const interactive = Boolean(animId) && !isPlaying; + // Add/remove apply to non-cubic motionPath arcs only (cubic anchors carry + // control points we don't synthesize; linear keyframe paths stay drag-only). + const arcAnim = animId ? selectedGsapAnimations?.find((a) => a.id === animId) : undefined; + const isCubic = arcAnim?.arcPath?.segments?.some((s) => s.cp1 != null) ?? false; + const structural = geometry.kind === "arc" && interactive && !isCubic; + const removable = structural && geometry.nodes.length > 2; + + const nodes = draft + ? geometry.nodes.map((n, i) => (i === draft.index ? { ...n, x: draft.x, y: draft.y } : n)) + : 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 })); + const points = abs.map((p) => `${p.ax},${p.ay}`).join(" "); + const clientToComp = (e: React.PointerEvent) => ({ + x: (e.clientX - rect.left) / scale, + y: (e.clientY - rect.top) / scale, + }); + + const onDown = ( + e: React.PointerEvent, + index: number, + x: number, + y: number, + ref: MotionNodeRef, + ) => { + if (!interactive) return; + e.stopPropagation(); + (e.target as Element).setPointerCapture(e.pointerId); + dragRef.current = { + index, + startX: e.clientX, + startY: e.clientY, + initX: x, + initY: y, + scale, + ref, + }; + setDraft({ index, x, y }); + }; + const onMove = (e: React.PointerEvent) => { + const d = dragRef.current; + 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, + }); + }; + // fallow-ignore-next-line complexity + const onUp = (e: React.PointerEvent) => { + const d = dragRef.current; + if (!d) return; + dragRef.current = null; + setDraft(null); + 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); + if (x === Math.round(d.initX) && y === Math.round(d.initY)) { + // No drag → treat as a click: select this keyframe and park the playhead on + // it. Selecting it makes the next drag MODIFY this keyframe (honored via + // activeKeyframePct) instead of creating a new one. + if (d.ref.type === "keyframe") { + usePlayerStore.getState().setActiveKeyframePct(d.ref.pct); + const anim = selectedGsapAnimations?.find((a) => a.id === animId); + if (anim) parkPlayheadOnKeyframe(anim, d.ref.pct); + } + return; // no commit + } + void commitNode(d.ref, x, y, animId, commitMutation); + // Park the playhead on the edited keyframe's time so the element previews AT + // that keyframe. Without it, a playhead sitting before the tween renders the + // element's base pose — the edit (correct on the path) looks like it vanished. + if (d.ref.type === "keyframe") { + const anim = selectedGsapAnimations?.find((a) => a.id === animId); + if (anim) parkPlayheadOnKeyframe(anim, d.ref.pct); + } + }; + + // Ghost "add" affordance: project the cursor onto the path; click inserts. + const onPathHover = (e: React.PointerEvent) => { + const c = clientToComp(e); + const np = nearestPointOnPath( + c.x, + c.y, + abs.map((p) => ({ x: p.ax, y: p.ay })), + ); + setGhost(np ? { x: np.x, y: np.y, segIndex: np.segIndex } : null); + }; + const onPathDown = (e: React.PointerEvent) => { + if (!animId) return; + // Compute the insertion point from the event directly so a click works + // without (or faster than) a preceding hover. + const c = clientToComp(e); + const np = nearestPointOnPath( + c.x, + c.y, + abs.map((p) => ({ x: p.ax, y: p.ay })), + ); + if (!np) return; + e.stopPropagation(); + void commitAddWaypoint( + animId, + np.segIndex + 1, + Math.round(np.x - home.x), + Math.round(np.y - home.y), + commitMutation, + ); + setGhost(null); + }; + const onRemove = (e: React.PointerEvent, index: number) => { + e.stopPropagation(); + if (!animId) return; + setHoverNode(null); + void commitRemoveWaypoint(animId, index, commitMutation); + }; + + const elementId = selection?.id ?? null; + // Right-click a keyframe node → the timeline's keyframe context menu (delete + // this keyframe / delete all), so motion-path keyframes are removable in place. + const onNodeContextMenu = (e: React.MouseEvent, ref: MotionNodeRef) => { + if (ref.type !== "keyframe" || !animId || !elementId) return; + e.preventDefault(); + e.stopPropagation(); + setKfMenu({ + x: e.clientX, + y: e.clientY, + elementId, + percentage: ref.pct, + tweenPercentage: ref.pct, + }); + }; + + return ( + <> + + {/* Wide transparent hit path drives the add-ghost; drawn under the nodes. */} + {structural && ( + setGhost(null)} + onPointerDown={onPathDown} + /> + )} + + {ghost && ( + + )} + {abs.map((p, i) => ( + setHoverNode(i)} + onLeave={() => setHoverNode((h) => (h === i ? null : h))} + onPointerDown={(e) => onDown(e, i, p.x, p.y, p.ref)} + onPointerMove={onMove} + onPointerUp={onUp} + onRemove={(e) => onRemove(e, i)} + onContextMenu={(e) => onNodeContextMenu(e, p.ref)} + /> + ))} + + {kfMenu && ( + setKfMenu(null)} + onDelete={(_elId, pct) => animId && handleGsapRemoveKeyframe(animId, pct)} + onDeleteAll={(elId) => handleGsapDeleteAllForElement(`#${elId}`)} + /> + )} + + ); +}); diff --git a/packages/studio/src/components/editor/domEditOverlayGeometry.ts b/packages/studio/src/components/editor/domEditOverlayGeometry.ts index 991d1f910c..20435e0aed 100644 --- a/packages/studio/src/components/editor/domEditOverlayGeometry.ts +++ b/packages/studio/src/components/editor/domEditOverlayGeometry.ts @@ -25,6 +25,66 @@ export function isElementVisibleForOverlay(el: HTMLElement): boolean { return isElementVisibleThroughAncestors(el); } +// Sample points (as fractions of the element box) for the occlusion hit-test. +const OCCLUSION_SAMPLE_POINTS: ReadonlyArray = [ + [0.5, 0.5], + [0.2, 0.2], + [0.8, 0.2], + [0.2, 0.8], + [0.8, 0.8], +]; + +/** Cumulative opacity of an element through its ancestors (0 if any link is ~0). */ +function effectiveOpacity(el: Element | null, win: Window): number { + let opacity = 1; + let current: Element | null = el; + while (current) { + const op = Number.parseFloat(win.getComputedStyle(current).opacity); + if (Number.isFinite(op)) opacity *= op; + if (opacity <= 0.01) return 0; + current = current.parentElement; + } + return opacity; +} + +/** + * True when the element is actually painted on screen — what the viewer sees in + * the preview. Extends `isElementVisibleForOverlay` (display/visibility/opacity) + * with an OCCLUSION test: this composition stacks scenes by z-index and fades them + * IN (never out), so an earlier scene's element stays opacity-1 yet is covered by a + * later opaque scene. + * + * Walks the painted stack (`elementsFromPoint`, top→bottom) at several sample points. + * A point "sees" the element if the element (or its subtree/ancestor) is reached + * before any unrelated element that's effectively opaque. Transparent covers (a + * faded-in scene still at opacity ~0) are skipped — they hit-test but don't paint. + * If every sampled point is blocked by an opaque cover, the element is hidden. + */ +export function isElementVisibleInPreview(el: HTMLElement): boolean { + if (!isElementVisibleForOverlay(el)) return false; + const doc = el.ownerDocument; + const win = doc.defaultView; + if (!win || typeof doc.elementsFromPoint !== "function") return true; + const rect = el.getBoundingClientRect(); + if (rect.width <= 0 || rect.height <= 0) return false; + + let sampledInViewport = false; + for (const [fx, fy] of OCCLUSION_SAMPLE_POINTS) { + const x = rect.left + rect.width * fx; + const y = rect.top + rect.height * fy; + if (x < 0 || y < 0 || x > win.innerWidth || y > win.innerHeight) continue; + sampledInViewport = true; + for (const hit of doc.elementsFromPoint(x, y)) { + if (hit === el || el.contains(hit) || hit.contains(el)) return true; // reached, uncovered + if (effectiveOpacity(hit, win) > 0.01) break; // opaque cover above → this point blocked + // transparent cover (e.g. a scene at opacity ~0) → ignore, keep descending + } + } + // Every in-viewport sample was blocked by an opaque cover → occluded. If nothing + // was testable (off-viewport), don't hide on this basis. + return !sampledInViewport; +} + function readPositiveDimension(value: string | null): number | null { if (!value) return null; const parsed = Number.parseFloat(value); diff --git a/packages/studio/src/components/editor/manualEditsDom.ts b/packages/studio/src/components/editor/manualEditsDom.ts index 98d05bde31..c7a9fee5e0 100644 --- a/packages/studio/src/components/editor/manualEditsDom.ts +++ b/packages/studio/src/components/editor/manualEditsDom.ts @@ -250,6 +250,50 @@ function stripGsapTranslateFromTransform(element: HTMLElement): void { } } +// GSAP owns the element's `transform` (it bakes x/y into a matrix and writes +// `translate: none` every tick). Folding the drag offset into a CSS `translate` +// — as the non-GSAP path does — composes ON TOP of GSAP's transform, and the +// subsequent strip/reapply math compounds into a runaway matrix that flings the +// element off-canvas. So for GSAP-animated elements we keep `translate: none` +// and push the offset straight into GSAP's x/y via gsap.set; the var() offset is +// still persisted (buildPathOffsetPatches), and GSAP re-reads it at init on +// reload. Returns true when handled as GSAP (caller must skip the CSS path). +function applyStudioPathOffsetViaGsap( + element: HTMLElement, + offset: { x: number; y: number }, +): boolean { + if (!gsapAnimatesProperty(element, "x", "y")) return false; + element.style.setProperty("translate", "none"); + const win = element.ownerDocument.defaultView as + | (Window & { + gsap?: { + set: (el: Element, vars: Record) => void; + getProperty: (el: Element, prop: string) => number; + }; + }) + | null; + if (win?.gsap) { + const baseX = Number.parseFloat(element.getAttribute("data-hf-drag-gsap-base-x") ?? ""); + const baseY = Number.parseFloat(element.getAttribute("data-hf-drag-gsap-base-y") ?? ""); + const origX = Number.parseFloat(element.getAttribute("data-hf-drag-initial-offset-x") ?? ""); + const origY = Number.parseFloat(element.getAttribute("data-hf-drag-initial-offset-y") ?? ""); + const gsapBaseX = Number.isFinite(baseX) + ? baseX + : (win.gsap.getProperty(element, "x") as number); + const gsapBaseY = Number.isFinite(baseY) + ? baseY + : (win.gsap.getProperty(element, "y") as number); + if (!Number.isFinite(baseX)) + element.setAttribute("data-hf-drag-gsap-base-x", String(gsapBaseX)); + if (!Number.isFinite(baseY)) + element.setAttribute("data-hf-drag-gsap-base-y", String(gsapBaseY)); + const deltaX = offset.x - (Number.isFinite(origX) ? origX : 0); + const deltaY = offset.y - (Number.isFinite(origY) ? origY : 0); + win.gsap.set(element, { x: gsapBaseX + deltaX, y: gsapBaseY + deltaY }); + } + return true; +} + export function applyStudioPathOffset( element: HTMLElement, offset: { x: number; y: number }, @@ -257,6 +301,10 @@ export function applyStudioPathOffset( ): void { promoteInlineForTransform(element); writeStudioPathOffsetVars(element, offset, { updateBase: options.updateBase ?? true }); + // GSAP elements: route through gsap.set, NOT a CSS translate (would corrupt the + // matrix). Symmetrical with applyStudioPathOffsetDraft — the commit path used to + // skip this branch, which is what flung dragged GSAP elements off-canvas. + if (applyStudioPathOffsetViaGsap(element, offset)) return; element.style.setProperty( "translate", composeTranslateValue( @@ -274,45 +322,13 @@ export function applyStudioPathOffsetDraft( ): void { promoteInlineForTransform(element); writeStudioPathOffsetVars(element, offset, { updateBase: false }); - - const isGsapAnimated = gsapAnimatesProperty(element, "x", "y"); - if (isGsapAnimated) { - element.style.setProperty("translate", "none"); - const win = element.ownerDocument.defaultView as - | (Window & { - gsap?: { - set: (el: Element, vars: Record) => void; - getProperty: (el: Element, prop: string) => number; - }; - }) - | null; - if (win?.gsap) { - const baseX = Number.parseFloat(element.getAttribute("data-hf-drag-gsap-base-x") ?? ""); - const baseY = Number.parseFloat(element.getAttribute("data-hf-drag-gsap-base-y") ?? ""); - const origX = Number.parseFloat(element.getAttribute("data-hf-drag-initial-offset-x") ?? ""); - const origY = Number.parseFloat(element.getAttribute("data-hf-drag-initial-offset-y") ?? ""); - const gsapBaseX = Number.isFinite(baseX) - ? baseX - : (win.gsap.getProperty(element, "x") as number); - const gsapBaseY = Number.isFinite(baseY) - ? baseY - : (win.gsap.getProperty(element, "y") as number); - if (!Number.isFinite(baseX)) - element.setAttribute("data-hf-drag-gsap-base-x", String(gsapBaseX)); - if (!Number.isFinite(baseY)) - element.setAttribute("data-hf-drag-gsap-base-y", String(gsapBaseY)); - const deltaX = offset.x - (Number.isFinite(origX) ? origX : 0); - const deltaY = offset.y - (Number.isFinite(origY) ? origY : 0); - win.gsap.set(element, { x: gsapBaseX + deltaX, y: gsapBaseY + deltaY }); - } - } else { - // Non-GSAP elements: use CSS translate as before. - element.style.setProperty( - "translate", - composeTranslateValue(element, `${Math.round(offset.x)}px`, `${Math.round(offset.y)}px`), - ); - stripGsapTranslateFromTransform(element); - } + if (applyStudioPathOffsetViaGsap(element, offset)) return; + // Non-GSAP elements: use CSS translate as before. + element.style.setProperty( + "translate", + composeTranslateValue(element, `${Math.round(offset.x)}px`, `${Math.round(offset.y)}px`), + ); + stripGsapTranslateFromTransform(element); } /* ── Box size apply ───────────────────────────────────────────────── */ diff --git a/packages/studio/src/components/editor/manualEditsDomGsap.test.ts b/packages/studio/src/components/editor/manualEditsDomGsap.test.ts new file mode 100644 index 0000000000..9800e556ab --- /dev/null +++ b/packages/studio/src/components/editor/manualEditsDomGsap.test.ts @@ -0,0 +1,82 @@ +// @vitest-environment jsdom +import { afterEach, describe, expect, it, vi } from "vitest"; +import { applyStudioPathOffset, applyStudioPathOffsetDraft } from "./manualEditsDom"; + +/** + * Regression: dragging a GSAP-animated element (e.g. a flat `to(#el, {x})` tween) + * must NOT fold the offset into a CSS `translate`. GSAP owns `style.transform`, so + * a CSS translate composes on top of it and the strip/reapply math compounds into + * a runaway matrix that flings the element off-canvas. Both the live draft and the + * commit must instead push the offset into GSAP's x/y via gsap.set and keep + * `translate: none`. Before the fix, the commit (applyStudioPathOffset) skipped the + * GSAP branch the draft already had — that asymmetry caused the off-canvas jump. + */ + +function makeGsapWindow( + el: HTMLElement, + gsapSet: (e: Element, v: Record) => void, +) { + const win = el.ownerDocument.defaultView as unknown as { + __timelines?: Record; + gsap?: unknown; + }; + win.__timelines = { + playground: { + getChildren: () => [{ targets: () => [el], vars: { x: -260 } }], + }, + }; + win.gsap = { + set: gsapSet, + getProperty: () => 0, + }; +} + +afterEach(() => { + const win = window as unknown as { __timelines?: unknown; gsap?: unknown }; + delete win.__timelines; + delete win.gsap; +}); + +describe("applyStudioPathOffset — GSAP-owned transform", () => { + it("non-GSAP element folds the offset into a CSS translate var()", () => { + const el = document.createElement("div"); + document.body.appendChild(el); + + applyStudioPathOffset(el, { x: -120, y: 40 }); + + expect(el.style.translate).toContain("var(--hf-studio-offset-x"); + expect(el.style.getPropertyValue("--hf-studio-offset-x")).toBe("-120px"); + expect(el.style.getPropertyValue("--hf-studio-offset-y")).toBe("40px"); + }); + + it("GSAP element keeps translate:none and routes the offset through gsap.set", () => { + const el = document.createElement("div"); + el.id = "puck-a"; + document.body.appendChild(el); + const gsapSet = vi.fn(); + makeGsapWindow(el, gsapSet); + + applyStudioPathOffset(el, { x: -409, y: 398 }); + + // No CSS translate to collide with GSAP's transform. + expect(el.style.translate).toBe("none"); + expect(el.style.translate).not.toContain("var("); + // Offset pushed into GSAP's x/y (gsapBase 0 + delta = the offset itself here). + expect(gsapSet).toHaveBeenCalledWith(el, { x: -409, y: 398 }); + }); + + it("draft and commit treat a GSAP element identically (translate:none)", () => { + const el = document.createElement("div"); + el.id = "puck-a"; + document.body.appendChild(el); + makeGsapWindow(el, vi.fn()); + + applyStudioPathOffsetDraft(el, { x: -50, y: 10 }); + const draftTranslate = el.style.translate; + applyStudioPathOffset(el, { x: -50, y: 10 }); + const commitTranslate = el.style.translate; + + expect(draftTranslate).toBe("none"); + expect(commitTranslate).toBe("none"); + }); +}); diff --git a/packages/studio/src/components/editor/motionPathCommit.test.ts b/packages/studio/src/components/editor/motionPathCommit.test.ts new file mode 100644 index 0000000000..823b8eb36d --- /dev/null +++ b/packages/studio/src/components/editor/motionPathCommit.test.ts @@ -0,0 +1,118 @@ +import { describe, it, expect, vi } from "vitest"; +import type { GsapAnimation } from "@hyperframes/core/gsap-parser"; +import { editableAnimationId } from "./motionPathSelection"; +import { + commitNode, + commitAddWaypoint, + commitRemoveWaypoint, + commitCreatePath, +} from "./motionPathCommit"; + +const anim = (over: Partial): GsapAnimation => + ({ + id: "a1", + targetSelector: "#el", + method: "to", + position: 0, + properties: {}, + ...over, + }) as GsapAnimation; + +describe("editableAnimationId", () => { + it("picks the arc animation for an arc path", () => { + const arc = anim({ id: "arc1", arcPath: { enabled: true, autoRotate: false, segments: [] } }); + expect(editableAnimationId([anim({ id: "other" }), arc], "arc")).toBe("arc1"); + }); + + it("picks a position-keyframe animation for a linear path", () => { + const kf = anim({ + id: "kf1", + propertyGroup: "position", + keyframes: { + format: "percentage", + keyframes: [{ percentage: 0, properties: { x: 0, y: 0 } }], + } as never, + }); + expect(editableAnimationId([kf], "linear")).toBe("kf1"); + }); + + it("returns null for dynamic (unresolved) tweens — read-only", () => { + const dyn = anim({ + id: "dyn", + arcPath: { enabled: true, autoRotate: false, segments: [] }, + hasUnresolvedKeyframes: true, + }); + expect(editableAnimationId([dyn], "arc")).toBeNull(); + }); + + it("returns null for non-literal (helper) provenance — read-only", () => { + const helper = anim({ + id: "h", + arcPath: { enabled: true, autoRotate: false, segments: [] }, + provenance: { kind: "helper" } as never, + }); + expect(editableAnimationId([helper], "arc")).toBeNull(); + }); + + it("returns null when nothing matches", () => { + expect(editableAnimationId([anim({ id: "x" })], "linear")).toBeNull(); + }); +}); + +describe("commitNode", () => { + it("routes a keyframe node to update-keyframe by percentage", async () => { + const commit = vi.fn().mockResolvedValue(undefined); + await commitNode({ type: "keyframe", pct: 50 }, 120, 30, "a1", commit); + expect(commit).toHaveBeenCalledWith( + { type: "update-keyframe", animationId: "a1", percentage: 50, properties: { x: 120, y: 30 } }, + expect.objectContaining({ softReload: true }), + ); + }); + + it("routes a waypoint node to update-motion-path-point by index", async () => { + const commit = vi.fn().mockResolvedValue(undefined); + await commitNode({ type: "waypoint", index: 2 }, 80, 40, "a1", commit); + expect(commit).toHaveBeenCalledWith( + { type: "update-motion-path-point", animationId: "a1", pointIndex: 2, x: 80, y: 40 }, + expect.objectContaining({ softReload: true }), + ); + }); +}); + +describe("commitAddWaypoint / commitRemoveWaypoint", () => { + it("adds a waypoint at an index with coordinates", async () => { + const commit = vi.fn().mockResolvedValue(undefined); + await commitAddWaypoint("a1", 1, 120, -40, commit); + expect(commit).toHaveBeenCalledWith( + { type: "add-motion-path-point", animationId: "a1", index: 1, x: 120, y: -40 }, + expect.objectContaining({ softReload: true }), + ); + }); + + it("removes a waypoint by index", async () => { + const commit = vi.fn().mockResolvedValue(undefined); + await commitRemoveWaypoint("a1", 2, commit); + expect(commit).toHaveBeenCalledWith( + { type: "remove-motion-path-point", animationId: "a1", index: 2 }, + expect.objectContaining({ softReload: true }), + ); + }); +}); + +describe("commitCreatePath", () => { + it("authors a new motionPath to a destination at a given time", async () => { + const commit = vi.fn().mockResolvedValue(undefined); + await commitCreatePath("#title", 2.0, 300, -120, commit); + expect(commit).toHaveBeenCalledWith( + { + type: "add-motion-path", + targetSelector: "#title", + position: 2.0, + duration: 1.5, + x: 300, + y: -120, + }, + expect.objectContaining({ softReload: true }), + ); + }); +}); diff --git a/packages/studio/src/components/editor/motionPathCommit.ts b/packages/studio/src/components/editor/motionPathCommit.ts new file mode 100644 index 0000000000..5a9b00d0a4 --- /dev/null +++ b/packages/studio/src/components/editor/motionPathCommit.ts @@ -0,0 +1,67 @@ +/** + * Commit helpers for the motion-path overlay. Each maps a canvas gesture to a + * GSAP source mutation routed through the (selection-bound) commit facade, which + * handles the soft reload, undo snapshot, and save-failure feedback. + */ +import type { MotionNodeRef } from "./motionPathGeometry"; + +export type CommitFn = ( + mutation: Record, + options: { label: string; softReload?: boolean }, +) => Promise; + +const NEW_PATH_DURATION = 1.5; + +export function commitNode( + ref: MotionNodeRef, + x: number, + y: number, + animationId: string, + commit: CommitFn, +): Promise { + const mutation: Record = + ref.type === "keyframe" + ? { type: "update-keyframe", animationId, percentage: ref.pct, properties: { x, y } } + : { type: "update-motion-path-point", animationId, pointIndex: ref.index, x, y }; + return commit(mutation, { + label: ref.type === "keyframe" ? "Move keyframe" : "Move waypoint", + softReload: true, + }); +} + +export function commitAddWaypoint( + animationId: string, + index: number, + x: number, + y: number, + commit: CommitFn, +): Promise { + return commit( + { type: "add-motion-path-point", animationId, index, x, y }, + { label: "Add waypoint", softReload: true }, + ); +} + +export function commitRemoveWaypoint( + animationId: string, + index: number, + commit: CommitFn, +): Promise { + return commit( + { type: "remove-motion-path-point", animationId, index }, + { label: "Remove waypoint", softReload: true }, + ); +} + +export function commitCreatePath( + targetSelector: string, + position: number, + x: number, + y: number, + commit: CommitFn, +): Promise { + return commit( + { type: "add-motion-path", targetSelector, position, duration: NEW_PATH_DURATION, x, y }, + { label: "Create motion path", softReload: true }, + ); +} diff --git a/packages/studio/src/components/editor/motionPathGeometry.test.ts b/packages/studio/src/components/editor/motionPathGeometry.test.ts new file mode 100644 index 0000000000..62a0f8e4c3 --- /dev/null +++ b/packages/studio/src/components/editor/motionPathGeometry.test.ts @@ -0,0 +1,122 @@ +import { describe, it, expect } from "vitest"; +import { buildMotionPathGeometry, nearestPointOnPath } from "./motionPathGeometry"; +import type { ReadTween } from "../../hooks/gsapRuntimeKeyframes"; + +const kf = (percentage: number, x: number, y: number) => ({ percentage, properties: { x, y } }); + +describe("buildMotionPathGeometry", () => { + it("builds a linear path with keyframe-ref nodes from an x/y tween", () => { + const read: ReadTween = { keyframes: [kf(0, 10, 20), kf(100, 200, 80)] }; + const geo = buildMotionPathGeometry(read); + expect(geo).not.toBeNull(); + expect(geo!.kind).toBe("linear"); + expect(geo!.points).toBe("10,20 200,80"); + expect(geo!.nodes).toEqual([ + { x: 10, y: 20, ref: { type: "keyframe", pct: 0 } }, + { x: 200, y: 80, ref: { type: "keyframe", pct: 100 } }, + ]); + }); + + it("preserves order and percentages for intermediate keyframes", () => { + const read: ReadTween = { keyframes: [kf(0, 0, 0), kf(50, 50, 90), kf(100, 100, 0)] }; + const geo = buildMotionPathGeometry(read); + expect(geo!.nodes.map((n) => n.ref)).toEqual([ + { type: "keyframe", pct: 0 }, + { type: "keyframe", pct: 50 }, + { type: "keyframe", pct: 100 }, + ]); + }); + + it("builds an arc path with waypoint-index refs when arcPath is present", () => { + const read: ReadTween = { + keyframes: [kf(0, 0, 0), kf(50, 60, 40), kf(100, 120, 10)], + arcPath: { enabled: true, autoRotate: false, segments: [{ curviness: 1 }, { curviness: 1 }] }, + }; + const geo = buildMotionPathGeometry(read); + expect(geo!.kind).toBe("arc"); + expect(geo!.nodes.map((n) => n.ref)).toEqual([ + { type: "waypoint", index: 0 }, + { type: "waypoint", index: 1 }, + { type: "waypoint", index: 2 }, + ]); + }); + + it("returns null for a tween with no positional keyframes", () => { + const read: ReadTween = { + keyframes: [ + { percentage: 0, properties: { opacity: 0 } }, + { percentage: 100, properties: { opacity: 1 } }, + ], + }; + expect(buildMotionPathGeometry(read)).toBeNull(); + }); + + it("draws a single-axis (x-only) tween, defaulting the missing axis to 0", () => { + // Regression: an `x`-only tween (e.g. `to({ x: -260 })`) carries no `y`, so the + // builder used to skip every node → no path until the user added the 2nd axis. + const read: ReadTween = { + keyframes: [ + { percentage: 0, properties: { x: 0 } }, + { percentage: 100, properties: { x: -260 } }, + ], + }; + const geo = buildMotionPathGeometry(read); + expect(geo).not.toBeNull(); + expect(geo!.points).toBe("0,0 -260,0"); // y defaults to 0 → horizontal path + }); + + it("draws a y-only tween too (x defaults to 0)", () => { + const read: ReadTween = { + keyframes: [ + { percentage: 0, properties: { y: 0 } }, + { percentage: 100, properties: { y: 500 } }, + ], + }; + expect(buildMotionPathGeometry(read)!.points).toBe("0,0 0,500"); + }); + + it("excludes keyframes missing a coordinate without throwing", () => { + const read: ReadTween = { + keyframes: [kf(0, 10, 20), { percentage: 50, properties: { x: 100 } }, kf(100, 200, 80)], + }; + const geo = buildMotionPathGeometry(read); + expect(geo!.nodes).toHaveLength(2); + expect(geo!.points).toBe("10,20 200,80"); + }); + + it("returns null when fewer than two valid nodes remain", () => { + const read: ReadTween = { keyframes: [kf(0, 10, 20)] }; + expect(buildMotionPathGeometry(read)).toBeNull(); + }); + + it("returns null for null input", () => { + expect(buildMotionPathGeometry(null)).toBeNull(); + }); +}); + +describe("nearestPointOnPath", () => { + const nodes = [ + { x: 0, y: 0 }, + { x: 100, y: 0 }, + { x: 100, y: 100 }, + ]; + + it("projects onto the nearest segment and reports its index", () => { + const p = nearestPointOnPath(50, 20, nodes); + expect(p).toEqual({ x: 50, y: 0, segIndex: 0, dist: 20 }); + }); + + it("picks the second segment when closer to it", () => { + const p = nearestPointOnPath(120, 50, nodes); + expect(p).toMatchObject({ x: 100, y: 50, segIndex: 1 }); + }); + + it("clamps to an endpoint when the projection falls past the segment", () => { + const p = nearestPointOnPath(-40, -10, nodes); + expect(p).toMatchObject({ x: 0, y: 0, segIndex: 0 }); + }); + + it("returns null for fewer than two nodes", () => { + expect(nearestPointOnPath(0, 0, [{ x: 0, y: 0 }])).toBeNull(); + }); +}); diff --git a/packages/studio/src/components/editor/motionPathGeometry.ts b/packages/studio/src/components/editor/motionPathGeometry.ts new file mode 100644 index 0000000000..8fe9fc7739 --- /dev/null +++ b/packages/studio/src/components/editor/motionPathGeometry.ts @@ -0,0 +1,102 @@ +/** + * Convert a live tween (from `readRuntimeKeyframes`) into renderable motion-path + * geometry for the on-canvas overlay. Pure — no React/DOM — so it unit-tests in + * isolation. Coordinates are in composition space (the same space the overlay's + * viewBox uses), so the caller renders nodes/points directly. + */ +import type { ReadTween } from "../../hooks/gsapRuntimeKeyframes"; + +/** Which source edit a dragged node maps to. */ +export type MotionNodeRef = + | { type: "keyframe"; pct: number } // x/y position keyframe at this tween-relative % + | { type: "waypoint"; index: number }; // motionPath waypoint (anchor) at this index + +export interface MotionPathNode { + x: number; + y: number; + ref: MotionNodeRef; +} + +export interface MotionPathGeometry { + /** "linear" = x/y keyframes; "arc" = motionPath tween. */ + kind: "linear" | "arc"; + /** SVG polyline points: "x,y x,y ...". */ + points: string; + nodes: MotionPathNode[]; +} + +/** + * Build motion-path geometry, or null when the tween carries no positional path + * (fewer than two keyframes with both x and y). For motionPath tweens the + * keyframes are the arc waypoints (anchors), index-aligned with the source path + * — so a waypoint node at index `i` rewrites source waypoint `i`. + * + * ponytail: the arc is drawn as a polyline through its waypoints (matching the + * angular dotted look of the reference), not GSAP's resolved curve. Dense + * curve sampling is a later refinement if the straight-segment preview proves + * insufficient. + */ +/** + * Nearest point on a polyline to (px, py), with the index of the segment it + * lies on. Used to position the ghost "add" node and decide where a new + * waypoint is inserted (between `segIndex` and `segIndex + 1`). Coordinates are + * whatever space the caller passes (the overlay uses absolute composition px). + */ +export function nearestPointOnPath( + px: number, + py: number, + nodes: Array<{ x: number; y: number }>, +): { x: number; y: number; segIndex: number; dist: number } | null { + if (nodes.length < 2) return null; + let best: { x: number; y: number; segIndex: number; dist: number } | null = null; + for (let i = 0; i < nodes.length - 1; i++) { + const a = nodes[i]!; + const b = nodes[i + 1]!; + const dx = b.x - a.x; + const dy = b.y - a.y; + const len2 = dx * dx + dy * dy; + const t = len2 === 0 ? 0 : Math.max(0, Math.min(1, ((px - a.x) * dx + (py - a.y) * dy) / len2)); + const cx = a.x + t * dx; + const cy = a.y + t * dy; + const dist = Math.hypot(px - cx, py - cy); + if (!best || dist < best.dist) best = { x: cx, y: cy, segIndex: i, dist }; + } + return best; +} + +export function buildMotionPathGeometry(read: ReadTween | null): MotionPathGeometry | null { + if (!read) return null; + const isArc = Boolean(read.arcPath); + const nodes: MotionPathNode[] = []; + + // Index by source position so a waypoint node maps to the matching source + // anchor. Arc waypoints always carry x/y (never filtered), so source index + // and node order stay aligned. + // Which axes does the tween animate at all? A single-axis tween (e.g. + // `to({ x: -260 })`) only carries x; its y stays at the base (0, the GSAP + // transform identity), so we default it and still draw a path. But if the tween + // DOES animate an axis and a given keyframe omits it, that value is interpolated + // (not 0) and can't be placed here → skip that node (the prior behavior). + const finite = (v: unknown): v is number => typeof v === "number" && isFinite(v); + const tweenHasX = read.keyframes.some((kf) => finite(kf.properties.x)); + const tweenHasY = read.keyframes.some((kf) => finite(kf.properties.y)); + if (!tweenHasX && !tweenHasY) return null; // no positional motion (opacity/scale only) + + read.keyframes.forEach((kf, i) => { + if (tweenHasX && !finite(kf.properties.x)) return; + if (tweenHasY && !finite(kf.properties.y)) return; + nodes.push({ + x: tweenHasX ? (kf.properties.x as number) : 0, + y: tweenHasY ? (kf.properties.y as number) : 0, + ref: isArc ? { type: "waypoint", index: i } : { type: "keyframe", pct: kf.percentage }, + }); + }); + + if (nodes.length < 2) return null; + + return { + kind: isArc ? "arc" : "linear", + points: nodes.map((n) => `${n.x},${n.y}`).join(" "), + nodes, + }; +} diff --git a/packages/studio/src/components/editor/motionPathSelection.ts b/packages/studio/src/components/editor/motionPathSelection.ts new file mode 100644 index 0000000000..333290be44 --- /dev/null +++ b/packages/studio/src/components/editor/motionPathSelection.ts @@ -0,0 +1,33 @@ +/** + * Resolving the selected element and the animation whose path is editable. + * Shared by the overlay and its diagnostics (kept here to avoid a circular + * import between the two). + */ +import type { GsapAnimation } from "@hyperframes/core/gsap-parser"; +import type { DomEditSelection } from "./domEditing"; + +export function selectorFor(sel: DomEditSelection | null): string | null { + if (!sel) return null; + if (sel.id) return `#${CSS.escape(sel.id)}`; + return sel.selector ?? null; +} + +/** The animation whose path is editable on-canvas: literal, statically resolved, + * and matching the rendered geometry kind. Returns null when the path can only + * be displayed (dynamic/helper tweens) — those nodes stay read-only. */ +export function editableAnimationId( + animations: GsapAnimation[], + kind: "linear" | "arc", +): string | null { + const ok = (a: GsapAnimation) => + !a.hasUnresolvedKeyframes && !a.hasUnresolvedSelector && !a.provenance; + if (kind === "arc") return animations.find((a) => a.arcPath?.enabled && ok(a))?.id ?? null; + const a = animations.find( + (anim) => + anim.keyframes && + ok(anim) && + (anim.propertyGroup === "position" || + anim.keyframes.keyframes.some((k) => "x" in k.properties || "y" in k.properties)), + ); + return a?.id ?? null; +} diff --git a/packages/studio/src/components/editor/useDomEditOverlayRects.ts b/packages/studio/src/components/editor/useDomEditOverlayRects.ts index a0c72e0837..a922d4404f 100644 --- a/packages/studio/src/components/editor/useDomEditOverlayRects.ts +++ b/packages/studio/src/components/editor/useDomEditOverlayRects.ts @@ -10,7 +10,7 @@ import { type OverlayRect, type ResolvedElementRef, groupOverlayItemsEqual, - isElementVisibleForOverlay, + isElementVisibleInPreview, rectsEqual, resolveElementForOverlay, selectionCacheKey, @@ -148,7 +148,7 @@ export function useDomEditOverlayRects({ activeCompositionPathRef.current, resolvedElementRef as ResolvedElementRef, ); - if (el && isElementVisibleForOverlay(el)) { + if (el && isElementVisibleInPreview(el)) { const nextRect = toOverlayRect(overlayEl, iframe, el); setOverlayRect(nextRect); const descendants = el.querySelectorAll("*"); diff --git a/packages/studio/src/components/nle/NLELayout.tsx b/packages/studio/src/components/nle/NLELayout.tsx index 8f67172b61..234f3d22f6 100644 --- a/packages/studio/src/components/nle/NLELayout.tsx +++ b/packages/studio/src/components/nle/NLELayout.tsx @@ -427,7 +427,7 @@ export const NLELayout = memo(function NLELayout({ {/* Preview + player controls */}
{ const el = iframeRef.current?.parentElement ?? iframeRef.current; diff --git a/packages/studio/src/components/renders/RenderQueue.tsx b/packages/studio/src/components/renders/RenderQueue.tsx index f3362843e2..e1de4f21c2 100644 --- a/packages/studio/src/components/renders/RenderQueue.tsx +++ b/packages/studio/src/components/renders/RenderQueue.tsx @@ -119,7 +119,7 @@ const FORMAT_INFO: Record<"mp4" | "webm" | "mov", { label: string; desc: string mp4: { label: "MP4", desc: "Best for general use. Smallest file, universal playback." }, mov: { label: "MOV (ProRes 4444)", - desc: "Transparent video. Works in CapCut, Final Cut Pro, Premiere, DaVinci Resolve, After Effects. Large files.", + desc: "Transparent video. Works in Final Cut Pro, DaVinci Resolve, and most video editors. Large files.", }, webm: { label: "WebM (VP9)", diff --git a/packages/studio/src/hooks/gsapDragCommit.test.ts b/packages/studio/src/hooks/gsapDragCommit.test.ts new file mode 100644 index 0000000000..af9b893b19 --- /dev/null +++ b/packages/studio/src/hooks/gsapDragCommit.test.ts @@ -0,0 +1,258 @@ +import { describe, expect, it, beforeEach } from "vitest"; +import type { GsapAnimation } from "@hyperframes/core/gsap-parser"; +import type { DomEditSelection } from "../components/editor/domEditingTypes"; +import { + commitGsapPositionFromDrag, + parkPlayheadOnKeyframe, + type GsapDragCommitCallbacks, +} from "./gsapDragCommit"; +import { usePlayerStore } from "../player/store/playerStore"; + +// Minimal selection whose element has no drag-baseline attributes (origX/Y = 0). +const selection = (): DomEditSelection => + ({ + id: "puck-a", + selector: "#puck-a", + element: { + style: { getPropertyValue: () => "", setProperty: () => {} }, + getAttribute: () => null, + removeAttribute: () => {}, + getBoundingClientRect: () => ({ top: 0, left: 0 }), + }, + }) as unknown as DomEditSelection; + +const flatTween = (): GsapAnimation => + ({ + id: "#puck-a-to", + targetSelector: "#puck-a", + method: "to", + resolvedStart: 1.2, + duration: 2.2, + properties: { x: -260 }, + }) as unknown as GsapAnimation; + +// What the flat tween becomes after convert-to-keyframes (returned by fetchAnimations). +const convertedTween = (): GsapAnimation => + ({ + id: "#puck-a-converted", + targetSelector: "#puck-a", + method: "to", + resolvedStart: 1.2, + duration: 2.2, + keyframes: { + keyframes: [ + { percentage: 0, properties: { x: 0, y: 0 } }, + { percentage: 100, properties: { x: -260, y: 0 } }, + ], + }, + }) as unknown as GsapAnimation; + +function recordingCallbacks(): { + types: string[]; + mutations: Array>; + callbacks: GsapDragCommitCallbacks; +} { + const types: string[] = []; + const mutations: Array> = []; + return { + types, + mutations, + callbacks: { + commitMutation: async (_sel, mutation) => { + types.push(mutation.type as string); + mutations.push(mutation); + }, + fetchAnimations: async () => [convertedTween()], + }, + }; +} + +describe("commitGsapPositionFromDrag — flat tween", () => { + beforeEach(() => { + usePlayerStore.setState({ currentTime: 0, activeKeyframePct: null }); + }); + + it("extends the existing tween (never spawns a parallel one) when dragged OUTSIDE its range", async () => { + usePlayerStore.setState({ currentTime: 6 }); // outside [1.2, 3.4] + const { types, callbacks } = recordingCallbacks(); + + await commitGsapPositionFromDrag( + selection(), + flatTween(), + { x: -100, y: 0 }, + { x: 0, y: 0 }, + null, + "#puck-a", + callbacks, + ); + + expect(types).toContain("convert-to-keyframes"); + expect(types).toContain("replace-with-keyframes"); // the extend + expect(types).not.toContain("add-with-keyframes"); // regression: no parallel tween + }); + + it("adds a keyframe at the playhead when dragged INSIDE its range", async () => { + usePlayerStore.setState({ currentTime: 2 }); // inside [1.2, 3.4] + const { types, callbacks } = recordingCallbacks(); + + await commitGsapPositionFromDrag( + selection(), + flatTween(), + { x: -100, y: 0 }, + { x: 0, y: 0 }, + null, + "#puck-a", + callbacks, + ); + + expect(types).toContain("add-keyframe"); + expect(types).not.toContain("add-with-keyframes"); + }); + + it("MODIFIES the selected keyframe (no extend) when one is selected, even past the tween end", async () => { + // User clicked the 100% diamond (activeKeyframePct=100), playhead drifted past + // the end. Expect: convert + add-keyframe AT 100% — not replace-with-keyframes. + usePlayerStore.setState({ currentTime: 6, activeKeyframePct: 100 }); // outside [1.2, 3.4] + const { types, mutations, callbacks } = recordingCallbacks(); + + await commitGsapPositionFromDrag( + selection(), + flatTween(), + { x: -100, y: 0 }, + { x: 0, y: 0 }, + null, + "#puck-a", + callbacks, + ); + + expect(types).toContain("add-keyframe"); + expect(types).not.toContain("replace-with-keyframes"); // not extended + const addKf = mutations.find((m) => m.type === "add-keyframe"); + expect(addKf?.percentage).toBe(100); // modified the selected endpoint + // consumed: cleared so the next free drag doesn't keep modifying + expect(usePlayerStore.getState().activeKeyframePct).toBeNull(); + // parked the playhead on the edited keyframe (1.2 start + 100% * 2.2 dur), + // so the edit is visible instead of rendering the base pose + expect(usePlayerStore.getState().requestedSeekTime).toBe(3.4); + }); +}); + +describe("commitGsapPositionFromDrag — keyframed tween backfill", () => { + beforeEach(() => { + usePlayerStore.setState({ currentTime: 0, activeKeyframePct: null }); + }); + + const keyframedTween = (): GsapAnimation => + ({ + id: "#puck-a-kf", + targetSelector: "#puck-a", + method: "to", + resolvedStart: 1.2, + duration: 2.2, + keyframes: { + keyframes: [ + { percentage: 0, properties: { x: 0 } }, + { percentage: 100, properties: { x: -260 } }, + ], + }, + }) as unknown as GsapAnimation; + + it("passes backfillDefaults so a newly-introduced prop doesn't move the other keyframes", async () => { + // Drag the 0% keyframe DOWN (introduces y on an x-only tween). The add-keyframe + // must carry backfillDefaults at the element's base so 100% gets y:0, not y:780. + usePlayerStore.setState({ currentTime: 1.2, activeKeyframePct: 0 }); + const { mutations, callbacks } = recordingCallbacks(); + + await commitGsapPositionFromDrag( + selection(), + keyframedTween(), + { x: 0, y: 780 }, // studioOffset: dragged straight down + { x: 0, y: 0 }, // gsapPos → base falls back to {0,0} (selection has no base attrs) + null, + "#puck-a", + callbacks, + ); + + const addKf = mutations.find((m) => m.type === "add-keyframe"); + expect(addKf).toBeDefined(); + expect(addKf?.percentage).toBe(0); // edited the selected 0% keyframe + expect(addKf?.properties).toMatchObject({ y: 780 }); + expect(addKf?.backfillDefaults).toEqual({ x: 0, y: 0 }); // base → 100% gets y:0 + }); +}); + +describe("commitGsapPositionFromDrag — from() tween dragged outside its range", () => { + beforeEach(() => usePlayerStore.setState({ currentTime: 0, activeKeyframePct: null })); + + const fromTween = (): GsapAnimation => + ({ + id: "#title-from-400", + targetSelector: "#title", + method: "from", + resolvedStart: 0.4, + duration: 0.9, + properties: { y: 70 }, + }) as unknown as GsapAnimation; + + it("REPLACES the split position from() tween (no parallel tween → no drop jump)", async () => { + usePlayerStore.setState({ currentTime: 2.13 }); // outside [0.4, 1.3] + const types: string[] = []; + const mutations: Array> = []; + const callbacks: GsapDragCommitCallbacks = { + commitMutation: async (_s, m) => { + types.push(m.type as string); + mutations.push(m); + }, + // After split-into-property-groups, the position group is a from() tween (no keyframes). + fetchAnimations: async () => [ + { + id: "#title-from-400-position", + targetSelector: "#title", + method: "from", + propertyGroup: "position", + resolvedStart: 0.4, + duration: 0.9, + properties: { y: 70 }, + } as unknown as GsapAnimation, + ], + }; + + await commitGsapPositionFromDrag( + selection(), + fromTween(), + { x: 0, y: -333 }, + { x: 0, y: 0 }, + null, + "#title", + callbacks, + ); + + expect(types).toContain("split-into-property-groups"); + expect(types).toContain("replace-with-keyframes"); + expect(types).not.toContain("add-with-keyframes"); // regression: no parallel tween + const replace = mutations.find((m) => m.type === "replace-with-keyframes"); + expect(replace?.animationId).toBe("#title-from-400-position"); // replaces the split from() + }); +}); + +describe("parkPlayheadOnKeyframe", () => { + beforeEach(() => usePlayerStore.setState({ requestedSeekTime: null })); + + const tween = (): GsapAnimation => + ({ + id: "#x", + targetSelector: "#x", + method: "to", + resolvedStart: 1.2, + duration: 2.2, + }) as unknown as GsapAnimation; + + it("seeks to the keyframe's absolute time so the element previews AT it, not at base", () => { + parkPlayheadOnKeyframe(tween(), 0); // tween start + expect(usePlayerStore.getState().requestedSeekTime).toBe(1.2); + parkPlayheadOnKeyframe(tween(), 100); // tween end + expect(usePlayerStore.getState().requestedSeekTime).toBe(3.4); + parkPlayheadOnKeyframe(tween(), 50); // midpoint + expect(usePlayerStore.getState().requestedSeekTime).toBe(2.3); + }); +}); diff --git a/packages/studio/src/hooks/gsapDragCommit.ts b/packages/studio/src/hooks/gsapDragCommit.ts index dffc1b07a6..8ab3599ebd 100644 --- a/packages/studio/src/hooks/gsapDragCommit.ts +++ b/packages/studio/src/hooks/gsapDragCommit.ts @@ -32,6 +32,17 @@ export function computeCurrentPercentage( return computeElementPercentage(usePlayerStore.getState().currentTime, selection, animation); } +// When a drag edits a SELECTED keyframe, park the playhead on that keyframe's exact +// time. Otherwise the playhead can sit a frame outside the tween (e.g. 1.1666 vs a +// 1.2 start), so the post-commit reseek renders the element's base pose and the edit +// looks like it snapped away. Keeping the playhead on the edited keyframe avoids that. +export function parkPlayheadOnKeyframe(anim: GsapAnimation, pct: number): void { + const ts = resolveTweenStart(anim); + const td = resolveTweenDuration(anim); + if (ts == null || !td || td <= 0) return; + usePlayerStore.getState().requestSeek(roundTo3(ts + (pct / 100) * td)); +} + // ── Dynamic keyframe materialization ────────────────────────────────────── export async function materializeIfDynamic( @@ -93,6 +104,7 @@ async function extendTweenAndAddKeyframe( tweenDuration: number, callbacks: GsapDragCommitCallbacks, beforeReload?: () => void, + backfillDefaults?: Record, ): Promise { const tweenEnd = tweenStart + tweenDuration; const newStart = Math.min(targetTime, tweenStart); @@ -104,7 +116,13 @@ async function extendTweenAndAddKeyframe( for (const kf of existingKfs) { const absTime = tweenStart + (kf.percentage / 100) * tweenDuration; const newPct = Math.round(((absTime - newStart) / newDuration) * 1000) / 10; - remappedKfs.push({ percentage: newPct, properties: { ...kf.properties } }); + const props: Record = { ...kf.properties }; + // Backfill props the new keyframe introduces but this one lacks, so GSAP + // doesn't hold the new prop's value across keyframes that omit it. + for (const k of Object.keys(properties)) { + if (!(k in props) && backfillDefaults?.[k] != null) props[k] = backfillDefaults[k]; + } + remappedKfs.push({ percentage: newPct, properties: props }); } const targetPct = Math.round(((targetTime - newStart) / newDuration) * 1000) / 10; @@ -133,9 +151,11 @@ async function commitKeyframedPosition( properties: Record, callbacks: GsapDragCommitCallbacks, beforeReload?: () => void, + backfillDefaults?: Record, ): Promise { const { activeKeyframePct, setActiveKeyframePct } = usePlayerStore.getState(); - const pct = activeKeyframePct ?? computeCurrentPercentage(selection, anim); + const computedPct = computeCurrentPercentage(selection, anim); + const pct = activeKeyframePct ?? computedPct; await callbacks.commitMutation( selection, { @@ -143,10 +163,18 @@ async function commitKeyframedPosition( animationId: anim.id, percentage: pct, properties, + // Backfill any newly-introduced prop (e.g. `y` on an x-only tween) into the + // OTHER keyframes at the element's base value. Without it, GSAP holds the new + // prop's value across keyframes that omit it — so editing one keyframe drags + // the others to the same position. + ...(backfillDefaults ? { backfillDefaults } : {}), }, { label: `Move layer (keyframe ${pct}%)`, softReload: true, beforeReload }, ); - if (activeKeyframePct != null) setActiveKeyframePct(null); + if (activeKeyframePct != null) { + setActiveKeyframePct(null); + parkPlayheadOnKeyframe(anim, pct); + } } /** @@ -162,11 +190,16 @@ async function commitFlatViaKeyframes( beforeReload?: () => void, iframe?: HTMLIFrameElement | null, selector?: string, + backfillDefaults?: Record, ): Promise { const ct = usePlayerStore.getState().currentTime; const ts = resolveTweenStart(anim); const td = resolveTweenDuration(anim); - const outsideRange = ts !== null && td > 0 && (ct < ts - 0.01 || ct > ts + td + 0.01); + // A flat tween shows two diamonds (0% / 100%). If the user selected one and then + // dragged, modify THAT endpoint — don't extend or place at the drifted playhead. + const { activeKeyframePct, setActiveKeyframePct } = usePlayerStore.getState(); + const outsideRange = + activeKeyframePct == null && ts !== null && td > 0 && (ct < ts - 0.01 || ct > ts + td + 0.01); // Read the runtime position at the tween's start time so the 0% keyframe // captures the actual interpolated value (e.g. x=300 after a preceding slide), @@ -180,6 +213,13 @@ async function commitFlatViaKeyframes( const timelines = iframeWin?.__timelines; const mainTl = timelines ? (Object.values(timelines)[0] as any) : null; if (gsapLib && el && mainTl?.seek) { + // Clear the live drag's gsap overrides first. Otherwise a property the + // tween doesn't animate (e.g. `y` on a flat `to({x})`) keeps the dragged + // value through the seek and pollutes the 0% keyframe (it would start at + // the dropped position instead of animating there). After clearing, the + // seek reapplies the timeline's real interpolated values for animated + // props, and untweened props fall back to their base (0). + gsapLib.set(el, { clearProps: Object.keys(properties).join(",") }); mainTl.seek(ts); for (const key of Object.keys(properties)) { const v = Number(gsapLib.getProperty(el, key)); @@ -193,50 +233,42 @@ async function commitFlatViaKeyframes( } if (outsideRange && ts !== null) { - // Outside the tween's range: add a brand new keyframed tween at the drag - // time instead of extending/replacing the existing one. This keeps all - // existing tweens untouched and creates a clean hold at the dragged position. - const tweenEnd = ts + td; - const holdStart = ct > tweenEnd ? tweenEnd : ct; - const holdEnd = ct > tweenEnd ? ct : ts; - const holdDur = Math.max(0.01, holdEnd - holdStart); - const kfs = - ct > tweenEnd - ? [ - { percentage: 0, properties: resolvedFromValues }, - { percentage: 100, properties }, - ] - : [ - { percentage: 0, properties }, - { percentage: 100, properties: resolvedFromValues }, - ]; - console.log( - "[drag:5] outside range — adding new tween", - JSON.stringify({ - ct, - ts, - td, - holdStart: roundTo3(holdStart), - holdDur: roundTo3(holdDur), - from: resolvedFromValues, - to: properties, - }), - ); + // Outside the tween's range: EXTEND the existing tween to reach the playhead + // instead of spawning a parallel tween (which left the element with two + // competing tweens, so edits hit one while the selected keyframe lived on the + // other). Convert the flat tween to keyframes, then extend + add at the + // playhead — existing keyframes keep their absolute times. + const coalesceKey = `gsap:convert-drag:${anim.id}`; await callbacks.commitMutation( selection, { - type: "add-with-keyframes", - targetSelector: anim.targetSelector, - position: roundTo3(holdStart), - duration: roundTo3(holdDur), - keyframes: kfs, + type: "convert-to-keyframes", + animationId: anim.id, + ...(Object.keys(resolvedFromValues).length > 0 ? { resolvedFromValues } : {}), }, - { label: "Move layer (new keyframe)", softReload: true, beforeReload }, + { label: "Convert to keyframes for drag", skipReload: true, coalesceKey }, + ); + const fresh = callbacks.fetchAnimations ? await callbacks.fetchAnimations() : []; + const converted = + fresh.find((a) => a.targetSelector === anim.targetSelector && a.keyframes) ?? anim; + const convertedStart = resolveTweenStart(converted) ?? ts; + const convertedDur = resolveTweenDuration(converted) || td; + await extendTweenAndAddKeyframe( + selection, + converted, + properties, + ct, + convertedStart, + convertedDur, + callbacks, + beforeReload, ); return; } - // Inside range: convert the flat tween to keyframes, then add at current %. + // Inside range (or a selected endpoint): convert the flat tween to keyframes, + // then add/modify at the target %. A selected diamond pins the % to that endpoint + // (0 / 100) so the drag edits it exactly; otherwise use the playhead %. const coalesceKey = `gsap:convert-drag:${anim.id}`; await callbacks.commitMutation( selection, @@ -247,7 +279,9 @@ async function commitFlatViaKeyframes( }, { label: "Convert to keyframes for drag", skipReload: true, coalesceKey }, ); - const pct = computeCurrentPercentage(selection, anim); + const pct = activeKeyframePct ?? computeCurrentPercentage(selection, anim); + const editedSelected = activeKeyframePct != null; + if (editedSelected) setActiveKeyframePct(null); await callbacks.commitMutation( selection, @@ -256,9 +290,11 @@ async function commitFlatViaKeyframes( animationId: anim.id, percentage: pct, properties, + ...(backfillDefaults ? { backfillDefaults } : {}), }, { label: `Move layer (keyframe ${pct}%)`, softReload: true, beforeReload, coalesceKey }, ); + if (editedSelected) parkPlayheadOnKeyframe(anim, pct); } // ── Main drag commit ────────────────────────────────────────────────────── @@ -302,6 +338,9 @@ export async function commitGsapPositionFromDrag( el.removeAttribute("data-hf-drag-initial-offset-y"); }; + // The element's base (un-animated) pose — used to backfill any prop the drag + // newly introduces (e.g. `y` on an x-only tween) into the other keyframes. + const backfillDefaults: Record = { x: baseGsapX, y: baseGsapY }; const ct = usePlayerStore.getState().currentTime; if (anim.keyframes) { const newId = await materializeIfDynamic(anim, iframe, callbacks.commitMutation, selection); @@ -311,7 +350,10 @@ export async function commitGsapPositionFromDrag( const ts = resolveTweenStart(effectiveAnim); const td = resolveTweenDuration(effectiveAnim); const outsideRange = ts !== null && td > 0 && (ct < ts - 0.01 || ct > ts + td + 0.01); - if (outsideRange) { + // A selected keyframe (clicked diamond) means "modify THIS keyframe" — never + // extend, even if the playhead drifted a frame past the tween's end. + const hasSelectedKeyframe = usePlayerStore.getState().activeKeyframePct != null; + if (outsideRange && !hasSelectedKeyframe) { await extendTweenAndAddKeyframe( selection, effectiveAnim, @@ -321,15 +363,26 @@ export async function commitGsapPositionFromDrag( td, callbacks, restoreOffset, + backfillDefaults, ); } else { - await commitKeyframedPosition(selection, effectiveAnim, dragProps, callbacks, restoreOffset); + await commitKeyframedPosition( + selection, + effectiveAnim, + dragProps, + callbacks, + restoreOffset, + backfillDefaults, + ); } } else if (anim.method === "from" || anim.method === "fromTo") { const ct = usePlayerStore.getState().currentTime; const ts = resolveTweenStart(anim); const td = resolveTweenDuration(anim); - const outsideRange = ts !== null && td > 0 && (ct < ts - 0.01 || ct > ts + td + 0.01); + // A selected keyframe means "modify it" — skip the extend/split branch. + const hasSelectedKeyframe = usePlayerStore.getState().activeKeyframePct != null; + const outsideRange = + !hasSelectedKeyframe && ts !== null && td > 0 && (ct < ts - 0.01 || ct > ts + td + 0.01); const dragProps: Record = { x: newX, y: newY }; if (outsideRange && ts !== null) { @@ -359,6 +412,7 @@ export async function commitGsapPositionFromDrag( posTd, callbacks, restoreOffset, + backfillDefaults, ); return; } @@ -389,19 +443,26 @@ export async function commitGsapPositionFromDrag( } keyframes.sort((a, b) => a.percentage - b.percentage); + // REPLACE the split position `from()` tween with the keyframed one (same id) + // instead of adding a parallel tween. Two position tweens on the same element + // fight on the shared axis — the leftover `from()` snaps to its natural state + // on the soft-reload re-seek, which is the visible "jump" after dropping. + const baseKf = { + targetSelector: anim.targetSelector, + position: roundTo3(newStart), + duration: roundTo3(newDuration), + keyframes, + }; await callbacks.commitMutation( selection, - { - type: "add-with-keyframes", - targetSelector: anim.targetSelector, - position: roundTo3(newStart), - duration: roundTo3(newDuration), - keyframes, - }, + existingPosAnim + ? { type: "replace-with-keyframes", animationId: existingPosAnim.id, ...baseKf } + : { type: "add-with-keyframes", ...baseKf }, { label: "Move layer (from extended)", softReload: true, beforeReload: restoreOffset }, ); } else { - // Inside tween range: convert then add keyframe at current time + // Inside tween range (or a selected keyframe): convert then add/modify at + // the selected endpoint % if one is active, else the playhead %. const coalesceKey = `gsap:convert-drag:${anim.id}`; await callbacks.commitMutation( selection, @@ -411,7 +472,9 @@ export async function commitGsapPositionFromDrag( }, { label: "Convert from() for drag", skipReload: true, coalesceKey }, ); - const pct = computeCurrentPercentage(selection, anim); + const { activeKeyframePct, setActiveKeyframePct } = usePlayerStore.getState(); + const pct = activeKeyframePct ?? computeCurrentPercentage(selection, anim); + if (activeKeyframePct != null) setActiveKeyframePct(null); await callbacks.commitMutation( selection, { @@ -419,6 +482,7 @@ export async function commitGsapPositionFromDrag( animationId: anim.id, percentage: pct, properties: dragProps, + ...(backfillDefaults ? { backfillDefaults } : {}), }, { label: `Move layer (keyframe ${pct}%)`, @@ -437,6 +501,7 @@ export async function commitGsapPositionFromDrag( restoreOffset, iframe, selector, + backfillDefaults, ); } } diff --git a/packages/studio/src/hooks/gsapRuntimeBridge.test.ts b/packages/studio/src/hooks/gsapRuntimeBridge.test.ts new file mode 100644 index 0000000000..9ff5c79019 --- /dev/null +++ b/packages/studio/src/hooks/gsapRuntimeBridge.test.ts @@ -0,0 +1,94 @@ +import { afterEach, describe, expect, it, vi } from "vitest"; +import type { GsapAnimation } from "@hyperframes/core/gsap-parser"; +import type { DomEditSelection } from "../components/editor/domEditingTypes"; +import { tryGsapDragIntercept } from "./gsapRuntimeBridge"; + +/** + * Regression: `selectedGsapAnimations` (and the fetch fallback) is an async + * server-parse that LAGS a delete-all. A drag in that window would resolve a + * phantom position tween from the stale cache and re-commit it — resurrecting the + * just-deleted animation. tryGsapDragIntercept must trust the live runtime: if no + * non-hold tween exists for the element, it bails (returns false → CSS fallback) + * instead of committing. + */ + +// A preview iframe whose runtime timeline holds `children`, resolves the element, +// and exposes a gsap stub — so the drag can reach the commit path (the guard, not +// a missing gsap, must be what stops it). +function fakeIframe(elId: string, children: unknown[]): HTMLIFrameElement { + const timeline = { getChildren: () => children, duration: () => 14.6 }; + const el = { id: elId }; + return { + contentWindow: { + __timelines: { "index.html": timeline }, + gsap: { getProperty: () => 0 }, + }, + contentDocument: { querySelector: (sel: string) => (sel === `#${elId}` ? el : null) }, + } as unknown as HTMLIFrameElement; +} + +// A selection whose element answers the reads commitGsapPositionFromDrag makes — +// so without the guard the drag would reach commitMutation (resurrecting the tween). +const fakeElement = { + id: "puck-b", + style: { getPropertyValue: () => "" }, + getAttribute: () => null, + getBoundingClientRect: () => ({ top: 100, left: 100, width: 50, height: 50 }), +} as unknown as HTMLElement; + +const selection = { + id: "puck-b", + selector: "#puck-b", + element: fakeElement, +} as unknown as DomEditSelection; + +// A stale parse-cache entry: a position tween the server still reports post-delete. +const stalePositionAnim = { + id: "#puck-b-to-1000-position", + targetSelector: "#puck-b", + propertyGroup: "position", + method: "to", + properties: { x: -180, y: -60 }, + position: 1, + resolvedStart: 1, + duration: 2, +} as unknown as GsapAnimation; + +afterEach(() => vi.restoreAllMocks()); + +describe("tryGsapDragIntercept — stale-parse guard (no resurrection after delete-all)", () => { + it("bails without committing when the runtime has no tween (only the parse is stale)", async () => { + const commitMutation = vi.fn(); + // Runtime empty (tween deleted) — readRuntimeKeyframes returns null. + const iframe = fakeIframe("puck-b", []); + + const handled = await tryGsapDragIntercept( + selection, + { x: -50, y: 30 }, + [stalePositionAnim], + iframe, + commitMutation, + ); + + expect(handled).toBe(false); + expect(commitMutation).not.toHaveBeenCalled(); + }); + + it("does not trip the stale-parse guard when the runtime still has the tween", async () => { + const logSpy = vi.spyOn(console, "log").mockImplementation(() => {}); + const liveTween = { + targets: () => [{ id: "puck-b" }], + vars: { x: -120, y: 40, duration: 1 }, + duration: () => 1, + startTime: () => 1, + }; + // No fake gsap → it returns false later (at the gsapPos read), but the point + // is the stale-parse guard must NOT be the reason. + const iframe = fakeIframe("puck-b", [liveTween]); + + await tryGsapDragIntercept(selection, { x: -50, y: 30 }, [stalePositionAnim], iframe, vi.fn()); + + const staleLogged = logSpy.mock.calls.some((c) => String(c[1] ?? "").includes("stale parse")); + expect(staleLogged).toBe(false); + }); +}); diff --git a/packages/studio/src/hooks/gsapRuntimeBridge.ts b/packages/studio/src/hooks/gsapRuntimeBridge.ts index 576372ddae..d8cbee753b 100644 --- a/packages/studio/src/hooks/gsapRuntimeBridge.ts +++ b/packages/studio/src/hooks/gsapRuntimeBridge.ts @@ -21,6 +21,7 @@ import { import { resolveTweenStart, resolveTweenDuration } from "../utils/globalTimeCompiler"; import type { GsapDragCommitCallbacks } from "./gsapDragCommit"; import { getIframeGsap, queryIframeElement, selectorFromSelection } from "./gsapShared"; +import { readRuntimeKeyframes } from "./gsapRuntimeKeyframes"; import { roundTo3 } from "../utils/rounding"; // ── Runtime reads ────────────────────────────────────────────────────────── @@ -198,15 +199,6 @@ export async function tryGsapDragIntercept( fetchFallbackAnimations?: () => Promise, ): Promise { const selector = selectorFromSelection(selection); - console.log( - "[drag:4] tryGsapDragIntercept", - JSON.stringify({ - sel: selection.id, - selector, - animCount: animations.length, - groups: animations.map((a) => a.propertyGroup).filter(Boolean), - }), - ); if (!selector) { return false; } @@ -218,12 +210,6 @@ export async function tryGsapDragIntercept( commitMutation, fetchFallbackAnimations, ); - console.log( - "[drag:4] resolveGroupTween('position') →", - resolved - ? JSON.stringify({ id: resolved.anim.id, group: resolved.anim.propertyGroup }) - : "null", - ); let posAnim = resolved?.anim ?? null; if (!posAnim) { @@ -231,27 +217,26 @@ export async function tryGsapDragIntercept( if (!posAnim && fetchFallbackAnimations) { const fresh = await fetchFallbackAnimations(); posAnim = findGsapPositionAnimation(fresh, selector); - console.log( - "[drag:4] findGsapPositionAnimation (fetched) →", - posAnim ? posAnim.id : "null", - "freshCount:", - fresh.length, - ); } } if (!posAnim) { return false; } + // The live runtime is authoritative; `selectedGsapAnimations` (and the fetch + // fallback) is an async server-parse that LAGS a delete-all, so `posAnim` can + // be a phantom of a just-deleted tween. If the live timeline has no non-hold + // tween for this element, the parse is stale — bail so the drag falls back to + // the CSS path instead of resurrecting the deleted animation from stale cache. + if (!readRuntimeKeyframes(iframe, selector)) { + return false; + } + const gsapPos = readGsapPositionFromIframe(iframe, selector); if (!gsapPos) { return false; } - console.log( - "[drag:4] committing GSAP position drag", - JSON.stringify({ posAnimId: posAnim.id, gsapPos }), - ); await commitGsapPositionFromDrag(selection, posAnim, offset, gsapPos, iframe, selector, { commitMutation, fetchAnimations: fetchFallbackAnimations, diff --git a/packages/studio/src/hooks/gsapRuntimeKeyframes.test.ts b/packages/studio/src/hooks/gsapRuntimeKeyframes.test.ts index 91cda720f4..3d9cc012e2 100644 --- a/packages/studio/src/hooks/gsapRuntimeKeyframes.test.ts +++ b/packages/studio/src/hooks/gsapRuntimeKeyframes.test.ts @@ -1,5 +1,49 @@ import { describe, expect, it } from "vitest"; -import { arcPathFromMotionPathValue } from "./gsapRuntimeKeyframes"; +import { arcPathFromMotionPathValue, readRuntimeKeyframes } from "./gsapRuntimeKeyframes"; + +// Build a fake preview iframe whose runtime timeline holds the given child tweens +// and resolves `selector` to `el`. +function fakeIframe(el: { id: string }, children: unknown[]): HTMLIFrameElement { + const timeline = { getChildren: () => children, duration: () => 14.6 }; + return { + contentWindow: { __timelines: { "index.html": timeline } }, + contentDocument: { querySelector: (sel: string) => (sel === `#${el.id}` ? el : null) }, + } as unknown as HTMLIFrameElement; +} + +describe("readRuntimeKeyframes — zero-duration set must not shadow the keyframed tween", () => { + const el = { id: "puck-b" }; + const holdSet = { + targets: () => [el], + vars: { x: 0, y: 0, data: "hf-hold" }, + duration: () => 0, + startTime: () => 0, + }; + const kfTween = { + targets: () => [el], + vars: { + keyframes: [ + { x: 0, y: 0 }, + { x: -180, y: -60 }, + { x: -320, y: 40 }, + { x: -460, y: -20 }, + ], + duration: 3.4, + ease: "power1.inOut", + }, + duration: () => 3.4, + startTime: () => 1.0, + }; + + it("reads all 4 keyframes from the to() even when a hold-set precedes it", () => { + const read = readRuntimeKeyframes(fakeIframe(el, [holdSet, kfTween]), "#puck-b"); + expect(read?.keyframes).toHaveLength(4); + }); + + it("returns null when the element only has a zero-duration set (no real motion)", () => { + expect(readRuntimeKeyframes(fakeIframe(el, [holdSet]), "#puck-b")).toBeNull(); + }); +}); describe("arcPathFromMotionPathValue", () => { it("builds arc config from object form { path, curviness }", () => { diff --git a/packages/studio/src/hooks/gsapRuntimeKeyframes.ts b/packages/studio/src/hooks/gsapRuntimeKeyframes.ts index 3b1ac272ce..e8ce0977b0 100644 --- a/packages/studio/src/hooks/gsapRuntimeKeyframes.ts +++ b/packages/studio/src/hooks/gsapRuntimeKeyframes.ts @@ -25,7 +25,7 @@ interface RuntimeTimeline { } type Pct = { percentage: number; properties: Record }; -type ReadTween = { keyframes: Pct[]; easeEach?: string; arcPath?: ArcPathConfig }; +export type ReadTween = { keyframes: Pct[]; easeEach?: string; arcPath?: ArcPathConfig }; export interface RuntimeKeyframeEntry { keyframes: Pct[]; @@ -160,7 +160,11 @@ export function readRuntimeKeyframes( ): ReadTween | null { const timelines = timelinesOf(iframe); if (!timelines) return null; - const tlId = compositionId || Object.keys(timelines)[0]; + // Skip non-timeline markers (e.g. the studio's `__proxied` flag) when no + // explicit composition id is given — picking those yields no getChildren. + const tlId = + compositionId || + Object.keys(timelines).find((k) => typeof timelines[k]?.getChildren === "function"); if (!tlId) return null; const timeline = timelines[tlId]; if (!timeline?.getChildren) return null; @@ -175,6 +179,12 @@ export function readRuntimeKeyframes( for (const tween of timeline.getChildren(true)) { if (!tween.vars || !matchesElement(tween, targetEl)) continue; + // Skip zero-duration tweens (`tl.set(...)`, incl. the studio position-hold + // `data:"hf-hold"`). They sit before the real keyframed tween and otherwise + // shadow it — `readTween` falls back to a degenerate 2-point flat path from + // the set's values, hiding the actual multi-keyframe motion. + const dur = typeof tween.duration === "function" ? tween.duration() : 0; + if (!(dur > 0)) continue; const read = readTween(tween.vars); if (read) return read; } @@ -217,9 +227,12 @@ function addScanEntry( clipById?: ClipDims, ): void { if (!tween.targets || !tween.vars) return; + const { start, duration } = tweenTiming(tween); + // Skip zero-duration sets/holds — they shadow the real keyframed tween (see + // readRuntimeKeyframes). + if (!(duration > 0)) return; const read = readTween(tween.vars); if (!read) return; - const { start, duration } = tweenTiming(tween); for (const target of tween.targets()) { const id = (target as HTMLElement).id; if (id && !result.has(id)) result.set(id, buildEntry(read, start, duration, clipById?.get(id))); diff --git a/packages/studio/src/hooks/gsapShared.test.ts b/packages/studio/src/hooks/gsapShared.test.ts new file mode 100644 index 0000000000..307cb54e47 --- /dev/null +++ b/packages/studio/src/hooks/gsapShared.test.ts @@ -0,0 +1,30 @@ +import { describe, it, expect } from "vitest"; +import { parsePercentageKeyframes } from "./gsapShared"; + +describe("parsePercentageKeyframes", () => { + it("parses the object/percentage form", () => { + const out = parsePercentageKeyframes({ "0%": { x: 0, y: 0 }, "100%": { x: 9, y: 4 } }); + expect(out?.keyframes).toEqual([ + { percentage: 0, properties: { x: 0, y: 0 } }, + { percentage: 100, properties: { x: 9, y: 4 } }, + ]); + }); + + it("parses GSAP array-form keyframes as evenly-distributed steps", () => { + // Regression: a multi-point shuttle path authored as `keyframes: [...]` used to + // read as null (no `N%` keys) → no motion path. Steps map to i/(n-1)*100%. + const out = parsePercentageKeyframes([ + { x: 0, y: 0 }, + { x: 520, y: 120 }, + { x: 1040, y: 0 }, + { x: 1480, y: 160 }, + ] as unknown as Record); + expect(out?.keyframes.map((k) => k.percentage)).toEqual([0, 33.3, 66.7, 100]); + expect(out?.keyframes[1]!.properties).toEqual({ x: 520, y: 120 }); + }); + + it("returns null for keyframes with no positional/animatable props", () => { + expect(parsePercentageKeyframes([] as unknown as Record)).toBeNull(); + expect(parsePercentageKeyframes({})).toBeNull(); + }); +}); diff --git a/packages/studio/src/hooks/gsapShared.ts b/packages/studio/src/hooks/gsapShared.ts index e299f15a53..eb2daa8fbc 100644 --- a/packages/studio/src/hooks/gsapShared.ts +++ b/packages/studio/src/hooks/gsapShared.ts @@ -97,16 +97,6 @@ export function queryIframeElement( } } -/** Safely access an iframe's contentDocument, returning null on cross-origin errors. */ -export function getIframeDocument(iframe: HTMLIFrameElement | null): Document | null { - if (!iframe) return null; - try { - return iframe.contentDocument; - } catch { - return null; - } -} - // ── Keyframe parsing ────────────────────────────────────────────────────────── export interface ParsedPercentageKeyframes { @@ -125,6 +115,26 @@ export function parsePercentageKeyframes( const keyframes: ParsedPercentageKeyframes["keyframes"] = []; let easeEach: string | undefined; + // GSAP array-form keyframes — `keyframes: [{x,y}, {x,y}, ...]` — are evenly + // distributed across the tween, so step i of n maps to i/(n-1)*100%. (The object + // form below uses explicit "0%" keys.) Without this, array-keyframed tweens (e.g. + // a multi-point shuttle path) read as null → no motion path. + if (Array.isArray(kfObj)) { + const steps = kfObj as unknown[]; + steps.forEach((entry, i) => { + if (!entry || typeof entry !== "object") return; + const percentage = steps.length > 1 ? Math.round((i / (steps.length - 1)) * 1000) / 10 : 0; + const properties: Record = {}; + for (const [pk, pv] of Object.entries(entry as Record)) { + if (pk === "ease") continue; + if (typeof pv === "number") properties[pk] = Math.round(pv * 1000) / 1000; + else if (typeof pv === "string") properties[pk] = pv; + } + if (Object.keys(properties).length > 0) keyframes.push({ percentage, properties }); + }); + return keyframes.length > 0 ? { keyframes } : null; + } + for (const [key, val] of Object.entries(kfObj)) { if (key === "easeEach") { if (typeof val === "string") easeEach = val; diff --git a/packages/studio/src/hooks/useDomSelection.ts b/packages/studio/src/hooks/useDomSelection.ts index c6b7b78ba4..898cede6e2 100644 --- a/packages/studio/src/hooks/useDomSelection.ts +++ b/packages/studio/src/hooks/useDomSelection.ts @@ -4,7 +4,11 @@ import { getAllPreviewTargetsFromPointer, getPreviewTargetFromPointer, } from "../utils/studioPreviewHelpers"; -import { findMatchingTimelineElementId, type RightPanelTab } from "../utils/studioHelpers"; +import { + findMatchingTimelineElementId, + findTimelineIdByAncestor, + type RightPanelTab, +} from "../utils/studioHelpers"; import { domEditSelectionsTargetSame, domEditSelectionInGroup, @@ -178,10 +182,13 @@ export function useDomSelection({ setRightCollapsed(false); setRightPanelTab("design"); } - const nextSelectedTimelineId = findMatchingTimelineElementId( - nextSelection, - timelineElements, - ); + const nextSelectedTimelineId = + findMatchingTimelineElementId(nextSelection, timelineElements) ?? + findTimelineIdByAncestor( + nextSelection.element, + timelineElements, + nextSelection.sourceFile || "index.html", + ); setSelectedTimelineElementId(nextSelectedTimelineId); return; } diff --git a/packages/studio/src/hooks/useEnableKeyframes.test.ts b/packages/studio/src/hooks/useEnableKeyframes.test.ts new file mode 100644 index 0000000000..f62b1c12f9 --- /dev/null +++ b/packages/studio/src/hooks/useEnableKeyframes.test.ts @@ -0,0 +1,29 @@ +import { describe, expect, it } from "vitest"; +import { resolveNewTweenRange } from "./useEnableKeyframes"; + +describe("resolveNewTweenRange", () => { + // Regression: "add a keyframe" must land at the PLAYHEAD. The runtime auto-stamps + // data-start="0" + data-duration= on every GSAP element, so honoring + // data-start as authored timing put the keyframe at 0. Clamping the playhead into + // the element's range fixes it (auto-stamp's full range passes the playhead through). + it("anchors at the playhead through the auto-stamped full-composition range", () => { + // data-start="0", data-duration="14" (the auto-stamp), playhead 4.9 → 4.9 + expect(resolveNewTweenRange("0", "14", 4.9)).toEqual({ start: 4.9, duration: 9.1 }); + }); + + it("anchors at the playhead when the element has no authored range", () => { + expect(resolveNewTweenRange(undefined, undefined, 4)).toEqual({ start: 4, duration: 1 }); + expect(resolveNewTweenRange(undefined, undefined, 6.123456).start).toBe(6.123); + }); + + it("never returns a negative start", () => { + expect(resolveNewTweenRange(undefined, undefined, -2).start).toBe(0); + }); + + it("clamps the playhead into a genuinely narrow authored clip", () => { + // clip [2.5, 8]: inside → playhead; before → start; after → end + expect(resolveNewTweenRange("2.5", "5.5", 4)).toEqual({ start: 4, duration: 4 }); + expect(resolveNewTweenRange("2.5", "5.5", 1).start).toBe(2.5); + expect(resolveNewTweenRange("2.5", "5.5", 99).start).toBe(8); + }); +}); diff --git a/packages/studio/src/hooks/useEnableKeyframes.ts b/packages/studio/src/hooks/useEnableKeyframes.ts index 4a7858fa8b..cc632adeb2 100644 --- a/packages/studio/src/hooks/useEnableKeyframes.ts +++ b/packages/studio/src/hooks/useEnableKeyframes.ts @@ -55,7 +55,9 @@ function readElementPosition( const element = sel.element; if (!element?.isConnected || !gsap?.getProperty) return result; - const props = anim ? Object.keys(anim.properties) : ["x", "y", "opacity"]; + // ponytail: a brand-new tween captures position only — bundling opacity made it + // a mixed group that the position-only drag intercept couldn't resolve. + const props = anim ? Object.keys(anim.properties) : ["x", "y"]; for (const prop of props) { const val = Number(gsap.getProperty(element, prop)); if (!Number.isFinite(val)) continue; @@ -65,6 +67,32 @@ function readElementPosition( return result; } +/** + * Range for a brand-new keyframe tween created via "Enable keyframes" on an element + * with no existing animation. "Add a keyframe" must land at the PLAYHEAD. + * + * The runtime auto-stamps `data-start="0"` + `data-duration=` on every + * timeline element, so we can't treat `data-start` as authored timing (doing so put + * the keyframe at 0). Instead, clamp the playhead into the element's [start, end] + * range: the auto-stamp's full-composition range passes the playhead through + * unchanged, while a genuinely narrow authored clip still clamps sensibly. + */ +export function resolveNewTweenRange( + authoredStart: string | undefined, + authoredDuration: string | undefined, + currentTime: number, +): { start: number; duration: number } { + const t = Math.max(0, roundTo3(currentTime)); + const start = authoredStart != null ? Number.parseFloat(authoredStart) : Number.NaN; + const duration = authoredDuration != null ? Number.parseFloat(authoredDuration) : Number.NaN; + if (!Number.isFinite(start) || !Number.isFinite(duration) || duration <= 0) { + return { start: t, duration: 1 }; + } + const end = start + duration; + const clampedStart = Math.min(Math.max(t, start), end); + return { start: clampedStart, duration: Math.max(0.5, roundTo3(end - clampedStart)) }; +} + async function fetchAnimationsForElement(sel: DomEditSelection): Promise { const projectId = window.location.hash.match(/project\/([^?/]+)/)?.[1]; if (!projectId) return []; @@ -122,9 +150,11 @@ export function useEnableKeyframes( } } else { const position = readElementPosition(iframe, sel, null); - const pct = computeElementPercentage(t, sel); - const elStart = Number.parseFloat(sel.dataAttributes?.start ?? "0") || 0; - const elDuration = Number.parseFloat(sel.dataAttributes?.duration ?? "1") || 1; + const { start: elStart, duration: elDuration } = resolveNewTweenRange( + sel.dataAttributes?.start, + sel.dataAttributes?.duration, + t, + ); const selector = selectorFromSelection(sel); if (!selector) { @@ -135,19 +165,13 @@ export function useEnableKeyframes( if (Object.keys(position).length === 0) { position.x = 0; position.y = 0; - position.opacity = 1; } + // One keyframe at the playhead — a single diamond capturing the current + // value. Motion comes from the user adding/dragging more keyframes later; + // creating 0%+100% up front showed two diamonds for a single "add keyframe". const keyframes: Array<{ percentage: number; properties: Record }> = [{ percentage: 0, properties: { ...position } }]; - if (pct > 1 && pct < 99) { - keyframes.push({ percentage: pct, properties: { ...position } }); - } - keyframes.push({ - percentage: 100, - properties: { ...position }, - auto: true, - } as (typeof keyframes)[number]); if (session.commitMutation) { await session.commitMutation( diff --git a/packages/studio/src/hooks/useGestureRecording.ts b/packages/studio/src/hooks/useGestureRecording.ts index 8dabae8e09..a16c94e0fd 100644 --- a/packages/studio/src/hooks/useGestureRecording.ts +++ b/packages/studio/src/hooks/useGestureRecording.ts @@ -29,7 +29,7 @@ interface BasePosition { interface GsapRuntime { seek: (t: number) => void; - set: (target: string, vars: Record) => void; + set: (target: string, vars: Record) => void; selector: string; element: HTMLElement; startTime: number; @@ -85,11 +85,18 @@ function connectGsapRuntime( ): GsapRuntime | null { try { const win = iframeEl.contentWindow as Window & { - gsap?: { set: (t: string, v: Record) => void }; + gsap?: { set: (t: string, v: Record) => void }; __timelines?: Record void; duration: () => number }>; __player?: { getTime: () => number }; }; - const tl = win?.__timelines ? Object.values(win.__timelines)[0] : null; + // Pick the first REAL timeline. `__timelines` also carries the studio's + // `__proxied` marker (a boolean, no `.seek`); `Object.values(...)[0]` would grab + // it and fail the connect — the cause of the no-live-preview gesture bug. + const tl = win?.__timelines + ? (Object.entries(win.__timelines).find( + ([key, value]) => key !== "__proxied" && typeof value?.seek === "function", + )?.[1] ?? null) + : null; if (win?.gsap?.set && tl?.seek && selector) { const tlDuration = tl.duration(); return { @@ -105,7 +112,7 @@ function connectGsapRuntime( }; } } catch { - /* cross-origin or missing runtime */ + /* connect failed */ } return null; } @@ -125,14 +132,14 @@ function applyRuntimePreview( } function recordSample(r: RecordingRefs, time: number, properties: Record): void { - const sampleProps = { ...properties }; - // Subtract both the CSS var offset AND the pointer-element snap offset - // so the first sample doesn't include the snap-to-cursor jump. - if ("x" in sampleProps) - sampleProps.x -= r.cssVarOffset.x + r.pointerElementOffset.x / (r.scale || 1); - if ("y" in sampleProps) - sampleProps.y -= r.cssVarOffset.y + r.pointerElementOffset.y / (r.scale || 1); - r.samples.push({ time, properties: sampleProps }); + // Record the FULL position the live preview shows (element centered on the + // pointer, with any manual path offset folded into basePosition). Do NOT + // subtract the path offset: when this gesture commits as a position tween the + // server strips the element's --hf-studio-offset (the tween owns position — see + // stripStudioEditsFromTarget in studio-api), so the keyframes must already + // include it. Subtracting it made the committed gesture play shoved off by the + // offset (the offset was removed twice). + r.samples.push({ time, properties: { ...properties } }); r.trail.push({ x: r.pointer.x, y: r.pointer.y }); } @@ -280,30 +287,34 @@ export function useGestureRecording() { r.accumulated = { opacity: base.baseOpacity, scale: base.baseScale, z: 0 }; r.basePosition = { x: base.baseX, y: base.baseY }; - if (base.cssOffX || base.cssOffY) { - element.style.setProperty("--hf-studio-offset-x", "0px"); - element.style.setProperty("--hf-studio-offset-y", "0px"); - } - - // --- Phase 2: Connect to the iframe GSAP runtime --- - const selector = element.id ? `#${element.id}` : null; - r.runtime = connectGsapRuntime(element, iframeEl, selector, elementEndTime); - - // --- Phase 3: Compute iframe viewport → composition scale --- + // --- Phase 2: scale + element center, measured BEFORE clearing the path offset --- + // baseX/baseY fold in the CSS path offset (`--hf-studio-offset`, see + // readBasePosition), so the element's on-screen center must be read while that + // offset is still applied — otherwise the pointer-centering offset is wrong by + // exactly the path offset and the element doesn't sit under the pointer (it + // looked correct only for elements that had no path offset). + // element.getBoundingClientRect() is in the iframe's viewport; convert to the + // studio (parent) viewport using the iframe's position and scale. r.scale = computeIframeScale(iframeEl); - - // --- Phase 4: Element center for pointer-element offset --- - // element.getBoundingClientRect() is in the iframe's viewport. - // Convert to the studio (parent) viewport using the iframe's position and scale. + const iframeScale = r.scale || 1; const iframeRect = iframeEl.getBoundingClientRect(); const elRect = element.getBoundingClientRect(); - const iframeScale = r.scale || 1; const elCenterViewport = { x: iframeRect.left + (elRect.left + elRect.width / 2) * iframeScale, y: iframeRect.top + (elRect.top + elRect.height / 2) * iframeScale, }; r.pointerElementOffset = { x: 0, y: 0 }; + // Now clear the optimistic path offset (already folded into baseX/baseY). + if (base.cssOffX || base.cssOffY) { + element.style.setProperty("--hf-studio-offset-x", "0px"); + element.style.setProperty("--hf-studio-offset-y", "0px"); + } + + // --- Phase 3: Connect to the iframe GSAP runtime --- + const selector = element.id ? `#${element.id}` : null; + r.runtime = connectGsapRuntime(element, iframeEl, selector, elementEndTime); + // --- Phase 5: Attach event listeners --- const handlePointerMove = (e: PointerEvent) => { r.pointer = { x: e.clientX, y: e.clientY }; @@ -391,6 +402,7 @@ export function useGestureRecording() { } recordSample(r, time, properties); + setRecordingDuration(time); r.rafId = requestAnimationFrame(tick); }; @@ -418,6 +430,18 @@ export function useGestureRecording() { const { element: el, savedVisibility, savedTranslate } = r.runtime; el.style.visibility = savedVisibility; el.style.setProperty("translate", savedTranslate || ""); + // Drop the gesture's inline gsap transform before re-applying the path + // offset below, so the two don't briefly stack (the recorded keyframes + // already encode the full position, offset included). On commit the + // re-seek lands on the gesture's first keyframe; on cancel this leaves the + // element at its pre-recording position. + try { + r.runtime.set(r.runtime.selector, { + clearProps: "x,y,scale,scaleX,scaleY,rotation,rotationX,rotationY,opacity,z", + }); + } catch { + /* runtime gone */ + } } if (r.cssVarOffset.x || r.cssVarOffset.y) { const el = r.runtime?.element; diff --git a/packages/studio/src/hooks/useGsapAnimationFetchFallback.test.ts b/packages/studio/src/hooks/useGsapAnimationFetchFallback.test.ts new file mode 100644 index 0000000000..a862ff3f90 --- /dev/null +++ b/packages/studio/src/hooks/useGsapAnimationFetchFallback.test.ts @@ -0,0 +1,27 @@ +import { describe, expect, it } from "vitest"; +import type { GsapAnimation, ParsedGsap } from "@hyperframes/core/gsap-parser"; +import { selectElementAnimationsOrRetry } from "./useGsapAnimationFetchFallback"; + +const anim = (targetSelector: string): GsapAnimation => + ({ id: targetSelector, targetSelector, properties: {} }) as unknown as GsapAnimation; +const parsed = (anims: GsapAnimation[]): ParsedGsap => ({ animations: anims }) as ParsedGsap; +const target = { id: "puck-a", selector: "#puck-a" }; + +describe("selectElementAnimationsOrRetry", () => { + it("returns null (retry) when the parse is cold — null or zero total animations", () => { + expect(selectElementAnimationsOrRetry(null, target)).toBeNull(); + expect(selectElementAnimationsOrRetry(parsed([]), target)).toBeNull(); + }); + + it("returns the matching animations from a warm parse", () => { + const result = selectElementAnimationsOrRetry( + parsed([anim("#puck-a"), anim("#other")]), + target, + ); + expect(result?.map((a) => a.targetSelector)).toEqual(["#puck-a"]); + }); + + it("returns [] (no retry) for a warm parse with no match — element genuinely has no animation", () => { + expect(selectElementAnimationsOrRetry(parsed([anim("#other")]), target)).toEqual([]); + }); +}); diff --git a/packages/studio/src/hooks/useGsapAnimationFetchFallback.ts b/packages/studio/src/hooks/useGsapAnimationFetchFallback.ts index f995d0ee6b..237198efe2 100644 --- a/packages/studio/src/hooks/useGsapAnimationFetchFallback.ts +++ b/packages/studio/src/hooks/useGsapAnimationFetchFallback.ts @@ -1,18 +1,43 @@ import { useCallback } from "react"; +import type { GsapAnimation, ParsedGsap } from "@hyperframes/core/gsap-parser"; import type { DomEditSelection } from "../components/editor/domEditing"; import { fetchParsedAnimations, getAnimationsForElement } from "./useGsapTweenCache"; +const COLD_PARSE_RETRIES = 5; +const COLD_PARSE_DELAY_MS = 120; + +const delay = (ms: number) => new Promise((resolve) => setTimeout(resolve, ms)); + +/** + * Decide an element's animations from a parse result, or signal a retry. + * + * Returns `null` only when the parse is *cold* (missing or zero total animations) + * — the initial-load race where the endpoint isn't ready yet, so the caller should + * retry. A warm parse with no match for this element returns `[]` (the element + * genuinely has no animation — create a new one, don't retry). + */ +export function selectElementAnimationsOrRetry( + parsed: ParsedGsap | null, + target: { id: string | null; selector: string | null }, +): GsapAnimation[] | null { + if (!parsed || parsed.animations.length === 0) return null; + return getAnimationsForElement(parsed.animations, target); +} + export function useGsapAnimationFetchFallback(projectId: string | null, gsapSourceFile: string) { return useCallback( - (selection: DomEditSelection) => async () => { - const pid = projectId; - if (!pid) return []; - const parsed = await fetchParsedAnimations(pid, gsapSourceFile); - if (!parsed) return []; - return getAnimationsForElement(parsed.animations, { - id: selection.id ?? null, - selector: selection.selector ?? null, - }); + (selection: DomEditSelection) => async (): Promise => { + if (!projectId) return []; + const target = { id: selection.id ?? null, selector: selection.selector ?? null }; + // A drag can fire before the async parse is warm; a cold parse must retry + // rather than fall through to the no-animation path (which duplicates the tween). + for (let attempt = 0; ; attempt++) { + const parsed = await fetchParsedAnimations(projectId, gsapSourceFile); + const resolved = selectElementAnimationsOrRetry(parsed, target); + if (resolved !== null) return resolved; + if (attempt >= COLD_PARSE_RETRIES) return []; + await delay(COLD_PARSE_DELAY_MS); + } }, [projectId, gsapSourceFile], ); diff --git a/packages/studio/src/hooks/useGsapAwareEditing.ts b/packages/studio/src/hooks/useGsapAwareEditing.ts index cd4b55acbf..6b135d4aae 100644 --- a/packages/studio/src/hooks/useGsapAwareEditing.ts +++ b/packages/studio/src/hooks/useGsapAwareEditing.ts @@ -18,6 +18,10 @@ import { tryGsapRotationIntercept, } from "./gsapRuntimeBridge"; import { useAnimatedPropertyCommit } from "./useAnimatedPropertyCommit"; +import { + useGsapSaveFailureTelemetry, + useSafeGsapCommitMutation, +} from "./useSafeGsapCommitMutation"; import type { CommitMutation } from "./gsapScriptCommitTypes"; export interface UseGsapAwareEditingParams { @@ -98,17 +102,6 @@ export function useGsapAwareEditing({ const handleGsapAwarePathOffsetCommit = useCallback( async (selection: DomEditSelection, next: { x: number; y: number }) => { const hasGsapAnims = selectedGsapAnimations.length > 0; - console.log( - "[drag:3] handleGsapAwarePathOffsetCommit", - JSON.stringify({ - sel: selection.id, - offset: next, - hasGsapAnims, - interceptEnabled: STUDIO_GSAP_DRAG_INTERCEPT_ENABLED, - animCount: selectedGsapAnimations.length, - animIds: selectedGsapAnimations.map((a) => a.id).slice(0, 5), - }), - ); if (hasGsapAnims && !STUDIO_GSAP_DRAG_INTERCEPT_ENABLED) { showToast(GSAP_CSS_FALLBACK_BLOCKED_MESSAGE, "error"); throw new Error(GSAP_CSS_FALLBACK_BLOCKED_MESSAGE); @@ -232,13 +225,24 @@ export function useGsapAwareEditing({ ); // ── Thin commitMutation facade ── + // Routes through the canonical safe wrapper so a server-save failure surfaces a + // toast + save telemetry instead of silently reverting — parity with the + // arc/keyframe/animation ops that all go through useSafeGsapCommitMutation. + + const noopCommit = useCallback(async () => {}, []); + const trackGsapSaveFailure = useGsapSaveFailureTelemetry(null); + const safeGsapCommit = useSafeGsapCommitMutation( + gsapCommitMutation ?? noopCommit, + trackGsapSaveFailure, + showToast, + ); const commitMutation = useCallback( async (mutation: Record, options: { label: string; softReload?: boolean }) => { if (!domEditSelection) return; - await gsapCommitMutation?.(domEditSelection, mutation, options); + safeGsapCommit(domEditSelection, mutation, options); }, - [domEditSelection, gsapCommitMutation], + [domEditSelection, safeGsapCommit], ); // Unroll all computed (helper/loop) tweens in the active timeline into literal diff --git a/packages/studio/src/hooks/useGsapScriptCommits.ts b/packages/studio/src/hooks/useGsapScriptCommits.ts index 1c15af70d5..422b8621b5 100644 --- a/packages/studio/src/hooks/useGsapScriptCommits.ts +++ b/packages/studio/src/hooks/useGsapScriptCommits.ts @@ -82,8 +82,10 @@ export function useGsapScriptCommits({ projectIdRef, activeCompPath, previewIfra if (options.skipReload) return; if (result.parsed?.animations) updateKeyframeCacheFromParsed(result.parsed.animations, targetPath, selection.id ?? undefined, mutation); options.beforeReload?.(); + let applied: "soft" | "full" = "full"; if (options.softReload && result.scriptText) { - if (!applySoftReload(previewIframeRef.current, result.scriptText)) reloadPreview(); + applied = applySoftReload(previewIframeRef.current, result.scriptText) ? "soft" : "full"; + if (applied === "full") reloadPreview(); } else { reloadPreview(); } diff --git a/packages/studio/src/hooks/useGsapTweenCache.ts b/packages/studio/src/hooks/useGsapTweenCache.ts index 246f53526c..2cbcf4c8b6 100644 --- a/packages/studio/src/hooks/useGsapTweenCache.ts +++ b/packages/studio/src/hooks/useGsapTweenCache.ts @@ -1,6 +1,7 @@ import { useEffect, useMemo, useRef, useState, useCallback } from "react"; import type { GsapAnimation, GsapKeyframesData, ParsedGsap } from "@hyperframes/core/gsap-parser"; import type { GsapPercentageKeyframe } from "@hyperframes/core/gsap-parser"; +import { isStudioHoldSet } from "@hyperframes/core/gsap-parser"; import { usePlayerStore } from "../player/store/playerStore"; import { readRuntimeKeyframes, scanAllRuntimeKeyframes } from "./gsapRuntimeBridge"; import { @@ -107,7 +108,12 @@ export async function fetchParsedAnimations( const res = await fetch( `/api/projects/${encodeURIComponent(projectId)}/gsap-animations/${encodeURIComponent(sourceFile)}`, ); - return res.ok ? ((await res.json()) as ParsedGsap) : null; + if (!res.ok) return null; + const parsed = (await res.json()) as ParsedGsap; + // Studio-emitted pre-keyframe hold `set`s are an internal runtime detail (they + // hold an element's first keyframe before its tween). They must not surface as + // user animations — otherwise they pollute the keyframe cache / timeline diamonds. + return { ...parsed, animations: parsed.animations.filter((a) => !isStudioHoldSet(a)) }; } catch { return null; } diff --git a/packages/studio/src/hooks/useRazorSplit.ts b/packages/studio/src/hooks/useRazorSplit.ts index 292d33232b..7fd8a72a69 100644 --- a/packages/studio/src/hooks/useRazorSplit.ts +++ b/packages/studio/src/hooks/useRazorSplit.ts @@ -38,13 +38,21 @@ async function splitHtmlElement( patchTarget: NonNullable>, splitTime: number, newId: string, + elementStart: number, + elementDuration: number, ): Promise<{ ok: boolean; changed?: boolean; content?: string }> { const response = await fetch( `/api/projects/${projectId}/file-mutations/split-element/${encodeURIComponent(targetPath)}`, { method: "POST", headers: { "Content-Type": "application/json" }, - body: JSON.stringify({ target: patchTarget, splitTime, newId }), + body: JSON.stringify({ + target: patchTarget, + splitTime, + newId, + elementStart, + elementDuration, + }), }, ); if (!response.ok) throw new Error("Split request failed"); @@ -114,7 +122,15 @@ async function executeSplit( const originalContent = await readFileContent(pid, targetPath); const newId = generateSplitId(collectHtmlIds(originalContent), element.domId || "clip"); - const splitResult = await splitHtmlElement(pid, targetPath, patchTarget, splitTime, newId); + const splitResult = await splitHtmlElement( + pid, + targetPath, + patchTarget, + splitTime, + newId, + element.start, + element.duration, + ); if (!splitResult.ok) throw new Error("Failed to split clip."); if (!splitResult.changed) { return { targetPath, originalContent, patchedContent: originalContent, changed: false }; diff --git a/packages/studio/src/hooks/useStudioContextValue.ts b/packages/studio/src/hooks/useStudioContextValue.ts index aa360586c8..177bd21a3f 100644 --- a/packages/studio/src/hooks/useStudioContextValue.ts +++ b/packages/studio/src/hooks/useStudioContextValue.ts @@ -90,8 +90,9 @@ export function useInspectorState( inspectorPanelActive, inspectorButtonActive: STUDIO_INSPECTOR_PANELS_ENABLED && !rightCollapsed && inspectorPanelActive, - shouldShowSelectedDomBounds: - inspectorPanelActive && !rightCollapsed && !isPlaying && !isGestureRecording, + // Keep the selection box + motion path drawn even when the Inspector is + // collapsed — closing the panel shouldn't visually deselect the element. + shouldShowSelectedDomBounds: inspectorPanelActive && !isPlaying && !isGestureRecording, }; }, [rightPanelTab, rightInspectorPanes, rightCollapsed, isPlaying, isGestureRecording]); } diff --git a/packages/studio/src/player/hooks/useExpandedTimelineElements.test.ts b/packages/studio/src/player/hooks/useExpandedTimelineElements.test.ts index 51d5980b3d..a8399dee8e 100644 --- a/packages/studio/src/player/hooks/useExpandedTimelineElements.test.ts +++ b/packages/studio/src/player/hooks/useExpandedTimelineElements.test.ts @@ -1,5 +1,6 @@ import { describe, expect, it } from "vitest"; import { buildExpandedElements } from "./useExpandedTimelineElements"; +import { buildTimelineElementKey } from "../lib/timelineElementHelpers"; import type { TimelineElement } from "../store/playerStore"; import type { ClipManifestClip } from "../lib/playbackTypes"; @@ -88,4 +89,37 @@ describe("buildExpandedElements", () => { expect(child.expandedParentStart).toBe(13); // C's start, not B's 12 or A's 10 expect(child.sourceFile).toBe("c.html"); // C's file, not b.html or a.html }); + + // Regression: an expanded child must share one identity (`key`) with the flat + // store element for the same DOM id. Before the fix the child key fell back to + // the colon form (`index.html:eyebrow:N`) while the store/selection used the + // hash form (`index.html#eyebrow`), so clicking an expanded child never + // highlighted it (isSelected compares the two keys). + it("keys expanded children in hash form, matching the flat store element", () => { + // Single composition (no sub-comps): scene `s1` with same-file children. + const elements = [el({ id: "s1", domId: "s1", start: 0, duration: 14 })]; + const manifest = [ + clip({ id: "s1", start: 0, duration: 14 }), + clip({ id: "eyebrow", start: 0, duration: 14 }), + clip({ id: "title", start: 0, duration: 14 }), + ]; + const parentMap = new Map([ + ["eyebrow", "s1"], + ["title", "s1"], + ]); + + const out = buildExpandedElements(elements, manifest, parentMap, "s1", "s1"); + const child = out.find((e) => e.domId === "eyebrow")!; + + const expectedStoreKey = buildTimelineElementKey({ + id: "eyebrow", + fallbackIndex: 0, + domId: "eyebrow", + selector: "#eyebrow", + sourceFile: undefined, + }); + expect(expectedStoreKey).toBe("index.html#eyebrow"); + expect(child.key).toBe("index.html#eyebrow"); + expect(child.key).toBe(expectedStoreKey); + }); }); diff --git a/packages/studio/src/player/hooks/useExpandedTimelineElements.ts b/packages/studio/src/player/hooks/useExpandedTimelineElements.ts index 6903402cc9..9649b2af74 100644 --- a/packages/studio/src/player/hooks/useExpandedTimelineElements.ts +++ b/packages/studio/src/player/hooks/useExpandedTimelineElements.ts @@ -2,6 +2,7 @@ import { useMemo } from "react"; import { usePlayerStore, type TimelineElement } from "../store/playerStore"; import type { ClipManifestClip } from "../lib/playbackTypes"; import { createTimelineElementFromManifestClip } from "../lib/timelineDOM"; +import { buildTimelineElementKey } from "../lib/timelineElementHelpers"; function findTopLevelAncestor(id: string, parentMap: Map): string | null { let current = parentMap.get(id); @@ -78,14 +79,31 @@ function buildChildElements( clip: child, fallbackIndex: result.length, }); + const domId = child.id ?? undefined; + const selector = child.id ? `#${child.id}` : undefined; + // `base.key` was built without a hostEl, so it fell back to the colon form + // (`index.html::`) even though we set domId below. Recompute it from + // the same inputs the store uses (`#`) so an expanded + // child shares one identity with its flat store element — otherwise selecting + // it sets `selectedElementId` to the store's hash key while the rendered row + // is keyed by the colon form, and `isSelected` never matches (no highlight). + const key = buildTimelineElementKey({ + id: base.id, + fallbackIndex: result.length, + domId, + selector, + selectorIndex: base.selectorIndex, + sourceFile: editBasis.sourceFile, + }); result.push({ ...base, + key, start: clamped.start, duration: clamped.duration, track: display.track + result.length, expandedParentStart: editBasis.start, - domId: child.id ?? undefined, - selector: child.id ? `#${child.id}` : undefined, + domId, + selector, sourceFile: editBasis.sourceFile, timingSource: "authored" as const, }); diff --git a/packages/studio/src/player/store/playerStore.ts b/packages/studio/src/player/store/playerStore.ts index 05b378d11d..1ce0b1a672 100644 --- a/packages/studio/src/player/store/playerStore.ts +++ b/packages/studio/src/player/store/playerStore.ts @@ -327,7 +327,16 @@ export const usePlayerStore = create((set, get) => ({ setTimelineReady: (ready) => set({ timelineReady: ready }), setBeatDragging: (dragging) => set({ beatDragging: dragging }), setElements: (elements) => set({ elements }), - setSelectedElementId: (id) => set({ selectedElementId: id }), + setSelectedElementId: (id) => + set((s) => + // Selecting a different element drops any active keyframe selection — otherwise + // a stale activeKeyframePct from a prior diamond click would force the next drag + // to "modify" a keyframe on the new element. A diamond click sets the pct AFTER + // calling setSelectedElementId, so this never clobbers a genuine keyframe select. + id !== s.selectedElementId + ? { selectedElementId: id, activeKeyframePct: null } + : { selectedElementId: id }, + ), updateElement: (elementId, updates) => set((state) => ({ elements: state.elements.map((el) => @@ -361,3 +370,10 @@ export const usePlayerStore = create((set, get) => ({ clipParentMap: new Map(), }), })); + +// Bug-bash aid: expose the store so a reproduction can dump live state from the +// console, e.g. `__playerStore.getState().selectedElementId`. Harmless read +// handle; no behavioural effect. +if (typeof window !== "undefined") { + (window as unknown as { __playerStore?: typeof usePlayerStore }).__playerStore = usePlayerStore; +} diff --git a/packages/studio/src/utils/studioHelpers.test.ts b/packages/studio/src/utils/studioHelpers.test.ts index 406585d6a4..c50abc548d 100644 --- a/packages/studio/src/utils/studioHelpers.test.ts +++ b/packages/studio/src/utils/studioHelpers.test.ts @@ -1,5 +1,11 @@ +// @vitest-environment happy-dom + import { describe, expect, it } from "vitest"; -import { findMatchingTimelineElementId, resolveTimelineSelectionSeekTime } from "./studioHelpers"; +import { + findMatchingTimelineElementId, + findTimelineIdByAncestor, + resolveTimelineSelectionSeekTime, +} from "./studioHelpers"; describe("resolveTimelineSelectionSeekTime", () => { it("keeps the current time when it is already inside the clip range", () => { @@ -42,3 +48,27 @@ describe("findMatchingTimelineElementId", () => { expect(findMatchingTimelineElementId({ id: "ghost", sourceFile: "index.html" }, [])).toBe(null); }); }); + +describe("findTimelineIdByAncestor", () => { + const el = (over: Record) => + ({ id: "x", start: 0, duration: 1, track: 0, tag: "div", ...over }) as never; + + it("resolves a static descendant (.num) to its nearest clip ancestor", () => { + // #stat1 (a clip) > .num (selected, not a clip) + const stat1 = document.createElement("div"); + stat1.id = "stat1"; + const num = document.createElement("div"); + num.className = "num"; + stat1.appendChild(num); + + const els = [el({ id: "stat1", domId: "stat1", key: "index.html#stat1" })]; + expect(findTimelineIdByAncestor(num, els, "index.html")).toBe("index.html#stat1"); + }); + + it("returns null when no ancestor is a clip", () => { + const wrap = document.createElement("div"); + const child = document.createElement("span"); + wrap.appendChild(child); + expect(findTimelineIdByAncestor(child, [], "index.html")).toBe(null); + }); +}); diff --git a/packages/studio/src/utils/studioHelpers.ts b/packages/studio/src/utils/studioHelpers.ts index 2dbaf77d96..5677f7f704 100644 --- a/packages/studio/src/utils/studioHelpers.ts +++ b/packages/studio/src/utils/studioHelpers.ts @@ -185,6 +185,30 @@ export function findMatchingTimelineElementId( return null; } +/** + * A selected DOM node may be a static descendant of a clip (e.g. the `.num` text + * inside a `#stat1` card) — not a timeline element itself. Walk up to the nearest + * ancestor that IS a clip so the timeline still selects + inline-expands around it. + */ +export function findTimelineIdByAncestor( + element: Element | null | undefined, + elements: TimelineElement[], + sourceFile: string, +): string | null { + let ancestor = element?.parentElement ?? null; + while (ancestor) { + const id = ancestor.id; + if (id) { + const match = elements.find( + (el) => el.domId === id && (el.sourceFile ?? "index.html") === sourceFile, + ); + if (match) return match.key ?? match.id; + } + ancestor = ancestor.parentElement; + } + return null; +} + export function resolveTimelineSelectionSeekTime( currentTime: number, element: Pick | null | undefined, @@ -204,8 +228,6 @@ export function clampNumber(value: number, min: number, max: number): number { return Math.min(Math.max(value, min), max); } -export { COMPOSITION_ROOT_OPEN_TAG_RE } from "./compositionPatterns"; - export function collectHtmlIds(source: string): string[] { return Array.from(source.matchAll(/\bid="([^"]+)"/g), (match) => match[1] ?? ""); } diff --git a/packages/studio/src/utils/studioPreviewHelpers.test.ts b/packages/studio/src/utils/studioPreviewHelpers.test.ts index fce01d7b36..c673142e6a 100644 --- a/packages/studio/src/utils/studioPreviewHelpers.test.ts +++ b/packages/studio/src/utils/studioPreviewHelpers.test.ts @@ -1,5 +1,30 @@ import { describe, expect, it, vi } from "vitest"; -import { pauseStudioPreviewPlayback } from "./studioPreviewHelpers"; +import { coversComposition, pauseStudioPreviewPlayback } from "./studioPreviewHelpers"; + +describe("coversComposition (full-bleed canvas-pick exclusion)", () => { + const viewport = { width: 1920, height: 1080 }; + + it("treats a full-bleed scene wrapper as covering the composition", () => { + expect(coversComposition({ width: 1920, height: 1080 }, viewport)).toBe(true); + expect(coversComposition({ width: 1900, height: 1040 }, viewport)).toBe(true); // ~99%/96% + }); + + it("does NOT exclude inner content (a stat card, a heading)", () => { + expect(coversComposition({ width: 320, height: 180 }, viewport)).toBe(false); + expect(coversComposition({ width: 1900, height: 200 }, viewport)).toBe(false); // wide but short + expect(coversComposition({ width: 200, height: 1040 }, viewport)).toBe(false); // tall but narrow + }); + + it("needs BOTH axes near full-bleed (>=95%)", () => { + expect(coversComposition({ width: 1800, height: 1080 }, viewport)).toBe(false); // 93.75% wide + expect(coversComposition({ width: 1920, height: 1000 }, viewport)).toBe(false); // 92.6% tall + }); + + it("guards against a degenerate viewport", () => { + expect(coversComposition({ width: 100, height: 100 }, { width: 0, height: 0 })).toBe(false); + expect(coversComposition({ width: 100, height: 100 }, { width: 1, height: 1 })).toBe(false); + }); +}); describe("pauseStudioPreviewPlayback", () => { it("pauses through __player without pausing sibling timelines directly", () => { diff --git a/packages/studio/src/utils/studioPreviewHelpers.ts b/packages/studio/src/utils/studioPreviewHelpers.ts index 6bb8cb8a5e..2ec9911bc5 100644 --- a/packages/studio/src/utils/studioPreviewHelpers.ts +++ b/packages/studio/src/utils/studioPreviewHelpers.ts @@ -1,5 +1,4 @@ import type { DomEditViewport } from "../components/editor/domEditing"; -import { resolveVisualDomEditSelectionTarget } from "../components/editor/domEditing"; import { getDomLayerPatchTarget, isElementComputedVisible, @@ -13,6 +12,29 @@ interface PreviewLocalPointer { viewport: DomEditViewport; } +// An element is "full-bleed" when its box spans nearly the whole composition on +// BOTH axes. Such elements (scene wrappers, backdrops) are excluded from canvas +// click-picking so a click lands on inner content — or deselects on empty area — +// instead of grabbing the giant container. The Layers panel still selects them. +// ponytail: pure size heuristic; tighten the ratio if decorative full-bleed art +// should remain canvas-selectable. +const FULL_BLEED_RATIO = 0.95; + +export function coversComposition( + elRect: { width: number; height: number }, + viewport: DomEditViewport, +): boolean { + if (viewport.width <= 1 || viewport.height <= 1) return false; + return ( + elRect.width / viewport.width >= FULL_BLEED_RATIO && + elRect.height / viewport.height >= FULL_BLEED_RATIO + ); +} + +function isFullBleedTarget(el: HTMLElement, viewport: DomEditViewport): boolean { + return coversComposition(el.getBoundingClientRect(), viewport); +} + function resolvePreviewLocalPointer( iframe: HTMLIFrameElement, doc: Document, @@ -82,18 +104,19 @@ export function getPreviewTargetFromPointer( const overrideStyle = forcePointerEventsAuto(doc); try { if (typeof doc.elementsFromPoint === "function") { - const visualTarget = resolveVisualDomEditSelectionTarget( + const candidates = resolveAllVisualDomEditTargets( doc.elementsFromPoint(localPointer.x, localPointer.y), - { - activeCompositionPath, - }, + { activeCompositionPath }, ); + const visualTarget = + candidates.find((el) => !isFullBleedTarget(el, localPointer.viewport)) ?? null; if (visualTarget) return visualTarget; } const fallback = getEventTargetElement(doc.elementFromPoint(localPointer.x, localPointer.y)); if (!fallback || !getDomLayerPatchTarget(fallback, activeCompositionPath)) return null; if (!isElementComputedVisible(fallback)) return null; + if (isFullBleedTarget(fallback, localPointer.viewport)) return null; return fallback; } finally { removePointerEventsOverride(overrideStyle); @@ -125,11 +148,12 @@ export function getAllPreviewTargetsFromPointer( if (typeof doc.elementsFromPoint === "function") { return resolveAllVisualDomEditTargets(doc.elementsFromPoint(localPointer.x, localPointer.y), { activeCompositionPath, - }); + }).filter((el) => !isFullBleedTarget(el, localPointer.viewport)); } const fallback = getEventTargetElement(doc.elementFromPoint(localPointer.x, localPointer.y)); if (!fallback || !getDomLayerPatchTarget(fallback, activeCompositionPath)) return []; if (!isElementComputedVisible(fallback)) return []; + if (isFullBleedTarget(fallback, localPointer.viewport)) return []; return [fallback]; } finally { removePointerEventsOverride(overrideStyle); diff --git a/skills/hyperframes-keyframes/SKILL.md b/skills/hyperframes-keyframes/SKILL.md new file mode 100644 index 0000000000..5f01f7e2dc --- /dev/null +++ b/skills/hyperframes-keyframes/SKILL.md @@ -0,0 +1,68 @@ +--- +name: hyperframes-keyframes +description: Read and edit GSAP keyframes and motion paths in a HyperFrames composition. Use whenever a task involves an element's MOTION over time — adding/removing/moving keyframes, refining a motion path, changing where or when something travels, debugging "why does it move there", or understanding an existing animation before editing it. Run `npx hyperframes keyframes` to surface every tween's keyframes + an ASCII motion-path so you can see and edit motion as data instead of guessing at raw numbers. +--- + +# HyperFrames Keyframes + +Editing motion by reading `keyframes: [{x:0},{x:-260}]` in source is guessing — you can't see the *shape* a tween traces, only opaque numbers. `npx hyperframes keyframes` surfaces every GSAP tween, its keyframes (with absolute times), and an **ASCII motion-path drawing** so you can reason about motion, then edit precisely and verify. + +## The loop + +1. **Surface** — `npx hyperframes keyframes [dir|file]` (defaults to `./index.html` + sub-compositions). +2. **Read** the path shape + keyframe list (or `--json` for exact data). +3. **Edit** the `keyframes` / `x`/`y` values in the composition source. +4. **Verify** — re-run `npx hyperframes keyframes` to confirm the new shape, then `npx hyperframes inspect` / `render`. + +```bash +npx hyperframes keyframes # whole project +npx hyperframes keyframes --selector '#hero' # one element +npx hyperframes keyframes compositions/s2.html # one composition file +npx hyperframes keyframes --json # machine-readable (agents) +``` + +## Reading the output + +``` +#puck-b position to/keyframes @1s→4.4s (3.4s) + 0% {x:0 y:0} 33% {x:-180 y:-60} 67% {x:-320 y:40} 100% {x:-460 y:-20} + ┌──────────────────────┐ + │ 1· │ + │ ·· ·· │ + │ 3· ·· ·0 │ + │ ·· ·2· │ + └──────────────────────┘ + x -460..0 y -60..40 (gsap px; marks 0..n = keyframe order) +``` + +- **`to/keyframes`** = method (`to`/`from`/`fromTo`/`set`) + shape (`keyframes` multi-stop, `flat` 2-point, `motionPath` arc). +- **`@1s→4.4s`** = absolute timeline window; each `%` is **tween-relative** (0 % = tween start, 100 % = tween end). +- **Keyframe line** = every stop with its properties. +- **ASCII grid** = the position path in GSAP **x/y offset** pixels (the element's translate from its layout home; +x right, +y down). Marks `0,1,2,…` are keyframes in order; on dense gesture paths only `S`→`E` are marked and the path is traced with `·`. +- `--json` gives exact `{ pct, time, properties }` per keyframe + the raw `path` points — use it when you need to compute edits. + +## Editing keyframes (in source) + +Percentages are **tween-relative**, and edits go in the composition's `