Skip to content

feat(studio): element groups#1736

Closed
miguel-heygen wants to merge 19 commits into
mainfrom
feat/element-groups
Closed

feat(studio): element groups#1736
miguel-heygen wants to merge 19 commits into
mainfrom
feat/element-groups

Conversation

@miguel-heygen

Copy link
Copy Markdown
Collaborator

Element groups (P1)

A group is a real <div data-hf-group="Group N"> wrapping its children in the composition source. The DOM gives us containment, parent→child transform composition, bounds, z-order, and persistence for free; GSAP selectors survive wrapping; groups nest.

Core (packages/core)

  • wrapElementsInHtml / unwrapElementsFromHtml mirroring splitElementInHtml: insert the wrapper at the first member's slot, move members in DOM order (= z-order), and rebase each member's inline left/top so its absolute position is unchanged. Members must share one parent (P1). Round-trip is identity.
    • Coordinate rebasing handles the wrinkle where left/top lives in a CSS rule rather than inline — the rebase writes inline on wrap and reverses on unwrap. Covered by a round-trip test over plain, GSAP-animated, and --hf-studio-offset elements.
  • wrap-elements / unwrap-elements file-mutation routes mirroring patch-element, with bbox/rebase coordinates validated as finite numbers (blocks style-string injection).

Studio (packages/studio)

  • Create / ungroup — ⌘G groups the multi-selection, ⌘⇧G ungroups (also an Ungroup button in the inspector for discoverability). Both surfaced in the timeline shortcuts panel.
  • Select-as-unitbuildStableSelector emits a [data-hf-group="…"] selector so a wrapper is selectable, patchable, and addressable; clicking a member selects the whole group.
  • Drill-in — double-click a group (canvas or layer tree) to enter it and edit members; a "← Back" breadcrumb and member-scoped layer tree; nested groups drill one level at a time.
  • Move / scale — selecting a wrapper routes through the existing single-element drag/resize intercepts; children follow via CSS composition.
  • Sub-compositions — grouping works inside sub-comps and in master view; routes to the right source file.
  • A group's overlay/off-canvas bounds are the union of its members (where the content actually renders), shared across selection, hover, and off-canvas so they always agree.

3D transform widget polish

Tangential to groups (came out of the same dogfooding pass) — easy to split into a separate PR if preferred:

  • Removed the redundant in-cube perspective slider; the grid's Z field is depth (translateZ).
  • Scroll over the cube to nudge depth, matching the studio's "scroll = z depth" gesture convention; a sensible default perspective is applied the first time depth is set so it's actually visible.
  • The cube now scales with depth for realistic Z feedback (P/(P−z)).
  • Fixed the cube's orientation: it projects Y-up while the element is CSS Y-down, so RotateX/RotateZ rendered inverted — flipped those axes' signs (and the matching drag deltas) so the gizmo is a true mirror.

Testing

  • Core: wrap/unwrap round-trip unit test.
  • Studio: group-capture (select-as-unit, nested drill-in, out-of-scope), layer-tree scoping, plus the existing selection/overlay suites — all green.
  • Manually dogfooded end to end in the studio (create, select-as-unit, drill-in, move, ungroup, sub-comp grouping, off-canvas, drag tracking); the group-drag-lag and off-canvas-marker fixes were root-caused with temporary instrumentation that's since been removed.

All packages typecheck, lint, and pass the file-size + audit gates.

Wrap selected elements in a <div data-hf-group> in the composition source, with
coordinate rebasing so the members' on-screen positions are unchanged. Supports
create (Cmd+G) / ungroup (Cmd+Shift+G or an inspector button), select-as-unit,
canvas + layer-tree drill-in with a back breadcrumb, move/scale by reusing the
single-element drag/resize intercepts, grouping inside sub-compositions, and
member-union bounds shared across the selection, hover, and off-canvas overlays.

Core: wrapElementsInHtml / unwrapElementsFromHtml mirror splitElementInHtml
(round-trip identity, handles left/top declared in a CSS rule), plus wrap-elements
/ unwrap-elements file-mutation routes with finite-number coordinate validation.

