Skip to content

feat(studio): a host app can mount the Layers panel outside Studio - #5049

Merged
miguel-heygen merged 3 commits into
mainfrom
dlayerspanel/export-layers-panel
Oct 5, 2026
Merged

miguel-heygen merged 3 commits into
mainfrom
dlayerspanel/export-layers-panel

Conversation

@miguel-heygen

@miguel-heygen miguel-heygen commented Oct 5, 2026 •

Copy link
Copy Markdown
Collaborator

What a user can do now

An app that embeds Studio outside EditorShell can show Studio's own Layers panel: every element of the current composition, front to back, select on click, and drag to reorder, with each reorder going through the host's DOM edit session as one undo step.

What changed

  • LayersPanel is exported from @hyperframes/studio, along with a LayersPanelHost type.
  • LayersPanel takes an optional host prop: the preview iframe ref, active composition path, toast, timeline elements and playing state, plus optional refreshKey and compositionLoading. Without the prop it reads Studio's shell and playback contexts as before, so Studio's own right panel is unchanged.
  • The host still wraps the panel in the already-exported DomEditProvider; that is where selection, hover and handleDomZIndexReorderCommit come from. A host that also wraps it in TimelineEditProvider gets the reorder mirrored into its timeline rows, in the same undo step.
  • Fix found while doing this: without a TimelineEditProvider, a z-order reorder (Layers drag, or the already-exported canvas menu) moved the clip's timeline row in the store only, never saved, so the timeline disagreed with the file until a reload. useMirrorLaneMoveCommit, which both mirrors go through, now skips the lane move when there is no move handler. Studio always mounts the provider, so Studio itself is unchanged.

Before this change, a host could not import the panel at all, and with the panel's file imported directly it threw on mount ("must be used within StudioShellProvider"), because those providers need Studio's whole shell state (render queue, edit history and more).

Tests

  • src/layersPanelExport.test.tsx imports the panel by package name and mounts it inside DomEditProvider with only host. Test 1 checks the rows (front first), that a click selects through the session, that a drag reorder commits once through handleDomZIndexReorderCommit, and that with no timeline provider the clip's row is left alone. Test 2 adds TimelineEditProvider and checks the reorder reaches onMoveElements once. Both passed 3 runs in a row; the test adds no waits of its own.
  • Mutations, each reverted: removing the export fails it ("Element type is invalid ... got: undefined"); ignoring host fails it with the new error; removing the new guard fails test 1 ("expected +0 to be 1").
  • Neighbours, each run alone: LayersPanel.test.ts 17, useCanvasZOrderTimelineMirror.test.tsx 5, CanvasContextMenu.test.tsx 8, ConnectedDomEditOverlay.test.tsx 5, domEditExports.test.tsx 4, zLaneGesture.test.ts 7, all passed.
  • tsc --noEmit in packages/studio, oxfmt and oxlint on the changed files: clean.

Before

An app embedding Studio (on a fixture project, with Studio 0.8.126): the right panel has no Layers tab, because the panel can't be mounted there.

Before: a host's right panel with no Layers tab

After

The same app built against this branch's packed Studio, with a Layers tab that renders this LayersPanel through host, inside DomEditProvider and TimelineEditProvider. In a recorded walk, the rows list front to back. Dragging the red box's row above the blue one's writes red over blue to the file (z 4 over 3), mirrors it into the timeline lanes (red's row moves from lane 4 to 3), and redraws the picture. One Cmd+Z puts the z order and the lanes back (z 1 under 2).

After: the host's Layers tab, paper
After: red dragged to the front, night
After: Cmd+Z restores the order, night
After: narrow window, paper

LayersPanel is exported from @hyperframes/studio with an optional host prop carrying the preview iframe, active composition, toast, timeline elements and playing state. Studio's own mount still reads its contexts.
…not save

Without a TimelineEditProvider the z-order lane mirror changed the clip's row in the store only, unsaved. It now skips the mirror there. LayersPanelHost is built from Studio's context types.
@github-actions

github-actions Bot commented Oct 5, 2026 •

Copy link
Copy Markdown

Edit accuracy: accurate 2040 (base branch 2040), smooth 1524 of those

The gate passes.
Smoothness is reported in the artifact, not gated. A case fails only if it fails 2 of 3 runs.

