mirror of
https://github.com/heygen-com/hyperframes.git
synced 2026-09-04 07:19:52 +00:00
refactor(studio): contexts, PropertyPanel split, duration fix, perf (#748)
* feat(studio): add manual DOM editing inspector (#466) * fix: stabilize studio preview and runtime sync * fix: pass selector through timeline thumbnails * feat: add studio timeline editing * fix: disambiguate timeline edit targets * fix: stop timeline auto-scroll in fit mode * feat: use percentage-based timeline zoom * fix: sync timeline playhead on zoom changes * fix: reset timeline scroll when returning to fit * feat(studio): add manual DOM editing inspector * docs: update studio manual dom editing guide * feat(studio): add image asset picker for fills * feat(studio): add inline image uploads for fills * fix(studio): use real file input for image fill uploads * fix(studio): restore toast plumbing after rebase * fix(studio): explain in-app upload limitation * fix(studio): reuse asset-tab upload pattern in fills * feat(studio): refine manual design inspector * fix(studio): polish manual design inspector * fix(studio): keep color picker in viewport * fix(studio): clarify color picker selection * docs: update manual DOM editing guide * fix(studio): keep gradient color picker open * fix(studio): scope text color to text layers * fix(studio): add agent fallback for immovable layers * fix(studio): address manual editing review feedback * fix(studio): make local font selection reliable * fix(studio): improve dom picking and thumbnails * fix(studio): copy absolute paths in agent prompts * fix(studio): prevent timeline track cutoff * fix: copy Studio agent prompts in Safari * fix(studio): hold canvas movement from inspector * feat(studio): add persistent undo redo (#537) Studio manual editing and timeline editing mutate project files directly, but those edits had no reliable undo/redo path. Before releasing manual editing, users need a way to recover from visual property changes, source-editor saves, timeline moves/resizes/deletes, and timeline asset drops. The history also needs to survive a page refresh. A refresh should not erase the only way back from a bad manual edit. - Adds a persistent per-project edit-history model for file snapshots. - Stores undo/redo stacks in IndexedDB so history survives Studio refreshes. - Records source editor saves, manual DOM edits, and timeline mutations. - Adds toolbar undo/redo buttons with standard keyboard shortcuts: `Cmd/Ctrl+Z`, `Cmd/Ctrl+Shift+Z`, and `Ctrl+Y`. - Validates current file hashes before applying undo/redo so external file changes do not silently overwrite newer content. - Keeps history available in memory if IndexedDB persistence fails during a session. - Adds focused unit coverage for the pure history model, storage adapter, controller/hook behavior, and project-file save helper. Studio previously treated every editor mutation as an immediate file write. Manual DOM editing, timeline updates, and source-editor saves each had separate write paths, so there was no common transaction boundary where Studio could capture the file contents before and after an edit. Undo/redo needed to sit above those write paths as a file-level transaction system: capture changed files before saving, write the new contents, persist the history entry by project, then apply undo/redo only when the current file content still matches the expected snapshot. - `bun --filter @hyperframes/studio test src/utils/editHistory.test.ts src/utils/editHistoryStorage.test.ts src/hooks/usePersistentEditHistory.test.ts src/utils/studioFileHistory.test.ts` -> 4 files pass, 15 tests pass - `bun --filter @hyperframes/studio test` -> 26 files pass, 289 tests pass - `bun --filter @hyperframes/studio typecheck` - `bunx oxlint packages/studio/src/App.tsx packages/studio/src/icons/SystemIcons.tsx packages/studio/src/hooks/usePersistentEditHistory.ts packages/studio/src/hooks/usePersistentEditHistory.test.ts packages/studio/src/utils/editHistory.ts packages/studio/src/utils/editHistory.test.ts packages/studio/src/utils/editHistoryStorage.ts packages/studio/src/utils/editHistoryStorage.test.ts packages/studio/src/utils/studioFileHistory.ts packages/studio/src/utils/studioFileHistory.test.ts` -> 0 warnings, 0 errors - `bunx oxfmt --check packages/studio/src/App.tsx packages/studio/src/icons/SystemIcons.tsx packages/studio/src/hooks/usePersistentEditHistory.ts packages/studio/src/hooks/usePersistentEditHistory.test.ts packages/studio/src/utils/editHistory.ts packages/studio/src/utils/editHistory.test.ts packages/studio/src/utils/editHistoryStorage.ts packages/studio/src/utils/editHistoryStorage.test.ts packages/studio/src/utils/studioFileHistory.ts packages/studio/src/utils/studioFileHistory.test.ts` - `git diff --check` - `bun run --filter @hyperframes/core build:hyperframes-runtime` before commit hook, because the clean worktree needed the ignored runtime-inline artifact for typecheck - Lefthook pre-commit -> lint, format, typecheck pass - Lefthook commit-msg -> commitlint pass - Started Studio locally at `http://127.0.0.1:5190/#project/undo-redo-sample`. - Used `agent-browser` to select a preview element in the Inspector and change `#hero-card` from `left: 220px` to `left: 260px`. - Refreshed Studio and verified Undo stayed enabled. - Clicked Undo and verified the project file returned to `left: 220px`; clicked Redo and verified the inline `left: 260px` returned. - Used `agent-browser` to drag the `side-card` timeline clip, refreshed Studio, then verified Undo restored the previous timeline attributes and Redo reapplied the timeline move. - Recorded the tested undo/redo flow with `agent-browser`: `qa-artifacts/studio-undo-redo-2026-04-28/studio-undo-redo-flow.webm`. - Local screenshots and recordings are kept under `qa-artifacts/studio-undo-redo-2026-04-28/` and are intentionally not committed. - The scratch Studio project used for browser proof is local-only under `packages/studio/data/projects/undo-redo-sample/` and is intentionally not committed. - The PR intentionally excludes the earlier PRD/TDD planning notes under `docs/superpowers/`; those remain local-only per request. * fix: align Studio capture with preview (#595) Studio frame capture could fail for projects mounted outside the repo when the project id came from an encoded hash route. A project like `Notion Showcase` loaded as `#project/Notion%20Showcase`, but the capture URL encoded that already-encoded value again, producing `/api/projects/Notion%2520Showcase/...` and a 404. While validating the fix by seeking through the preview, capture also diverged from the visible player for nested compositions because the thumbnail route sought raw timelines instead of the same player seek path used by Studio preview. - Decodes project ids when reading Studio `#project/...` routes and centralizes project hash/API path construction. - Keeps API URLs encoded exactly once, including project names with spaces, literal `%`, reserved characters, and unicode. - Updates Studio thumbnail capture to prefer `window.__player.seek(t)` and only fall back to raw timeline seeking for standalone pages. - Preserves explicit `t=0` thumbnail requests instead of falling back to `0.5` seconds. - Adds preview-regression CI coverage for Studio routing, frame capture URL construction, thumbnail seeking, and core thumbnail seek parsing. Studio treated the hash route segment as the canonical project id even when the browser had already percent-encoded it. `buildFrameCaptureUrl` then encoded that string again, so a decoded project directory name and the capture API path no longer matched. The preview/capture mismatch was a separate seek-path issue: the visible Studio preview seeks through the HyperFrames player, which maps global time into nested composition time. The capture route bypassed that layer and paused all registered timelines at the same global time. The zero-second capture case came from parsing `t` with a truthiness fallback, so `parseFloat("0") || 0.5` became `0.5`. - `bun run --cwd packages/studio test -- vite.thumbnail.test.ts src/utils/projectRouting.test.ts src/utils/frameCapture.test.ts` - `bun run --cwd packages/core test -- src/studio-api/routes/thumbnail.test.ts` - `bunx oxfmt --check .github/workflows/preview-regression.yml packages/studio/vite.thumbnail.ts packages/studio/vite.thumbnail.test.ts packages/studio/vite.config.ts packages/studio/src/utils/projectRouting.ts packages/studio/src/utils/projectRouting.test.ts packages/studio/src/utils/frameCapture.ts packages/studio/src/App.tsx packages/core/src/studio-api/routes/thumbnail.ts packages/core/src/studio-api/routes/thumbnail.test.ts` - `bunx oxlint .github/workflows/preview-regression.yml packages/studio/vite.thumbnail.ts packages/studio/vite.thumbnail.test.ts packages/studio/vite.config.ts packages/studio/src/utils/projectRouting.ts packages/studio/src/utils/projectRouting.test.ts packages/studio/src/utils/frameCapture.ts packages/studio/src/App.tsx packages/core/src/studio-api/routes/thumbnail.ts packages/core/src/studio-api/routes/thumbnail.test.ts` - `bun run --cwd packages/studio typecheck` - `bun run --cwd packages/core build:hyperframes-runtime` - `bun run --cwd packages/core typecheck` - `git diff --check` Pre-commit also reran lint, format, and typecheck successfully for the committed files. Using `agent-browser`, I mounted `/Users/miguel07code/Downloads/Notion Showcase` into Studio's project data and opened: ```text http://127.0.0.1:5197/#project/Notion%20Showcase ``` Before the fix, Capture requested `/api/projects/Notion%2520Showcase/thumbnail/index.html?...` and Studio showed `Capture failed`. After the fix, I sought the preview to `0s`, `2s`, `10s`, and `18s`, captured each frame, and compared the visible preview crop against the capture output. The capture URLs all used `Notion%20Showcase`, not `Notion%2520Showcase`, and no failure toast appeared. Mean pixel diffs for preview vs capture were: - `0s`: `0.0` - `2s`: `0.8641` - `10s`: `0.3496` - `18s`: `0.2309` The small non-zero diffs are raster/antialias-level differences after resizing the capture to the preview crop dimensions. - Browser screenshots, comparison sheets, network logs, and the `agent-browser` recording are local-only under `qa-artifacts/capture-button/` and are not committed. - The local Notion Showcase project mount is an ignored symlink under `packages/studio/data/projects/` and is not committed. - Thumbnail cache versions were bumped so stale captures generated with the old seek behavior are not reused. * feat: persist studio manual edits via manifest * fix(studio): stabilize manual edit manifest rendering * fix(studio): allow master canvas layer selection * fix(studio): scale master edits in source coordinates * fix(studio): reapply manual edits during playback * fix(studio): keep rotation edit base stable * feat(studio): highlight hovered canvas target * fix(studio): drag hovered canvas targets immediately * fix(studio): rotate manual edits around center * fix(studio): keep rotate handle aligned while dragging * fix(studio): allow small rotation adjustments * fix(studio): match rotate handle size to resize handle * fix(studio): connect rotate handle line to selection * feat(studio): reset selected manual edits * fix(studio): route inspector geometry through manual edits * feat: add studio group repositioning * fix: preserve studio group selections * fix: seed additive studio selection groups * fix: select studio groups on pointerdown * fix: harden studio group overlay events * fix: address studio manual edit review feedback * fix: apply nested manual edits in drilled previews * fix: commit drag offsets from gesture math * fix: persist manual preview edits on refresh * fix: harden manual edit refresh apply * fix: share manual edit render runtime * chore: release v0.5.0-alpha.15 * feat(core): add studio animation preview APIs * feat(studio): add alpha editor layer inspector * chore: release v0.6.0-alpha.1 * feat(studio): enable inspector panels by default * fix(studio): keep motion panel opt-in * chore: release v0.6.0-alpha.2 * feat: auto-open timeline clip layers * feat: show composition loading in studio * feat: disable Studio timeline while composition loads * chore: ignore .claude directory * chore: release v0.6.0-alpha.3 * feat(studio): simplify inspector selection ux * fix(studio): keep notion preview playback moving * fix(studio): handle raster inspector clicks * fix(studio): stale selection, rotation control, design panel polish Fixes and improvements based on power-user testing feedback: 1. Fix stale selection after style edits — handleDomStyleCommit now calls refreshDomEditSelectionFromPreview after persisting, matching every other commit handler. Without this, the PropertyPanel showed frozen computedStyles after color/radius/shadow edits, making it look like editing "didn't work." Also adds error handling around the persist call. 2. Add rotation field to the Design panel Layout section — reads the current rotation angle from the manual edit manifest and commits via the existing handleDomRotationCommit handler. 3. Enable motion panel by default — STUDIO_MOTION_PANEL_ENABLED now defaults to true so the Motion tab is discoverable without env vars. 4. Color controls only when element has color — fill color section now only shows when the element has an explicit non-transparent background-color. Text color shows only when the element has a color style. Prevents showing color pickers on elements where color edits have no visible effect. 5. Exclude canvas from selection — added "canvas" to DOM_LAYER_IGNORED_TAGS so canvas elements are not selectable in the preview or listed in the layer panel. 6. Multi-selection feedback — shows "N elements selected" with guidance instead of the generic empty state when multiple elements are selected. Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com> * fix(studio): prevent browser launch timeout from crashing dev server The shared Puppeteer browser pool in getSharedBrowser() could throw a 30s TimeoutError during launch. This error propagated as an uncaught rejection and killed the vite process, even though generateThumbnail had its own try/catch — the browser launch promise rejected outside that scope. Now getSharedBrowser itself catches launch failures and returns null, so thumbnails degrade gracefully instead of crashing. Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com> * fix(studio): revert motion panel default to false Motion panel stays opt-in via env var per product direction. Only the Design panel is enabled by default. Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com> * fix(studio): prevent read-only property crash in manual edit wrappers The seek/play/applyAfter wrapper functions in manualEdits.ts crashed with "Cannot set property X which has only a getter" when the player or timeline objects define seek/play as getter-only properties. This prevented ALL manual edits (position, rotation, size) from persisting to disk — the error thrown during applyCurrentStudioManualEditsToPreview aborted the save queue. Wrapped all three property assignments in try/catch so wrapping gracefully degrades when the target object is non-configurable. Verified: position edit (X=42px) now persists to .hyperframes/studio-manual-edits.json and survives page refresh. Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com> * fix: alpha preview e2e fixes — exports, init templates, EPIPE crash Three bugs found via automated e2e testing of the v0.6.0-alpha preview: 1. core: add missing package.json export specifiers for studio-api/manual-edits-render-script and studio-api/studio-motion-render-script — the alpha.3 npm publish failed because the studio build could not resolve these sub-paths. 2. cli: fix init --example creating empty projects — tsup leaves empty template directories in dist/ during the build, causing existsSync(templateDir) to return true and skip the remote fetch fallback. Now checks for index.html inside the dir instead. 3. engine: fix unhandled EPIPE crash in streaming encoder — ffmpeg stdin/stdout had no error handlers, so a write after the ffmpeg process exits throws an uncaught error that crashes the process. Verified with 8 consecutive e2e iterations (424 test runs, 0 flaky). Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com> * fix(studio): thumbnail crash, feature defaults, multi-select UX, fps selector Power-user audit fixes for the alpha studio: - vite.config.ts: wrap thumbnail generation in try/catch so Puppeteer TimeoutError doesn't crash the entire vite dev server as an uncaught rejection. Close the page on error to prevent browser session leaks. - manualEditingAvailability.ts: enable motion panel and manual canvas drag editing by default (were both false, undiscoverable without knowing the env vars). - PropertyPanel.tsx: show "N elements selected" feedback when multiple elements are selected instead of the generic "Select an element" empty state. - RenderQueue.tsx + App.tsx: add FPS selector (24/30/60) to the render export bar instead of hardcoding 30fps. Pass the user's choice through to startRender. Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com> * chore: release v0.6.0-alpha.4 * fix(runtime): update clock duration when root timeline is late-bound Compositions with external sub-compositions (like apple-presentation with 7 slides) load child compositions via fetch(). The root GSAP timeline is only bound after all external compositions finish loading, but the TransportClock duration was only set during initial setup. When bindRootTimelineIfAvailable runs after the external compositions load, it captures the root timeline but never updates the clock. player.getDuration() continues returning 0, so the player's probe interval never fires the 'ready' event, and the Studio shows "Loading composition" indefinitely. Now bindRootTimelineIfAvailable updates clock.setDuration when the root timeline is late-bound. Guarded with try/catch for the early call site where clock is not yet initialized (temporal dead zone). Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com> * fix(studio): block element selection while composition is loading Prevent users from selecting elements in the preview while the composition is still loading (showing "Loading composition" overlay). Selection and hover highlighting are suppressed until the player fires the ready event. Also reverts motion panel and manual drag editing defaults to false — these were accidentally set to true during the PR #693 merge. Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com> * chore: release v0.6.0-alpha.5 * chore: release v0.6.0-alpha.6 * fix(runtime): remove per-tick timeline.pause() that causes audio stutter The seekRuntimeTimeline helper added timeline.pause() before every totalTime() seek. During transport-driven playback, this runs 60 times per second, causing GSAP to cascade pause events to media elements on every frame. The result: audio plays/stops/plays/stops in a stutter pattern. The captured root timeline is already paused once in player.play() — the TransportClock drives it via totalTime(t) which keeps it paused. The extra per-tick pause() was redundant for the root timeline but actively harmful for media sync. Fix: restore the original inline seek for the captured timeline (totalTime without pause), keep seekRuntimeTimeline with pause() only for standalone child timelines where explicit pause control is needed. Also fixes rebase artifact: missing PropertyPanel props in App.tsx. Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com> * chore: release v0.6.0-alpha.7 * fix(studio): restore text field handlers lost in rebase Restores handleDomAddTextField and handleDomRemoveTextField that were dropped when resolving App.tsx conflicts during the main→next rebase. Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com> * chore: release v0.6.0-alpha.8 * fix(runtime): comprehensive audio stutter fix Three changes that together caused audio play/stop/play/stop stutter during transport-driven playback: 1. seekRuntimeTimeline called timeline.pause() before every totalTime() seek, 60x per second. GSAP cascades pause to media elements on every frame. Fix: restore original inline seek for the captured timeline (totalTime without pause). The timeline is already paused once in player.play(). seekRuntimeTimeline with pause() remains only for standalone child timelines. 2. player.play() removed the !tl guard, allowing play without a captured timeline. But getSafeTimelineDurationSeconds(null) returns 0, so the clock has no duration → immediately reaches end → stops → restarts. Fix: when no timeline provides duration, fall back to the root composition element's data-duration attribute. 3. Audio source attachment added networkState guard that could cause the clock to flicker between audio-source and monotonic timing on transient media states. Fix: keep !rawEl.error guard (prevents errored audio from freezing the clock) but drop the networkState check. Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com> * fix(runtime): skip drift corrections on playing video elements Seeking a playing video resets the browser's decoder pipeline, causing a ~150ms freeze while it re-buffers. During that freeze the monotonic clock advances, drift grows, and strict sync fires another seek — creating a perpetual stutter loop (176 seek events / 8s observed on the apple-presentation composition). Skip strict and force drift corrections for playing video elements; only hard sync (>0.5s catastrophic drift) warrants the decoder-reset cost. Audio elements are unaffected and retain the full correction tiers. Also propagate the asset-loading overlay state to the timeline so controls are disabled during "Preparing preview assets", matching the existing behavior for the initial composition loading overlay. * chore: release v0.6.0-alpha.9 * feat(studio): consolidate keyboard shortcuts into single handler Move all window-level keyboard shortcuts from 4 separate files into one `handleAppKeyDown` listener in App.tsx: - Shift+T: toggle timeline (was App.tsx, separate useMountEffect) - Cmd/Ctrl+Z: undo (was App.tsx, separate useEffect) - Cmd/Ctrl+Shift+Z: redo (was App.tsx, separate useEffect) - Cmd/Ctrl+1: sidebar Compositions tab (was LeftSidebar.tsx) - Cmd/Ctrl+2: sidebar Assets tab (was LeftSidebar.tsx) - Delete/Backspace: remove selected element (was Timeline.tsx) LeftSidebar exposes a ref handle for tab switching. Timeline watches selectedElement becoming null to clean up popover/range UI state. History hotkey kept as named function for iframe forwarding. Playback shortcuts (Space, J/K/L, arrows) and caption nudge remain in their component hooks — tightly coupled to component state. Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com> * fix(studio): sidebar tab overflow + hot-reload double-refresh 1. Sidebar tabs: use equal 1fr columns, shorter "Comps" label, truncate on overflow, tighter padding. Fixes tabs clipping outside the rounded pill at narrow sidebar widths. 2. Hot reload: set domEditSaveTimestampRef before every save-then-refresh path (source editor, timeline move/resize/delete, asset drop). The file-change watcher already checks this timestamp and suppresses echoed events — but source editor saves and timeline operations weren't setting it, causing a double refreshKey increment that could leave the player in a non-playable state. Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com> * fix(studio): delete key removes preview-selected elements The consolidated keyboard handler only checked selectedElementId (timeline clips). When a user selected a child element in the preview via the inspector, selectedElementId was null because the element didn't correspond to a top-level timeline clip, so Delete/Backspace did nothing. Add handleDomEditElementDelete that removes the element referenced by the current domEditSelection via the remove-element mutation API. The Delete key handler now falls through from timeline selection to DOM edit selection. * fix(studio): remove unused deleteInFlightRef from Timeline Leftover from moving Delete handling to the consolidated keyboard handler in App.tsx. Also suppress pre-existing exhaustive-deps warning on the intentional every-render selection-change watcher. * fix(studio): forward all keyboard shortcuts to preview iframe The consolidated handleAppKeyDown was only added to the parent window. When focus was inside the preview iframe (after clicking an element), keydown events didn't reach the parent, so Delete and other shortcuts didn't fire. Replace the per-function iframe forwarding (handleTimelineToggleHotkey only) with the full app-level handler via a ref-stable wrapper. All app shortcuts (Delete, Undo/Redo, Shift+T, Cmd+1/2) now work from within the preview iframe. * fix(core): search inside <template> content when removing elements linkedom's document.querySelectorAll does not traverse <template> content. Elements in template-based compositions (like .title-word, .bullet-text) were invisible to the removal logic, so delete returned changed: false and the element survived the reload. Fall back to template.querySelectorAll when the document-level query returns no matches. Uses template.querySelectorAll directly (not template.content.querySelectorAll) because removing from the content DocumentFragment doesn't update the serialized output. * fix(studio): suppress loading overlay on hot-reload Only show the composition loading overlay on the first iframe load. Hot-reloads (source editor save, timeline edits, element delete) no longer flash the full-screen loading state. * fix(studio): reorder design panel, fix stroke height, rename Blending - Move Text section to the top of the panel (before Layout) - Remove Selection Colors section - Rename "Blending" to "Transparency" - Fix stroke Width/Style height mismatch by making SelectField use inline label layout matching MetricField * fix(studio): prevent panel scroll when wheel-adjusting metric inputs React registers onWheel passively, so preventDefault had no effect on the parent scroll container. Replace with a native wheel listener (passive: false) that blocks both default scroll and propagation. * chore: release v0.6.0-alpha.10 * chore: release v0.6.0-alpha.11 * fix(studio): clean next alpha inspector artifacts * chore: release v0.6.0-alpha.12 * fix(studio,player,core): eliminate double audio and manifest polling loop (#722) Three bugs that compound in Studio preview: 1. **Double audio on pause/resume**: syncRuntimeMedia played audio through the HTML <audio> element while WebAudioTransport simultaneously played the same source through AudioBufferSourceNode. Fixed by passing webAudio.isActive() as outputMuted so HTML elements stay muted when Web Audio owns playback. Also removed the priorMuted restore in stopAll() which raced with the next play cycle. 2. **Manifest polling loop**: applyStudioManualEditsToPreview and applyStudioMotionToPreview unconditionally fetched from disk on every call, even without forceFromDisk. The runtime posts state messages every frame via postMessage, triggering React re-renders that re-invoked these functions ~60x/second. Fixed by returning early when no disk read is requested, and using refs instead of callbacks in useEffect deps. 3. **Parent proxy double-play**: the player web component created parent-frame audio proxies even when the runtime bridge was available, causing two audio sources on autoplay-blocked promotion. Fixed by skipping proxy creation when _hasRuntimeBridge returns true, and synchronously muting iframe media on promotion to close the async race window. Also fixes pre-existing ResolutionPreset type missing square variants. Co-authored-by: Claude Opus 4.6 (1M context) <noreply@anthropic.com> * fix(studio): improve font picker and text property controls (#736) - Line height and letter-spacing: convert from free-text to select with presets - Font style: remove oblique (browser falls back to italic), keep normal/italic - Font weight: detect available weights via document.fonts.check(), add labels - Font source: local fonts matching Google catalog tagged as Google - Font list: balanced per-source caps prevent any source from being cut off - Sort order: Google fonts rank before Local so curated fonts appear first Co-authored-by: Claude Opus 4.6 (1M context) <noreply@anthropic.com> * fix(studio): inspector visibility, undo/redo blinking, and preview caching Inspector picks invisible elements when an ancestor has GSAP-set opacity: 0 because CSS opacity is not inherited — getComputedStyle on the child still returns 1. Walk the ancestor chain in the picker, domEditing, and overlay visibility checks to catch this. Also: - Containers with all-invisible children are no longer selectable - Selection/hover overlay hides during playback and while loading - Undo/redo no longer double-refreshes (echo suppression for all file writes) - Undo/redo reloads iframe in-place instead of recreating the Player, preserving shader transition cache - Preview routes return ETag + Cache-Control headers; composition HTML uses project signature for conditional 304, binary assets use mtime+size - Loading overlay deferred 400ms so cached loads never flash it * fix(studio): remove timeline inspector buttons, enable manual dragging Remove the eye icon (inspector) and image icon (thumbnail toggle) from timeline clips. The timeline layer inspector feature and all supporting code is removed. Enable manual dragging in the preview by default. Add scrub-to-drag on X/Y/W/H fields in the design panel. Hide the Radius section when the element has no visible background. Fix pre-existing ResolutionPreset type for square presets. * chore: release v0.6.0-alpha.13 Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com> * fix(studio): add rotation field, inline element drag, fix manifest load regression (#743) - Add rotation (R) field to geometry row (X, Y, W, H, R) in property panel. Goes through manifest via handleDomRotationCommit, resettable with Reset Edits. - Auto-promote display:inline elements to inline-block when dragged so translate works on inline spans. - Fix regression from polling fix: iframe load now passes readFromDiskFirst to load manifest from disk, so Reset Edits finds existing entries. Co-authored-by: Claude Opus 4.6 (1M context) <noreply@anthropic.com> * refactor(studio): decompose App.tsx monolith (4297 → 567 lines) (#741) * refactor(studio): decompose App.tsx from 4297 to 567 lines Break the monolithic StudioApp component into focused modules: Hooks (12 new): - usePanelLayout: resizable/collapsible panel state - useFileManager: file tree, CRUD, uploads, derived lists - useManifestPersistence: manual edit + motion manifest save queue - useTimelineEditing: clip move/resize/delete/drop handlers - useDomEditSession: DOM selection, style/text commits, preview interaction - useAppHotkeys: keyboard shortcuts, undo/redo, iframe hotkey sync - useCaptionDetection: auto-detect caption compositions - useRenderClipContent: timeline clip thumbnail rendering - useConsoleErrorCapture: preview iframe console error capture - useFrameCapture: frame capture download flow - useLintModal: lint execution and modal state - useCompositionDimensions: stage-size message listener Components (6 new): - AskAgentModal: agent prompt modal - StudioHeader: toolbar with undo/redo, capture, inspector toggle - StudioLeftSidebar: file tree + code editor (handles collapsed state) - StudioPreviewArea: NLELayout + overlays + caption timeline - StudioRightPanel: Design/Motion/Renders tab panel - TimelineToolbar: zoom controls + timeline toggle Utilities (4 new): - studioHelpers: types, path helpers, DOM utilities - studioPreviewHelpers: preview pointer/player interaction - domEditHelpers: selection group algebra - studioFontHelpers: font injection + @font-face management Also removes dead timeline layer inspector code (eye icon, thumbnail toggle, layer panel) that was disabled behind a feature flag. * feat(studio): add Layer (z-index) field to design panel Adds a scrub-enabled "Layer" field below the W/H inputs in the Layout section. Available for all elements regardless of style editing capability since z-index is fundamental to composition stacking order. * docs: architecture spec for studio domain contexts, hook split, and file-size lint * docs: implementation plan for studio contexts, hook split, and file-size lint * refactor(studio): consolidate duplicate helpers in useDomEditSession Remove ~370 lines of helper functions that were copied into the hook instead of imported. All removed functions already exist in the canonical utility files (studioHelpers, studioFontHelpers, studioPreviewHelpers, domEditHelpers). Also removes the duplicate local type definitions for RightPanelTab, AgentModalAnchorPoint, and PreviewLocalPointer, and drops now-unused imports (googleFontStylesheetUrl, importedFontFaceCss, resolveVisualDomEditSelectionTarget, DomEditViewport). Temporarily excludes useDomEditSession.ts from the 500 LOC file-size check until Tasks 3-5 split it into focused hooks. * refactor(studio): extract useDomSelection from useDomEditSession * refactor(studio): extract useAskAgentModal from useDomEditSession * refactor(studio): extract usePreviewInteraction from useDomEditSession * refactor(studio): extract useDomEditCommits, useDomEditSession now thin orchestrator Split the 897-line useDomEditSession into focused hooks: - useDomEditCommits (439 LOC): manifest commits (path offset, box size, rotation, manual edits reset, motion), persist operations, element delete, font asset resolution - useDomEditTextCommits (329 LOC): style/text/text-field commits - useDomEditSession (339 LOC): thin orchestrator wiring selection, agent modal, preview interaction, and commit hooks All files now under 500 LOC limit. Removed the temporary lefthook filesize exclusion for useDomEditSession. * feat(studio): add 4 domain contexts (PanelLayout, FileManager, DomEdit, Studio) Create context providers that wrap hook return values for prop-drilling elimination. Each context destructures and reconstructs the value inside useMemo so exhaustive-deps is satisfied and re-renders are minimized. Not yet wired into App.tsx — that comes in a follow-up. * refactor(studio): wire domain contexts, eliminate prop drilling in 4 components Wire StudioProvider, PanelLayoutProvider, FileManagerProvider, and DomEditProvider in App.tsx. Migrate StudioHeader, StudioLeftSidebar, StudioPreviewArea, and StudioRightPanel to consume contexts instead of props. Prop counts reduced: - StudioHeader: 13 -> 6 - StudioLeftSidebar: 19 -> 4 - StudioPreviewArea: 37 -> 11 - StudioRightPanel: 39 -> 3 Net: -118 lines, 108 props removed from call sites. * chore: upgrade to React 19 Upgrade react and react-dom from 18.3 to 19.2.6 across the workspace. Add resolutions/overrides in root package.json to prevent peer dependency pins (e.g. @phosphor-icons/react) from pulling React 18. Regenerate bun.lock. This enables the React 19 context syntax (<Context value={...}>) used by the new domain contexts. * fix(studio): refresh preview after z-index change so stacking updates visually * fix(studio): remove duplicate duration override causing oscillation The timeline message handler set the duration twice: once via processTimelineMessage and once via a raw durationInFrames override. When drilled into a sub-composition, these could disagree, causing the duration to oscillate after element deletion. * fix(studio): use in-place iframe reload after clip delete, remove confirm dialogs Two changes to fix duration oscillation after deleting a timeline clip: 1. Replace setRefreshKey (full Player remount) with in-place iframe.contentWindow.location.reload() after deleting a clip. The full remount triggered a chaotic re-probing cycle with multiple duration sources (adapter, manifest, postMessage) fighting each other, causing the timeline to oscillate between durations. In-place reload preserves the Player web component and its state. 2. Remove window.confirm dialogs from both timeline clip delete and DOM element delete. Undo is available so the confirmation adds friction without value. * chore: gitignore docs/superpowers * feat(studio): add favicon * perf(studio): skip no-op state updates in timeline sync syncTimelineElements was called 60+ times per page load, each time triggering setElements/setDuration/setTimelineReady even when nothing changed. This caused massive re-render churn and memory usage. Add early-return guards to skip updates when values haven't changed. Also fixes the duration oscillation after element delete. * refactor(studio): split PropertyPanel.tsx (3126 LOC) into 8 focused modules The monolithic PropertyPanel.tsx exceeded the 500 LOC filesize limit. Split into cohesive modules by responsibility: - propertyPanelHelpers.ts (401) — pure utility functions, shared types/constants - propertyPanelPrimitives.tsx (357) — CommitField, MetricField, DetailField, SliderControl, SegmentedControl, SelectField, Section - propertyPanelColor.tsx (371) — ColorField, ColorSlider - propertyPanelFill.tsx (421) — ImageFillField, GradientField, asset path helpers - propertyPanelFont.tsx (455) — FontFamilyField + font catalog helpers - propertyPanelSections.tsx (453) — TextSection, TextFieldEditor, text controls - propertyPanelStyleSections.tsx (411) — StyleSections (stroke, effects, clip, fill) - PropertyPanel.tsx (347) — main component, LayerTree, re-exports for consumers All re-exports from PropertyPanel.tsx preserved for backwards compatibility. No behavioral changes — pure structural split. * fix(studio): use in-place iframe reload for all timeline operations Replace setRefreshKey with in-place iframe reload for move, resize, and asset drop — matching delete which was already fixed. Prevents the Player remount probe cycle that causes duration oscillation. * perf(studio): replace 5s polling loop with event-driven adapter init The Player's onIframeLoad used a setInterval polling loop (25 attempts × 200ms = 5 seconds) to detect when the runtime's __player/__timeline globals appeared. Each poll that missed triggered wasted work, and multiple duration sources fighting during the probe cycle caused oscillation bugs. Replace with event-driven initialization: 1. Fast path: try initializeAdapter() immediately (works for in-place reloads where the adapter is already present) 2. If not ready, listen for the runtime's "state"/"timeline" postMessage signals and initialize on the first one 3. Single 5s timeout as safety net (replaces 25 interval ticks) This eliminates the polling overhead, reduces setDuration/setElements calls to exactly 1 per load, and makes the Player responsive within one frame of the runtime being ready instead of up to 200ms later. * fix(studio): prevent duration oscillation after element delete Two fixes for the duration display oscillating between sub-composition and master durations after deleting an element in the preview: 1. Clear store elements before iframe reload in handleDomEditElementDelete. Without this, stale pre-delete elements remain in the store and cause mergeTimelineElementsPreservingDowngrades to alternate between REPLACE and PRESERVE modes as the element count fluctuates. 2. Add 500ms cooldown on enrichMissingCompositions after timeline messages. The "state" handler was calling enrichMissingCompositions every ~80ms, which added extra elements from GSAP timelines. These fought with the authoritative element list from "timeline" messages (~333ms), creating a feedback loop where element count oscillated and triggered alternating merge strategies with different durations. Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com> * fix(studio): single reloadPreview as source of truth for preview refresh Create reloadPreview() in App.tsx that encapsulates the correct behavior (in-place iframe reload with setRefreshKey fallback). Pass it as the sole refresh mechanism to hooks, removing direct setRefreshKey access from useTimelineEditing and useDomEditCommits. --------- Co-authored-by: Claude Opus 4.6 (1M context) <noreply@anthropic.com> * refactor(studio): decompose App.tsx from 4297 to 567 lines Break the monolithic StudioApp component into focused modules: Hooks (12 new): - usePanelLayout: resizable/collapsible panel state - useFileManager: file tree, CRUD, uploads, derived lists - useManifestPersistence: manual edit + motion manifest save queue - useTimelineEditing: clip move/resize/delete/drop handlers - useDomEditSession: DOM selection, style/text commits, preview interaction - useAppHotkeys: keyboard shortcuts, undo/redo, iframe hotkey sync - useCaptionDetection: auto-detect caption compositions - useRenderClipContent: timeline clip thumbnail rendering - useConsoleErrorCapture: preview iframe console error capture - useFrameCapture: frame capture download flow - useLintModal: lint execution and modal state - useCompositionDimensions: stage-size message listener Components (6 new): - AskAgentModal: agent prompt modal - StudioHeader: toolbar with undo/redo, capture, inspector toggle - StudioLeftSidebar: file tree + code editor (handles collapsed state) - StudioPreviewArea: NLELayout + overlays + caption timeline - StudioRightPanel: Design/Motion/Renders tab panel - TimelineToolbar: zoom controls + timeline toggle Utilities (4 new): - studioHelpers: types, path helpers, DOM utilities - studioPreviewHelpers: preview pointer/player interaction - domEditHelpers: selection group algebra - studioFontHelpers: font injection + @font-face management Also removes dead timeline layer inspector code (eye icon, thumbnail toggle, layer panel) that was disabled behind a feature flag. * docs: architecture spec for studio domain contexts, hook split, and file-size lint * docs: implementation plan for studio contexts, hook split, and file-size lint * refactor(studio): consolidate duplicate helpers in useDomEditSession Remove ~370 lines of helper functions that were copied into the hook instead of imported. All removed functions already exist in the canonical utility files (studioHelpers, studioFontHelpers, studioPreviewHelpers, domEditHelpers). Also removes the duplicate local type definitions for RightPanelTab, AgentModalAnchorPoint, and PreviewLocalPointer, and drops now-unused imports (googleFontStylesheetUrl, importedFontFaceCss, resolveVisualDomEditSelectionTarget, DomEditViewport). Temporarily excludes useDomEditSession.ts from the 500 LOC file-size check until Tasks 3-5 split it into focused hooks. * refactor(studio): extract useDomSelection from useDomEditSession * refactor(studio): extract useAskAgentModal from useDomEditSession * refactor(studio): extract usePreviewInteraction from useDomEditSession * refactor(studio): extract useDomEditCommits, useDomEditSession now thin orchestrator Split the 897-line useDomEditSession into focused hooks: - useDomEditCommits (439 LOC): manifest commits (path offset, box size, rotation, manual edits reset, motion), persist operations, element delete, font asset resolution - useDomEditTextCommits (329 LOC): style/text/text-field commits - useDomEditSession (339 LOC): thin orchestrator wiring selection, agent modal, preview interaction, and commit hooks All files now under 500 LOC limit. Removed the temporary lefthook filesize exclusion for useDomEditSession. * refactor(studio): wire domain contexts, eliminate prop drilling in 4 components Wire StudioProvider, PanelLayoutProvider, FileManagerProvider, and DomEditProvider in App.tsx. Migrate StudioHeader, StudioLeftSidebar, StudioPreviewArea, and StudioRightPanel to consume contexts instead of props. Prop counts reduced: - StudioHeader: 13 -> 6 - StudioLeftSidebar: 19 -> 4 - StudioPreviewArea: 37 -> 11 - StudioRightPanel: 39 -> 3 Net: -118 lines, 108 props removed from call sites. * fix(studio): refresh preview after z-index change so stacking updates visually * fix(studio): remove duplicate duration override causing oscillation The timeline message handler set the duration twice: once via processTimelineMessage and once via a raw durationInFrames override. When drilled into a sub-composition, these could disagree, causing the duration to oscillate after element deletion. * fix(studio): use in-place iframe reload after clip delete, remove confirm dialogs Two changes to fix duration oscillation after deleting a timeline clip: 1. Replace setRefreshKey (full Player remount) with in-place iframe.contentWindow.location.reload() after deleting a clip. The full remount triggered a chaotic re-probing cycle with multiple duration sources (adapter, manifest, postMessage) fighting each other, causing the timeline to oscillate between durations. In-place reload preserves the Player web component and its state. 2. Remove window.confirm dialogs from both timeline clip delete and DOM element delete. Undo is available so the confirmation adds friction without value. * chore: gitignore docs/superpowers * perf(studio): skip no-op state updates in timeline sync syncTimelineElements was called 60+ times per page load, each time triggering setElements/setDuration/setTimelineReady even when nothing changed. This caused massive re-render churn and memory usage. Add early-return guards to skip updates when values haven't changed. Also fixes the duration oscillation after element delete. * refactor(studio): split PropertyPanel.tsx (3126 LOC) into 8 focused modules The monolithic PropertyPanel.tsx exceeded the 500 LOC filesize limit. Split into cohesive modules by responsibility: - propertyPanelHelpers.ts (401) — pure utility functions, shared types/constants - propertyPanelPrimitives.tsx (357) — CommitField, MetricField, DetailField, SliderControl, SegmentedControl, SelectField, Section - propertyPanelColor.tsx (371) — ColorField, ColorSlider - propertyPanelFill.tsx (421) — ImageFillField, GradientField, asset path helpers - propertyPanelFont.tsx (455) — FontFamilyField + font catalog helpers - propertyPanelSections.tsx (453) — TextSection, TextFieldEditor, text controls - propertyPanelStyleSections.tsx (411) — StyleSections (stroke, effects, clip, fill) - PropertyPanel.tsx (347) — main component, LayerTree, re-exports for consumers All re-exports from PropertyPanel.tsx preserved for backwards compatibility. No behavioral changes — pure structural split. * fix(studio): use in-place iframe reload for all timeline operations Replace setRefreshKey with in-place iframe reload for move, resize, and asset drop — matching delete which was already fixed. Prevents the Player remount probe cycle that causes duration oscillation. * perf(studio): replace 5s polling loop with event-driven adapter init The Player's onIframeLoad used a setInterval polling loop (25 attempts × 200ms = 5 seconds) to detect when the runtime's __player/__timeline globals appeared. Each poll that missed triggered wasted work, and multiple duration sources fighting during the probe cycle caused oscillation bugs. Replace with event-driven initialization: 1. Fast path: try initializeAdapter() immediately (works for in-place reloads where the adapter is already present) 2. If not ready, listen for the runtime's "state"/"timeline" postMessage signals and initialize on the first one 3. Single 5s timeout as safety net (replaces 25 interval ticks) This eliminates the polling overhead, reduces setDuration/setElements calls to exactly 1 per load, and makes the Player responsive within one frame of the runtime being ready instead of up to 200ms later. * fix(studio): prevent duration oscillation after element delete Two fixes for the duration display oscillating between sub-composition and master durations after deleting an element in the preview: 1. Clear store elements before iframe reload in handleDomEditElementDelete. Without this, stale pre-delete elements remain in the store and cause mergeTimelineElementsPreservingDowngrades to alternate between REPLACE and PRESERVE modes as the element count fluctuates. 2. Add 500ms cooldown on enrichMissingCompositions after timeline messages. The "state" handler was calling enrichMissingCompositions every ~80ms, which added extra elements from GSAP timelines. These fought with the authoritative element list from "timeline" messages (~333ms), creating a feedback loop where element count oscillated and triggered alternating merge strategies with different durations. Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com> * fix(studio): single reloadPreview as source of truth for preview refresh Create reloadPreview() in App.tsx that encapsulates the correct behavior (in-place iframe reload with setRefreshKey fallback). Pass it as the sole refresh mechanism to hooks, removing direct setRefreshKey access from useTimelineEditing and useDomEditCommits. * fix: resolve lint errors from rebase (unused imports, duplicate declarations) * fix: prefix unused probeResult variable * fix: restore renderOrchestrator.ts from origin/next (rebase conflict artifact) * fix: resolve rebase conflicts by using main's producer and next's studio/player * fix: restore rebase-conflicted files from origin/next * fix: use 'load' instead of 'networkidle0' for Puppeteer waitUntil (type compatibility) * fix: restore webAudioTransport.ts from main (test compatibility) --------- Co-authored-by: Vance Ingalls <vance@heygen.com> Co-authored-by: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
This commit is contained in:
co-authored by
Vance Ingalls
Claude Opus 4.6
parent
b8a2c48291
commit
38efe168e2
@@ -0,0 +1,326 @@
|
||||
import { useCallback, useEffect, useRef } from "react";
|
||||
import { usePlayerStore } from "../player";
|
||||
import type { TimelineElement } from "../player";
|
||||
import type { DomEditSelection } from "../components/editor/domEditing";
|
||||
import type { LeftSidebarHandle } from "../components/sidebar/LeftSidebar";
|
||||
import { STUDIO_MANUAL_EDITS_PATH } from "../components/editor/manualEdits";
|
||||
import { STUDIO_MOTION_PATH } from "../components/editor/studioMotion";
|
||||
import { shouldHandleTimelineToggleHotkey, isEditableTarget } from "../utils/timelineDiscovery";
|
||||
import { shouldIgnoreHistoryShortcut } from "../utils/studioHelpers";
|
||||
|
||||
// ── Types ──
|
||||
|
||||
interface EditHistoryHandle {
|
||||
undo: (callbacks: {
|
||||
readFile: (path: string) => Promise<string>;
|
||||
writeFile: (path: string, content: string) => Promise<void>;
|
||||
}) => Promise<{
|
||||
ok: boolean;
|
||||
reason?: string;
|
||||
label?: string;
|
||||
paths?: string[];
|
||||
}>;
|
||||
redo: (callbacks: {
|
||||
readFile: (path: string) => Promise<string>;
|
||||
writeFile: (path: string, content: string) => Promise<void>;
|
||||
}) => Promise<{
|
||||
ok: boolean;
|
||||
reason?: string;
|
||||
label?: string;
|
||||
paths?: string[];
|
||||
}>;
|
||||
}
|
||||
|
||||
interface UseAppHotkeysParams {
|
||||
toggleTimelineVisibility: () => void;
|
||||
handleTimelineElementDelete: (element: TimelineElement) => Promise<void>;
|
||||
handleDomEditElementDelete: (selection: DomEditSelection) => Promise<void>;
|
||||
domEditSelectionRef: React.MutableRefObject<DomEditSelection | null>;
|
||||
clearDomSelectionRef: React.MutableRefObject<() => void>;
|
||||
editHistory: EditHistoryHandle;
|
||||
readOptionalProjectFile: (path: string) => Promise<string>;
|
||||
readProjectFile: (path: string) => Promise<string>;
|
||||
writeProjectFile: (path: string, content: string) => Promise<void>;
|
||||
domEditSaveTimestampRef: React.MutableRefObject<number>;
|
||||
showToast: (message: string, tone?: "error" | "info") => void;
|
||||
syncHistoryPreviewAfterApply: (paths: string[] | undefined) => Promise<void>;
|
||||
waitForPendingDomEditSaves: () => Promise<void>;
|
||||
leftSidebarRef: React.RefObject<LeftSidebarHandle | null>;
|
||||
}
|
||||
|
||||
// ── Hook ──
|
||||
|
||||
export function useAppHotkeys({
|
||||
toggleTimelineVisibility,
|
||||
handleTimelineElementDelete,
|
||||
handleDomEditElementDelete,
|
||||
domEditSelectionRef,
|
||||
clearDomSelectionRef,
|
||||
editHistory,
|
||||
readOptionalProjectFile,
|
||||
readProjectFile,
|
||||
writeProjectFile,
|
||||
domEditSaveTimestampRef,
|
||||
showToast,
|
||||
syncHistoryPreviewAfterApply,
|
||||
waitForPendingDomEditSaves,
|
||||
leftSidebarRef,
|
||||
}: UseAppHotkeysParams) {
|
||||
const previewHotkeyWindowRef = useRef<Window | null>(null);
|
||||
const handleAppKeyDownRef = useRef<((event: KeyboardEvent) => void) | undefined>(undefined);
|
||||
const previewHistoryHotkeyCleanupRef = useRef<(() => void) | null>(null);
|
||||
|
||||
// ── Timeline toggle hotkey ──
|
||||
|
||||
const handleTimelineToggleHotkey = useCallback(
|
||||
(event: KeyboardEvent) => {
|
||||
if (!shouldHandleTimelineToggleHotkey(event)) return;
|
||||
event.preventDefault();
|
||||
toggleTimelineVisibility();
|
||||
},
|
||||
[toggleTimelineVisibility],
|
||||
);
|
||||
|
||||
// ── History file read/write helpers ──
|
||||
|
||||
const readHistoryProjectFile = useCallback(
|
||||
async (path: string): Promise<string> => {
|
||||
return path === STUDIO_MANUAL_EDITS_PATH || path === STUDIO_MOTION_PATH
|
||||
? readOptionalProjectFile(path)
|
||||
: readProjectFile(path);
|
||||
},
|
||||
[readOptionalProjectFile, readProjectFile],
|
||||
);
|
||||
|
||||
const writeHistoryProjectFile = useCallback(
|
||||
async (path: string, content: string): Promise<void> => {
|
||||
domEditSaveTimestampRef.current = Date.now();
|
||||
await writeProjectFile(path, content);
|
||||
},
|
||||
[domEditSaveTimestampRef, writeProjectFile],
|
||||
);
|
||||
|
||||
// ── Undo / Redo ──
|
||||
|
||||
const handleUndo = useCallback(async () => {
|
||||
await waitForPendingDomEditSaves();
|
||||
const result = await editHistory.undo({
|
||||
readFile: readHistoryProjectFile,
|
||||
writeFile: writeHistoryProjectFile,
|
||||
});
|
||||
if (!result.ok && result.reason === "content-mismatch") {
|
||||
showToast("File changed outside Studio. Undo history was not applied.", "info");
|
||||
return;
|
||||
}
|
||||
if (result.ok && result.label) {
|
||||
clearDomSelectionRef.current();
|
||||
await syncHistoryPreviewAfterApply(result.paths);
|
||||
showToast(`Undid ${result.label}`, "info");
|
||||
}
|
||||
}, [
|
||||
clearDomSelectionRef,
|
||||
editHistory,
|
||||
readHistoryProjectFile,
|
||||
showToast,
|
||||
syncHistoryPreviewAfterApply,
|
||||
waitForPendingDomEditSaves,
|
||||
writeHistoryProjectFile,
|
||||
]);
|
||||
|
||||
const handleRedo = useCallback(async () => {
|
||||
await waitForPendingDomEditSaves();
|
||||
const result = await editHistory.redo({
|
||||
readFile: readHistoryProjectFile,
|
||||
writeFile: writeHistoryProjectFile,
|
||||
});
|
||||
if (!result.ok && result.reason === "content-mismatch") {
|
||||
showToast("File changed outside Studio. Redo history was not applied.", "info");
|
||||
return;
|
||||
}
|
||||
if (result.ok && result.label) {
|
||||
clearDomSelectionRef.current();
|
||||
await syncHistoryPreviewAfterApply(result.paths);
|
||||
showToast(`Redid ${result.label}`, "info");
|
||||
}
|
||||
}, [
|
||||
clearDomSelectionRef,
|
||||
editHistory,
|
||||
readHistoryProjectFile,
|
||||
showToast,
|
||||
syncHistoryPreviewAfterApply,
|
||||
waitForPendingDomEditSaves,
|
||||
writeHistoryProjectFile,
|
||||
]);
|
||||
|
||||
// ── Stable refs for the consolidated keydown handler ──
|
||||
|
||||
const handleToggleRef = useRef(handleTimelineToggleHotkey);
|
||||
handleToggleRef.current = handleTimelineToggleHotkey;
|
||||
const handleDeleteRef = useRef(handleTimelineElementDelete);
|
||||
handleDeleteRef.current = handleTimelineElementDelete;
|
||||
const handleDomEditDeleteRef = useRef(handleDomEditElementDelete);
|
||||
handleDomEditDeleteRef.current = handleDomEditElementDelete;
|
||||
const handleUndoRef = useRef(handleUndo);
|
||||
handleUndoRef.current = handleUndo;
|
||||
const handleRedoRef = useRef(handleRedo);
|
||||
handleRedoRef.current = handleRedo;
|
||||
|
||||
// ── Consolidated keydown handler ──
|
||||
|
||||
handleAppKeyDownRef.current = (event: KeyboardEvent) => {
|
||||
// Shift+T — toggle timeline
|
||||
handleToggleRef.current(event);
|
||||
|
||||
// Cmd/Ctrl+Z — undo, Cmd/Ctrl+Shift+Z or Ctrl+Y — redo
|
||||
if (event.metaKey || event.ctrlKey) {
|
||||
if (!shouldIgnoreHistoryShortcut(event.target)) {
|
||||
const key = event.key.toLowerCase();
|
||||
if (key === "z" && !event.shiftKey) {
|
||||
event.preventDefault();
|
||||
void handleUndoRef.current();
|
||||
return;
|
||||
}
|
||||
if ((key === "z" && event.shiftKey) || (event.ctrlKey && !event.metaKey && key === "y")) {
|
||||
event.preventDefault();
|
||||
void handleRedoRef.current();
|
||||
return;
|
||||
}
|
||||
}
|
||||
|
||||
// Cmd/Ctrl+1 — sidebar: Compositions tab
|
||||
if (event.key === "1") {
|
||||
event.preventDefault();
|
||||
leftSidebarRef.current?.selectTab("compositions");
|
||||
return;
|
||||
}
|
||||
|
||||
// Cmd/Ctrl+2 — sidebar: Assets tab
|
||||
if (event.key === "2") {
|
||||
event.preventDefault();
|
||||
leftSidebarRef.current?.selectTab("assets");
|
||||
return;
|
||||
}
|
||||
}
|
||||
|
||||
// Delete / Backspace — remove selected element (timeline clip or preview selection)
|
||||
if (
|
||||
(event.key === "Delete" || event.key === "Backspace") &&
|
||||
!event.metaKey &&
|
||||
!event.ctrlKey &&
|
||||
!event.altKey &&
|
||||
!isEditableTarget(event.target)
|
||||
) {
|
||||
const { selectedElementId, elements } = usePlayerStore.getState();
|
||||
if (selectedElementId) {
|
||||
const element = elements.find((el) => (el.key ?? el.id) === selectedElementId);
|
||||
if (element) {
|
||||
event.preventDefault();
|
||||
void handleDeleteRef.current(element);
|
||||
return;
|
||||
}
|
||||
}
|
||||
const domSelection = domEditSelectionRef.current;
|
||||
if (domSelection) {
|
||||
event.preventDefault();
|
||||
void handleDomEditDeleteRef.current(domSelection);
|
||||
}
|
||||
}
|
||||
};
|
||||
|
||||
// ── Window keydown listener ──
|
||||
|
||||
// eslint-disable-next-line no-restricted-syntax
|
||||
useEffect(() => {
|
||||
function handleAppKeyDown(event: KeyboardEvent) {
|
||||
handleAppKeyDownRef.current?.(event);
|
||||
}
|
||||
window.addEventListener("keydown", handleAppKeyDown, true);
|
||||
return () => window.removeEventListener("keydown", handleAppKeyDown, true);
|
||||
}, []);
|
||||
|
||||
// ── Preview iframe keydown forwarding ──
|
||||
|
||||
const previewAppKeyDownHandler = useCallback((event: KeyboardEvent) => {
|
||||
handleAppKeyDownRef.current?.(event);
|
||||
}, []);
|
||||
|
||||
const syncPreviewTimelineHotkey = useCallback(
|
||||
(iframe: HTMLIFrameElement | null) => {
|
||||
const nextWindow = iframe?.contentWindow ?? null;
|
||||
if (previewHotkeyWindowRef.current === nextWindow) return;
|
||||
if (previewHotkeyWindowRef.current) {
|
||||
previewHotkeyWindowRef.current.removeEventListener("keydown", previewAppKeyDownHandler);
|
||||
}
|
||||
previewHotkeyWindowRef.current = nextWindow;
|
||||
nextWindow?.addEventListener("keydown", previewAppKeyDownHandler, true);
|
||||
},
|
||||
[previewAppKeyDownHandler],
|
||||
);
|
||||
|
||||
useEffect(
|
||||
() => () => {
|
||||
if (previewHotkeyWindowRef.current) {
|
||||
previewHotkeyWindowRef.current.removeEventListener("keydown", previewAppKeyDownHandler);
|
||||
previewHotkeyWindowRef.current = null;
|
||||
}
|
||||
},
|
||||
[previewAppKeyDownHandler],
|
||||
);
|
||||
|
||||
// ── History hotkey for iframe forwarding ──
|
||||
|
||||
const handleHistoryHotkey = useCallback((event: KeyboardEvent) => {
|
||||
if (!(event.metaKey || event.ctrlKey)) return;
|
||||
if (shouldIgnoreHistoryShortcut(event.target)) return;
|
||||
const key = event.key.toLowerCase();
|
||||
if (key === "z" && !event.shiftKey) {
|
||||
event.preventDefault();
|
||||
void handleUndoRef.current();
|
||||
return;
|
||||
}
|
||||
if ((key === "z" && event.shiftKey) || (event.ctrlKey && !event.metaKey && key === "y")) {
|
||||
event.preventDefault();
|
||||
void handleRedoRef.current();
|
||||
}
|
||||
}, []);
|
||||
|
||||
const syncPreviewHistoryHotkey = useCallback(
|
||||
(iframe: HTMLIFrameElement | null) => {
|
||||
previewHistoryHotkeyCleanupRef.current?.();
|
||||
previewHistoryHotkeyCleanupRef.current = null;
|
||||
|
||||
const win = iframe?.contentWindow ?? null;
|
||||
let doc: Document | null = null;
|
||||
try {
|
||||
doc = iframe?.contentDocument ?? null;
|
||||
} catch {
|
||||
doc = null;
|
||||
}
|
||||
if (!win && !doc) return;
|
||||
|
||||
win?.addEventListener("keydown", handleHistoryHotkey, true);
|
||||
doc?.addEventListener("keydown", handleHistoryHotkey, true);
|
||||
previewHistoryHotkeyCleanupRef.current = () => {
|
||||
win?.removeEventListener("keydown", handleHistoryHotkey, true);
|
||||
doc?.removeEventListener("keydown", handleHistoryHotkey, true);
|
||||
};
|
||||
},
|
||||
[handleHistoryHotkey],
|
||||
);
|
||||
|
||||
useEffect(
|
||||
() => () => {
|
||||
previewHistoryHotkeyCleanupRef.current?.();
|
||||
previewHistoryHotkeyCleanupRef.current = null;
|
||||
},
|
||||
[],
|
||||
);
|
||||
|
||||
return {
|
||||
handleUndo,
|
||||
handleRedo,
|
||||
syncPreviewTimelineHotkey,
|
||||
syncPreviewHistoryHotkey,
|
||||
handleTimelineToggleHotkey,
|
||||
};
|
||||
}
|
||||
@@ -0,0 +1,162 @@
|
||||
import { useState, useCallback, useRef, useEffect } from "react";
|
||||
import { copyTextToClipboard } from "../utils/clipboard";
|
||||
import { readTagSnippetByTarget } from "../utils/sourcePatcher";
|
||||
import { toProjectAbsolutePath, type AgentModalAnchorPoint } from "../utils/studioHelpers";
|
||||
import { buildElementAgentPrompt, type DomEditSelection } from "../components/editor/domEditing";
|
||||
|
||||
// ── Types ──
|
||||
|
||||
export interface UseAskAgentModalParams {
|
||||
projectId: string | null;
|
||||
activeCompPath: string | null;
|
||||
projectDir: string | null;
|
||||
projectIdRef: React.MutableRefObject<string | null>;
|
||||
currentTime: number;
|
||||
showToast: (message: string, tone?: "error" | "info") => void;
|
||||
domEditSelectionRef: React.MutableRefObject<DomEditSelection | null>;
|
||||
domEditSelection: DomEditSelection | null;
|
||||
}
|
||||
|
||||
// ── Hook ──
|
||||
|
||||
export function useAskAgentModal({
|
||||
activeCompPath,
|
||||
projectDir,
|
||||
projectIdRef,
|
||||
currentTime,
|
||||
showToast,
|
||||
domEditSelectionRef,
|
||||
domEditSelection,
|
||||
}: UseAskAgentModalParams) {
|
||||
// ── State ──
|
||||
|
||||
const [agentPromptTagSnippet, setAgentPromptTagSnippet] = useState<string | undefined>();
|
||||
const [agentPromptSelectionContext, setAgentPromptSelectionContext] = useState<
|
||||
string | undefined
|
||||
>();
|
||||
const [agentModalAnchorPoint, setAgentModalAnchorPoint] = useState<AgentModalAnchorPoint | null>(
|
||||
null,
|
||||
);
|
||||
const [copiedAgentPrompt, setCopiedAgentPrompt] = useState(false);
|
||||
const [agentModalOpen, setAgentModalOpen] = useState(false);
|
||||
|
||||
// ── Refs ──
|
||||
|
||||
const copiedAgentTimerRef = useRef<ReturnType<typeof setTimeout> | null>(null);
|
||||
|
||||
// ── Callbacks ──
|
||||
|
||||
const preloadAgentPromptSnippet = useCallback(
|
||||
async (selection: DomEditSelection) => {
|
||||
const pid = projectIdRef.current;
|
||||
if (!pid) return;
|
||||
|
||||
const targetPath = selection.sourceFile || activeCompPath || "index.html";
|
||||
try {
|
||||
const response = await fetch(
|
||||
`/api/projects/${pid}/files/${encodeURIComponent(targetPath)}`,
|
||||
);
|
||||
if (!response.ok) return;
|
||||
|
||||
const data = (await response.json()) as { content?: string };
|
||||
const html = data.content;
|
||||
const tagSnippet =
|
||||
typeof html === "string" ? readTagSnippetByTarget(html, selection) : undefined;
|
||||
|
||||
setAgentPromptTagSnippet((current) => {
|
||||
if (domEditSelectionRef.current !== selection) return current;
|
||||
return tagSnippet;
|
||||
});
|
||||
} catch {
|
||||
// Runtime outerHTML is still available as a synchronous copy fallback.
|
||||
}
|
||||
},
|
||||
[activeCompPath, domEditSelectionRef, projectIdRef],
|
||||
);
|
||||
|
||||
const handleAskAgent = useCallback(() => {
|
||||
if (!domEditSelection) return;
|
||||
setAgentPromptTagSnippet(undefined);
|
||||
setAgentPromptSelectionContext(undefined);
|
||||
setAgentModalAnchorPoint(null);
|
||||
void preloadAgentPromptSnippet(domEditSelection);
|
||||
setAgentModalOpen(true);
|
||||
}, [domEditSelection, preloadAgentPromptSnippet]);
|
||||
|
||||
const handleAgentModalSubmit = useCallback(
|
||||
async (userInstruction: string) => {
|
||||
if (!domEditSelection) return;
|
||||
|
||||
const targetPath = domEditSelection.sourceFile || activeCompPath || "index.html";
|
||||
const tagSnippet = agentPromptTagSnippet ?? domEditSelection.element.outerHTML;
|
||||
const prompt = buildElementAgentPrompt({
|
||||
selection: domEditSelection,
|
||||
currentTime,
|
||||
tagSnippet,
|
||||
selectionContext: agentPromptSelectionContext,
|
||||
userInstruction,
|
||||
sourceFilePath: toProjectAbsolutePath(projectDir, targetPath),
|
||||
});
|
||||
|
||||
const copied = await copyTextToClipboard(prompt);
|
||||
if (!copied) {
|
||||
showToast("Could not copy prompt to clipboard.", "error");
|
||||
return;
|
||||
}
|
||||
|
||||
setAgentModalOpen(false);
|
||||
setAgentPromptSelectionContext(undefined);
|
||||
setAgentModalAnchorPoint(null);
|
||||
if (copiedAgentTimerRef.current) clearTimeout(copiedAgentTimerRef.current);
|
||||
setCopiedAgentPrompt(true);
|
||||
copiedAgentTimerRef.current = setTimeout(() => setCopiedAgentPrompt(false), 1600);
|
||||
},
|
||||
[
|
||||
activeCompPath,
|
||||
agentPromptSelectionContext,
|
||||
agentPromptTagSnippet,
|
||||
currentTime,
|
||||
domEditSelection,
|
||||
projectDir,
|
||||
showToast,
|
||||
],
|
||||
);
|
||||
|
||||
// ── Effects ──
|
||||
|
||||
// Clear agent-prompt state when selection changes
|
||||
// eslint-disable-next-line no-restricted-syntax
|
||||
useEffect(() => {
|
||||
setAgentPromptTagSnippet(undefined);
|
||||
setAgentPromptSelectionContext(undefined);
|
||||
setAgentModalAnchorPoint(null);
|
||||
setCopiedAgentPrompt(false);
|
||||
}, [domEditSelection]);
|
||||
|
||||
// Cleanup copiedAgentTimerRef
|
||||
// eslint-disable-next-line no-restricted-syntax
|
||||
useEffect(
|
||||
() => () => {
|
||||
if (copiedAgentTimerRef.current) clearTimeout(copiedAgentTimerRef.current);
|
||||
},
|
||||
[],
|
||||
);
|
||||
|
||||
return {
|
||||
// State
|
||||
agentModalOpen,
|
||||
agentModalAnchorPoint,
|
||||
copiedAgentPrompt,
|
||||
agentPromptSelectionContext,
|
||||
|
||||
// Setters (consumed by handlePreviewCanvasMouseDown and other callers)
|
||||
setAgentModalOpen,
|
||||
setAgentPromptSelectionContext,
|
||||
setAgentModalAnchorPoint,
|
||||
|
||||
// Callbacks
|
||||
preloadAgentPromptSnippet,
|
||||
handleAskAgent,
|
||||
handleAgentModalSubmit,
|
||||
};
|
||||
}
|
||||
@@ -0,0 +1,132 @@
|
||||
import { useEffect } from "react";
|
||||
import { useCaptionStore } from "../captions/store";
|
||||
import { useCaptionSync } from "../captions/hooks/useCaptionSync";
|
||||
import { parseCaptionComposition } from "../captions/parser";
|
||||
|
||||
interface UseCaptionDetectionParams {
|
||||
projectId: string | null;
|
||||
activeCompPath: string | null;
|
||||
compIdToSrc: Map<string, string>;
|
||||
captionEditMode: boolean;
|
||||
captionHasSelection: boolean;
|
||||
previewIframeRef: React.MutableRefObject<HTMLIFrameElement | null>;
|
||||
captionSync: ReturnType<typeof useCaptionSync>;
|
||||
setRightCollapsed: (collapsed: boolean) => void;
|
||||
}
|
||||
|
||||
export function useCaptionDetection({
|
||||
projectId,
|
||||
activeCompPath,
|
||||
compIdToSrc,
|
||||
captionEditMode,
|
||||
captionHasSelection,
|
||||
previewIframeRef,
|
||||
captionSync,
|
||||
setRightCollapsed,
|
||||
}: UseCaptionDetectionParams) {
|
||||
// eslint-disable-next-line no-restricted-syntax
|
||||
useEffect(() => {
|
||||
if (!projectId) return;
|
||||
|
||||
let activating = false;
|
||||
|
||||
const tryActivateCaptions = () => {
|
||||
if (useCaptionStore.getState().isEditMode || activating) {
|
||||
return;
|
||||
}
|
||||
|
||||
const iframe = previewIframeRef.current;
|
||||
let doc: Document | null = null;
|
||||
let win: Window | null = null;
|
||||
try {
|
||||
doc = iframe?.contentDocument ?? null;
|
||||
win = iframe?.contentWindow ?? null;
|
||||
} catch {
|
||||
return;
|
||||
}
|
||||
if (!doc || !win) return;
|
||||
|
||||
const groups = doc.querySelectorAll(".caption-group");
|
||||
if (groups.length === 0) return;
|
||||
|
||||
let captionSrcPath: string | null = null;
|
||||
|
||||
const compHosts = doc.querySelectorAll("[data-composition-src], [data-composition-file]");
|
||||
for (const host of compHosts) {
|
||||
const src =
|
||||
host.getAttribute("data-composition-src") || host.getAttribute("data-composition-file");
|
||||
if (src && src.includes("captions")) {
|
||||
captionSrcPath = src;
|
||||
break;
|
||||
}
|
||||
}
|
||||
|
||||
if (!captionSrcPath) {
|
||||
for (const [id, src] of compIdToSrc) {
|
||||
if (id.includes("caption") || src.includes("caption")) {
|
||||
captionSrcPath = src;
|
||||
break;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
if (!captionSrcPath && activeCompPath?.includes("captions")) {
|
||||
captionSrcPath = activeCompPath;
|
||||
}
|
||||
|
||||
if (!captionSrcPath) {
|
||||
const captionComp = doc.querySelector('[data-composition-id*="caption"]');
|
||||
if (captionComp) {
|
||||
const compId = captionComp.getAttribute("data-composition-id") || "";
|
||||
captionSrcPath = compIdToSrc.get(compId) || null;
|
||||
}
|
||||
}
|
||||
|
||||
if (!captionSrcPath) return;
|
||||
|
||||
activating = true;
|
||||
const srcPath = captionSrcPath;
|
||||
fetch(`/api/projects/${projectId}/files/${encodeURIComponent(srcPath)}`)
|
||||
.then((r) => r.json())
|
||||
.then((data: { content?: string }) => {
|
||||
if (!data.content || !doc || !win || useCaptionStore.getState().isEditMode) return;
|
||||
const root = doc.querySelector("[data-composition-id]");
|
||||
const w = parseInt(root?.getAttribute("data-width") ?? "1920", 10);
|
||||
const h = parseInt(root?.getAttribute("data-height") ?? "1080", 10);
|
||||
const dur = parseFloat(root?.getAttribute("data-duration") ?? "0");
|
||||
const model = parseCaptionComposition(doc, win, data.content, w, h, dur);
|
||||
if (!model) return;
|
||||
const store = useCaptionStore.getState();
|
||||
store.setModel(model);
|
||||
store.setSourceFilePath(srcPath);
|
||||
store.setEditMode(true);
|
||||
captionSync.loadOverrides();
|
||||
})
|
||||
.catch(() => {})
|
||||
.finally(() => {
|
||||
activating = false;
|
||||
});
|
||||
};
|
||||
|
||||
const handleMessage = (e: MessageEvent) => {
|
||||
const data = e.data;
|
||||
if (data?.source === "hf-preview" && (data?.type === "state" || data?.type === "timeline")) {
|
||||
tryActivateCaptions();
|
||||
}
|
||||
};
|
||||
|
||||
window.addEventListener("message", handleMessage);
|
||||
tryActivateCaptions();
|
||||
|
||||
return () => {
|
||||
window.removeEventListener("message", handleMessage);
|
||||
};
|
||||
}, [activeCompPath, projectId, compIdToSrc, captionSync, previewIframeRef]);
|
||||
|
||||
// eslint-disable-next-line no-restricted-syntax
|
||||
useEffect(() => {
|
||||
if (captionEditMode) {
|
||||
setRightCollapsed(!captionHasSelection);
|
||||
}
|
||||
}, [captionHasSelection, captionEditMode, setRightCollapsed]);
|
||||
}
|
||||
@@ -0,0 +1,25 @@
|
||||
import { useState } from "react";
|
||||
import { useMountEffect } from "./useMountEffect";
|
||||
import type { CompositionDimensions } from "../components/renders/RenderQueue";
|
||||
|
||||
export function useCompositionDimensions() {
|
||||
const [compositionDimensions, setCompositionDimensions] = useState<CompositionDimensions | null>(
|
||||
null,
|
||||
);
|
||||
|
||||
useMountEffect(() => {
|
||||
const handleMessage = (e: MessageEvent) => {
|
||||
const data = e.data;
|
||||
if (data?.source !== "hf-preview" || data?.type !== "stage-size") return;
|
||||
const { width, height } = data as { width: number; height: number };
|
||||
if (!(width > 0) || !(height > 0)) return;
|
||||
setCompositionDimensions((prev) =>
|
||||
prev && prev.width === width && prev.height === height ? prev : { width, height },
|
||||
);
|
||||
};
|
||||
window.addEventListener("message", handleMessage);
|
||||
return () => window.removeEventListener("message", handleMessage);
|
||||
});
|
||||
|
||||
return compositionDimensions;
|
||||
}
|
||||
@@ -0,0 +1,60 @@
|
||||
import { useEffect, useRef, useState } from "react";
|
||||
import type { LintFinding } from "../components/LintModal";
|
||||
|
||||
/**
|
||||
* Captures `console.error` and `window.onerror` events from a preview iframe
|
||||
* and exposes them as LintFinding[] for the console errors modal.
|
||||
*/
|
||||
export function useConsoleErrorCapture(previewIframe: HTMLIFrameElement | null) {
|
||||
const [consoleErrors, setConsoleErrors] = useState<LintFinding[] | null>(null);
|
||||
const consoleErrorsRef = useRef<LintFinding[]>([]);
|
||||
|
||||
const resetErrors = () => {
|
||||
consoleErrorsRef.current = [];
|
||||
setConsoleErrors(null);
|
||||
};
|
||||
|
||||
// eslint-disable-next-line no-restricted-syntax
|
||||
useEffect(() => {
|
||||
if (!previewIframe) return;
|
||||
const attachErrorCapture = () => {
|
||||
try {
|
||||
const win = previewIframe.contentWindow as (Window & typeof globalThis) | null;
|
||||
if (!win) return;
|
||||
if ((win as unknown as Record<string, unknown>).__hfErrorCapture) return;
|
||||
(win as unknown as Record<string, unknown>).__hfErrorCapture = true;
|
||||
const origError = win.console.error.bind(win.console);
|
||||
win.console.error = function (...args: unknown[]) {
|
||||
origError(...args);
|
||||
const text = args.map((a) => (a instanceof Error ? a.message : String(a))).join(" ");
|
||||
if (text.includes("favicon")) return;
|
||||
consoleErrorsRef.current = [
|
||||
...consoleErrorsRef.current,
|
||||
{ severity: "error", message: text },
|
||||
];
|
||||
setConsoleErrors([...consoleErrorsRef.current]);
|
||||
};
|
||||
win.addEventListener("error", (e: ErrorEvent) => {
|
||||
const text = e.message || String(e);
|
||||
consoleErrorsRef.current = [
|
||||
...consoleErrorsRef.current,
|
||||
{ severity: "error", message: text },
|
||||
];
|
||||
setConsoleErrors([...consoleErrorsRef.current]);
|
||||
});
|
||||
} catch {
|
||||
/* same-origin only */
|
||||
}
|
||||
};
|
||||
attachErrorCapture();
|
||||
const handleLoad = () => {
|
||||
consoleErrorsRef.current = [];
|
||||
setConsoleErrors(null);
|
||||
attachErrorCapture();
|
||||
};
|
||||
previewIframe.addEventListener("load", handleLoad);
|
||||
return () => previewIframe.removeEventListener("load", handleLoad);
|
||||
}, [previewIframe]);
|
||||
|
||||
return { consoleErrors, setConsoleErrors, resetErrors };
|
||||
}
|
||||
@@ -0,0 +1,437 @@
|
||||
import { useCallback } from "react";
|
||||
import { usePlayerStore } from "../player";
|
||||
import { FONT_EXT } from "../utils/mediaTypes";
|
||||
import { applyPatchByTarget } from "../utils/sourcePatcher";
|
||||
import { saveProjectFilesWithHistory } from "../utils/studioFileHistory";
|
||||
import { primaryFontFamilyValue } from "../utils/studioFontHelpers";
|
||||
import { getDomEditTargetKey, type DomEditSelection } from "../components/editor/domEditing";
|
||||
import {
|
||||
removeStudioManualEditsForSelection,
|
||||
type StudioManualEditManifest,
|
||||
upsertStudioBoxSizeEdit,
|
||||
upsertStudioPathOffsetEdit,
|
||||
upsertStudioRotationEdit,
|
||||
} from "../components/editor/manualEdits";
|
||||
import {
|
||||
removeStudioMotionForSelection,
|
||||
type StudioGsapMotion,
|
||||
type StudioMotionManifest,
|
||||
upsertStudioGsapMotion,
|
||||
} from "../components/editor/studioMotion";
|
||||
import { fontFamilyFromAssetPath, type ImportedFontAsset } from "../components/editor/fontAssets";
|
||||
import type { DomEditGroupPathOffsetCommit } from "../components/editor/DomEditOverlay";
|
||||
import type { EditHistoryKind } from "../utils/editHistory";
|
||||
import { useDomEditTextCommits } from "./useDomEditTextCommits";
|
||||
|
||||
// ── Types ──
|
||||
|
||||
interface RecordEditInput {
|
||||
label: string;
|
||||
kind: EditHistoryKind;
|
||||
coalesceKey?: string;
|
||||
files: Record<string, { before: string; after: string }>;
|
||||
}
|
||||
|
||||
export type PersistDomEditOperations = (
|
||||
selection: DomEditSelection,
|
||||
operations: Parameters<typeof applyPatchByTarget>[2][],
|
||||
options?: {
|
||||
label?: string;
|
||||
coalesceKey?: string;
|
||||
skipRefresh?: boolean;
|
||||
prepareContent?: (html: string, sourceFile: string) => string;
|
||||
shouldSave?: () => boolean;
|
||||
},
|
||||
) => Promise<void>;
|
||||
|
||||
export interface UseDomEditCommitsParams {
|
||||
activeCompPath: string | null;
|
||||
previewIframeRef: React.MutableRefObject<HTMLIFrameElement | null>;
|
||||
showToast: (message: string, tone?: "error" | "info") => void;
|
||||
commitStudioManualEditManifestOptimistically: (
|
||||
updateManifest: (manifest: StudioManualEditManifest) => StudioManualEditManifest,
|
||||
options: { label: string; coalesceKey: string },
|
||||
) => void;
|
||||
commitStudioMotionManifestOptimistically: (
|
||||
updateManifest: (manifest: StudioMotionManifest) => StudioMotionManifest,
|
||||
options: { label: string; coalesceKey: string },
|
||||
) => void;
|
||||
applyCurrentStudioManualEditsToPreview: (iframe: HTMLIFrameElement | null) => void;
|
||||
applyCurrentStudioMotionToPreview: (iframe: HTMLIFrameElement | null) => void;
|
||||
writeProjectFile: (path: string, content: string) => Promise<void>;
|
||||
domEditSaveTimestampRef: React.MutableRefObject<number>;
|
||||
editHistory: { recordEdit: (entry: RecordEditInput) => Promise<void> };
|
||||
fileTree: string[];
|
||||
importedFontAssetsRef: React.MutableRefObject<ImportedFontAsset[]>;
|
||||
projectId: string | null;
|
||||
projectIdRef: React.MutableRefObject<string | null>;
|
||||
reloadPreview: () => void;
|
||||
|
||||
// From useDomSelection
|
||||
domEditSelection: DomEditSelection | null;
|
||||
domEditSelectionRef: React.MutableRefObject<DomEditSelection | null>;
|
||||
domEditGroupSelectionsRef: React.MutableRefObject<DomEditSelection[]>;
|
||||
applyDomSelection: (
|
||||
selection: DomEditSelection | null,
|
||||
options?: { revealPanel?: boolean; additive?: boolean; preserveGroup?: boolean },
|
||||
) => void;
|
||||
clearDomSelection: () => void;
|
||||
refreshDomEditSelectionFromPreview: (selection: DomEditSelection) => void;
|
||||
refreshDomEditGroupSelectionsFromPreview: (selections: DomEditSelection[]) => void;
|
||||
buildDomSelectionFromTarget: (
|
||||
target: HTMLElement,
|
||||
options?: { preferClipAncestor?: boolean },
|
||||
) => DomEditSelection | null;
|
||||
}
|
||||
|
||||
// ── Hook ──
|
||||
|
||||
export function useDomEditCommits({
|
||||
activeCompPath,
|
||||
previewIframeRef,
|
||||
showToast,
|
||||
commitStudioManualEditManifestOptimistically,
|
||||
commitStudioMotionManifestOptimistically,
|
||||
applyCurrentStudioManualEditsToPreview,
|
||||
applyCurrentStudioMotionToPreview,
|
||||
writeProjectFile,
|
||||
domEditSaveTimestampRef,
|
||||
editHistory,
|
||||
fileTree,
|
||||
importedFontAssetsRef,
|
||||
projectId,
|
||||
projectIdRef,
|
||||
reloadPreview,
|
||||
domEditSelection,
|
||||
domEditGroupSelectionsRef,
|
||||
applyDomSelection,
|
||||
clearDomSelection,
|
||||
refreshDomEditSelectionFromPreview,
|
||||
refreshDomEditGroupSelectionsFromPreview,
|
||||
buildDomSelectionFromTarget,
|
||||
}: UseDomEditCommitsParams) {
|
||||
const resolveImportedFontAsset = useCallback(
|
||||
(fontFamilyValue: string): ImportedFontAsset | null => {
|
||||
const family = primaryFontFamilyValue(fontFamilyValue);
|
||||
if (!family) return null;
|
||||
const imported = importedFontAssetsRef.current.find(
|
||||
(font) => font.family.toLowerCase() === family.toLowerCase(),
|
||||
);
|
||||
if (imported) return imported;
|
||||
const asset = fileTree.find(
|
||||
(path) =>
|
||||
FONT_EXT.test(path) &&
|
||||
fontFamilyFromAssetPath(path).toLowerCase() === family.toLowerCase(),
|
||||
);
|
||||
if (!asset) return null;
|
||||
return {
|
||||
family: fontFamilyFromAssetPath(asset),
|
||||
path: asset,
|
||||
url: `/api/projects/${projectId}/preview/${asset}`,
|
||||
};
|
||||
},
|
||||
[fileTree, projectId, importedFontAssetsRef],
|
||||
);
|
||||
|
||||
const persistDomEditOperations: PersistDomEditOperations = useCallback(
|
||||
async (selection, operations, options) => {
|
||||
const pid = projectIdRef.current;
|
||||
if (!pid) throw new Error("No active project");
|
||||
if (options?.shouldSave && !options.shouldSave()) return;
|
||||
|
||||
const targetPath = selection.sourceFile || activeCompPath || "index.html";
|
||||
const response = await fetch(`/api/projects/${pid}/files/${encodeURIComponent(targetPath)}`);
|
||||
if (!response.ok) {
|
||||
throw new Error(`Failed to read ${targetPath}`);
|
||||
}
|
||||
|
||||
const data = (await response.json()) as { content?: string };
|
||||
const originalContent = data.content;
|
||||
if (typeof originalContent !== "string") {
|
||||
throw new Error(`Missing file contents for ${targetPath}`);
|
||||
}
|
||||
|
||||
let patchedContent = originalContent;
|
||||
for (const operation of operations) {
|
||||
patchedContent = applyPatchByTarget(patchedContent, selection, operation);
|
||||
}
|
||||
if (options?.prepareContent) {
|
||||
patchedContent = options.prepareContent(patchedContent, targetPath);
|
||||
}
|
||||
if (options?.shouldSave && !options.shouldSave()) return;
|
||||
|
||||
if (patchedContent === originalContent) {
|
||||
throw new Error(`Unable to patch ${selection.selector ?? selection.id ?? "selection"}`);
|
||||
}
|
||||
|
||||
await saveProjectFilesWithHistory({
|
||||
projectId: pid,
|
||||
label: options?.label ?? "Edit layer",
|
||||
kind: "manual",
|
||||
coalesceKey: options?.coalesceKey,
|
||||
files: { [targetPath]: patchedContent },
|
||||
readFile: async () => originalContent,
|
||||
writeFile: writeProjectFile,
|
||||
recordEdit: editHistory.recordEdit,
|
||||
});
|
||||
|
||||
if (options?.skipRefresh) {
|
||||
domEditSaveTimestampRef.current = Date.now();
|
||||
} else {
|
||||
reloadPreview();
|
||||
}
|
||||
},
|
||||
[
|
||||
activeCompPath,
|
||||
editHistory.recordEdit,
|
||||
writeProjectFile,
|
||||
projectIdRef,
|
||||
domEditSaveTimestampRef,
|
||||
reloadPreview,
|
||||
],
|
||||
);
|
||||
|
||||
// ── Text & style commits (delegated to useDomEditTextCommits) ──
|
||||
|
||||
const {
|
||||
handleDomStyleCommit,
|
||||
handleDomTextCommit,
|
||||
commitDomTextFields,
|
||||
handleDomTextFieldStyleCommit,
|
||||
handleDomAddTextField,
|
||||
handleDomRemoveTextField,
|
||||
} = useDomEditTextCommits({
|
||||
activeCompPath,
|
||||
previewIframeRef,
|
||||
domEditSelection,
|
||||
applyDomSelection,
|
||||
refreshDomEditSelectionFromPreview,
|
||||
buildDomSelectionFromTarget,
|
||||
persistDomEditOperations,
|
||||
resolveImportedFontAsset,
|
||||
});
|
||||
|
||||
// ── Manifest commits ──
|
||||
|
||||
const handleDomPathOffsetCommit = useCallback(
|
||||
(selection: DomEditSelection, next: { x: number; y: number }) => {
|
||||
commitStudioManualEditManifestOptimistically(
|
||||
(manifest) => upsertStudioPathOffsetEdit(manifest, selection, next),
|
||||
{
|
||||
label: "Move layer",
|
||||
coalesceKey: `path-offset:${getDomEditTargetKey(selection)}`,
|
||||
},
|
||||
);
|
||||
refreshDomEditSelectionFromPreview(selection);
|
||||
},
|
||||
[commitStudioManualEditManifestOptimistically, refreshDomEditSelectionFromPreview],
|
||||
);
|
||||
|
||||
const handleDomGroupPathOffsetCommit = useCallback(
|
||||
(updates: DomEditGroupPathOffsetCommit[]) => {
|
||||
if (updates.length === 0) return;
|
||||
const coalesceKey = updates
|
||||
.map((update) => getDomEditTargetKey(update.selection))
|
||||
.sort()
|
||||
.join(":");
|
||||
commitStudioManualEditManifestOptimistically(
|
||||
(manifest) =>
|
||||
updates.reduce(
|
||||
(nextManifest, update) =>
|
||||
upsertStudioPathOffsetEdit(nextManifest, update.selection, update.next),
|
||||
manifest,
|
||||
),
|
||||
{
|
||||
label: `Move ${updates.length} layers`,
|
||||
coalesceKey: `group-path-offset:${coalesceKey}`,
|
||||
},
|
||||
);
|
||||
refreshDomEditGroupSelectionsFromPreview(domEditGroupSelectionsRef.current);
|
||||
},
|
||||
[
|
||||
commitStudioManualEditManifestOptimistically,
|
||||
domEditGroupSelectionsRef,
|
||||
refreshDomEditGroupSelectionsFromPreview,
|
||||
],
|
||||
);
|
||||
|
||||
const handleDomBoxSizeCommit = useCallback(
|
||||
(selection: DomEditSelection, next: { width: number; height: number }) => {
|
||||
commitStudioManualEditManifestOptimistically(
|
||||
(manifest) => upsertStudioBoxSizeEdit(manifest, selection, next),
|
||||
{
|
||||
label: "Resize layer box",
|
||||
coalesceKey: `box-size:${getDomEditTargetKey(selection)}`,
|
||||
},
|
||||
);
|
||||
refreshDomEditSelectionFromPreview(selection);
|
||||
},
|
||||
[commitStudioManualEditManifestOptimistically, refreshDomEditSelectionFromPreview],
|
||||
);
|
||||
|
||||
const handleDomRotationCommit = useCallback(
|
||||
(selection: DomEditSelection, next: { angle: number }) => {
|
||||
commitStudioManualEditManifestOptimistically(
|
||||
(manifest) => upsertStudioRotationEdit(manifest, selection, next),
|
||||
{
|
||||
label: "Rotate layer",
|
||||
coalesceKey: `rotation:${getDomEditTargetKey(selection)}`,
|
||||
},
|
||||
);
|
||||
refreshDomEditSelectionFromPreview(selection);
|
||||
},
|
||||
[commitStudioManualEditManifestOptimistically, refreshDomEditSelectionFromPreview],
|
||||
);
|
||||
|
||||
const handleDomManualEditsReset = useCallback(
|
||||
(selection: DomEditSelection) => {
|
||||
commitStudioManualEditManifestOptimistically(
|
||||
(manifest) => removeStudioManualEditsForSelection(manifest, selection),
|
||||
{
|
||||
label: "Reset layer edits",
|
||||
coalesceKey: `manual-reset:${getDomEditTargetKey(selection)}`,
|
||||
},
|
||||
);
|
||||
applyCurrentStudioManualEditsToPreview(previewIframeRef.current);
|
||||
refreshDomEditSelectionFromPreview(selection);
|
||||
},
|
||||
[
|
||||
applyCurrentStudioManualEditsToPreview,
|
||||
commitStudioManualEditManifestOptimistically,
|
||||
refreshDomEditSelectionFromPreview,
|
||||
previewIframeRef,
|
||||
],
|
||||
);
|
||||
|
||||
const handleDomMotionCommit = useCallback(
|
||||
(
|
||||
selection: DomEditSelection,
|
||||
motion: Omit<StudioGsapMotion, "kind" | "target" | "updatedAt">,
|
||||
) => {
|
||||
commitStudioMotionManifestOptimistically(
|
||||
(manifest) => upsertStudioGsapMotion(manifest, selection, motion),
|
||||
{
|
||||
label: "Set GSAP motion",
|
||||
coalesceKey: `motion:${getDomEditTargetKey(selection)}`,
|
||||
},
|
||||
);
|
||||
refreshDomEditSelectionFromPreview(selection);
|
||||
},
|
||||
[commitStudioMotionManifestOptimistically, refreshDomEditSelectionFromPreview],
|
||||
);
|
||||
|
||||
const handleDomMotionClear = useCallback(
|
||||
(selection: DomEditSelection) => {
|
||||
commitStudioMotionManifestOptimistically(
|
||||
(manifest) => removeStudioMotionForSelection(manifest, selection),
|
||||
{
|
||||
label: "Clear GSAP motion",
|
||||
coalesceKey: `motion:${getDomEditTargetKey(selection)}`,
|
||||
},
|
||||
);
|
||||
applyCurrentStudioMotionToPreview(previewIframeRef.current);
|
||||
refreshDomEditSelectionFromPreview(selection);
|
||||
},
|
||||
[
|
||||
applyCurrentStudioMotionToPreview,
|
||||
commitStudioMotionManifestOptimistically,
|
||||
refreshDomEditSelectionFromPreview,
|
||||
previewIframeRef,
|
||||
],
|
||||
);
|
||||
|
||||
const handleDomEditElementDelete = useCallback(
|
||||
async (selection: DomEditSelection) => {
|
||||
const pid = projectIdRef.current;
|
||||
if (!pid) return;
|
||||
const label = selection.label || selection.id || selection.selector || selection.tagName;
|
||||
|
||||
const targetPath = selection.sourceFile || activeCompPath || "index.html";
|
||||
try {
|
||||
const response = await fetch(
|
||||
`/api/projects/${pid}/files/${encodeURIComponent(targetPath)}`,
|
||||
);
|
||||
if (!response.ok) throw new Error(`Failed to read ${targetPath}`);
|
||||
|
||||
const data = (await response.json()) as { content?: string };
|
||||
const originalContent = data.content;
|
||||
if (typeof originalContent !== "string")
|
||||
throw new Error(`Missing file contents for ${targetPath}`);
|
||||
|
||||
const patchTarget: { id?: string; selector?: string; selectorIndex?: number } = selection.id
|
||||
? {
|
||||
id: selection.id,
|
||||
selector: selection.selector,
|
||||
selectorIndex: selection.selectorIndex,
|
||||
}
|
||||
: selection.selector
|
||||
? { selector: selection.selector, selectorIndex: selection.selectorIndex }
|
||||
: ({} as never);
|
||||
if (!patchTarget.id && !patchTarget.selector) {
|
||||
throw new Error("Selected element has no patchable target");
|
||||
}
|
||||
|
||||
const removeResponse = await fetch(
|
||||
`/api/projects/${pid}/file-mutations/remove-element/${encodeURIComponent(targetPath)}`,
|
||||
{
|
||||
method: "POST",
|
||||
headers: { "Content-Type": "application/json" },
|
||||
body: JSON.stringify({ target: patchTarget }),
|
||||
},
|
||||
);
|
||||
if (!removeResponse.ok) throw new Error(`Failed to delete element from ${targetPath}`);
|
||||
|
||||
const removeData = (await removeResponse.json()) as { changed?: boolean; content?: string };
|
||||
const patchedContent =
|
||||
typeof removeData.content === "string" ? removeData.content : originalContent;
|
||||
|
||||
domEditSaveTimestampRef.current = Date.now();
|
||||
await saveProjectFilesWithHistory({
|
||||
projectId: pid,
|
||||
label: "Delete element",
|
||||
kind: "timeline",
|
||||
files: { [targetPath]: patchedContent },
|
||||
readFile: async () => originalContent,
|
||||
writeFile: writeProjectFile,
|
||||
recordEdit: editHistory.recordEdit,
|
||||
});
|
||||
|
||||
clearDomSelection();
|
||||
usePlayerStore.getState().setSelectedElementId(null);
|
||||
reloadPreview();
|
||||
showToast(`Deleted ${label}. Use Undo to restore it.`, "info");
|
||||
} catch (error) {
|
||||
const message = error instanceof Error ? error.message : "Failed to delete element";
|
||||
showToast(message);
|
||||
}
|
||||
},
|
||||
[
|
||||
activeCompPath,
|
||||
clearDomSelection,
|
||||
domEditSaveTimestampRef,
|
||||
editHistory.recordEdit,
|
||||
projectIdRef,
|
||||
reloadPreview,
|
||||
showToast,
|
||||
writeProjectFile,
|
||||
],
|
||||
);
|
||||
|
||||
return {
|
||||
resolveImportedFontAsset,
|
||||
handleDomStyleCommit,
|
||||
handleDomTextCommit,
|
||||
commitDomTextFields,
|
||||
handleDomTextFieldStyleCommit,
|
||||
handleDomAddTextField,
|
||||
handleDomRemoveTextField,
|
||||
handleDomPathOffsetCommit,
|
||||
handleDomGroupPathOffsetCommit,
|
||||
handleDomBoxSizeCommit,
|
||||
handleDomRotationCommit,
|
||||
handleDomManualEditsReset,
|
||||
handleDomMotionCommit,
|
||||
handleDomMotionClear,
|
||||
handleDomEditElementDelete,
|
||||
};
|
||||
}
|
||||
@@ -0,0 +1,342 @@
|
||||
import { useEffect } from "react";
|
||||
import type { TimelineElement } from "../player";
|
||||
import { STUDIO_INSPECTOR_PANELS_ENABLED } from "../components/editor/manualEditingAvailability";
|
||||
import { findElementForSelection } from "../components/editor/domEditing";
|
||||
import type { StudioManualEditManifest } from "../components/editor/manualEdits";
|
||||
import type { StudioMotionManifest } from "../components/editor/studioMotion";
|
||||
import type { ImportedFontAsset } from "../components/editor/fontAssets";
|
||||
import type { EditHistoryKind } from "../utils/editHistory";
|
||||
import type { RightPanelTab } from "../utils/studioHelpers";
|
||||
import { useAskAgentModal } from "./useAskAgentModal";
|
||||
import { useDomSelection } from "./useDomSelection";
|
||||
import { usePreviewInteraction } from "./usePreviewInteraction";
|
||||
import { useDomEditCommits } from "./useDomEditCommits";
|
||||
|
||||
// ── Types ──
|
||||
|
||||
interface RecordEditInput {
|
||||
label: string;
|
||||
kind: EditHistoryKind;
|
||||
coalesceKey?: string;
|
||||
files: Record<string, { before: string; after: string }>;
|
||||
}
|
||||
|
||||
export interface UseDomEditSessionParams {
|
||||
projectId: string | null;
|
||||
activeCompPath: string | null;
|
||||
isMasterView: boolean;
|
||||
compIdToSrc: Map<string, string>;
|
||||
captionEditMode: boolean;
|
||||
compositionLoading: boolean;
|
||||
previewIframeRef: React.MutableRefObject<HTMLIFrameElement | null>;
|
||||
timelineElements: TimelineElement[];
|
||||
currentTime: number;
|
||||
setSelectedTimelineElementId: (id: string | null) => void;
|
||||
setRightCollapsed: (collapsed: boolean) => void;
|
||||
setRightPanelTab: (tab: RightPanelTab) => void;
|
||||
showToast: (message: string, tone?: "error" | "info") => void;
|
||||
refreshPreviewDocumentVersion: () => void;
|
||||
commitStudioManualEditManifestOptimistically: (
|
||||
updateManifest: (manifest: StudioManualEditManifest) => StudioManualEditManifest,
|
||||
options: { label: string; coalesceKey: string },
|
||||
) => void;
|
||||
commitStudioMotionManifestOptimistically: (
|
||||
updateManifest: (manifest: StudioMotionManifest) => StudioMotionManifest,
|
||||
options: { label: string; coalesceKey: string },
|
||||
) => void;
|
||||
applyCurrentStudioManualEditsToPreview: (iframe: HTMLIFrameElement | null) => void;
|
||||
applyCurrentStudioMotionToPreview: (iframe: HTMLIFrameElement | null) => void;
|
||||
readProjectFile: (path: string) => Promise<string>;
|
||||
writeProjectFile: (path: string, content: string) => Promise<void>;
|
||||
domEditSaveTimestampRef: React.MutableRefObject<number>;
|
||||
editHistory: { recordEdit: (entry: RecordEditInput) => Promise<void> };
|
||||
fileTree: string[];
|
||||
importedFontAssetsRef: React.MutableRefObject<ImportedFontAsset[]>;
|
||||
projectDir: string | null;
|
||||
projectIdRef: React.MutableRefObject<string | null>;
|
||||
previewIframe: HTMLIFrameElement | null;
|
||||
refreshKey: number;
|
||||
rightPanelTab: RightPanelTab;
|
||||
applyStudioManualEditsToPreviewRef: React.MutableRefObject<
|
||||
(iframe: HTMLIFrameElement) => Promise<void>
|
||||
>;
|
||||
applyStudioMotionToPreviewRef: React.MutableRefObject<
|
||||
(iframe: HTMLIFrameElement) => Promise<void>
|
||||
>;
|
||||
syncPreviewHistoryHotkey: (iframe: HTMLIFrameElement | null) => void;
|
||||
reloadPreview: () => void;
|
||||
setRefreshKey: React.Dispatch<React.SetStateAction<number>>;
|
||||
}
|
||||
|
||||
// ── Hook ──
|
||||
|
||||
export function useDomEditSession({
|
||||
projectId,
|
||||
activeCompPath,
|
||||
isMasterView,
|
||||
compIdToSrc,
|
||||
captionEditMode,
|
||||
compositionLoading,
|
||||
previewIframeRef,
|
||||
timelineElements,
|
||||
currentTime,
|
||||
setSelectedTimelineElementId,
|
||||
setRightCollapsed,
|
||||
setRightPanelTab,
|
||||
showToast,
|
||||
refreshPreviewDocumentVersion,
|
||||
commitStudioManualEditManifestOptimistically,
|
||||
commitStudioMotionManifestOptimistically,
|
||||
applyCurrentStudioManualEditsToPreview,
|
||||
applyCurrentStudioMotionToPreview,
|
||||
readProjectFile: _readProjectFile,
|
||||
writeProjectFile,
|
||||
domEditSaveTimestampRef,
|
||||
editHistory,
|
||||
fileTree,
|
||||
importedFontAssetsRef,
|
||||
projectDir,
|
||||
projectIdRef,
|
||||
previewIframe,
|
||||
refreshKey,
|
||||
rightPanelTab,
|
||||
applyStudioManualEditsToPreviewRef,
|
||||
applyStudioMotionToPreviewRef,
|
||||
syncPreviewHistoryHotkey,
|
||||
reloadPreview,
|
||||
setRefreshKey: _setRefreshKey,
|
||||
}: UseDomEditSessionParams) {
|
||||
void _setRefreshKey;
|
||||
// ── Selection (delegated to useDomSelection) ──
|
||||
|
||||
const {
|
||||
domEditSelection,
|
||||
domEditGroupSelections,
|
||||
domEditHoverSelection,
|
||||
domEditSelectionRef,
|
||||
domEditGroupSelectionsRef,
|
||||
applyDomSelection,
|
||||
clearDomSelection,
|
||||
buildDomSelectionFromTarget,
|
||||
resolveDomSelectionFromPreviewPoint,
|
||||
updateDomEditHoverSelection,
|
||||
buildDomSelectionForTimelineElement,
|
||||
handleTimelineElementSelect,
|
||||
refreshDomEditSelectionFromPreview,
|
||||
refreshDomEditGroupSelectionsFromPreview,
|
||||
} = useDomSelection({
|
||||
projectId,
|
||||
activeCompPath,
|
||||
isMasterView,
|
||||
compIdToSrc,
|
||||
captionEditMode,
|
||||
previewIframeRef,
|
||||
timelineElements,
|
||||
setSelectedTimelineElementId,
|
||||
setRightCollapsed,
|
||||
setRightPanelTab,
|
||||
previewIframe,
|
||||
refreshKey,
|
||||
rightPanelTab,
|
||||
});
|
||||
|
||||
// ── Agent modal (delegated to useAskAgentModal) ──
|
||||
|
||||
const {
|
||||
agentModalOpen,
|
||||
agentModalAnchorPoint,
|
||||
copiedAgentPrompt,
|
||||
agentPromptSelectionContext,
|
||||
setAgentModalOpen,
|
||||
setAgentPromptSelectionContext,
|
||||
setAgentModalAnchorPoint,
|
||||
preloadAgentPromptSnippet,
|
||||
handleAskAgent,
|
||||
handleAgentModalSubmit,
|
||||
} = useAskAgentModal({
|
||||
projectId,
|
||||
activeCompPath,
|
||||
projectDir,
|
||||
projectIdRef,
|
||||
currentTime,
|
||||
showToast,
|
||||
domEditSelectionRef,
|
||||
domEditSelection,
|
||||
});
|
||||
|
||||
// ── Preview interaction (delegated to usePreviewInteraction) ──
|
||||
|
||||
const {
|
||||
handlePreviewCanvasMouseDown,
|
||||
handlePreviewCanvasPointerMove,
|
||||
handlePreviewCanvasPointerLeave,
|
||||
handleBlockedDomMove,
|
||||
handleDomManualDragStart,
|
||||
} = usePreviewInteraction({
|
||||
captionEditMode,
|
||||
compositionLoading,
|
||||
previewIframeRef,
|
||||
activeCompPath,
|
||||
showToast,
|
||||
applyDomSelection,
|
||||
resolveDomSelectionFromPreviewPoint,
|
||||
updateDomEditHoverSelection,
|
||||
preloadAgentPromptSnippet,
|
||||
setAgentPromptSelectionContext,
|
||||
setAgentModalAnchorPoint,
|
||||
setAgentModalOpen,
|
||||
});
|
||||
|
||||
// ── Commit handlers (delegated to useDomEditCommits) ──
|
||||
|
||||
const {
|
||||
resolveImportedFontAsset,
|
||||
handleDomStyleCommit,
|
||||
handleDomTextCommit,
|
||||
handleDomTextFieldStyleCommit,
|
||||
handleDomAddTextField,
|
||||
handleDomRemoveTextField,
|
||||
handleDomPathOffsetCommit,
|
||||
handleDomGroupPathOffsetCommit,
|
||||
handleDomBoxSizeCommit,
|
||||
handleDomRotationCommit,
|
||||
handleDomManualEditsReset,
|
||||
handleDomMotionCommit,
|
||||
handleDomMotionClear,
|
||||
handleDomEditElementDelete,
|
||||
} = useDomEditCommits({
|
||||
activeCompPath,
|
||||
previewIframeRef,
|
||||
showToast,
|
||||
commitStudioManualEditManifestOptimistically,
|
||||
commitStudioMotionManifestOptimistically,
|
||||
applyCurrentStudioManualEditsToPreview,
|
||||
applyCurrentStudioMotionToPreview,
|
||||
writeProjectFile,
|
||||
domEditSaveTimestampRef,
|
||||
editHistory,
|
||||
fileTree,
|
||||
importedFontAssetsRef,
|
||||
projectId,
|
||||
projectIdRef,
|
||||
reloadPreview,
|
||||
domEditSelection,
|
||||
domEditSelectionRef,
|
||||
domEditGroupSelectionsRef,
|
||||
applyDomSelection,
|
||||
clearDomSelection,
|
||||
refreshDomEditSelectionFromPreview,
|
||||
refreshDomEditGroupSelectionsFromPreview,
|
||||
buildDomSelectionFromTarget,
|
||||
});
|
||||
|
||||
// ── Effects ──
|
||||
|
||||
// Sync selection from preview document on load / refresh
|
||||
// eslint-disable-next-line no-restricted-syntax
|
||||
useEffect(() => {
|
||||
if (!previewIframe) return;
|
||||
|
||||
const syncSelectionFromDocument = () => {
|
||||
if (!STUDIO_INSPECTOR_PANELS_ENABLED || captionEditMode) return;
|
||||
const currentSelection = domEditSelectionRef.current;
|
||||
if (!currentSelection) return;
|
||||
let doc: Document | null = null;
|
||||
try {
|
||||
doc = previewIframe.contentDocument;
|
||||
} catch {
|
||||
return;
|
||||
}
|
||||
if (!doc) return;
|
||||
|
||||
const nextElement = findElementForSelection(doc, currentSelection, activeCompPath);
|
||||
if (!nextElement) {
|
||||
applyDomSelection(null, { revealPanel: false });
|
||||
return;
|
||||
}
|
||||
|
||||
const nextSelection = buildDomSelectionFromTarget(nextElement);
|
||||
if (nextSelection) {
|
||||
applyDomSelection(nextSelection, { revealPanel: false, preserveGroup: true });
|
||||
}
|
||||
};
|
||||
|
||||
syncPreviewHistoryHotkey(previewIframe);
|
||||
void (async () => {
|
||||
await applyStudioManualEditsToPreviewRef.current(previewIframe);
|
||||
await applyStudioMotionToPreviewRef.current(previewIframe);
|
||||
})();
|
||||
syncSelectionFromDocument();
|
||||
refreshPreviewDocumentVersion();
|
||||
|
||||
const handleLoad = () => {
|
||||
syncPreviewHistoryHotkey(previewIframe);
|
||||
void (async () => {
|
||||
await applyStudioManualEditsToPreviewRef.current(previewIframe);
|
||||
await applyStudioMotionToPreviewRef.current(previewIframe);
|
||||
})();
|
||||
syncSelectionFromDocument();
|
||||
refreshPreviewDocumentVersion();
|
||||
};
|
||||
|
||||
previewIframe.addEventListener("load", handleLoad);
|
||||
return () => {
|
||||
previewIframe.removeEventListener("load", handleLoad);
|
||||
};
|
||||
}, [
|
||||
activeCompPath,
|
||||
applyDomSelection,
|
||||
buildDomSelectionFromTarget,
|
||||
captionEditMode,
|
||||
domEditSelectionRef,
|
||||
previewIframe,
|
||||
refreshPreviewDocumentVersion,
|
||||
syncPreviewHistoryHotkey,
|
||||
applyStudioManualEditsToPreviewRef,
|
||||
applyStudioMotionToPreviewRef,
|
||||
]);
|
||||
|
||||
return {
|
||||
// State
|
||||
domEditSelection,
|
||||
domEditGroupSelections,
|
||||
domEditHoverSelection,
|
||||
agentModalOpen,
|
||||
agentModalAnchorPoint,
|
||||
copiedAgentPrompt,
|
||||
agentPromptSelectionContext,
|
||||
|
||||
// Refs
|
||||
domEditSelectionRef,
|
||||
|
||||
// Callbacks
|
||||
handleTimelineElementSelect,
|
||||
handlePreviewCanvasMouseDown,
|
||||
handlePreviewCanvasPointerMove,
|
||||
handlePreviewCanvasPointerLeave,
|
||||
applyDomSelection,
|
||||
clearDomSelection,
|
||||
handleDomStyleCommit,
|
||||
handleDomPathOffsetCommit,
|
||||
handleDomGroupPathOffsetCommit,
|
||||
handleDomBoxSizeCommit,
|
||||
handleDomRotationCommit,
|
||||
handleDomManualEditsReset,
|
||||
handleDomMotionCommit,
|
||||
handleDomMotionClear,
|
||||
handleDomTextCommit,
|
||||
handleDomTextFieldStyleCommit,
|
||||
handleDomAddTextField,
|
||||
handleDomRemoveTextField,
|
||||
handleAskAgent,
|
||||
handleAgentModalSubmit,
|
||||
handleBlockedDomMove,
|
||||
handleDomManualDragStart,
|
||||
handleDomEditElementDelete,
|
||||
buildDomSelectionForTimelineElement,
|
||||
resolveImportedFontAsset,
|
||||
setAgentModalOpen,
|
||||
setAgentPromptSelectionContext,
|
||||
setAgentModalAnchorPoint,
|
||||
};
|
||||
}
|
||||
@@ -0,0 +1,330 @@
|
||||
import { useCallback, useRef } from "react";
|
||||
import type { PatchOperation } from "../utils/sourcePatcher";
|
||||
import {
|
||||
isImageBackgroundValue,
|
||||
isManualGeometryStyleProperty,
|
||||
normalizeDomEditStyleValue,
|
||||
} from "../utils/studioHelpers";
|
||||
import {
|
||||
injectPreviewGoogleFont,
|
||||
injectPreviewImportedFont,
|
||||
ensureImportedFontFace,
|
||||
} from "../utils/studioFontHelpers";
|
||||
import {
|
||||
buildDomEditStylePatchOperation,
|
||||
buildDomEditTextPatchOperation,
|
||||
findElementForSelection,
|
||||
isTextEditableSelection,
|
||||
serializeDomEditTextFields,
|
||||
buildDefaultDomEditTextField,
|
||||
type DomEditTextField,
|
||||
type DomEditSelection,
|
||||
} from "../components/editor/domEditing";
|
||||
import type { ImportedFontAsset } from "../components/editor/fontAssets";
|
||||
import type { PersistDomEditOperations } from "./useDomEditCommits";
|
||||
|
||||
// ── Types ──
|
||||
|
||||
export interface UseDomEditTextCommitsParams {
|
||||
activeCompPath: string | null;
|
||||
previewIframeRef: React.MutableRefObject<HTMLIFrameElement | null>;
|
||||
domEditSelection: DomEditSelection | null;
|
||||
applyDomSelection: (
|
||||
selection: DomEditSelection | null,
|
||||
options?: { revealPanel?: boolean; additive?: boolean; preserveGroup?: boolean },
|
||||
) => void;
|
||||
refreshDomEditSelectionFromPreview: (selection: DomEditSelection) => void;
|
||||
buildDomSelectionFromTarget: (
|
||||
target: HTMLElement,
|
||||
options?: { preferClipAncestor?: boolean },
|
||||
) => DomEditSelection | null;
|
||||
persistDomEditOperations: PersistDomEditOperations;
|
||||
resolveImportedFontAsset: (fontFamilyValue: string) => ImportedFontAsset | null;
|
||||
}
|
||||
|
||||
// ── Hook ──
|
||||
|
||||
export function useDomEditTextCommits({
|
||||
activeCompPath,
|
||||
previewIframeRef,
|
||||
domEditSelection,
|
||||
applyDomSelection,
|
||||
refreshDomEditSelectionFromPreview,
|
||||
buildDomSelectionFromTarget,
|
||||
persistDomEditOperations,
|
||||
resolveImportedFontAsset,
|
||||
}: UseDomEditTextCommitsParams) {
|
||||
const domTextCommitVersionRef = useRef(0);
|
||||
|
||||
const handleDomStyleCommit = useCallback(
|
||||
async (property: string, value: string) => {
|
||||
if (!domEditSelection) return;
|
||||
if (isManualGeometryStyleProperty(property)) return;
|
||||
if (!domEditSelection.capabilities.canEditStyles) return;
|
||||
const importedFont = property === "font-family" ? resolveImportedFontAsset(value) : null;
|
||||
const iframe = previewIframeRef.current;
|
||||
const doc = iframe?.contentDocument;
|
||||
if (doc) {
|
||||
const el = findElementForSelection(doc, domEditSelection, activeCompPath);
|
||||
if (el) {
|
||||
el.style.setProperty(property, normalizeDomEditStyleValue(property, value));
|
||||
if (property === "font-family") {
|
||||
injectPreviewGoogleFont(doc, value);
|
||||
if (importedFont) injectPreviewImportedFont(doc, importedFont);
|
||||
}
|
||||
if (property === "background-image" && isImageBackgroundValue(value)) {
|
||||
el.style.setProperty("background-position", "center");
|
||||
el.style.setProperty("background-repeat", "no-repeat");
|
||||
el.style.setProperty("background-size", "contain");
|
||||
}
|
||||
}
|
||||
}
|
||||
const operations: PatchOperation[] = [
|
||||
buildDomEditStylePatchOperation(property, normalizeDomEditStyleValue(property, value)),
|
||||
];
|
||||
if (property === "background-image" && isImageBackgroundValue(value)) {
|
||||
operations.push(
|
||||
buildDomEditStylePatchOperation("background-position", "center"),
|
||||
buildDomEditStylePatchOperation("background-repeat", "no-repeat"),
|
||||
buildDomEditStylePatchOperation("background-size", "contain"),
|
||||
);
|
||||
}
|
||||
const skipRefresh = property !== "z-index";
|
||||
try {
|
||||
await persistDomEditOperations(domEditSelection, operations, {
|
||||
label: "Edit layer style",
|
||||
skipRefresh,
|
||||
prepareContent: importedFont
|
||||
? (html, sourceFile) => ensureImportedFontFace(html, importedFont, sourceFile)
|
||||
: undefined,
|
||||
});
|
||||
} catch (err) {
|
||||
console.warn("[Studio] Style persist failed:", err instanceof Error ? err.message : err);
|
||||
}
|
||||
refreshDomEditSelectionFromPreview(domEditSelection);
|
||||
},
|
||||
[
|
||||
activeCompPath,
|
||||
domEditSelection,
|
||||
persistDomEditOperations,
|
||||
refreshDomEditSelectionFromPreview,
|
||||
resolveImportedFontAsset,
|
||||
previewIframeRef,
|
||||
],
|
||||
);
|
||||
|
||||
const handleDomTextCommit = useCallback(
|
||||
async (value: string, fieldKey?: string) => {
|
||||
if (!domEditSelection) return;
|
||||
if (!isTextEditableSelection(domEditSelection)) return;
|
||||
const commitVersion = domTextCommitVersionRef.current + 1;
|
||||
domTextCommitVersionRef.current = commitVersion;
|
||||
const nextTextFields =
|
||||
domEditSelection.textFields.length > 0
|
||||
? domEditSelection.textFields.map((field) =>
|
||||
field.key === fieldKey ? { ...field, value } : field,
|
||||
)
|
||||
: [];
|
||||
const nextContent =
|
||||
nextTextFields.length > 1 || nextTextFields.some((field) => field.source === "child")
|
||||
? serializeDomEditTextFields(nextTextFields)
|
||||
: value;
|
||||
const iframe = previewIframeRef.current;
|
||||
const doc = iframe?.contentDocument;
|
||||
if (doc) {
|
||||
const el = findElementForSelection(doc, domEditSelection, activeCompPath);
|
||||
if (el) {
|
||||
if (
|
||||
nextTextFields.length > 1 ||
|
||||
nextTextFields.some((field) => field.source === "child")
|
||||
) {
|
||||
el.innerHTML = nextContent;
|
||||
} else {
|
||||
el.textContent = value;
|
||||
}
|
||||
}
|
||||
}
|
||||
await persistDomEditOperations(
|
||||
domEditSelection,
|
||||
[buildDomEditTextPatchOperation(nextContent)],
|
||||
{
|
||||
label: "Edit text",
|
||||
skipRefresh: true,
|
||||
shouldSave: () => domTextCommitVersionRef.current === commitVersion,
|
||||
},
|
||||
);
|
||||
if (domTextCommitVersionRef.current !== commitVersion) return;
|
||||
|
||||
if (doc) {
|
||||
const refreshed = findElementForSelection(doc, domEditSelection, activeCompPath);
|
||||
if (refreshed) {
|
||||
const nextSelection = buildDomSelectionFromTarget(refreshed);
|
||||
if (nextSelection) {
|
||||
applyDomSelection(nextSelection, { revealPanel: false, preserveGroup: true });
|
||||
}
|
||||
}
|
||||
}
|
||||
},
|
||||
[
|
||||
activeCompPath,
|
||||
applyDomSelection,
|
||||
buildDomSelectionFromTarget,
|
||||
domEditSelection,
|
||||
persistDomEditOperations,
|
||||
previewIframeRef,
|
||||
],
|
||||
);
|
||||
|
||||
const commitDomTextFields = useCallback(
|
||||
async (
|
||||
selection: DomEditSelection,
|
||||
nextTextFields: DomEditTextField[],
|
||||
options?: { importedFont?: ImportedFontAsset | null },
|
||||
) => {
|
||||
const nextContent =
|
||||
nextTextFields.length > 1 || nextTextFields.some((field) => field.source === "child")
|
||||
? serializeDomEditTextFields(nextTextFields)
|
||||
: (nextTextFields[0]?.value ?? "");
|
||||
|
||||
const iframe = previewIframeRef.current;
|
||||
const doc = iframe?.contentDocument;
|
||||
if (doc) {
|
||||
const el = findElementForSelection(doc, selection, activeCompPath);
|
||||
if (el) {
|
||||
if (
|
||||
nextTextFields.length > 1 ||
|
||||
nextTextFields.some((field) => field.source === "child")
|
||||
) {
|
||||
el.innerHTML = nextContent;
|
||||
} else {
|
||||
el.textContent = nextContent;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
const importedFont = options?.importedFont ?? null;
|
||||
await persistDomEditOperations(selection, [buildDomEditTextPatchOperation(nextContent)], {
|
||||
label: "Edit text",
|
||||
skipRefresh: true,
|
||||
prepareContent: importedFont
|
||||
? (html, sourceFile) => ensureImportedFontFace(html, importedFont, sourceFile)
|
||||
: undefined,
|
||||
});
|
||||
|
||||
if (doc) {
|
||||
const refreshed = findElementForSelection(doc, selection, activeCompPath);
|
||||
if (refreshed) {
|
||||
const nextSelection = buildDomSelectionFromTarget(refreshed);
|
||||
if (nextSelection) {
|
||||
applyDomSelection(nextSelection, { revealPanel: false, preserveGroup: true });
|
||||
}
|
||||
}
|
||||
}
|
||||
},
|
||||
[
|
||||
activeCompPath,
|
||||
applyDomSelection,
|
||||
buildDomSelectionFromTarget,
|
||||
persistDomEditOperations,
|
||||
previewIframeRef,
|
||||
],
|
||||
);
|
||||
|
||||
const handleDomTextFieldStyleCommit = useCallback(
|
||||
async (fieldKey: string, property: string, value: string) => {
|
||||
if (!domEditSelection) return;
|
||||
const field = domEditSelection.textFields.find((entry) => entry.key === fieldKey);
|
||||
if (!field) return;
|
||||
|
||||
if (field.source === "self") {
|
||||
await handleDomStyleCommit(property, value);
|
||||
return;
|
||||
}
|
||||
|
||||
const normalizedValue = normalizeDomEditStyleValue(property, value);
|
||||
const importedFont = property === "font-family" ? resolveImportedFontAsset(value) : null;
|
||||
if (property === "font-family") {
|
||||
const doc = previewIframeRef.current?.contentDocument;
|
||||
if (doc) {
|
||||
injectPreviewGoogleFont(doc, normalizedValue);
|
||||
if (importedFont) injectPreviewImportedFont(doc, importedFont);
|
||||
}
|
||||
}
|
||||
const nextTextFields = domEditSelection.textFields.map((entry) =>
|
||||
entry.key === fieldKey
|
||||
? {
|
||||
...entry,
|
||||
inlineStyles: {
|
||||
...entry.inlineStyles,
|
||||
[property]: normalizedValue,
|
||||
},
|
||||
computedStyles: {
|
||||
...entry.computedStyles,
|
||||
[property]: normalizedValue,
|
||||
},
|
||||
}
|
||||
: entry,
|
||||
);
|
||||
|
||||
await commitDomTextFields(domEditSelection, nextTextFields, { importedFont });
|
||||
},
|
||||
[
|
||||
commitDomTextFields,
|
||||
domEditSelection,
|
||||
handleDomStyleCommit,
|
||||
resolveImportedFontAsset,
|
||||
previewIframeRef,
|
||||
],
|
||||
);
|
||||
|
||||
const handleDomAddTextField = useCallback(
|
||||
async (afterFieldKey?: string) => {
|
||||
if (!domEditSelection) return null;
|
||||
if (!domEditSelection.textFields.some((field) => field.source === "child")) return null;
|
||||
|
||||
const insertionIndex = domEditSelection.textFields.findIndex(
|
||||
(field) => field.key === afterFieldKey,
|
||||
);
|
||||
const baseField =
|
||||
domEditSelection.textFields[insertionIndex >= 0 ? insertionIndex : 0] ??
|
||||
domEditSelection.textFields[0];
|
||||
const nextField = buildDefaultDomEditTextField(baseField);
|
||||
const nextTextFields = [...domEditSelection.textFields];
|
||||
nextTextFields.splice(
|
||||
insertionIndex >= 0 ? insertionIndex + 1 : nextTextFields.length,
|
||||
0,
|
||||
nextField,
|
||||
);
|
||||
|
||||
await commitDomTextFields(domEditSelection, nextTextFields);
|
||||
return nextField.key;
|
||||
},
|
||||
[commitDomTextFields, domEditSelection],
|
||||
);
|
||||
|
||||
const handleDomRemoveTextField = useCallback(
|
||||
async (fieldKey: string) => {
|
||||
if (!domEditSelection) return;
|
||||
const field = domEditSelection.textFields.find((entry) => entry.key === fieldKey);
|
||||
if (!field) return;
|
||||
|
||||
if (field.source === "self") {
|
||||
await handleDomTextCommit("", fieldKey);
|
||||
return;
|
||||
}
|
||||
|
||||
const nextTextFields = domEditSelection.textFields.filter((entry) => entry.key !== fieldKey);
|
||||
await commitDomTextFields(domEditSelection, nextTextFields);
|
||||
},
|
||||
[commitDomTextFields, domEditSelection, handleDomTextCommit],
|
||||
);
|
||||
|
||||
return {
|
||||
handleDomStyleCommit,
|
||||
handleDomTextCommit,
|
||||
commitDomTextFields,
|
||||
handleDomTextFieldStyleCommit,
|
||||
handleDomAddTextField,
|
||||
handleDomRemoveTextField,
|
||||
};
|
||||
}
|
||||
@@ -0,0 +1,398 @@
|
||||
import { useState, useCallback, useRef, useEffect } from "react";
|
||||
import type { TimelineElement } from "../player";
|
||||
import { getPreviewTargetFromPointer } from "../utils/studioPreviewHelpers";
|
||||
import { findMatchingTimelineElementId, type RightPanelTab } from "../utils/studioHelpers";
|
||||
import {
|
||||
domEditSelectionsTargetSame,
|
||||
domEditSelectionInGroup,
|
||||
toggleDomEditGroupSelection,
|
||||
replaceDomEditGroupSelection,
|
||||
seedDomEditGroupWithSelection,
|
||||
} from "../utils/domEditHelpers";
|
||||
import { STUDIO_INSPECTOR_PANELS_ENABLED } from "../components/editor/manualEditingAvailability";
|
||||
import {
|
||||
findElementForSelection,
|
||||
findElementForTimelineElement,
|
||||
resolveDomEditSelection,
|
||||
type DomEditSelection,
|
||||
} from "../components/editor/domEditing";
|
||||
|
||||
// ── Types ──
|
||||
|
||||
export interface UseDomSelectionParams {
|
||||
projectId: string | null;
|
||||
activeCompPath: string | null;
|
||||
isMasterView: boolean;
|
||||
compIdToSrc: Map<string, string>;
|
||||
captionEditMode: boolean;
|
||||
previewIframeRef: React.MutableRefObject<HTMLIFrameElement | null>;
|
||||
timelineElements: TimelineElement[];
|
||||
setSelectedTimelineElementId: (id: string | null) => void;
|
||||
setRightCollapsed: (collapsed: boolean) => void;
|
||||
setRightPanelTab: (tab: RightPanelTab) => void;
|
||||
previewIframe: HTMLIFrameElement | null;
|
||||
refreshKey: number;
|
||||
rightPanelTab: RightPanelTab;
|
||||
}
|
||||
|
||||
export interface UseDomSelectionReturn {
|
||||
// State
|
||||
domEditSelection: DomEditSelection | null;
|
||||
domEditGroupSelections: DomEditSelection[];
|
||||
domEditHoverSelection: DomEditSelection | null;
|
||||
// Refs
|
||||
domEditSelectionRef: React.MutableRefObject<DomEditSelection | null>;
|
||||
domEditGroupSelectionsRef: React.MutableRefObject<DomEditSelection[]>;
|
||||
domEditHoverSelectionRef: React.MutableRefObject<DomEditSelection | null>;
|
||||
// State setters (needed by useDomEditSession for agent-prompt reset flows)
|
||||
setDomEditSelection: React.Dispatch<React.SetStateAction<DomEditSelection | null>>;
|
||||
setDomEditGroupSelections: React.Dispatch<React.SetStateAction<DomEditSelection[]>>;
|
||||
// Callbacks
|
||||
applyDomSelection: (
|
||||
selection: DomEditSelection | null,
|
||||
options?: { revealPanel?: boolean; additive?: boolean; preserveGroup?: boolean },
|
||||
) => void;
|
||||
clearDomSelection: () => void;
|
||||
buildDomSelectionFromTarget: (
|
||||
target: HTMLElement,
|
||||
options?: { preferClipAncestor?: boolean },
|
||||
) => DomEditSelection | null;
|
||||
resolveDomSelectionFromPreviewPoint: (
|
||||
clientX: number,
|
||||
clientY: number,
|
||||
options?: { preferClipAncestor?: boolean },
|
||||
) => DomEditSelection | null;
|
||||
updateDomEditHoverSelection: (selection: DomEditSelection | null) => void;
|
||||
buildDomSelectionForTimelineElement: (element: TimelineElement) => DomEditSelection | null;
|
||||
handleTimelineElementSelect: (element: TimelineElement | null) => void;
|
||||
refreshDomEditSelectionFromPreview: (selection: DomEditSelection) => void;
|
||||
refreshDomEditGroupSelectionsFromPreview: (selections: DomEditSelection[]) => void;
|
||||
}
|
||||
|
||||
// ── Hook ──
|
||||
|
||||
export function useDomSelection({
|
||||
projectId,
|
||||
activeCompPath,
|
||||
isMasterView,
|
||||
compIdToSrc,
|
||||
captionEditMode,
|
||||
previewIframeRef,
|
||||
timelineElements,
|
||||
setSelectedTimelineElementId,
|
||||
setRightCollapsed,
|
||||
setRightPanelTab,
|
||||
previewIframe,
|
||||
refreshKey,
|
||||
rightPanelTab,
|
||||
}: UseDomSelectionParams): UseDomSelectionReturn {
|
||||
// ── State ──
|
||||
|
||||
const [domEditSelection, setDomEditSelection] = useState<DomEditSelection | null>(null);
|
||||
const [domEditGroupSelections, setDomEditGroupSelections] = useState<DomEditSelection[]>([]);
|
||||
const [domEditHoverSelection, setDomEditHoverSelection] = useState<DomEditSelection | null>(null);
|
||||
|
||||
// ── Refs ──
|
||||
|
||||
const domEditSelectionRef = useRef<DomEditSelection | null>(domEditSelection);
|
||||
const domEditGroupSelectionsRef = useRef<DomEditSelection[]>(domEditGroupSelections);
|
||||
const domEditHoverSelectionRef = useRef<DomEditSelection | null>(domEditHoverSelection);
|
||||
|
||||
// Keep refs in sync with state
|
||||
domEditSelectionRef.current = domEditSelection;
|
||||
domEditGroupSelectionsRef.current = domEditGroupSelections;
|
||||
domEditHoverSelectionRef.current = domEditHoverSelection;
|
||||
|
||||
// ── Callbacks ──
|
||||
|
||||
const applyDomSelection = useCallback(
|
||||
(
|
||||
selection: DomEditSelection | null,
|
||||
options?: { revealPanel?: boolean; additive?: boolean; preserveGroup?: boolean },
|
||||
) => {
|
||||
if (!selection) {
|
||||
domEditSelectionRef.current = null;
|
||||
domEditGroupSelectionsRef.current = [];
|
||||
setDomEditSelection(null);
|
||||
setDomEditGroupSelections([]);
|
||||
setSelectedTimelineElementId(null);
|
||||
return;
|
||||
}
|
||||
if (!STUDIO_INSPECTOR_PANELS_ENABLED) {
|
||||
domEditSelectionRef.current = null;
|
||||
domEditGroupSelectionsRef.current = [];
|
||||
setDomEditSelection(null);
|
||||
setDomEditGroupSelections([]);
|
||||
setSelectedTimelineElementId(null);
|
||||
return;
|
||||
}
|
||||
|
||||
const isAdditiveSelection = Boolean(options?.additive);
|
||||
const currentSelection = domEditSelectionRef.current;
|
||||
const previousGroup = domEditGroupSelectionsRef.current;
|
||||
const currentGroup = isAdditiveSelection
|
||||
? seedDomEditGroupWithSelection(previousGroup, currentSelection)
|
||||
: previousGroup;
|
||||
const wasInGroup = domEditSelectionInGroup(currentGroup, selection);
|
||||
const nextGroup = options?.preserveGroup
|
||||
? replaceDomEditGroupSelection(currentGroup, selection)
|
||||
: isAdditiveSelection
|
||||
? toggleDomEditGroupSelection(currentGroup, selection)
|
||||
: [selection];
|
||||
const nextSelection = options?.preserveGroup
|
||||
? selection
|
||||
: isAdditiveSelection && wasInGroup
|
||||
? domEditSelectionsTargetSame(currentSelection, selection)
|
||||
? (nextGroup[0] ?? null)
|
||||
: domEditSelectionInGroup(nextGroup, currentSelection)
|
||||
? currentSelection
|
||||
: (nextGroup[0] ?? null)
|
||||
: selection;
|
||||
|
||||
domEditSelectionRef.current = nextSelection;
|
||||
domEditGroupSelectionsRef.current = nextGroup;
|
||||
setDomEditSelection(nextSelection);
|
||||
setDomEditGroupSelections(nextGroup);
|
||||
|
||||
if (nextSelection) {
|
||||
if (options?.revealPanel !== false) {
|
||||
setRightCollapsed(false);
|
||||
setRightPanelTab("design");
|
||||
}
|
||||
const nextSelectedTimelineId = findMatchingTimelineElementId(
|
||||
nextSelection,
|
||||
timelineElements,
|
||||
);
|
||||
setSelectedTimelineElementId(nextSelectedTimelineId);
|
||||
return;
|
||||
}
|
||||
|
||||
setSelectedTimelineElementId(null);
|
||||
},
|
||||
[setSelectedTimelineElementId, timelineElements, setRightCollapsed, setRightPanelTab],
|
||||
);
|
||||
|
||||
const clearDomSelection = useCallback(() => {
|
||||
applyDomSelection(null, { revealPanel: false });
|
||||
}, [applyDomSelection]);
|
||||
|
||||
const buildDomSelectionFromTarget = useCallback(
|
||||
(target: HTMLElement, options?: { preferClipAncestor?: boolean }) => {
|
||||
return resolveDomEditSelection(target, {
|
||||
activeCompositionPath: activeCompPath,
|
||||
isMasterView,
|
||||
preferClipAncestor: options?.preferClipAncestor,
|
||||
});
|
||||
},
|
||||
[activeCompPath, isMasterView],
|
||||
);
|
||||
|
||||
const resolveDomSelectionFromPreviewPoint = useCallback(
|
||||
(clientX: number, clientY: number, options?: { preferClipAncestor?: boolean }) => {
|
||||
const iframe = previewIframeRef.current;
|
||||
if (!iframe || captionEditMode) return null;
|
||||
const target = getPreviewTargetFromPointer(iframe, clientX, clientY, activeCompPath);
|
||||
if (!target) return null;
|
||||
return buildDomSelectionFromTarget(target, {
|
||||
preferClipAncestor: options?.preferClipAncestor,
|
||||
});
|
||||
},
|
||||
[activeCompPath, buildDomSelectionFromTarget, captionEditMode, previewIframeRef],
|
||||
);
|
||||
|
||||
const updateDomEditHoverSelection = useCallback((selection: DomEditSelection | null) => {
|
||||
if (domEditSelectionsTargetSame(domEditHoverSelectionRef.current, selection)) return;
|
||||
domEditHoverSelectionRef.current = selection;
|
||||
setDomEditHoverSelection(selection);
|
||||
}, []);
|
||||
|
||||
const buildDomSelectionForTimelineElement = useCallback(
|
||||
(element: TimelineElement): DomEditSelection | null => {
|
||||
const iframe = previewIframeRef.current;
|
||||
let doc: Document | null = null;
|
||||
try {
|
||||
doc = iframe?.contentDocument ?? null;
|
||||
} catch {
|
||||
return null;
|
||||
}
|
||||
if (!doc) return null;
|
||||
|
||||
const targetElement = findElementForTimelineElement(doc, element, {
|
||||
activeCompositionPath: activeCompPath,
|
||||
compIdToSrc,
|
||||
isMasterView,
|
||||
});
|
||||
return targetElement
|
||||
? buildDomSelectionFromTarget(targetElement, { preferClipAncestor: false })
|
||||
: null;
|
||||
},
|
||||
[activeCompPath, buildDomSelectionFromTarget, compIdToSrc, isMasterView, previewIframeRef],
|
||||
);
|
||||
|
||||
const handleTimelineElementSelect = useCallback(
|
||||
(element: TimelineElement | null) => {
|
||||
if (!STUDIO_INSPECTOR_PANELS_ENABLED) return;
|
||||
if (!element) {
|
||||
applyDomSelection(null, { revealPanel: false });
|
||||
return;
|
||||
}
|
||||
|
||||
const selection = buildDomSelectionForTimelineElement(element);
|
||||
if (selection) applyDomSelection(selection);
|
||||
},
|
||||
[applyDomSelection, buildDomSelectionForTimelineElement],
|
||||
);
|
||||
|
||||
const refreshDomEditSelectionFromPreview = useCallback(
|
||||
(selection: DomEditSelection) => {
|
||||
const iframe = previewIframeRef.current;
|
||||
let doc: Document | null = null;
|
||||
try {
|
||||
doc = iframe?.contentDocument ?? null;
|
||||
} catch {
|
||||
return;
|
||||
}
|
||||
if (!doc) return;
|
||||
|
||||
const element = findElementForSelection(doc, selection, activeCompPath);
|
||||
if (!element) return;
|
||||
|
||||
const nextSelection = buildDomSelectionFromTarget(element);
|
||||
if (nextSelection) {
|
||||
applyDomSelection(nextSelection, { revealPanel: false, preserveGroup: true });
|
||||
}
|
||||
},
|
||||
[activeCompPath, applyDomSelection, buildDomSelectionFromTarget, previewIframeRef],
|
||||
);
|
||||
|
||||
const refreshDomEditGroupSelectionsFromPreview = useCallback(
|
||||
(selections: DomEditSelection[]) => {
|
||||
const iframe = previewIframeRef.current;
|
||||
let doc: Document | null = null;
|
||||
try {
|
||||
doc = iframe?.contentDocument ?? null;
|
||||
} catch {
|
||||
return;
|
||||
}
|
||||
if (!doc) return;
|
||||
|
||||
const nextGroup: DomEditSelection[] = [];
|
||||
for (const selection of selections) {
|
||||
const element = findElementForSelection(doc, selection, activeCompPath);
|
||||
if (!element) continue;
|
||||
const nextSelection = buildDomSelectionFromTarget(element);
|
||||
if (nextSelection) nextGroup.push(nextSelection);
|
||||
}
|
||||
if (nextGroup.length === 0) return;
|
||||
|
||||
const currentSelection = domEditSelectionRef.current;
|
||||
const nextSelection =
|
||||
nextGroup.find((selection) => domEditSelectionsTargetSame(selection, currentSelection)) ??
|
||||
nextGroup[0] ??
|
||||
null;
|
||||
|
||||
domEditSelectionRef.current = nextSelection;
|
||||
domEditGroupSelectionsRef.current = nextGroup;
|
||||
setDomEditSelection(nextSelection);
|
||||
setDomEditGroupSelections(nextGroup);
|
||||
|
||||
if (nextSelection) {
|
||||
setSelectedTimelineElementId(
|
||||
findMatchingTimelineElementId(nextSelection, timelineElements),
|
||||
);
|
||||
} else {
|
||||
setSelectedTimelineElementId(null);
|
||||
}
|
||||
},
|
||||
[
|
||||
activeCompPath,
|
||||
buildDomSelectionFromTarget,
|
||||
setSelectedTimelineElementId,
|
||||
timelineElements,
|
||||
previewIframeRef,
|
||||
],
|
||||
);
|
||||
|
||||
// ── Effects ──
|
||||
|
||||
// Clear hover on caption mode change
|
||||
// eslint-disable-next-line no-restricted-syntax
|
||||
useEffect(() => {
|
||||
if (captionEditMode) updateDomEditHoverSelection(null);
|
||||
}, [captionEditMode, updateDomEditHoverSelection]);
|
||||
|
||||
// Clear hover on composition/project/preview change
|
||||
// eslint-disable-next-line no-restricted-syntax
|
||||
useEffect(() => {
|
||||
updateDomEditHoverSelection(null);
|
||||
}, [activeCompPath, projectId, previewIframe, refreshKey, updateDomEditHoverSelection]);
|
||||
|
||||
// Clear hover when matching selection
|
||||
// eslint-disable-next-line no-restricted-syntax
|
||||
useEffect(() => {
|
||||
if (!domEditHoverSelection) return;
|
||||
const hoverMatchesSelection = domEditSelectionsTargetSame(
|
||||
domEditHoverSelection,
|
||||
domEditSelection,
|
||||
);
|
||||
const hoverMatchesGroup = domEditSelectionInGroup(
|
||||
domEditGroupSelections,
|
||||
domEditHoverSelection,
|
||||
);
|
||||
if (!hoverMatchesSelection && !hoverMatchesGroup) return;
|
||||
updateDomEditHoverSelection(null);
|
||||
}, [
|
||||
domEditGroupSelections,
|
||||
domEditHoverSelection,
|
||||
domEditSelection,
|
||||
updateDomEditHoverSelection,
|
||||
]);
|
||||
|
||||
// Clear hover when element disconnected
|
||||
// eslint-disable-next-line no-restricted-syntax
|
||||
useEffect(() => {
|
||||
if (!domEditHoverSelection) return;
|
||||
if (domEditHoverSelection.element.isConnected) return;
|
||||
updateDomEditHoverSelection(null);
|
||||
}, [domEditHoverSelection, updateDomEditHoverSelection]);
|
||||
|
||||
// Clear selection on caption mode change
|
||||
// eslint-disable-next-line no-restricted-syntax
|
||||
useEffect(() => {
|
||||
if (!captionEditMode) return;
|
||||
applyDomSelection(null, { revealPanel: false });
|
||||
}, [applyDomSelection, captionEditMode]);
|
||||
|
||||
// Disabled inspector effect
|
||||
// eslint-disable-next-line no-restricted-syntax
|
||||
useEffect(() => {
|
||||
if (STUDIO_INSPECTOR_PANELS_ENABLED) return;
|
||||
updateDomEditHoverSelection(null);
|
||||
applyDomSelection(null, { revealPanel: false });
|
||||
if (rightPanelTab !== "renders") setRightPanelTab("renders");
|
||||
}, [applyDomSelection, rightPanelTab, updateDomEditHoverSelection, setRightPanelTab]);
|
||||
|
||||
return {
|
||||
// State
|
||||
domEditSelection,
|
||||
domEditGroupSelections,
|
||||
domEditHoverSelection,
|
||||
// Refs
|
||||
domEditSelectionRef,
|
||||
domEditGroupSelectionsRef,
|
||||
domEditHoverSelectionRef,
|
||||
// State setters
|
||||
setDomEditSelection,
|
||||
setDomEditGroupSelections,
|
||||
// Callbacks
|
||||
applyDomSelection,
|
||||
clearDomSelection,
|
||||
buildDomSelectionFromTarget,
|
||||
resolveDomSelectionFromPreviewPoint,
|
||||
updateDomEditHoverSelection,
|
||||
buildDomSelectionForTimelineElement,
|
||||
handleTimelineElementSelect,
|
||||
refreshDomEditSelectionFromPreview,
|
||||
refreshDomEditGroupSelectionsFromPreview,
|
||||
};
|
||||
}
|
||||
@@ -0,0 +1,431 @@
|
||||
import { useState, useCallback, useRef, useEffect, useMemo } from "react";
|
||||
import type { EditingFile } from "../utils/studioHelpers";
|
||||
import { FONT_EXT, isMediaFile } from "../utils/mediaTypes";
|
||||
import { fontFamilyFromAssetPath, type ImportedFontAsset } from "../components/editor/fontAssets";
|
||||
import { saveProjectFilesWithHistory } from "../utils/studioFileHistory";
|
||||
import type { EditHistoryKind } from "../utils/editHistory";
|
||||
|
||||
// ── Types ──
|
||||
|
||||
interface RecordEditInput {
|
||||
label: string;
|
||||
kind: EditHistoryKind;
|
||||
coalesceKey?: string;
|
||||
files: Record<string, { before: string; after: string }>;
|
||||
}
|
||||
|
||||
interface UseFileManagerOptions {
|
||||
projectId: string | null;
|
||||
showToast: (message: string, tone?: "error" | "info") => void;
|
||||
recordEdit: (input: RecordEditInput) => Promise<void>;
|
||||
domEditSaveTimestampRef: React.MutableRefObject<number>;
|
||||
setRefreshKey: React.Dispatch<React.SetStateAction<number>>;
|
||||
}
|
||||
|
||||
// ── Hook ──
|
||||
|
||||
export function useFileManager({
|
||||
projectId,
|
||||
showToast,
|
||||
recordEdit,
|
||||
domEditSaveTimestampRef,
|
||||
setRefreshKey,
|
||||
}: UseFileManagerOptions) {
|
||||
// ── State ──
|
||||
|
||||
const [editingFile, setEditingFile] = useState<EditingFile | null>(null);
|
||||
const [projectDir, setProjectDir] = useState<string | null>(null);
|
||||
const [fileTree, setFileTree] = useState<string[]>([]);
|
||||
|
||||
// ── Refs ──
|
||||
|
||||
const editingPathRef = useRef(editingFile?.path);
|
||||
editingPathRef.current = editingFile?.path;
|
||||
|
||||
const projectIdRef = useRef(projectId);
|
||||
projectIdRef.current = projectId;
|
||||
|
||||
const saveTimerRef = useRef<ReturnType<typeof setTimeout> | null>(null);
|
||||
const refreshTimerRef = useRef<ReturnType<typeof setTimeout> | null>(null);
|
||||
const importedFontAssetsRef = useRef<ImportedFontAsset[]>([]);
|
||||
|
||||
// ── Load file tree when projectId changes ──
|
||||
|
||||
// eslint-disable-next-line no-restricted-syntax
|
||||
useEffect(() => {
|
||||
if (!projectId) return;
|
||||
let cancelled = false;
|
||||
fetch(`/api/projects/${projectId}`)
|
||||
.then((r) => r.json())
|
||||
.then((data: { files?: string[]; dir?: string }) => {
|
||||
if (!cancelled && data.files) setFileTree(data.files);
|
||||
if (!cancelled) setProjectDir(typeof data.dir === "string" ? data.dir : null);
|
||||
})
|
||||
.catch(() => {
|
||||
if (!cancelled) setProjectDir(null);
|
||||
});
|
||||
return () => {
|
||||
cancelled = true;
|
||||
};
|
||||
}, [projectId]);
|
||||
|
||||
// ── Core file I/O ──
|
||||
|
||||
const readProjectFile = useCallback(async (path: string): Promise<string> => {
|
||||
const pid = projectIdRef.current;
|
||||
if (!pid) throw new Error("No active project");
|
||||
const response = await fetch(`/api/projects/${pid}/files/${encodeURIComponent(path)}`);
|
||||
if (!response.ok) throw new Error(`Failed to read ${path}`);
|
||||
const data = (await response.json()) as { content?: string };
|
||||
if (typeof data.content !== "string") throw new Error(`Missing file contents for ${path}`);
|
||||
return data.content;
|
||||
}, []);
|
||||
|
||||
const writeProjectFile = useCallback(async (path: string, content: string): Promise<void> => {
|
||||
const pid = projectIdRef.current;
|
||||
if (!pid) throw new Error("No active project");
|
||||
const response = await fetch(`/api/projects/${pid}/files/${encodeURIComponent(path)}`, {
|
||||
method: "PUT",
|
||||
headers: { "Content-Type": "text/plain" },
|
||||
body: content,
|
||||
});
|
||||
if (!response.ok) throw new Error(`Failed to save ${path}`);
|
||||
if (editingPathRef.current === path) {
|
||||
setEditingFile({ path, content });
|
||||
}
|
||||
}, []);
|
||||
|
||||
const readOptionalProjectFile = useCallback(async (path: string): Promise<string> => {
|
||||
const pid = projectIdRef.current;
|
||||
if (!pid) throw new Error("No active project");
|
||||
const response = await fetch(`/api/projects/${pid}/files/${encodeURIComponent(path)}`);
|
||||
if (response.status === 404) return "";
|
||||
if (!response.ok) throw new Error(`Failed to read ${path}`);
|
||||
const data = (await response.json()) as { content?: string };
|
||||
return typeof data.content === "string" ? data.content : "";
|
||||
}, []);
|
||||
|
||||
// ── File select ──
|
||||
|
||||
const handleFileSelect = useCallback((path: string) => {
|
||||
const pid = projectIdRef.current;
|
||||
if (!pid) return;
|
||||
// Skip fetching binary content for media files — just set the path for preview
|
||||
if (isMediaFile(path)) {
|
||||
setEditingFile({ path, content: null });
|
||||
return;
|
||||
}
|
||||
fetch(`/api/projects/${pid}/files/${encodeURIComponent(path)}`)
|
||||
.then((r) => r.json())
|
||||
.then((data: { content?: string }) => {
|
||||
if (data.content != null) {
|
||||
setEditingFile({ path, content: data.content });
|
||||
}
|
||||
})
|
||||
.catch(() => {});
|
||||
}, []);
|
||||
|
||||
// ── Content change (debounced save) ──
|
||||
|
||||
const handleContentChange = useCallback(
|
||||
(content: string) => {
|
||||
const pid = projectIdRef.current;
|
||||
if (!pid) return;
|
||||
const path = editingPathRef.current;
|
||||
if (!path) return;
|
||||
|
||||
// Debounce the server write (600ms)
|
||||
if (saveTimerRef.current) clearTimeout(saveTimerRef.current);
|
||||
saveTimerRef.current = setTimeout(() => {
|
||||
// Suppress the file-change watcher echo — the save callback triggers
|
||||
// its own refresh, so a second one from the watcher causes a double-reload
|
||||
// race that can leave the player in a non-playable state.
|
||||
domEditSaveTimestampRef.current = Date.now();
|
||||
saveProjectFilesWithHistory({
|
||||
projectId: pid,
|
||||
label: "Edit source",
|
||||
kind: "source",
|
||||
coalesceKey: `source:${path}`,
|
||||
files: { [path]: content },
|
||||
readFile: readProjectFile,
|
||||
writeFile: writeProjectFile,
|
||||
recordEdit,
|
||||
})
|
||||
.then(() => {
|
||||
if (refreshTimerRef.current) clearTimeout(refreshTimerRef.current);
|
||||
refreshTimerRef.current = setTimeout(() => setRefreshKey((k) => k + 1), 600);
|
||||
})
|
||||
.catch(() => {});
|
||||
}, 600);
|
||||
},
|
||||
[domEditSaveTimestampRef, readProjectFile, recordEdit, setRefreshKey, writeProjectFile],
|
||||
);
|
||||
|
||||
// ── File tree refresh ──
|
||||
|
||||
const refreshFileTree = useCallback(async () => {
|
||||
const pid = projectIdRef.current;
|
||||
if (!pid) return;
|
||||
const res = await fetch(`/api/projects/${pid}`);
|
||||
const data = await res.json();
|
||||
if (data.files) setFileTree(data.files);
|
||||
}, []);
|
||||
|
||||
// ── Upload ──
|
||||
|
||||
const uploadProjectFiles = useCallback(
|
||||
async (files: Iterable<File>, dir?: string): Promise<string[]> => {
|
||||
const pid = projectIdRef.current;
|
||||
const fileList = Array.from(files);
|
||||
if (!pid || fileList.length === 0) return [];
|
||||
|
||||
const formData = new FormData();
|
||||
for (const file of fileList) {
|
||||
formData.append("file", file);
|
||||
}
|
||||
|
||||
const qs = dir ? `?dir=${encodeURIComponent(dir)}` : "";
|
||||
try {
|
||||
const res = await fetch(`/api/projects/${pid}/upload${qs}`, {
|
||||
method: "POST",
|
||||
body: formData,
|
||||
});
|
||||
if (res.ok) {
|
||||
const data = await res.json();
|
||||
if (data.skipped?.length) {
|
||||
showToast(`Skipped (too large): ${data.skipped.join(", ")}`);
|
||||
}
|
||||
if (data.invalid?.length) {
|
||||
const names = data.invalid.map((entry: { name: string }) => entry.name).join(", ");
|
||||
showToast(`Unsupported media skipped: ${names}`);
|
||||
}
|
||||
await refreshFileTree();
|
||||
setRefreshKey((k) => k + 1);
|
||||
return Array.isArray(data.files) ? data.files : [];
|
||||
} else if (res.status === 413) {
|
||||
showToast("Upload rejected: payload too large");
|
||||
} else {
|
||||
showToast(`Upload failed (${res.status})`);
|
||||
}
|
||||
} catch {
|
||||
showToast("Upload failed: network error");
|
||||
}
|
||||
return [];
|
||||
},
|
||||
[refreshFileTree, setRefreshKey, showToast],
|
||||
);
|
||||
|
||||
// ── File management handlers ──
|
||||
|
||||
const handleCreateFile = useCallback(
|
||||
async (path: string) => {
|
||||
const pid = projectIdRef.current;
|
||||
if (!pid) return;
|
||||
let content = "";
|
||||
if (path.endsWith(".html")) {
|
||||
content =
|
||||
'<!DOCTYPE html>\n<html>\n<head>\n <meta charset="UTF-8">\n</head>\n<body>\n\n</body>\n</html>\n';
|
||||
}
|
||||
const res = await fetch(`/api/projects/${pid}/files/${encodeURIComponent(path)}`, {
|
||||
method: "POST",
|
||||
headers: { "Content-Type": "text/plain" },
|
||||
body: content,
|
||||
});
|
||||
if (res.ok) {
|
||||
await refreshFileTree();
|
||||
handleFileSelect(path);
|
||||
} else {
|
||||
const err = await res.json().catch(() => ({ error: "unknown" }));
|
||||
console.error(`Create file failed: ${err.error}`);
|
||||
}
|
||||
},
|
||||
[refreshFileTree, handleFileSelect],
|
||||
);
|
||||
|
||||
const handleCreateFolder = useCallback(
|
||||
async (path: string) => {
|
||||
const pid = projectIdRef.current;
|
||||
if (!pid) return;
|
||||
// Create a .gitkeep inside the folder so it appears in the tree
|
||||
const res = await fetch(
|
||||
`/api/projects/${pid}/files/${encodeURIComponent(path + "/.gitkeep")}`,
|
||||
{
|
||||
method: "POST",
|
||||
headers: { "Content-Type": "text/plain" },
|
||||
body: "",
|
||||
},
|
||||
);
|
||||
if (res.ok) {
|
||||
await refreshFileTree();
|
||||
} else {
|
||||
const err = await res.json().catch(() => ({ error: "unknown" }));
|
||||
console.error(`Create folder failed: ${err.error}`);
|
||||
}
|
||||
},
|
||||
[refreshFileTree],
|
||||
);
|
||||
|
||||
const handleDeleteFile = useCallback(
|
||||
async (path: string) => {
|
||||
const pid = projectIdRef.current;
|
||||
if (!pid) return;
|
||||
const res = await fetch(`/api/projects/${pid}/files/${encodeURIComponent(path)}`, {
|
||||
method: "DELETE",
|
||||
});
|
||||
if (res.ok) {
|
||||
if (editingPathRef.current === path) setEditingFile(null);
|
||||
await refreshFileTree();
|
||||
} else {
|
||||
const err = await res.json().catch(() => ({ error: "unknown" }));
|
||||
console.error(`Delete failed: ${err.error}`);
|
||||
}
|
||||
},
|
||||
[refreshFileTree],
|
||||
);
|
||||
|
||||
const handleRenameFile = useCallback(
|
||||
async (oldPath: string, newPath: string) => {
|
||||
const pid = projectIdRef.current;
|
||||
if (!pid) return;
|
||||
const res = await fetch(`/api/projects/${pid}/files/${encodeURIComponent(oldPath)}`, {
|
||||
method: "PATCH",
|
||||
headers: { "Content-Type": "application/json" },
|
||||
body: JSON.stringify({ newPath }),
|
||||
});
|
||||
if (res.ok) {
|
||||
if (editingPathRef.current === oldPath) {
|
||||
handleFileSelect(newPath);
|
||||
}
|
||||
await refreshFileTree();
|
||||
// Refresh preview — references in compositions may have been updated
|
||||
setRefreshKey((k) => k + 1);
|
||||
} else {
|
||||
const err = await res.json().catch(() => ({ error: "unknown" }));
|
||||
console.error(`Rename failed: ${err.error}`);
|
||||
}
|
||||
},
|
||||
[refreshFileTree, handleFileSelect, setRefreshKey],
|
||||
);
|
||||
|
||||
const handleDuplicateFile = useCallback(
|
||||
async (path: string) => {
|
||||
const pid = projectIdRef.current;
|
||||
if (!pid) return;
|
||||
const res = await fetch(`/api/projects/${pid}/duplicate-file`, {
|
||||
method: "POST",
|
||||
headers: { "Content-Type": "application/json" },
|
||||
body: JSON.stringify({ path }),
|
||||
});
|
||||
if (res.ok) {
|
||||
const data = await res.json();
|
||||
await refreshFileTree();
|
||||
if (data.path) handleFileSelect(data.path);
|
||||
} else {
|
||||
const err = await res.json().catch(() => ({ error: "unknown" }));
|
||||
console.error(`Duplicate failed: ${err.error}`);
|
||||
}
|
||||
},
|
||||
[refreshFileTree, handleFileSelect],
|
||||
);
|
||||
|
||||
const handleMoveFile = handleRenameFile;
|
||||
|
||||
const handleImportFiles = useCallback(
|
||||
async (files: FileList | File[], dir?: string) => {
|
||||
return uploadProjectFiles(Array.from(files), dir);
|
||||
},
|
||||
[uploadProjectFiles],
|
||||
);
|
||||
|
||||
const handleImportFonts = useCallback(
|
||||
async (files: FileList | File[]): Promise<ImportedFontAsset[]> => {
|
||||
const uploaded = await uploadProjectFiles(
|
||||
Array.from(files).filter((file) => FONT_EXT.test(file.name)),
|
||||
"assets/fonts",
|
||||
);
|
||||
const pid = projectIdRef.current;
|
||||
const imported = uploaded
|
||||
.filter((asset) => FONT_EXT.test(asset))
|
||||
.map((asset) => ({
|
||||
family: fontFamilyFromAssetPath(asset),
|
||||
path: asset,
|
||||
url: `/api/projects/${pid}/preview/${asset}`,
|
||||
}));
|
||||
importedFontAssetsRef.current = [
|
||||
...imported,
|
||||
...importedFontAssetsRef.current.filter(
|
||||
(existing) =>
|
||||
!imported.some((font) => font.family.toLowerCase() === existing.family.toLowerCase()),
|
||||
),
|
||||
];
|
||||
return imported;
|
||||
},
|
||||
[uploadProjectFiles],
|
||||
);
|
||||
|
||||
// ── Derived state ──
|
||||
|
||||
const compositions = useMemo(
|
||||
() => fileTree.filter((f) => f === "index.html" || f.startsWith("compositions/")),
|
||||
[fileTree],
|
||||
);
|
||||
|
||||
const assets = useMemo(
|
||||
() =>
|
||||
fileTree.filter((f) => !f.endsWith(".html") && !f.endsWith(".md") && !f.endsWith(".json")),
|
||||
[fileTree],
|
||||
);
|
||||
|
||||
const fontAssets = useMemo<ImportedFontAsset[]>(
|
||||
() =>
|
||||
assets
|
||||
.filter((asset) => FONT_EXT.test(asset))
|
||||
.map((asset) => ({
|
||||
family: fontFamilyFromAssetPath(asset),
|
||||
path: asset,
|
||||
url: `/api/projects/${projectId}/preview/${asset}`,
|
||||
})),
|
||||
[assets, projectId],
|
||||
);
|
||||
|
||||
// ── Return ──
|
||||
|
||||
return {
|
||||
// State
|
||||
editingFile,
|
||||
setEditingFile,
|
||||
projectDir,
|
||||
fileTree,
|
||||
setFileTree,
|
||||
|
||||
// Refs
|
||||
editingPathRef,
|
||||
projectIdRef,
|
||||
saveTimerRef,
|
||||
importedFontAssetsRef,
|
||||
|
||||
// Core I/O
|
||||
readProjectFile,
|
||||
writeProjectFile,
|
||||
readOptionalProjectFile,
|
||||
|
||||
// Callbacks
|
||||
handleFileSelect,
|
||||
handleContentChange,
|
||||
refreshFileTree,
|
||||
uploadProjectFiles,
|
||||
handleCreateFile,
|
||||
handleCreateFolder,
|
||||
handleDeleteFile,
|
||||
handleRenameFile,
|
||||
handleDuplicateFile,
|
||||
handleMoveFile,
|
||||
handleImportFiles,
|
||||
handleImportFonts,
|
||||
|
||||
// Derived
|
||||
compositions,
|
||||
assets,
|
||||
fontAssets,
|
||||
};
|
||||
}
|
||||
@@ -0,0 +1,77 @@
|
||||
import { useState, useCallback, type MouseEvent } from "react";
|
||||
import { useMountEffect } from "./useMountEffect";
|
||||
import { liveTime, usePlayerStore } from "../player";
|
||||
import { buildFrameCaptureFilename, buildFrameCaptureUrl } from "../utils/frameCapture";
|
||||
|
||||
interface UseFrameCaptureParams {
|
||||
projectId: string | null;
|
||||
activeCompPath: string | null;
|
||||
showToast: (message: string, tone?: "error" | "info") => void;
|
||||
waitForPendingDomEditSaves: () => Promise<void>;
|
||||
}
|
||||
|
||||
export function useFrameCapture({
|
||||
projectId,
|
||||
activeCompPath,
|
||||
showToast,
|
||||
waitForPendingDomEditSaves,
|
||||
}: UseFrameCaptureParams) {
|
||||
const [captureFrameTime, setCaptureFrameTime] = useState(0);
|
||||
|
||||
useMountEffect(() => {
|
||||
setCaptureFrameTime(usePlayerStore.getState().currentTime);
|
||||
return liveTime.subscribe(setCaptureFrameTime);
|
||||
});
|
||||
|
||||
const refreshCaptureFrameTime = useCallback(() => {
|
||||
setCaptureFrameTime(usePlayerStore.getState().currentTime);
|
||||
}, []);
|
||||
|
||||
const handleCaptureFrameClick = useCallback(
|
||||
async (event: MouseEvent<HTMLAnchorElement>) => {
|
||||
if (!projectId) return;
|
||||
event.preventDefault();
|
||||
const time = usePlayerStore.getState().currentTime;
|
||||
setCaptureFrameTime(time);
|
||||
await waitForPendingDomEditSaves();
|
||||
const href = buildFrameCaptureUrl({
|
||||
projectId,
|
||||
compositionPath: activeCompPath,
|
||||
currentTime: time,
|
||||
});
|
||||
const filename = buildFrameCaptureFilename(activeCompPath, time);
|
||||
try {
|
||||
const response = await fetch(href, { cache: "no-store" });
|
||||
if (!response.ok) throw new Error(`Capture failed (${response.status})`);
|
||||
const blob = await response.blob();
|
||||
const blobUrl = URL.createObjectURL(blob);
|
||||
const link = document.createElement("a");
|
||||
link.href = blobUrl;
|
||||
link.download = filename;
|
||||
document.body.appendChild(link);
|
||||
link.click();
|
||||
link.remove();
|
||||
setTimeout(() => URL.revokeObjectURL(blobUrl), 0);
|
||||
} catch (err) {
|
||||
showToast(err instanceof Error ? err.message : "Capture failed");
|
||||
}
|
||||
},
|
||||
[activeCompPath, projectId, showToast, waitForPendingDomEditSaves],
|
||||
);
|
||||
|
||||
const captureFrameHref = projectId
|
||||
? buildFrameCaptureUrl({
|
||||
projectId,
|
||||
compositionPath: activeCompPath,
|
||||
currentTime: captureFrameTime,
|
||||
})
|
||||
: "#";
|
||||
const captureFrameFilename = buildFrameCaptureFilename(activeCompPath, captureFrameTime);
|
||||
|
||||
return {
|
||||
captureFrameHref,
|
||||
captureFrameFilename,
|
||||
handleCaptureFrameClick,
|
||||
refreshCaptureFrameTime,
|
||||
};
|
||||
}
|
||||
@@ -0,0 +1,35 @@
|
||||
import { useState, useCallback } from "react";
|
||||
import type { LintFinding } from "../components/LintModal";
|
||||
|
||||
export function useLintModal(projectId: string | null) {
|
||||
const [lintModal, setLintModal] = useState<LintFinding[] | null>(null);
|
||||
const [linting, setLinting] = useState(false);
|
||||
|
||||
const handleLint = useCallback(async () => {
|
||||
if (!projectId) return;
|
||||
setLinting(true);
|
||||
try {
|
||||
const res = await fetch(`/api/projects/${projectId}/lint`);
|
||||
const data = await res.json();
|
||||
setLintModal(
|
||||
(data.findings ?? []).map(
|
||||
(f: { severity?: string; message?: string; file?: string; fixHint?: string }) => ({
|
||||
severity: f.severity === "error" ? ("error" as const) : ("warning" as const),
|
||||
message: f.message ?? "",
|
||||
file: f.file,
|
||||
fixHint: f.fixHint,
|
||||
}),
|
||||
),
|
||||
);
|
||||
} catch (err) {
|
||||
const msg = err instanceof Error ? err.message : String(err);
|
||||
setLintModal([{ severity: "error", message: `Failed to run lint: ${msg}` }]);
|
||||
} finally {
|
||||
setLinting(false);
|
||||
}
|
||||
}, [projectId]);
|
||||
|
||||
const closeLintModal = useCallback(() => setLintModal(null), []);
|
||||
|
||||
return { lintModal, linting, handleLint, closeLintModal };
|
||||
}
|
||||
@@ -0,0 +1,492 @@
|
||||
import { useCallback, useEffect, useRef, useState } from "react";
|
||||
import { useMountEffect } from "./useMountEffect";
|
||||
import {
|
||||
STUDIO_MANUAL_EDITS_PATH,
|
||||
applyStudioManualEditManifest,
|
||||
emptyStudioManualEditManifest,
|
||||
installStudioManualEditSeekReapply,
|
||||
isStudioManualEditManifestPath,
|
||||
parseStudioManualEditManifest,
|
||||
readStudioFileChangePath,
|
||||
serializeStudioManualEditManifest,
|
||||
type StudioManualEditManifest,
|
||||
} from "../components/editor/manualEdits";
|
||||
import {
|
||||
STUDIO_MOTION_PATH,
|
||||
applyStudioMotionManifest,
|
||||
emptyStudioMotionManifest,
|
||||
installStudioMotionSeekReapply,
|
||||
isStudioMotionManifestPath,
|
||||
parseStudioMotionManifest,
|
||||
serializeStudioMotionManifest,
|
||||
type StudioMotionManifest,
|
||||
} from "../components/editor/studioMotion";
|
||||
import { saveProjectFilesWithHistory } from "../utils/studioFileHistory";
|
||||
import type { EditHistoryKind } from "../utils/editHistory";
|
||||
|
||||
// ── Types ──
|
||||
|
||||
interface RecordEditInput {
|
||||
label: string;
|
||||
kind: EditHistoryKind;
|
||||
coalesceKey?: string;
|
||||
files: Record<string, { before: string; after: string }>;
|
||||
}
|
||||
|
||||
interface UseManifestPersistenceParams {
|
||||
projectId: string | null;
|
||||
showToast: (message: string, tone?: "error" | "info") => void;
|
||||
readOptionalProjectFile: (path: string) => Promise<string>;
|
||||
writeProjectFile: (path: string, content: string) => Promise<void>;
|
||||
recordEdit: (entry: RecordEditInput) => Promise<void>;
|
||||
previewIframeRef: React.MutableRefObject<HTMLIFrameElement | null>;
|
||||
activeCompPathRef: React.MutableRefObject<string | null>;
|
||||
}
|
||||
|
||||
// ── Hook ──
|
||||
|
||||
export function useManifestPersistence({
|
||||
projectId,
|
||||
showToast,
|
||||
readOptionalProjectFile,
|
||||
writeProjectFile,
|
||||
recordEdit,
|
||||
previewIframeRef,
|
||||
activeCompPathRef,
|
||||
}: UseManifestPersistenceParams) {
|
||||
const [, setStudioMotionRevision] = useState(0);
|
||||
|
||||
const domEditSaveTimestampRef = useRef(0);
|
||||
const domTextCommitVersionRef = useRef(0);
|
||||
const domEditSaveQueueRef = useRef(Promise.resolve());
|
||||
const studioManualEditManifestRef = useRef<StudioManualEditManifest>(
|
||||
emptyStudioManualEditManifest(),
|
||||
);
|
||||
const studioManualEditRevisionRef = useRef(0);
|
||||
const studioMotionManifestRef = useRef<StudioMotionManifest>(emptyStudioMotionManifest());
|
||||
const studioMotionRevisionRef = useRef(0);
|
||||
const applyStudioManualEditsToPreviewRef = useRef<
|
||||
(
|
||||
iframe?: HTMLIFrameElement | null,
|
||||
options?: { forceFromDisk?: boolean; readFromDiskFirst?: boolean },
|
||||
) => Promise<void>
|
||||
>(async () => {});
|
||||
const applyStudioMotionToPreviewRef = useRef<
|
||||
(
|
||||
iframe?: HTMLIFrameElement | null,
|
||||
options?: { forceFromDisk?: boolean; readFromDiskFirst?: boolean },
|
||||
) => Promise<void>
|
||||
>(async () => {});
|
||||
const studioManualEditProjectRef = useRef<string | null>(projectId);
|
||||
|
||||
// Keep a ref to the latest projectId so async save callbacks always read the
|
||||
// current value, even when the callback was captured in a stale closure.
|
||||
const projectIdRef = useRef(projectId);
|
||||
projectIdRef.current = projectId;
|
||||
|
||||
// ── Queue / drain helpers ──
|
||||
|
||||
const queueDomEditSave = useCallback((save: () => Promise<void>) => {
|
||||
const queuedSave = domEditSaveQueueRef.current.catch(() => undefined).then(save);
|
||||
domEditSaveQueueRef.current = queuedSave.then(
|
||||
() => undefined,
|
||||
() => undefined,
|
||||
);
|
||||
return queuedSave;
|
||||
}, []);
|
||||
|
||||
const waitForPendingDomEditSaves = useCallback(async () => {
|
||||
await domEditSaveQueueRef.current.catch(() => undefined);
|
||||
}, []);
|
||||
|
||||
// ── Apply manual edits ──
|
||||
|
||||
const applyCurrentStudioManualEditsToPreview = useCallback(
|
||||
(iframe: HTMLIFrameElement | null = previewIframeRef.current) => {
|
||||
if (!iframe) return;
|
||||
let doc: Document | null = null;
|
||||
try {
|
||||
doc = iframe.contentDocument;
|
||||
} catch {
|
||||
return;
|
||||
}
|
||||
if (!doc) return;
|
||||
const previewDoc = doc;
|
||||
|
||||
const applyManifest = () => {
|
||||
applyStudioManualEditManifest(
|
||||
previewDoc,
|
||||
studioManualEditManifestRef.current,
|
||||
activeCompPathRef.current,
|
||||
);
|
||||
};
|
||||
const applyAndInstallSeekHooks = () => {
|
||||
applyManifest();
|
||||
if (iframe.contentWindow) {
|
||||
installStudioManualEditSeekReapply(iframe.contentWindow, applyManifest);
|
||||
}
|
||||
};
|
||||
|
||||
const win = iframe.contentWindow;
|
||||
applyAndInstallSeekHooks();
|
||||
win?.requestAnimationFrame?.(applyAndInstallSeekHooks);
|
||||
win?.setTimeout?.(applyAndInstallSeekHooks, 80);
|
||||
win?.setTimeout?.(applyAndInstallSeekHooks, 250);
|
||||
win?.setTimeout?.(applyAndInstallSeekHooks, 500);
|
||||
win?.setTimeout?.(applyAndInstallSeekHooks, 1000);
|
||||
win?.setTimeout?.(applyAndInstallSeekHooks, 2000);
|
||||
},
|
||||
[activeCompPathRef, previewIframeRef],
|
||||
);
|
||||
|
||||
const applyStudioManualEditsToPreview = useCallback(
|
||||
async (
|
||||
iframe: HTMLIFrameElement | null = previewIframeRef.current,
|
||||
options?: { forceFromDisk?: boolean; readFromDiskFirst?: boolean },
|
||||
) => {
|
||||
const readFromDiskFirst = Boolean(options?.forceFromDisk || options?.readFromDiskFirst);
|
||||
if (!readFromDiskFirst) {
|
||||
applyCurrentStudioManualEditsToPreview(iframe);
|
||||
return;
|
||||
}
|
||||
const readRevision = studioManualEditRevisionRef.current;
|
||||
let content: string;
|
||||
try {
|
||||
content = await readOptionalProjectFile(STUDIO_MANUAL_EDITS_PATH);
|
||||
} catch (error) {
|
||||
const message =
|
||||
error instanceof Error ? error.message : "Failed to read manual edit manifest";
|
||||
showToast(message);
|
||||
applyCurrentStudioManualEditsToPreview(iframe);
|
||||
return;
|
||||
}
|
||||
if (options?.forceFromDisk || readRevision === studioManualEditRevisionRef.current) {
|
||||
studioManualEditManifestRef.current = parseStudioManualEditManifest(content);
|
||||
if (options?.forceFromDisk) studioManualEditRevisionRef.current += 1;
|
||||
}
|
||||
applyCurrentStudioManualEditsToPreview(iframe);
|
||||
},
|
||||
[applyCurrentStudioManualEditsToPreview, previewIframeRef, readOptionalProjectFile, showToast],
|
||||
);
|
||||
applyStudioManualEditsToPreviewRef.current = applyStudioManualEditsToPreview;
|
||||
|
||||
// ── Apply motion ──
|
||||
|
||||
const applyCurrentStudioMotionToPreview = useCallback(
|
||||
(iframe: HTMLIFrameElement | null = previewIframeRef.current) => {
|
||||
if (!iframe) return;
|
||||
let doc: Document | null = null;
|
||||
try {
|
||||
doc = iframe.contentDocument;
|
||||
} catch {
|
||||
return;
|
||||
}
|
||||
if (!doc) return;
|
||||
const previewDoc = doc;
|
||||
|
||||
const applyManifest = () => {
|
||||
applyStudioMotionManifest(
|
||||
previewDoc,
|
||||
studioMotionManifestRef.current,
|
||||
activeCompPathRef.current,
|
||||
);
|
||||
};
|
||||
const applyAndInstallSeekHooks = () => {
|
||||
applyManifest();
|
||||
if (iframe.contentWindow) {
|
||||
installStudioMotionSeekReapply(iframe.contentWindow, applyManifest);
|
||||
}
|
||||
};
|
||||
|
||||
const win = iframe.contentWindow;
|
||||
win?.requestAnimationFrame?.(applyAndInstallSeekHooks);
|
||||
win?.setTimeout?.(applyAndInstallSeekHooks, 120);
|
||||
},
|
||||
[activeCompPathRef, previewIframeRef],
|
||||
);
|
||||
|
||||
const applyStudioMotionToPreview = useCallback(
|
||||
async (
|
||||
iframe: HTMLIFrameElement | null = previewIframeRef.current,
|
||||
options?: { forceFromDisk?: boolean; readFromDiskFirst?: boolean },
|
||||
) => {
|
||||
const readFromDiskFirst = Boolean(options?.forceFromDisk || options?.readFromDiskFirst);
|
||||
if (!readFromDiskFirst) {
|
||||
applyCurrentStudioMotionToPreview(iframe);
|
||||
return;
|
||||
}
|
||||
const readRevision = studioMotionRevisionRef.current;
|
||||
let content: string;
|
||||
try {
|
||||
content = await readOptionalProjectFile(STUDIO_MOTION_PATH);
|
||||
} catch (error) {
|
||||
const message = error instanceof Error ? error.message : "Failed to read motion manifest";
|
||||
showToast(message);
|
||||
applyCurrentStudioMotionToPreview(iframe);
|
||||
return;
|
||||
}
|
||||
if (options?.forceFromDisk || readRevision === studioMotionRevisionRef.current) {
|
||||
studioMotionManifestRef.current = parseStudioMotionManifest(content);
|
||||
if (options?.forceFromDisk) studioMotionRevisionRef.current += 1;
|
||||
setStudioMotionRevision((revision) => revision + 1);
|
||||
}
|
||||
applyCurrentStudioMotionToPreview(iframe);
|
||||
},
|
||||
[applyCurrentStudioMotionToPreview, previewIframeRef, readOptionalProjectFile, showToast],
|
||||
);
|
||||
applyStudioMotionToPreviewRef.current = applyStudioMotionToPreview;
|
||||
|
||||
// ── Optimistic commits ──
|
||||
|
||||
const commitStudioManualEditManifestOptimistically = useCallback(
|
||||
(
|
||||
updateManifest: (manifest: StudioManualEditManifest) => StudioManualEditManifest,
|
||||
options: { label: string; coalesceKey: string },
|
||||
) => {
|
||||
const previousManifest = studioManualEditManifestRef.current;
|
||||
const nextManifest = updateManifest(previousManifest);
|
||||
const previousContent = serializeStudioManualEditManifest(previousManifest);
|
||||
const nextContent = serializeStudioManualEditManifest(nextManifest);
|
||||
if (nextContent === previousContent) {
|
||||
return;
|
||||
}
|
||||
|
||||
const revision = studioManualEditRevisionRef.current + 1;
|
||||
studioManualEditRevisionRef.current = revision;
|
||||
studioManualEditManifestRef.current = nextManifest;
|
||||
applyCurrentStudioManualEditsToPreview(previewIframeRef.current);
|
||||
|
||||
const save = async () => {
|
||||
const originalContent = await readOptionalProjectFile(STUDIO_MANUAL_EDITS_PATH);
|
||||
const diskManifest = parseStudioManualEditManifest(originalContent);
|
||||
const nextDiskManifest = updateManifest(diskManifest);
|
||||
const nextDiskContent = serializeStudioManualEditManifest(nextDiskManifest);
|
||||
if (nextDiskContent === originalContent) {
|
||||
return;
|
||||
}
|
||||
|
||||
const pid = projectIdRef.current;
|
||||
if (!pid) throw new Error("No active project");
|
||||
domEditSaveTimestampRef.current = Date.now();
|
||||
await saveProjectFilesWithHistory({
|
||||
projectId: pid,
|
||||
label: options.label,
|
||||
kind: "manual",
|
||||
coalesceKey: options.coalesceKey,
|
||||
files: { [STUDIO_MANUAL_EDITS_PATH]: nextDiskContent },
|
||||
readFile: async () => originalContent,
|
||||
writeFile: writeProjectFile,
|
||||
recordEdit,
|
||||
});
|
||||
domEditSaveTimestampRef.current = Date.now();
|
||||
|
||||
if (studioManualEditRevisionRef.current === revision) {
|
||||
studioManualEditManifestRef.current = nextDiskManifest;
|
||||
applyCurrentStudioManualEditsToPreview(previewIframeRef.current);
|
||||
}
|
||||
};
|
||||
|
||||
void queueDomEditSave(save).catch((error) => {
|
||||
if (studioManualEditRevisionRef.current === revision) {
|
||||
studioManualEditRevisionRef.current += 1;
|
||||
studioManualEditManifestRef.current = previousManifest;
|
||||
applyCurrentStudioManualEditsToPreview(previewIframeRef.current);
|
||||
}
|
||||
const message = error instanceof Error ? error.message : "Failed to save manual edit";
|
||||
showToast(message);
|
||||
});
|
||||
},
|
||||
[
|
||||
applyCurrentStudioManualEditsToPreview,
|
||||
recordEdit,
|
||||
queueDomEditSave,
|
||||
readOptionalProjectFile,
|
||||
showToast,
|
||||
writeProjectFile,
|
||||
previewIframeRef,
|
||||
],
|
||||
);
|
||||
|
||||
const commitStudioMotionManifestOptimistically = useCallback(
|
||||
(
|
||||
updateManifest: (manifest: StudioMotionManifest) => StudioMotionManifest,
|
||||
options: { label: string; coalesceKey: string },
|
||||
) => {
|
||||
const previousManifest = studioMotionManifestRef.current;
|
||||
const nextManifest = updateManifest(previousManifest);
|
||||
const previousContent = serializeStudioMotionManifest(previousManifest);
|
||||
const nextContent = serializeStudioMotionManifest(nextManifest);
|
||||
if (nextContent === previousContent) {
|
||||
return;
|
||||
}
|
||||
|
||||
const revision = studioMotionRevisionRef.current + 1;
|
||||
studioMotionRevisionRef.current = revision;
|
||||
studioMotionManifestRef.current = nextManifest;
|
||||
setStudioMotionRevision((current) => current + 1);
|
||||
applyCurrentStudioMotionToPreview(previewIframeRef.current);
|
||||
|
||||
const save = async () => {
|
||||
const originalContent = await readOptionalProjectFile(STUDIO_MOTION_PATH);
|
||||
const diskManifest = parseStudioMotionManifest(originalContent);
|
||||
const nextDiskManifest = updateManifest(diskManifest);
|
||||
const nextDiskContent = serializeStudioMotionManifest(nextDiskManifest);
|
||||
if (nextDiskContent === originalContent) {
|
||||
return;
|
||||
}
|
||||
|
||||
const pid = projectIdRef.current;
|
||||
if (!pid) throw new Error("No active project");
|
||||
domEditSaveTimestampRef.current = Date.now();
|
||||
await saveProjectFilesWithHistory({
|
||||
projectId: pid,
|
||||
label: options.label,
|
||||
kind: "motion",
|
||||
coalesceKey: options.coalesceKey,
|
||||
files: { [STUDIO_MOTION_PATH]: nextDiskContent },
|
||||
readFile: async () => originalContent,
|
||||
writeFile: writeProjectFile,
|
||||
recordEdit,
|
||||
});
|
||||
domEditSaveTimestampRef.current = Date.now();
|
||||
|
||||
if (studioMotionRevisionRef.current === revision) {
|
||||
studioMotionManifestRef.current = nextDiskManifest;
|
||||
setStudioMotionRevision((current) => current + 1);
|
||||
applyCurrentStudioMotionToPreview(previewIframeRef.current);
|
||||
}
|
||||
};
|
||||
|
||||
void queueDomEditSave(save).catch((error) => {
|
||||
if (studioMotionRevisionRef.current === revision) {
|
||||
studioMotionRevisionRef.current += 1;
|
||||
studioMotionManifestRef.current = previousManifest;
|
||||
setStudioMotionRevision((current) => current + 1);
|
||||
applyCurrentStudioMotionToPreview(previewIframeRef.current);
|
||||
}
|
||||
const message = error instanceof Error ? error.message : "Failed to save motion edit";
|
||||
showToast(message);
|
||||
});
|
||||
},
|
||||
[
|
||||
applyCurrentStudioMotionToPreview,
|
||||
recordEdit,
|
||||
queueDomEditSave,
|
||||
readOptionalProjectFile,
|
||||
showToast,
|
||||
writeProjectFile,
|
||||
previewIframeRef,
|
||||
],
|
||||
);
|
||||
|
||||
// ── Sync preview after undo/redo ──
|
||||
|
||||
const syncHistoryPreviewAfterApply = useCallback(
|
||||
async (paths: string[] | undefined) => {
|
||||
const changedPaths = paths ?? [];
|
||||
const manualManifestOnly =
|
||||
changedPaths.length > 0 && changedPaths.every((path) => path === STUDIO_MANUAL_EDITS_PATH);
|
||||
const motionManifestOnly =
|
||||
changedPaths.length > 0 && changedPaths.every((path) => path === STUDIO_MOTION_PATH);
|
||||
|
||||
if (manualManifestOnly) {
|
||||
await applyStudioManualEditsToPreview(previewIframeRef.current, { forceFromDisk: true });
|
||||
return;
|
||||
}
|
||||
if (motionManifestOnly) {
|
||||
await applyStudioMotionToPreview(previewIframeRef.current, { forceFromDisk: true });
|
||||
return;
|
||||
}
|
||||
|
||||
// Reload the iframe in-place rather than recreating the Player component.
|
||||
// This preserves the <hyperframes-player> web component and its shader
|
||||
// transition cache — only the iframe document reloads, so transitions that
|
||||
// weren't touched by the undo/redo don't need to rebuild from scratch.
|
||||
const iframe = previewIframeRef.current;
|
||||
if (iframe?.contentWindow) {
|
||||
try {
|
||||
iframe.contentWindow.location.reload();
|
||||
return;
|
||||
} catch {
|
||||
// Cross-origin or detached — fall through to full refresh
|
||||
}
|
||||
}
|
||||
},
|
||||
[applyStudioManualEditsToPreview, applyStudioMotionToPreview, previewIframeRef],
|
||||
);
|
||||
|
||||
// ── Reset manifests when project changes ──
|
||||
|
||||
// eslint-disable-next-line no-restricted-syntax
|
||||
useEffect(() => {
|
||||
const previousProjectId = studioManualEditProjectRef.current;
|
||||
studioManualEditProjectRef.current = projectId;
|
||||
if (!previousProjectId || previousProjectId === projectId) return;
|
||||
studioManualEditManifestRef.current = emptyStudioManualEditManifest();
|
||||
studioManualEditRevisionRef.current += 1;
|
||||
studioMotionManifestRef.current = emptyStudioMotionManifest();
|
||||
studioMotionRevisionRef.current += 1;
|
||||
setStudioMotionRevision((revision) => revision + 1);
|
||||
}, [projectId]);
|
||||
|
||||
// ── Listen for external file changes (HMR / SSE) ──
|
||||
// In dev: use Vite HMR. In embedded/production: use SSE from /api/events.
|
||||
// Suppress file-change events that echo back from a recent DOM edit save —
|
||||
// those changes are already applied to the iframe DOM and a full reload
|
||||
// would flash the preview.
|
||||
useMountEffect(() => {
|
||||
const handler = (payload?: unknown) => {
|
||||
const changedPath = readStudioFileChangePath(payload);
|
||||
const recentDomEditSave = Date.now() - domEditSaveTimestampRef.current < 1200;
|
||||
if (isStudioManualEditManifestPath(changedPath)) {
|
||||
if (!recentDomEditSave) {
|
||||
void applyStudioManualEditsToPreviewRef.current(previewIframeRef.current, {
|
||||
forceFromDisk: true,
|
||||
});
|
||||
}
|
||||
return;
|
||||
}
|
||||
if (isStudioMotionManifestPath(changedPath)) {
|
||||
if (!recentDomEditSave) {
|
||||
void applyStudioMotionToPreviewRef.current(previewIframeRef.current, {
|
||||
forceFromDisk: true,
|
||||
});
|
||||
}
|
||||
return;
|
||||
}
|
||||
// Non-manifest file changes are not handled here — the caller is
|
||||
// responsible for triggering a preview refresh via onExternalFileChange
|
||||
// if needed. This hook only suppresses echoes and handles manifest reloads.
|
||||
};
|
||||
if (import.meta.hot) {
|
||||
import.meta.hot.on("hf:file-change", handler);
|
||||
return () => import.meta.hot?.off?.("hf:file-change", handler);
|
||||
}
|
||||
// SSE fallback for embedded studio server
|
||||
const es = new EventSource("/api/events");
|
||||
es.addEventListener("file-change", handler);
|
||||
return () => es.close();
|
||||
});
|
||||
|
||||
return {
|
||||
domEditSaveTimestampRef,
|
||||
domTextCommitVersionRef,
|
||||
domEditSaveQueueRef,
|
||||
studioManualEditManifestRef,
|
||||
studioManualEditRevisionRef,
|
||||
studioMotionManifestRef,
|
||||
studioMotionRevisionRef,
|
||||
applyStudioManualEditsToPreviewRef,
|
||||
applyStudioMotionToPreviewRef,
|
||||
studioManualEditProjectRef,
|
||||
queueDomEditSave,
|
||||
waitForPendingDomEditSaves,
|
||||
applyCurrentStudioManualEditsToPreview,
|
||||
applyStudioManualEditsToPreview,
|
||||
applyCurrentStudioMotionToPreview,
|
||||
applyStudioMotionToPreview,
|
||||
commitStudioManualEditManifestOptimistically,
|
||||
commitStudioMotionManifestOptimistically,
|
||||
syncHistoryPreviewAfterApply,
|
||||
};
|
||||
}
|
||||
@@ -0,0 +1,68 @@
|
||||
import { useState, useCallback, useRef } from "react";
|
||||
import type { RightPanelTab } from "../utils/studioHelpers";
|
||||
|
||||
export function usePanelLayout() {
|
||||
const [leftWidth, setLeftWidth] = useState(240);
|
||||
const [rightWidth, setRightWidth] = useState(400);
|
||||
const [leftCollapsed, setLeftCollapsed] = useState(false);
|
||||
const [rightCollapsed, setRightCollapsed] = useState(true);
|
||||
const [rightPanelTab, setRightPanelTab] = useState<RightPanelTab>("renders");
|
||||
const panelDragRef = useRef<{
|
||||
side: "left" | "right";
|
||||
startX: number;
|
||||
startW: number;
|
||||
} | null>(null);
|
||||
|
||||
const toggleLeftSidebar = useCallback(() => {
|
||||
setLeftCollapsed((collapsed) => !collapsed);
|
||||
}, []);
|
||||
|
||||
const handlePanelResizeStart = useCallback(
|
||||
(side: "left" | "right", e: React.PointerEvent) => {
|
||||
e.preventDefault();
|
||||
(e.target as HTMLElement).setPointerCapture(e.pointerId);
|
||||
panelDragRef.current = {
|
||||
side,
|
||||
startX: e.clientX,
|
||||
startW: side === "left" ? leftWidth : rightWidth,
|
||||
};
|
||||
},
|
||||
[leftWidth, rightWidth],
|
||||
);
|
||||
|
||||
const handlePanelResizeMove = useCallback((e: React.PointerEvent) => {
|
||||
const drag = panelDragRef.current;
|
||||
if (!drag) return;
|
||||
const delta = e.clientX - drag.startX;
|
||||
const maxLeft = Math.floor(window.innerWidth * 0.5);
|
||||
const newW = Math.max(
|
||||
160,
|
||||
Math.min(
|
||||
drag.side === "left" ? maxLeft : 600,
|
||||
drag.startW + (drag.side === "left" ? delta : -delta),
|
||||
),
|
||||
);
|
||||
if (drag.side === "left") setLeftWidth(newW);
|
||||
else setRightWidth(newW);
|
||||
}, []);
|
||||
|
||||
const handlePanelResizeEnd = useCallback(() => {
|
||||
panelDragRef.current = null;
|
||||
}, []);
|
||||
|
||||
return {
|
||||
leftWidth,
|
||||
setLeftWidth,
|
||||
rightWidth,
|
||||
leftCollapsed,
|
||||
setLeftCollapsed,
|
||||
rightCollapsed,
|
||||
setRightCollapsed,
|
||||
rightPanelTab,
|
||||
setRightPanelTab,
|
||||
toggleLeftSidebar,
|
||||
handlePanelResizeStart,
|
||||
handlePanelResizeMove,
|
||||
handlePanelResizeEnd,
|
||||
};
|
||||
}
|
||||
@@ -0,0 +1,256 @@
|
||||
import { describe, expect, it } from "vitest";
|
||||
import { createEmptyEditHistory } from "../utils/editHistory";
|
||||
import type { EditHistoryStorageAdapter } from "../utils/editHistoryStorage";
|
||||
import { createMemoryEditHistoryStorage } from "../utils/editHistoryStorage";
|
||||
import {
|
||||
createPersistentEditHistoryController,
|
||||
createPersistentEditHistoryStore,
|
||||
} from "./usePersistentEditHistory";
|
||||
|
||||
describe("createPersistentEditHistoryController", () => {
|
||||
it("records history and reloads it for the same project", async () => {
|
||||
const storage = createMemoryEditHistoryStorage();
|
||||
const first = await createPersistentEditHistoryController({
|
||||
projectId: "project-1",
|
||||
storage,
|
||||
now: () => 100,
|
||||
onChange: () => {},
|
||||
});
|
||||
|
||||
await first.recordEdit({
|
||||
label: "Move layer",
|
||||
kind: "manual",
|
||||
files: { "index.html": { before: "a", after: "b" } },
|
||||
});
|
||||
|
||||
const second = await createPersistentEditHistoryController({
|
||||
projectId: "project-1",
|
||||
storage,
|
||||
now: () => 200,
|
||||
onChange: () => {},
|
||||
});
|
||||
|
||||
expect(second.snapshot().canUndo).toBe(true);
|
||||
expect(second.snapshot().undoLabel).toBe("Move layer");
|
||||
expect(second.snapshot().undoPaths).toEqual(["index.html"]);
|
||||
});
|
||||
|
||||
it("undo applies files through the provided callback and persists redo state", async () => {
|
||||
const storage = createMemoryEditHistoryStorage();
|
||||
const controller = await createPersistentEditHistoryController({
|
||||
projectId: "project-1",
|
||||
storage,
|
||||
now: () => 100,
|
||||
onChange: () => {},
|
||||
});
|
||||
await controller.recordEdit({
|
||||
label: "Move layer",
|
||||
kind: "manual",
|
||||
files: { "index.html": { before: "a", after: "b" } },
|
||||
});
|
||||
|
||||
const result = await controller.undo({
|
||||
readFile: async (path) => {
|
||||
expect(path).toBe("index.html");
|
||||
return "b";
|
||||
},
|
||||
writeFile: async (path, content) => {
|
||||
expect(path).toBe("index.html");
|
||||
expect(content).toBe("a");
|
||||
},
|
||||
});
|
||||
expect(result.ok).toBe(true);
|
||||
expect(result.paths).toEqual(["index.html"]);
|
||||
|
||||
expect(controller.snapshot().canUndo).toBe(false);
|
||||
expect(controller.snapshot().canRedo).toBe(true);
|
||||
expect(controller.snapshot().redoPaths).toEqual(["index.html"]);
|
||||
});
|
||||
|
||||
it("keeps in-memory history when storage saves fail", async () => {
|
||||
const storage: EditHistoryStorageAdapter = {
|
||||
async get() {
|
||||
return null;
|
||||
},
|
||||
async set() {
|
||||
throw new Error("IndexedDB unavailable");
|
||||
},
|
||||
async delete() {},
|
||||
};
|
||||
const controller = await createPersistentEditHistoryController({
|
||||
projectId: "project-1",
|
||||
storage,
|
||||
now: () => 100,
|
||||
onChange: () => {},
|
||||
});
|
||||
|
||||
await expect(
|
||||
controller.recordEdit({
|
||||
label: "Move layer",
|
||||
kind: "manual",
|
||||
files: { "index.html": { before: "a", after: "b" } },
|
||||
}),
|
||||
).resolves.toBeUndefined();
|
||||
|
||||
expect(controller.snapshot().canUndo).toBe(true);
|
||||
});
|
||||
|
||||
it("serializes concurrent record edits against the latest state", async () => {
|
||||
const storage = createMemoryEditHistoryStorage();
|
||||
let timestamp = 100;
|
||||
const store = createPersistentEditHistoryStore({
|
||||
projectId: "project-1",
|
||||
storage,
|
||||
initialState: createEmptyEditHistory(),
|
||||
now: () => timestamp++,
|
||||
onChange: () => {},
|
||||
});
|
||||
|
||||
await Promise.all([
|
||||
store.recordEdit({
|
||||
label: "Move layer",
|
||||
kind: "manual",
|
||||
files: { "index.html": { before: "a", after: "b" } },
|
||||
}),
|
||||
store.recordEdit({
|
||||
label: "Resize layer",
|
||||
kind: "manual",
|
||||
files: { "index.html": { before: "b", after: "c" } },
|
||||
}),
|
||||
]);
|
||||
|
||||
expect(store.snapshot().state.undo.map((entry) => entry.label)).toEqual([
|
||||
"Move layer",
|
||||
"Resize layer",
|
||||
]);
|
||||
});
|
||||
|
||||
it("still coalesces concurrent source edits that share a coalesce key", async () => {
|
||||
const storage = createMemoryEditHistoryStorage();
|
||||
let timestamp = 100;
|
||||
const store = createPersistentEditHistoryStore({
|
||||
projectId: "project-1",
|
||||
storage,
|
||||
initialState: createEmptyEditHistory(),
|
||||
now: () => timestamp++,
|
||||
onChange: () => {},
|
||||
});
|
||||
|
||||
await Promise.all([
|
||||
store.recordEdit({
|
||||
label: "Edit source",
|
||||
kind: "source",
|
||||
coalesceKey: "source:index.html",
|
||||
files: { "index.html": { before: "a", after: "b" } },
|
||||
}),
|
||||
store.recordEdit({
|
||||
label: "Edit source",
|
||||
kind: "source",
|
||||
coalesceKey: "source:index.html",
|
||||
files: { "index.html": { before: "b", after: "c" } },
|
||||
}),
|
||||
]);
|
||||
|
||||
expect(store.snapshot().state.undo).toHaveLength(1);
|
||||
expect(store.snapshot().state.undo[0].files["index.html"].before).toBe("a");
|
||||
expect(store.snapshot().state.undo[0].files["index.html"].after).toBe("c");
|
||||
});
|
||||
|
||||
it("reads undo hashes from the live top entry during queued undo calls", async () => {
|
||||
const storage = createMemoryEditHistoryStorage();
|
||||
let timestamp = 100;
|
||||
const store = createPersistentEditHistoryStore({
|
||||
projectId: "project-1",
|
||||
storage,
|
||||
initialState: createEmptyEditHistory(),
|
||||
now: () => timestamp++,
|
||||
onChange: () => {},
|
||||
});
|
||||
await store.recordEdit({
|
||||
label: "Edit first file",
|
||||
kind: "manual",
|
||||
files: { "first.html": { before: "first-before", after: "first-after" } },
|
||||
});
|
||||
await store.recordEdit({
|
||||
label: "Edit second file",
|
||||
kind: "manual",
|
||||
files: { "second.html": { before: "second-before", after: "second-after" } },
|
||||
});
|
||||
|
||||
const files: Record<string, string> = {
|
||||
"first.html": "first-after",
|
||||
"second.html": "second-after",
|
||||
};
|
||||
const readPaths: string[] = [];
|
||||
|
||||
await Promise.all([
|
||||
store.undo({
|
||||
readFile: async (path) => {
|
||||
readPaths.push(path);
|
||||
return files[path];
|
||||
},
|
||||
writeFile: async (path, content) => {
|
||||
files[path] = content;
|
||||
},
|
||||
}),
|
||||
store.undo({
|
||||
readFile: async (path) => {
|
||||
readPaths.push(path);
|
||||
return files[path];
|
||||
},
|
||||
writeFile: async (path, content) => {
|
||||
files[path] = content;
|
||||
},
|
||||
}),
|
||||
]);
|
||||
|
||||
expect(readPaths).toEqual(["second.html", "first.html"]);
|
||||
expect(files).toEqual({
|
||||
"first.html": "first-before",
|
||||
"second.html": "second-before",
|
||||
});
|
||||
expect(store.snapshot().canUndo).toBe(false);
|
||||
expect(store.snapshot().canRedo).toBe(true);
|
||||
});
|
||||
|
||||
it("rolls back files when an undo write fails partway through", async () => {
|
||||
const storage = createMemoryEditHistoryStorage();
|
||||
const store = createPersistentEditHistoryStore({
|
||||
projectId: "project-1",
|
||||
storage,
|
||||
initialState: createEmptyEditHistory(),
|
||||
now: () => 100,
|
||||
onChange: () => {},
|
||||
});
|
||||
await store.recordEdit({
|
||||
label: "Edit files",
|
||||
kind: "manual",
|
||||
files: {
|
||||
"first.html": { before: "first-before", after: "first-after" },
|
||||
"second.html": { before: "second-before", after: "second-after" },
|
||||
},
|
||||
});
|
||||
|
||||
const files: Record<string, string> = {
|
||||
"first.html": "first-after",
|
||||
"second.html": "second-after",
|
||||
};
|
||||
const result = store.undo({
|
||||
readFile: async (path) => files[path],
|
||||
writeFile: async (path, content) => {
|
||||
if (path === "second.html" && content === "second-before") {
|
||||
throw new Error("write failed");
|
||||
}
|
||||
files[path] = content;
|
||||
},
|
||||
});
|
||||
|
||||
await expect(result).rejects.toThrow("write failed");
|
||||
expect(files).toEqual({
|
||||
"first.html": "first-after",
|
||||
"second.html": "second-after",
|
||||
});
|
||||
expect(store.snapshot().undoLabel).toBe("Edit files");
|
||||
expect(store.snapshot().canRedo).toBe(false);
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,337 @@
|
||||
import { useCallback, useEffect, useMemo, useRef, useState } from "react";
|
||||
import {
|
||||
buildEditHistoryEntry,
|
||||
createEmptyEditHistory,
|
||||
hashEditHistoryContent,
|
||||
pushEditHistoryEntry,
|
||||
redoEditHistory,
|
||||
undoEditHistory,
|
||||
type BuildEditHistoryEntryInput,
|
||||
type EditHistoryKind,
|
||||
type EditHistoryState,
|
||||
} from "../utils/editHistory";
|
||||
import {
|
||||
createIndexedDbEditHistoryStorage,
|
||||
loadEditHistoryState,
|
||||
saveEditHistoryState,
|
||||
type EditHistoryStorageAdapter,
|
||||
} from "../utils/editHistoryStorage";
|
||||
|
||||
interface RecordEditInput {
|
||||
label: string;
|
||||
kind: EditHistoryKind;
|
||||
coalesceKey?: string;
|
||||
files: BuildEditHistoryEntryInput["files"];
|
||||
}
|
||||
|
||||
interface ApplyCallbacks {
|
||||
readFile: (path: string) => Promise<string>;
|
||||
writeFile: (path: string, content: string) => Promise<void>;
|
||||
}
|
||||
|
||||
interface UsePersistentEditHistoryOptions {
|
||||
projectId: string | null;
|
||||
storage?: EditHistoryStorageAdapter;
|
||||
now?: () => number;
|
||||
}
|
||||
|
||||
interface ApplyResult {
|
||||
ok: boolean;
|
||||
reason?: "empty" | "content-mismatch";
|
||||
label?: string;
|
||||
paths?: string[];
|
||||
}
|
||||
|
||||
interface PersistentEditHistoryStoreOptions {
|
||||
projectId: string;
|
||||
storage: EditHistoryStorageAdapter;
|
||||
initialState: EditHistoryState;
|
||||
now?: () => number;
|
||||
onChange: (state: EditHistoryState) => void;
|
||||
}
|
||||
|
||||
type EditHistoryMutation<T> = (state: EditHistoryState) => Promise<{
|
||||
state: EditHistoryState;
|
||||
result: T;
|
||||
}>;
|
||||
|
||||
function createEntryId(now: number): string {
|
||||
return `edit-${now.toString(36)}-${Math.random().toString(36).slice(2, 10)}`;
|
||||
}
|
||||
|
||||
function snapshotEditHistoryState(state: EditHistoryState) {
|
||||
const undoEntry = state.undo[state.undo.length - 1] ?? null;
|
||||
const redoEntry = state.redo[state.redo.length - 1] ?? null;
|
||||
return {
|
||||
canUndo: Boolean(undoEntry),
|
||||
canRedo: Boolean(redoEntry),
|
||||
undoLabel: undoEntry?.label ?? null,
|
||||
redoLabel: redoEntry?.label ?? null,
|
||||
undoPaths: undoEntry ? Object.keys(undoEntry.files) : [],
|
||||
redoPaths: redoEntry ? Object.keys(redoEntry.files) : [],
|
||||
state,
|
||||
};
|
||||
}
|
||||
|
||||
async function readCurrentFileHashes(
|
||||
paths: string[],
|
||||
readFile: (path: string) => Promise<string>,
|
||||
): Promise<{
|
||||
currentFiles: Record<string, string>;
|
||||
currentHashes: Record<string, string>;
|
||||
}> {
|
||||
const currentFiles: Record<string, string> = {};
|
||||
const currentHashes: Record<string, string> = {};
|
||||
for (const path of paths) {
|
||||
const content = await readFile(path);
|
||||
currentFiles[path] = content;
|
||||
currentHashes[path] = hashEditHistoryContent(content);
|
||||
}
|
||||
return { currentFiles, currentHashes };
|
||||
}
|
||||
|
||||
async function writeFilesWithRollback({
|
||||
files,
|
||||
rollbackFiles,
|
||||
writeFile,
|
||||
}: {
|
||||
files: Record<string, string>;
|
||||
rollbackFiles: Record<string, string>;
|
||||
writeFile: (path: string, content: string) => Promise<void>;
|
||||
}): Promise<void> {
|
||||
const writtenPaths: string[] = [];
|
||||
try {
|
||||
for (const [path, content] of Object.entries(files)) {
|
||||
await writeFile(path, content);
|
||||
writtenPaths.push(path);
|
||||
}
|
||||
} catch (error) {
|
||||
try {
|
||||
for (const path of writtenPaths.reverse()) {
|
||||
await writeFile(path, rollbackFiles[path]);
|
||||
}
|
||||
} catch (rollbackError) {
|
||||
throw new AggregateError(
|
||||
[error, rollbackError],
|
||||
"Failed to apply edit history and rollback did not complete",
|
||||
);
|
||||
}
|
||||
throw error;
|
||||
}
|
||||
}
|
||||
|
||||
export function createPersistentEditHistoryStore({
|
||||
projectId,
|
||||
storage,
|
||||
initialState,
|
||||
now = Date.now,
|
||||
onChange,
|
||||
}: PersistentEditHistoryStoreOptions) {
|
||||
let state = initialState;
|
||||
let queue = Promise.resolve();
|
||||
|
||||
const save = async (nextState: EditHistoryState) => {
|
||||
state = nextState;
|
||||
onChange(nextState);
|
||||
try {
|
||||
await saveEditHistoryState(storage, projectId, nextState);
|
||||
} catch {
|
||||
// Keep in-memory history usable when IndexedDB is unavailable.
|
||||
}
|
||||
};
|
||||
|
||||
const mutate = async <T>(mutation: EditHistoryMutation<T>): Promise<T> => {
|
||||
const run = queue.then(async () => {
|
||||
const { state: nextState, result } = await mutation(state);
|
||||
if (nextState !== state) await save(nextState);
|
||||
return result;
|
||||
});
|
||||
queue = run.then(
|
||||
() => undefined,
|
||||
() => undefined,
|
||||
);
|
||||
return run;
|
||||
};
|
||||
|
||||
return {
|
||||
snapshot: () => snapshotEditHistoryState(state),
|
||||
async recordEdit(input: RecordEditInput) {
|
||||
await mutate<void>(async (currentState) => {
|
||||
const timestamp = now();
|
||||
const entry = buildEditHistoryEntry({
|
||||
...input,
|
||||
id: createEntryId(timestamp),
|
||||
projectId,
|
||||
now: timestamp,
|
||||
});
|
||||
return {
|
||||
state: pushEditHistoryEntry(currentState, entry),
|
||||
result: undefined,
|
||||
};
|
||||
});
|
||||
},
|
||||
async undo(callbacks: ApplyCallbacks): Promise<ApplyResult> {
|
||||
return mutate<ApplyResult>(async (currentState) => {
|
||||
const entry = currentState.undo[currentState.undo.length - 1];
|
||||
if (!entry) {
|
||||
return {
|
||||
state: currentState,
|
||||
result: { ok: false, reason: "empty" },
|
||||
};
|
||||
}
|
||||
const { currentFiles, currentHashes } = await readCurrentFileHashes(
|
||||
Object.keys(entry.files),
|
||||
callbacks.readFile,
|
||||
);
|
||||
const result = undoEditHistory(currentState, currentHashes, now());
|
||||
if (!result.ok) {
|
||||
return {
|
||||
state: currentState,
|
||||
result: { ok: false, reason: result.reason },
|
||||
};
|
||||
}
|
||||
await writeFilesWithRollback({
|
||||
files: result.filesToWrite,
|
||||
rollbackFiles: currentFiles,
|
||||
writeFile: callbacks.writeFile,
|
||||
});
|
||||
return {
|
||||
state: result.state,
|
||||
result: { ok: true, label: result.entry.label, paths: Object.keys(result.entry.files) },
|
||||
};
|
||||
});
|
||||
},
|
||||
async redo(callbacks: ApplyCallbacks): Promise<ApplyResult> {
|
||||
return mutate<ApplyResult>(async (currentState) => {
|
||||
const entry = currentState.redo[currentState.redo.length - 1];
|
||||
if (!entry) {
|
||||
return {
|
||||
state: currentState,
|
||||
result: { ok: false, reason: "empty" },
|
||||
};
|
||||
}
|
||||
const { currentFiles, currentHashes } = await readCurrentFileHashes(
|
||||
Object.keys(entry.files),
|
||||
callbacks.readFile,
|
||||
);
|
||||
const result = redoEditHistory(currentState, currentHashes, now());
|
||||
if (!result.ok) {
|
||||
return {
|
||||
state: currentState,
|
||||
result: { ok: false, reason: result.reason },
|
||||
};
|
||||
}
|
||||
await writeFilesWithRollback({
|
||||
files: result.filesToWrite,
|
||||
rollbackFiles: currentFiles,
|
||||
writeFile: callbacks.writeFile,
|
||||
});
|
||||
return {
|
||||
state: result.state,
|
||||
result: { ok: true, label: result.entry.label, paths: Object.keys(result.entry.files) },
|
||||
};
|
||||
});
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
export async function createPersistentEditHistoryController({
|
||||
projectId,
|
||||
storage,
|
||||
now = Date.now,
|
||||
onChange,
|
||||
}: {
|
||||
projectId: string;
|
||||
storage: EditHistoryStorageAdapter;
|
||||
now?: () => number;
|
||||
onChange: (state: EditHistoryState) => void;
|
||||
}) {
|
||||
let state = await loadEditHistoryState(storage, projectId);
|
||||
const store = createPersistentEditHistoryStore({
|
||||
projectId,
|
||||
storage,
|
||||
initialState: state,
|
||||
now,
|
||||
onChange: (nextState) => {
|
||||
state = nextState;
|
||||
onChange(nextState);
|
||||
},
|
||||
});
|
||||
|
||||
return store;
|
||||
}
|
||||
|
||||
export function usePersistentEditHistory(options: UsePersistentEditHistoryOptions) {
|
||||
const storage = useMemo(
|
||||
() => options.storage ?? createIndexedDbEditHistoryStorage(),
|
||||
[options.storage],
|
||||
);
|
||||
const now = options.now ?? Date.now;
|
||||
const [state, setState] = useState<EditHistoryState>(() => createEmptyEditHistory());
|
||||
const [loaded, setLoaded] = useState(false);
|
||||
const projectId = options.projectId;
|
||||
const storeRef = useRef<ReturnType<typeof createPersistentEditHistoryStore> | null>(null);
|
||||
|
||||
useEffect(() => {
|
||||
let cancelled = false;
|
||||
const emptyState = createEmptyEditHistory();
|
||||
storeRef.current = null;
|
||||
setState(emptyState);
|
||||
setLoaded(false);
|
||||
if (!projectId) {
|
||||
setLoaded(true);
|
||||
return;
|
||||
}
|
||||
|
||||
loadEditHistoryState(storage, projectId)
|
||||
.then((loadedState) => {
|
||||
if (cancelled) return;
|
||||
storeRef.current = createPersistentEditHistoryStore({
|
||||
projectId,
|
||||
storage,
|
||||
initialState: loadedState,
|
||||
now,
|
||||
onChange: setState,
|
||||
});
|
||||
setState(loadedState);
|
||||
})
|
||||
.catch(() => {
|
||||
if (cancelled) return;
|
||||
storeRef.current = createPersistentEditHistoryStore({
|
||||
projectId,
|
||||
storage,
|
||||
initialState: emptyState,
|
||||
now,
|
||||
onChange: setState,
|
||||
});
|
||||
setState(emptyState);
|
||||
})
|
||||
.finally(() => {
|
||||
if (!cancelled) setLoaded(true);
|
||||
});
|
||||
|
||||
return () => {
|
||||
cancelled = true;
|
||||
};
|
||||
}, [now, projectId, storage]);
|
||||
|
||||
const recordEdit = useCallback(async (input: RecordEditInput) => {
|
||||
await storeRef.current?.recordEdit(input);
|
||||
}, []);
|
||||
|
||||
const undo = useCallback(async (callbacks: ApplyCallbacks): Promise<ApplyResult> => {
|
||||
return storeRef.current?.undo(callbacks) ?? { ok: false, reason: "empty" };
|
||||
}, []);
|
||||
|
||||
const redo = useCallback(async (callbacks: ApplyCallbacks): Promise<ApplyResult> => {
|
||||
return storeRef.current?.redo(callbacks) ?? { ok: false, reason: "empty" };
|
||||
}, []);
|
||||
|
||||
return {
|
||||
loaded,
|
||||
...snapshotEditHistoryState(state),
|
||||
recordEdit,
|
||||
undo,
|
||||
redo,
|
||||
};
|
||||
}
|
||||
@@ -0,0 +1,153 @@
|
||||
import { useCallback } from "react";
|
||||
import { liveTime, usePlayerStore } from "../player";
|
||||
import {
|
||||
getPreviewLocalPointer,
|
||||
buildRasterClickSelectionContext,
|
||||
pauseStudioPreviewPlayback,
|
||||
} from "../utils/studioPreviewHelpers";
|
||||
import { STUDIO_PREVIEW_SELECTION_ENABLED } from "../components/editor/manualEditingAvailability";
|
||||
import {
|
||||
isLargeRasterDomEditSelection,
|
||||
type DomEditSelection,
|
||||
} from "../components/editor/domEditing";
|
||||
import type { AgentModalAnchorPoint } from "../utils/studioHelpers";
|
||||
|
||||
// ── Types ──
|
||||
|
||||
export interface UsePreviewInteractionParams {
|
||||
captionEditMode: boolean;
|
||||
compositionLoading: boolean;
|
||||
previewIframeRef: React.MutableRefObject<HTMLIFrameElement | null>;
|
||||
activeCompPath: string | null;
|
||||
showToast: (message: string, tone?: "error" | "info") => void;
|
||||
|
||||
// From useDomSelection
|
||||
applyDomSelection: (
|
||||
selection: DomEditSelection | null,
|
||||
options?: { revealPanel?: boolean; additive?: boolean; preserveGroup?: boolean },
|
||||
) => void;
|
||||
resolveDomSelectionFromPreviewPoint: (
|
||||
clientX: number,
|
||||
clientY: number,
|
||||
options?: { preferClipAncestor?: boolean },
|
||||
) => DomEditSelection | null;
|
||||
updateDomEditHoverSelection: (selection: DomEditSelection | null) => void;
|
||||
|
||||
// From useAskAgentModal
|
||||
preloadAgentPromptSnippet: (selection: DomEditSelection) => Promise<void>;
|
||||
setAgentPromptSelectionContext: (context: string | undefined) => void;
|
||||
setAgentModalAnchorPoint: (point: AgentModalAnchorPoint | null) => void;
|
||||
setAgentModalOpen: (open: boolean) => void;
|
||||
}
|
||||
|
||||
// ── Hook ──
|
||||
|
||||
export function usePreviewInteraction({
|
||||
captionEditMode,
|
||||
compositionLoading,
|
||||
previewIframeRef,
|
||||
showToast,
|
||||
applyDomSelection,
|
||||
resolveDomSelectionFromPreviewPoint,
|
||||
updateDomEditHoverSelection,
|
||||
preloadAgentPromptSnippet,
|
||||
setAgentPromptSelectionContext,
|
||||
setAgentModalAnchorPoint,
|
||||
setAgentModalOpen,
|
||||
}: UsePreviewInteractionParams) {
|
||||
const handlePreviewCanvasMouseDown = useCallback(
|
||||
(e: React.MouseEvent<HTMLDivElement>, options?: { preferClipAncestor?: boolean }) => {
|
||||
if (!STUDIO_PREVIEW_SELECTION_ENABLED || captionEditMode || compositionLoading) return;
|
||||
const nextSelection = resolveDomSelectionFromPreviewPoint(e.clientX, e.clientY, {
|
||||
preferClipAncestor: options?.preferClipAncestor ?? false,
|
||||
});
|
||||
if (!nextSelection) {
|
||||
if (!e.shiftKey) applyDomSelection(null, { revealPanel: false });
|
||||
return;
|
||||
}
|
||||
e.preventDefault();
|
||||
e.stopPropagation();
|
||||
const localPointer = previewIframeRef.current
|
||||
? getPreviewLocalPointer(previewIframeRef.current, e.clientX, e.clientY)
|
||||
: null;
|
||||
applyDomSelection(nextSelection, { additive: e.shiftKey });
|
||||
if (
|
||||
!e.shiftKey &&
|
||||
localPointer &&
|
||||
isLargeRasterDomEditSelection(nextSelection, localPointer.viewport)
|
||||
) {
|
||||
setAgentPromptSelectionContext(
|
||||
buildRasterClickSelectionContext(nextSelection, localPointer),
|
||||
);
|
||||
setAgentModalAnchorPoint({ x: e.clientX, y: e.clientY });
|
||||
void preloadAgentPromptSnippet(nextSelection);
|
||||
setAgentModalOpen(true);
|
||||
}
|
||||
},
|
||||
[
|
||||
applyDomSelection,
|
||||
captionEditMode,
|
||||
compositionLoading,
|
||||
preloadAgentPromptSnippet,
|
||||
resolveDomSelectionFromPreviewPoint,
|
||||
previewIframeRef,
|
||||
setAgentModalAnchorPoint,
|
||||
setAgentModalOpen,
|
||||
setAgentPromptSelectionContext,
|
||||
],
|
||||
);
|
||||
|
||||
const handlePreviewCanvasPointerMove = useCallback(
|
||||
(e: React.PointerEvent<HTMLDivElement>, options?: { preferClipAncestor?: boolean }) => {
|
||||
if (!STUDIO_PREVIEW_SELECTION_ENABLED || captionEditMode || compositionLoading) {
|
||||
updateDomEditHoverSelection(null);
|
||||
return null;
|
||||
}
|
||||
|
||||
const nextSelection = resolveDomSelectionFromPreviewPoint(e.clientX, e.clientY, {
|
||||
preferClipAncestor: options?.preferClipAncestor ?? false,
|
||||
});
|
||||
updateDomEditHoverSelection(nextSelection);
|
||||
return nextSelection;
|
||||
},
|
||||
[
|
||||
captionEditMode,
|
||||
compositionLoading,
|
||||
resolveDomSelectionFromPreviewPoint,
|
||||
updateDomEditHoverSelection,
|
||||
],
|
||||
);
|
||||
|
||||
const handlePreviewCanvasPointerLeave = useCallback(() => {
|
||||
updateDomEditHoverSelection(null);
|
||||
}, [updateDomEditHoverSelection]);
|
||||
|
||||
const handleBlockedDomMove = useCallback(
|
||||
(selection: DomEditSelection) => {
|
||||
showToast(
|
||||
selection.capabilities.reasonIfDisabled ??
|
||||
"This element can't be adjusted directly from the preview.",
|
||||
"info",
|
||||
);
|
||||
},
|
||||
[showToast],
|
||||
);
|
||||
|
||||
const handleDomManualDragStart = useCallback(() => {
|
||||
const pausedTime = pauseStudioPreviewPlayback(previewIframeRef.current);
|
||||
const playerStore = usePlayerStore.getState();
|
||||
playerStore.setIsPlaying(false);
|
||||
if (pausedTime != null) {
|
||||
playerStore.setCurrentTime(pausedTime);
|
||||
liveTime.notify(pausedTime);
|
||||
}
|
||||
}, [previewIframeRef]);
|
||||
|
||||
return {
|
||||
handlePreviewCanvasMouseDown,
|
||||
handlePreviewCanvasPointerMove,
|
||||
handlePreviewCanvasPointerLeave,
|
||||
handleBlockedDomMove,
|
||||
handleDomManualDragStart,
|
||||
};
|
||||
}
|
||||
@@ -0,0 +1,124 @@
|
||||
import { useCallback, type ReactNode } from "react";
|
||||
import { createElement } from "react";
|
||||
import { CompositionThumbnail, VideoThumbnail } from "../player";
|
||||
import type { TimelineElement } from "../player";
|
||||
import { AudioWaveform } from "../player/components/AudioWaveform";
|
||||
import { getTimelineElementLabel } from "../utils/studioHelpers";
|
||||
|
||||
interface UseRenderClipContentOptions {
|
||||
projectIdRef: { current: string | null };
|
||||
compIdToSrc: Map<string, string>;
|
||||
activePreviewUrl: string | null;
|
||||
effectiveTimelineDuration: number;
|
||||
}
|
||||
|
||||
export function useRenderClipContent({
|
||||
projectIdRef,
|
||||
compIdToSrc,
|
||||
activePreviewUrl,
|
||||
effectiveTimelineDuration,
|
||||
}: UseRenderClipContentOptions) {
|
||||
return useCallback(
|
||||
(el: TimelineElement, style: { clip: string; label: string }): ReactNode => {
|
||||
const pid = projectIdRef.current;
|
||||
if (!pid) return null;
|
||||
|
||||
// Resolve composition source path using the compIdToSrc map
|
||||
let compSrc = el.compositionSrc;
|
||||
if (compSrc && compIdToSrc.size > 0) {
|
||||
const resolved =
|
||||
compIdToSrc.get(el.id) ||
|
||||
compIdToSrc.get(compSrc.replace(/^compositions\//, "").replace(/\.html$/, ""));
|
||||
if (resolved) compSrc = resolved;
|
||||
}
|
||||
|
||||
// Composition clips — always use the comp's own preview URL for thumbnails.
|
||||
// This renders the composition in isolation so we get clean frames
|
||||
// instead of capturing the master at a time when the comp is fading in.
|
||||
if (compSrc) {
|
||||
return createElement(CompositionThumbnail, {
|
||||
previewUrl: `/api/projects/${pid}/preview/comp/${compSrc}`,
|
||||
label: getTimelineElementLabel(el),
|
||||
labelColor: style.label,
|
||||
accentColor: style.clip,
|
||||
seekTime: 0,
|
||||
duration: el.duration,
|
||||
});
|
||||
}
|
||||
|
||||
// When drilled into a composition, render all inner elements via
|
||||
// CompositionThumbnail at their start time — most accurate visual.
|
||||
if (activePreviewUrl && el.duration > 0) {
|
||||
return createElement(CompositionThumbnail, {
|
||||
previewUrl: activePreviewUrl,
|
||||
label: getTimelineElementLabel(el),
|
||||
labelColor: style.label,
|
||||
accentColor: style.clip,
|
||||
selector: el.selector,
|
||||
selectorIndex: el.selectorIndex,
|
||||
seekTime: el.start,
|
||||
duration: el.duration,
|
||||
});
|
||||
}
|
||||
|
||||
const htmlPreviewEligible =
|
||||
el.duration > 0 &&
|
||||
effectiveTimelineDuration > 0 &&
|
||||
el.duration < effectiveTimelineDuration * 0.92 &&
|
||||
!/(backdrop|background|overlay|scrim|mask)/i.test(el.id);
|
||||
|
||||
// Audio clips — waveform visualization
|
||||
if (el.tag === "audio") {
|
||||
const previewBase = `/api/projects/${pid}/preview/`;
|
||||
const previewIdx = el.src?.startsWith("http") ? el.src.indexOf(previewBase) : -1;
|
||||
const srcRelative = el.src
|
||||
? previewIdx !== -1
|
||||
? decodeURIComponent(el.src.slice(previewIdx + previewBase.length))
|
||||
: el.src.startsWith("http")
|
||||
? null
|
||||
: el.src
|
||||
: null;
|
||||
const audioUrl = srcRelative
|
||||
? `/api/projects/${pid}/preview/${srcRelative}`
|
||||
: (el.src ?? "");
|
||||
const waveformUrl = srcRelative
|
||||
? `/api/projects/${pid}/waveform/${srcRelative}`
|
||||
: undefined;
|
||||
return createElement(AudioWaveform, {
|
||||
audioUrl,
|
||||
waveformUrl,
|
||||
label: getTimelineElementLabel(el),
|
||||
labelColor: style.label,
|
||||
});
|
||||
}
|
||||
|
||||
if ((el.tag === "video" || el.tag === "img") && el.src) {
|
||||
const mediaSrc = el.src.startsWith("http")
|
||||
? el.src
|
||||
: `/api/projects/${pid}/preview/${el.src}`;
|
||||
return createElement(VideoThumbnail, {
|
||||
videoSrc: mediaSrc,
|
||||
label: getTimelineElementLabel(el),
|
||||
labelColor: style.label,
|
||||
duration: el.duration,
|
||||
});
|
||||
}
|
||||
|
||||
if (htmlPreviewEligible) {
|
||||
return createElement(CompositionThumbnail, {
|
||||
previewUrl: `/api/projects/${pid}/preview`,
|
||||
label: getTimelineElementLabel(el),
|
||||
labelColor: style.label,
|
||||
accentColor: style.clip,
|
||||
selector: el.selector,
|
||||
selectorIndex: el.selectorIndex,
|
||||
seekTime: el.start,
|
||||
duration: el.duration,
|
||||
});
|
||||
}
|
||||
|
||||
return null;
|
||||
},
|
||||
[projectIdRef, compIdToSrc, activePreviewUrl, effectiveTimelineDuration],
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,472 @@
|
||||
import { useCallback, useRef } from "react";
|
||||
import type { TimelineElement } from "../player";
|
||||
import { usePlayerStore } from "../player";
|
||||
import { applyPatchByTarget, readAttributeByTarget } from "../utils/sourcePatcher";
|
||||
import {
|
||||
buildTrackZIndexMap,
|
||||
formatTimelineAttributeNumber,
|
||||
} from "../player/components/timelineEditing";
|
||||
import {
|
||||
buildTimelineAssetId,
|
||||
buildTimelineAssetInsertHtml,
|
||||
buildTimelineFileDropPlacements,
|
||||
getTimelineAssetKind,
|
||||
insertTimelineAssetIntoSource,
|
||||
resolveTimelineAssetInitialGeometry,
|
||||
resolveTimelineAssetSrc,
|
||||
} from "../utils/timelineAssetDrop";
|
||||
import { saveProjectFilesWithHistory } from "../utils/studioFileHistory";
|
||||
import {
|
||||
getTimelineElementLabel,
|
||||
collectHtmlIds,
|
||||
resolveDroppedAssetDuration,
|
||||
} from "../utils/studioHelpers";
|
||||
import type { EditHistoryKind } from "../utils/editHistory";
|
||||
|
||||
// ── Types ──
|
||||
|
||||
interface RecordEditInput {
|
||||
label: string;
|
||||
kind: EditHistoryKind;
|
||||
coalesceKey?: string;
|
||||
files: Record<string, { before: string; after: string }>;
|
||||
}
|
||||
|
||||
interface UseTimelineEditingOptions {
|
||||
projectId: string | null;
|
||||
activeCompPath: string | null;
|
||||
timelineElements: TimelineElement[];
|
||||
showToast: (message: string, tone?: "error" | "info") => void;
|
||||
writeProjectFile: (path: string, content: string) => Promise<void>;
|
||||
recordEdit: (input: RecordEditInput) => Promise<void>;
|
||||
domEditSaveTimestampRef: React.MutableRefObject<number>;
|
||||
reloadPreview: () => void;
|
||||
uploadProjectFiles: (files: Iterable<File>, dir?: string) => Promise<string[]>;
|
||||
}
|
||||
|
||||
// ── Helpers ──
|
||||
|
||||
function buildPatchTarget(element: { domId?: string; selector?: string; selectorIndex?: number }) {
|
||||
if (element.domId) {
|
||||
return { id: element.domId, selector: element.selector, selectorIndex: element.selectorIndex };
|
||||
}
|
||||
if (element.selector) {
|
||||
return { selector: element.selector, selectorIndex: element.selectorIndex };
|
||||
}
|
||||
return null;
|
||||
}
|
||||
|
||||
async function readFileContent(projectId: string, targetPath: string): Promise<string> {
|
||||
const response = await fetch(
|
||||
`/api/projects/${projectId}/files/${encodeURIComponent(targetPath)}`,
|
||||
);
|
||||
if (!response.ok) {
|
||||
throw new Error(`Failed to read ${targetPath}`);
|
||||
}
|
||||
const data = (await response.json()) as { content?: string };
|
||||
if (typeof data.content !== "string") {
|
||||
throw new Error(`Missing file contents for ${targetPath}`);
|
||||
}
|
||||
return data.content;
|
||||
}
|
||||
|
||||
// ── Hook ──
|
||||
|
||||
export function useTimelineEditing({
|
||||
projectId,
|
||||
activeCompPath,
|
||||
timelineElements,
|
||||
showToast,
|
||||
writeProjectFile,
|
||||
recordEdit,
|
||||
domEditSaveTimestampRef,
|
||||
reloadPreview,
|
||||
uploadProjectFiles,
|
||||
}: UseTimelineEditingOptions) {
|
||||
const projectIdRef = useRef(projectId);
|
||||
projectIdRef.current = projectId;
|
||||
|
||||
const lastBlockedTimelineToastAtRef = useRef(0);
|
||||
|
||||
const handleTimelineElementMove = useCallback(
|
||||
async (element: TimelineElement, updates: Pick<TimelineElement, "start" | "track">) => {
|
||||
const pid = projectIdRef.current;
|
||||
if (!pid) throw new Error("No active project");
|
||||
|
||||
const targetPath = element.sourceFile || activeCompPath || "index.html";
|
||||
const originalContent = await readFileContent(pid, targetPath);
|
||||
|
||||
const patchTarget = buildPatchTarget(element);
|
||||
if (!patchTarget) {
|
||||
throw new Error(`Timeline element ${element.id} is missing a patchable target`);
|
||||
}
|
||||
|
||||
const resolvedTargetPath = targetPath || "index.html";
|
||||
const relevantElements = timelineElements
|
||||
.map((te) =>
|
||||
(te.key ?? te.id) === (element.key ?? element.id)
|
||||
? { ...te, start: updates.start, track: updates.track }
|
||||
: te,
|
||||
)
|
||||
.filter((te) => (te.sourceFile || activeCompPath || "index.html") === resolvedTargetPath);
|
||||
const trackZIndices = buildTrackZIndexMap(relevantElements.map((te) => te.track));
|
||||
|
||||
let patchedContent = applyPatchByTarget(originalContent, patchTarget, {
|
||||
type: "attribute",
|
||||
property: "start",
|
||||
value: formatTimelineAttributeNumber(updates.start),
|
||||
});
|
||||
patchedContent = applyPatchByTarget(patchedContent, patchTarget, {
|
||||
type: "attribute",
|
||||
property: "track-index",
|
||||
value: String(updates.track),
|
||||
});
|
||||
for (const te of relevantElements) {
|
||||
const elementTarget = buildPatchTarget(te);
|
||||
if (!elementTarget) continue;
|
||||
const nextZIndex = trackZIndices.get(te.track);
|
||||
if (nextZIndex == null) continue;
|
||||
patchedContent = applyPatchByTarget(patchedContent, elementTarget, {
|
||||
type: "inline-style",
|
||||
property: "z-index",
|
||||
value: String(nextZIndex),
|
||||
});
|
||||
}
|
||||
|
||||
if (patchedContent === originalContent) {
|
||||
throw new Error(`Unable to patch timeline element ${element.id} in ${targetPath}`);
|
||||
}
|
||||
|
||||
domEditSaveTimestampRef.current = Date.now();
|
||||
await saveProjectFilesWithHistory({
|
||||
projectId: pid,
|
||||
label: "Move timeline clip",
|
||||
kind: "timeline",
|
||||
files: { [targetPath]: patchedContent },
|
||||
readFile: async () => originalContent,
|
||||
writeFile: writeProjectFile,
|
||||
recordEdit,
|
||||
});
|
||||
|
||||
reloadPreview();
|
||||
},
|
||||
[
|
||||
activeCompPath,
|
||||
recordEdit,
|
||||
timelineElements,
|
||||
writeProjectFile,
|
||||
domEditSaveTimestampRef,
|
||||
reloadPreview,
|
||||
],
|
||||
);
|
||||
|
||||
const handleTimelineElementResize = useCallback(
|
||||
async (
|
||||
element: TimelineElement,
|
||||
updates: Pick<TimelineElement, "start" | "duration" | "playbackStart">,
|
||||
) => {
|
||||
const pid = projectIdRef.current;
|
||||
if (!pid) throw new Error("No active project");
|
||||
|
||||
const targetPath = element.sourceFile || activeCompPath || "index.html";
|
||||
const originalContent = await readFileContent(pid, targetPath);
|
||||
|
||||
const patchTarget = buildPatchTarget(element);
|
||||
if (!patchTarget) {
|
||||
throw new Error(`Timeline element ${element.id} is missing a patchable target`);
|
||||
}
|
||||
|
||||
const playbackStartAttrName =
|
||||
element.playbackStartAttr === "playback-start" ? "playback-start" : "media-start";
|
||||
const currentPlaybackStartValue =
|
||||
readAttributeByTarget(originalContent, patchTarget, "playback-start") ??
|
||||
readAttributeByTarget(originalContent, patchTarget, "media-start");
|
||||
const currentPlaybackStart =
|
||||
currentPlaybackStartValue != null ? parseFloat(currentPlaybackStartValue) : undefined;
|
||||
const trimDelta = updates.start - element.start;
|
||||
const fallbackPlaybackStart =
|
||||
updates.playbackStart == null &&
|
||||
trimDelta !== 0 &&
|
||||
Number.isFinite(currentPlaybackStart) &&
|
||||
currentPlaybackStart != null
|
||||
? Math.max(0, currentPlaybackStart + trimDelta * Math.max(element.playbackRate ?? 1, 0.1))
|
||||
: undefined;
|
||||
const nextPlaybackStart = updates.playbackStart ?? fallbackPlaybackStart;
|
||||
|
||||
let patchedContent = originalContent;
|
||||
patchedContent = applyPatchByTarget(patchedContent, patchTarget, {
|
||||
type: "attribute",
|
||||
property: "start",
|
||||
value: formatTimelineAttributeNumber(updates.start),
|
||||
});
|
||||
patchedContent = applyPatchByTarget(patchedContent, patchTarget, {
|
||||
type: "attribute",
|
||||
property: "duration",
|
||||
value: formatTimelineAttributeNumber(updates.duration),
|
||||
});
|
||||
if (nextPlaybackStart != null) {
|
||||
patchedContent = applyPatchByTarget(patchedContent, patchTarget, {
|
||||
type: "attribute",
|
||||
property: playbackStartAttrName,
|
||||
value: formatTimelineAttributeNumber(nextPlaybackStart),
|
||||
});
|
||||
}
|
||||
|
||||
if (patchedContent === originalContent) {
|
||||
throw new Error(`Unable to patch timeline element ${element.id} in ${targetPath}`);
|
||||
}
|
||||
|
||||
domEditSaveTimestampRef.current = Date.now();
|
||||
await saveProjectFilesWithHistory({
|
||||
projectId: pid,
|
||||
label: "Resize timeline clip",
|
||||
kind: "timeline",
|
||||
files: { [targetPath]: patchedContent },
|
||||
readFile: async () => originalContent,
|
||||
writeFile: writeProjectFile,
|
||||
recordEdit,
|
||||
});
|
||||
|
||||
reloadPreview();
|
||||
},
|
||||
[activeCompPath, recordEdit, writeProjectFile, domEditSaveTimestampRef, reloadPreview],
|
||||
);
|
||||
|
||||
const handleTimelineElementDelete = useCallback(
|
||||
async (element: TimelineElement) => {
|
||||
const pid = projectIdRef.current;
|
||||
if (!pid) throw new Error("No active project");
|
||||
const label = getTimelineElementLabel(element);
|
||||
|
||||
const targetPath = element.sourceFile || activeCompPath || "index.html";
|
||||
try {
|
||||
const originalContent = await readFileContent(pid, targetPath);
|
||||
|
||||
const patchTarget = buildPatchTarget(element);
|
||||
if (!patchTarget) {
|
||||
throw new Error(`Timeline element ${element.id} is missing a patchable target`);
|
||||
}
|
||||
|
||||
const resolvedTargetPath = targetPath || "index.html";
|
||||
const remainingElements = timelineElements.filter(
|
||||
(te) =>
|
||||
(te.key ?? te.id) !== (element.key ?? element.id) &&
|
||||
(te.sourceFile || activeCompPath || "index.html") === resolvedTargetPath,
|
||||
);
|
||||
const trackZIndices = buildTrackZIndexMap(remainingElements.map((te) => te.track));
|
||||
|
||||
const removeResponse = await fetch(
|
||||
`/api/projects/${pid}/file-mutations/remove-element/${encodeURIComponent(targetPath)}`,
|
||||
{
|
||||
method: "POST",
|
||||
headers: { "Content-Type": "application/json" },
|
||||
body: JSON.stringify({ target: patchTarget }),
|
||||
},
|
||||
);
|
||||
if (!removeResponse.ok) {
|
||||
throw new Error(`Failed to delete ${element.id} from ${targetPath}`);
|
||||
}
|
||||
|
||||
const removeData = (await removeResponse.json()) as {
|
||||
changed?: boolean;
|
||||
content?: string;
|
||||
};
|
||||
let patchedContent =
|
||||
typeof removeData.content === "string" ? removeData.content : originalContent;
|
||||
for (const te of remainingElements) {
|
||||
const elementTarget = buildPatchTarget(te);
|
||||
if (!elementTarget) continue;
|
||||
const nextZIndex = trackZIndices.get(te.track);
|
||||
if (nextZIndex == null) continue;
|
||||
patchedContent = applyPatchByTarget(patchedContent, elementTarget, {
|
||||
type: "inline-style",
|
||||
property: "z-index",
|
||||
value: String(nextZIndex),
|
||||
});
|
||||
}
|
||||
|
||||
domEditSaveTimestampRef.current = Date.now();
|
||||
await saveProjectFilesWithHistory({
|
||||
projectId: pid,
|
||||
label: "Delete timeline clip",
|
||||
kind: "timeline",
|
||||
files: { [targetPath]: patchedContent },
|
||||
readFile: async () => originalContent,
|
||||
writeFile: writeProjectFile,
|
||||
recordEdit,
|
||||
});
|
||||
|
||||
usePlayerStore
|
||||
.getState()
|
||||
.setElements(
|
||||
timelineElements.filter((te) => (te.key ?? te.id) !== (element.key ?? element.id)),
|
||||
);
|
||||
usePlayerStore.getState().setSelectedElementId(null);
|
||||
reloadPreview();
|
||||
showToast(`Deleted ${label}. Use Undo to restore it.`, "info");
|
||||
} catch (error) {
|
||||
const message = error instanceof Error ? error.message : "Failed to delete timeline clip";
|
||||
showToast(message);
|
||||
}
|
||||
},
|
||||
[
|
||||
activeCompPath,
|
||||
recordEdit,
|
||||
showToast,
|
||||
timelineElements,
|
||||
writeProjectFile,
|
||||
domEditSaveTimestampRef,
|
||||
reloadPreview,
|
||||
],
|
||||
);
|
||||
|
||||
const handleTimelineAssetDrop = useCallback(
|
||||
async (
|
||||
assetPath: string,
|
||||
placement: Pick<TimelineElement, "start" | "track">,
|
||||
durationOverride?: number,
|
||||
) => {
|
||||
const pid = projectIdRef.current;
|
||||
if (!pid) throw new Error("No active project");
|
||||
|
||||
const kind = getTimelineAssetKind(assetPath);
|
||||
if (!kind) {
|
||||
showToast("Only image, video, and audio assets can be dropped onto the timeline.");
|
||||
return;
|
||||
}
|
||||
|
||||
const targetPath = activeCompPath || "index.html";
|
||||
try {
|
||||
const originalContent = await readFileContent(pid, targetPath);
|
||||
|
||||
const normalizedStart = Number(formatTimelineAttributeNumber(placement.start));
|
||||
const duration =
|
||||
Number.isFinite(durationOverride) && durationOverride != null && durationOverride > 0
|
||||
? durationOverride
|
||||
: await resolveDroppedAssetDuration(pid, assetPath, kind);
|
||||
const normalizedDuration = Number(formatTimelineAttributeNumber(duration));
|
||||
const newId = buildTimelineAssetId(assetPath, collectHtmlIds(originalContent));
|
||||
const resolvedAssetSrc = resolveTimelineAssetSrc(targetPath, assetPath);
|
||||
|
||||
const resolvedTargetPath = targetPath || "index.html";
|
||||
const relevantElements = timelineElements.filter(
|
||||
(te) => (te.sourceFile || activeCompPath || "index.html") === resolvedTargetPath,
|
||||
);
|
||||
const trackZIndices = buildTrackZIndexMap([
|
||||
...relevantElements.map((te) => te.track),
|
||||
placement.track,
|
||||
]);
|
||||
|
||||
let patchedContent = originalContent;
|
||||
for (const te of relevantElements) {
|
||||
const elementTarget = buildPatchTarget(te);
|
||||
if (!elementTarget) continue;
|
||||
const nextZIndex = trackZIndices.get(te.track);
|
||||
if (nextZIndex == null) continue;
|
||||
patchedContent = applyPatchByTarget(patchedContent, elementTarget, {
|
||||
type: "inline-style",
|
||||
property: "z-index",
|
||||
value: String(nextZIndex),
|
||||
});
|
||||
}
|
||||
|
||||
patchedContent = insertTimelineAssetIntoSource(
|
||||
patchedContent,
|
||||
buildTimelineAssetInsertHtml({
|
||||
id: newId,
|
||||
assetPath: resolvedAssetSrc,
|
||||
kind,
|
||||
start: normalizedStart,
|
||||
duration: normalizedDuration,
|
||||
track: placement.track,
|
||||
zIndex: trackZIndices.get(placement.track) ?? 1,
|
||||
geometry: resolveTimelineAssetInitialGeometry(originalContent),
|
||||
}),
|
||||
);
|
||||
|
||||
domEditSaveTimestampRef.current = Date.now();
|
||||
await saveProjectFilesWithHistory({
|
||||
projectId: pid,
|
||||
label: "Add timeline asset",
|
||||
kind: "timeline",
|
||||
files: { [targetPath]: patchedContent },
|
||||
readFile: async () => originalContent,
|
||||
writeFile: writeProjectFile,
|
||||
recordEdit,
|
||||
});
|
||||
|
||||
reloadPreview();
|
||||
} catch (error) {
|
||||
const message =
|
||||
error instanceof Error ? error.message : "Failed to drop asset onto timeline";
|
||||
showToast(message);
|
||||
}
|
||||
},
|
||||
[
|
||||
activeCompPath,
|
||||
recordEdit,
|
||||
showToast,
|
||||
timelineElements,
|
||||
writeProjectFile,
|
||||
domEditSaveTimestampRef,
|
||||
reloadPreview,
|
||||
],
|
||||
);
|
||||
|
||||
const handleTimelineFileDrop = useCallback(
|
||||
async (files: File[], placement?: Pick<TimelineElement, "start" | "track">) => {
|
||||
const pid = projectIdRef.current;
|
||||
if (!pid) return;
|
||||
const uploaded = await uploadProjectFiles(files);
|
||||
if (uploaded.length === 0) return;
|
||||
const durations: number[] = [];
|
||||
for (const assetPath of uploaded) {
|
||||
const kind = getTimelineAssetKind(assetPath);
|
||||
const duration = kind ? await resolveDroppedAssetDuration(pid, assetPath, kind) : 0;
|
||||
durations.push(Number(formatTimelineAttributeNumber(duration)));
|
||||
}
|
||||
const placements = buildTimelineFileDropPlacements(
|
||||
placement ?? { start: 0, track: 0 },
|
||||
durations,
|
||||
timelineElements
|
||||
.filter(
|
||||
(te) =>
|
||||
(te.sourceFile || activeCompPath || "index.html") ===
|
||||
(activeCompPath || "index.html"),
|
||||
)
|
||||
.map((te) => ({
|
||||
start: te.start,
|
||||
duration: te.duration,
|
||||
track: te.track,
|
||||
})),
|
||||
);
|
||||
for (const [index, assetPath] of uploaded.entries()) {
|
||||
await handleTimelineAssetDrop(
|
||||
assetPath,
|
||||
placements[index] ?? placements[0],
|
||||
durations[index],
|
||||
);
|
||||
}
|
||||
},
|
||||
[activeCompPath, handleTimelineAssetDrop, timelineElements, uploadProjectFiles],
|
||||
);
|
||||
|
||||
const handleBlockedTimelineEdit = useCallback(
|
||||
(_element: TimelineElement) => {
|
||||
const now = Date.now();
|
||||
if (now - lastBlockedTimelineToastAtRef.current < 1500) return;
|
||||
lastBlockedTimelineToastAtRef.current = now;
|
||||
showToast("This clip can't be moved or resized from the timeline yet.", "info");
|
||||
},
|
||||
[showToast],
|
||||
);
|
||||
|
||||
return {
|
||||
handleTimelineElementMove,
|
||||
handleTimelineElementResize,
|
||||
handleTimelineElementDelete,
|
||||
handleTimelineAssetDrop,
|
||||
handleTimelineFileDrop,
|
||||
handleBlockedTimelineEdit,
|
||||
};
|
||||
}
|
||||
Reference in New Issue
Block a user