From df29fa7a5e43203a989a2ed00c00b5f0e45cee4c Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Miguel=20=C3=81ngel?= Date: Mon, 13 Jul 2026 02:55:36 -0400 Subject: [PATCH] feat(studio): revamps Studio + improves code quality (#2291) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit * feat(studio): glue API coexistence layer for the NLE swap What: extends 21 glue files so the OLD timeline/canvas engine and the NEW NLE components type-check side by side: playerStore (multi-select setters, zoom pin, snap toggle, non-reactive scale scratch), drag-state types gain optional NLE fields, timelineLayout/timelineAssetDrop/timelineEditingHelpers/ timelineEditing/timelineElementHelpers/studioHelpers/assetHelpers gain the NLE exports, DomEditOverlay + gestures + AssetContextMenu + Timeline props gain optional callbacks/params, contexts gain *Optional hooks, and TimelineEditCallbacks.onMoveElements becomes a bivariant method accepting both engines' change shapes. patchDocumentRootDuration's test rides along. Why: this is the keystone that dissolves the old "welded glue" problem — every symbol the NLE components need is ADDED next to what the old engine still uses, so the engine components and the swaps can land as separate reviewable PRs. How: 15 authored intermediate files (main content + additive symbols; no behavior changes — new fields optional, new callbacks unused until wired) plus 6 files whose final content is already purely additive. New exports without consumers yet carry TEMP(studio-dnd) ignoreExports entries, removed by the app-shell swap. Test plan: tsc --noEmit in studio + studio-server (verifies BOTH engines compile); bunx vitest run (full suite green incl. the 6 new patchDocumentRootDuration tests); fallow audit clean. * feat(studio): timeline interaction hooks and lanes component (unwired) What: the timeline-side wiring layer, unwired: TimelineLanes (the lane renderer driving drag/resize/marquee), timelineMarquee (+tests), useTimelineStackingSync, useTimelineGeometry, useTimelineEditPinning, useTimelineEditingDrops. Why: everything between the pure drag math and itself; the timeline-glue swap PR then only rewires Timeline/TimelineCanvas onto these. How: new files, tsc-clean against the coexistence layer. Unwired components carry TEMP(studio-dnd) entry registrations, removed at the app-shell swap. Test plan: bunx vitest run timelineMarquee.test.ts; tsc --noEmit; fallow audit clean. * feat(studio): NLE shell assembly (unwired) What: EditorShell (the full editor layout replacing NLELayout + StudioPreviewArea), TimelinePane (timeline host with sub-comp rebasing) and useTimelineEditCallbacks (the callback bag bridging store edits to the timeline), all unwired. Why: the shell that App swaps to in the final step; reviewing it standalone keeps that swap PR small. How: new files against the coexistence layer; TEMP(studio-dnd) entries until App mounts EditorShell in the app-shell swap. Test plan: tsc --noEmit; bunx vitest run (suite unchanged); fallow audit clean. * feat(studio): timeline glue swap — Timeline/TimelineCanvas onto the NLE engine What: flips the timeline glue to its final form (23 files): Timeline and TimelineCanvas rebuilt on TimelineLanes/TimelineOverlays, useTimelineClipDrag drives preview/commit through the new drag engine, range selection goes multi-select, playback loop moves to useTimelinePlayerLoop. Deletes the 9 old-engine files this orphans (group drag, marquee selection, snap targets, layer gutter, selection overlays + their suites) — each is compile- or gate-forced by this swap, verified by probe. Why: second swap step; timeline-only, canvas and App untouched. How: modified files to final content + forced deletions. playerStore/timelineEditing/timelineCallbacks stay at their coexistence form until the app swap (the old App still runs on them). Test plan: tsc --noEmit; bunx vitest run (full suite); fallow audit clean. * feat(studio): clip thumbnail modules What: ImageThumbnail (+tests) and thumbnailUtils (+tests) — frame decode with SVG/AVIF format fallbacks and rounded-corner clipping — plus VideoThumbnail updates. Why: the decode layer for timeline clip thumbnails, ahead of the visual refresh that renders them. How: new modules + one modified file; purely presentational. Test plan: bunx vitest run on both test files; tsc --noEmit; fallow audit clean. * feat(studio): assets/blocks panel behaviors + preview helpers What: blocks tab install flow, right-panel and global drag-overlay polish, music beat analysis and clip-content rendering hooks, and the preview-helper utilities backing asset preview. Why: completes the studio NLE stack on top of the visual refresh. How: modified files only (kept as one PR: splitting further would produce sub-150-LOC fragments of interdependent panel glue). Test plan: bunx vitest run studioPreviewHelpers/studioUrlState suites; tsc --noEmit; fallow audit clean. * fix(studio): restore timeline playback loop * fix(studio): restore missing GSAP helpers module * refactor(studio): split timeline GSAP helpers * style(studio): keep timeline helper under size limit * fix(studio): restore timeline overlays module * fix(studio): remove stale GSAP import * fix(studio): restore canonical timeline dependencies * style(studio): format restored timeline helpers * style(studio): satisfy helper line limit * fix(studio): repair rebuilt timeline integration * feat(studio): complete rebuilt NLE cutover * fix(studio): guard project and timeline race boundaries * fix(studio): preserve graded resize and crop geometry * fix(studio): log resize/rotate commit failures, move anchor accumulator to resize-local * fix(studio): treat duration-0 tweens as static holds and settle resize position before persist Instant holds (to()/fromTo() with duration 0) were classified as animated tweens by every commit route, so resizing or rotating them converted the hold into a corrupt duration-0 keyframes tween (new value at 0%, old at 100%) that GSAP drops; panel edits appended a losing set. A shared isInstantHold() now routes them through the static replace-in-place path, and percentage math guards zero-duration windows. Separately, anchored-corner resizes painted 3-5 frames at the new size but old position while the offset persist round-tripped the server. The commit path now applies the corrected GSAP position synchronously before awaiting the offset persist, mirroring the scale route's settle. * feat(studio): gesture-transaction seam with commit observability Introduce runGestureTransaction — one owner for a gesture commit's settle -> persist -> record lifecycle. It settles the live DOM synchronously before any async persist, folds every mutation into one undo entry via a per-transaction coalesceKey, restores pre-gesture state exactly once on failure, and asserts (dev console) + reports (PostHog: commit_transaction / commit_invariant_violation / commit_transaction_failed) that a persist never changes pixels. The box-size resize path is migrated onto it; the ad hoc per-route coalesceKey/reload handling is removed. Extract the resize draft-rect math into resizeDraft.ts to keep the gesture-handler file under the size cap. Also: keep url_hash telemetry to the route slug only (drop the query string, which carried the user's selected element id/selector), and gate the [hf-resize] diagnostics behind localStorage hf-resize-debug so they ship as opt-in tracing rather than console noise. * fix(studio): transaction owns the undo label The coalesced history entry took the last sub-mutation's label, so a resize surfaced as "Move layer" (the offset persist) in undo/redo. The seam now stamps tx.label on every wrapped mutation, so the folded entry reads as the gesture. * fix(studio): atomic static size/position commits (no data loss) Static resize/position holds updated an existing set via delete+add — two undo entries, and a delete that succeeded before a failed add lost the hold on disk. Use one in-place update-properties mutation when a set exists (one undo entry, no partial-failure window). The keyframed-hold heal that can't be expressed as a property update now adds before it deletes, so any single failure leaves a recoverable duplicate, never a lost hold. Transaction-owned commits are tracked via a WeakSet so the heal path never double-wraps an already-wrapped gesture. * fix(core): restore timed-clip visibility after a forced timeline rebind __hfForceTimelineRebind force-rendered the re-registered timeline but never re-ran the per-[data-start] visibility pass, so after undo or soft reload every clip rendered regardless of its time window until a full page reload. Extract the visibility loop into syncTimedElementVisibility and call it from both syncMediaForCurrentState (unchanged) and the rebind. * fix(studio): atomic z-order/keyframe/split commits, one undo entry each Three edit-commit paths hardened onto the one-transaction invariant: - Z-order reorder (useElementLifecycleOps): N per-element writes now fold into one undo entry (coalesceMs Infinity) and, on a failed persist, restore already-written files to disk so no partial reorder survives. - Enable-keyframes (useEnableKeyframes/useGsapKeyframeOps): the intermediate convert phase no longer full-reloads the preview (skipReload), killing the black-flash remount; convert + edit share one coalesce key = one undo entry. - Razor split-all (useRazorSplit): snapshot before the batch and restore on any failure, so a mid-batch error never leaves un-revertable partial splits. Shared file-history helpers (RecordEditInput, DomEditCommitBaseParams, readProjectFileContent, restoreFilesToOriginal) dedupe the rollback/commit logic across these paths. Commit options thread as one partial object rather than field-by-field. Test setup extracted into colocated helpers. * fix(studio): fold multi-step edits into one undo entry; guard text revert - Gesture recording (useGestureCommit): the per-property-group commits now share one coalesce key and only the last reloads, so a recording is one undo entry and one preview reload instead of up to four. - Delete selected keyframes (deleteSelectedKeyframes, split out of timelineEditingHelpers): N removals fold into one coalesced undo entry with a single reload. - Text-field commit (useDomEditTextCommits): commitDomTextFields now uses the same version-guarded revert as handleDomTextCommit, so a stale failed commit can no longer stomp a newer successful one. * feat(studio): batch a gesture's mutations into one atomic server write A transaction that emits N mutations previously did N sequential POSTs, each rewriting the file and soft-reloading — the root of the multi-phase persist window. Add a gsap-mutations-batch endpoint that validates every mutation up front, applies them in one in-memory rewrite chain, and writes the file once (all-or-nothing: an invalid entry rejects the whole batch, no partial write). The seam buffers a transaction's commits and, when more than one targets the same file, dispatches a single batch — one write, one history entry, one reload. The batch capability rides on the existing commit-function reference; no option fields are threaded through callers. * fix(studio): soften off-canvas indicator outline to 30% opacity The dashed off-canvas selection outline at 60% was noisy with many protruding elements on screen; drop the resting opacity to 30% (hover still restores full opacity so it stays discoverable). * fix(studio): drop off-canvas indicator outline to 10% opacity Follow-up to the 30% softening — 10% resting opacity reads much calmer with many protruding elements; hover still restores full opacity. * fix(studio): gate [hf-commit] console traces to dev only The start/settled/persisted/restore lifecycle traces logged on every gesture commit in all environments — console noise for end users. Route them through a dev-only traceCommit helper (matching the pixel-violation error's existing DEV gate). The commit_* PostHog events stay always on; they are the production observability, the console lines are a dev aid. * fix(studio): count actual reloads, not softReload requests, in commit telemetry A resize's size and offset persists both request softReload; the seam counted each request, so a batched gesture reported reload_count 2 even though the batch is one write and one reload. Compute the count from what dispatchBufferedCommits actually did — one for a batch, the request count for the sequential fallback. * fix(studio): rotate hover + off-canvas overlays with the element; flicker-free crop - Hover overlay applied the element's rotation only to the selection chrome, not the hover box; it now rotates about center like the selection, via a shared orientedGroupAwareOverlayRect router (one owner for rotation-aware overlay geometry across hover/selection/off-canvas). - Off-canvas indicator was axis-aligned; it now rotates with the element and inverse-rotates the canvas-exclusion clip into the element's local frame, so the protruding-sliver clip stays correct for rotated elements. - Crop commit re-lifted the element only in the commit's .then(), so one frame painted the cropped state (the flicker). Re-lift synchronously right after onStyleCommit (which applies the clip before its first await), so the cropped state never paints; the persisted file value is unchanged. * fix(studio): address code-review findings across the commit-hardening campaign Correctness (would ship green, bite under latency): - Enable-keyframes phase 2 now carries coalesceMs: Infinity, so the convert folds into one undo entry instead of splitting past the 300ms default. - The SDK keyframe persist path forwards coalesceMs (CutoverOptions gains the field); multi-keyframe delete and convert coalesce correctly when SDK-routed. - Razor split-all's rollback is guarded so a failing restore can't swallow the error toast that tells the user the split failed. Simplification (single source of truth / no dead flexibility): - Decompose resolveResizeDraftRect (drops a fallow-ignore suppression). - Delegate the third readProjectFileContent copy to the shared helper. - Inline setPatchFromUpdateProperties (its only caller passes one mutation). - One toSdkPersistOptions translates gesture overrides to SDK options. - Bundle the reorder-rollback deps into one object (was 7-9 positional args). - Dedupe the 'last group reloads' ternary; type gesture options as CommitMutationOptions; drop a Map+array wrapper around a single write. * feat(studio): atomic z-order reorder via batch patch-element endpoint Z-order reorder issued N per-element inline-style patches (one server write each), so a mid-chain failure could leave a partial reorder on disk. Add a patch-elements-batch endpoint that validates every patch, folds them over the file in one in-memory rewrite, and writes once (all-or-nothing; unsafe input rejects with no write). The reorder now sends one batch per source file and records one undo entry. Because a failed atomic write persists nothing, the interim disk-write-back rollback (restoreReorderedFile / restoreFulfilledReorderFiles / ReorderRollbackDeps) is deleted — failure rolls back only live DOM/store state. Closes the last disk-atomicity gap. * fix(studio): razor-split undo no longer silently no-ops The split clone was written to disk without a data-hf-id, so the split endpoint recorded that unstamped HTML as the undo entry's afterHash. The next reloadPreview() ran the preview route's ensureHfIds write-back, which minted a fresh id and persisted DIFFERENT bytes — so at undo time the disk hash no longer matched afterHash and editHistory's content-mismatch guard silently refused the undo (no write, no network, no error). Stamp the split output via ensureHfIds in splitElementInHtml before it is written/returned, so the preview write-back is a no-op and the recorded afterHash always equals the final on-disk bytes. Fixes at the source rather than relaxing the mismatch guard. Corrects the stale comment that credited forceReloadSdkSession. * feat(studio): closed-hand grab cursor on the rotate handle The rotate handle used the default arrow cursor; show a grabbing (closed-hand) cursor on hover to signal it's grabbed and dragged to rotate. * fix(studio): dropping a dragged element over another no longer selects it A moved drag's release fired the box click, which re-selected whatever now sat under the pointer via the hover cache — so dropping an element over a higher-z one selected the drop target instead of keeping the dragged element selected. The drag-move branch now suppresses the next box click, mirroring the resize branch. * fix(studio): group drag is one undo entry, not one per element Dragging a multi-selected group committed each member's position write as its own undo entry, so reverting took N Cmd+Z presses. Force a shared coalesceKey (infinite window) across every member's commit so they fold into a single undo entry, like the other multi-step commit paths. * fix(studio): undo of a split no longer leaves a ghost clip in the timeline The file and the composition iframe revert correctly on undo, but the timeline panel kept a ghost node for the split clone. The element-merge that repopulates the timeline preserves elements the fresh scan dropped — intended for enriched sub-composition children a bare DOM re-scan misses, but it also preserved a genuinely-removed TOP-LEVEL element (the split clone after undo), leaving a phantom clip. Restrict the preserve to elements with a compositionSrc (the enriched sub-comp children); a top-level element missing from the fresh scan was truly removed. --------- Co-authored-by: ukimsanov --- .fallowrc.jsonc | 67 +- packages/core/src/runtime/init.test.ts | 30 + packages/core/src/runtime/init.ts | 115 ++- .../src/helpers/sourceMutation.ts | 6 +- .../sourceMutationSplitAndGroup.test.ts | 3 + .../studio-server/src/routes/files.test.ts | 240 ++++- packages/studio-server/src/routes/files.ts | 318 ++++-- packages/studio/src/App.tsx | 202 ++-- .../studio/src/components/EditorShell.tsx | 253 +++++ .../components/StudioGlobalDragOverlay.tsx | 4 +- .../src/components/StudioLeftSidebar.tsx | 16 +- .../src/components/StudioPreviewArea.tsx | 500 ---------- .../src/components/StudioRightPanel.tsx | 11 +- .../src/components/TimelineToolbar.test.tsx | 4 +- .../studio/src/components/TimelineToolbar.tsx | 300 +++--- .../editor/DomEditCropHandles.test.tsx | 77 +- .../components/editor/DomEditCropHandles.tsx | 93 +- .../components/editor/DomEditOverlay.test.ts | 524 +++++----- .../src/components/editor/DomEditOverlay.tsx | 284 +++--- .../editor/DomEditOverlayHover.test.tsx | 90 ++ .../components/editor/DomEditRotateHandle.tsx | 56 +- .../editor/DomEditSelectionChrome.test.tsx | 80 ++ .../editor/DomEditSelectionChrome.tsx | 257 +++++ .../src/components/editor/LayersPanel.test.ts | 68 +- .../src/components/editor/LayersPanel.tsx | 44 +- .../editor/OffCanvasIndicators.test.tsx | 34 + .../components/editor/OffCanvasIndicators.tsx | 59 +- .../components/editor/SnapGuideOverlay.tsx | 51 +- .../components/editor/SnapToolbar.test.tsx | 1 - .../anchoredResizeCommitFeedsOffset.test.ts | 214 ++++ .../editor/anchoredResizeReleaseShift.test.ts | 116 ++- .../components/editor/domEditNudge.test.ts | 80 ++ .../src/components/editor/domEditNudge.ts | 44 + .../editor/domEditOverlayGeometry.test.ts | 168 +++- .../editor/domEditOverlayGeometry.ts | 300 +++++- .../editor/domEditOverlayGestures.ts | 85 +- .../editor/domEditOverlayStartGesture.ts | 64 +- .../editor/domEditResizeLocal.test.ts | 131 +++ .../components/editor/domEditResizeLocal.ts | 125 +++ .../src/components/editor/domEditingDom.ts | 2 +- .../editor/manualEditsDomPatches.test.ts | 47 + .../src/components/editor/manualOffsetDrag.ts | 67 +- .../editor/offCanvasIndicatorGeometry.ts | 41 +- .../editor/offCanvasIndicatorRefresh.test.tsx | 26 +- .../src/components/editor/resizeDraft.ts | 108 ++ .../src/components/editor/snapEngine.test.ts | 92 +- .../src/components/editor/snapEngine.ts | 66 +- .../editor/useDomEditNudge.test.tsx | 228 +++++ .../src/components/editor/useDomEditNudge.ts | 254 +++++ .../editor/useDomEditOverlayGestures.ts | 231 +++-- .../editor/useDomEditOverlayRects.ts | 11 +- .../components/nle/AssetPreviewOverlay.tsx | 147 +++ .../src/components/nle/NLEContext.test.ts | 144 +++ .../studio/src/components/nle/NLEContext.tsx | 328 ++++++ .../src/components/nle/NLELayout.test.ts | 12 - .../studio/src/components/nle/NLELayout.tsx | 591 ----------- .../studio/src/components/nle/NLEPreview.tsx | 2 +- .../src/components/nle/PreviewOverlays.tsx | 240 +++++ .../studio/src/components/nle/PreviewPane.tsx | 163 +++ .../src/components/nle/TimelinePane.test.ts | 60 ++ .../src/components/nle/TimelinePane.tsx | 293 ++++++ .../components/nle/TimelineResizeDivider.tsx | 10 +- .../nle/useCompositionStack.test.tsx | 44 + .../src/components/nle/useCompositionStack.ts | 26 +- .../nle/useTimelineEditCallbacks.ts | 217 ++++ .../src/components/sidebar/AssetCard.tsx | 326 ++++++ .../components/sidebar/AssetContextMenu.tsx | 45 +- .../src/components/sidebar/AssetsTab.test.ts | 96 ++ .../src/components/sidebar/AssetsTab.tsx | 277 ++--- .../src/components/sidebar/AudioRow.tsx | 41 +- .../src/components/sidebar/BlocksTab.tsx | 9 +- .../src/components/sidebar/LeftSidebar.tsx | 5 +- .../src/components/sidebar/assetHelpers.ts | 29 + .../studio/src/contexts/DomEditContext.tsx | 9 + .../studio/src/contexts/StudioContext.tsx | 17 +- .../src/contexts/TimelineEditContext.tsx | 10 +- .../src/hooks/deleteSelectedKeyframes.ts | 35 + .../studio/src/hooks/domEditCommitTypes.ts | 13 +- .../src/hooks/domSelectionTestHarness.ts | 14 + .../src/hooks/gestureTransaction.test.ts | 311 ++++++ .../studio/src/hooks/gestureTransaction.ts | 199 ++++ .../studio/src/hooks/gsapDragCommit.test.ts | 188 +++- packages/studio/src/hooks/gsapDragCommit.ts | 138 ++- .../src/hooks/gsapDragStaticSetHelpers.ts | 32 +- .../src/hooks/gsapResizeIntercept.test.ts | 69 ++ .../studio/src/hooks/gsapResizeIntercept.ts | 48 +- .../src/hooks/gsapRuntimeBridge.test.ts | 62 +- .../studio/src/hooks/gsapRuntimeBridge.ts | 8 +- .../studio/src/hooks/gsapScriptCommitTypes.ts | 21 +- packages/studio/src/hooks/gsapShared.test.ts | 20 +- packages/studio/src/hooks/gsapShared.ts | 14 + .../hooks/patchDocumentRootDuration.test.ts | 71 ++ .../studio/src/hooks/timelineEditingGsap.ts | 14 + .../src/hooks/timelineEditingHelpers.test.ts | 35 +- .../src/hooks/timelineEditingHelpers.ts | 70 +- .../src/hooks/timelineMoveAdapter.test.ts | 77 ++ .../studio/src/hooks/timelineMoveAdapter.ts | 37 + .../hooks/useAnimatedPropertyCommit.test.tsx | 44 +- .../src/hooks/useAnimatedPropertyCommit.ts | 57 +- packages/studio/src/hooks/useAppHotkeys.ts | 41 +- packages/studio/src/hooks/useBlockHandlers.ts | 82 +- .../studio/src/hooks/useContextMenuDismiss.ts | 38 +- .../src/hooks/useDomEditCommits.test.tsx | 155 ++- .../studio/src/hooks/useDomEditCommits.ts | 103 +- .../hooks/useDomEditPositionPatchCommit.ts | 2 + .../studio/src/hooks/useDomEditSession.ts | 4 +- .../src/hooks/useDomEditTextCommits.test.tsx | 135 +++ .../studio/src/hooks/useDomEditTextCommits.ts | 11 +- packages/studio/src/hooks/useDomEditWiring.ts | 16 +- .../studio/src/hooks/useDomGeometryCommits.ts | 19 +- .../studio/src/hooks/useDomSelection.test.ts | 38 +- packages/studio/src/hooks/useDomSelection.ts | 132 ++- .../useDomSelectionSelectionGuards.test.ts | 276 +++++ .../src/hooks/useElementLifecycleOps.test.tsx | 258 +++++ .../src/hooks/useElementLifecycleOps.ts | 111 +-- .../src/hooks/useEnableKeyframes.test.ts | 143 ++- .../studio/src/hooks/useEnableKeyframes.ts | 102 +- .../src/hooks/useGestureCommit.test.tsx | 130 +++ packages/studio/src/hooks/useGestureCommit.ts | 40 +- packages/studio/src/hooks/useGroupCommits.ts | 35 +- .../src/hooks/useGsapAwareEditing.test.tsx | 239 +++++ .../studio/src/hooks/useGsapAwareEditing.ts | 135 ++- .../src/hooks/useGsapKeyframeOps.test.tsx | 59 ++ .../studio/src/hooks/useGsapKeyframeOps.ts | 48 +- .../src/hooks/useGsapScriptCommits.test.tsx | 41 +- .../studio/src/hooks/useGsapScriptCommits.ts | 184 +++- .../src/hooks/useGsapSelectionHandlers.ts | 65 +- .../studio/src/hooks/useMusicBeatAnalysis.ts | 110 +- .../hooks/usePersistentEditHistory.test.ts | 33 + .../src/hooks/usePersistentEditHistory.ts | 150 +-- .../studio/src/hooks/usePreviewPersistence.ts | 32 +- .../src/hooks/useRazorSplit.history.test.tsx | 166 ++- .../studio/src/hooks/useRazorSplit.test.ts | 269 +++++ .../src/hooks/useRazorSplit.testHelpers.ts | 64 ++ packages/studio/src/hooks/useRazorSplit.ts | 143 ++- .../studio/src/hooks/useRenderClipContent.ts | 30 +- .../studio/src/hooks/useStudioContextValue.ts | 20 +- .../studio/src/hooks/useStudioUrlState.ts | 6 +- .../src/hooks/useTimelineEditing.test.tsx | 49 +- .../src/hooks/useTimelineEditingTypes.ts | 6 + .../src/hooks/useTimelineGroupEditing.ts | 22 +- packages/studio/src/index.ts | 2 +- .../player/components/ImageThumbnail.test.tsx | 173 ++++ .../src/player/components/ImageThumbnail.tsx | 160 +++ .../player/components/PlayheadIndicator.tsx | 55 +- .../src/player/components/Timeline.test.ts | 121 ++- .../studio/src/player/components/Timeline.tsx | 388 +++---- .../src/player/components/TimelineCanvas.tsx | 732 ++++---------- .../src/player/components/TimelineClip.tsx | 5 +- .../components/TimelineClipDiamonds.tsx | 49 +- .../player/components/TimelineEmptyState.tsx | 4 +- .../src/player/components/TimelineLanes.tsx | 487 +++++++++ .../player/components/TimelineLayerGutter.tsx | 61 -- .../player/components/TimelineOverlays.tsx | 122 +++ .../components/TimelineSelectionOverlays.tsx | 55 - .../player/components/VideoThumbnail.test.tsx | 152 +++ .../src/player/components/VideoThumbnail.tsx | 35 +- .../player/components/thumbnailUtils.test.ts | 127 +++ .../src/player/components/thumbnailUtils.ts | 54 + .../player/components/timelineCallbacks.ts | 36 +- .../components/timelineClipChildren.tsx | 39 + .../components/timelineClipDragCommit.test.ts | 943 ++++++++++++++++++ .../components/timelineClipDragCommit.ts | 401 ++++++++ .../timelineClipDragPreview.test.ts | 144 +++ .../components/timelineClipDragPreview.ts | 361 ++++++- .../components/timelineClipDragTypes.ts | 65 ++ .../components/timelineCollision.test.ts | 44 +- .../src/player/components/timelineDragDrop.ts | 129 +-- .../components/timelineEditCapabilities.ts | 52 + .../player/components/timelineEditing.test.ts | 2 +- .../src/player/components/timelineEditing.ts | 135 +-- .../components/timelineGroupEditing.test.ts | 126 +++ .../player/components/timelineGroupEditing.ts | 133 +++ .../components/timelineGroupResizeCommit.ts | 54 + .../player/components/timelineLayout.test.ts | 118 ++- .../src/player/components/timelineLayout.ts | 181 ++-- .../player/components/timelineMarquee.test.ts | 197 ++++ .../src/player/components/timelineMarquee.ts | 112 +++ .../timelineMarqueeSelection.test.ts | 87 -- .../components/timelineOptimisticRevision.ts | 42 + .../components/timelineSnapTargets.test.ts | 144 --- .../player/components/timelineSnapTargets.ts | 164 --- .../src/player/components/timelineTheme.ts | 7 +- .../useResolvedTimelineEditCallbacks.ts | 28 +- .../useTimelineClipDrag.resize.test.tsx | 241 +++++ .../components/useTimelineClipDrag.test.tsx | 506 ---------- .../player/components/useTimelineClipDrag.ts | 766 +++++++------- .../components/useTimelineClipGroupDrag.ts | 417 -------- .../components/useTimelineEditPinning.ts | 121 +++ .../player/components/useTimelineGeometry.ts | 129 +++ .../useTimelineMarqueeSelection.test.tsx | 231 ----- .../components/useTimelineMarqueeSelection.ts | 276 ----- .../player/components/useTimelinePlayhead.ts | 20 +- .../components/useTimelineRangeSelection.ts | 399 +++++++- .../useTimelineStackingSync.test.tsx | 67 ++ .../components/useTimelineStackingSync.ts | 83 ++ .../hooks/useExpandedTimelineElements.ts | 3 - .../src/player/hooks/usePlaybackKeyboard.ts | 5 + .../src/player/hooks/useTimelinePlayer.ts | 101 +- .../src/player/hooks/useTimelinePlayerLoop.ts | 85 ++ packages/studio/src/player/index.ts | 5 +- packages/studio/src/player/lib/time.test.ts | 38 +- packages/studio/src/player/lib/time.ts | 8 +- .../studio/src/player/lib/timelineDOM.test.ts | 125 +-- packages/studio/src/player/lib/timelineDOM.ts | 93 +- .../src/player/lib/timelineElementHelpers.ts | 59 +- .../src/player/lib/timelineIframeHelpers.ts | 2 + .../studio/src/player/store/playerStore.ts | 65 +- packages/studio/src/styles/studio.css | 27 + .../studio/src/utils/assetPreviewStore.ts | 33 + packages/studio/src/utils/blockInstaller.ts | 12 +- packages/studio/src/utils/editHistory.test.ts | 35 + .../studio/src/utils/gsapSoftReload.test.ts | 120 ++- packages/studio/src/utils/gsapSoftReload.ts | 153 +++ packages/studio/src/utils/mediaTypes.ts | 5 +- packages/studio/src/utils/resizeDebug.ts | 55 + .../studio/src/utils/rootDuration.test.ts | 91 +- packages/studio/src/utils/rootDuration.ts | 89 +- packages/studio/src/utils/sdkCutover.ts | 4 + .../studio/src/utils/studioFileHistory.ts | 44 +- packages/studio/src/utils/studioHelpers.ts | 62 ++ .../src/utils/studioPreviewHelpers.test.ts | 73 +- .../studio/src/utils/studioPreviewHelpers.ts | 10 + packages/studio/src/utils/studioTelemetry.ts | 5 +- .../studio/src/utils/studioUrlState.test.ts | 1 - .../studio/src/utils/timelineAssetDrop.ts | 62 ++ .../src/utils/timelineDiscovery.test.ts | 90 -- .../studio/src/utils/timelineDiscovery.ts | 17 - 228 files changed, 18774 insertions(+), 7332 deletions(-) create mode 100644 packages/studio/src/components/EditorShell.tsx delete mode 100644 packages/studio/src/components/StudioPreviewArea.tsx create mode 100644 packages/studio/src/components/editor/DomEditOverlayHover.test.tsx create mode 100644 packages/studio/src/components/editor/DomEditSelectionChrome.test.tsx create mode 100644 packages/studio/src/components/editor/DomEditSelectionChrome.tsx create mode 100644 packages/studio/src/components/editor/OffCanvasIndicators.test.tsx create mode 100644 packages/studio/src/components/editor/anchoredResizeCommitFeedsOffset.test.ts create mode 100644 packages/studio/src/components/editor/domEditNudge.test.ts create mode 100644 packages/studio/src/components/editor/domEditNudge.ts create mode 100644 packages/studio/src/components/editor/domEditResizeLocal.test.ts create mode 100644 packages/studio/src/components/editor/domEditResizeLocal.ts create mode 100644 packages/studio/src/components/editor/resizeDraft.ts create mode 100644 packages/studio/src/components/editor/useDomEditNudge.test.tsx create mode 100644 packages/studio/src/components/editor/useDomEditNudge.ts create mode 100644 packages/studio/src/components/nle/AssetPreviewOverlay.tsx create mode 100644 packages/studio/src/components/nle/NLEContext.test.ts create mode 100644 packages/studio/src/components/nle/NLEContext.tsx delete mode 100644 packages/studio/src/components/nle/NLELayout.test.ts delete mode 100644 packages/studio/src/components/nle/NLELayout.tsx create mode 100644 packages/studio/src/components/nle/PreviewOverlays.tsx create mode 100644 packages/studio/src/components/nle/PreviewPane.tsx create mode 100644 packages/studio/src/components/nle/TimelinePane.test.ts create mode 100644 packages/studio/src/components/nle/TimelinePane.tsx create mode 100644 packages/studio/src/components/nle/useCompositionStack.test.tsx create mode 100644 packages/studio/src/components/nle/useTimelineEditCallbacks.ts create mode 100644 packages/studio/src/components/sidebar/AssetCard.tsx create mode 100644 packages/studio/src/hooks/deleteSelectedKeyframes.ts create mode 100644 packages/studio/src/hooks/gestureTransaction.test.ts create mode 100644 packages/studio/src/hooks/gestureTransaction.ts create mode 100644 packages/studio/src/hooks/patchDocumentRootDuration.test.ts create mode 100644 packages/studio/src/hooks/timelineEditingGsap.ts create mode 100644 packages/studio/src/hooks/timelineMoveAdapter.test.ts create mode 100644 packages/studio/src/hooks/timelineMoveAdapter.ts create mode 100644 packages/studio/src/hooks/useDomEditTextCommits.test.tsx create mode 100644 packages/studio/src/hooks/useDomSelectionSelectionGuards.test.ts create mode 100644 packages/studio/src/hooks/useElementLifecycleOps.test.tsx create mode 100644 packages/studio/src/hooks/useGestureCommit.test.tsx create mode 100644 packages/studio/src/hooks/useGsapAwareEditing.test.tsx create mode 100644 packages/studio/src/hooks/useRazorSplit.test.ts create mode 100644 packages/studio/src/hooks/useRazorSplit.testHelpers.ts create mode 100644 packages/studio/src/player/components/ImageThumbnail.test.tsx create mode 100644 packages/studio/src/player/components/ImageThumbnail.tsx create mode 100644 packages/studio/src/player/components/TimelineLanes.tsx delete mode 100644 packages/studio/src/player/components/TimelineLayerGutter.tsx create mode 100644 packages/studio/src/player/components/TimelineOverlays.tsx delete mode 100644 packages/studio/src/player/components/TimelineSelectionOverlays.tsx create mode 100644 packages/studio/src/player/components/VideoThumbnail.test.tsx create mode 100644 packages/studio/src/player/components/thumbnailUtils.test.ts create mode 100644 packages/studio/src/player/components/thumbnailUtils.ts create mode 100644 packages/studio/src/player/components/timelineClipChildren.tsx create mode 100644 packages/studio/src/player/components/timelineClipDragCommit.test.ts create mode 100644 packages/studio/src/player/components/timelineClipDragCommit.ts create mode 100644 packages/studio/src/player/components/timelineClipDragPreview.test.ts create mode 100644 packages/studio/src/player/components/timelineClipDragTypes.ts create mode 100644 packages/studio/src/player/components/timelineEditCapabilities.ts create mode 100644 packages/studio/src/player/components/timelineGroupEditing.test.ts create mode 100644 packages/studio/src/player/components/timelineGroupResizeCommit.ts create mode 100644 packages/studio/src/player/components/timelineMarquee.test.ts create mode 100644 packages/studio/src/player/components/timelineMarquee.ts delete mode 100644 packages/studio/src/player/components/timelineMarqueeSelection.test.ts create mode 100644 packages/studio/src/player/components/timelineOptimisticRevision.ts delete mode 100644 packages/studio/src/player/components/timelineSnapTargets.test.ts delete mode 100644 packages/studio/src/player/components/timelineSnapTargets.ts create mode 100644 packages/studio/src/player/components/useTimelineClipDrag.resize.test.tsx delete mode 100644 packages/studio/src/player/components/useTimelineClipDrag.test.tsx delete mode 100644 packages/studio/src/player/components/useTimelineClipGroupDrag.ts create mode 100644 packages/studio/src/player/components/useTimelineEditPinning.ts create mode 100644 packages/studio/src/player/components/useTimelineGeometry.ts delete mode 100644 packages/studio/src/player/components/useTimelineMarqueeSelection.test.tsx delete mode 100644 packages/studio/src/player/components/useTimelineMarqueeSelection.ts create mode 100644 packages/studio/src/player/components/useTimelineStackingSync.test.tsx create mode 100644 packages/studio/src/player/components/useTimelineStackingSync.ts create mode 100644 packages/studio/src/player/hooks/useTimelinePlayerLoop.ts create mode 100644 packages/studio/src/utils/assetPreviewStore.ts create mode 100644 packages/studio/src/utils/resizeDebug.ts delete mode 100644 packages/studio/src/utils/timelineDiscovery.test.ts diff --git a/.fallowrc.jsonc b/.fallowrc.jsonc index 7fbf76be9..4f5f8f6b2 100644 --- a/.fallowrc.jsonc +++ b/.fallowrc.jsonc @@ -44,7 +44,7 @@ // Keyframe UI components — wired dynamically via EaseCurveSection/MotionPanel. "packages/studio/src/components/editor/KeyframeDiamond.tsx", "packages/studio/src/components/editor/SpringEaseEditor.tsx", - // NLE notice — rendered conditionally via NLELayout when timeline is first shown. + // NLE notice — rendered conditionally via NLEContext/EditorShell when timeline is first shown. "packages/studio/src/components/nle/TimelineEditorNotice.tsx", // Zoom hook extracted for downstream razor-blade PRs (#1330, #1331). "packages/studio/src/player/components/useTimelineZoom.ts", @@ -53,9 +53,6 @@ "packages/studio/src/hooks/gsapTargetCache.ts", // Preview helper consumed dynamically from the studio iframe bridge. "packages/studio/src/hooks/gsapRuntimePreview.ts", - // TEMP(studio-dnd): shipped unwired ahead of the NLE integration; - // the app-shell swap PR (studio-dnd/pr22) wires the consumers and removes this block. - "packages/studio/src/components/editor/CanvasContextMenu.tsx", ], "ignorePatterns": [ "docs/**", @@ -93,10 +90,52 @@ "packages/cli/src/cloud/_gen/**", ], "ignoreExports": [ - // TEMP(studio-dnd): consumers land later in the stack; removed by studio-dnd/pr22. + // Part of useTimelineEditing's inferred public return type; consumers invoke + // handleTimelineGroupResize without importing the change type directly. { - "file": "packages/studio/src/components/editor/canvasContextMenuZOrder.ts", - "exports": ["readEffectiveZIndex"], + "file": "packages/studio/src/hooks/useTimelineGroupEditing.ts", + "exports": ["TimelineGroupResizeChange"], + }, + { + "file": "packages/studio/src/player/components/timelineGroupEditing.ts", + "exports": [ + "buildTimelineGroupResizeMembers", + "resolveTimelineGroupResizeChanges", + "applyTimelineGroupResizePreview", + ], + }, + { + "file": "packages/studio/src/player/components/timelineEditing.ts", + "exports": [ + "selectTimelineElementsInMarquee", + "TimelineGroupResizeEdge", + "TimelineGroupTimingMember", + ], + }, + { + "file": "packages/studio/src/player/lib/timelineElementHelpers.ts", + "exports": ["furthestClipEndFromDocument", "furthestClipEndFromSource"], + }, + { + "file": "packages/studio/src/components/sidebar/AssetContextMenu.tsx", + "exports": ["DeleteConfirm"], + }, + { + "file": "packages/studio/src/utils/timelineAssetDrop.ts", + "exports": [ + "setCompositionDurationToContent", + "extendCompositionDurationIfNeeded", + "fitTimelineAssetGeometry", + "resolveTimelineAssetCompositionSize", + ], + }, + { + "file": "packages/studio/src/components/sidebar/assetHelpers.ts", + "exports": ["truncateMiddle", "formatDuration"], + }, + { + "file": "packages/studio/src/utils/studioHelpers.ts", + "exports": ["resolveDroppedAssetDimensions"], }, // drawElementService is the bottom of the fast-capture Graphite stack // (#1917): its consumers (frameCapture in #1919) land two PRs upstack, so @@ -320,6 +359,11 @@ // and attribute tag-patchers; only the PatchOperation type gained two // optional fields here, but the line shift makes fallow re-flag them. "packages/studio/src/utils/sourcePatcher.ts", + // useGsapSelectionHandlers.ts: pre-existing parallel structure with + // useDomEditWiring.ts (thin useCallback wrappers guarding on selection); + // only gained two optional pass-through parameters here, but the line + // shift makes fallow re-flag the pre-existing clone. + "packages/studio/src/hooks/useGsapSelectionHandlers.ts", // gsapParser.ts: recast/babel GSAP writer — intentional duplication between // recast and acorn parallel implementations (pre-existing, moved from core). "packages/parsers/src/gsapParser.ts", @@ -441,13 +485,6 @@ // complexity pre-dates the computed-timeline work. Exempted at file level // rather than refactored as scope creep. "ignore": [ - // TEMP(studio-dnd): coexistence-window complexity flare (inherited/CRAP-no-coverage); - // removed by studio-dnd/pr22 when the final config lands. - "packages/studio/src/player/hooks/useTimelineSyncCallbacks.ts", - "packages/studio/src/components/editor/CanvasContextMenu.tsx", - "packages/studio/src/components/editor/canvasContextMenuZOrder.test.ts", - "packages/studio/src/player/components/timelineCollision.test.ts", - "packages/studio/src/player/components/timelineStackingSync.test.ts", // sourcePatcher.ts: resolveSourceFile / splitInlineStyleDeclarations / // patch*InTag pre-date this PR; only the PatchOperation type gained two // optional fields, but the line-shift fingerprint re-flags the inherited @@ -587,14 +624,12 @@ "packages/studio/src/components/editor/Transform3DCube.tsx", "packages/studio/src/components/LintModal.tsx", "packages/studio/src/components/MediaPreview.tsx", - "packages/studio/src/components/nle/NLELayout.tsx", "packages/studio/src/components/nle/NLEPreview.tsx", "packages/studio/src/components/sidebar/AudioRow.tsx", "packages/studio/src/components/sidebar/BlocksTab.tsx", "packages/studio/src/components/sidebar/CompositionsTab.tsx", "packages/studio/src/components/sidebar/LeftSidebar.tsx", "packages/studio/src/components/storyboard/StoryboardLoaded.tsx", - "packages/studio/src/components/StudioPreviewArea.tsx", "packages/studio/src/components/StudioRightPanel.tsx", "packages/studio/src/components/StudioToast.tsx", "packages/studio/src/components/ui/Tooltip.tsx", diff --git a/packages/core/src/runtime/init.test.ts b/packages/core/src/runtime/init.test.ts index 378a6a9bb..9c7700b5f 100644 --- a/packages/core/src/runtime/init.test.ts +++ b/packages/core/src/runtime/init.test.ts @@ -1564,6 +1564,36 @@ describe("initSandboxRuntimeModular", () => { expect(seekTimes[seekTimes.length - 1]).toBe(0); }); + it("restores timed element visibility after a forced timeline rebind", () => { + document.body.innerHTML = ` +
+
+
+
+
+ `; + const clipExpired = document.querySelector("#clip-expired"); + const clipFuture = document.querySelector("#clip-future"); + const clipControl = document.querySelector("#clip-control"); + window.__timelines = { root: createMockTimeline(30) }; + + initSandboxRuntimeModular(); + window.__player?.seek(16.2); + + expect(clipExpired?.style.visibility).toBe("hidden"); + expect(clipFuture?.style.visibility).toBe("hidden"); + expect(clipControl?.style.visibility).toBe("visible"); + + if (clipExpired) clipExpired.style.visibility = "visible"; + if (clipFuture) clipFuture.style.visibility = "visible"; + + window.__hfForceTimelineRebind?.(); + + expect(clipExpired?.style.visibility).toBe("hidden"); + expect(clipFuture?.style.visibility).toBe("hidden"); + expect(clipControl?.style.visibility).toBe("visible"); + }); + it("onSetMuted preserves authored muted attribute on video elements", () => { const root = document.createElement("div"); root.setAttribute("data-composition-id", "root"); diff --git a/packages/core/src/runtime/init.ts b/packages/core/src/runtime/init.ts index acaf614e9..67ace5d15 100644 --- a/packages/core/src/runtime/init.ts +++ b/packages/core/src/runtime/init.ts @@ -1334,6 +1334,7 @@ export function initSandboxRuntimeModular(): void { (window as Window & { __hfForceTimelineRebind?: () => void }).__hfForceTimelineRebind = () => { childrenBound = false; bindRootTimelineIfAvailable(); + syncTimedElementVisibility(state.currentTime); }; const emitRootStageLayoutDiagnostics = () => { @@ -1691,6 +1692,64 @@ export function initSandboxRuntimeModular(): void { const dataHiddenDisplayRestores = new WeakMap(); const dataHiddenDisplayNodes = new WeakSet(); + const syncTimedElementVisibility = (currentTime: number) => { + const visibilityNodes = Array.from(document.querySelectorAll("[data-start]")); + const rootComp = resolveRootCompositionElement(); + for (const rawNode of visibilityNodes) { + if (!(rawNode instanceof HTMLElement)) continue; + + if (rawNode.hasAttribute("data-hidden")) { + if (!dataHiddenDisplayNodes.has(rawNode)) { + dataHiddenDisplayRestores.set(rawNode, rawNode.style.getPropertyValue("display")); + dataHiddenDisplayNodes.add(rawNode); + } + rawNode.style.display = "none"; + if (rawNode instanceof HTMLVideoElement || rawNode instanceof HTMLImageElement) { + colorGradingRuntime?.setSourceVisibility(rawNode, false); + } + continue; + } + + if (dataHiddenDisplayNodes.has(rawNode)) { + const previousDisplay = dataHiddenDisplayRestores.get(rawNode); + if (previousDisplay) { + rawNode.style.display = previousDisplay; + } else { + rawNode.style.removeProperty("display"); + } + dataHiddenDisplayRestores.delete(rawNode); + dataHiddenDisplayNodes.delete(rawNode); + } + + let isVisibleNow = isTimedElementVisibleAt(rawNode, currentTime); + // Descendants must not override a hidden ancestor clip. CSS visibility can + // otherwise leak child pixels through inactive scenes because a descendant + // with visibility:visible escapes an ancestor's visibility:hidden. + if (isVisibleNow) { + let ancestor = rawNode.parentElement; + while (ancestor) { + if (ancestor === rootComp) break; + if (ancestor instanceof HTMLElement && ancestor.hasAttribute("data-start")) { + if (!isTimedElementVisibleAt(ancestor, currentTime)) { + isVisibleNow = false; + break; + } + } + ancestor = ancestor.parentElement; + } + } + rawNode.style.visibility = isVisibleNow ? "visible" : "hidden"; + if (rawNode instanceof HTMLVideoElement || rawNode instanceof HTMLImageElement) { + colorGradingRuntime?.setSourceVisibility(rawNode, isVisibleNow); + } + if (isVisibleNow) { + if (isTimedClipInFlow(rawNode)) rawNode.style.removeProperty("display"); + } else if (isTimedClipInFlow(rawNode) && isTimedClipLeaf(rawNode)) { + rawNode.style.display = "none"; + } + } + }; + const syncMediaForCurrentState = () => { const resolveMediaCompositionContext = (element: HTMLVideoElement | HTMLAudioElement) => { const compositionRoot = element.closest("[data-composition-id]"); @@ -1773,61 +1832,7 @@ export function initSandboxRuntimeModular(): void { }, }); } - const visibilityNodes = Array.from(document.querySelectorAll("[data-start]")); - const rootComp = resolveRootCompositionElement(); - for (const rawNode of visibilityNodes) { - if (!(rawNode instanceof HTMLElement)) continue; - - if (rawNode.hasAttribute("data-hidden")) { - if (!dataHiddenDisplayNodes.has(rawNode)) { - dataHiddenDisplayRestores.set(rawNode, rawNode.style.getPropertyValue("display")); - dataHiddenDisplayNodes.add(rawNode); - } - rawNode.style.display = "none"; - if (rawNode instanceof HTMLVideoElement || rawNode instanceof HTMLImageElement) { - colorGradingRuntime?.setSourceVisibility(rawNode, false); - } - continue; - } - - if (dataHiddenDisplayNodes.has(rawNode)) { - const previousDisplay = dataHiddenDisplayRestores.get(rawNode); - if (previousDisplay) { - rawNode.style.display = previousDisplay; - } else { - rawNode.style.removeProperty("display"); - } - dataHiddenDisplayRestores.delete(rawNode); - dataHiddenDisplayNodes.delete(rawNode); - } - - let isVisibleNow = isTimedElementVisibleAt(rawNode, state.currentTime); - // Descendants must not override a hidden ancestor clip. CSS visibility can - // otherwise leak child pixels through inactive scenes because a descendant - // with visibility:visible escapes an ancestor's visibility:hidden. - if (isVisibleNow) { - let ancestor = rawNode.parentElement; - while (ancestor) { - if (ancestor === rootComp) break; - if (ancestor instanceof HTMLElement && ancestor.hasAttribute("data-start")) { - if (!isTimedElementVisibleAt(ancestor, state.currentTime)) { - isVisibleNow = false; - break; - } - } - ancestor = ancestor.parentElement; - } - } - rawNode.style.visibility = isVisibleNow ? "visible" : "hidden"; - if (rawNode instanceof HTMLVideoElement || rawNode instanceof HTMLImageElement) { - colorGradingRuntime?.setSourceVisibility(rawNode, isVisibleNow); - } - if (isVisibleNow) { - if (isTimedClipInFlow(rawNode)) rawNode.style.removeProperty("display"); - } else if (isTimedClipInFlow(rawNode) && isTimedClipLeaf(rawNode)) { - rawNode.style.display = "none"; - } - } + syncTimedElementVisibility(state.currentTime); }; const postState = (force: boolean) => { diff --git a/packages/studio-server/src/helpers/sourceMutation.ts b/packages/studio-server/src/helpers/sourceMutation.ts index b26558b9c..ef76ef618 100644 --- a/packages/studio-server/src/helpers/sourceMutation.ts +++ b/packages/studio-server/src/helpers/sourceMutation.ts @@ -2,6 +2,7 @@ import { parseHTML } from "linkedom"; import postcss from "postcss"; import selectorParser from "postcss-selector-parser"; import { isAllowedHtmlAttribute, isSafeAttributeValue } from "@hyperframes/core/html-attr-safety"; +import { ensureHfIds } from "@hyperframes/parsers/hf-ids"; import { parseStyleDecls, patchStyleAttrString } from "./sourceStyleMutation.js"; export interface SourceMutationTarget { @@ -349,8 +350,11 @@ export function splitElementInHtml( el.parentElement!.appendChild(clone); } + const html = wrappedFragment ? document.body.innerHTML || "" : document.toString(); return { - html: wrappedFragment ? document.body.innerHTML || "" : document.toString(), + // The split owns its new nodes' stable ids. Leaving the clone unstamped makes + // the next preview request persist different bytes after history is recorded. + html: ensureHfIds(html), matched: true, newId, }; diff --git a/packages/studio-server/src/helpers/sourceMutationSplitAndGroup.test.ts b/packages/studio-server/src/helpers/sourceMutationSplitAndGroup.test.ts index 6bd4d92dc..47bc557fc 100644 --- a/packages/studio-server/src/helpers/sourceMutationSplitAndGroup.test.ts +++ b/packages/studio-server/src/helpers/sourceMutationSplitAndGroup.test.ts @@ -12,8 +12,11 @@ describe("splitElementInHtml — hfId clone isolation", () => { const { html, matched } = splitElementInHtml(source, { id: "clip1" }, 5, "clip2"); expect(matched).toBe(true); + const { document } = parseHTML(html); const occurrences = (html.match(/data-hf-id="hf-abc123"/g) ?? []).length; expect(occurrences).toBe(1); + expect(document.getElementById("clip2")?.getAttribute("data-hf-id")).toMatch(/^hf-/); + expect(document.getElementById("clip2")?.getAttribute("data-hf-id")).not.toBe("hf-abc123"); }); }); diff --git a/packages/studio-server/src/routes/files.test.ts b/packages/studio-server/src/routes/files.test.ts index 90699abd5..6df7cf367 100644 --- a/packages/studio-server/src/routes/files.test.ts +++ b/packages/studio-server/src/routes/files.test.ts @@ -1,6 +1,14 @@ import { afterEach, describe, expect, it } from "vitest"; import { Hono } from "hono"; -import { mkdirSync, mkdtempSync, readFileSync, rmSync, writeFileSync } from "node:fs"; +import { + existsSync, + mkdirSync, + mkdtempSync, + readFileSync, + readdirSync, + rmSync, + writeFileSync, +} from "node:fs"; import { tmpdir } from "node:os"; import { join } from "node:path"; import { registerFileRoutes } from "./files"; @@ -38,6 +46,14 @@ function createAdapter(projectDir: string): StudioApiAdapter { }; } +function postElementPatchBatch(app: Hono, file: string, patches: unknown[]): Promise { + return app.request(`http://localhost/projects/demo/file-mutations/patch-elements-batch/${file}`, { + method: "POST", + headers: { "Content-Type": "application/json" }, + body: JSON.stringify({ patches }), + }); +} + describe("registerFileRoutes", () => { it("returns empty content for missing files when caller marks the read optional", async () => { const projectDir = createProjectDir(); @@ -116,13 +132,13 @@ describe("registerFileRoutes", () => { }), }, ); + expect(response.status).toBe(200); const payload = (await response.json()) as { changed?: boolean; path?: string; backupPath?: string; }; - expect(response.status).toBe(200); expect(payload.changed).toBe(true); expect(payload.path).toBe("index.html"); expect(payload.backupPath).toMatch(/^\.hyperframes\/backup\//); @@ -132,6 +148,96 @@ describe("registerFileRoutes", () => { expect(readFileSync(join(projectDir, "index.html"), "utf-8")).toContain("After"); }); + it("applies an ordered element patch batch with one file write", async () => { + const projectDir = createProjectDir(); + const original = + '
Back
Front
'; + writeFileSync(join(projectDir, "index.html"), original); + const app = new Hono(); + registerFileRoutes(app, createAdapter(projectDir)); + + const response = await postElementPatchBatch(app, "index.html", [ + { + target: { id: "back" }, + operations: [{ type: "inline-style", property: "z-index", value: "2" }], + }, + { + target: { id: "front" }, + operations: [{ type: "inline-style", property: "z-index", value: "1" }], + }, + ]); + expect(response.status).toBe(200); + const payload = (await response.json()) as { + changed?: boolean; + matched?: boolean[]; + content?: string; + backupPath?: string; + }; + + expect(response.status).toBe(200); + expect(payload.changed).toBe(true); + expect(payload.matched).toEqual([true, true]); + expect(payload.content).toBe(readFileSync(join(projectDir, "index.html"), "utf-8")); + expect(payload.content).toContain('id="back" style="z-index: 2"'); + expect(payload.content).toContain('id="front" style="z-index: 1"'); + expect(readFileSync(join(projectDir, payload.backupPath!), "utf-8")).toBe(original); + expect(readdirSync(join(projectDir, ".hyperframes", "backup"))).toHaveLength(1); + }); + + it("returns changed false without writing for a no-op element patch batch", async () => { + const projectDir = createProjectDir(); + const original = '
Title
'; + writeFileSync(join(projectDir, "index.html"), original); + const app = new Hono(); + registerFileRoutes(app, createAdapter(projectDir)); + + const response = await postElementPatchBatch(app, "index.html", [ + { + target: { id: "title" }, + operations: [{ type: "inline-style", property: "z-index", value: "4" }], + }, + ]); + expect(response.status).toBe(200); + const payload = (await response.json()) as { + changed?: boolean; + matched?: boolean[]; + content?: string; + backupPath?: string; + }; + + expect(payload.changed).toBe(false); + expect(payload.matched).toEqual([true]); + expect(payload.content).toBe(original); + expect(payload.backupPath).toBeUndefined(); + expect(existsSync(join(projectDir, ".hyperframes", "backup"))).toBe(false); + }); + + it("rejects an unsafe value anywhere in an element patch batch without writing", async () => { + const projectDir = createProjectDir(); + const original = '
First
Second
'; + writeFileSync(join(projectDir, "index.html"), original); + const app = new Hono(); + registerFileRoutes(app, createAdapter(projectDir)); + + const response = await postElementPatchBatch(app, "index.html", [ + { + target: { id: "first" }, + operations: [{ type: "inline-style", property: "z-index", value: "2" }], + }, + { + target: { id: "second", selectorIndex: Number.NaN }, + operations: [{ type: "inline-style", property: "z-index", value: "1" }], + }, + ]); + expect(response.status).toBe(400); + const payload = (await response.json()) as { error?: string; fields?: string[] }; + + expect(payload.error).toContain("unsafe values"); + expect(payload.fields).toContain("body.target.selectorIndex"); + expect(readFileSync(join(projectDir, "index.html"), "utf-8")).toBe(original); + expect(existsSync(join(projectDir, ".hyperframes", "backup"))).toBe(false); + }); + // A realistic sub-composition: markup + GSAP wrapped in a