Also polishes the 3D transform widget (tangential to groups): depth via
scroll-over-cube instead of a redundant perspective slider, a depth-scaled cube
for realistic Z feedback, and a screen-Y-down orientation fix so the gizmo
mirrors the element's rotation.
Comment thread packages/studio/src/hooks/useGroupCommits.ts
Comment thread packages/studio/src/hooks/useGroupCommits.ts
Model the timeline as a structural TimelineRef (identifier OR member expression)
instead of a string variable name, so the acorn read parser can detect and read
the inline window.__timelines[id] = gsap.timeline() form — matching tweens by AST
structure rather than identifier name. Static string/dot keys (single/double quote)
supported; computed keys stay flagged unsupported; canonical const form unchanged.
…frames)

Proves the acorn write path round-trips inline window.__timelines[id] = gsap.timeline()
edits — the read-path member ref makes the writer emit window.__timelines[id].to(...)
with no writer changes. Covers edit-in-place, add (incl. empty timeline), delete,
keyframe add, remove-all-keyframes, and single-quote preservation.
…er path)

Mirror the TimelineRef model into the recast parser/writer (gsapParser.ts) — the
default studio-api write path — so window.__timelines[id] = gsap.timeline() reads
AND round-trips edits (the emitter roots tweens at the member source). Static
string/dot keys supported; computed keys stay unsupported; canonical const form
unchanged (194 recast tests green). 9 inline read+write tests added.
The unsupported-pattern banner now clears for static window.__timelines["id"] =
gsap.timeline() (the parser reports it editable), and the banner copy is retargeted
to the genuinely-unsupported case: computed/dynamic keys (window.__timelines[var]).
Sub-comp internal elements (group wrappers + their children) carry no data-start,
so the clip tree/manifest never enumerate them and the Scene row had nothing to
expand into. Descend into each sub-comp host's DOM studio-side (useTimelineSyncCallbacks),
collect groups + children as domClipChildren with parent links, and synthesize
child rows spanning the host's bounds at expand time (useExpandedTimelineElements).
Manifest stays lean (timed clips only). Verified: selecting a pill shows its
siblings under the Scene row; selecting the group shows the group.
Two group selection bugs with animated members:

1) Empty space inside a group's overlay didn't hover/select the group. Members
   animated outside the wrapper's static box (110px box vs 340px member union),
   so elementsFromPoint hit only the full-bleed background there. Add a
   member-union hit-test fallback: a point inside a group's live member bounds
   resolves to that group (innermost wins).

2) After drilling into a group and selecting a child, nothing else was
   selectable — out-of-scope resolved to null. Make drill-in non-sticky:
   interacting outside the drilled group re-resolves normally and exits the
   drill-in, so a later click on the group selects it as a unit again.
Setting depth fired two un-awaited gsap mutations (transformPerspective, then z)
to the same script. They raced read-modify-write: the second read the base before
the first landed and wrote back without the other prop, so the lens or z reverted
after a seek (depth 'didn't stick' after scrolling). The concurrent writes could
also collide on the file and 404 the save. Batch both into one keyframe commit,
exactly as commitPose/recenter already do for the rotation axes.
… keyframe

promoteSetToKeyframes returned early when the playhead was at or before the set's
start (t <= setStart) — so clicking Enable keyframes on a gsap.set element while
seeking at 0 (set start 0) did nothing. It only knew how to promote a set into a
FORWARD range (held@0% → live@100%). Now, when there's no forward range, replace
the set with a single keyframe at the playhead holding its value (matching the
no-animation branch), so a 0% keyframe is actually created.
…ne clips

Two gaps for elements inside a sub-composition:

1) Clip keyframes rendered off-clip. The keyframe cache computes clip-relative
   percentages from the element's start/duration, but sub-comp internals aren't in
   the timeline elements list, so duration defaulted to 1s and percentages blew
   past 100%. Resolve the timing basis from the sub-comp HOST's bounds (via
   domClipChildren, since the host's data-composition-src is stripped in the
   rendered DOM). Shared resolveClipTimingBasis used by both cache populators,
   which now re-run when the sub-comp children appear.

