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:
@@ -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`,
|
||||||
|
|||||||
@@ -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
@@ -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>,
|
||||||
|
|||||||
@@ -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,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|||||||
@@ -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);
|
||||||
|
}
|
||||||
|
|||||||
@@ -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>,
|
||||||
|
|||||||
@@ -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-attribute–backed) ──
|
// ── Motion commits (HTML-attribute–backed) ──
|
||||||
|
|
||||||
|
// 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 });
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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 });
|
||||||
|
|
||||||
|
|||||||
Reference in New Issue
Block a user