feat(studio): a host app can mount the Layers panel outside Studio - #5049
Conversation
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.
Edit accuracy: accurate 2040 (base branch 2040), smooth 1524 of thoseThe gate passes. Quarantined, measured but not gated (0) |
terencecho
left a comment
There was a problem hiding this comment.
No blocker at 3fe6080bacaf774fd9e028f3d11c8a2c5b417d8d. Approving; the CI jobs were all green when I stamped.
What I checked (local, NODE_ENV=test)
- New
layersPanelExport.test.tsx2/2,LayersPanel.test.ts17,useCanvasZOrderTimelineMirror.test.tsx5,CanvasContextMenu.test.tsx8,ConnectedDomEditOverlay.test.tsx5,domEditExports.test.tsx4, all run one file at a time.tsc --noEmitinpackages/studiois clean. - Mutation: removing the new
!onMoveElementsguard inuseMirrorLaneMoveCommitfails 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 aspanelsintoEditorShell, rendered insideTimelineEditProvider(EditorShell.tsx:179-230,:353), whose value always carriesonMoveElements(useTimelineEditCallbacks.ts:223). So the new guard never fires there. Withouthost,useLayersPanelHostreturns{...shell, ...playback}, the same values as before. - With
host,hostwins; outside Studio's providers and withouthost, the panel throws a clear error instead of the old provider error. - Side effect of the guard worth knowing:
TimelineProviderin read-only mode passesNO_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
LayersPanelHostrequirestimelineElements/isPlayingas props, so a host has to keep them in sync with its own store. The type comment coverspreviewIframeRefstability andrefreshKey, not that.- 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)
jrusso1020
left a comment
There was a problem hiding this comment.
Reviewed at 3fe6080b. Approving.
What I checked:
- Reuse.
useLayersPanelHostbuilds on the existinguseStudioShellContextOptionalanduseStudioPlaybackContextOptional, whichMotionPathOverlay,useDomEditZOrderandTimelineHistoryButtonsalready use the same way.LayersPanelHostis aPickof the two context value types, so it can't drift from them.- Both hooks always run before the
hostcheck, so the hook order is stable. - Without
hostand outside the providers, the panel throws with a message that names the fix.
- Both hooks always run before the
- The root cause of the reorder fix. Before this PR,
useMirrorLaneMoveCommitpassed an undefinedonMoveElementsthroughcommitZMirrorLaneMove. That reachedpersistMoveEdits(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'sTimelineEditProvider. Its value comes fromuseTimelineEditCallbacks, which always setsonMoveElements(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 andCanvasContextMenu: 32 of 32 pass. - Studio
tscis clean. - Removing
|| !onMoveElementsfails 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
What a user can do now
An app that embeds Studio outside
EditorShellcan 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
LayersPanelis exported from@hyperframes/studio, along with aLayersPanelHosttype.LayersPaneltakes an optionalhostprop: the preview iframe ref, active composition path, toast, timeline elements and playing state, plus optionalrefreshKeyandcompositionLoading. Without the prop it reads Studio's shell and playback contexts as before, so Studio's own right panel is unchanged.DomEditProvider; that is where selection, hover andhandleDomZIndexReorderCommitcome from. A host that also wraps it inTimelineEditProvidergets the reorder mirrored into its timeline rows, in the same undo step.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.tsximports the panel by package name and mounts it insideDomEditProviderwith onlyhost. Test 1 checks the rows (front first), that a click selects through the session, that a drag reorder commits once throughhandleDomZIndexReorderCommit, and that with no timeline provider the clip's row is left alone. Test 2 addsTimelineEditProviderand checks the reorder reachesonMoveElementsonce. Both passed 3 runs in a row; the test adds no waits of its own.hostfails it with the new error; removing the new guard fails test 1 ("expected +0 to be 1").LayersPanel.test.ts17,useCanvasZOrderTimelineMirror.test.tsx5,CanvasContextMenu.test.tsx8,ConnectedDomEditOverlay.test.tsx5,domEditExports.test.tsx4,zLaneGesture.test.ts7, all passed.tsc --noEmitin 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.
After
The same app built against this branch's packed Studio, with a Layers tab that renders this
LayersPanelthroughhost, insideDomEditProviderandTimelineEditProvider. 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).