Quarantined, measured but not gated (0)

@terencecho terencecho left a comment

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

No blocker at 3fe6080bacaf774fd9e028f3d11c8a2c5b417d8d. Approving; the CI jobs were all green when I stamped.

What I checked (local, NODE_ENV=test)

  • New layersPanelExport.test.tsx 2/2, LayersPanel.test.ts 17, useCanvasZOrderTimelineMirror.test.tsx 5, CanvasContextMenu.test.tsx 8, ConnectedDomEditOverlay.test.tsx 5, domEditExports.test.tsx 4, all run one file at a time. tsc --noEmit in packages/studio is clean.
  • Mutation: removing the new !onMoveElements guard in useMirrorLaneMoveCommit fails the no-provider case ("expected +0 to be 1", the clip's row moved in the store only); everything else stays green.
  • Studio is unchanged. StudioRightPanels (which mounts <LayersPanel />) is passed as panels into EditorShell, rendered inside TimelineEditProvider (EditorShell.tsx:179-230, :353), whose value always carries onMoveElements (useTimelineEditCallbacks.ts:223). So the new guard never fires there. Without host, useLayersPanelHost returns {...shell, ...playback}, the same values as before.
  • With host, host wins; outside Studio's providers and without host, the panel throws a clear error instead of the old provider error.
  • Side effect of the guard worth knowing: TimelineProvider in read-only mode passes NO_EDITS, so a z reorder there no longer moves the row in the store either. That matches the file, so I read it as correct.

Non-blocking

  1. LayersPanelHost requires timelineElements/isPlaying as props, so a host has to keep them in sync with its own store. The type comment covers previewIframeRef stability and refreshKey, not that.
  2. The export test mounts the panel in happy-dom with mocked rects, so it does not cover a real drag in a browser; the attached Before/After recording does.

Reviewed on 3fe6080bacaf774fd9e028f3d11c8a2c5b417d8d. This is a review verdict, not authorization to merge.

— Review by tai (pr-review)

@miguel-heygen
miguel-heygen marked this pull request as ready for review October 5, 2026 09:17
@miguel-heygen
miguel-heygen added this pull request to the merge queue Oct 5, 2026

@jrusso1020 jrusso1020 left a comment

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Reviewed at 3fe6080b. Approving.

What I checked:

  • Reuse. useLayersPanelHost builds on the existing useStudioShellContextOptional and useStudioPlaybackContextOptional, which MotionPathOverlay, useDomEditZOrder and TimelineHistoryButtons already use the same way. LayersPanelHost is a Pick of the two context value types, so it can't drift from them.
    • Both hooks always run before the host check, so the hook order is stable.
    • Without host and outside the providers, the panel throws with a message that names the fix.
  • The root cause of the reorder fix. Before this PR, useMirrorLaneMoveCommit passed an undefined onMoveElements through commitZMirrorLaneMove. That reached persistMoveEdits (timelineClipDragCommit.ts:130), whose no-handler branch warns "applied to the store only, not saved" and still applies the optimistic store update. The new guard returns before that, so the action is only a z change.
  • Studio is unchanged. In Studio, the panels mount under EditorShell's TimelineEditProvider. Its value comes from useTimelineEditCallbacks, which always sets onMoveElements (useTimelineEditCallbacks.ts:223), so the guard never fires there.
  • Simpler? The change is already small: one optional prop, one resolver hook, a one-condition guard and two export lines. I don't see a smaller version.

Tests I ran:

  • The new export test, LayersPanel, the mirror suite and CanvasContextMenu: 32 of 32 pass.
  • Studio tsc is clean.
  • Removing || !onMoveElements fails the no-provider case.

Nit (not blocking): no test covers host taking precedence over Studio's contexts when both are present. A mutant that prefers the shell survives. No caller mixes the two today.

Verdict: APPROVE
Reasoning: The export reuses the existing optional-context pattern, the guard removes the store-only lane move at its source, and Studio's own provider always supplies the handler. The tests pin the new behaviour.

— Rames Jusso

Merged via the queue into main with commit 618fd76 Oct 5, 2026
168 checks passed
@miguel-heygen
miguel-heygen deleted the dlayerspanel/export-layers-panel branch October 5, 2026 09:27
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.

3 participants