2) Only GROUPED sub-comp children expanded. Generalize the DOM-children collector
   to gather id'd children of the sub-comp inner-root (grouped OR ungrouped),
   descending through id-less structural wrappers; one level into groups for
   drill-in. Ungrouped pills now expand into timeline rows too.
Dragging timeline keyframe diamonds was unreliable — clip<->tween percentage
remapping, an optimistic-hold workaround, and an intermittent no-op/revert when
the GSAP session lagged the drag (its own comments document the flakiness). Remove
the drag interaction entirely: diamonds still display, click-to-seek, and offer the
context menu (add/remove/ease) — keyframe timing is edited via the playhead + panel,
which are deterministic. Deletes the keyframe-move plan module + its wiring through
TimelineClipDiamonds -> TimelineCanvas -> Timeline -> TimelineEditContext.
Ungrouping removed the wrapper element but left its gsap.set("#group-1") behind,
targeting a now-deleted element. GSAP then threw "target not found" on every
preview run, which drove a selection re-render storm that made canvas context
menus (e.g. Delete All Keyframes) unclickable. unwrapElementsFromHtml now returns
the unwrapped wrapper's id, and the unwrap route strips any GSAP animation
targeting it (reusing the parser + removeAnimationFromScript).
- show the 3D Transform panel for any selected element, not only ones already
  animated; the first edit creates the gsap.set
- scrolling depth on a flat element drops a gentle tilt so depth reads in place
- clamp the depth scroll in front of the perspective lens (no runaway z)
- register a no-op for the internal _auto keyframe marker so GSAP stops warning
…orms

A z/perspective transform foreshortens an element by 1/m44; the drag offset and
motion-path geometry ignored it, so the selection overlay and path drifted off
depth elements. Fold m44 into the drag offset and the motion-path points, with the
inverse applied where a pointer maps back to a stored offset.
- pickBestAnimation is group-aware: a rotation/3D edit no longer merges into a
  position tween — a fresh same-group tween with a 0% baseline is created instead
- editing at a playhead past the tween extends it and keyframes there (matches drag)
- update-keyframe MERGES into the existing keyframe instead of overwriting, so
  editing one property no longer drops z/transformPerspective (the lens then
  animated from 0 and the element popped)
- dragging a keyframed element with a constant position tween keyframes rather
  than writing a static set
Ungroup only baked the wrapper's layout (left/top), not its GSAP transform — so a
moved group's members snapped back to their creation-time positions. Distribute the
group's static transform onto each member before stripping it: translation is an
exact per-axis add; rotation/scale are composed about the group center so off-center
members don't drift. Animated group transforms are left to be stripped, not baked.
Label the keyframe toolbar button 'Add keyframe (K)' (and the at-playhead /
extend variants) so its action and shortcut are discoverable.
@github-actions

Copy link
Copy Markdown

Fallow audit report

Found 72 findings.

