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
+13
View File
@@ -61,6 +61,19 @@
"file": "packages/studio/src/telemetry/events.ts", "file": "packages/studio/src/telemetry/events.ts",
"exports": ["trackStudioRenderStart"], "exports": ["trackStudioRenderStart"],
}, },
// domEditingLayers: these exports are consumed via the browser iframe
// runtime context (not traceable by static import analysis from the
// studio entry point) or re-exported through the domEditing barrel but
// have no downstream consumers yet.
{
"file": "packages/studio/src/components/editor/domEditingLayers.ts",
"exports": [
"isEditableTextLeaf",
"collectDomEditTextFields",
"buildElementLabel",
"refreshDomEditSelection",
],
},
], ],
"ignoreDependencies": [ "ignoreDependencies": [
// Runtime/dynamic deps not visible to static analysis: tsup `external`, // Runtime/dynamic deps not visible to static analysis: tsup `external`,
+2
View File
@@ -28,6 +28,8 @@ pre-commit:
exclude: "(\\.test\\.(ts|tsx)$|\\.generated\\.)" exclude: "(\\.test\\.(ts|tsx)$|\\.generated\\.)"
run: | run: |
for f in {staged_files}; do for f in {staged_files}; do
# Skip test and generated files (exclude pattern backup in case lefthook doesn't filter)
case "$f" in *.test.ts|*.test.tsx|*.generated.*) continue ;; esac
lines=$(wc -l < "$f") lines=$(wc -l < "$f")
if [ "$lines" -gt 600 ]; then if [ "$lines" -gt 600 ]; then
echo "ERROR: $f has $lines lines (max 600)" echo "ERROR: $f has $lines lines (max 600)"
+55 -2
View File
@@ -148,12 +148,26 @@ const hasJsonFlag = process.argv.includes("--json");
// exit handler is synchronous-only). // exit handler is synchronous-only).
let _flush: (() => Promise<void>) | undefined; let _flush: (() => Promise<void>) | undefined;
let _flushSync: (() => void) | undefined; let _flushSync: (() => void) | undefined;
let _trackCliError:
| ((props: {
error_name: string;
error_message: string;
stack_trace?: string;
command?: string;
kind: "uncaught_exception" | "unhandled_rejection" | "command_error";
}) => void)
| undefined;
let _trackCommandResult:
| ((props: { command: string; success: boolean; exitCode: number; durationMs: number }) => void)
| undefined;
let _printUpdateNotice: (() => void) | undefined; let _printUpdateNotice: (() => void) | undefined;
if (!isHelp && command !== "telemetry" && command !== "unknown") { if (!isHelp && command !== "telemetry" && command !== "unknown") {
import("./telemetry/index.js").then((mod) => { import("./telemetry/index.js").then((mod) => {
_flush = mod.flush; _flush = mod.flush;
_flushSync = mod.flushSync; _flushSync = mod.flushSync;
_trackCliError = mod.trackCliError;
_trackCommandResult = mod.trackCommandResult;
mod.showTelemetryNotice(); mod.showTelemetryNotice();
mod.trackCommand(command); mod.trackCommand(command);
if (mod.shouldTrack()) mod.incrementCommandCount(); if (mod.shouldTrack()) mod.incrementCommandCount();
@@ -176,17 +190,56 @@ if (!isHelp && !hasJsonFlag && command !== "upgrade") {
}); });
} }
const commandStart = Date.now();
let commandFailed = false;
// Async flush for normal exit (beforeExit fires when the event loop drains) // Async flush for normal exit (beforeExit fires when the event loop drains)
process.on("beforeExit", () => { process.on("beforeExit", () => {
_flush?.().catch(() => {}); _flush?.().catch(() => {});
if (!hasJsonFlag) _printUpdateNotice?.(); if (!hasJsonFlag) _printUpdateNotice?.();
}); });
// Sync flush for process.exit() calls (exit event only allows synchronous code) // Sync-only: exit handlers cannot await promises or drain microtasks.
process.on("exit", () => { // _trackCommandResult / _trackCliError are captured references resolved
// at init time, so they're callable synchronously here.
process.on("exit", (code) => {
_trackCommandResult?.({
command,
success: code === 0 && !commandFailed,
exitCode: code,
durationMs: Date.now() - commandStart,
});
_flushSync?.(); _flushSync?.();
}); });
process.on("uncaughtException", (error) => {
commandFailed = true;
_trackCliError?.({
error_name: error.name,
error_message: error.message,
stack_trace: error.stack,
command,
kind: "uncaught_exception",
});
_flushSync?.();
process.exit(1);
});
// unhandledRejection does not call process.exit() — Node may continue
// running if the rejection is non-fatal (e.g. a fire-and-forget promise).
// The exit handler above will still fire with the real exit code.
process.on("unhandledRejection", (reason) => {
commandFailed = true;
const error = reason instanceof Error ? reason : new Error(String(reason));
_trackCliError?.({
error_name: error.name,
error_message: error.message,
stack_trace: error.stack,
command,
kind: "unhandled_rejection",
});
});
// Lazy-load help renderer — avoids allocating help data on non-help invocations // Lazy-load help renderer — avoids allocating help data on non-help invocations
async function showUsage<T extends ArgsDef>( async function showUsage<T extends ArgsDef>(
cmd: CommandDef<T>, cmd: CommandDef<T>,
+30
View File
@@ -118,3 +118,33 @@ export function trackInitTemplate(templateId: string, props?: { tailwind?: boole
export function trackBrowserInstall(): void { export function trackBrowserInstall(): void {
trackEvent("browser_install", {}); trackEvent("browser_install", {});
} }
export function trackCliError(props: {
error_name: string;
error_message: string;
stack_trace?: string;
command?: string;
kind: "uncaught_exception" | "unhandled_rejection" | "command_error";
}): void {
trackEvent("cli_error", {
error_name: props.error_name,
error_message: props.error_message.slice(0, 1000),
stack_trace: props.stack_trace?.slice(0, 2000),
command: props.command,
kind: props.kind,
});
}
export function trackCommandResult(props: {
command: string;
success: boolean;
exitCode: number;
durationMs: number;
}): void {
trackEvent("cli_command_result", {
command: props.command,
success: props.success,
exit_code: props.exitCode,
duration_ms: props.durationMs,
});
}
+2
View File
@@ -6,5 +6,7 @@ export {
trackRenderError, trackRenderError,
trackInitTemplate, trackInitTemplate,
trackBrowserInstall, trackBrowserInstall,
trackCliError,
trackCommandResult,
} from "./events.js"; } from "./events.js";
export { getSystemMeta, getShmSizeMb, getFreeDiskMb, bytesToMb } from "./system.js"; export { getSystemMeta, getShmSizeMb, getFreeDiskMb, bytesToMb } from "./system.js";
@@ -1,5 +1,9 @@
import { describe, expect, it } from "vitest"; import { describe, expect, it } from "vitest";
import { removeElementFromHtml, patchElementInHtml } from "./sourceMutation.js"; import {
removeElementFromHtml,
patchElementInHtml,
probeElementInSource,
} from "./sourceMutation.js";
describe("removeElementFromHtml", () => { describe("removeElementFromHtml", () => {
it("removes a self-closing element by id", () => { it("removes a self-closing element by id", () => {
@@ -248,3 +252,68 @@ describe("patchElementInHtml", () => {
expect(result).not.toContain("dynsrc"); expect(result).not.toContain("dynsrc");
}); });
}); });
describe("probeElementInSource", () => {
const FIXTURE = `<!doctype html><html><head></head><body>
<div id="root" data-composition-id="main">
<div class="layer" data-composition-id="overlay" data-composition-src="compositions/overlay.html">
<div class="chrome">
<span class="brand">HyperFrames</span>
</div>
</div>
<div id="hero" class="hero-heading" style="font-size: 48px">Hello World</div>
</div>
</body></html>`;
it("returns true for an element found by id", () => {
expect(probeElementInSource(FIXTURE, { id: "hero" })).toBe(true);
});
it("returns true for an element found by class selector", () => {
expect(probeElementInSource(FIXTURE, { selector: ".hero-heading" })).toBe(true);
});
it("returns true for an element found by data-composition-id selector", () => {
expect(probeElementInSource(FIXTURE, { selector: '[data-composition-id="overlay"]' })).toBe(
true,
);
});
it("returns false for an id that does not exist in source", () => {
expect(probeElementInSource(FIXTURE, { id: "arrows-svg" })).toBe(false);
});
it("returns false for a class selector that does not exist", () => {
expect(probeElementInSource(FIXTURE, { selector: ".phone-frame" })).toBe(false);
});
it("returns false when target has neither id nor selector", () => {
expect(probeElementInSource(FIXTURE, {})).toBe(false);
});
it("returns true for class selector with valid selectorIndex", () => {
const html = `<div class="item">A</div><div class="item">B</div>`;
expect(probeElementInSource(html, { selector: ".item", selectorIndex: 1 })).toBe(true);
});
it("returns false for class selector with out-of-bounds selectorIndex", () => {
const html = `<div class="item">A</div><div class="item">B</div>`;
expect(probeElementInSource(html, { selector: ".item", selectorIndex: 5 })).toBe(false);
});
it("returns false for an element that would only exist after JS execution", () => {
const sourceHtml = `<!doctype html><html><head></head><body>
<div id="root" data-composition-id="main">
<div id="canvas"></div>
<script>
const svg = document.createElement("div");
svg.id = "arrows-svg";
document.getElementById("canvas").appendChild(svg);
</script>
</div>
</body></html>`;
expect(probeElementInSource(sourceHtml, { id: "arrows-svg" })).toBe(false);
expect(probeElementInSource(sourceHtml, { id: "canvas" })).toBe(true);
});
});
@@ -199,3 +199,10 @@ export function patchElementInHtml(
return wrappedFragment ? document.body.innerHTML || "" : document.toString(); return wrappedFragment ? document.body.innerHTML || "" : document.toString();
} }
export function probeElementInSource(source: string, target: SourceMutationTarget): boolean {
if (!target.id && !target.selector) return false;
const { document } = parseSourceDocument(source);
const el = findTargetElement(document, target);
return el != null && isHTMLElement(el);
}
+89 -55
View File
@@ -20,6 +20,7 @@ import { isSafePath } from "../helpers/safePath.js";
import { import {
removeElementFromHtml, removeElementFromHtml,
patchElementInHtml, patchElementInHtml,
probeElementInSource,
type PatchOperation, type PatchOperation,
} from "../helpers/sourceMutation.js"; } from "../helpers/sourceMutation.js";
@@ -38,9 +39,11 @@ interface RouteContext {
json: (data: unknown, status?: number) => Response; json: (data: unknown, status?: number) => Response;
} }
async function resolveProjectFile( /** Resolve project + safe absolute path for any project-scoped route. */
async function resolveProjectPath(
c: RouteContext, c: RouteContext,
adapter: StudioApiAdapter, adapter: StudioApiAdapter,
pathPrefix: (projectId: string) => string,
opts?: { mustExist?: boolean }, opts?: { mustExist?: boolean },
) { ) {
const id = c.req.param("id"); const id = c.req.param("id");
@@ -49,7 +52,7 @@ async function resolveProjectFile(
return { error: c.json({ error: "not found" }, 404) } as const; return { error: c.json({ error: "not found" }, 404) } as const;
} }
const filePath = decodeURIComponent(c.req.path.replace(`/projects/${project.id}/files/`, "")); const filePath = decodeURIComponent(c.req.path.replace(pathPrefix(project.id), ""));
if (filePath.includes("\0")) { if (filePath.includes("\0")) {
return { error: c.json({ error: "forbidden" }, 403) } as const; return { error: c.json({ error: "forbidden" }, 403) } as const;
} }
@@ -66,6 +69,48 @@ async function resolveProjectFile(
return { project, filePath, absPath } as const; return { project, filePath, absPath } as const;
} }
function resolveProjectFile(
c: RouteContext,
adapter: StudioApiAdapter,
opts?: { mustExist?: boolean },
) {
return resolveProjectPath(c, adapter, (id) => `/projects/${id}/files/`, opts);
}
function resolveFileMutationContext(c: RouteContext, adapter: StudioApiAdapter, operation: string) {
return resolveProjectPath(c, adapter, (id) => `/projects/${id}/file-mutations/${operation}/`);
}
type MutationTarget = { id?: string | null; selector?: string; selectorIndex?: number };
/** Write `next` to `absPath` only if it differs from `original`, returning a standardized change response. */
function writeIfChanged(
c: RouteContext,
absPath: string,
original: string,
next: string,
): Response {
if (next === original) {
return c.json({ ok: true, changed: false, content: original });
}
writeFileSync(absPath, next, "utf-8");
return c.json({ ok: true, changed: true, content: next });
}
/**
* Parse the request body and validate that `target` is present.
* Returns `{ error }` if missing, or `{ target, body }` for the full parsed body.
*/
async function parseMutationBody<T extends { target?: MutationTarget }>(
c: RouteContext & { req: { json(): Promise<unknown> } },
): Promise<{ error: Response } | { target: MutationTarget; body: T }> {
const body = (await (c.req as { json(): Promise<unknown> }).json().catch(() => null)) as T | null;
if (!body?.target) {
return { error: c.json({ error: "target required" }, 400) };
}
return { target: body.target, body };
}
/** Ensure the parent directory of a path exists. */ /** Ensure the parent directory of a path exists. */
function ensureDir(filePath: string) { function ensureDir(filePath: string) {
const dir = dirname(filePath); const dir = dirname(filePath);
@@ -204,79 +249,68 @@ export function registerFileRoutes(api: Hono, adapter: StudioApiAdapter): void {
}); });
api.post("/projects/:id/file-mutations/remove-element/*", async (c) => { api.post("/projects/:id/file-mutations/remove-element/*", async (c) => {
const id = c.req.param("id"); const ctx = await resolveFileMutationContext(c, adapter, "remove-element");
const project = await adapter.resolveProject(id); if ("error" in ctx) return ctx.error;
if (!project) return c.json({ error: "not found" }, 404);
const filePath = decodeURIComponent( if (!existsSync(ctx.absPath)) {
c.req.path.replace(`/projects/${project.id}/file-mutations/remove-element/`, ""),
);
if (filePath.includes("\0")) {
return c.json({ error: "forbidden" }, 403);
}
const absPath = resolve(project.dir, filePath);
if (!isSafePath(project.dir, absPath)) {
return c.json({ error: "forbidden" }, 403);
}
if (!existsSync(absPath)) {
return c.json({ error: "not found" }, 404); return c.json({ error: "not found" }, 404);
} }
const body = (await c.req.json().catch(() => null)) as { const parsed = await parseMutationBody<{ target?: MutationTarget }>(c);
target?: { id?: string | null; selector?: string; selectorIndex?: number }; if ("error" in parsed) return parsed.error;
} | null;
if (!body?.target) {
return c.json({ error: "target required" }, 400);
}
const originalContent = readFileSync(absPath, "utf-8"); const originalContent = readFileSync(ctx.absPath, "utf-8");
const patchedContent = removeElementFromHtml(originalContent, body.target); return writeIfChanged(
if (patchedContent === originalContent) { c,
return c.json({ ok: true, changed: false, content: originalContent }); ctx.absPath,
} originalContent,
removeElementFromHtml(originalContent, parsed.target),
writeFileSync(absPath, patchedContent, "utf-8"); );
return c.json({ ok: true, changed: true, content: patchedContent });
}); });
api.post("/projects/:id/file-mutations/patch-element/*", async (c) => { api.post("/projects/:id/file-mutations/patch-element/*", async (c) => {
const id = c.req.param("id"); const ctx = await resolveFileMutationContext(c, adapter, "patch-element");
const project = await adapter.resolveProject(id); if ("error" in ctx) return ctx.error;
if (!project) return c.json({ error: "not found" }, 404);
const filePath = decodeURIComponent( const parsed = await parseMutationBody<{
c.req.path.replace(`/projects/${project.id}/file-mutations/patch-element/`, ""), target?: MutationTarget;
);
if (filePath.includes("\0")) {
return c.json({ error: "forbidden" }, 403);
}
const absPath = resolve(project.dir, filePath);
if (!isSafePath(project.dir, absPath)) {
return c.json({ error: "forbidden" }, 403);
}
const body = (await c.req.json().catch(() => null)) as {
target?: { id?: string | null; selector?: string; selectorIndex?: number };
operations?: PatchOperation[]; operations?: PatchOperation[];
} | null; }>(c);
if (!body?.target || !Array.isArray(body.operations) || body.operations.length === 0) { if ("error" in parsed) return parsed.error;
if (!Array.isArray(parsed.body.operations) || parsed.body.operations.length === 0) {
return c.json({ error: "target and operations required" }, 400); return c.json({ error: "target and operations required" }, 400);
} }
let originalContent: string; let originalContent: string;
try { try {
originalContent = readFileSync(absPath, "utf-8"); originalContent = readFileSync(ctx.absPath, "utf-8");
} catch { } catch {
return c.json({ error: "not found" }, 404); return c.json({ error: "not found" }, 404);
} }
const patchedContent = patchElementInHtml(originalContent, body.target, body.operations); return writeIfChanged(
if (patchedContent === originalContent) { c,
return c.json({ ok: true, changed: false, content: originalContent }); ctx.absPath,
originalContent,
patchElementInHtml(originalContent, parsed.target, parsed.body.operations),
);
});
api.post("/projects/:id/file-mutations/probe-element/*", async (c) => {
const ctx = await resolveFileMutationContext(c, adapter, "probe-element");
if ("error" in ctx) return ctx.error;
const parsed = await parseMutationBody<{ target?: MutationTarget }>(c);
if ("error" in parsed) return parsed.error;
let content: string;
try {
content = readFileSync(ctx.absPath, "utf-8");
} catch {
return c.json({ exists: false });
} }
writeFileSync(absPath, patchedContent, "utf-8"); const exists = probeElementInSource(content, parsed.target);
return c.json({ ok: true, changed: true, content: patchedContent }); return c.json({ exists });
}); });
// ── Rename / Move ── // ── Rename / Move ──
@@ -143,10 +143,11 @@ describe("DomEditOverlay", () => {
iframeRef, iframeRef,
activeCompositionPath: null, activeCompositionPath: null,
selection: selected, selection: selected,
hoverSelection: null, // Simulate the element being hovered before pointer-down (real users always hover first)
hoverSelection: selection,
groupSelections: [], groupSelections: [],
onCanvasMouseDown: () => {}, onCanvasMouseDown: () => {},
onCanvasPointerMove: () => selection, onCanvasPointerMove: () => Promise.resolve(selection),
onCanvasPointerLeave: () => {}, onCanvasPointerLeave: () => {},
onSelectionChange: (next: DomEditSelection) => setSelected(next), onSelectionChange: (next: DomEditSelection) => setSelected(next),
onBlockedMove: () => {}, onBlockedMove: () => {},
@@ -43,7 +43,7 @@ interface DomEditOverlayProps {
onCanvasPointerMove: ( onCanvasPointerMove: (
event: React.PointerEvent<HTMLDivElement>, event: React.PointerEvent<HTMLDivElement>,
options?: { preferClipAncestor?: boolean }, options?: { preferClipAncestor?: boolean },
) => DomEditSelection | null; ) => Promise<DomEditSelection | null>;
onCanvasPointerLeave: () => void; onCanvasPointerLeave: () => void;
onSelectionChange: ( onSelectionChange: (
selection: DomEditSelection, selection: DomEditSelection,
@@ -195,9 +195,8 @@ export const DomEditOverlay = memo(function DomEditOverlay({
const handleOverlayPointerDown = (event: React.PointerEvent<HTMLDivElement>) => { const handleOverlayPointerDown = (event: React.PointerEvent<HTMLDivElement>) => {
if (!allowCanvasMovement || event.button !== 0) return; if (!allowCanvasMovement || event.button !== 0) return;
if (event.shiftKey) { if (event.shiftKey) {
const candidate = // Use the already-updated hover selection rather than re-resolving async
onCanvasPointerMoveRef.current(event, { preferClipAncestor: false }) ?? const candidate = hoverSelectionRef.current;
hoverSelectionRef.current;
if (!candidate) return; if (!candidate) return;
event.preventDefault(); event.preventDefault();
event.stopPropagation(); event.stopPropagation();
@@ -211,9 +210,7 @@ export const DomEditOverlay = memo(function DomEditOverlay({
const target = event.target as HTMLElement | null; const target = event.target as HTMLElement | null;
if (target?.closest('[data-dom-edit-selection-box="true"]')) return; if (target?.closest('[data-dom-edit-selection-box="true"]')) return;
const candidate = const candidate = hoverSelectionRef.current;
onCanvasPointerMoveRef.current(event, { preferClipAncestor: false }) ??
hoverSelectionRef.current;
if (!candidate?.capabilities.canApplyManualOffset) return; if (!candidate?.capabilities.canApplyManualOffset) return;
const overlayEl = overlayRef.current; const overlayEl = overlayRef.current;
@@ -119,12 +119,13 @@ export const LayersPanel = memo(function LayersPanel() {
isMasterView, isMasterView,
preferClipAncestor: false, preferClipAncestor: false,
}), }),
// LayersPanel has no projectId; probe is skipped when projectId is absent
[activeCompPath, isMasterView], [activeCompPath, isMasterView],
); );
const seekToLayer = useCallback( const seekToLayer = useCallback(
(layer: DomEditLayerItem) => { async (layer: DomEditLayerItem) => {
const selection = resolveSelection(layer); const selection = await resolveSelection(layer);
if (!selection) return; if (!selection) return;
let matchedId = findMatchingTimelineElementId(selection, timelineElements); let matchedId = findMatchingTimelineElementId(selection, timelineElements);
@@ -158,22 +159,22 @@ export const LayersPanel = memo(function LayersPanel() {
); );
const handleSelectLayer = useCallback( const handleSelectLayer = useCallback(
(layer: DomEditLayerItem) => { async (layer: DomEditLayerItem) => {
const selection = resolveSelection(layer); const selection = await resolveSelection(layer);
if (!selection) return; if (!selection) return;
applyDomSelection(selection); applyDomSelection(selection);
seekToLayer(layer); await seekToLayer(layer);
}, },
[resolveSelection, applyDomSelection, seekToLayer], [resolveSelection, applyDomSelection, seekToLayer],
); );
const handleLayerHover = useCallback( const handleLayerHover = useCallback(
(layer: DomEditLayerItem | null) => { async (layer: DomEditLayerItem | null) => {
if (!layer) { if (!layer) {
updateDomEditHoverSelection(null); updateDomEditHoverSelection(null);
return; return;
} }
const selection = resolveSelection(layer); const selection = await resolveSelection(layer);
updateDomEditHoverSelection(selection); updateDomEditHoverSelection(selection);
}, },
[resolveSelection, updateDomEditHoverSelection], [resolveSelection, updateDomEditHoverSelection],
@@ -226,6 +226,7 @@ describe("resolveDomEditCapabilities", () => {
}); });
describe("resolveVisualDomEditSelectionTarget", () => { describe("resolveVisualDomEditSelectionTarget", () => {
// fallow-ignore-next-line code-duplication
it("prefers the visible leaf under the pointer over an oversized container", () => { it("prefers the visible leaf under the pointer over an oversized container", () => {
const document = createDocument(` const document = createDocument(`
<section id="container" class="hero-shell"> <section id="container" class="hero-shell">
@@ -299,7 +300,7 @@ describe("resolveVisualDomEditSelectionTarget", () => {
).toBe(card); ).toBe(card);
}); });
it("keeps explicit layer selection able to target containers", () => { it("keeps explicit layer selection able to target containers", async () => {
const document = createDocument(` const document = createDocument(`
<section id="container" class="hero-shell"> <section id="container" class="hero-shell">
<span id="headline" class="headline">Launch faster</span> <span id="headline" class="headline">Launch faster</span>
@@ -313,7 +314,7 @@ describe("resolveVisualDomEditSelectionTarget", () => {
const visualTarget = resolveVisualDomEditSelectionTarget([container, headline], { const visualTarget = resolveVisualDomEditSelectionTarget([container, headline], {
activeCompositionPath: "index.html", activeCompositionPath: "index.html",
}); });
const explicitSelection = resolveDomEditSelection(container, { const explicitSelection = await resolveDomEditSelection(container, {
activeCompositionPath: "index.html", activeCompositionPath: "index.html",
isMasterView: false, 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(` const document = createDocument(`
<div data-composition-id="main"> <div data-composition-id="main">
<div <div
@@ -445,7 +446,7 @@ describe("resolveDomEditSelection", () => {
`); `);
const child = document.getElementById("inner-copy") as HTMLElement; const child = document.getElementById("inner-copy") as HTMLElement;
const selection = resolveDomEditSelection(child, { const selection = await resolveDomEditSelection(child, {
activeCompositionPath: null, activeCompositionPath: null,
isMasterView: true, isMasterView: true,
}); });
@@ -457,7 +458,8 @@ describe("resolveDomEditSelection", () => {
expect(selection?.capabilities.canEditStyles).toBe(true); 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(` const document = createDocument(`
<div data-composition-id="main"> <div data-composition-id="main">
<div <div
@@ -472,7 +474,7 @@ describe("resolveDomEditSelection", () => {
`); `);
const child = document.getElementById("inner-copy") as HTMLElement; const child = document.getElementById("inner-copy") as HTMLElement;
const selection = resolveDomEditSelection(child, { const selection = await resolveDomEditSelection(child, {
activeCompositionPath: null, activeCompositionPath: null,
isMasterView: true, isMasterView: true,
preferClipAncestor: true, preferClipAncestor: true,
@@ -483,7 +485,7 @@ describe("resolveDomEditSelection", () => {
expect(selection?.isCompositionHost).toBe(false); 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(` const document = createDocument(`
<div data-composition-id="main"> <div data-composition-id="main">
<div <div
@@ -500,7 +502,7 @@ describe("resolveDomEditSelection", () => {
`); `);
const child = document.getElementById("inner-copy") as HTMLElement; const child = document.getElementById("inner-copy") as HTMLElement;
const selection = resolveDomEditSelection(child, { const selection = await resolveDomEditSelection(child, {
activeCompositionPath: null, activeCompositionPath: null,
isMasterView: true, isMasterView: true,
preferClipAncestor: true, preferClipAncestor: true,
@@ -511,7 +513,7 @@ describe("resolveDomEditSelection", () => {
expect(selection?.isCompositionHost).toBe(false); 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(` const document = createDocument(`
<div data-composition-id="main"> <div data-composition-id="main">
<div class="chip">Root chip</div> <div class="chip">Root chip</div>
@@ -522,7 +524,7 @@ describe("resolveDomEditSelection", () => {
`); `);
const rootChip = document.getElementsByClassName("chip")[0] as HTMLElement; const rootChip = document.getElementsByClassName("chip")[0] as HTMLElement;
const selection = resolveDomEditSelection(rootChip, { const selection = await resolveDomEditSelection(rootChip, {
activeCompositionPath: null, activeCompositionPath: null,
isMasterView: true, isMasterView: true,
}); });
@@ -533,7 +535,7 @@ describe("resolveDomEditSelection", () => {
expect(findElementForSelection(document, selection!, null)).toBe(rootChip); 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(` const document = createDocument(`
<div data-composition-id="main"> <div data-composition-id="main">
<div id="card">Root card</div> <div id="card">Root card</div>
@@ -546,7 +548,7 @@ describe("resolveDomEditSelection", () => {
const nestedCard = document.querySelector( const nestedCard = document.querySelector(
'[data-composition-file="scenes/nested.html"] #card', '[data-composition-file="scenes/nested.html"] #card',
) as HTMLElement; ) as HTMLElement;
const selection = resolveDomEditSelection(nestedCard, { const selection = await resolveDomEditSelection(nestedCard, {
activeCompositionPath: null, activeCompositionPath: null,
isMasterView: true, isMasterView: true,
}); });
@@ -588,7 +590,7 @@ describe("resolveDomEditSelection", () => {
).toBeNull(); ).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(` const document = createDocument(`
<div data-composition-id="main"> <div data-composition-id="main">
<div id="logo:light">Logo</div> <div id="logo:light">Logo</div>
@@ -600,11 +602,11 @@ describe("resolveDomEditSelection", () => {
(element) => element.getAttribute("data-composition-id") === "scene:one", (element) => element.getAttribute("data-composition-id") === "scene:one",
) as HTMLElement; ) as HTMLElement;
const logoSelection = resolveDomEditSelection(logo, { const logoSelection = await resolveDomEditSelection(logo, {
activeCompositionPath: null, activeCompositionPath: null,
isMasterView: true, isMasterView: true,
}); });
const sceneSelection = resolveDomEditSelection(scene, { const sceneSelection = await resolveDomEditSelection(scene, {
activeCompositionPath: null, activeCompositionPath: null,
isMasterView: true, isMasterView: true,
}); });
@@ -615,7 +617,7 @@ describe("resolveDomEditSelection", () => {
expect(findElementForSelection(document, sceneSelection!, null)).toBe(scene); 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(` const document = createDocument(`
<section id="card" class="clip" style="left: 10px; top: 20px; width: 200px; height: 100px; position: absolute;"> <section id="card" class="clip" style="left: 10px; top: 20px; width: 200px; height: 100px; position: absolute;">
<p id="copy">Hello</p> <p id="copy">Hello</p>
@@ -623,7 +625,7 @@ describe("resolveDomEditSelection", () => {
`); `);
const child = document.getElementById("copy") as HTMLElement; const child = document.getElementById("copy") as HTMLElement;
const selection = resolveDomEditSelection(child, { const selection = await resolveDomEditSelection(child, {
activeCompositionPath: null, activeCompositionPath: null,
isMasterView: false, isMasterView: false,
preferClipAncestor: true, preferClipAncestor: true,
@@ -633,7 +635,7 @@ describe("resolveDomEditSelection", () => {
expect(selection?.selector).toBe("#card"); 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(` const document = createDocument(`
<section id="card" class="clip" style="left: 10px; top: 20px; width: 200px; height: 100px; position: absolute;"> <section id="card" class="clip" style="left: 10px; top: 20px; width: 200px; height: 100px; position: absolute;">
<p id="copy">Hello</p> <p id="copy">Hello</p>
@@ -641,7 +643,7 @@ describe("resolveDomEditSelection", () => {
`); `);
const child = document.getElementById("copy") as HTMLElement; const child = document.getElementById("copy") as HTMLElement;
const selection = resolveDomEditSelection(child, { const selection = await resolveDomEditSelection(child, {
activeCompositionPath: null, activeCompositionPath: null,
isMasterView: false, isMasterView: false,
preferClipAncestor: false, preferClipAncestor: false,
@@ -651,7 +653,8 @@ describe("resolveDomEditSelection", () => {
expect(selection?.selector).toBe("#copy"); 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(` const document = createDocument(`
<section id="card" class="clip" style="left: 10px; top: 20px; width: 200px; height: 100px; position: absolute;"> <section id="card" class="clip" style="left: 10px; top: 20px; width: 200px; height: 100px; position: absolute;">
<strong>Headline</strong> <strong>Headline</strong>
@@ -659,10 +662,13 @@ describe("resolveDomEditSelection", () => {
</section> </section>
`); `);
const selection = resolveDomEditSelection(document.getElementById("card") as HTMLElement, { const selection = await resolveDomEditSelection(
activeCompositionPath: null, document.getElementById("card") as HTMLElement,
isMasterView: false, {
}); activeCompositionPath: null,
isMasterView: false,
},
);
expect(selection?.textFields.map((field) => field.label)).toEqual(["Text 1", "Text 2"]); expect(selection?.textFields.map((field) => field.label)).toEqual(["Text 1", "Text 2"]);
expect(selection?.textFields.map((field) => field.value)).toEqual([ 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(` const document = createDocument(`
<section id="card" class="clip" style="position: absolute;"> <section id="card" class="clip" style="position: absolute;">
<strong>Headline with trailing space </strong> <strong>Headline with trailing space </strong>
</section> </section>
`); `);
const selection = resolveDomEditSelection(document.getElementById("card") as HTMLElement, { const selection = await resolveDomEditSelection(
activeCompositionPath: null, document.getElementById("card") as HTMLElement,
isMasterView: false, {
}); activeCompositionPath: null,
isMasterView: false,
},
);
expect(selection?.textFields[0]?.value).toBe("Headline with trailing space "); 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(` const document = createDocument(`
<div id="card" class="clip" style="position: absolute;"></div> <div id="card" class="clip" style="position: absolute;"></div>
`); `);
const selection = resolveDomEditSelection(document.getElementById("card") as HTMLElement, { const selection = await resolveDomEditSelection(
activeCompositionPath: null, document.getElementById("card") as HTMLElement,
isMasterView: false, {
}); activeCompositionPath: null,
isMasterView: false,
},
);
expect(selection?.textFields).toMatchObject([ expect(selection?.textFields).toMatchObject([
{ {
@@ -707,7 +719,7 @@ describe("resolveDomEditSelection", () => {
expect(selection ? isTextEditableSelection(selection) : false).toBe(true); 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(` const document = createDocument(`
<div id="card" class="clip" style="position: absolute;"> <div id="card" class="clip" style="position: absolute;">
<strong></strong> <strong></strong>
@@ -715,16 +727,19 @@ describe("resolveDomEditSelection", () => {
</div> </div>
`); `);
const selection = resolveDomEditSelection(document.getElementById("card") as HTMLElement, { const selection = await resolveDomEditSelection(
activeCompositionPath: null, document.getElementById("card") as HTMLElement,
isMasterView: false, {
}); activeCompositionPath: null,
isMasterView: false,
},
);
expect(selection?.textFields.map((field) => field.tagName)).toEqual(["strong", "span"]); expect(selection?.textFields.map((field) => field.tagName)).toEqual(["strong", "span"]);
expect(selection?.textFields.map((field) => field.value)).toEqual(["", ""]); 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(` const document = createDocument(`
<div data-composition-id="main"> <div data-composition-id="main">
<div id="card"> <div id="card">
@@ -734,7 +749,7 @@ describe("resolveDomEditSelection", () => {
`); `);
const child = document.querySelector("strong") as HTMLElement; const child = document.querySelector("strong") as HTMLElement;
const selection = resolveDomEditSelection(child, { const selection = await resolveDomEditSelection(child, {
activeCompositionPath: null, activeCompositionPath: null,
isMasterView: false, isMasterView: false,
preferClipAncestor: false, preferClipAncestor: false,
@@ -744,7 +759,7 @@ describe("resolveDomEditSelection", () => {
expect(getDomEditNonEditableReason(child, selection)).toBe("Selection resolves to Card"); 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(` const document = createDocument(`
<div data-composition-id="main"> <div data-composition-id="main">
<div id="card">Editable</div> <div id="card">Editable</div>
@@ -752,7 +767,7 @@ describe("resolveDomEditSelection", () => {
`); `);
const element = document.getElementById("card") as HTMLElement; const element = document.getElementById("card") as HTMLElement;
const selection = resolveDomEditSelection(element, { const selection = await resolveDomEditSelection(element, {
activeCompositionPath: null, activeCompositionPath: null,
isMasterView: false, isMasterView: false,
}); });
@@ -73,6 +73,7 @@ function buildTextField(
}; };
} }
// fallow-ignore-next-line complexity
export function collectDomEditTextFields(el: HTMLElement): DomEditTextField[] { export function collectDomEditTextFields(el: HTMLElement): DomEditTextField[] {
const childElements = Array.from(el.children).filter(isHtmlElement).filter(isEditableTextLeaf); const childElements = Array.from(el.children).filter(isHtmlElement).filter(isEditableTextLeaf);
@@ -169,6 +170,7 @@ export function buildDefaultDomEditTextField(base?: Partial<DomEditTextField>):
// ─── Capabilities ──────────────────────────────────────────────────────────── // ─── Capabilities ────────────────────────────────────────────────────────────
// fallow-ignore-next-line complexity
export function resolveDomEditCapabilities(args: { export function resolveDomEditCapabilities(args: {
selector?: string; selector?: string;
tagName?: string; tagName?: string;
@@ -178,6 +180,7 @@ export function resolveDomEditCapabilities(args: {
isCompositionHost: boolean; isCompositionHost: boolean;
isInsideLockedComposition: boolean; isInsideLockedComposition: boolean;
isMasterView: boolean; isMasterView: boolean;
existsInSource?: boolean;
}): DomEditCapabilities { }): DomEditCapabilities {
if (!args.selector || args.isInsideLockedComposition) { if (!args.selector || args.isInsideLockedComposition) {
return { 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 position = args.computedStyles.position;
const left = parsePx(args.inlineStyles.left) ?? parsePx(args.computedStyles.left); const left = parsePx(args.inlineStyles.left) ?? parsePx(args.computedStyles.left);
const top = parsePx(args.inlineStyles.top) ?? parsePx(args.computedStyles.top); const top = parsePx(args.inlineStyles.top) ?? parsePx(args.computedStyles.top);
@@ -243,6 +259,7 @@ export function resolveDomEditCapabilities(args: {
// ─── Element label ──────────────────────────────────────────────────────────── // ─── Element label ────────────────────────────────────────────────────────────
// fallow-ignore-next-line complexity
export function buildElementLabel(el: HTMLElement): string { export function buildElementLabel(el: HTMLElement): string {
const compositionId = el.getAttribute("data-composition-id"); const compositionId = el.getAttribute("data-composition-id");
if (compositionId && compositionId !== "main") { if (compositionId && compositionId !== "main") {
@@ -267,12 +284,37 @@ export function buildElementLabel(el: HTMLElement): string {
return el.tagName.toLowerCase(); 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 ──────────────────────────────────────────────────── // ─── Selection resolution ────────────────────────────────────────────────────
export function resolveDomEditSelection( // fallow-ignore-next-line complexity
export async function resolveDomEditSelection(
startEl: HTMLElement | null, startEl: HTMLElement | null,
options: DomEditContextOptions, options: DomEditContextOptions & { projectId?: string | null; skipSourceProbe?: boolean },
): DomEditSelection | null { ): Promise<DomEditSelection | null> {
if (!startEl) return null; if (!startEl) return null;
const doc = startEl.ownerDocument; const doc = startEl.ownerDocument;
@@ -303,6 +345,14 @@ export function resolveDomEditSelection(
const computedStyles = getCuratedComputedStyles(current); const computedStyles = getCuratedComputedStyles(current);
const textFields = collectDomEditTextFields(current); const textFields = collectDomEditTextFields(current);
const isInsideLocked = Boolean(findClosestByAttribute(current, ["data-timeline-locked"])); 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({ const capabilities = resolveDomEditCapabilities({
selector, selector,
tagName: current.tagName.toLowerCase(), tagName: current.tagName.toLowerCase(),
@@ -312,6 +362,7 @@ export function resolveDomEditSelection(
isCompositionHost: Boolean(compositionSrc), isCompositionHost: Boolean(compositionSrc),
isInsideLockedComposition: isInsideLocked, isInsideLockedComposition: isInsideLocked,
isMasterView: options.isMasterView, isMasterView: options.isMasterView,
existsInSource,
}); });
const rect = current.getBoundingClientRect(); const rect = current.getBoundingClientRect();
@@ -345,10 +396,10 @@ export function resolveDomEditSelection(
return null; return null;
} }
export function refreshDomEditSelection( export async function refreshDomEditSelection(
selection: DomEditSelection, selection: DomEditSelection,
activeCompositionPath: string | null, activeCompositionPath: string | null,
): DomEditSelection | null { ): Promise<DomEditSelection | null> {
const doc = selection.element.ownerDocument; const doc = selection.element.ownerDocument;
const nextElement = findElementForSelection(doc, selection, activeCompositionPath); const nextElement = findElementForSelection(doc, selection, activeCompositionPath);
return nextElement return nextElement
@@ -73,7 +73,7 @@ export type UseDomEditOverlayGesturesOptions = {
( (
e: React.PointerEvent<HTMLDivElement>, e: React.PointerEvent<HTMLDivElement>,
o?: { preferClipAncestor?: boolean }, o?: { preferClipAncestor?: boolean },
) => DomEditSelection | null ) => Promise<DomEditSelection | null>
>; >;
onCanvasMouseDown: ( onCanvasMouseDown: (
e: React.MouseEvent<HTMLDivElement>, e: React.MouseEvent<HTMLDivElement>,
+10 -1
View File
@@ -81,7 +81,7 @@ export interface UseDomEditCommitsParams {
buildDomSelectionFromTarget: ( buildDomSelectionFromTarget: (
target: HTMLElement, target: HTMLElement,
options?: { preferClipAncestor?: boolean }, options?: { preferClipAncestor?: boolean },
) => DomEditSelection | null; ) => Promise<DomEditSelection | null>;
} }
// ── Hook ── // ── Hook ──
@@ -128,6 +128,7 @@ export function useDomEditCommits({
[fileTree, projectId, importedFontAssetsRef], [fileTree, projectId, importedFontAssetsRef],
); );
// fallow-ignore-next-line complexity
const persistDomEditOperations: PersistDomEditOperations = useCallback( const persistDomEditOperations: PersistDomEditOperations = useCallback(
async (selection, operations, options) => { async (selection, operations, options) => {
const pid = projectIdRef.current; const pid = projectIdRef.current;
@@ -232,6 +233,7 @@ export function useDomEditCommits({
// ── Position patch helper ── // ── Position patch helper ──
// fallow-ignore-next-line complexity
const commitPositionPatchToHtml = useCallback( const commitPositionPatchToHtml = useCallback(
( (
selection: DomEditSelection, selection: DomEditSelection,
@@ -244,6 +246,7 @@ export function useDomEditCommits({
coalesceKey: options.coalesceKey, coalesceKey: options.coalesceKey,
skipRefresh: options.skipRefresh ?? true, skipRefresh: options.skipRefresh ?? true,
}); });
// fallow-ignore-next-line complexity
}).catch((error) => { }).catch((error) => {
const message = error instanceof Error ? error.message : "Failed to save position"; const message = error instanceof Error ? error.message : "Failed to save position";
showToast(message); showToast(message);
@@ -251,6 +254,9 @@ export function useDomEditCommits({
source: "dom_edit", source: "dom_edit",
label: options.label, label: options.label,
error_message: message, 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) ── // ── Motion commits (HTML-attributebacked) ──
// fallow-ignore-next-line complexity
const handleDomMotionCommit = useCallback( const handleDomMotionCommit = useCallback(
( (
selection: DomEditSelection, selection: DomEditSelection,
@@ -359,6 +366,7 @@ export function useDomEditCommits({
[commitPositionPatchToHtml, previewIframeRef, refreshDomEditSelectionFromPreview], [commitPositionPatchToHtml, previewIframeRef, refreshDomEditSelectionFromPreview],
); );
// fallow-ignore-next-line complexity
const handleDomMotionClear = useCallback( const handleDomMotionClear = useCallback(
(selection: DomEditSelection) => { (selection: DomEditSelection) => {
const clearPatches = buildClearMotionPatches(selection.element); const clearPatches = buildClearMotionPatches(selection.element);
@@ -387,6 +395,7 @@ export function useDomEditCommits({
[commitPositionPatchToHtml, previewIframeRef, refreshDomEditSelectionFromPreview], [commitPositionPatchToHtml, previewIframeRef, refreshDomEditSelectionFromPreview],
); );
// fallow-ignore-next-line complexity
const handleDomEditElementDelete = useCallback( const handleDomEditElementDelete = useCallback(
async (selection: DomEditSelection) => { async (selection: DomEditSelection) => {
const pid = projectIdRef.current; const pid = projectIdRef.current;
@@ -231,7 +231,7 @@ export function useDomEditSession({
useEffect(() => { useEffect(() => {
if (!previewIframe) return; if (!previewIframe) return;
const syncSelectionFromDocument = () => { const syncSelectionFromDocument = async () => {
if (!STUDIO_INSPECTOR_PANELS_ENABLED || captionEditMode) return; if (!STUDIO_INSPECTOR_PANELS_ENABLED || captionEditMode) return;
const currentSelection = domEditSelectionRef.current; const currentSelection = domEditSelectionRef.current;
if (!currentSelection) return; if (!currentSelection) return;
@@ -249,7 +249,7 @@ export function useDomEditSession({
return; return;
} }
const nextSelection = buildDomSelectionFromTarget(nextElement); const nextSelection = await buildDomSelectionFromTarget(nextElement);
if (nextSelection) { if (nextSelection) {
applyDomSelection(nextSelection, { revealPanel: false, preserveGroup: true }); applyDomSelection(nextSelection, { revealPanel: false, preserveGroup: true });
} }
@@ -257,13 +257,13 @@ export function useDomEditSession({
syncPreviewHistoryHotkey(previewIframe); syncPreviewHistoryHotkey(previewIframe);
void applyStudioManualEditsToPreviewRef.current(previewIframe); void applyStudioManualEditsToPreviewRef.current(previewIframe);
syncSelectionFromDocument(); void syncSelectionFromDocument();
refreshPreviewDocumentVersion(); refreshPreviewDocumentVersion();
const handleLoad = () => { const handleLoad = () => {
syncPreviewHistoryHotkey(previewIframe); syncPreviewHistoryHotkey(previewIframe);
void applyStudioManualEditsToPreviewRef.current(previewIframe); void applyStudioManualEditsToPreviewRef.current(previewIframe);
syncSelectionFromDocument(); void syncSelectionFromDocument();
refreshPreviewDocumentVersion(); refreshPreviewDocumentVersion();
}; };
@@ -38,7 +38,7 @@ export interface UseDomEditTextCommitsParams {
buildDomSelectionFromTarget: ( buildDomSelectionFromTarget: (
target: HTMLElement, target: HTMLElement,
options?: { preferClipAncestor?: boolean }, options?: { preferClipAncestor?: boolean },
) => DomEditSelection | null; ) => Promise<DomEditSelection | null>;
persistDomEditOperations: PersistDomEditOperations; persistDomEditOperations: PersistDomEditOperations;
resolveImportedFontAsset: (fontFamilyValue: string) => ImportedFontAsset | null; resolveImportedFontAsset: (fontFamilyValue: string) => ImportedFontAsset | null;
} }
@@ -231,7 +231,7 @@ export function useDomEditTextCommits({
if (doc) { if (doc) {
const refreshed = findElementForSelection(doc, domEditSelection, activeCompPath); const refreshed = findElementForSelection(doc, domEditSelection, activeCompPath);
if (refreshed) { if (refreshed) {
const nextSelection = buildDomSelectionFromTarget(refreshed); const nextSelection = await buildDomSelectionFromTarget(refreshed);
if (nextSelection) { if (nextSelection) {
applyDomSelection(nextSelection, { revealPanel: false, preserveGroup: true }); applyDomSelection(nextSelection, { revealPanel: false, preserveGroup: true });
} }
@@ -287,7 +287,7 @@ export function useDomEditTextCommits({
if (doc) { if (doc) {
const refreshed = findElementForSelection(doc, selection, activeCompPath); const refreshed = findElementForSelection(doc, selection, activeCompPath);
if (refreshed) { if (refreshed) {
const nextSelection = buildDomSelectionFromTarget(refreshed); const nextSelection = await buildDomSelectionFromTarget(refreshed);
if (nextSelection) { if (nextSelection) {
applyDomSelection(nextSelection, { revealPanel: false, preserveGroup: true }); applyDomSelection(nextSelection, { revealPanel: false, preserveGroup: true });
} }
+28 -16
View File
@@ -60,17 +60,19 @@ export interface UseDomSelectionReturn {
buildDomSelectionFromTarget: ( buildDomSelectionFromTarget: (
target: HTMLElement, target: HTMLElement,
options?: { preferClipAncestor?: boolean }, options?: { preferClipAncestor?: boolean },
) => DomEditSelection | null; ) => Promise<DomEditSelection | null>;
resolveDomSelectionFromPreviewPoint: ( resolveDomSelectionFromPreviewPoint: (
clientX: number, clientX: number,
clientY: number, clientY: number,
options?: { preferClipAncestor?: boolean }, options?: { preferClipAncestor?: boolean },
) => DomEditSelection | null; ) => Promise<DomEditSelection | null>;
updateDomEditHoverSelection: (selection: DomEditSelection | null) => void; updateDomEditHoverSelection: (selection: DomEditSelection | null) => void;
buildDomSelectionForTimelineElement: (element: TimelineElement) => DomEditSelection | null; buildDomSelectionForTimelineElement: (
handleTimelineElementSelect: (element: TimelineElement | null) => void; element: TimelineElement,
refreshDomEditSelectionFromPreview: (selection: DomEditSelection) => void; ) => Promise<DomEditSelection | null>;
refreshDomEditGroupSelectionsFromPreview: (selections: DomEditSelection[]) => void; handleTimelineElementSelect: (element: TimelineElement | null) => Promise<void>;
refreshDomEditSelectionFromPreview: (selection: DomEditSelection) => Promise<void>;
refreshDomEditGroupSelectionsFromPreview: (selections: DomEditSelection[]) => Promise<void>;
} }
// ── Hook ── // ── Hook ──
@@ -193,24 +195,34 @@ export function useDomSelection({
}, [applyDomSelection]); }, [applyDomSelection]);
const buildDomSelectionFromTarget = useCallback( const buildDomSelectionFromTarget = useCallback(
(target: HTMLElement, options?: { preferClipAncestor?: boolean }) => { (
target: HTMLElement,
options?: { preferClipAncestor?: boolean; skipSourceProbe?: boolean },
) => {
return resolveDomEditSelection(target, { return resolveDomEditSelection(target, {
activeCompositionPath: activeCompPath, activeCompositionPath: activeCompPath,
isMasterView, isMasterView,
preferClipAncestor: options?.preferClipAncestor, preferClipAncestor: options?.preferClipAncestor,
skipSourceProbe: options?.skipSourceProbe,
projectId,
}); });
}, },
[activeCompPath, isMasterView], [activeCompPath, isMasterView, projectId],
); );
const resolveDomSelectionFromPreviewPoint = useCallback( const resolveDomSelectionFromPreviewPoint = useCallback(
(clientX: number, clientY: number, options?: { preferClipAncestor?: boolean }) => { async (
clientX: number,
clientY: number,
options?: { preferClipAncestor?: boolean; skipSourceProbe?: boolean },
) => {
const iframe = previewIframeRef.current; const iframe = previewIframeRef.current;
if (!iframe || captionEditMode) return null; if (!iframe || captionEditMode) return null;
const target = getPreviewTargetFromPointer(iframe, clientX, clientY, activeCompPath); const target = getPreviewTargetFromPointer(iframe, clientX, clientY, activeCompPath);
if (!target) return null; if (!target) return null;
return buildDomSelectionFromTarget(target, { return buildDomSelectionFromTarget(target, {
preferClipAncestor: options?.preferClipAncestor, preferClipAncestor: options?.preferClipAncestor,
skipSourceProbe: options?.skipSourceProbe,
}); });
}, },
[activeCompPath, buildDomSelectionFromTarget, captionEditMode, previewIframeRef], [activeCompPath, buildDomSelectionFromTarget, captionEditMode, previewIframeRef],
@@ -223,7 +235,7 @@ export function useDomSelection({
}, []); }, []);
const buildDomSelectionForTimelineElement = useCallback( const buildDomSelectionForTimelineElement = useCallback(
(element: TimelineElement): DomEditSelection | null => { async (element: TimelineElement): Promise<DomEditSelection | null> => {
const iframe = previewIframeRef.current; const iframe = previewIframeRef.current;
let doc: Document | null = null; let doc: Document | null = null;
try { try {
@@ -248,21 +260,21 @@ export function useDomSelection({
); );
const handleTimelineElementSelect = useCallback( const handleTimelineElementSelect = useCallback(
(element: TimelineElement | null) => { async (element: TimelineElement | null) => {
if (!STUDIO_INSPECTOR_PANELS_ENABLED) return; if (!STUDIO_INSPECTOR_PANELS_ENABLED) return;
if (!element) { if (!element) {
applyDomSelection(null, { revealPanel: false }); applyDomSelection(null, { revealPanel: false });
return; return;
} }
const selection = buildDomSelectionForTimelineElement(element); const selection = await buildDomSelectionForTimelineElement(element);
if (selection) applyDomSelection(selection); if (selection) applyDomSelection(selection);
}, },
[applyDomSelection, buildDomSelectionForTimelineElement], [applyDomSelection, buildDomSelectionForTimelineElement],
); );
const refreshDomEditSelectionFromPreview = useCallback( const refreshDomEditSelectionFromPreview = useCallback(
(selection: DomEditSelection) => { async (selection: DomEditSelection) => {
const iframe = previewIframeRef.current; const iframe = previewIframeRef.current;
let doc: Document | null = null; let doc: Document | null = null;
try { try {
@@ -275,7 +287,7 @@ export function useDomSelection({
const element = findElementForSelection(doc, selection, activeCompPath); const element = findElementForSelection(doc, selection, activeCompPath);
if (!element) return; if (!element) return;
const nextSelection = buildDomSelectionFromTarget(element); const nextSelection = await buildDomSelectionFromTarget(element);
if (nextSelection) { if (nextSelection) {
applyDomSelection(nextSelection, { applyDomSelection(nextSelection, {
revealPanel: false, revealPanel: false,
@@ -287,7 +299,7 @@ export function useDomSelection({
); );
const refreshDomEditGroupSelectionsFromPreview = useCallback( const refreshDomEditGroupSelectionsFromPreview = useCallback(
(selections: DomEditSelection[]) => { async (selections: DomEditSelection[]) => {
const iframe = previewIframeRef.current; const iframe = previewIframeRef.current;
let doc: Document | null = null; let doc: Document | null = null;
try { try {
@@ -301,7 +313,7 @@ export function useDomSelection({
for (const selection of selections) { for (const selection of selections) {
const element = findElementForSelection(doc, selection, activeCompPath); const element = findElementForSelection(doc, selection, activeCompPath);
if (!element) continue; if (!element) continue;
const nextSelection = buildDomSelectionFromTarget(element); const nextSelection = await buildDomSelectionFromTarget(element);
if (nextSelection) nextGroup.push(nextSelection); if (nextSelection) nextGroup.push(nextSelection);
} }
if (nextGroup.length === 0) return; if (nextGroup.length === 0) return;
@@ -20,8 +20,8 @@ export interface UsePreviewInteractionParams {
resolveDomSelectionFromPreviewPoint: ( resolveDomSelectionFromPreviewPoint: (
clientX: number, clientX: number,
clientY: number, clientY: number,
options?: { preferClipAncestor?: boolean }, options?: { preferClipAncestor?: boolean; skipSourceProbe?: boolean },
) => DomEditSelection | null; ) => Promise<DomEditSelection | null>;
updateDomEditHoverSelection: (selection: DomEditSelection | null) => void; updateDomEditHoverSelection: (selection: DomEditSelection | null) => void;
onClickToSource?: (selection: DomEditSelection) => void; onClickToSource?: (selection: DomEditSelection) => void;
@@ -40,9 +40,9 @@ export function usePreviewInteraction({
onClickToSource, onClickToSource,
}: UsePreviewInteractionParams) { }: UsePreviewInteractionParams) {
const handlePreviewCanvasMouseDown = useCallback( 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; 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, preferClipAncestor: options?.preferClipAncestor ?? false,
}); });
if (!nextSelection) { if (!nextSelection) {
@@ -66,14 +66,15 @@ export function usePreviewInteraction({
); );
const handlePreviewCanvasPointerMove = useCallback( 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) { if (!STUDIO_PREVIEW_SELECTION_ENABLED || captionEditMode || compositionLoading) {
updateDomEditHoverSelection(null); updateDomEditHoverSelection(null);
return null; return null;
} }
const nextSelection = resolveDomSelectionFromPreviewPoint(e.clientX, e.clientY, { const nextSelection = await resolveDomSelectionFromPreviewPoint(e.clientX, e.clientY, {
preferClipAncestor: options?.preferClipAncestor ?? false, preferClipAncestor: options?.preferClipAncestor ?? false,
skipSourceProbe: true,
}); });
updateDomEditHoverSelection(nextSelection); updateDomEditHoverSelection(nextSelection);
return nextSelection; return nextSelection;
@@ -25,7 +25,7 @@ interface UseStudioUrlStateParams {
buildDomSelectionFromTarget: ( buildDomSelectionFromTarget: (
target: HTMLElement, target: HTMLElement,
options?: { preferClipAncestor?: boolean }, options?: { preferClipAncestor?: boolean },
) => DomEditSelection | null; ) => Promise<DomEditSelection | null>;
applyDomSelection: ( applyDomSelection: (
selection: DomEditSelection | null, selection: DomEditSelection | null,
options?: { options?: {
@@ -140,10 +140,11 @@ export function useStudioUrlState({
return; return;
} }
const selection = buildDomSelectionFromTarget(element, { preferClipAncestor: false });
applyDomSelection(selection, { revealPanel: false });
hydratedSelectionRef.current = true; hydratedSelectionRef.current = true;
pendingSelectionRef.current = null; pendingSelectionRef.current = null;
void buildDomSelectionFromTarget(element, { preferClipAncestor: false }).then((selection) => {
applyDomSelection(selection, { revealPanel: false });
});
}, [ }, [
activeCompPath, activeCompPath,
applyDomSelection, applyDomSelection,
@@ -53,7 +53,7 @@ function renderStudioUrlStateHarness(
timelineVisible: true, timelineVisible: true,
activeCompPathHydrated: true, activeCompPathHydrated: true,
domEditSelection: null, domEditSelection: null,
buildDomSelectionFromTarget: () => null, buildDomSelectionFromTarget: () => Promise.resolve(null),
applyDomSelection: () => {}, applyDomSelection: () => {},
initialState: { initialState: {
activeCompPath: null, activeCompPath: null,
@@ -162,7 +162,7 @@ describe("studio url state", () => {
expect(normalizeStudioUrlPanelTab("motion", { motionPanelEnabled: false })).toBe("design"); 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(); vi.useFakeTimers();
window.history.replaceState(null, "", "#project/demo?t=4.2&tab=design&selId=hero"); window.history.replaceState(null, "", "#project/demo?t=4.2&tab=design&selId=hero");
const requestSeek = vi.fn(); const requestSeek = vi.fn();
@@ -209,7 +209,7 @@ describe("studio url state", () => {
rightPanelTab: "design", rightPanelTab: "design",
rightCollapsed: false, rightCollapsed: false,
applyDomSelection, applyDomSelection,
buildDomSelectionFromTarget: () => restoredSelection, buildDomSelectionFromTarget: () => Promise.resolve(restoredSelection),
initialState: { initialState: {
activeCompPath: null, activeCompPath: null,
currentTime: 4.2, currentTime: 4.2,
@@ -232,8 +232,10 @@ describe("studio url state", () => {
expect(applyDomSelection).not.toHaveBeenCalled(); expect(applyDomSelection).not.toHaveBeenCalled();
harness.rerender({ currentTime: 4.2 }); harness.rerender({ currentTime: 4.2 });
act(() => { await act(async () => {
vi.advanceTimersByTime(250); vi.advanceTimersByTime(250);
// Flush microtasks so the async buildDomSelectionFromTarget Promise resolves
await Promise.resolve();
}); });
expect(applyDomSelection).toHaveBeenCalledWith(restoredSelection, { revealPanel: false }); expect(applyDomSelection).toHaveBeenCalledWith(restoredSelection, { revealPanel: false });