mirror of
https://github.com/heygen-com/hyperframes.git
synced 2026-09-03 04:38:33 +00:00
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:
@@ -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>,
|
||||
|
||||
@@ -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-attribute–backed) ──
|
||||
|
||||
// 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 });
|
||||
}
|
||||
|
||||
@@ -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 });
|
||||
|
||||
|
||||
Reference in New Issue
Block a user