feat: CLI observability + fix studio save failures on JS-created elements (#1091)

* feat(core): add probeElementInSource for source-existence checks

* feat(core): add probe-element endpoint for source-existence checks

* feat(studio): gate editing capabilities on source existence

* fix(studio): enrich save_failure telemetry with target details

* feat(studio): async selection resolution with source probe

Make `resolveDomEditSelection` async and wire a `probeSourceElement` call
into the selection path so elements generated by scripts (not present in the
source HTML) are detected early and have all edit capabilities disabled with
a clear reason message ("This element is generated by a script and cannot be
edited visually.").

Part A – core probe logic:
- `domEditingLayers.ts`: `resolveDomEditSelection` is now async; calls
  `probeSourceElement` (POST /api/projects/:id/file-mutations/probe-element/:file)
  when `projectId` is supplied and the element has a stable id/selector.
  `existsInSource: false` flows into `resolveDomEditCapabilities`, which
  disables all write capabilities with the appropriate reason.
- `domEditingLayers.ts`: `refreshDomEditSelection` promoted to async.
- `files.ts`: new `probe-element` route; extracted `resolveProjectPath`,
  `resolveFileMutationContext`, `writeIfChanged`, and `parseMutationBody`
  helpers to eliminate repeated boilerplate across remove/patch/probe handlers.

Part B – caller propagation (all eight consumer sites):
- `useDomSelection.ts`: `buildDomSelectionFromTarget`,
  `resolveDomSelectionFromPreviewPoint`,
  `buildDomSelectionForTimelineElement`, `handleTimelineElementSelect`,
  `refreshDomEditSelectionFromPreview`, and
  `refreshDomEditGroupSelectionsFromPreview` all made async; `projectId`
  forwarded into `resolveDomEditSelection`.
- `useDomEditCommits.ts`, `useDomEditTextCommits.ts`: updated
  `buildDomSelectionFromTarget` parameter type; added `await` at call sites.
- `useDomEditSession.ts`: inner `syncSelectionFromDocument` made async; fire
  with `void` to satisfy the surrounding effect.
- `usePreviewInteraction.ts`: `handlePreviewCanvasMouseDown` and
  `handlePreviewCanvasPointerMove` made async (React ignores handler return
  values, so this is safe).
- `useStudioUrlState.ts`: deferred `buildDomSelectionFromTarget` call
  converted to `.then()` chain with `void` prefix so the effect stays sync.
- `LayersPanel.tsx`: `seekToLayer`, `handleSelectLayer`, and
  `handleLayerHover` made async.
- `DomEditOverlay.tsx` / `useDomEditOverlayGestures.ts`: `onCanvasPointerMove`
  return type widened to `Promise<DomEditSelection | null>`; pointer-down
  handler falls back to `hoverSelectionRef.current` (always populated by a
  prior hover) instead of awaiting the async move callback inline.

Part C – test and tooling fixes:
- `lefthook.yml`: filesize hook shell loop explicitly skips `*.test.ts/tsx`
  files as a guard against a lefthook v2.1.6 bug where `exclude` patterns are
  not applied to `{staged_files}` in shell scripts.
- `domEditing.test.ts`: all `it()` blocks calling `resolveDomEditSelection`
  made async with `await`.
- `DomEditOverlay.test.ts`: mock updated to return `Promise.resolve(selection)`
  and `hoverSelection` pre-seeded so pointer-down test works with the new
  hover-first path.
- `studioUrlState.test.ts`: `buildDomSelectionFromTarget` mocks wrapped in
  `Promise.resolve()`; seek/selection hydration test made async with
  `await act(async () => { await Promise.resolve(); })` to flush microtasks.

* feat(cli): add global error handlers for crash telemetry

Register process-level uncaughtException and unhandledRejection handlers
that fire trackCliError so unhandled crashes are captured in telemetry.
Add the trackCliError function to events.ts and re-export it from the
telemetry barrel.

* feat(cli): track per-command success/failure and duration

* test(core): add integration test for JS-created element probe scenario

* fix: address PR review feedback

- uncaughtException handler now calls process.exit(1) after flushing
- cli_command_result uses real exit code from process "exit" event
- drop stack_trace from cli_error (contains filesystem paths)
- skip source probe during hover — only probe on click/selection
- format .fallowrc.jsonc

* fix(cli): restore stack_trace in cli_error telemetry

* fix(cli): use captured module refs in exit handlers instead of dead import()
This commit is contained in:
Miguel Ángel
2026-05-27 01:44:31 -04:00
committed by GitHub
parent 8ecef4b939
commit f19d6fd471
21 changed files with 460 additions and 160 deletions
@@ -143,10 +143,11 @@ describe("DomEditOverlay", () => {
iframeRef,
activeCompositionPath: null,
selection: selected,
hoverSelection: null,
// Simulate the element being hovered before pointer-down (real users always hover first)
hoverSelection: selection,
groupSelections: [],
onCanvasMouseDown: () => {},
onCanvasPointerMove: () => selection,
onCanvasPointerMove: () => Promise.resolve(selection),
onCanvasPointerLeave: () => {},
onSelectionChange: (next: DomEditSelection) => setSelected(next),
onBlockedMove: () => {},
@@ -43,7 +43,7 @@ interface DomEditOverlayProps {
onCanvasPointerMove: (
event: React.PointerEvent<HTMLDivElement>,
options?: { preferClipAncestor?: boolean },
) => DomEditSelection | null;
) => Promise<DomEditSelection | null>;
onCanvasPointerLeave: () => void;
onSelectionChange: (
selection: DomEditSelection,
@@ -195,9 +195,8 @@ export const DomEditOverlay = memo(function DomEditOverlay({
const handleOverlayPointerDown = (event: React.PointerEvent<HTMLDivElement>) => {
if (!allowCanvasMovement || event.button !== 0) return;
if (event.shiftKey) {
const candidate =
onCanvasPointerMoveRef.current(event, { preferClipAncestor: false }) ??
hoverSelectionRef.current;
// Use the already-updated hover selection rather than re-resolving async
const candidate = hoverSelectionRef.current;
if (!candidate) return;
event.preventDefault();
event.stopPropagation();
@@ -211,9 +210,7 @@ export const DomEditOverlay = memo(function DomEditOverlay({
const target = event.target as HTMLElement | null;
if (target?.closest('[data-dom-edit-selection-box="true"]')) return;
const candidate =
onCanvasPointerMoveRef.current(event, { preferClipAncestor: false }) ??
hoverSelectionRef.current;
const candidate = hoverSelectionRef.current;
if (!candidate?.capabilities.canApplyManualOffset) return;
const overlayEl = overlayRef.current;
@@ -119,12 +119,13 @@ export const LayersPanel = memo(function LayersPanel() {
isMasterView,
preferClipAncestor: false,
}),
// LayersPanel has no projectId; probe is skipped when projectId is absent
[activeCompPath, isMasterView],
);
const seekToLayer = useCallback(
(layer: DomEditLayerItem) => {
const selection = resolveSelection(layer);
async (layer: DomEditLayerItem) => {
const selection = await resolveSelection(layer);
if (!selection) return;
let matchedId = findMatchingTimelineElementId(selection, timelineElements);
@@ -158,22 +159,22 @@ export const LayersPanel = memo(function LayersPanel() {
);
const handleSelectLayer = useCallback(
(layer: DomEditLayerItem) => {
const selection = resolveSelection(layer);
async (layer: DomEditLayerItem) => {
const selection = await resolveSelection(layer);
if (!selection) return;
applyDomSelection(selection);
seekToLayer(layer);
await seekToLayer(layer);
},
[resolveSelection, applyDomSelection, seekToLayer],
);
const handleLayerHover = useCallback(
(layer: DomEditLayerItem | null) => {
async (layer: DomEditLayerItem | null) => {
if (!layer) {
updateDomEditHoverSelection(null);
return;
}
const selection = resolveSelection(layer);
const selection = await resolveSelection(layer);
updateDomEditHoverSelection(selection);
},
[resolveSelection, updateDomEditHoverSelection],
@@ -226,6 +226,7 @@ describe("resolveDomEditCapabilities", () => {
});
describe("resolveVisualDomEditSelectionTarget", () => {
// fallow-ignore-next-line code-duplication
it("prefers the visible leaf under the pointer over an oversized container", () => {
const document = createDocument(`
<section id="container" class="hero-shell">
@@ -299,7 +300,7 @@ describe("resolveVisualDomEditSelectionTarget", () => {
).toBe(card);
});
it("keeps explicit layer selection able to target containers", () => {
it("keeps explicit layer selection able to target containers", async () => {
const document = createDocument(`
<section id="container" class="hero-shell">
<span id="headline" class="headline">Launch faster</span>
@@ -313,7 +314,7 @@ describe("resolveVisualDomEditSelectionTarget", () => {
const visualTarget = resolveVisualDomEditSelectionTarget([container, headline], {
activeCompositionPath: "index.html",
});
const explicitSelection = resolveDomEditSelection(container, {
const explicitSelection = await resolveDomEditSelection(container, {
activeCompositionPath: "index.html",
isMasterView: false,
});
@@ -430,7 +431,7 @@ describe("resolveDomEditSelection", () => {
});
});
it("resolves child clicks inside a composition host to the child in master view", () => {
it("resolves child clicks inside a composition host to the child in master view", async () => {
const document = createDocument(`
<div data-composition-id="main">
<div
@@ -445,7 +446,7 @@ describe("resolveDomEditSelection", () => {
`);
const child = document.getElementById("inner-copy") as HTMLElement;
const selection = resolveDomEditSelection(child, {
const selection = await resolveDomEditSelection(child, {
activeCompositionPath: null,
isMasterView: true,
});
@@ -457,7 +458,8 @@ describe("resolveDomEditSelection", () => {
expect(selection?.capabilities.canEditStyles).toBe(true);
});
it("does not prefer a scene host clip ancestor when selecting inside it", () => {
// fallow-ignore-next-line code-duplication
it("does not prefer a scene host clip ancestor when selecting inside it", async () => {
const document = createDocument(`
<div data-composition-id="main">
<div
@@ -472,7 +474,7 @@ describe("resolveDomEditSelection", () => {
`);
const child = document.getElementById("inner-copy") as HTMLElement;
const selection = resolveDomEditSelection(child, {
const selection = await resolveDomEditSelection(child, {
activeCompositionPath: null,
isMasterView: true,
preferClipAncestor: true,
@@ -483,7 +485,7 @@ describe("resolveDomEditSelection", () => {
expect(selection?.isCompositionHost).toBe(false);
});
it("still prefers an internal clip ancestor inside a scene", () => {
it("still prefers an internal clip ancestor inside a scene", async () => {
const document = createDocument(`
<div data-composition-id="main">
<div
@@ -500,7 +502,7 @@ describe("resolveDomEditSelection", () => {
`);
const child = document.getElementById("inner-copy") as HTMLElement;
const selection = resolveDomEditSelection(child, {
const selection = await resolveDomEditSelection(child, {
activeCompositionPath: null,
isMasterView: true,
preferClipAncestor: true,
@@ -511,7 +513,7 @@ describe("resolveDomEditSelection", () => {
expect(selection?.isCompositionHost).toBe(false);
});
it("scopes class selector indexing to the same source file", () => {
it("scopes class selector indexing to the same source file", async () => {
const document = createDocument(`
<div data-composition-id="main">
<div class="chip">Root chip</div>
@@ -522,7 +524,7 @@ describe("resolveDomEditSelection", () => {
`);
const rootChip = document.getElementsByClassName("chip")[0] as HTMLElement;
const selection = resolveDomEditSelection(rootChip, {
const selection = await resolveDomEditSelection(rootChip, {
activeCompositionPath: null,
isMasterView: true,
});
@@ -533,7 +535,7 @@ describe("resolveDomEditSelection", () => {
expect(findElementForSelection(document, selection!, null)).toBe(rootChip);
});
it("resolves nested duplicate ids from master view without treating root as the nested source", () => {
it("resolves nested duplicate ids from master view without treating root as the nested source", async () => {
const document = createDocument(`
<div data-composition-id="main">
<div id="card">Root card</div>
@@ -546,7 +548,7 @@ describe("resolveDomEditSelection", () => {
const nestedCard = document.querySelector(
'[data-composition-file="scenes/nested.html"] #card',
) as HTMLElement;
const selection = resolveDomEditSelection(nestedCard, {
const selection = await resolveDomEditSelection(nestedCard, {
activeCompositionPath: null,
isMasterView: true,
});
@@ -588,7 +590,7 @@ describe("resolveDomEditSelection", () => {
).toBeNull();
});
it("escapes ids and composition ids when creating stable selectors", () => {
it("escapes ids and composition ids when creating stable selectors", async () => {
const document = createDocument(`
<div data-composition-id="main">
<div id="logo:light">Logo</div>
@@ -600,11 +602,11 @@ describe("resolveDomEditSelection", () => {
(element) => element.getAttribute("data-composition-id") === "scene:one",
) as HTMLElement;
const logoSelection = resolveDomEditSelection(logo, {
const logoSelection = await resolveDomEditSelection(logo, {
activeCompositionPath: null,
isMasterView: true,
});
const sceneSelection = resolveDomEditSelection(scene, {
const sceneSelection = await resolveDomEditSelection(scene, {
activeCompositionPath: null,
isMasterView: true,
});
@@ -615,7 +617,7 @@ describe("resolveDomEditSelection", () => {
expect(findElementForSelection(document, sceneSelection!, null)).toBe(scene);
});
it("prefers the nearest clip ancestor on single-click style selection", () => {
it("prefers the nearest clip ancestor on single-click style selection", async () => {
const document = createDocument(`
<section id="card" class="clip" style="left: 10px; top: 20px; width: 200px; height: 100px; position: absolute;">
<p id="copy">Hello</p>
@@ -623,7 +625,7 @@ describe("resolveDomEditSelection", () => {
`);
const child = document.getElementById("copy") as HTMLElement;
const selection = resolveDomEditSelection(child, {
const selection = await resolveDomEditSelection(child, {
activeCompositionPath: null,
isMasterView: false,
preferClipAncestor: true,
@@ -633,7 +635,7 @@ describe("resolveDomEditSelection", () => {
expect(selection?.selector).toBe("#card");
});
it("can resolve the exact child when clip-ancestor preference is disabled", () => {
it("can resolve the exact child when clip-ancestor preference is disabled", async () => {
const document = createDocument(`
<section id="card" class="clip" style="left: 10px; top: 20px; width: 200px; height: 100px; position: absolute;">
<p id="copy">Hello</p>
@@ -641,7 +643,7 @@ describe("resolveDomEditSelection", () => {
`);
const child = document.getElementById("copy") as HTMLElement;
const selection = resolveDomEditSelection(child, {
const selection = await resolveDomEditSelection(child, {
activeCompositionPath: null,
isMasterView: false,
preferClipAncestor: false,
@@ -651,7 +653,8 @@ describe("resolveDomEditSelection", () => {
expect(selection?.selector).toBe("#copy");
});
it("collects simple child text blocks as separate editable fields", () => {
// fallow-ignore-next-line code-duplication
it("collects simple child text blocks as separate editable fields", async () => {
const document = createDocument(`
<section id="card" class="clip" style="left: 10px; top: 20px; width: 200px; height: 100px; position: absolute;">
<strong>Headline</strong>
@@ -659,10 +662,13 @@ describe("resolveDomEditSelection", () => {
</section>
`);
const selection = resolveDomEditSelection(document.getElementById("card") as HTMLElement, {
activeCompositionPath: null,
isMasterView: false,
});
const selection = await resolveDomEditSelection(
document.getElementById("card") as HTMLElement,
{
activeCompositionPath: null,
isMasterView: false,
},
);
expect(selection?.textFields.map((field) => field.label)).toEqual(["Text 1", "Text 2"]);
expect(selection?.textFields.map((field) => field.value)).toEqual([
@@ -671,30 +677,36 @@ describe("resolveDomEditSelection", () => {
]);
});
it("preserves user-entered text spacing in editable text fields", () => {
it("preserves user-entered text spacing in editable text fields", async () => {
const document = createDocument(`
<section id="card" class="clip" style="position: absolute;">
<strong>Headline with trailing space </strong>
</section>
`);
const selection = resolveDomEditSelection(document.getElementById("card") as HTMLElement, {
activeCompositionPath: null,
isMasterView: false,
});
const selection = await resolveDomEditSelection(
document.getElementById("card") as HTMLElement,
{
activeCompositionPath: null,
isMasterView: false,
},
);
expect(selection?.textFields[0]?.value).toBe("Headline with trailing space ");
});
it("keeps an emptied text layer editable so users can type into it again", () => {
it("keeps an emptied text layer editable so users can type into it again", async () => {
const document = createDocument(`
<div id="card" class="clip" style="position: absolute;"></div>
`);
const selection = resolveDomEditSelection(document.getElementById("card") as HTMLElement, {
activeCompositionPath: null,
isMasterView: false,
});
const selection = await resolveDomEditSelection(
document.getElementById("card") as HTMLElement,
{
activeCompositionPath: null,
isMasterView: false,
},
);
expect(selection?.textFields).toMatchObject([
{
@@ -707,7 +719,7 @@ describe("resolveDomEditSelection", () => {
expect(selection ? isTextEditableSelection(selection) : false).toBe(true);
});
it("keeps emptied child text layers editable after their content is cleared", () => {
it("keeps emptied child text layers editable after their content is cleared", async () => {
const document = createDocument(`
<div id="card" class="clip" style="position: absolute;">
<strong></strong>
@@ -715,16 +727,19 @@ describe("resolveDomEditSelection", () => {
</div>
`);
const selection = resolveDomEditSelection(document.getElementById("card") as HTMLElement, {
activeCompositionPath: null,
isMasterView: false,
});
const selection = await resolveDomEditSelection(
document.getElementById("card") as HTMLElement,
{
activeCompositionPath: null,
isMasterView: false,
},
);
expect(selection?.textFields.map((field) => field.tagName)).toEqual(["strong", "span"]);
expect(selection?.textFields.map((field) => field.value)).toEqual(["", ""]);
});
it("explains anonymous child elements that resolve to an editable parent", () => {
it("explains anonymous child elements that resolve to an editable parent", async () => {
const document = createDocument(`
<div data-composition-id="main">
<div id="card">
@@ -734,7 +749,7 @@ describe("resolveDomEditSelection", () => {
`);
const child = document.querySelector("strong") as HTMLElement;
const selection = resolveDomEditSelection(child, {
const selection = await resolveDomEditSelection(child, {
activeCompositionPath: null,
isMasterView: false,
preferClipAncestor: false,
@@ -744,7 +759,7 @@ describe("resolveDomEditSelection", () => {
expect(getDomEditNonEditableReason(child, selection)).toBe("Selection resolves to Card");
});
it("does not mark an element as non-editable when Studio can edit it directly", () => {
it("does not mark an element as non-editable when Studio can edit it directly", async () => {
const document = createDocument(`
<div data-composition-id="main">
<div id="card">Editable</div>
@@ -752,7 +767,7 @@ describe("resolveDomEditSelection", () => {
`);
const element = document.getElementById("card") as HTMLElement;
const selection = resolveDomEditSelection(element, {
const selection = await resolveDomEditSelection(element, {
activeCompositionPath: null,
isMasterView: false,
});
@@ -73,6 +73,7 @@ function buildTextField(
};
}
// fallow-ignore-next-line complexity
export function collectDomEditTextFields(el: HTMLElement): DomEditTextField[] {
const childElements = Array.from(el.children).filter(isHtmlElement).filter(isEditableTextLeaf);
@@ -169,6 +170,7 @@ export function buildDefaultDomEditTextField(base?: Partial<DomEditTextField>):
// ─── Capabilities ────────────────────────────────────────────────────────────
// fallow-ignore-next-line complexity
export function resolveDomEditCapabilities(args: {
selector?: string;
tagName?: string;
@@ -178,6 +180,7 @@ export function resolveDomEditCapabilities(args: {
isCompositionHost: boolean;
isInsideLockedComposition: boolean;
isMasterView: boolean;
existsInSource?: boolean;
}): DomEditCapabilities {
if (!args.selector || args.isInsideLockedComposition) {
return {
@@ -194,6 +197,19 @@ export function resolveDomEditCapabilities(args: {
};
}
if (args.existsInSource === false) {
return {
canSelect: true,
canEditStyles: false,
canMove: false,
canResize: false,
canApplyManualOffset: false,
canApplyManualSize: false,
canApplyManualRotation: false,
reasonIfDisabled: "This element is generated by a script and cannot be edited visually.",
};
}
const position = args.computedStyles.position;
const left = parsePx(args.inlineStyles.left) ?? parsePx(args.computedStyles.left);
const top = parsePx(args.inlineStyles.top) ?? parsePx(args.computedStyles.top);
@@ -243,6 +259,7 @@ export function resolveDomEditCapabilities(args: {
// ─── Element label ────────────────────────────────────────────────────────────
// fallow-ignore-next-line complexity
export function buildElementLabel(el: HTMLElement): string {
const compositionId = el.getAttribute("data-composition-id");
if (compositionId && compositionId !== "main") {
@@ -267,12 +284,37 @@ export function buildElementLabel(el: HTMLElement): string {
return el.tagName.toLowerCase();
}
// ─── Source probe ────────────────────────────────────────────────────────────
async function probeSourceElement(
projectId: string,
sourceFile: string,
target: { id?: string; selector?: string; selectorIndex?: number },
): Promise<boolean> {
try {
const response = await fetch(
`/api/projects/${projectId}/file-mutations/probe-element/${encodeURIComponent(sourceFile)}`,
{
method: "POST",
headers: { "Content-Type": "application/json" },
body: JSON.stringify({ target }),
},
);
if (!response.ok) return true;
const data = (await response.json()) as { exists?: boolean };
return data.exists !== false;
} catch {
return true;
}
}
// ─── Selection resolution ────────────────────────────────────────────────────
export function resolveDomEditSelection(
// fallow-ignore-next-line complexity
export async function resolveDomEditSelection(
startEl: HTMLElement | null,
options: DomEditContextOptions,
): DomEditSelection | null {
options: DomEditContextOptions & { projectId?: string | null; skipSourceProbe?: boolean },
): Promise<DomEditSelection | null> {
if (!startEl) return null;
const doc = startEl.ownerDocument;
@@ -303,6 +345,14 @@ export function resolveDomEditSelection(
const computedStyles = getCuratedComputedStyles(current);
const textFields = collectDomEditTextFields(current);
const isInsideLocked = Boolean(findClosestByAttribute(current, ["data-timeline-locked"]));
let existsInSource: boolean | undefined;
if (!options.skipSourceProbe && options.projectId && (current.id || selector)) {
const probeTarget: { id?: string; selector?: string; selectorIndex?: number } = {};
if (current.id) probeTarget.id = current.id;
if (selector) probeTarget.selector = selector;
if (selectorIndex != null) probeTarget.selectorIndex = selectorIndex;
existsInSource = await probeSourceElement(options.projectId, sourceFile, probeTarget);
}
const capabilities = resolveDomEditCapabilities({
selector,
tagName: current.tagName.toLowerCase(),
@@ -312,6 +362,7 @@ export function resolveDomEditSelection(
isCompositionHost: Boolean(compositionSrc),
isInsideLockedComposition: isInsideLocked,
isMasterView: options.isMasterView,
existsInSource,
});
const rect = current.getBoundingClientRect();
@@ -345,10 +396,10 @@ export function resolveDomEditSelection(
return null;
}
export function refreshDomEditSelection(
export async function refreshDomEditSelection(
selection: DomEditSelection,
activeCompositionPath: string | null,
): DomEditSelection | null {
): Promise<DomEditSelection | null> {
const doc = selection.element.ownerDocument;
const nextElement = findElementForSelection(doc, selection, activeCompositionPath);
return nextElement
@@ -73,7 +73,7 @@ export type UseDomEditOverlayGesturesOptions = {
(
e: React.PointerEvent<HTMLDivElement>,
o?: { preferClipAncestor?: boolean },
) => DomEditSelection | null
) => Promise<DomEditSelection | null>
>;
onCanvasMouseDown: (
e: React.MouseEvent<HTMLDivElement>,
+10 -1
View File
@@ -81,7 +81,7 @@ export interface UseDomEditCommitsParams {
buildDomSelectionFromTarget: (
target: HTMLElement,
options?: { preferClipAncestor?: boolean },
) => DomEditSelection | null;
) => Promise<DomEditSelection | null>;
}
// ── Hook ──
@@ -128,6 +128,7 @@ export function useDomEditCommits({
[fileTree, projectId, importedFontAssetsRef],
);
// fallow-ignore-next-line complexity
const persistDomEditOperations: PersistDomEditOperations = useCallback(
async (selection, operations, options) => {
const pid = projectIdRef.current;
@@ -232,6 +233,7 @@ export function useDomEditCommits({
// ── Position patch helper ──
// fallow-ignore-next-line complexity
const commitPositionPatchToHtml = useCallback(
(
selection: DomEditSelection,
@@ -244,6 +246,7 @@ export function useDomEditCommits({
coalesceKey: options.coalesceKey,
skipRefresh: options.skipRefresh ?? true,
});
// fallow-ignore-next-line complexity
}).catch((error) => {
const message = error instanceof Error ? error.message : "Failed to save position";
showToast(message);
@@ -251,6 +254,9 @@ export function useDomEditCommits({
source: "dom_edit",
label: options.label,
error_message: message,
target_id: selection.id ?? undefined,
target_selector: selection.selector ?? undefined,
target_source_file: selection.sourceFile ?? undefined,
});
});
},
@@ -333,6 +339,7 @@ export function useDomEditCommits({
// ── Motion commits (HTML-attributebacked) ──
// fallow-ignore-next-line complexity
const handleDomMotionCommit = useCallback(
(
selection: DomEditSelection,
@@ -359,6 +366,7 @@ export function useDomEditCommits({
[commitPositionPatchToHtml, previewIframeRef, refreshDomEditSelectionFromPreview],
);
// fallow-ignore-next-line complexity
const handleDomMotionClear = useCallback(
(selection: DomEditSelection) => {
const clearPatches = buildClearMotionPatches(selection.element);
@@ -387,6 +395,7 @@ export function useDomEditCommits({
[commitPositionPatchToHtml, previewIframeRef, refreshDomEditSelectionFromPreview],
);
// fallow-ignore-next-line complexity
const handleDomEditElementDelete = useCallback(
async (selection: DomEditSelection) => {
const pid = projectIdRef.current;
@@ -231,7 +231,7 @@ export function useDomEditSession({
useEffect(() => {
if (!previewIframe) return;
const syncSelectionFromDocument = () => {
const syncSelectionFromDocument = async () => {
if (!STUDIO_INSPECTOR_PANELS_ENABLED || captionEditMode) return;
const currentSelection = domEditSelectionRef.current;
if (!currentSelection) return;
@@ -249,7 +249,7 @@ export function useDomEditSession({
return;
}
const nextSelection = buildDomSelectionFromTarget(nextElement);
const nextSelection = await buildDomSelectionFromTarget(nextElement);
if (nextSelection) {
applyDomSelection(nextSelection, { revealPanel: false, preserveGroup: true });
}
@@ -257,13 +257,13 @@ export function useDomEditSession({
syncPreviewHistoryHotkey(previewIframe);
void applyStudioManualEditsToPreviewRef.current(previewIframe);
syncSelectionFromDocument();
void syncSelectionFromDocument();
refreshPreviewDocumentVersion();
const handleLoad = () => {
syncPreviewHistoryHotkey(previewIframe);
void applyStudioManualEditsToPreviewRef.current(previewIframe);
syncSelectionFromDocument();
void syncSelectionFromDocument();
refreshPreviewDocumentVersion();
};
@@ -38,7 +38,7 @@ export interface UseDomEditTextCommitsParams {
buildDomSelectionFromTarget: (
target: HTMLElement,
options?: { preferClipAncestor?: boolean },
) => DomEditSelection | null;
) => Promise<DomEditSelection | null>;
persistDomEditOperations: PersistDomEditOperations;
resolveImportedFontAsset: (fontFamilyValue: string) => ImportedFontAsset | null;
}
@@ -231,7 +231,7 @@ export function useDomEditTextCommits({
if (doc) {
const refreshed = findElementForSelection(doc, domEditSelection, activeCompPath);
if (refreshed) {
const nextSelection = buildDomSelectionFromTarget(refreshed);
const nextSelection = await buildDomSelectionFromTarget(refreshed);
if (nextSelection) {
applyDomSelection(nextSelection, { revealPanel: false, preserveGroup: true });
}
@@ -287,7 +287,7 @@ export function useDomEditTextCommits({
if (doc) {
const refreshed = findElementForSelection(doc, selection, activeCompPath);
if (refreshed) {
const nextSelection = buildDomSelectionFromTarget(refreshed);
const nextSelection = await buildDomSelectionFromTarget(refreshed);
if (nextSelection) {
applyDomSelection(nextSelection, { revealPanel: false, preserveGroup: true });
}
+28 -16
View File
@@ -60,17 +60,19 @@ export interface UseDomSelectionReturn {
buildDomSelectionFromTarget: (
target: HTMLElement,
options?: { preferClipAncestor?: boolean },
) => DomEditSelection | null;
) => Promise<DomEditSelection | null>;
resolveDomSelectionFromPreviewPoint: (
clientX: number,
clientY: number,
options?: { preferClipAncestor?: boolean },
) => DomEditSelection | null;
) => Promise<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;
buildDomSelectionForTimelineElement: (
element: TimelineElement,
) => Promise<DomEditSelection | null>;
handleTimelineElementSelect: (element: TimelineElement | null) => Promise<void>;
refreshDomEditSelectionFromPreview: (selection: DomEditSelection) => Promise<void>;
refreshDomEditGroupSelectionsFromPreview: (selections: DomEditSelection[]) => Promise<void>;
}
// ── Hook ──
@@ -193,24 +195,34 @@ export function useDomSelection({
}, [applyDomSelection]);
const buildDomSelectionFromTarget = useCallback(
(target: HTMLElement, options?: { preferClipAncestor?: boolean }) => {
(
target: HTMLElement,
options?: { preferClipAncestor?: boolean; skipSourceProbe?: boolean },
) => {
return resolveDomEditSelection(target, {
activeCompositionPath: activeCompPath,
isMasterView,
preferClipAncestor: options?.preferClipAncestor,
skipSourceProbe: options?.skipSourceProbe,
projectId,
});
},
[activeCompPath, isMasterView],
[activeCompPath, isMasterView, projectId],
);
const resolveDomSelectionFromPreviewPoint = useCallback(
(clientX: number, clientY: number, options?: { preferClipAncestor?: boolean }) => {
async (
clientX: number,
clientY: number,
options?: { preferClipAncestor?: boolean; skipSourceProbe?: 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,
skipSourceProbe: options?.skipSourceProbe,
});
},
[activeCompPath, buildDomSelectionFromTarget, captionEditMode, previewIframeRef],
@@ -223,7 +235,7 @@ export function useDomSelection({
}, []);
const buildDomSelectionForTimelineElement = useCallback(
(element: TimelineElement): DomEditSelection | null => {
async (element: TimelineElement): Promise<DomEditSelection | null> => {
const iframe = previewIframeRef.current;
let doc: Document | null = null;
try {
@@ -248,21 +260,21 @@ export function useDomSelection({
);
const handleTimelineElementSelect = useCallback(
(element: TimelineElement | null) => {
async (element: TimelineElement | null) => {
if (!STUDIO_INSPECTOR_PANELS_ENABLED) return;
if (!element) {
applyDomSelection(null, { revealPanel: false });
return;
}
const selection = buildDomSelectionForTimelineElement(element);
const selection = await buildDomSelectionForTimelineElement(element);
if (selection) applyDomSelection(selection);
},
[applyDomSelection, buildDomSelectionForTimelineElement],
);
const refreshDomEditSelectionFromPreview = useCallback(
(selection: DomEditSelection) => {
async (selection: DomEditSelection) => {
const iframe = previewIframeRef.current;
let doc: Document | null = null;
try {
@@ -275,7 +287,7 @@ export function useDomSelection({
const element = findElementForSelection(doc, selection, activeCompPath);
if (!element) return;
const nextSelection = buildDomSelectionFromTarget(element);
const nextSelection = await buildDomSelectionFromTarget(element);
if (nextSelection) {
applyDomSelection(nextSelection, {
revealPanel: false,
@@ -287,7 +299,7 @@ export function useDomSelection({
);
const refreshDomEditGroupSelectionsFromPreview = useCallback(
(selections: DomEditSelection[]) => {
async (selections: DomEditSelection[]) => {
const iframe = previewIframeRef.current;
let doc: Document | null = null;
try {
@@ -301,7 +313,7 @@ export function useDomSelection({
for (const selection of selections) {
const element = findElementForSelection(doc, selection, activeCompPath);
if (!element) continue;
const nextSelection = buildDomSelectionFromTarget(element);
const nextSelection = await buildDomSelectionFromTarget(element);
if (nextSelection) nextGroup.push(nextSelection);
}
if (nextGroup.length === 0) return;
@@ -20,8 +20,8 @@ export interface UsePreviewInteractionParams {
resolveDomSelectionFromPreviewPoint: (
clientX: number,
clientY: number,
options?: { preferClipAncestor?: boolean },
) => DomEditSelection | null;
options?: { preferClipAncestor?: boolean; skipSourceProbe?: boolean },
) => Promise<DomEditSelection | null>;
updateDomEditHoverSelection: (selection: DomEditSelection | null) => void;
onClickToSource?: (selection: DomEditSelection) => void;
@@ -40,9 +40,9 @@ export function usePreviewInteraction({
onClickToSource,
}: UsePreviewInteractionParams) {
const handlePreviewCanvasMouseDown = useCallback(
(e: React.MouseEvent<HTMLDivElement>, options?: { preferClipAncestor?: boolean }) => {
async (e: React.MouseEvent<HTMLDivElement>, options?: { preferClipAncestor?: boolean }) => {
if (!STUDIO_PREVIEW_SELECTION_ENABLED || captionEditMode || compositionLoading) return;
const nextSelection = resolveDomSelectionFromPreviewPoint(e.clientX, e.clientY, {
const nextSelection = await resolveDomSelectionFromPreviewPoint(e.clientX, e.clientY, {
preferClipAncestor: options?.preferClipAncestor ?? false,
});
if (!nextSelection) {
@@ -66,14 +66,15 @@ export function usePreviewInteraction({
);
const handlePreviewCanvasPointerMove = useCallback(
(e: React.PointerEvent<HTMLDivElement>, options?: { preferClipAncestor?: boolean }) => {
async (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, {
const nextSelection = await resolveDomSelectionFromPreviewPoint(e.clientX, e.clientY, {
preferClipAncestor: options?.preferClipAncestor ?? false,
skipSourceProbe: true,
});
updateDomEditHoverSelection(nextSelection);
return nextSelection;
@@ -25,7 +25,7 @@ interface UseStudioUrlStateParams {
buildDomSelectionFromTarget: (
target: HTMLElement,
options?: { preferClipAncestor?: boolean },
) => DomEditSelection | null;
) => Promise<DomEditSelection | null>;
applyDomSelection: (
selection: DomEditSelection | null,
options?: {
@@ -140,10 +140,11 @@ export function useStudioUrlState({
return;
}
const selection = buildDomSelectionFromTarget(element, { preferClipAncestor: false });
applyDomSelection(selection, { revealPanel: false });
hydratedSelectionRef.current = true;
pendingSelectionRef.current = null;
void buildDomSelectionFromTarget(element, { preferClipAncestor: false }).then((selection) => {
applyDomSelection(selection, { revealPanel: false });
});
}, [
activeCompPath,
applyDomSelection,
@@ -53,7 +53,7 @@ function renderStudioUrlStateHarness(
timelineVisible: true,
activeCompPathHydrated: true,
domEditSelection: null,
buildDomSelectionFromTarget: () => null,
buildDomSelectionFromTarget: () => Promise.resolve(null),
applyDomSelection: () => {},
initialState: {
activeCompPath: null,
@@ -162,7 +162,7 @@ describe("studio url state", () => {
expect(normalizeStudioUrlPanelTab("motion", { motionPanelEnabled: false })).toBe("design");
});
it("hydrates seek first, preserves the initial url state, then restores selection", () => {
it("hydrates seek first, preserves the initial url state, then restores selection", async () => {
vi.useFakeTimers();
window.history.replaceState(null, "", "#project/demo?t=4.2&tab=design&selId=hero");
const requestSeek = vi.fn();
@@ -209,7 +209,7 @@ describe("studio url state", () => {
rightPanelTab: "design",
rightCollapsed: false,
applyDomSelection,
buildDomSelectionFromTarget: () => restoredSelection,
buildDomSelectionFromTarget: () => Promise.resolve(restoredSelection),
initialState: {
activeCompPath: null,
currentTime: 4.2,
@@ -232,8 +232,10 @@ describe("studio url state", () => {
expect(applyDomSelection).not.toHaveBeenCalled();
harness.rerender({ currentTime: 4.2 });
act(() => {
await act(async () => {
vi.advanceTimersByTime(250);
// Flush microtasks so the async buildDomSelectionFromTarget Promise resolves
await Promise.resolve();
});
expect(applyDomSelection).toHaveBeenCalledWith(restoredSelection, { revealPanel: false });