mirror of
https://github.com/heygen-com/hyperframes.git
synced 2026-09-03 12:54:29 +00:00
* feat(studio): mirror canvas z-order actions into timeline lanes, badge z overrides
Track order = default paint order; authored z = advanced override.
- timelineZMirror.ts: pure resolver mapping a successful z-menu action to a
timeline lane move — closest track in the action's direction that is free
over the clip's whole span, else a new lane adjacent to the crossed
neighbor; temporal-overlap scope (default pending product sign-off, see
module doc); visual zone only; same-file reference scoping; persistTrack
via the shared authored-space rules. null for non-clips (menu stays
z-only) and at-extreme/no-overlap cases.
- useCanvasZOrderTimelineMirror.ts: after the z commit resolves, the mirror
persists the lane move through the same machinery as a timeline lane drag
(optimistic store update, authoredTrack refresh, rollback); inserts reuse
commitTrackInsert's renumber via a shared buildTrackInsertEdits core. Both
writes share one coalesce key (zReorderCoalesceKey) and fold into ONE undo
entry (test proves it over the real history reducer). The mirror never
triggers the lane->z stacking sync, so it cannot fight the z values the
action just set.
- timelineZOverride.ts + TimelineClip badge: clips whose paint order
contradicts lane order among temporally-overlapping same-context visual
neighbors (laneIsAbove XOR paintsAbove, the stacking-sync predicates) show
a 'z' badge — authored z overrides are surfaced instead of silently
disagreeing with the timeline.
- Timeline.tsx track derivations extracted to useTimelineTrackDerivations
(600-line cap).
* fix(studio): fold mirrored z-order gestures into one undo entry across slow persists
Live verification caught the z write and the mirrored lane write splitting
into two undo entries: the mirror runs after the z persist's server round
trip, which exceeds editHistory's default 300ms coalesce window under real
latency (the unit test's deterministic clock sat inside it).
zReorderCoalesceKey now mints a per-gesture-unique key (monotonic seq, the
laneChangeGestureSeq precedent) and both records carry coalesceMs Infinity —
distinct gestures can never merge, and one gesture always folds regardless
of write latency. coalesceMs threaded through the persist chain alongside
coalesceKey. Also hardens the existing lane-drag move->z fold, which had the
same latent split. Fold test now simulates a 400ms gap (failed before the
fix, passes after); a two-separate-gestures test asserts two entries.
* feat(studio): flashless lane mirror, z-order menu icons, close-gap track menu
- Track-only batch moves (the z-mirror's lane hop and the insert renumber)
skip the GSAP fallback round-trip and the preview reload entirely — the
renderer never reads data-track-index, and the live DOM patch + optimistic
store update cover the UI. Mixed batches keep current behavior. Kills the
canvas blink on mirrored Bring/Send actions (live-verified: an
iframe-scoped marker survives the whole gesture).
- The four z-order menu items get 16px stroke icons (single layer diamond +
directional arrow for Forward/Backward; pierced two-layer stack for
Front/Back); labels unchanged — they are the industry-standard names.
- New track context menu on empty lane space: 'Close gap' (shifts the next
clip and every clip after it on that lane left by the clicked gap's width;
leading gaps count, so a single clip with empty space before it compacts
to 0) and 'Close all gaps' (whole lane contiguous from 0). Pure gap math
in timelineGaps.ts; persists through the drag path's atomic batch move
(one undo per action); refuses when a clip that must shift is locked;
items disable when there is nothing to close.
* fix(studio): rebind-only preview sync for unmutated timing edits, classical z-menu order
Timing edits that rewrote NO GSAP positions (gap closes and moves of
selector-addressed caption clips, zero-delta batches, comps without a
rewritable script) full-reloaded the preview — and the rerun-current-scripts
attempt was wrong for real compositions: re-executing init-style scripts
(three.js scenes, caption engines) is exactly the unsafe case, verified live
by doubled init warnings and a fallback reload anyway.
The correct observation: when mutated === false the existing __timelines are
still valid — only the runtime's clip visibility windows are stale, and the
live DOM timing attributes were already patched. So the no-mutation path now
runs applySoftReloadFinalization only (seek + __hfForceTimelineRebind +
manual-edits reapply), extracted from the soft-reload machinery — zero
script execution. This also un-blinks comps with no GSAP script at all,
which previously always remounted. Rewritten-script soft reloads,
cannot-soft-reload, otherFileChanged, and mutation failures keep their
existing behavior. gsapSoftReload's undo/redo restore section moved verbatim
to gsapUndoRestore.ts for the 600-line cap.
Also: z-order menu items reordered to the classical arrangement (Bring to
Front, Bring Forward, Send Backward, Send to Back).
Live-verified on a three.js-heavy composition: Close-all-gaps shifted 4
caption clips with correct cumulative amounts, the preview iframe was never
remounted (marker survived), and one undo reverted everything.
* fix(studio): bound forward/backward mirror to a one-element step
User-specified semantic: Bring Forward / Send Backward move the clip past
EXACTLY ONE element. The mirror's lane target is now bounded by the next
temporally-overlapping element beyond the crossed neighbor: a free lane
strictly between the two is taken (closest to the neighbor), and when they
are back-to-back a new track is inserted immediately beyond the crossed
element — never past the second one. Previously the resolver took the
closest free lane anywhere beyond the neighbor, which could carry the track
past a second element while the z action only stepped past one — a
track/paint contradiction our own zOverride badge would flag. Front/back
keep whole-set semantics (past everything; back stays above the audio
zone). End-to-end test pins the 3-stacked case through commitZMirrorLaneMove
to the persisted renumbered tracks.
* feat(studio): permanent gap-menu rows with hover and click-select gap highlights
- TrackGapContextMenu always renders both rows; an inapplicable action dims
with a tooltip ("No gap here" / lock reason / "No gaps on this track")
instead of vanishing into a one-item menu. Width badge only when a gap
exists under the pointer.
- Hovering an ACTIONABLE row highlights the strip(s) it would close in the
timeline: the single gap for Close gap, every current gap (leading included)
for Close all gaps. New resolveAllGapIntervals in timelineGaps.ts reports
present-state intervals (epsilon-tolerant, overlap-safe), distinct from
resolveAllTrackGaps' post-compaction starts.
- Click-selecting a single clip paints a quieter tint over its lane's gaps
(suppressed for marquee multi-selection and during drags; the gap-menu hover
wins on its own lane). Derivation lives in useTimelineGapHighlights with the
pure buildTimelineGapStrips exported and unit-tested.
- Strips render in TimelineCanvas with the drop-placeholder geometry (row top
+ clip inset), dashed accent for hover, faint tint for selection.
- Timeline.tsx stayed under the 600-line cap by extracting the scroll-viewport
plumbing (ResizeObserver width + shortcut-hint sync) into
useTimelineScrollViewport, behavior unchanged.
* feat(studio): stronger capcut-style timeline zoom steps
One button press / pinch gesture now moves the zoom meaningfully: step
factors 1.25x/0.8x -> 1.5x/(2/3) (kept reciprocal so in+out round-trips) and
pinch sensitivity 0.0035 -> 0.007. Addresses "zooming several times to get
anywhere" feedback; cursor anchoring unchanged.
* feat(studio): three-way z sync — layers drags mirror timeline lanes, panel tracks live z edits
Completes the layers/canvas/timeline sync triangle: the Layers panel was the
one surface whose reorders never reached the timeline, and the one that went
stale when the other two wrote z flashlessly.
- Layers drag -> minimal z + equal-jump lane mirror. handleReorder now uses
the canvas menu's realization core via resolveZOrderReposition (one
between-z write when a strict gap exists, band-safe scoped renumber
otherwise) instead of computeReorderZValues' all-sibling stamp — that
helper is deleted, completing the #2347 unification follow-up. The drop
then mirrors into a timeline lane move through the same machinery as the
canvas menu (new resolveRepositionLaneMove: the clip lands on a free lane
strictly between its NEW paint neighbors' lanes — nearest clip siblings in
the desired render order, decorations skipped — else a track insert at
that boundary; audio zone never crossed). Both writes share one
per-gesture zReorderCoalesceKey with an unbounded fold window, so a drag
is exactly ONE undo entry; useCanvasZOrderTimelineMirror's plumbing is
factored into useMirrorLaneMoveCommit and reused by the new
useLayerReorderTimelineMirror. A same-slot drop is a hard no-op (new
order-equality guard in resolveZOrderReposition).
- Panel staleness fix: flashless z commits (skipReload) reload nothing and
bump no refreshKey, so the panel's z-sorted order went stale while paused.
handleDomZIndexReorderCommit now bumps a store zEditVersion on apply AND
rollback; the panel re-collects on it. Verified live: the panel re-sorts
the instant a drag commits and again on undo.
- Layer click reveal (useLayerRevealOverride): clicking a layer that stays
hidden at the current frame (animation-parked opacity, non-clip
display/visibility hides, hidden ancestors) temporarily forces the chain
visible with live inline styles — exact priors restored on deselect, on
another reveal, on play, and on unmount; never persisted (file diff == 0
verified live). Clips keep the existing seek-into-window behavior; the
override applies on a short defer so a seek-revealed clip needs none.
- layerOrdering's unused hasExplicitZIndex probe (zero callers) removed.
Live-verified on a bed copy: a 2-position layers drag wrote exactly one
element (z 6->23 + data-track-index 15->2), the timeline lane moved without
a reload, and a single Cmd+Z restored the file byte-identically.
* feat(studio): full-track selection highlight, borderless gap hover strips
- Click-selecting a clip now lights the WHOLE lane minus its clips — leading
gap, inter-clip gaps, and the open space after the last clip to the rendered
end (new resolveLaneEmptyIntervals; displayDuration threaded into the strip
derivation). Still click-only: any drag/resize suppresses the strips, and a
marquee multi-select never shows them.
- The gap-menu hover strips drop the dashed border (user feedback) — fill only,
nudged to 0.18 alpha to keep the same visual weight.
* feat(studio): selected layer paints on top via a reader-transparent z lift
Clicking a layer in the Layers tab now shows the element as if it were at the
very top of the stack while selected — whatever its authored z or panel
position — extending the reveal override (which already forced hidden chains
visible) with a temporary inline z lift:
- liftElementToTop parks the TRUE effective z in data-hf-reveal-prior-z and
writes a far-top inline z; a static element gets a layout-preserving
position:relative with its prior parked in data-hf-reveal-prior-pos. Only
the RENDERER sees the lift: all three studio z readers
(readTimelineElementZIndex, getElementZIndex, readEffectiveZIndex) return
the parked prior while the attribute is present, so the canvas z-menu, the
zOverride badge, the lane mirror, the stacking sync, and the panel sort
keep reasoning on the element's real z.
- Strictly ephemeral: exact priors restored on deselect / another reveal /
play / unmount, each property only while it still holds the value the
override wrote (a later real edit is never clobbered). File diff == 0
verified live across a full lift/restore cycle.
- A z-reorder commit CONSUMES an active lift (handleDomZIndexReorderCommit
reads the parked position for its persist-position:relative static check,
then drops the attributes) — the committed z becomes the truth and the
later restore is a guarded no-op.
* fix(studio): flashless undo/redo — three full-reload causes in the soft-restore path
Cmd+Z blinked the canvas on essentially every undo. Three independent causes
in applyUndoRestoreToPreview, each sufficient on its own:
1. Master-view path gate: activeCompPath is NULL at the master view, so the
'paths[0] === activeCompPath' eligibility check could never match the
index.html restore and every default-view undo full-reloaded at the first
gate. Normalized to the codebase-wide 'activeCompPath ?? "index.html"'.
2. Nested identity innerHTML check: the diff compared each identified
element's innerHTML, but the composition root wraps every clip — any child
change re-detected at the root rejected the restore. Change detection now
compares only each element's OWN attribute surface; structure/text
integrity is still guaranteed by the normalize-residual whole-doc pass
(text nodes, added/removed elements, and un-identified attrs all remain
after normalization and force the full reload).
3. id-only identity: elements addressed by data-hf-id / selector (no DOM id)
fell outside the diff entirely. Identity is now id OR data-hf-id, with the
live sync resolving either.
Also stop re-running an UNCHANGED GSAP script: attribute-only restores (z,
lane, timing, style — the overwhelmingly common undo) now use the rebind-only
finalization (seek + __hfForceTimelineRebind + manual reapply, zero script
execution — the same path as flashless timing edits), instead of tearing down
and rebuilding live timelines or full-reloading when the script can't be
scoped. A restore whose script text genuinely changed still re-runs it via
applySoftReload, and structural restores (split/delete) still full-reload.
Live-verified on the bed (iframe marker): gap-close undo AND redo both keep
the iframe mounted, live DOM lands on the restored values, disk restored
byte-identically.
* feat(studio): left breathing pad before t=0, double zoom sensitivity again
TRACKS_LEFT_PAD (48px) — the horizontal sibling of TRACKS_TOP_PAD: empty lane
surface between the sticky gutter and the ruler's 00:00 / the first clips,
scrolling WITH the content.
- The lanes and the ruler realize it as a plain flow spacer between the
sticky gutter cell and the time-mapped content div, so every
content-relative computation (clip left = t*pps, beat lines, lane-menu
time, clip drag deltas) is untouched by construction.
- Canvas-space overlays shift by the pad: playhead (getTimelinePlayheadLeft),
gap strips, drop placeholder, snap guide, range highlight, marquee clip
rects, beat SVG; the insert line spans the pad.
- Every pointer->time inverse subtracts it symmetrically: seekFromX, razor,
range/marquee anchors, asset drops, and the zoom-anchor gutter basis; fit
pps and the display width account for the consumed viewport width.
- Live-verified: t=0 clip edge, the 00:00 tick, and the playhead line center
all sit at GUTTER + TRACKS_LEFT_PAD, and a ruler click lands the playhead
center exactly under the pointer.
Also doubles the timeline zoom sensitivity again (user feedback after
feel-testing the first bump): button steps 1.5x/(2/3) -> 2x/0.5, pinch
0.007 -> 0.014.
* fix(studio): left pad renders as true empty space, not lane surface
The pad before t=0 inherited each row's background and bottom border from the
row wrapper, so it read as track lanes. Lane visuals now live on the cells:
the sticky gutter keeps its own separator (header column stays delineated),
the time-mapped content div carries the row background + separator, and the
pad spacer stays transparent — bare shell background, no lines. The
new-track insertion line also starts at the pad's end instead of crossing it.
* fix(studio): no vertical line in the ruler band before 00:00
The ruler corner's right border drew the header-boundary line through the
ruler strip, so the band didn't read as starting at 00:00. Dropped it — the
boundary line belongs to the track rows below; the ruler stays completely
clean from the panel edge to the first tick, matching the empty left pad.
* refactor(studio): remove the timeline z-override badge
User decision: the "z" chip on clips never earned its place — dropped
entirely (timelineZOverride.ts + test deleted, TimelineClip badge rendering
and the zOverrideKeys derivation/threading removed). This also eliminates the
review's D2 finding at the root: the badge's cross-document comparison
(stackingContextId ?? null collides across source files in the expanded view)
produced false positives, and there is no longer a detector to mis-fire.
overlapsInTime/paintsAbove lose their export (the badge was their only
external consumer); the paint-order predicate itself is unchanged.
* fix(studio): collision-free expanded child lanes and host-window gap floors
Review findings D1 (blocker) and 4.
- D1: buildChildElements assigned expanded children synthetic display rows as
`host.track + index` — integers that can EQUAL a real clip's lane in another
file (host on 0 with two children puts child #2 on 1). Lane grouping merges
purely by track number, so the collision fused clips from different source
files into one display lane, and lane-scoped actions (the gap menu) then
batch-persisted a foreign file's clip. Children now take FRACTIONS strictly
between the host's lane and the next integer — structurally unable to
collide with any normalized lane, while still rendering as ordered rows
under the host. Regression test pins the reviewer's exact two-file scenario.
- Finding 4: gap math compacted toward absolute 0, but an expanded child's
display time is host-anchored — close/compact could drag it before its host
window and persist a wrong (even negative) local time. All gap functions
now take a lane FLOOR (laneGapFloor: 0 for ordinary lanes, the children's
expandedParentStart for child lanes — single-origin per lane post-D1),
threaded through the menu model, hover highlights, selected-lane strips,
and both commits. Close-gap shifts clamp at the gap's own left edge.
* fix(studio): scope mirror references, insert writes, and crossed-neighbor identity
Review findings 1, 2, and 3.
- Finding 1: buildTrackInsertEdits normalized the FULL display set and
persisted every shifted clip — writing host-lane numbers into OTHER
composition files when expanded children were showing. The renumber write
set is now the edited element's own source file (the sanctioned multi-write
converges one FILE to lane space, never neighbors' files); foreign clips
keep their authored tracks and re-derive display lanes. The locked-clip
refusal scopes the same way. Expanded-origin elements refuse the insert
outright (a new lane is a host-space renumber, meaningless in the child's
file), and the mirrors restrict an expanded child's lane candidates to its
own siblings' lanes — a sub-comp child still mirrors WITHIN its sub-comp
(persisting the sibling's authored track) but can never land on a host lane
with no same-file occupant. authoredTrackForLane's offset fallback rounds:
fractional synthetic rows must never leak fractions into data-track-index.
- Finding 2: the mirror comparison sets required only sameSourceFile, but a
file can contain several CSS stacking contexts and leaf z is only
comparable within one. Both resolvers now scope by samePaintScope — same
source file AND same stackingContextId (the file check also stops null root
contexts of different files from comparing equal in the expanded view).
- Finding 3: the crossed-neighbor key was derived without selectorIndex, so
duplicate class selectors (.sub) resolved to occurrence 0 — a different
clip. The key now carries getSelectorIndex, matching how z-reorder entries
derive theirs.
* fix(studio): z-to-lane gestures are one serialized transaction gated on durable persists
Review findings 5 and 7.
- Finding 5: commitDomEditPatchBatches resolved successfully even when the
server matched NO patch target — the z write never reached disk (the
preview reloads to reconverge) yet the lane mirror still ran, desyncing
track order from what actually paints. The commit now resolves a durability
report ({allMatched, changed}; the save queue and commit types are generic
over the result), and the mirror phase is skipped on allMatched === false.
- Finding 7: the z persist rides the DOM-edit save queue while the lane move
rides the timeline/SDK path — two queues, so a second rapid gesture's z
write could land BETWEEN the first gesture's z and lane phases. Every
z-to-lane gesture (canvas z-order menu AND Layers-panel drag) now runs
through runZLaneGesture: a single module-level tail that serializes the
COMPLETE two-phase transaction, with unit tests for ordering, the
durability gate, and queue resilience to failed gestures. The timeline
lane-drag's inverse (move-then-z-sync) shares its phases' await ordering
already; cross-gesture serialization for that path is noted as follow-up.
- LayersPanel's pure sort helpers moved to layersPanelSort.ts (600-line cap).
* fix(studio): multi-clip GSAP batch mutations roll back on late failure
Review finding 6. finishGroupTimingGsapFallback mutates files sequentially
per clip; a late per-clip failure left the earlier rewrites on disk with no
aggregate history entry — unreachable by undo. foldGsapMutationIntoHistory
already snapshots every touched path before mutating; on a mutation failure
it now restores each path whose disk content changed (all-or-nothing batch),
reports restore errors without masking the original failure, and rethrows.
Regression test drives a two-clip batch whose second rewrite fails and
asserts the first clip's write is restored byte-identically.
* fix(studio): scope mirror inserts to their lane zone
* fix(studio): unify source-scoped clip identity
* fix(studio): isolate track insert topology
* fix(studio): harden timeline paint synchronization
---------
Co-authored-by: Miguel Angel Simon Sierra <miguel.sierra@heygen.com>
601 lines
22 KiB
TypeScript
601 lines
22 KiB
TypeScript
import { useCallback, useRef } from "react";
|
|
import { findUnsafeDomPatchValues } from "@hyperframes/core/studio-api/finite-mutation";
|
|
import { FONT_EXT } from "../utils/mediaTypes";
|
|
|
|
import { trackStudioEvent } from "../utils/studioTelemetry";
|
|
import { primaryFontFamilyValue } from "../utils/studioFontHelpers";
|
|
import {
|
|
createStudioSaveHttpError,
|
|
StudioSaveHttpError,
|
|
trackStudioSaveFailure,
|
|
} from "../utils/studioSaveDiagnostics";
|
|
import { buildDomEditPatchTarget, type DomEditSelection } from "../components/editor/domEditing";
|
|
import { fontFamilyFromAssetPath, type ImportedFontAsset } from "../components/editor/fontAssets";
|
|
import type { EditHistoryKind } from "../utils/editHistory";
|
|
import type {
|
|
CommitDomEditPatchBatches,
|
|
DomEditPatchBatch,
|
|
PersistDomEditOperations,
|
|
} from "./domEditCommitTypes";
|
|
import type { PatchOperation } from "../utils/sourcePatcher";
|
|
import {
|
|
DomEditPersistUnsafeValueError,
|
|
DomEditPersistUnresolvableError,
|
|
warnDomEditPersistNoOp,
|
|
} from "./domEditPersistFailure";
|
|
import { useDomEditPositionPatchCommit } from "./useDomEditPositionPatchCommit";
|
|
import { useDomEditTextCommits } from "./useDomEditTextCommits";
|
|
import { useDomGeometryCommits } from "./useDomGeometryCommits";
|
|
import { useElementLifecycleOps } from "./useElementLifecycleOps";
|
|
import { formatFieldsSuffix } from "./gsapScriptCommitHelpers";
|
|
|
|
// ── Helpers ──
|
|
|
|
function formatUnsafeFieldList(fields: Array<{ path: string }>): string {
|
|
return fields.map((field) => field.path).join(", ");
|
|
}
|
|
|
|
function getErrorDetail(error: unknown): string {
|
|
return error instanceof Error ? error.message : String(error);
|
|
}
|
|
|
|
async function readErrorResponseBody(
|
|
response: Response,
|
|
): Promise<{ error?: string; fields?: string[] } | null> {
|
|
const contentType = response.headers.get("content-type") ?? "";
|
|
if (!contentType.includes("application/json")) return null;
|
|
return (await response.json().catch(() => null)) as { error?: string; fields?: string[] } | null;
|
|
}
|
|
|
|
function formatPatchRejectionMessage(body: { error?: string; fields?: string[] } | null): string {
|
|
if (!body?.error) return "Couldn't save edit";
|
|
return `Couldn't save edit: ${body.error}${formatFieldsSuffix(body.fields)}`;
|
|
}
|
|
|
|
interface RecordEditInput {
|
|
label: string;
|
|
kind: EditHistoryKind;
|
|
coalesceKey?: string;
|
|
coalesceMs?: number;
|
|
files: Record<string, { before: string; after: string }>;
|
|
}
|
|
|
|
/** Human-readable identifier for a batch patch target (for the unmatched warning). */
|
|
function describeBatchPatchTarget(patch: DomEditPatchBatch["patches"][number]): string {
|
|
return patch.target.id ?? patch.target.hfId ?? patch.target.selector ?? "(unaddressed)";
|
|
}
|
|
|
|
/**
|
|
* Surface server-reported unmatched patches. The server atomically refuses the
|
|
* whole multi-file gesture; the caller uses `durable: false` to roll back and
|
|
* reload, so report the refusal without turning it into a second failure.
|
|
*/
|
|
function reportUnmatchedBatchPatches(batch: DomEditPatchBatch, matched: boolean[]): void {
|
|
const unmatchedIds = batch.patches
|
|
.filter((_, index) => matched[index] === false)
|
|
.map(describeBatchPatchTarget);
|
|
if (unmatchedIds.length === 0) return;
|
|
console.warn(
|
|
`[studio] z-index reorder: server could not match ${unmatchedIds.length} patch target(s) in ` +
|
|
`${batch.sourceFile} (the whole z-order gesture will revert on reload):`,
|
|
unmatchedIds.join(", "),
|
|
);
|
|
trackStudioSaveFailure({
|
|
source: "dom_edit",
|
|
error: new Error(`Batch patch target(s) unmatched: ${unmatchedIds.join(", ")}`),
|
|
filePath: batch.sourceFile,
|
|
mutationType: "z-reorder-unmatched",
|
|
});
|
|
}
|
|
|
|
interface AtomicElementPatchFile {
|
|
sourceFile: string;
|
|
changed: boolean;
|
|
matched?: boolean[];
|
|
before: string;
|
|
after: string;
|
|
}
|
|
|
|
class AtomicElementPatchConvergenceError extends Error {
|
|
constructor(message: string, options?: { cause?: unknown }) {
|
|
super(message, options);
|
|
this.name = "AtomicElementPatchConvergenceError";
|
|
}
|
|
}
|
|
|
|
// Keep the atomic response contract in one guard so callers do not compose validity.
|
|
// fallow-ignore-next-line complexity
|
|
function isAtomicElementPatchFile(value: unknown): value is AtomicElementPatchFile {
|
|
return (
|
|
typeof value === "object" &&
|
|
value !== null &&
|
|
"sourceFile" in value &&
|
|
typeof value.sourceFile === "string" &&
|
|
"changed" in value &&
|
|
typeof value.changed === "boolean" &&
|
|
(!("matched" in value) ||
|
|
(Array.isArray(value.matched) &&
|
|
value.matched.every((matched) => typeof matched === "boolean"))) &&
|
|
"before" in value &&
|
|
typeof value.before === "string" &&
|
|
"after" in value &&
|
|
typeof value.after === "string" &&
|
|
value.changed === (value.before !== value.after)
|
|
);
|
|
}
|
|
|
|
// This is the single client owner for dispatching and validating the aggregate
|
|
// atomic endpoint. Splitting validation from the request would weaken that wire contract.
|
|
// fallow-ignore-next-line complexity
|
|
async function patchElementBatches(projectId: string, batches: DomEditPatchBatch[]) {
|
|
const body = JSON.stringify({ batches });
|
|
try {
|
|
const response = await fetch(
|
|
`/api/projects/${encodeURIComponent(projectId)}/file-mutations/patch-element-batches`,
|
|
{
|
|
method: "POST",
|
|
headers: { "Content-Type": "application/json" },
|
|
body,
|
|
},
|
|
);
|
|
if (!response.ok) {
|
|
const rejection = await readErrorResponseBody(response);
|
|
throw new StudioSaveHttpError(formatPatchRejectionMessage(rejection), response.status);
|
|
}
|
|
const result: unknown = await response.json().catch(() => null);
|
|
if (
|
|
typeof result !== "object" ||
|
|
result === null ||
|
|
!("durable" in result) ||
|
|
typeof result.durable !== "boolean" ||
|
|
!("files" in result) ||
|
|
!Array.isArray(result.files) ||
|
|
result.files.length !== batches.length ||
|
|
!result.files.every(isAtomicElementPatchFile) ||
|
|
(!result.durable && result.files.some((file) => file.changed))
|
|
) {
|
|
throw new StudioSaveHttpError("Invalid atomic element patch response", 502);
|
|
}
|
|
const files = result.files.map((file, index) => {
|
|
const batch = batches[index];
|
|
const matched = file.matched ?? [];
|
|
if (
|
|
!batch ||
|
|
file.sourceFile !== batch.sourceFile ||
|
|
(matched.length !== 0 && matched.length !== batch.patches.length)
|
|
) {
|
|
throw new StudioSaveHttpError("Invalid atomic element patch response", 502);
|
|
}
|
|
reportUnmatchedBatchPatches(batch, matched);
|
|
return {
|
|
...file,
|
|
matched,
|
|
allMatched: matched.length === batch.patches.length && matched.every(Boolean),
|
|
};
|
|
});
|
|
return { durable: result.durable, files };
|
|
} catch (error) {
|
|
throw new AtomicElementPatchConvergenceError(getErrorDetail(error), { cause: error });
|
|
}
|
|
}
|
|
|
|
/**
|
|
* A batch is reload-skippable only when it is style-only: every operation is an
|
|
* `inline-style` write. The z-reorder commit applies those exact styles to the
|
|
* live iframe DOM synchronously, so persisting them adds nothing the preview
|
|
* doesn't already show. Any other op type (attribute / text-content / …) can
|
|
* have server-side semantics the live DOM hasn't mirrored — reload for those.
|
|
*/
|
|
function batchesAreInlineStyleOnly(batches: DomEditPatchBatch[]): boolean {
|
|
return batches.every((batch) =>
|
|
batch.patches.every((patch) => patch.operations.every((op) => op.type === "inline-style")),
|
|
);
|
|
}
|
|
|
|
export interface UseDomEditCommitsParams {
|
|
activeCompPath: string | null;
|
|
previewIframeRef: React.MutableRefObject<HTMLIFrameElement | null>;
|
|
showToast: (message: string, tone?: "error" | "info") => void;
|
|
queueDomEditSave: <T>(save: () => Promise<T>) => Promise<T>;
|
|
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;
|
|
applyDomSelection: (
|
|
selection: DomEditSelection | null,
|
|
options?: { revealPanel?: boolean; additive?: boolean; preserveGroup?: boolean },
|
|
) => void;
|
|
clearDomSelection: () => void;
|
|
refreshDomEditSelectionFromPreview: (selection: DomEditSelection) => void;
|
|
buildDomSelectionFromTarget: (
|
|
target: HTMLElement,
|
|
options?: { preferClipAncestor?: boolean },
|
|
) => Promise<DomEditSelection | null>;
|
|
/** Resync the in-memory SDK session after a SERVER-side write (NOT the SDK
|
|
* path, whose session is already current) so a later SDK edit doesn't
|
|
* serialize the pre-write doc and revert the server's change. */
|
|
forceReloadSdkSession?: () => void;
|
|
/** Stage 7 Step 3c: called before the server-side patch path; returns true if SDK handled it. */
|
|
onTrySdkPersist?: (
|
|
selection: DomEditSelection,
|
|
operations: PatchOperation[],
|
|
originalContent: string,
|
|
targetPath: string,
|
|
options?: { label?: string; coalesceKey?: string; skipRefresh?: boolean },
|
|
) => Promise<boolean>;
|
|
/** Stage 7 §3.1: called before the server-side delete path; returns true if SDK handled it. */
|
|
onTrySdkDelete?: (hfId: string, originalContent: string, targetPath: string) => Promise<boolean>;
|
|
/** Resolver-shadow tripwire for z-index reorder targets (telemetry-only, decoupled from cutover). */
|
|
onReorderShadow?: (targets: string[]) => void;
|
|
}
|
|
|
|
export function useDomEditCommits({
|
|
activeCompPath,
|
|
previewIframeRef,
|
|
showToast,
|
|
queueDomEditSave,
|
|
writeProjectFile,
|
|
domEditSaveTimestampRef,
|
|
editHistory,
|
|
fileTree,
|
|
importedFontAssetsRef,
|
|
projectId,
|
|
projectIdRef,
|
|
reloadPreview,
|
|
domEditSelection,
|
|
applyDomSelection,
|
|
clearDomSelection,
|
|
refreshDomEditSelectionFromPreview,
|
|
buildDomSelectionFromTarget,
|
|
forceReloadSdkSession,
|
|
onTrySdkPersist,
|
|
onTrySdkDelete,
|
|
onReorderShadow,
|
|
}: 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 reportedUnresolvableRef = useRef(new Set<string>());
|
|
|
|
// fallow-ignore-next-line complexity
|
|
const persistDomEditOperations: PersistDomEditOperations = useCallback(
|
|
// fallow-ignore-next-line complexity
|
|
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 readResponse = await fetch(
|
|
`/api/projects/${pid}/files/${encodeURIComponent(targetPath)}`,
|
|
);
|
|
if (!readResponse.ok) {
|
|
throw await createStudioSaveHttpError(readResponse, `Failed to read ${targetPath}`);
|
|
}
|
|
const readData = (await readResponse.json()) as { content?: string };
|
|
const originalContent = readData.content;
|
|
if (typeof originalContent !== "string") {
|
|
throw new Error(`Missing file contents for ${targetPath}`);
|
|
}
|
|
|
|
if (options?.shouldSave && !options.shouldSave()) return;
|
|
|
|
// Validate layout values BEFORE any persist path runs. The SDK cutover
|
|
// path (onTrySdkPersist) returns early on success, so leaving this check
|
|
// after it let invalid numeric values bypass the guard whenever the
|
|
// cutover flag was on.
|
|
const patchTarget = buildDomEditPatchTarget(selection);
|
|
const patchBody = { target: patchTarget, operations };
|
|
const unsafeFields = findUnsafeDomPatchValues(patchBody);
|
|
if (unsafeFields.length > 0) {
|
|
const fields = formatUnsafeFieldList(unsafeFields);
|
|
showToast("Couldn't save edit because it contains invalid layout values", "error");
|
|
throw new DomEditPersistUnsafeValueError(`DOM patch contains unsafe values: ${fields}`, {
|
|
alreadyToasted: true,
|
|
});
|
|
}
|
|
|
|
// Skip the SDK path when prepareContent is set (e.g. @font-face injection
|
|
// for a custom font): sdkCutoverPersist serializes only the patched DOM
|
|
// and would drop the injected content. Let the server path run prepareContent.
|
|
if (
|
|
onTrySdkPersist &&
|
|
!options?.prepareContent &&
|
|
(await onTrySdkPersist(selection, operations, originalContent, targetPath, {
|
|
label: options?.label,
|
|
coalesceKey: options?.coalesceKey,
|
|
skipRefresh: options?.skipRefresh,
|
|
}))
|
|
) {
|
|
// SDK handled it — its in-memory doc is already current, so do NOT
|
|
// forceReload (that would echo-reload the session we just wrote).
|
|
return;
|
|
}
|
|
|
|
// Mark the save timestamp before the file write so the SSE file-change
|
|
// handler suppresses the reload even if the event arrives before the
|
|
// response (the server writes the file and emits SSE during the fetch).
|
|
domEditSaveTimestampRef.current = Date.now();
|
|
|
|
const patchResponse = await fetch(
|
|
`/api/projects/${pid}/file-mutations/patch-element/${encodeURIComponent(targetPath)}`,
|
|
{
|
|
method: "POST",
|
|
headers: { "Content-Type": "application/json" },
|
|
body: JSON.stringify(patchBody),
|
|
},
|
|
);
|
|
if (!patchResponse.ok) {
|
|
showToast(formatPatchRejectionMessage(await readErrorResponseBody(patchResponse)), "error");
|
|
throw await createStudioSaveHttpError(patchResponse, `Failed to patch ${targetPath}`, {
|
|
alreadyToasted: true,
|
|
});
|
|
}
|
|
|
|
const patchData = (await patchResponse.json()) as {
|
|
ok?: boolean;
|
|
changed?: boolean;
|
|
matched?: boolean;
|
|
content?: string;
|
|
};
|
|
|
|
if (!patchData.changed) {
|
|
if (patchData.matched === false) {
|
|
const targetKey = selection.selector ?? selection.id ?? "selection";
|
|
if (!reportedUnresolvableRef.current.has(targetKey)) {
|
|
reportedUnresolvableRef.current.add(targetKey);
|
|
trackStudioEvent("save_skipped_unresolvable", {
|
|
target_id: selection.id ?? undefined,
|
|
target_selector: selection.selector ?? undefined,
|
|
target_source_file: selection.sourceFile ?? undefined,
|
|
composition: activeCompPath ?? undefined,
|
|
});
|
|
}
|
|
throw new DomEditPersistUnresolvableError(targetPath);
|
|
}
|
|
warnDomEditPersistNoOp(selection, operations);
|
|
return;
|
|
}
|
|
|
|
const patchedContent =
|
|
typeof patchData.content === "string" ? patchData.content : originalContent;
|
|
|
|
let finalContent = patchedContent;
|
|
if (options?.prepareContent) {
|
|
const preparedContent = options.prepareContent(patchedContent, targetPath);
|
|
if (preparedContent !== patchedContent) {
|
|
try {
|
|
await writeProjectFile(targetPath, preparedContent);
|
|
finalContent = preparedContent;
|
|
} catch (error) {
|
|
// The patch above already landed on disk — only the prepareContent
|
|
// embellishment (e.g. an injected @font-face) failed to write. Keep
|
|
// the already-persisted patchedContent instead of throwing, which
|
|
// would otherwise revert a change the server already committed.
|
|
showToast(
|
|
`Saved, but couldn't finish updating ${targetPath}: ${getErrorDetail(error)}`,
|
|
"error",
|
|
);
|
|
}
|
|
}
|
|
}
|
|
|
|
await editHistory.recordEdit({
|
|
label: options?.label ?? "Edit layer",
|
|
kind: "manual",
|
|
coalesceKey: options?.coalesceKey,
|
|
coalesceMs: options?.coalesceMs,
|
|
files: { [targetPath]: { before: originalContent, after: finalContent } },
|
|
});
|
|
forceReloadSdkSession?.();
|
|
|
|
if (!options?.skipRefresh) {
|
|
reloadPreview();
|
|
}
|
|
},
|
|
[
|
|
activeCompPath,
|
|
editHistory,
|
|
writeProjectFile,
|
|
projectIdRef,
|
|
domEditSaveTimestampRef,
|
|
reloadPreview,
|
|
showToast,
|
|
forceReloadSdkSession,
|
|
onTrySdkPersist,
|
|
],
|
|
);
|
|
|
|
const commitDomEditPatchBatches: CommitDomEditPatchBatches = useCallback(
|
|
(batches, options) =>
|
|
queueDomEditSave(
|
|
// One queued transaction owns validation, persistence, history, reload,
|
|
// and its durable result; splitting those phases risks partial commits.
|
|
// fallow-ignore-next-line complexity
|
|
async () => {
|
|
const pid = projectIdRef.current;
|
|
if (!pid) throw new Error("No active project");
|
|
const unsafeFields = batches.flatMap((batch) =>
|
|
batch.patches.flatMap((patch) => findUnsafeDomPatchValues(patch)),
|
|
);
|
|
if (unsafeFields.length > 0) {
|
|
showToast("Couldn't save edit because it contains invalid layout values", "error");
|
|
throw new DomEditPersistUnsafeValueError(
|
|
`DOM patch contains unsafe values: ${formatUnsafeFieldList(unsafeFields)}`,
|
|
{ alreadyToasted: true },
|
|
);
|
|
}
|
|
|
|
domEditSaveTimestampRef.current = Date.now();
|
|
const atomicResult = await patchElementBatches(pid, batches);
|
|
const allMatched =
|
|
atomicResult.durable && atomicResult.files.every((result) => result.allMatched);
|
|
const files = Object.fromEntries(
|
|
atomicResult.files
|
|
.filter((result) => result.changed)
|
|
.map((result) => [result.sourceFile, { before: result.before, after: result.after }]),
|
|
);
|
|
const changed = Object.keys(files).length > 0;
|
|
if (changed) {
|
|
await editHistory.recordEdit({
|
|
label: options.label,
|
|
kind: "manual",
|
|
coalesceKey: options.coalesceKey,
|
|
coalesceMs: options.coalesceMs,
|
|
files,
|
|
});
|
|
forceReloadSdkSession?.();
|
|
}
|
|
const durable = allMatched;
|
|
// A z-only reorder already applied its inline styles to the live iframe
|
|
// DOM (and the store) synchronously, so remounting the iframe here only
|
|
// produces a visible blink. Skip the reload when the caller asked for it
|
|
// AND the persist is provably in sync: style-only ops, every target
|
|
// matched. Any unmatched patch means the live DOM now shows state disk
|
|
// doesn't hold — reload so the preview reconverges. (The SSE/file-watcher
|
|
// reload is independently suppressed by domEditSaveTimestampRef above.)
|
|
const skipSafe =
|
|
options.skipReload === true && batchesAreInlineStyleOnly(batches) && durable;
|
|
if (!durable || (changed && !skipSafe)) reloadPreview();
|
|
return { durable, allMatched, changed };
|
|
},
|
|
).catch((error) => {
|
|
if (error instanceof AtomicElementPatchConvergenceError) reloadPreview();
|
|
const alreadyToasted =
|
|
(error instanceof StudioSaveHttpError ||
|
|
error instanceof DomEditPersistUnsafeValueError) &&
|
|
error.alreadyToasted;
|
|
if (!alreadyToasted) {
|
|
showToast(error instanceof Error ? error.message : "Failed to reorder layers", "error");
|
|
}
|
|
trackStudioSaveFailure({
|
|
source: "dom_edit",
|
|
error,
|
|
filePath: batches.map((batch) => batch.sourceFile).join(","),
|
|
mutationType: "z-reorder",
|
|
label: options.label,
|
|
});
|
|
throw error;
|
|
}),
|
|
[
|
|
domEditSaveTimestampRef,
|
|
editHistory,
|
|
forceReloadSdkSession,
|
|
projectIdRef,
|
|
queueDomEditSave,
|
|
reloadPreview,
|
|
showToast,
|
|
],
|
|
);
|
|
|
|
// ── Text & style commits (delegated to useDomEditTextCommits) ──
|
|
|
|
const {
|
|
handleDomStyleCommit,
|
|
handleDomAttributeCommit,
|
|
handleDomAttributeLiveCommit,
|
|
handleDomHtmlAttributeCommit,
|
|
handleDomTextCommit,
|
|
commitDomTextFields,
|
|
handleDomTextFieldStyleCommit,
|
|
handleDomAddTextField,
|
|
handleDomRemoveTextField,
|
|
} = useDomEditTextCommits({
|
|
activeCompPath,
|
|
previewIframeRef,
|
|
domEditSelection,
|
|
applyDomSelection,
|
|
refreshDomEditSelectionFromPreview,
|
|
buildDomSelectionFromTarget,
|
|
persistDomEditOperations,
|
|
resolveImportedFontAsset,
|
|
showToast,
|
|
});
|
|
|
|
// ── Position patch helper (shared by geometry + lifecycle hooks) ──
|
|
|
|
const commitPositionPatchToHtml = useDomEditPositionPatchCommit({
|
|
activeCompPath,
|
|
persistDomEditOperations,
|
|
queueDomEditSave,
|
|
showToast,
|
|
});
|
|
|
|
// ── Geometry commits (path offset, box size, rotation) ──
|
|
|
|
const {
|
|
handleDomPathOffsetCommit,
|
|
handleDomBoxSizeCommit,
|
|
handleDomRotationCommit,
|
|
handleDomManualEditsReset,
|
|
} = useDomGeometryCommits({
|
|
previewIframeRef,
|
|
showToast,
|
|
commitPositionPatchToHtml,
|
|
});
|
|
|
|
// ── Element lifecycle (delete, z-index reorder) ──
|
|
|
|
const { handleDomEditElementDelete, handleDomZIndexReorderCommit } = useElementLifecycleOps({
|
|
activeCompPath,
|
|
showToast,
|
|
writeProjectFile,
|
|
domEditSaveTimestampRef,
|
|
editHistory,
|
|
projectIdRef,
|
|
reloadPreview,
|
|
clearDomSelection,
|
|
onTrySdkDelete,
|
|
onReorderShadow,
|
|
forceReloadSdkSession,
|
|
commitDomEditPatchBatches,
|
|
});
|
|
|
|
return {
|
|
resolveImportedFontAsset,
|
|
handleDomStyleCommit,
|
|
handleDomAttributeCommit,
|
|
handleDomAttributeLiveCommit,
|
|
handleDomHtmlAttributeCommit,
|
|
handleDomTextCommit,
|
|
commitDomTextFields,
|
|
handleDomTextFieldStyleCommit,
|
|
handleDomAddTextField,
|
|
handleDomRemoveTextField,
|
|
handleDomPathOffsetCommit,
|
|
handleDomBoxSizeCommit,
|
|
handleDomRotationCommit,
|
|
handleDomManualEditsReset,
|
|
handleDomEditElementDelete,
|
|
handleDomZIndexReorderCommit,
|
|
};
|
|
}
|