Dead code (1)
Severity Rule Location Description
major fallow/unused-export packages/studio/src/components/editor/GestureRecordControl.tsx:59 Export 'GestureRecordBadge' is never imported by other modules
Duplication (37)
Severity Rule Location Description
minor fallow/code-duplication packages/core/src/parsers/gsapParser.ts:693 Code clone group 1 (7 lines, 2 instances)
minor fallow/code-duplication packages/core/src/parsers/gsapParser.ts:808 Code clone group 1 (7 lines, 2 instances)
minor fallow/code-duplication packages/core/src/parsers/gsapParser.ts:1445 Code clone group 2 (10 lines, 2 instances)
minor fallow/code-duplication packages/core/src/parsers/gsapParser.ts:1473 Code clone group 2 (10 lines, 2 instances)
minor fallow/code-duplication packages/core/src/parsers/gsapParser.ts:1568 Code clone group 3 (8 lines, 2 instances)
minor fallow/code-duplication packages/core/src/parsers/gsapParser.ts:2223 Code clone group 4 (27 lines, 2 instances)
minor fallow/code-duplication packages/core/src/parsers/gsapParser.ts:2258 Code clone group 5 (9 lines, 2 instances)
minor fallow/code-duplication packages/core/src/parsers/gsapParser.ts:2292 Code clone group 4 (27 lines, 2 instances)
minor fallow/code-duplication packages/core/src/parsers/gsapParser.ts:2320 Code clone group 5 (9 lines, 2 instances)
minor fallow/code-duplication packages/core/src/parsers/gsapParser.ts:2536 Code clone group 6 (8 lines, 2 instances)
minor fallow/code-duplication packages/core/src/parsers/gsapParser.ts:2698 Code clone group 7 (13 lines, 2 instances)
minor fallow/code-duplication packages/core/src/parsers/gsapParser.ts:2699 Code clone group 8 (10 lines, 3 instances)
minor fallow/code-duplication packages/core/src/parsers/gsapParser.ts:2741 Code clone group 7 (13 lines, 2 instances)
minor fallow/code-duplication packages/core/src/parsers/gsapParser.ts:2742 Code clone group 8 (10 lines, 3 instances)
minor fallow/code-duplication packages/core/src/parsers/gsapParser.ts:2769 Code clone group 8 (10 lines, 3 instances)
minor fallow/code-duplication packages/core/src/parsers/gsapParser.ts:2788 Code clone group 9 (9 lines, 2 instances)
minor fallow/code-duplication packages/core/src/parsers/gsapParser.ts:2815 Code clone group 9 (9 lines, 2 instances)
minor fallow/code-duplication packages/core/src/parsers/gsapParser.ts:2873 Code clone group 3 (8 lines, 2 instances)
minor fallow/code-duplication packages/core/src/parsers/gsapParserAcorn.inline.test.ts:25 Code clone group 10 (8 lines, 2 instances)
minor fallow/code-duplication packages/core/src/parsers/gsapParserAcorn.inline.test.ts:36 Code clone group 10 (8 lines, 2 instances)
minor fallow/code-duplication packages/core/src/parsers/gsapSerialize.ts:592 Code clone group 6 (8 lines, 2 instances)
minor fallow/code-duplication packages/core/src/studio-api/helpers/sourceMutation.test.ts:377 Code clone group 11 (9 lines, 2 instances)
minor fallow/code-duplication packages/core/src/studio-api/helpers/sourceMutation.test.ts:444 Code clone group 11 (9 lines, 2 instances)
minor fallow/code-duplication packages/studio/src/components/editor/manualEditsDom.ts:168 Code clone group 12 (7 lines, 2 instances)
minor fallow/code-duplication packages/studio/src/components/editor/propertyPanelHelpers.ts:328 Code clone group 12 (7 lines, 2 instances)
minor fallow/code-duplication packages/studio/src/hooks/gsapRuntimeBridge.ts:397 Code clone group 13 (6 lines, 2 instances)
minor fallow/code-duplication packages/studio/src/hooks/gsapRuntimeBridge.ts:549 Code clone group 13 (6 lines, 2 instances)
minor fallow/code-duplication packages/studio/src/hooks/useDomEditWiring.ts:46 Code clone group 14 (48 lines, 2 instances)
minor fallow/code-duplication packages/studio/src/hooks/useDomSelection.ts:71 Code clone group 15 (22 lines, 2 instances)
minor fallow/code-duplication packages/studio/src/hooks/useElementLifecycleOps.ts:20 Code clone group 16 (9 lines, 2 instances)
minor fallow/code-duplication packages/studio/src/hooks/useElementLifecycleOps.ts:69 Code clone group 17 (11 lines, 2 instances)
minor fallow/code-duplication packages/studio/src/hooks/useGroupCommits.ts:14 Code clone group 16 (9 lines, 2 instances)
minor fallow/code-duplication packages/studio/src/hooks/useGroupCommits.ts:84 Code clone group 17 (11 lines, 2 instances)
minor fallow/code-duplication packages/studio/src/hooks/useGsapSelectionHandlers.ts:33 Code clone group 14 (48 lines, 2 instances)
minor fallow/code-duplication packages/studio/src/hooks/usePreviewInteraction.ts:17 Code clone group 15 (22 lines, 2 instances)
minor fallow/code-duplication packages/studio/src/player/hooks/useExpandedTimelineElements.test.ts:45 Code clone group 18 (7 lines, 2 instances)
minor fallow/code-duplication packages/studio/src/player/hooks/useExpandedTimelineElements.test.ts:68 Code clone group 18 (7 lines, 2 instances)
Health (34)
Severity Rule Location Description
major fallow/high-crap-score packages/core/src/parsers/gsapParserAcorn.ts:392 'sameMemberAccess' has CRAP score 63.6 (threshold: 30.0, cyclomatic 15)
major fallow/high-crap-score packages/studio/src/components/TimelineToolbar.tsx:37 'useKeyframeToggle' has CRAP score 72.0 (threshold: 30.0, cyclomatic 8)
minor fallow/high-crap-score packages/studio/src/components/TimelineToolbar.tsx:172 '<arrow>' has CRAP score 42.0 (threshold: 30.0, cyclomatic 6)
minor fallow/high-crap-score packages/studio/src/components/editor/LayersPanel.tsx:151 'seekToLayer' has CRAP score 43.1 (threshold: 30.0, cyclomatic 12)
minor fallow/high-crap-score packages/studio/src/components/editor/LayersPanel.tsx:306 '<arrow>' has CRAP score 37.1 (threshold: 30.0, cyclomatic 11)
critical fallow/high-crap-score packages/studio/src/components/editor/MotionPathOverlay.tsx:356 'onPathDown' has CRAP score 110.0 (threshold: 30.0, cyclomatic 10)
critical fallow/high-crap-score packages/studio/src/components/editor/domEditingDom.ts:202 'escapeCssIdentifier' has CRAP score 148.4 (threshold: 30.0, cyclomatic 24)
minor fallow/high-cognitive-complexity packages/studio/src/components/editor/manualOffsetDrag.ts:328 'createManualOffsetDragMember' has cognitive complexity 17 (threshold: 15)
critical fallow/high-crap-score packages/studio/src/components/editor/useDomEditOverlayRects.ts:115 'update' has CRAP score 299.6 (threshold: 30.0, cyclomatic 35)
major fallow/high-crap-score packages/studio/src/components/editor/useMotionPathData.ts:10 'transformTranslate' has CRAP score 90.0 (threshold: 30.0, cyclomatic 9)
minor fallow/high-crap-score packages/studio/src/components/editor/useMotionPathData.ts:32 'transformWDivisor' has CRAP score 42.0 (threshold: 30.0, cyclomatic 6)
major fallow/high-crap-score packages/studio/src/components/editor/useMotionPathData.ts:40 'elementHome' has CRAP score 90.0 (threshold: 30.0, cyclomatic 9)
minor fallow/high-crap-score packages/studio/src/components/editor/useMotionPathData.ts:68 'isPreviewHtmlElement' has CRAP score 30.0 (threshold: 30.0, cyclomatic 5)
major fallow/high-crap-score packages/studio/src/components/editor/useMotionPathData.ts:125 'tick' has CRAP score 90.0 (threshold: 30.0, cyclomatic 9)
major fallow/high-crap-score packages/studio/src/components/editor/useMotionPathData.ts:171 '<arrow>' has CRAP score 56.0 (threshold: 30.0, cyclomatic 7)
minor fallow/high-crap-score packages/studio/src/hooks/gsapRuntimeBridge.ts:77 '<arrow>' has CRAP score 37.1 (threshold: 30.0, cyclomatic 11)
minor fallow/high-crap-score packages/studio/src/hooks/gsapRuntimeBridge.ts:110 '<arrow>' has CRAP score 43.1 (threshold: 30.0, cyclomatic 12)
minor fallow/high-crap-score packages/studio/src/hooks/gsapRuntimeBridge.ts:138 'resolveGroupTween' has CRAP score 31.6 (threshold: 30.0, cyclomatic 10)
critical fallow/high-crap-score packages/studio/src/hooks/gsapRuntimeBridge.ts:314 'tryGsapResizeIntercept' has CRAP score 50.5 (threshold: 30.0, cyclomatic 44)
minor fallow/high-cognitive-complexity packages/studio/src/hooks/gsapRuntimeBridge.ts:497 'tryGsapRotationIntercept' has cognitive complexity 19 (threshold: 15)
minor fallow/high-crap-score packages/studio/src/hooks/useAnimatedPropertyCommit.ts:64 'scored' has CRAP score 31.6 (threshold: 30.0, cyclomatic 10)
major fallow/high-crap-score packages/studio/src/hooks/useAnimatedPropertyCommit.ts:310 'commitAnimatedProperties' has CRAP score 71.3 (threshold: 30.0, cyclomatic 16)
critical fallow/high-crap-score packages/studio/src/hooks/useAppHotkeys.ts:157 'dispatchModifierKey' has CRAP score 420.0 (threshold: 30.0, cyclomatic 20)
critical fallow/high-crap-score packages/studio/src/hooks/useAppHotkeys.ts:373 'applyHistory' has CRAP score 156.0 (threshold: 30.0, cyclomatic 12)
minor fallow/high-crap-score packages/studio/src/hooks/useAppHotkeys.ts:444 'handleAppKeyDown' has CRAP score 30.0 (threshold: 30.0, cyclomatic 5)
major fallow/high-crap-score packages/studio/src/hooks/useAppHotkeys.ts:504 'syncPreviewHistoryHotkey' has CRAP score 56.0 (threshold: 30.0, cyclomatic 7)
minor fallow/high-cognitive-complexity packages/studio/src/hooks/useEnableKeyframes.ts:247 'promoteSetToKeyframes' has cognitive complexity 21 (threshold: 15)
major fallow/high-crap-score packages/studio/src/player/components/TimelineCanvas.tsx:207 '<arrow>' has CRAP score 97.0 (threshold: 30.0, cyclomatic 19)
minor fallow/high-crap-score packages/studio/src/player/components/TimelineCanvas.tsx:285 '<arrow>' has CRAP score 37.1 (threshold: 30.0, cyclomatic 11)
major fallow/high-crap-score packages/studio/src/player/components/TimelineCanvas.tsx:335 '<arrow>' has CRAP score 63.6 (threshold: 30.0, cyclomatic 15)
critical fallow/high-crap-score packages/studio/src/player/hooks/useTimelineSyncCallbacks.ts:60 'processTimelineMessage' has CRAP score 148.4 (threshold: 30.0, cyclomatic 24)
critical fallow/high-crap-score packages/studio/src/player/hooks/useTimelineSyncCallbacks.ts:209 'initializeAdapter' has CRAP score 238.6 (threshold: 30.0, cyclomatic 31)
minor fallow/high-crap-score packages/studio/src/player/hooks/useTimelineSyncCallbacks.ts:320 'onMessage' has CRAP score 31.6 (threshold: 30.0, cyclomatic 10)
minor fallow/high-crap-score packages/studio/src/utils/studioPreviewHelpers.ts:89 'findGroupAtPoint' has CRAP score 37.1 (threshold: 30.0, cyclomatic 11)

Generated by fallow.

@miguel-heygen

Copy link
Copy Markdown
Collaborator Author

Superseded by a Graphite stack of 8 smaller PRs (each <700 LOC), split along the thematic boundaries:

  1. feat(studio): element groups — source mutations #1758 — element groups: source mutations
  2. feat(studio): element groups — studio UI #1759 — element groups: studio UI
  3. feat(gsap): read timelines authored inline (acorn read path) #1760 — edit inline-authored GSAP timelines
  4. feat(studio): expand sub-composition groups + children in the timeline #1761 — sub-composition timeline expansion
  5. fix(studio): batch 3D depth commit so perspective + z don't race #1762 — 3D transform + keyframe editing correctness
  6. fix(studio): remove keyframe dragging from the timeline #1763 — remove keyframe dragging
  7. fix(studio): strip a group's GSAP when ungrouping #1764 — ungroup preserves member positions
  8. chore(studio): add anonymous usage events #1765 — studio chores (telemetry, copy, log cleanup)

Same changes, rebased onto current main. Review bottom-up.

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

2 participants