feat(studio): track completed preview editing gestures (#4916)

* feat(studio): track completed preview editing gestures

* fix(studio): preserve gesture receipts and suppress duplicate usage

* fix(studio): count semantic keyframe writes once per gesture

* fix(studio): isolate recording state during pending saves

* fix(studio): count marquee selection changes from owner receipts

* fix(studio): retain accurate empty marquee receipts

* refactor(studio): simplify recording transitions and selection tests

* refactor(studio): share recording timing and marquee fixtures

* fix(studio): preserve recording targets and batch attribution

* fix(studio): retain per-member telemetry suppression

* style(studio): format batch receipt attribution

* test(studio): match writer receipts and animation metadata

* docs(studio): clarify paused position save rejection

* fix(studio): replace telemetry route names with route identity

* style(studio): format route identity regression

* Use random tab-local route identities in Studio telemetry

* Preserve route identities inherited through session storage

* Separate route storage validation from token creation
This commit is contained in:
Miguel Ángel
2026-10-03 00:13:18 +00:00
committed by GitHub
parent 01c6f6004a
commit 852782bbb0
83 changed files with 2382 additions and 583 deletions
@@ -1496,6 +1496,44 @@ tl.fromTo("#box", { opacity: 0, x: -50 }, { opacity: 1, x: 0, duration: 1.5, eas
expect(result.parsed.animations[0].fromProperties).toMatchObject({ opacity: 0.2, x: -25 });
});
it("reports a no-op keyframe member separately from a changed batch member", async () => {
const projectDir = createProjectDir();
writeHtml(
projectDir,
"comp.html",
`<script>const tl = gsap.timeline();tl.to("#box", {keyframes: {"0%": {x: 0}, "100%": {x: 100}}, duration: 2}, 0);</script>`,
);
const app = new Hono();
registerFileRoutes(app, createAdapter(projectDir));
const anim = await getFirstAnimation(app, "comp.html");
const add = {
type: "add-keyframe",
animationId: anim.id,
percentage: 50,
properties: { x: 50 },
};
const seed = await postGsapMutationBatch(app, "comp.html", { mutations: [add] });
expect(seed.status).toBe(200);
const seeded = await getFirstAnimation(app, "comp.html");
const response = await postGsapMutationBatch(app, "comp.html", {
mutations: [
{ ...add, animationId: seeded.id },
{ type: "update-meta", animationId: seeded.id, updates: { duration: 3 } },
],
});
expect(response.status).toBe(200);
const result = (await response.json()) as {
changed: boolean;
mutationChanges: boolean[];
after: string;
};
expect(result).toMatchObject({ changed: true, mutationChanges: [false, true] });
expect(result.after).toBe(readFileSync(join(projectDir, "comp.html"), "utf-8"));
expect(result).toMatchObject({
parsed: { animations: [expect.objectContaining({ duration: 3 })] },
});
});
it("conditionally restores the exact GSAP mutation output", async () => {
const projectDir = createProjectDir();
writeHtml(projectDir, "comp.html", "MUTATED");
@@ -1467,7 +1467,9 @@ async function applyGsapMutations(
return c.json({ error: error instanceof Error ? error.message : String(error) }, 400);
}
const mutationChanges: boolean[] = [];
for (const mutation of mutations) {
const previousScript = block.scriptText;
const result = await executeGsapMutation(mutation, block, respond, writer);
if (result instanceof Response) return result;
let newScript = typeof result === "string" ? result : result.script;
@@ -1480,6 +1482,7 @@ async function applyGsapMutations(
? syncPositionHoldsBeforeKeyframes(newScript)
: (await loadGsapParser()).syncPositionHoldsBeforeKeyframes(newScript);
}
mutationChanges.push(newScript !== previousScript);
block.scriptText = newScript;
}
@@ -1509,6 +1512,7 @@ async function applyGsapMutations(
ok: true,
changed,
mutated: changed,
mutationChanges,
parsed: parseGsapScriptAcorn(block.scriptText),
before: beforeHtml,
after: newHtml,
+2 -2
View File
@@ -272,7 +272,7 @@ export function StudioApp({ readOnlyPreview = false, readOnlyPreviewReason }: St
onUngroupSelection: () => domEditSessionRef.current.handleUngroupSelection(),
activeCompPath,
forceReloadSdkSession: sdkHandle.forceReload,
onToggleRecording: () => handleToggleRecordingRef.current(),
onToggleRecording: () => handleToggleRecordingRef.current("keyboard"),
readOnlyPreview,
});
const domEditSessionResult = useDomEditSession({
@@ -363,7 +363,7 @@ export function StudioApp({ readOnlyPreview = false, readOnlyPreviewReason }: St
resetErrors: resetConsoleErrors,
} = useConsoleErrorCapture(previewIframe);
const fileDrop = useGlobalFileDrop(timelineEditing.handleTimelineFileDrop);
const handleToggleRecordingRef = useRef<() => void>(() => {});
const handleToggleRecordingRef = useRef<(method?: "button" | "keyboard") => void>(() => {});
const domEditSessionRef = useRef(domEditSession);
domEditSessionRef.current = domEditSession;
const { gestureState, gestureRecording, handleToggleRecording } = useGestureCommit({
@@ -345,3 +345,30 @@ describe("DomEditCropHandles preset bar", () => {
expect(useCropPresetBarStore.getState().openFor).toBeNull();
});
});
vi.mock("../../utils/studioTelemetry", () => ({ trackStudioEvent: vi.fn() }));
import { trackStudioEvent } from "../../utils/studioTelemetry";
describe("crop usage", () => {
it.each([true, false])(
"counts a crop drag only after a changed writer result (%s)",
async (changed) => {
vi.mocked(trackStudioEvent).mockClear();
const saved = vi.fn(async () => ({ ok: true, changed }));
const { root } = render(makeEl("private-target", "inset(10px)"), saved);
const handle = document.querySelector<HTMLElement>('[aria-label="Crop right"]')!;
dragCropRight(handle, 1, [
[200, 1],
[180, 1],
[160, 1],
]);
await act(async () => {
await Promise.resolve();
});
expect(saved).toHaveBeenCalledTimes(1);
expect(vi.mocked(trackStudioEvent).mock.calls).toEqual(
changed ? [["feature_used", { feature: "crop", surface: "preview", method: "drag" }]] : [],
);
act(() => root.unmount());
},
);
});
@@ -1,3 +1,4 @@
import { trackPreviewEditResult, type PreviewMethod } from "../../utils/previewFeatureUsage";
import { useEffect, useRef, useState, type PointerEvent as ReactPointerEvent } from "react";
import type { DomEditSelection } from "./domEditing";
import { type OverlayRect, RESIZE_HANDLE_HIT_PX } from "./domEditOverlayGeometry";
@@ -229,19 +230,21 @@ export function DomEditCropHandles({
// the lift keeps it hidden while selected. A drag that ends where it started saves nothing.
const value = buildInsetClipPathSides(gesture.insets, gesture.radius);
if (value === buildInsetClipPathSides(gesture.startInsets, gesture.radius)) return;
commitClipPath(value);
commitClipPath(value, "drag");
};
const commitClipPath = (value: string) => {
const commitClipPath = (value: string, method: PreviewMethod) => {
const commit = onStyleCommit?.("clip-path", value);
void Promise.resolve(commit).catch(() => undefined);
void Promise.resolve(commit)
.then((result) => trackPreviewEditResult("crop", method, result))
.catch(() => undefined);
};
const applyPresetInsets = (insets: ClipPathInsetSides | null) => {
const next = insets ?? { top: 0, right: 0, bottom: 0, left: 0 };
setState((prev) => ({ ...prev, insets: next }));
const radius = readCropFollowingResize(selection.element)?.radius ?? 0;
commitClipPath(insets === null ? "" : buildInsetClipPathSides(next, radius));
commitClipPath(insets === null ? "" : buildInsetClipPathSides(next, radius), "button");
};
const cancelCropGesture = (event: ReactPointerEvent<HTMLElement>) => {
@@ -1,3 +1,4 @@
import type { DomSelectionResult } from "../../hooks/useDomSelectionTypes";
import type { RotationCommit } from "./rotationDraft";
import { memo, useEffect, useMemo, useRef, type RefObject } from "react";
import { type DomEditSelection } from "./domEditing";
@@ -98,7 +99,10 @@ export interface DomEditOverlayProps {
onStyleCommit?: (property: string, value: string) => Promise<unknown> | void;
recordingState?: GestureRecordingState;
onToggleRecording?: () => void;
onMarqueeSelect?: (selections: DomEditSelection[], additive: boolean) => void;
onMarqueeSelect?: (
selections: DomEditSelection[],
additive: boolean,
) => DomSelectionResult | void;
/**
* Delete the selected canvas element.
* Wire to handleDomEditElementDelete from useDomEditActionsContext —
@@ -1,3 +1,5 @@
import { observeGsapGesture } from "../../hooks/gsapGestureOutcome";
import { trackPreviewEditResult } from "../../utils/previewFeatureUsage";
import { scopedElementKey } from "../../hooks/gsapKeyframeCacheHelpers";
import { memo, useEffect, useRef, useState, type RefObject } from "react";
import type { DomEditSelection } from "./domEditing";
@@ -382,15 +384,18 @@ export const MotionPathOverlay = memo(function MotionPathOverlay({
selection &&
!usePlayerStore.getState().autoKeyframeEnabled
) {
const writes = observeGsapGesture((_sel, mutation, options) =>
commitMutation(mutation, options),
);
void commitWholePropertyOffset(
selection,
anim,
{ x, y },
d.ref.pct,
iframeRef.current,
{ commitMutation: (_sel, mutation, options) => commitMutation(mutation, options) },
{ commitMutation: writes.commit! },
"Move animation path",
);
).then(() => trackPreviewEditResult("motion_path", "drag", writes.finish()));
} else {
void commitNode(d.ref, x, y, animId, commitMutation);
}
@@ -1,6 +1,7 @@
import { Eye, Layers } from "../../icons/SystemIcons";
import type { DomEditSelection } from "./domEditingTypes";
import { canHideSelections } from "../../utils/timelineInspector";
import { useTrackDesignInput } from "../../contexts/DesignPanelInputContext";
function FlatEmptyState() {
return (
@@ -71,6 +72,7 @@ function FlatMultiSelectState({
// One predicate for both actions and for the handler's own refusal, so the
// button and the refusal cannot disagree about what audio is.
const hasAudio = !canHideSelections(multiSelectedElements);
const trackInput = useTrackDesignInput();
return (
<div className="flex flex-col gap-3 px-4 py-3">
<div className="flex items-center gap-3 rounded-xl border border-panel-border bg-panel-surface p-3">
@@ -141,7 +143,11 @@ function FlatMultiSelectState({
<button
type="button"
data-flat-multiselect-group="true"
onClick={onGroupSelection}
onClick={() => {
if (!onGroupSelection) return;
onGroupSelection();
trackInput("button", "group-selection");
}}
className="flex h-[34px] flex-1 items-center justify-center gap-2 rounded-lg bg-panel-hover text-[11px] font-semibold text-panel-text-0"
>
<Layers size={13} />
@@ -150,7 +156,11 @@ function FlatMultiSelectState({
<button
type="button"
data-flat-multiselect-hide-all="true"
onClick={onHideAllSelected}
onClick={() => {
if (!onHideAllSelected) return;
onHideAllSelected();
trackInput("button", "hide-all");
}}
className="flex h-[34px] items-center gap-1.5 rounded-lg border border-panel-border-input bg-panel-input px-3 text-[11px] font-medium text-panel-text-2"
>
<Eye size={13} />
@@ -1,8 +1,9 @@
import { trackStudioEvent } from "../../utils/studioTelemetry";
// @vitest-environment happy-dom
import React, { act, useRef } from "react";
import { createRoot } from "react-dom/client";
import { afterEach, describe, expect, it, vi } from "vitest";
import { afterEach, beforeEach, describe, expect, it, vi } from "vitest";
import { useAppHotkeys } from "../../hooks/useAppHotkeys";
import { usePlayerStore } from "../../player/store/playerStore";
import type { DomEditSelection } from "./domEditing";
@@ -182,3 +183,101 @@ describe("SnapToolbar ruler and safe-margin toggles", () => {
act(() => root.unmount());
});
});
vi.mock("../../utils/studioTelemetry", () => ({ trackStudioEvent: vi.fn() }));
beforeEach(() => vi.clearAllMocks());
describe("preview setting usage", () => {
it.each([
["Toggle snap", "snapping"],
["Toggle grid", "grid"],
["Toggle ruler", "ruler"],
["Toggle safe margins", "safe_margins"],
])("counts the %s button without setting values", (label, feature) => {
const { root } = renderToolbar();
const button = document.querySelector<HTMLButtonElement>(`[aria-label="${label}"]`)!;
act(() => button.click());
expect(trackStudioEvent).toHaveBeenCalledExactlyOnceWith("feature_used", {
feature,
surface: "preview",
method: "button",
});
act(() => root.unmount());
});
it("counts keyboard grid toggling once", () => {
const { root } = renderToolbar();
act(() => document.dispatchEvent(new KeyboardEvent("keydown", { key: "g", bubbles: true })));
expect(trackStudioEvent).toHaveBeenCalledExactlyOnceWith("feature_used", {
feature: "grid",
surface: "preview",
method: "keyboard",
});
act(() => root.unmount());
});
});
describe("preview field and key gesture counting", () => {
it("counts a grid spacing edit once when the field settles", () => {
const { root } = renderToolbar();
act(() => document.querySelector<HTMLButtonElement>('[aria-label="Grid options"]')!.click());
const input = document.querySelector<HTMLInputElement>('input[type="number"]')!;
const setter = Object.getOwnPropertyDescriptor(HTMLInputElement.prototype, "value")!.set!;
act(() => input.focus());
for (const value of ["20", "200"]) {
act(() => {
setter.call(input, value);
input.dispatchEvent(new Event("input", { bubbles: true }));
});
}
expect(trackStudioEvent).not.toHaveBeenCalled();
act(() => input.blur());
expect(trackStudioEvent).toHaveBeenCalledExactlyOnceWith("feature_used", {
feature: "grid_spacing",
surface: "preview",
method: "field",
});
act(() => root.unmount());
});
it("counts snap-to-grid through its committed checkbox", () => {
const { root } = renderToolbar();
act(() => document.querySelector<HTMLButtonElement>('[aria-label="Grid options"]')!.click());
act(() => document.querySelector<HTMLInputElement>('input[type="checkbox"]')!.click());
expect(trackStudioEvent).toHaveBeenCalledExactlyOnceWith("feature_used", {
feature: "snap_to_grid",
surface: "preview",
method: "button",
});
act(() => root.unmount());
});
it.each(["s", "g"])("does not count auto-repeat as another %s key gesture", (key) => {
const { root } = renderToolbar();
act(() => document.dispatchEvent(new KeyboardEvent("keydown", { key, bubbles: true })));
act(() =>
document.dispatchEvent(new KeyboardEvent("keydown", { key, repeat: true, bubbles: true })),
);
expect(trackStudioEvent).toHaveBeenCalledExactlyOnceWith("feature_used", {
feature: key === "s" ? "snapping" : "grid",
surface: "preview",
method: "keyboard",
});
act(() => root.unmount());
});
});
it("counts a changed field when click-away dismisses it before blur", () => {
const { root } = renderToolbar();
act(() => document.querySelector<HTMLButtonElement>('[aria-label="Grid options"]')!.click());
const input = document.querySelector<HTMLInputElement>('input[type="number"]')!;
act(() => input.focus());
act(() => {
Object.getOwnPropertyDescriptor(HTMLInputElement.prototype, "value")!.set!.call(input, "230");
input.dispatchEvent(new Event("input", { bubbles: true }));
});
act(() => document.body.dispatchEvent(new MouseEvent("mousedown", { bubbles: true })));
expect(document.querySelector('input[type="number"]')).toBeNull();
expect(trackStudioEvent).toHaveBeenCalledExactlyOnceWith("feature_used", {
feature: "grid_spacing",
surface: "preview",
method: "field",
});
act(() => root.unmount());
});
@@ -1,3 +1,4 @@
import { trackPreviewFeatureUsed } from "../../utils/previewFeatureUsage";
import { memo, useCallback, useEffect, useRef, useState } from "react";
import { MagnetStraight, GridFour, Path, Ruler, FrameCorners } from "@phosphor-icons/react";
import { usePlayerStore } from "../../player/store/playerStore";
@@ -16,6 +17,16 @@ export const SnapToolbar = memo(function SnapToolbar() {
const setMotionPathArmed = usePlayerStore((s) => s.setMotionPathArmed);
const popoverRef = useRef<HTMLDivElement>(null);
const gridButtonRef = useRef<HTMLButtonElement>(null);
const gridSpacingAtFocus = useRef<number | null>(null);
const currentGridSpacing = useRef(prefs.gridSpacing);
currentGridSpacing.current = prefs.gridSpacing;
const settleGridSpacing = useCallback(() => {
const previous = gridSpacingAtFocus.current;
gridSpacingAtFocus.current = null;
if (previous !== null && previous !== currentGridSpacing.current)
trackPreviewFeatureUsed("grid_spacing", "field");
}, []);
useEffect(() => settleGridSpacing, [settleGridSpacing]);
const updatePrefs = useCallback(
(patch: Partial<typeof prefs>) => {
@@ -26,10 +37,12 @@ export const SnapToolbar = memo(function SnapToolbar() {
const toggleSnap = useCallback(() => {
updatePrefs({ snapEnabled: !prefs.snapEnabled });
trackPreviewFeatureUsed("snapping", "button");
}, [prefs.snapEnabled, updatePrefs]);
const toggleGrid = useCallback(() => {
updatePrefs({ gridVisible: !prefs.gridVisible });
trackPreviewFeatureUsed("grid", "button");
}, [prefs.gridVisible, updatePrefs]);
useEffect(() => {
@@ -41,10 +54,12 @@ export const SnapToolbar = memo(function SnapToolbar() {
if (e.key === "s" && !e.metaKey && !e.ctrlKey && !e.altKey) {
e.preventDefault();
updatePrefs({ snapEnabled: !prefs.snapEnabled });
if (!e.repeat) trackPreviewFeatureUsed("snapping", "keyboard");
}
if (e.key === "g" && !e.metaKey && !e.ctrlKey && !e.altKey) {
e.preventDefault();
updatePrefs({ gridVisible: !prefs.gridVisible });
if (!e.repeat) trackPreviewFeatureUsed("grid", "keyboard");
}
};
document.addEventListener("keydown", handleKeyDown);
@@ -56,11 +71,12 @@ export const SnapToolbar = memo(function SnapToolbar() {
const handleClickOutside = (e: MouseEvent) => {
const target = e.target as Node;
if (popoverRef.current?.contains(target) || gridButtonRef.current?.contains(target)) return;
settleGridSpacing();
setGridPopoverOpen(false);
};
document.addEventListener("mousedown", handleClickOutside);
return () => document.removeEventListener("mousedown", handleClickOutside);
}, [gridPopoverOpen]);
}, [gridPopoverOpen, settleGridSpacing]);
return (
<div
@@ -101,7 +117,10 @@ export const SnapToolbar = memo(function SnapToolbar() {
? "bg-studio-accent/20 text-accent-ink"
: "bg-black/40 text-white/60 hover:bg-black/60 hover:text-white/80"
}`}
onClick={toggle}
onClick={() => {
toggle();
trackPreviewFeatureUsed(key === "rulerVisible" ? "ruler" : "safe_margins", "button");
}}
title={`${label} ${visible ? "on" : "off"}`}
aria-label={`Toggle ${label.toLowerCase()}`}
aria-pressed={visible}
@@ -136,6 +155,7 @@ export const SnapToolbar = memo(function SnapToolbar() {
onClick={toggleGrid}
onContextMenu={(e) => {
e.preventDefault();
if (gridPopoverOpen) settleGridSpacing();
setGridPopoverOpen((v) => !v);
}}
title={
@@ -150,7 +170,10 @@ export const SnapToolbar = memo(function SnapToolbar() {
<button
type="button"
className="absolute -right-0.5 -bottom-0.5 rounded-sm p-0.5 text-white/50 hover:text-white/90 bg-black/50"
onClick={() => setGridPopoverOpen((v) => !v)}
onClick={() => {
if (gridPopoverOpen) settleGridSpacing();
setGridPopoverOpen((v) => !v);
}}
title="Grid options"
aria-label="Grid options"
aria-expanded={gridPopoverOpen}
@@ -173,9 +196,21 @@ export const SnapToolbar = memo(function SnapToolbar() {
max={500}
step={10}
value={prefs.gridSpacing}
onFocus={() => {
gridSpacingAtFocus.current = prefs.gridSpacing;
}}
onBlur={settleGridSpacing}
onKeyDown={(e) => {
if (e.key === "Enter") e.currentTarget.blur();
}}
onChange={(e) => {
const val = Number.parseInt(e.target.value, 10);
if (Number.isFinite(val) && val >= 10 && val <= 500) {
if (
Number.isFinite(val) &&
val >= 10 &&
val <= 500 &&
val !== prefs.gridSpacing
) {
updatePrefs({ gridSpacing: val });
}
}}
@@ -186,7 +221,10 @@ export const SnapToolbar = memo(function SnapToolbar() {
<input
type="checkbox"
checked={prefs.snapToGrid}
onChange={() => updatePrefs({ snapToGrid: !prefs.snapToGrid })}
onChange={() => {
updatePrefs({ snapToGrid: !prefs.snapToGrid });
trackPreviewFeatureUsed("snap_to_grid", "button");
}}
className="accent-studio-accent"
/>
<span>Snap to grid</span>
@@ -1,6 +1,8 @@
// @vitest-environment happy-dom
import { afterEach, describe, expect, it, vi } from "vitest";
import { trackStudioEvent } from "../../utils/studioTelemetry";
vi.mock("../../utils/studioTelemetry", () => ({ trackStudioEvent: vi.fn() }));
import { DomEditSaveQueueOpenError } from "../../utils/domEditSaveQueue";
import type { DomEditSelection } from "./domEditing";
import type { GestureState, UseDomEditOverlayGesturesOptions } from "./domEditOverlayGestures";
@@ -178,7 +180,7 @@ function buildHarness(
};
const handlers = createDomEditOverlayGestureHandlers(opts);
return { handlers, commits, selection };
return { handlers, commits, selection, opts };
}
type OverlayRectLike = {
@@ -287,3 +289,48 @@ describe("anchored corner resize — the release commit feeds the center-pin off
expect(consoleError).toHaveBeenCalledWith("resize commit failed", failure);
});
});
describe("resize usage at pointer release", () => {
it("counts one completed resize despite multiple pointer moves", async () => {
vi.mocked(trackStudioEvent).mockClear();
const h = buildHarness(async () => ({ ok: true, changed: true }));
await finishResize(h.handlers);
expect(trackStudioEvent).toHaveBeenCalledExactlyOnceWith("feature_used", {
feature: "resize",
surface: "preview",
method: "drag",
});
});
it.each([undefined, { ok: true as const, changed: false }])(
"does not count an unchanged or unconfirmed resize",
async (result) => {
vi.mocked(trackStudioEvent).mockClear();
const h = buildHarness(async () => result);
await finishResize(h.handlers);
expect(trackStudioEvent).not.toHaveBeenCalled();
},
);
});
describe("move and rotation usage", () => {
it.each(["drag", "rotate"] as const)("counts %s once after pointer release", async (kind) => {
vi.mocked(trackStudioEvent).mockClear();
const h = buildHarness();
h.selection.capabilities.canApplyManualRotation = true;
const saved = vi.fn(async () => ({ ok: true as const, changed: true }));
h.opts.onPathOffsetCommitRef.current = saved;
h.opts.onRotationCommitRef.current = saved;
h.handlers.startGesture(kind, evt(200, 50));
h.handlers.onPointerMove(evt(150, 120));
h.handlers.onPointerMove(evt(100, 150));
expect(trackStudioEvent).not.toHaveBeenCalled();
h.handlers.onPointerUp(evt(100, 150));
await Promise.resolve();
expect(saved).toHaveBeenCalledTimes(1);
expect(trackStudioEvent).toHaveBeenCalledExactlyOnceWith("feature_used", {
feature: kind === "drag" ? "move" : "rotate",
surface: "preview",
method: "drag",
});
});
});
@@ -10,9 +10,8 @@
* filenames, not composition paths, not chain JSON. Every property below is
* either a fixed identifier from our own catalogue (a preset id, an effect
* type, a parameter key), a number, or a boolean. `studioTelemetry.ts` already
* strips the query string off `url_hash` for exactly this reason — the ids in
* it are the author's own. The rack sees the same class of data and must hold
* the same line.
* sends only a route kind in `url_hash` for the same reason. The rack sees
* the same class of data and must hold the same line.
*
* The second rule is about volume: **commit, not preview.** Every control in
* the rack has a preview path that fires continuously while a slider moves and
@@ -287,7 +287,7 @@ describe("handles follow what Studio would commit", () => {
{ x: 0, y: 0 },
commitList,
preview,
vi.fn(),
vi.fn(async () => undefined),
undefined,
preflightOnly,
);
@@ -401,7 +401,7 @@ describe("handles follow what Studio would commit", () => {
{ x: 0, y: 0 },
[stagger],
preview,
vi.fn(),
vi.fn(async () => undefined),
undefined,
groupPreflight,
);
@@ -1,3 +1,4 @@
import type { DomEditPersistOutcome } from "../../hooks/domEditCommitTypes";
import type { PatchOperation } from "../../utils/sourcePatcher";
import type { CommitMutation } from "../../hooks/gsapScriptCommitTypes";
import type { DomEditSelection } from "./domEditingTypes";
@@ -94,7 +95,7 @@ type PatchCommit = (
selection: DomEditSelection,
patches: PatchOperation[],
options: { label: string; coalesceKey: string; coalesceMs?: number },
) => Promise<void>;
) => Promise<DomEditPersistOutcome | undefined>;
/** Stage the crop and save it under the resize's undo key, taking it back off the element if that fails. */
export async function saveCropResize(
@@ -102,11 +103,11 @@ export async function saveCropResize(
selection: DomEditSelection,
commit: PatchCommit,
coalesceKey: string,
): Promise<void> {
): Promise<DomEditPersistOutcome | undefined> {
const crop = stage();
if (!crop) return;
try {
await commit(selection, [crop.patch], {
return await commit(selection, [crop.patch], {
label: "Resize layer",
coalesceKey,
coalesceMs: Number.POSITIVE_INFINITY,
@@ -1,10 +1,14 @@
// @vitest-environment happy-dom
import { act, useRef } from "react";
import { act, useRef, type RefObject } from "react";
import type { Root } from "react-dom/client";
import { afterEach, beforeEach, expect, it, vi } from "vitest";
import { installReactActEnvironment, mountReactHarness } from "../../hooks/domSelectionTestHarness";
import { trackStudioEvent } from "../../utils/studioTelemetry";
import type { DomSelectionResult } from "../../hooks/useDomSelectionTypes";
import { useMarqueeGestures } from "./marqueeCommit";
vi.mock("../../utils/studioTelemetry", () => ({ trackStudioEvent: vi.fn() }));
// Layout stands in for a real preview: every element is visible and sits at the band's start.
vi.mock("./domEditingElement", async (importOriginal) => ({
...(await importOriginal<typeof import("./domEditingElement")>()),
@@ -40,9 +44,53 @@ function Overlay() {
);
}
const pointer = (type: string, clientX: number, clientY: number) =>
function MarqueeSurface({
overlayRef,
marquee,
}: {
overlayRef: RefObject<HTMLDivElement | null>;
marquee: ReturnType<typeof useMarqueeGestures>;
}) {
return (
<div
ref={overlayRef}
data-band-overlay
onPointerDown={marquee.begin}
onPointerMove={marquee.onPointerMove}
onPointerUp={marquee.onPointerUp}
/>
);
}
function TestMarquee({
iframeRef,
onSelect,
resolveHits,
}: {
iframeRef: RefObject<HTMLIFrameElement | null>;
onSelect: (selections: HTMLElement[], additive: boolean) => DomSelectionResult | void;
resolveHits: (elements: HTMLElement[]) => HTMLElement[] | Promise<HTMLElement[]>;
}) {
const overlayRef = useRef<HTMLDivElement>(null);
const marquee = useMarqueeGestures({
iframeRef,
overlayRef,
activeCompositionPathRef: useRef<string | null>("index.html"),
onMarqueeSelectRef: { current: onSelect },
resolveHits,
});
return <MarqueeSurface overlayRef={overlayRef} marquee={marquee} />;
}
const pointer = (
type: string,
clientX: number,
clientY: number,
selector = "[data-overlay]",
shiftKey = false,
) =>
act(() => {
document.querySelector("[data-overlay]")!.dispatchEvent(
document.querySelector(selector)!.dispatchEvent(
new PointerEvent(type, {
bubbles: true,
buttons: type === "pointerup" ? 0 : 1,
@@ -50,6 +98,7 @@ const pointer = (type: string, clientX: number, clientY: number) =>
pointerId: 1,
clientX,
clientY,
shiftKey,
}),
);
});
@@ -68,6 +117,7 @@ const hostListener = (e: KeyboardEvent) => {
};
beforeEach(() => {
vi.clearAllMocks();
hostEscapes = [];
window.addEventListener("keydown", hostListener);
root = mountReactHarness(<Overlay />);
@@ -128,42 +178,17 @@ it("a reload promoted mid-band selects from the preview on screen, not the retir
const [retired, live] = [preview(), preview()];
const picked: HTMLElement[][] = [];
const iframeRef = { current: retired as HTMLIFrameElement | null };
function Band() {
const overlayRef = useRef<HTMLDivElement>(null);
const marquee = useMarqueeGestures({
iframeRef,
overlayRef,
activeCompositionPathRef: useRef<string | null>("index.html"),
onMarqueeSelectRef: useRef(() => undefined),
resolveHits: async (elements: HTMLElement[]) => {
const band = mountReactHarness(
<TestMarquee
iframeRef={iframeRef}
onSelect={() => undefined}
resolveHits={async (elements) => {
picked.push(elements);
return [];
},
});
return (
<div
ref={overlayRef}
data-band-overlay
onPointerDown={marquee.begin}
onPointerMove={marquee.onPointerMove}
onPointerUp={marquee.onPointerUp}
/>
);
}
const band = mountReactHarness(<Band />);
const fire = (type: string, x: number, y: number) =>
act(() => {
document.querySelector("[data-band-overlay]")!.dispatchEvent(
new PointerEvent(type, {
bubbles: true,
button: 0,
buttons: type === "pointerup" ? 0 : 1,
pointerId: 1,
clientX: x,
clientY: y,
}),
);
});
}}
/>,
);
const fire = (type: string, x: number, y: number) => pointer(type, x, y, "[data-band-overlay]");
try {
fire("pointerdown", 0, 0);
fire("pointermove", 120, 90);
@@ -175,3 +200,47 @@ it("a reload promoted mid-band selects from the preview on screen, not the retir
act(() => band.unmount());
}
});
it.each([
[2, false, 2, 0],
[1, true, 2, 1],
[2, true, 2, 1],
[1, true, 1, 0],
])(
"counts marquee receipt: %s hits, changed=%s, group=%s",
async (hits, changed, count, events) => {
const iframe = document.createElement("iframe");
const doc = document.implementation.createHTMLDocument("preview");
doc.body.innerHTML =
'<div data-composition-id="main"><h1 id="title">Title</h1><p id="subtitle">Subtitle</p></div>';
Object.defineProperty(iframe, "contentDocument", { value: doc });
const apply = vi.fn((_selections: HTMLElement[], _additive: boolean) => ({ changed, count }));
const band = mountReactHarness(
<TestMarquee
iframeRef={{ current: iframe }}
onSelect={apply}
resolveHits={(elements) => elements.slice(0, hits)}
/>,
);
const fire = (type: string, x: number, y: number) =>
pointer(type, x, y, "[data-band-overlay]", true);
try {
fire("pointerdown", 0, 0);
fire("pointermove", 120, 90);
await act(async () => {
fire("pointerup", 120, 90);
});
expect(apply).toHaveBeenCalledTimes(1);
expect(apply.mock.calls[0]![0]).toHaveLength(hits);
expect(trackStudioEvent).toHaveBeenCalledTimes(events);
if (events)
expect(trackStudioEvent).toHaveBeenCalledWith("feature_used", {
feature: "multi_select",
surface: "preview",
method: "drag",
});
} finally {
act(() => band.unmount());
}
},
);
@@ -1,3 +1,5 @@
import type { DomSelectionResult } from "../../hooks/useDomSelectionTypes";
import { trackPreviewFeatureUsed } from "../../utils/previewFeatureUsage";
// fallow-ignore-file code-duplication
import { useCallback, useEffect, useRef, useState } from "react";
import type { DomEditSelection } from "./domEditing";
@@ -18,6 +20,16 @@ interface MarqueeState {
target: Element;
}
function currentMarqueeCandidates(
cached: MarqueeHit[] | null,
iframe: HTMLIFrameElement,
overlay: HTMLDivElement,
path: string,
): MarqueeHit[] {
if (cached?.every((hit) => hit.element.ownerDocument === iframe.contentDocument)) return cached;
return collectMarqueeCandidates(iframe, overlay, path);
}
const MARQUEE_THRESHOLD_PX = 4;
interface MarqueeHit {
@@ -103,7 +115,9 @@ export interface MarqueeGesturesDeps<T = DomEditSelection> {
iframeRef: React.RefObject<HTMLIFrameElement | null>;
overlayRef: React.RefObject<HTMLDivElement | null>;
activeCompositionPathRef: React.RefObject<string | null>;
onMarqueeSelectRef: React.RefObject<((selections: T[], additive: boolean) => void) | undefined>;
onMarqueeSelectRef: React.RefObject<
((selections: T[], additive: boolean) => DomSelectionResult | void) | undefined
>;
/** Turns the elements a drag touched into picks; without it, Studio's edit selections. */
resolveHits?: (elements: HTMLElement[]) => T[] | Promise<T[]>;
selectionRef?: React.RefObject<DomEditSelection | null>;
@@ -127,6 +141,10 @@ export interface MarqueeGestures {
onPointerCancel: () => void;
}
function trackMarqueeSelection(result: DomSelectionResult | void): void {
if (result?.changed && result.count > 1) trackPreviewFeatureUsed("multi_select", "drag");
}
function releaseCapture(m: MarqueeState): void {
try {
m.target.releasePointerCapture(m.pointerId);
@@ -160,18 +178,16 @@ export function useMarqueeGestures<T>(deps: MarqueeGesturesDeps<T>): MarqueeGest
const overlay = deps.overlayRef.current;
if (!iframe || !overlay || !deps.onMarqueeSelectRef.current) return;
const acp = deps.activeCompositionPathRef.current ?? "index.html";
const cached = candidatesRef.current;
const measuredOnScreen = cached?.every(
(hit) => hit.element.ownerDocument === iframe.contentDocument,
);
const candidates =
cached && measuredOnScreen ? cached : collectMarqueeCandidates(iframe, overlay, acp);
const candidates = currentMarqueeCandidates(candidatesRef.current, iframe, overlay, acp);
const elements = hitsWithin(rect, candidates).map((hit) => hit.element);
const resolveHits = deps.resolveHits;
const picks = resolveHits
? await resolveHits(elements)
: ((await resolveDomEditSelections(elements, acp)) as T[]);
deps.onMarqueeSelectRef.current?.(picks, additive);
const applySelection = deps.onMarqueeSelectRef.current;
if (!applySelection) return;
const result = applySelection(picks, additive);
trackMarqueeSelection(result);
},
[
deps.iframeRef,
@@ -1,5 +1,5 @@
// @vitest-environment jsdom
import { describe, it, expect, vi } from "vitest";
import { beforeEach, describe, it, expect, vi } from "vitest";
import type { GsapAnimation } from "@hyperframes/parsers/gsap-parser";
import { editableAnimationId } from "./motionPathSelection";
import {
@@ -148,3 +148,31 @@ describe("commitCreatePath", () => {
);
});
});
vi.mock("../../utils/studioTelemetry", () => ({ trackStudioEvent: vi.fn() }));
import { trackStudioEvent } from "../../utils/studioTelemetry";
import type { CommitFn } from "./motionPathCommit";
beforeEach(() => vi.clearAllMocks());
describe("motion path usage at the writer", () => {
it("counts a changed drag once without coordinates or target ids", async () => {
const commit: CommitFn = async (_mutation, options) => {
options.onResult?.({ ok: true, changed: true });
};
await commitNode({ type: "keyframe", pct: 50 }, 120, 30, "private-target", commit);
expect(trackStudioEvent).toHaveBeenCalledExactlyOnceWith("feature_used", {
feature: "motion_path",
surface: "preview",
method: "drag",
});
});
it.each([undefined, { ok: true }, { ok: true, changed: false }, { ok: false, changed: true }])(
"does not count absent or unsuccessful results (%j)",
async (result) => {
const commit: CommitFn = async (_mutation, options) => {
if (result) options.onResult?.(result);
};
await commitAddWaypoint("private-target", 1, 20, 40, commit);
expect(trackStudioEvent).not.toHaveBeenCalled();
},
);
});
@@ -1,3 +1,5 @@
import type { CommitMutationOptions } from "../../hooks/gsapScriptCommitTypes";
import { trackPreviewFeatureUsed, type PreviewMethod } from "../../utils/previewFeatureUsage";
/**
* Commit helpers for the motion-path overlay. Each maps a canvas gesture to a
* GSAP source mutation routed through the (selection-bound) commit facade, which
@@ -7,11 +9,21 @@ import type { MotionNodeRef } from "./motionPathGeometry";
export type CommitFn = (
mutation: Record<string, unknown>,
options: { label: string; softReload?: boolean },
options: CommitMutationOptions,
) => Promise<void>;
const NEW_PATH_DURATION = 1.5;
function motionPathCommitOptions(label: string, method: PreviewMethod): CommitMutationOptions {
return {
label,
softReload: true,
onResult: (result) => {
if (result.ok && result.changed === true) trackPreviewFeatureUsed("motion_path", method);
},
};
}
export function commitNode(
ref: MotionNodeRef,
x: number,
@@ -23,10 +35,10 @@ export function commitNode(
ref.type === "keyframe"
? { type: "update-keyframe", animationId, percentage: ref.pct, properties: { x, y } }
: { type: "update-motion-path-point", animationId, pointIndex: ref.index, x, y };
return commit(mutation, {
label: ref.type === "keyframe" ? "Move keyframe" : "Move waypoint",
softReload: true,
});
return commit(
mutation,
motionPathCommitOptions(ref.type === "keyframe" ? "Move keyframe" : "Move waypoint", "drag"),
);
}
export function commitAddWaypoint(
@@ -38,7 +50,7 @@ export function commitAddWaypoint(
): Promise<void> {
return commit(
{ type: "add-motion-path-point", animationId, index, x, y },
{ label: "Add waypoint", softReload: true },
motionPathCommitOptions("Add waypoint", "button"),
);
}
@@ -54,7 +66,7 @@ export function commitAddKeyframe(
// at that pct) and converts a flat tween to keyframes form when needed.
return commit(
{ type: "add-keyframe", animationId, percentage, properties: { x, y } },
{ label: "Add keyframe", softReload: true },
motionPathCommitOptions("Add keyframe", "button"),
);
}
@@ -65,7 +77,7 @@ export function commitRemoveWaypoint(
): Promise<void> {
return commit(
{ type: "remove-motion-path-point", animationId, index },
{ label: "Remove waypoint", softReload: true },
motionPathCommitOptions("Remove waypoint", "button"),
);
}
@@ -78,6 +90,6 @@ export function commitCreatePath(
): Promise<void> {
return commit(
{ type: "add-motion-path", targetSelector, position, duration: NEW_PATH_DURATION, x, y },
{ label: "Create motion path", softReload: true },
motionPathCommitOptions("Create motion path", "button"),
);
}
@@ -1,3 +1,4 @@
import type { PropertyPanelProps } from "./propertyPanelTypes";
import { useEffect, useState } from "react";
import { useTrackDesignInput } from "../../contexts/DesignPanelInputContext";
import { Plus, X } from "../../icons/SystemIcons";
@@ -246,13 +247,15 @@ export function FlatTextSection({
element: DomEditSelection;
styles: Record<string, string>;
fontAssets: ImportedFontAsset[];
onImportFonts?: (files: FileList | File[]) => Promise<ImportedFontAsset[]>;
onSetText: (value: string, fieldKey?: string) => void;
onSetTextFieldStyle: (fieldKey: string, property: string, value: string) => void;
onPreviewTextFieldStyle?: (fieldKey: string, property: string, value: string) => void;
onAddTextField: (afterFieldKey?: string) => string | Promise<string | null> | null;
onRemoveTextField: (fieldKey: string) => void;
}) {
} & Pick<
PropertyPanelProps,
| "onImportFonts"
| "onSetText"
| "onSetTextFieldStyle"
| "onPreviewTextFieldStyle"
| "onAddTextField"
| "onRemoveTextField"
>) {
const track = useTrackDesignInput();
const [activeFieldKey, setActiveFieldKey] = useState<string | null>(
element.textFields[0]?.key ?? null,
@@ -1,3 +1,4 @@
import { useTrackDesignInput } from "../../contexts/DesignPanelInputContext";
/**
* The add-effect shelf: Tone, the composite jobs, and the raw registry.
*
@@ -81,6 +82,7 @@ export function FxAddMenu({
withJob,
withEffect,
}: FxAddMenuProps) {
const trackInput = useTrackDesignInput();
return (
<div
className="hf-fx-add-menu space-y-1.5 rounded-[4px] border border-panel-border-input p-1.5"
@@ -108,8 +110,15 @@ export function FxAddMenu({
title="Listen to this track and even out its loud and quiet parts."
disabled={disabled || analysing}
onClick={() => {
if (levelled) onRemoveLevel?.();
else onLevel();
if (levelled) {
if (onRemoveLevel) {
onRemoveLevel();
trackInput("button", "remove-levelling");
}
} else {
onLevel();
trackInput("button", "add-levelling");
}
onClose();
}}
// The one module here that cannot answer instantly: it has to
@@ -1,3 +1,4 @@
import { useTrackDesignInput } from "../../contexts/DesignPanelInputContext";
/**
* The voiceover carve, as one module in the FX rack.
*
@@ -165,6 +166,7 @@ export function FxCarveModule({
onCarvePreview(carve: HfCarveSettings): void;
}) {
const on = carve.enabled;
const trackInput = useTrackDesignInput();
const soleVoice = soleCarveVoice(sourceOptions, carve.sources);
const summary = carveSummary({ nodes, carve, analysing });
// The carve's own colour, used three ways: the module's left edge, the title,
@@ -215,7 +217,10 @@ export function FxCarveModule({
aria-pressed={on}
title={on ? "Switch the carve off" : "Switch the carve on"}
disabled={disabled}
onClick={() => onCarveChange({ ...carve, enabled: !on })}
onClick={() => {
onCarveChange({ ...carve, enabled: !on });
trackInput("toggle", "carve-enabled");
}}
>
{on ? "On" : "Off"}
</button>
@@ -250,7 +255,10 @@ export function FxCarveModule({
value={carve.strength}
disabled={disabled || carve.sources.length === 0}
onChange={(_k, v) => onCarvePreview({ ...carve, strength: Number(v) })}
onCommit={(_k, v) => onCarveChange({ ...carve, strength: Number(v) })}
onCommit={(_k, v) => {
onCarveChange({ ...carve, strength: Number(v) });
trackInput("slider", "carve-strength");
}}
/>
</div>
{/* What the analysis made of all that. Divided rather than boxed: these
@@ -334,6 +342,7 @@ function CarveSourceRow({
disabled?: boolean;
onCarveChange(carve: HfCarveSettings): void;
}) {
const trackInput = useTrackDesignInput();
return (
<div className="hf-fx-row flex min-h-6 items-center gap-2">
{/* Wraps like every other name in this column (see FxParamRow) — one
@@ -366,14 +375,15 @@ function CarveSourceRow({
data-carve-source={o.id}
checked={carve.sources.includes(o.id)}
disabled={disabled}
onChange={(e) =>
onChange={(e) => {
onCarveChange({
...carve,
sources: e.target.checked
? [...carve.sources, o.id]
: carve.sources.filter((id) => id !== o.id),
})
}
});
trackInput("toggle", "carve-source");
}}
/>
<span className="truncate">{o.label}</span>
</label>
@@ -1,3 +1,4 @@
import { useTrackDesignInput } from "../../contexts/DesignPanelInputContext";
/**
* An effect row's open face: the derived or primary one-knob control, and
* Details underneath it.
@@ -54,6 +55,7 @@ function FxNodeDerivedKnob({
onUpdate(index: number, patch: Partial<HfAudioFxNode>): void;
trackKind?: string;
}) {
const trackInput = useTrackDesignInput();
if (!derived) return null;
return (
<>
@@ -66,6 +68,7 @@ function FxNodeDerivedKnob({
onCommit={(_k, v) => {
trackProfileCommitted(node.type, Number(v), { trackKind });
onUpdate(index, { params: applyAudioFxProfile(node.type, Number(v), params) });
trackInput("slider", "profile-strength");
}}
/>
</div>
@@ -1,3 +1,4 @@
import { useTrackDesignInput } from "../../contexts/DesignPanelInputContext";
/**
* An open effect's knobs, with whatever automation surface applies to them.
*
@@ -69,6 +70,7 @@ export function FxNodeParams({
index: number;
disabled: boolean;
}) {
const trackInput = useTrackDesignInput();
const nodeId = node.id;
// Lanes address a node by id; the controls know their own parameter keys. This
// is the one place that translation belongs.
@@ -97,14 +99,25 @@ export function FxNodeParams({
if (before[key] === value) continue;
if (typeof value !== "number" && typeof value !== "string") continue;
trackParamCommitted(node.type, key, value, "details", { trackKind });
trackInput(typeof value === "number" ? "slider" : "select", key);
}
onUpdate(index, { params: next });
}}
automatedKeys={automatedKeysOf(node, def.params, automatedTargets)}
onAutomate={nodeId && onAutomateParam ? (key) => onAutomateParam(nodeId, key) : undefined}
onAutomate={
nodeId && onAutomateParam
? (key) => {
onAutomateParam(nodeId, key);
trackInput("button", "automate-param");
}
: undefined
}
onRemoveAutomation={
nodeId && onRemoveParamAutomation
? (key) => onRemoveParamAutomation(nodeId, key)
? (key) => {
onRemoveParamAutomation(nodeId, key);
trackInput("button", "remove-param-automation");
}
: undefined
}
/>
@@ -1,3 +1,4 @@
import { useTrackDesignInput } from "../../contexts/DesignPanelInputContext";
/**
* One effect in the FX rack: its header controls, and its knobs when open.
*
@@ -246,6 +247,7 @@ export function FxNodeRow({
onPreview,
trackKind,
}: FxNodeRowProps) {
const trackInput = useTrackDesignInput();
const registryDef = getAudioFxDef(node.type);
const def = useMemo(() => (registryDef ? plainDef(registryDef) : null), [registryDef]);
const primary = registryDef ? primaryParamOf(registryDef) : null;
@@ -306,6 +308,7 @@ export function FxNodeRow({
onToggleBypass={() => {
trackNodeBypassed(node.type, !bypassed, { trackKind });
onUpdate(index, { enabled: bypassed });
trackInput("toggle", "effect-bypass");
}}
onMove={(delta) => onMove(index, delta)}
onRemove={() => onRemove(index)}
@@ -1,3 +1,4 @@
import { useTrackDesignInput } from "../../contexts/DesignPanelInputContext";
/**
* One preset's bracket in the rack: its own header, on/off switch, amount lane
* and the rows it wraps — or, for a run with no preset, just the rows.
@@ -93,6 +94,7 @@ export function FxPresetRun({
onAutomate,
onRemoveAutomation,
}: FxPresetRunProps) {
const trackInput = useTrackDesignInput();
const rows = run.items.map(({ node, i }) => (
<FxNodeRow
// Keyed by id, as the carve module's list above already is. On
@@ -183,7 +185,10 @@ export function FxPresetRun({
aria-pressed={runOn}
title={runOn ? `Switch ${preset.label} off` : `Switch ${preset.label} back on`}
disabled={disabled}
onClick={() => onSetAmount(runOn ? 0 : 1)}
onClick={() => {
onSetAmount(runOn ? 0 : 1);
trackInput("toggle", "preset-amount");
}}
>
{runOn ? "On" : "Off"}
</button>
@@ -205,7 +210,10 @@ export function FxPresetRun({
disabled={disabled || automated}
automated={automated}
onChange={(_k, v) => onSetAmount(Number(v), false)}
onCommit={(_k, v) => onSetAmount(Number(v))}
onCommit={(_k, v) => {
onSetAmount(Number(v));
trackInput("slider", "preset-amount");
}}
onAutomate={onAutomate}
onRemoveAutomation={onRemoveAutomation}
/>
@@ -1787,3 +1787,59 @@ describe("voiceover carve visibility", () => {
expect(carveBlock(host)).toBeTruthy();
});
});
vi.mock("../../utils/studioTelemetry", () => ({ trackStudioEvent: vi.fn() }));
import { trackStudioEvent } from "../../utils/studioTelemetry";
import { __resetDesignInputThrottle } from "../../utils/designInputTracking";
import { DesignPanelInputProvider } from "../../contexts/DesignPanelInputContext";
function renderTrackedFx(props: Partial<Parameters<typeof FxSection>[0]> = {}) {
vi.mocked(trackStudioEvent).mockClear();
__resetDesignInputThrottle();
return renderInto(
<DesignPanelInputProvider ui="flat" section="media">
<FxSection
chain={chainOf("lowpass", "gain")}
onChainChange={vi.fn()}
carve={null}
onCarveChange={vi.fn()}
sourceOptions={[]}
{...props}
/>
</DesignPanelInputProvider>,
);
}
describe("custom FX inspector input usage", () => {
it.each([
[".hf-fx-bypass", "toggle", "effect-bypass"],
['.hf-fx-move[title="Move down"]', "button", "move-effect"],
[".hf-fx-remove", "button", "remove-effect"],
])("tracks %s through design_input once", (selector, control, name) => {
const onChainChange = vi.fn();
const { host, root } = renderTrackedFx({ onChainChange });
click(host.querySelector(selector));
expect(onChainChange).toHaveBeenCalledTimes(1);
expect(
vi.mocked(trackStudioEvent).mock.calls.filter(([event]) => event === "design_input"),
).toEqual([["design_input", { ui: "flat", section: "media", control, name }]]);
expect(vi.mocked(trackStudioEvent).mock.calls.some(([event]) => event === "feature_used")).toBe(
false,
);
act(() => root.unmount());
});
it.each([false, true])("tracks a levelling button once (already levelled: %s)", (levelled) => {
const action = vi.fn();
const { host, root } = renderTrackedFx({ levelled, onLevel: action, onRemoveLevel: action });
click(byText(host, "button", "+ effect"));
vi.mocked(trackStudioEvent).mockClear();
click(byText(host, "button", levelled ? "Remove levelling" : "Even Out Levels"));
expect(action).toHaveBeenCalledTimes(1);
expect(trackStudioEvent).toHaveBeenCalledExactlyOnceWith("design_input", {
ui: "flat",
section: "media",
control: "button",
name: levelled ? "remove-levelling" : "add-levelling",
});
act(() => root.unmount());
});
});
@@ -1,3 +1,4 @@
import { useTrackDesignInput } from "../../contexts/DesignPanelInputContext";
/**
* The FX section for an audio element: the chain, plus the voiceover carve.
*
@@ -120,13 +121,17 @@ export function FxSection({
// could only name a source it must not.
const showCarve = !carvedAgainstBy && (sourceOptions.length > 0 || carve !== null);
const trackInput = useTrackDesignInput();
const [adding, setAdding] = useState(false);
const [picking, setPicking] = useState(false);
const [openNode, setOpenNode] = useState<number | null>(0);
const mutate = useCallback(
(nodes: HfAudioFxNode[]) => onChainChange({ ...chain, nodes }),
[chain, onChainChange],
(nodes: HfAudioFxNode[], input?: string, control = "button") => {
onChainChange({ ...chain, nodes });
if (input) trackInput(control, input);
},
[chain, onChainChange, trackInput],
);
// Dragging a knob previews without persisting; releasing it commits once.
@@ -156,7 +161,7 @@ export function FxSection({
// old chain back over the write that just landed is a race the author
// hears as the preset arriving and then leaving again.
clearAudition();
mutate(next.nodes);
mutate(next.nodes, "apply-preset");
// Land on the first node the preset wrote, so the author can hear what
// arrived and immediately see what it is made of.
setOpenNode(next.nodes.findIndex((n) => n.fromPreset === id));
@@ -169,7 +174,7 @@ export function FxSection({
(job: HfAudioFxJob) => {
clearAudition();
trackNodeAdded(job.type, "job", job.id, { trackKind });
mutate(withJob(chain, job).nodes);
mutate(withJob(chain, job).nodes, "add-job");
setOpenNode(chain.nodes.length);
setAdding(false);
},
@@ -180,7 +185,7 @@ export function FxSection({
(type: string) => {
clearAudition();
trackNodeAdded(type, "effect", null, { trackKind });
mutate(withEffect(chain, type).nodes);
mutate(withEffect(chain, type).nodes, "add-effect");
setOpenNode(chain.nodes.length);
setAdding(false);
},
@@ -218,26 +223,18 @@ export function FxSection({
[chain, mutate, onChainPreview],
);
/**
* Take a preset back out whole, lanes and all.
*
* Same contract as removing one node — an orphaned lane keeps driving a
* parameter that is no longer in the graph, and with ids minted lowest-free
* the next effect added inherits it.
*/
// Remove the run and its lanes together so new nodes cannot inherit orphaned automation.
const removeRun = useCallback(
(items: { node: HfAudioFxNode; i: number }[], presetId?: string) => {
// One call, not a loop: every write is computed from the same snapshot and
// replaces the whole attribute, so a loop kept only its last write and left
// the other nodes' lanes behind as orphans. The preset id goes with it —
// the `fx.preset.<id>` amount lane belongs to the preset, not to any node,
// so nothing else would ever collect it, and re-applying the preset later
// resurrected the old ramp.
// Remove node and preset automation together before replacing the chain.
const ids = items.map(({ node }) => node.id).filter((id): id is string => Boolean(id));
if (ids.length > 0 || presetId) onRemoveNodesAutomation?.(ids, presetId);
if (presetId) trackPresetRemoved(presetId, { trackKind });
const slots = new Set(items.map((item) => item.i));
mutate(chain.nodes.filter((_, i) => !slots.has(i)));
mutate(
chain.nodes.filter((_, i) => !slots.has(i)),
"remove-preset",
);
setOpenNode(null);
},
[chain.nodes, mutate, onRemoveNodesAutomation, trackKind],
@@ -254,7 +251,10 @@ export function FxSection({
const removedId = removed?.id;
if (removedId) onRemoveNodeAutomation?.(removedId);
if (removed) trackNodeRemoved(removed.type, nodeOrigin(removed), { trackKind });
mutate(chain.nodes.filter((_, i) => i !== index));
mutate(
chain.nodes.filter((_, i) => i !== index),
"remove-effect",
);
setOpenNode(null);
},
[chain.nodes, mutate, onRemoveNodeAutomation, trackKind],
@@ -372,7 +372,7 @@ export function FxSection({
clearAudition();
const { chain: next, eqId } = addAudioEq(chain);
trackNodeAdded("eq", "eq", null, { trackKind });
mutate(next.nodes);
mutate(next.nodes, "add-eq");
setOpenEq(eqId);
setAdding(false);
}, [chain, mutate, clearAudition, trackKind]);
@@ -386,20 +386,18 @@ export function FxSection({
);
const commitEqBand = useCallback(
(eqId: string, band: string, gain: number) =>
mutate(setAudioEqBandGain(chain, eqId, band, gain).nodes),
mutate(setAudioEqBandGain(chain, eqId, band, gain).nodes, "eq-gain", "slider"),
[chain, mutate],
);
const removeEq = useCallback(
(eqId: string) => {
// Batched for the same reason as `removeRun` — this loop had the identical
// last-write-wins bug and was only unreachable because an EQ band row
// offers no automation toggle today.
// Remove the EQ automation in one write.
const ids = chain.nodes
.filter((node) => node.fromEq === eqId)
.map((node) => node.id)
.filter((id): id is string => Boolean(id));
if (ids.length > 0) onRemoveNodesAutomation?.(ids);
mutate(removeAudioEq(chain, eqId).nodes);
mutate(removeAudioEq(chain, eqId).nodes, "remove-eq");
},
[chain, mutate, onRemoveNodesAutomation],
);
@@ -412,7 +410,7 @@ export function FxSection({
const [moved] = next.splice(index, 1);
next.splice(target, 0, moved!);
if (moved) trackNodeMoved(moved.type, delta < 0 ? "up" : "down", { trackKind });
mutate(next);
mutate(next, "move-effect");
setOpenNode(target);
},
[chain.nodes, mutate, trackKind],
@@ -448,6 +446,7 @@ export function FxSection({
return () => {
trackPresetAutomated(presetId, true, { trackKind });
onAutomatePreset(presetId, amount);
trackInput("button", "automate-preset");
};
};
@@ -456,7 +455,10 @@ export function FxSection({
presetId: string | undefined,
): (() => void) | undefined => {
if (!presetId || !onRemovePresetAutomation || !presetAutomated.has(presetId)) return undefined;
return () => onRemovePresetAutomation(presetId);
return () => {
onRemovePresetAutomation(presetId);
trackInput("button", "remove-preset-automation");
};
};
return (
@@ -82,12 +82,15 @@ export interface PropertyPanelProps {
element: DomEditSelection,
next: { x: number; y: number },
route?: { plainTranslate: boolean },
) => void | Promise<void>;
) => void | Promise<void | import("../../utils/previewFeatureUsage").GeometryCommitResult>;
onSetManualSize: (
element: DomEditSelection,
next: { width: number; height: number },
) => void | Promise<void>;
onSetManualRotation: (element: DomEditSelection, next: { angle: number }) => void | Promise<void>;
) => void | Promise<void | import("../../utils/previewFeatureUsage").GeometryCommitResult>;
onSetManualRotation: (
element: DomEditSelection,
next: { angle: number },
) => void | Promise<void | import("../../utils/previewFeatureUsage").GeometryCommitResult>;
onSetText: (value: string, fieldKey?: string) => void;
onSetTextFieldStyle: (fieldKey: string, property: string, value: string) => void;
onPreviewTextFieldStyle?: (fieldKey: string, property: string, value: string) => void;
@@ -1,3 +1,4 @@
import { trackStudioEvent } from "../../utils/studioTelemetry";
// @vitest-environment happy-dom
import React, { act } from "react";
import { createRoot } from "react-dom/client";
@@ -423,3 +424,39 @@ describe("useDomEditNudge — a commit that throws", () => {
act(() => root.unmount());
});
});
vi.mock("../../utils/studioTelemetry", () => ({ trackStudioEvent: vi.fn() }));
describe("nudge usage", () => {
beforeEach(() => {
vi.clearAllMocks();
vi.useFakeTimers();
__resetForTests();
});
afterEach(() => vi.useRealTimers());
it.each([true, false])(
"counts a whole key burst only when the writer changed source (%s)",
async (changed) => {
const root = createRoot(document.body.appendChild(document.createElement("div")));
const element = document.body.appendChild(document.createElement("div"));
const commit = vi.fn().mockResolvedValue({ ok: true, changed });
act(() =>
root.render(
<Harness selection={makeSelection("Dot", element)} onPathOffsetCommit={commit} />,
),
);
act(() => {
dispatchArrowRight();
dispatchArrowRight();
});
await act(async () => vi.advanceTimersByTimeAsync(CANVAS_NUDGE_COMMIT_DEBOUNCE_MS + 10));
expect(commit).toHaveBeenCalledTimes(1);
expect(vi.mocked(trackStudioEvent).mock.calls).toEqual(
changed
? [["feature_used", { feature: "nudge", surface: "preview", method: "keyboard" }]]
: [],
);
act(() => root.unmount());
element.remove();
},
);
});
@@ -1,3 +1,4 @@
import { trackPreviewEditResult } from "../../utils/previewFeatureUsage";
/**
* Canvas arrow-key nudge for DomEditOverlay: arrows move the selected
* element(s) 1 composition px, Shift = 10. Each keypress previews through the
@@ -173,6 +174,7 @@ export function useDomEditNudge(params: UseDomEditNudgeParams): { flushNudge: ()
for (const member of session.members)
restoreStudioPathOffset(member.element, member.initialPathOffset);
saved = Promise.resolve(commit)
.then((result) => trackPreviewEditResult("nudge", "keyboard", result))
.catch(() => {
for (const member of session.members) {
if (isStudioManualEditGestureCurrent(member.element, member.gestureToken)) {
@@ -1,3 +1,4 @@
import { trackPreviewEditResult } from "../../utils/previewFeatureUsage";
// fallow-ignore-file code-duplication
/**
* Gesture handling for DomEditOverlay.
@@ -353,6 +354,7 @@ export function createDomEditOverlayGestureHandlers(opts: UseDomEditOverlayGestu
const groupSaved = Promise.resolve(
groupEdit.adopt(() => opts.onGroupPathOffsetCommitRef.current(updates)),
)
.then((result) => trackPreviewEditResult("move", "drag", result))
.catch(() => {
for (const member of groupG.members) {
if (
@@ -442,6 +444,7 @@ export function createDomEditOverlayGestureHandlers(opts: UseDomEditOverlayGestu
applyRotationDraft(sel.element, finalRotation.angle, g.plainRotation);
const commit = g.plainRotation ? { ...finalRotation, plain: g.plainRotation } : finalRotation;
void Promise.resolve(opts.onRotationCommitRef.current(sel, commit))
.then((result) => trackPreviewEditResult("rotate", "drag", result))
.catch((error) => {
logGestureCommitFailure("rotate commit failed", error);
if (
@@ -488,6 +491,7 @@ export function createDomEditOverlayGestureHandlers(opts: UseDomEditOverlayGestu
}),
),
)
.then((result) => trackPreviewEditResult("move", "drag", result))
.catch(() => {
if (
g.pathOffsetMember?.gestureToken &&
@@ -534,6 +538,7 @@ export function createDomEditOverlayGestureHandlers(opts: UseDomEditOverlayGestu
void Promise.resolve(
opts.onBoxSizeCommitRef.current(sel, finalSize, finalOffset ?? undefined, restore),
)
.then((result) => trackPreviewEditResult("resize", "drag", result))
.catch((error) => {
logGestureCommitFailure("resize commit failed", error);
})
@@ -1,3 +1,4 @@
import { trackPreviewFeatureUsed } from "../../utils/previewFeatureUsage";
import { useCallback, useMemo } from "react";
import { useDomEditActionsContext } from "../../contexts/DomEditContext";
import { readHfId, type DomEditSelection } from "./domEditing";
@@ -156,8 +157,13 @@ export function useDomEditZOrder(): DomEditZOrder {
// One serialized z→lane transaction: the mirror runs only AFTER a durable z commit and
// no second gesture interleaves (see runZLaneGesture). A failed z commit has already
// toasted and rolled back, so the catch only keeps its rejection from going unhandled.
let changed = false;
runZLaneGesture({
commitZ: () => handleDomZIndexReorderCommit(entries, coalesceKey, action),
commitZ: async () => {
const result = await handleDomZIndexReorderCommit(entries, coalesceKey, action);
changed = result?.durable === true && result.changed;
return result;
},
mirror: () =>
mirrorZOrderToTimeline({
selectionKey: entries.find((e) => e.element === sel.element)?.key,
@@ -166,7 +172,11 @@ export function useDomEditZOrder(): DomEditZOrder {
sourceFile: sel.sourceFile,
coalesceKey,
}),
}).catch(() => undefined);
})
.then(() => {
if (changed) trackPreviewFeatureUsed("z_order", "button");
})
.catch(() => undefined);
return true;
},
[activeCompPath, handleDomZIndexReorderCommit, mirrorZOrderToTimeline, readOnly],
@@ -409,6 +409,7 @@ describe("useTimelineEditCallbacks — flat tween keyframe lanes", () => {
expect(mocks.actions.handleGsapRemoveAllKeyframes).toHaveBeenCalledWith(
scaleAnimation.id,
circleSelection,
false,
);
view.unmount();
});
@@ -1,4 +1,5 @@
import type { TimelineEditOutcome } from "../../hooks/timelineEditPermission";
import { trackKeyframeUsage } from "../../utils/keyframeUsage";
import { useCallback, useMemo } from "react";
import type { GsapAnimation } from "@hyperframes/core/gsap-parser";
import type { TimelineElement } from "../../player";
@@ -259,7 +260,12 @@ export function useTimelineEditCallbacks({
if (!selection) return;
// Serial: each removal rewrites the same source file, so dispatching
// them together would have the later writes read a pre-edit document.
for (const anim of anims) await handleGsapRemoveAllKeyframes(anim.id, selection);
let changed = false;
for (const anim of anims) {
const result = await handleGsapRemoveAllKeyframes(anim.id, selection, false);
changed = result || changed;
}
if (changed) trackKeyframeUsage("remove_all");
});
},
onDeleteKeyframe: (elId, keyframe) => {
@@ -251,7 +251,7 @@ export function DomEditProvider({
commitMutationRef.current = commitMutation;
const stableCommitMutation = useCallback<DomEditActionsValue["commitMutation"]>(
(mutation, options) => commitMutationRef.current(mutation, options),
(mutation, options, selection) => commitMutationRef.current(mutation, options, selection),
[],
);
+9 -1
View File
@@ -79,7 +79,7 @@ describe("DOM editing package exports", () => {
front.id = "front";
parent.append(back, front);
document.body.append(parent);
const commitZ = vi.fn(() => Promise.resolve());
const commitZ = vi.fn(async () => ({ durable: true, changed: true }));
const session = {
handleDomZIndexReorderCommit: commitZ,
} as unknown as Parameters<typeof DomEditProvider>[0]["value"];
@@ -107,6 +107,11 @@ describe("DOM editing package exports", () => {
await act(async () => {
expect(zOrder?.apply(sel, toFront)).toBe(true);
});
expect(trackStudioEvent).toHaveBeenCalledWith("feature_used", {
feature: "z_order",
surface: "preview",
method: "button",
});
expect(commitZ).toHaveBeenCalledTimes(1);
expect(commitZ).toHaveBeenCalledWith(
expect.arrayContaining([expect.objectContaining({ element: back, id: "back" })]),
@@ -159,3 +164,6 @@ describe("DOM editing package exports", () => {
await act(async () => root.unmount());
});
});
vi.mock("./utils/studioTelemetry", () => ({ trackStudioEvent: vi.fn() }));
import { trackStudioEvent } from "./utils/studioTelemetry";
@@ -94,7 +94,7 @@ export type DomEditCommitDeclineReason =
| "persist-failed";
export type DomEditCommitOutcome =
| { ok: true; persistence?: DomEditPersistOutcome }
| { ok: true; persistence?: DomEditPersistOutcome; changed?: boolean }
| { ok: false; reason: DomEditCommitDeclineReason };
export function domEditCommitDeclined(reason: DomEditCommitDeclineReason): DomEditCommitOutcome {
@@ -1,3 +1,4 @@
import type { DomEditPersistOutcome } from "./domEditCommitTypes";
import { getDomEditTargetKey, type DomEditSelection } from "../components/editor/domEditing";
import {
applyElementPositionOffset,
@@ -23,7 +24,7 @@ export interface ElementOffsetStagerDeps {
selection: DomEditSelection,
patches: PatchOperation[],
options: { label: string; coalesceKey: string; coalesceMs?: number },
) => Promise<void>;
) => Promise<DomEditPersistOutcome | undefined>;
showToast: (message: string, tone?: "error" | "info") => void;
readOnlyPreview?: boolean;
}
@@ -59,7 +60,7 @@ function stagePlainTranslate(
selection: DomEditSelection,
next: { x: number; y: number },
coalesceKey?: string,
): { save: () => Promise<void>; rollback: () => void } {
): { save: () => Promise<DomEditPersistOutcome | undefined>; rollback: () => void } {
const el = selection.element;
const before = captureStudioPathOffset(el);
const patches = writePlainMove(el, next);
@@ -85,9 +86,9 @@ export function stageElementOffset(
next: { x: number; y: number },
plainTranslate: boolean,
coalesceKey?: string,
): { save: () => Promise<void>; rollback: () => void } {
): { save: () => Promise<DomEditPersistOutcome | undefined>; rollback: () => void } {
const el = selection.element;
if (readOnlyPreview) return { save: () => Promise.resolve(), rollback: () => undefined };
if (readOnlyPreview) return { save: () => Promise.resolve(undefined), rollback: () => undefined };
if (plainTranslate) {
refuseGsapTakeover(el, showToast);
return stagePlainTranslate(commitPositionPatchToHtml, selection, next, coalesceKey);
+2 -20
View File
@@ -13,7 +13,7 @@ import { resolveTweenStart, resolveTweenDuration } from "../utils/globalTimeComp
import { roundTo3, roundToLayoutPx } from "../utils/rounding";
import { computeElementPercentage, writeTargetSelector } from "./gsapShared";
import { computeDraggedGsapPosition } from "./draggedGsapPosition";
import type { RuntimeTweenChange } from "./gsapRuntimePatch";
import type { CommitMutation } from "./gsapScriptCommitTypes";
import { isGestureTransactionCommit, runGestureTransaction } from "./gestureTransaction";
import { setPatchFromUpdateProperty } from "./gsapDragStaticSetHelpers";
import { GsapEditBlockedError } from "./gsapEditOutcome";
@@ -23,25 +23,7 @@ export {
findSizeSetAnimation,
} from "./gsapDragStaticSetHelpers";
export interface GsapDragCommitCallbacks {
commitMutation: (
selection: DomEditSelection,
mutation: Record<string, unknown>,
options: {
label: string;
coalesceKey?: string;
coalesceMs?: number;
softReload?: boolean;
skipReload?: boolean;
beforeReload?: () => void;
/**
* Value-only fast path: when set, `runCommit` patches the changed tween in
* the preview runtime in place (instant, no re-run) and only falls back to
* the soft reload if the patch can't be safely applied. Attached only to
* value-only `set` commits; structural/keyframe commits omit it.
*/
instantPatch?: { selector: string; change: RuntimeTweenChange };
},
) => Promise<void>;
commitMutation: CommitMutation;
fetchAnimations?: () => Promise<GsapAnimation[]>;
}
@@ -81,7 +81,12 @@ async function extendTweenAndAddKeyframe(
duration: roundTo3(newDuration),
keyframes: remappedKfs,
},
{ label: `Move layer (extended keyframe)`, softReload: true, beforeReload },
{
label: `Move layer (extended keyframe)`,
keyframeAction: "add",
softReload: true,
beforeReload,
},
);
}
@@ -339,7 +344,12 @@ export async function commitGsapPositionFromDrag(
keyframes: temporalKeyframes,
ease: "none",
},
{ label: "Move layer (new keyframe)", softReload: true, beforeReload: restoreOffset },
{
label: "Move layer (new keyframe)",
keyframeAction: "add",
softReload: true,
beforeReload: restoreOffset,
},
);
return;
}
@@ -449,7 +459,12 @@ export async function commitGsapPositionFromDrag(
existingPosAnim
? { type: "replace-with-keyframes", animationId: existingPosAnim.id, ...baseKf }
: { type: "add-with-keyframes", ...baseKf },
{ label: "Move layer (from extended)", softReload: true, beforeReload: restoreOffset },
{
label: "Move layer (from extended)",
keyframeAction: "add",
softReload: true,
beforeReload: restoreOffset,
},
);
} else {
const coalesceKey = `gsap:convert-drag:${anim.id}`;
@@ -0,0 +1,161 @@
import { beforeEach, describe, expect, it, vi } from "vitest";
import { trackKeyframeCommit } from "../utils/keyframeUsage";
import { observeGsapGesture } from "./gsapGestureOutcome";
import type { CommitMutation, MutationResult } from "./gsapScriptCommitTypes";
import { trackStudioEvent } from "../utils/studioTelemetry";
vi.mock("../utils/studioTelemetry", () => ({ trackStudioEvent: vi.fn() }));
beforeEach(() => vi.clearAllMocks());
const selection = {} as Parameters<CommitMutation>[0];
const mutation = {
type: "add-keyframe",
animationId: "private-target",
properties: { text: "private-content" },
};
function writer(result?: MutationResult): CommitMutation {
const commit: CommitMutation = async (_selection, _mutation, options) => {
if (result) options.onResult?.(result);
};
commit.batch = async (_calls, options) => {
if (result) options.onResult?.(result);
};
return commit;
}
describe("GSAP gesture usage", () => {
it("counts a multi-write add gesture once from successful writer results", async () => {
const observed = observeGsapGesture(
writer({ ok: true, changed: true, mutationChanges: [true] }),
);
const onResult = vi.fn();
await observed.commit!(
selection,
{ type: "convert-to-keyframes" },
{ label: "Convert", onResult },
);
await observed.commit!(selection, mutation, { label: "Add" });
expect(trackStudioEvent).not.toHaveBeenCalled();
expect(observed.finish()).toEqual({ ok: true, changed: true });
expect(onResult).toHaveBeenCalledWith({ ok: true, changed: true, mutationChanges: [true] });
expect(trackStudioEvent).toHaveBeenCalledExactlyOnceWith("keyframe", { action: "add" });
});
it("counts a batched insertion once", async () => {
const observed = observeGsapGesture(
writer({ ok: true, changed: true, mutationChanges: [true, true] }),
);
await observed.commit!.batch!(
[
{ selection, mutation, options: { label: "Add" } },
{ selection, mutation, options: { label: "Add" } },
],
{ label: "Add together" },
);
expect(observed.finish().changed).toBe(true);
expect(trackStudioEvent).toHaveBeenCalledExactlyOnceWith("keyframe", { action: "add" });
});
it("retains insertion metadata from an earlier member of a mixed batch", async () => {
const observed = observeGsapGesture(
writer({ ok: true, changed: true, mutationChanges: [true, true] }),
);
await observed.commit!.batch!(
[
{
selection,
mutation: { type: "replace-with-keyframes" },
options: { label: "Extend", keyframeAction: "add" },
},
{ selection, mutation: { type: "update-keyframe" }, options: { label: "Update" } },
],
{ label: "Update" },
);
observed.finish();
expect(trackStudioEvent).toHaveBeenCalledExactlyOnceWith("keyframe", { action: "add" });
});
it("uses semantic insertion metadata for a replacement", async () => {
const observed = observeGsapGesture(
writer({ ok: true, changed: true, mutationChanges: [true] }),
);
await observed.commit!(
selection,
{ type: "replace-with-keyframes" },
{ label: "Replace", keyframeAction: "add" },
);
observed.finish();
expect(trackStudioEvent).toHaveBeenCalledExactlyOnceWith("keyframe", { action: "add" });
});
it.each([undefined, { ok: true }, { ok: true, changed: false }, { ok: false, changed: true }])(
"does not count resolution without a changed successful write (%j)",
async (result) => {
const observed = observeGsapGesture(writer(result));
await observed.commit!(selection, mutation, { label: "Add" });
expect(observed.finish()).toEqual({ ok: true, changed: false });
expect(trackStudioEvent).not.toHaveBeenCalled();
},
);
it("does not count missing writers or an unfinished group", async () => {
expect(observeGsapGesture(null).finish()).toEqual({ ok: true, changed: false });
const observed = observeGsapGesture(writer());
await observed.commit!(selection, mutation, { label: "Add" });
expect(observed.finish(true).changed).toBe(false);
expect(trackStudioEvent).not.toHaveBeenCalled();
});
it("reports a successful plain DOM gesture without inventing keyframes", () => {
expect(observeGsapGesture(null).finish(true).changed).toBe(true);
expect(trackStudioEvent).not.toHaveBeenCalled();
});
});
it.each([
[[false, true], 0],
[[true, false], 1],
[undefined, 0],
] as const)(
"attributes mixed batches only to changed members (%j)",
async (mutationChanges, count) => {
const result = {
ok: true,
changed: true,
mutationChanges: mutationChanges && [...mutationChanges],
};
const observed = observeGsapGesture(writer(result));
const mutations = [mutation, { type: "update-property" }];
await observed.commit!.batch!(
mutations.map((mutation) => ({ selection, mutation, options: { label: "Edit" } })),
{ label: "Edit" },
);
expect(observed.finish()).toEqual({ ok: true, changed: true });
expect(trackStudioEvent).toHaveBeenCalledTimes(count);
if (count) expect(trackStudioEvent).toHaveBeenCalledWith("keyframe", { action: "add" });
vi.mocked(trackStudioEvent).mockClear();
trackKeyframeCommit(mutations, result, { label: "Edit" });
expect(trackStudioEvent).toHaveBeenCalledTimes(count);
},
);
it("excludes a suppressed preparation from mixed-batch attribution", async () => {
const result = { ok: true, changed: true, mutationChanges: [true, false] };
const calls = [
{
selection,
mutation: { type: "convert-to-keyframes" },
options: { label: "Prepare", keyframeTelemetry: false },
},
{ selection, mutation, options: { label: "Add" } },
];
const observed = observeGsapGesture(writer(result));
await observed.commit!.batch!(calls, { label: "Add" });
expect(observed.finish()).toEqual({ ok: true, changed: true });
trackKeyframeCommit(
calls.map((call) => call.mutation),
result,
{ label: "Add" },
calls.map((call) => call.options),
);
expect(trackStudioEvent).not.toHaveBeenCalled();
});
@@ -0,0 +1,64 @@
import type {
CommitMutationCall,
CommitMutation,
CommitMutationOptions,
MutationResult,
} from "./gsapScriptCommitTypes";
import {
keyframeUsageActions,
changedMutationIndices,
primaryKeyframeAction,
trackKeyframeUsage,
type KeyframeUsageAction,
} from "../utils/keyframeUsage";
import type { GeometryCommitResult } from "../utils/previewFeatureUsage";
export function observeGsapGesture(writer: CommitMutation | null) {
let changed = false;
let pendingResults = 0;
const actions = new Set<KeyframeUsageAction>();
const observe = (calls: CommitMutationCall[], options: CommitMutationOptions) => {
pendingResults += 1;
return {
...options,
keyframeTelemetry: false,
onResult: (result: MutationResult) => {
pendingResults -= 1;
options.onResult?.(result);
if (!result.ok || result.changed !== true) return;
changed = true;
const members = changedMutationIndices(
result,
calls.length,
calls.map((call) => call.options),
).map((index) => calls[index]!);
for (const action of keyframeUsageActions(
members.map((call) => call.mutation),
members.map((call) => call.options.keyframeAction),
))
actions.add(action);
},
};
};
let commit: CommitMutation | null = null;
if (writer) {
commit = (selection, mutation, options) =>
writer(selection, mutation, observe([{ selection, mutation, options }], options));
if (writer.batch) {
const batch = writer.batch;
commit.batch = (calls, options) => batch(calls, observe(calls, options));
}
}
return {
commit,
recordDomResult: (result: { changed: boolean } | undefined) => {
changed ||= result?.changed === true;
},
finish: (domChanged = false): GeometryCommitResult => {
if (pendingResults !== 0) return { ok: true, changed: false };
const action = primaryKeyframeAction(actions);
if (action) trackKeyframeUsage(action);
return { ok: true, changed: changed || domChanged };
},
};
}
@@ -4,23 +4,14 @@ import { absoluteToPercentageForAnimation, findTweenAtTime } from "../utils/glob
import { PROPERTY_DEFAULTS, selectorFromSelection, writeTargetSelector } from "./gsapShared";
import { roundToCenti } from "../utils/rounding";
type CommitFn = (
selection: DomEditSelection,
mutation: Record<string, unknown>,
options: {
label: string;
coalesceKey?: string;
softReload?: boolean;
skipReload?: boolean;
},
) => Promise<void>;
import type { CommitMutation } from "./gsapScriptCommitTypes";
export async function commitKeyframeAtTimeImpl(
selection: DomEditSelection,
absoluteTime: number,
animations: GsapAnimation[],
properties: Record<string, number | string>,
commitMutation: CommitFn,
commitMutation: CommitMutation,
): Promise<void> {
// Matching an authored tween is a string compare against what the author
// wrote, so it keeps using the selection's own selector; the NEW tween below
@@ -38,7 +29,7 @@ export async function commitKeyframeAtTimeImpl(
await commitMutation(
selection,
{ type: "convert-to-keyframes", animationId: tween.id },
{ label: "Convert to keyframes", skipReload: true },
{ label: "Convert to keyframes", keyframeTelemetry: false, skipReload: true },
);
}
@@ -3,10 +3,16 @@ import { afterEach, expect, it, vi } from "vitest";
import type { GsapAnimation } from "@hyperframes/core/gsap-parser";
import type { DomEditSelection } from "../components/editor/domEditingTypes";
import { usePlayerStore } from "../player/store/playerStore";
import { observeGsapGesture } from "./gsapGestureOutcome";
import { trackStudioEvent } from "../utils/studioTelemetry";
import type { CommitMutation } from "./gsapScriptCommitTypes";
import { computeCurrentPercentage } from "./gsapDragCommit";
import { tryGsapResizeIntercept } from "./gsapResizeIntercept";
vi.mock("../utils/studioTelemetry", () => ({ trackStudioEvent: vi.fn() }));
afterEach(() => {
vi.clearAllMocks();
vi.restoreAllMocks();
usePlayerStore.setState({ currentTime: 0, activeKeyframePct: null });
});
@@ -553,3 +559,30 @@ it("hands the size to the element's CSS when its only tween is a fade", async ()
expect(handled).toEqual({ status: "element-size" });
expect(commitMutation).not.toHaveBeenCalled();
});
it.each([
[5, 1],
[0.5, 1],
[0.2, 0],
])("counts scale resize insertion at %ss", async (time, count) => {
const el = makeGradedElement();
const iframe = fakeIframe(el, { scale: 1, scaleX: 1, scaleY: 1 });
usePlayerStore.setState({ currentTime: time, activeKeyframePct: null });
const writer: CommitMutation = async (_selection, _mutation, options) => {
options.onResult?.({ ok: true, changed: true });
};
const outcome = observeGsapGesture(writer);
const result = await tryGsapResizeIntercept(
{ id: "clip", selector: "#clip", element: el } as DomEditSelection,
{ width: 700, height: 400 },
[keyframedScaleFixture()],
iframe,
outcome.commit!,
);
expect(result.status).toBe("persisted");
outcome.finish();
expect(trackStudioEvent).toHaveBeenCalledTimes(count);
if (count) expect(trackStudioEvent).toHaveBeenCalledWith("keyframe", { action: "add" });
el.remove();
document.querySelector("#__hf_color_grading_clip")?.remove();
});
@@ -541,6 +541,7 @@ export async function tryGsapResizeIntercept(
? `Resize (extended to ${ct.toFixed(2)}s)`
: `Resize (keyframe ${Math.round(((ct - newStart) / newDuration) * 1000) / 10}%)`,
softReload: true,
...(!collidingKf ? { keyframeAction: "add" as const } : {}),
},
);
return { status: "persisted", ownsDragOffset: await finalizeScaleResizeCommit() };
@@ -3,10 +3,12 @@ import type { Composition } from "@hyperframes/sdk";
import type { DomEditSelection } from "../components/editor/domEditingTypes";
import type { PublishSdkSession } from "../utils/sdkCutover";
import type { RuntimeTweenChange } from "./gsapRuntimePatch";
import type { KeyframeUsageAction } from "../utils/keyframeUsage";
export interface MutationResult {
ok: boolean;
changed?: boolean;
mutationChanges?: boolean[];
parsed?: ParsedGsap;
before?: string;
after?: string;
@@ -15,6 +17,9 @@ export interface MutationResult {
export interface CommitMutationOptions {
label: string;
keyframeTelemetry?: boolean;
keyframeAction?: KeyframeUsageAction;
keyframeProperty?: string;
/** Observe the durable writer result without duplicating the request path. */
onResult?: (result: MutationResult) => void;
coalesceKey?: string;
@@ -6,6 +6,10 @@ import type { DomEditSelection } from "../components/editor/domEditingTypes";
import { buildStableSelector, getSelectorIndex } from "../components/editor/domEditingDom";
import { resolveSelectorElementIds, tweenTargetsElement, writeTargetSelector } from "./gsapShared";
import { commitKeyframeAtTimeImpl } from "./gsapKeyframeCommit";
import { trackStudioEvent } from "../utils/studioTelemetry";
import { trackKeyframeCommit } from "../utils/keyframeUsage";
import type { CommitMutation } from "./gsapScriptCommitTypes";
vi.mock("../utils/studioTelemetry", () => ({ trackStudioEvent: vi.fn() }));
import { promoteSetToKeyframes } from "./useEnableKeyframes";
afterEach(() => {
@@ -287,3 +291,19 @@ describe("commitKeyframeAtTimeImpl — no one-element target", () => {
expect(commitMutation).not.toHaveBeenCalled();
});
});
it("counts a keyframe-at-time conversion and insertion as one add gesture", async () => {
vi.clearAllMocks();
const el = document.createElement("div");
el.id = "target";
document.body.append(el);
const selection = selectionFor(el);
const animations = parseGsapScript(
'const tl = gsap.timeline(); tl.to("#target", {x:100,duration:4},0);',
).animations;
const commit: CommitMutation = async (_selection, mutation, options) => {
trackKeyframeCommit([mutation], { ok: true, changed: true }, options);
};
await commitKeyframeAtTimeImpl(selection, 1, animations, { x: 25 }, commit);
expect(trackStudioEvent).toHaveBeenCalledExactlyOnceWith("keyframe", { action: "add" });
});
+3 -2
View File
@@ -1,3 +1,4 @@
import type { DomEditPersistOutcome } from "./domEditCommitTypes";
import type { DomEditSelection } from "../components/editor/domEditing";
import { captureStudioRotation, clearStudioRotation } from "../components/editor/manualEdits";
import { buildClearRotationPatches } from "../components/editor/manualEditsDomPatches";
@@ -17,8 +18,8 @@ export function savePlainRotation(
{ commitPositionPatchToHtml, readOnlyPreview }: Omit<ElementOffsetStagerDeps, "showToast">,
selection: DomEditSelection,
next: RotationCommit,
): Promise<void> {
if (readOnlyPreview) return Promise.resolve();
): Promise<DomEditPersistOutcome | undefined> {
if (readOnlyPreview) return Promise.resolve(undefined);
const { element } = selection;
const before = captureStudioRotation(element);
// Legacy marks go in the same write, or a seek puts their angle back; clearing them voids the press read.
@@ -101,7 +101,7 @@ describe("a shadow reload promoted without a host re-render", () => {
});
expect(overlayNode).toBe(selection.element);
const commitPositionPatchToHtml = vi.fn(async () => {});
const commitPositionPatchToHtml = vi.fn(async () => undefined);
await savePlainRotation({ commitPositionPatchToHtml }, selection, { angle: 30 });
await stageElementOffset(
{ commitPositionPatchToHtml, showToast: vi.fn() },
@@ -2,17 +2,22 @@
import React, { act } from "react";
import { afterEach, describe, expect, it, vi } from "vitest";
import type { GsapAnimation } from "@hyperframes/core/gsap-parser";
import { parseGsapScript, type GsapAnimation } from "@hyperframes/core/gsap-parser";
import type { DomEditSelection } from "../components/editor/domEditingTypes";
import { usePlayerStore } from "../player/store/playerStore";
import { useAnimatedPropertyCommit } from "./useAnimatedPropertyCommit";
import { mountReactHarness, withInlineLayoutBox } from "./domSelectionTestHarness";
import { writeSizeWithCrop } from "../components/editor/cropResize";
import type { CommitMutation } from "./gsapScriptCommitTypes";
import { trackStudioEvent } from "../utils/studioTelemetry";
import { trackKeyframeCommit } from "../utils/keyframeUsage";
import type { CommitMutationOptions, CommitMutation } from "./gsapScriptCommitTypes";
vi.mock("../utils/studioTelemetry", () => ({ trackStudioEvent: vi.fn() }));
(globalThis as unknown as { IS_REACT_ACT_ENVIRONMENT: boolean }).IS_REACT_ACT_ENVIRONMENT = true;
afterEach(() => {
vi.clearAllMocks();
document.body.innerHTML = "";
usePlayerStore.setState({ autoKeyframeEnabled: true, currentTime: 0 });
});
@@ -43,7 +48,11 @@ type Commit = (
/** Renders the hook and hands its commit function to the caller via a ref callback. */
function renderHookWith(
animations: GsapAnimation[],
onMutation: (mutation: Record<string, unknown>, label: string) => unknown | Promise<unknown>,
onMutation: (
mutation: Record<string, unknown>,
label: string,
options: CommitMutationOptions,
) => unknown | Promise<unknown>,
onReady: (commit: Commit) => void,
bumpGsapCache = vi.fn(),
onBatch?: (
@@ -56,9 +65,9 @@ function renderHookWith(
async (
_sel: DomEditSelection,
mutation: Record<string, unknown>,
options: { label: string },
options: CommitMutationOptions,
) => {
await onMutation(mutation, options.label);
await onMutation(mutation, options.label, options);
},
onBatch
? {
@@ -401,3 +410,35 @@ describe("useAnimatedPropertyCommit — a size write and its crop are one undo s
act(() => root.unmount());
});
});
it("counts one add when an inspector edit first converts a flat animation", async () => {
usePlayerStore.setState({ autoKeyframeEnabled: true, currentTime: 1 });
const flat = parseGsapScript(
'const tl = gsap.timeline(); tl.to("#box", { rotationX: 0, duration: 2 }, 0);',
).animations[0]!;
const mutations: Record<string, unknown>[] = [];
let commit!: Commit;
const root = renderHookWith(
[flat, keyframedAnim],
(mutation, _label, options) => {
mutations.push(mutation);
trackKeyframeCommit([mutation], { ok: true, changed: true }, options);
},
(value) => {
commit = value;
},
);
try {
await act(async () => {
await commit(selection, { rotationX: 20 });
});
expect(mutations.map((mutation) => mutation.type)).toEqual([
"convert-to-keyframes",
"add-keyframe",
]);
expect(trackStudioEvent).toHaveBeenCalledTimes(1);
expect(trackStudioEvent).toHaveBeenCalledWith("keyframe", { action: "add" });
} finally {
act(() => root.unmount());
}
});
@@ -333,7 +333,7 @@ async function commitKeyframeProps(
await commit(
selection,
{ type: "convert-to-keyframes", animationId: anim.id },
{ label: "Convert to keyframes", skipReload: true },
{ label: "Convert to keyframes", keyframeTelemetry: false, skipReload: true },
);
}
const ct = usePlayerStore.getState().currentTime;
@@ -389,7 +389,7 @@ async function commitKeyframeProps(
duration: roundTo3(newDuration),
keyframes: remapped,
},
{ label: `Edit ${primaryProp} (extended keyframe)`, softReload: true },
{ label: `Edit ${primaryProp} (extended keyframe)`, keyframeAction: "add", softReload: true },
);
return;
}
@@ -33,30 +33,25 @@ export function useDomEditPositionPatchCommit({
coalesceMs: options.coalesceMs,
skipRefresh: options.skipRefresh ?? true,
deferRender: options.deferRender,
})
.then(() => undefined)
.catch((error) => {
// A paused save queue is not worth a toast: the paused-save banner is
// already on screen, and one toast per blocked edit is what this branch
// exists to prevent. It still has to REJECT, though. Swallowing it
// resolved the commit, which skipped the caller's revert, so the element
// stayed where the drag put it while nothing reached the file.
if (error instanceof DomEditSaveQueueOpenError) throw error;
if (!wasAlreadyToasted(error)) {
showToast(error instanceof Error ? error.message : "Failed to save position");
}
trackStudioSaveFailure({
source: "dom_edit",
error,
filePath: selection.sourceFile ?? activeCompPath ?? "index.html",
mutationType: "position",
label: options.label,
targetId: selection.id,
targetSelector: selection.selector,
targetSourceFile: selection.sourceFile,
});
throw error;
}).catch((error) => {
// The paused-save banner already explains this refusal. Rethrow so the
// caller reverts the preview instead of treating an unsaved edit as committed.
if (error instanceof DomEditSaveQueueOpenError) throw error;
if (!wasAlreadyToasted(error)) {
showToast(error instanceof Error ? error.message : "Failed to save position");
}
trackStudioSaveFailure({
source: "dom_edit",
error,
filePath: selection.sourceFile ?? activeCompPath ?? "index.html",
mutationType: "position",
label: options.label,
targetId: selection.id,
targetSelector: selection.selector,
targetSourceFile: selection.sourceFile,
});
throw error;
});
},
[activeCompPath, persistDomEditOperations, showToast],
);
@@ -160,6 +160,21 @@ describe("useDomEditTextCommits", () => {
};
}
it.each([true, false])(
"counts in-place text only after a changed write (%s)",
async (changed) => {
vi.mocked(trackStudioEvent).mockClear();
const { persist, save } = richTextProbe();
persist.mockResolvedValue({ changed, sourceFile: "private.html", version: "v2" });
await save();
expect(vi.mocked(trackStudioEvent).mock.calls).toEqual(
changed
? [["feature_used", { feature: "text_edit", surface: "preview", method: "field" }]]
: [],
);
},
);
it("saves in-place text while the preview is editable, and refreshes the selection it edited", async () => {
const applyDomSelection = vi.fn();
const { persist, element, save } = richTextProbe(undefined, () => ({ applyDomSelection }));
@@ -464,3 +479,6 @@ describe("useDomEditTextCommits", () => {
expect(agentElement.textContent).toBe("Edited");
});
});
vi.mock("../utils/studioTelemetry", () => ({ trackStudioEvent: vi.fn() }));
import { trackStudioEvent } from "../utils/studioTelemetry";
@@ -1,3 +1,4 @@
import { trackPreviewFeatureUsed } from "../utils/previewFeatureUsage";
import { useCallback, useRef } from "react";
import { normalizeDomEditStyleValue } from "../utils/studioHelpers";
import { injectPreviewGoogleFont, injectPreviewImportedFont } from "../utils/studioFontHelpers";
@@ -272,11 +273,12 @@ export function useDomEditTextCommits({
appliedHtml = element.innerHTML;
},
persist: async () => {
await persistDomEditOperations(selection, operations, {
const result = await persistDomEditOperations(selection, operations, {
label: "Edit text",
skipRefresh: true,
shouldSave: isLatestTextCommit,
});
if (result?.changed) trackPreviewFeatureUsed("text_edit", "field");
},
shouldRevert: () => isLatestTextCommit(),
revert: () => {
@@ -104,8 +104,10 @@ export interface UseDomEditWiringParams {
animId: string,
resolvedFromValues?: Record<string, number | string>,
) => Promise<void>;
removeAllKeyframes: (sel: DomEditSelection, animId: string) => Promise<void>;
handleDomManualEditsReset: (sel: DomEditSelection) => Promise<void>;
removeAllKeyframes: Parameters<typeof useGsapSelectionHandlers>[0]["removeAllKeyframes"];
handleDomManualEditsReset: (
sel: DomEditSelection,
) => Promise<void | import("./domEditCommitTypes").DomEditPersistOutcome>;
}
// fallow-ignore-next-line complexity
@@ -127,7 +127,7 @@ describe("useDomGeometryCommit, from the package entry", () => {
y: 20,
});
expect(outcome).toEqual({ ok: true });
expect(outcome).toEqual({ ok: true, changed: true });
expect(mutations).toEqual([
expect.objectContaining({ type: "add", targetSelector: "#card", method: "set" }),
]);
@@ -146,7 +146,14 @@ describe("useDomGeometryCommit, from the package entry", () => {
const url = String(input instanceof Request ? input.url : input);
calls.urls.push(url);
if (url.includes("/patch-element/")) calls.patches.push(JSON.parse(String(init?.body)));
const saved = { ok: true, changed: true, matched: true, content: "AFTER", version: "v2" };
const saved = {
ok: true,
changed: true,
matched: true,
path: "index.html",
content: "AFTER",
version: "v2",
};
const body = url.includes("/files/") ? { content: SOURCE } : saved;
const status = url.includes("/patch-element/") ? patchStatus : 200;
return new Response(JSON.stringify(body), {
@@ -163,7 +170,10 @@ describe("useDomGeometryCommit, from the package entry", () => {
const { element, recordEdit, hook, unmount } = renderHost();
const card = makeSelection("card", element);
await expect(hook().commitPathOffset(card, { x: 130.5, y: 90 })).resolves.toEqual({ ok: true });
await expect(hook().commitPathOffset(card, { x: 130.5, y: 90 })).resolves.toEqual({
ok: true,
changed: true,
});
expect(element.style.getPropertyValue("translate")).toBe("130.5px 90px");
expect(calls.patches).toEqual([
@@ -185,7 +195,7 @@ describe("useDomGeometryCommit, from the package entry", () => {
await expect(
hook().commitBoxSize(makeSelection("card", element), { width: 300, height: 90 }),
).resolves.toEqual({ ok: true });
).resolves.toEqual({ ok: true, changed: true });
expect(element.style.getPropertyValue("width")).toBe("300px");
expect(calls.patches).toEqual([
@@ -247,7 +257,7 @@ describe("useDomGeometryCommit, from the package entry", () => {
{ x: 1, y: 2 },
{ plainTranslate: true },
),
).resolves.toEqual({ ok: true });
).resolves.toEqual({ ok: true, changed: true });
expect(calls.patches).toHaveLength(1);
unmount();
});
@@ -396,7 +406,14 @@ describe("useDomGeometryCommit, one word of a staggered phrase", () => {
headers: { "content-type": "application/json" },
});
}
return json({ ok: true, changed: true, matched: true, content: "AFTER", version: "v2" });
return json({
ok: true,
changed: true,
matched: true,
path: "index.html",
content: "AFTER",
version: "v2",
});
}
if (url.includes("/api/projects/p1/gsap-mutations/")) {
calls.gsapMutations.push(JSON.parse(String(init?.body)));
@@ -426,6 +443,7 @@ describe("useDomGeometryCommit, one word of a staggered phrase", () => {
await expect(hook().commitPathOffset(selection, { x: 40, y: 20 })).resolves.toEqual({
ok: true,
changed: true,
});
expect(calls.gsapMutations).toHaveLength(0);
@@ -455,6 +473,7 @@ describe("useDomGeometryCommit, one word of a staggered phrase", () => {
]);
await expect(hook().commitPathOffset(selection, { x: 40, y: 20 })).resolves.toEqual({
ok: true,
changed: true,
});
expect(calls.patches).toHaveLength(2);
@@ -1,3 +1,4 @@
import type { GeometryCommitResult } from "../utils/previewFeatureUsage";
import type { RotationCommit } from "../components/editor/rotationDraft";
import { useCallback, useMemo, useRef, useState } from "react";
import type { GsapAnimation } from "@hyperframes/core/gsap-parser";
@@ -67,7 +68,7 @@ export function useDomGeometryCommit({
}: UseDomGeometryCommitOptions): DomGeometryCommits {
const projectIdRef = useRef(projectId);
projectIdRef.current = projectId;
const pending = useRef(new Set<Promise<void>>()).current;
const pending = useRef(new Set<Promise<GeometryCommitResult | undefined>>()).current;
const editHistory = useMemo(() => ({ recordEdit }), [recordEdit]);
const reload = useCallback(
() => (reloadPreview ? reloadPreview() : iframeRef.current?.contentWindow?.location.reload()),
@@ -142,7 +143,10 @@ export function useDomGeometryCommit({
updateArcSegment: gsap.updateArcSegment,
});
const saved = useCallback(
async (commit: () => Promise<void>, restore = noop): Promise<DomEditCommitOutcome> => {
async (
commit: () => Promise<GeometryCommitResult | undefined>,
restore = noop,
): Promise<DomEditCommitOutcome> => {
const refusal = !projectIdRef.current
? "No project is open"
: !isPreviewBooted(projectIdRef.current)
@@ -156,11 +160,10 @@ export function useDomGeometryCommit({
const run = commit();
pending.add(run);
try {
await run;
return { ok: true, changed: (await run)?.changed === true };
} finally {
pending.delete(run);
}
return { ok: true };
},
[pending, showToast],
);
@@ -305,6 +305,7 @@ describe("Reset after a plain move puts the author's translate back", () => {
async (_selection, patches) => {
project.history.push(project.file);
project.file = patches.reduce((html, op) => applyPatch(html, "layer", op), project.file);
return undefined;
},
);
const selection = { id: "layer", selector: "#layer", element } as unknown as DomEditSelection;
@@ -1,3 +1,4 @@
import type { DomEditPersistOutcome } from "./domEditCommitTypes";
import type { RotationCommit } from "../components/editor/rotationDraft";
import { useCallback } from "react";
import { getDomEditTargetKey, type DomEditSelection } from "../components/editor/domEditing";
@@ -39,7 +40,7 @@ export interface UseDomGeometryCommitsParams {
skipRefresh?: boolean;
deferRender?: boolean;
},
) => Promise<void>;
) => Promise<DomEditPersistOutcome | undefined>;
readOnlyPreview: boolean;
}
@@ -73,7 +74,7 @@ export function useDomGeometryCommits({
restore?: () => void,
undoKey?: string,
) => {
if (readOnlyPreview) return Promise.resolve();
if (readOnlyPreview) return Promise.resolve(undefined);
const element = selection.element;
const beforeSize = captureStudioBoxSize(element);
const beforeOffset = captureStudioPathOffset(element);
@@ -334,3 +334,29 @@ describe("useDomSelection", () => {
harness.cleanup();
});
});
it("reports unchanged marquee membership and one new additive member", () => {
const { harness, card, chip } = renderCardAndChip();
try {
act(() => harness.current().applyDomSelection(card));
let receipt!: ReturnType<ReturnType<typeof useDomSelection>["applyMarqueeSelection"]>;
act(() => {
receipt = harness.current().applyMarqueeSelection([chip], true);
});
expect(receipt).toEqual({ changed: true, count: 2 });
act(() => {
receipt = harness.current().applyMarqueeSelection([card, chip], true);
});
expect(receipt).toEqual({ changed: false, count: 2 });
act(() => {
receipt = harness.current().applyMarqueeSelection([], true);
});
expect(receipt).toEqual({ changed: false, count: 2 });
act(() => {
receipt = harness.current().applyMarqueeSelection([], false);
});
expect(receipt).toEqual({ changed: true, count: 0 });
} finally {
harness.cleanup();
}
});
+11 -4
View File
@@ -487,12 +487,13 @@ export function useDomSelection({
// fallow-ignore-next-line complexity
(selections: DomEditSelection[], additive: boolean) => {
logSelect("marquee", { hits: selections.length, additive });
if (selections.length === 0) {
if (!additive) applyDomSelection(null, { revealPanel: false });
return;
}
const current = domEditSelectionRef.current;
const currentGroup = domEditGroupSelectionsRef.current;
if (selections.length === 0) {
const count = seedDomEditGroupWithSelection(currentGroup, current).length;
if (!additive) applyDomSelection(null, { revealPanel: false });
return { changed: !additive && count > 0, count: additive ? count : 0 };
}
let nextGroup: DomEditSelection[];
if (additive) {
nextGroup = seedDomEditGroupWithSelection(currentGroup, current);
@@ -507,11 +508,17 @@ export function useDomSelection({
}
}
const nextSelection = additive && current ? current : selections[0];
const previous = seedDomEditGroupWithSelection(currentGroup, current);
const changed =
previous.length !== nextGroup.length ||
nextGroup.some((selection) => !domEditSelectionInGroup(previous, selection)) ||
!domEditSelectionsTargetSame(current, nextSelection);
domEditSelectionRef.current = nextSelection;
domEditGroupSelectionsRef.current = nextGroup;
setDomEditSelection(nextSelection);
setDomEditGroupSelections(nextGroup);
announceTimelineSelection(nextGroup, nextSelection, true);
return { changed, count: nextGroup.length };
},
[applyDomSelection, announceTimelineSelection],
);
@@ -3,6 +3,11 @@ import type { DomEditSelection } from "../components/editor/domEditing";
import type { SelectElementOptions, TimelineElement } from "../player";
import type { RightPanelTab } from "../utils/studioHelpers";
export interface DomSelectionResult {
changed: boolean;
count: number;
}
export interface ApplyDomSelectionOptions {
revealPanel?: boolean;
additive?: boolean;
@@ -85,5 +90,5 @@ export interface UseDomSelectionReturn {
handleTimelineElementSelect: (element: TimelineElement | null) => Promise<void>;
refreshDomEditSelectionFromPreview: (selection: DomEditSelection) => Promise<void>;
refreshDomEditGroupSelectionsFromPreview: (selections: DomEditSelection[]) => Promise<void>;
applyMarqueeSelection: (selections: DomEditSelection[], additive: boolean) => void;
applyMarqueeSelection: (selections: DomEditSelection[], additive: boolean) => DomSelectionResult;
}
@@ -314,7 +314,7 @@ describe("applyArcKeyframeAtPlayhead", () => {
],
ease: "none",
},
{ label: "Add keyframe", softReload: true },
{ label: "Add keyframe", softReload: true, keyframeAction: "add" },
);
});
@@ -336,7 +336,7 @@ describe("applyArcKeyframeAtPlayhead", () => {
duration: 10,
keyframes: expect.arrayContaining([{ percentage: 25, properties: { x: 25, y: 25 } }]),
}),
{ label: "Add keyframe", softReload: true },
{ label: "Add keyframe", softReload: true, keyframeAction: "add" },
);
});
});
@@ -159,7 +159,7 @@ async function replaceSetWithSingleKeyframe(
keyframes: [{ percentage: 0, properties: position }],
ease: setAnim.ease,
},
{ label: "Enable keyframes", softReload: true },
{ label: "Enable keyframes", keyframeAction: "add", softReload: true },
);
}
@@ -270,6 +270,7 @@ async function extendKeyframedTweenToPlayhead(
},
{
label: "Add keyframe",
keyframeAction: "add",
softReload: true,
...commitOverrides,
},
@@ -377,7 +378,7 @@ export async function promoteSetToKeyframes(
],
ease: setAnim.ease,
},
{ label: "Add keyframe", softReload: true },
{ label: "Add keyframe", keyframeAction: "add", softReload: true },
);
}
@@ -454,7 +455,7 @@ export async function applyArcKeyframeAtPlayhead(
}),
ease: "none",
},
{ label: "Add keyframe", softReload: true },
{ label: "Add keyframe", keyframeAction: "add", softReload: true },
);
}
@@ -503,6 +504,7 @@ export function useEnableKeyframes(
enableKeyframesTransactionCounter += 1;
const coalesceKey = `enable-keyframes:${flatAnim.id}:${enableKeyframesTransactionCounter}`;
const convertCommitOverrides: Partial<CommitMutationOptions> = {
keyframeTelemetry: false,
skipReload: true,
coalesceKey,
coalesceMs: Number.POSITIVE_INFINITY,
@@ -1,12 +1,18 @@
// @vitest-environment jsdom
import { act } from "react";
import { act, useLayoutEffect } from "react";
import { afterEach, describe, expect, it, vi } from "vitest";
import type { GsapAnimation } from "@hyperframes/core/gsap-parser";
import { trackStudioEvent } from "../utils/studioTelemetry";
import { trackKeyframeCommit } from "../utils/keyframeUsage";
import type { DomEditSelection } from "../components/editor/domEditing";
import { usePlayerStore } from "../player";
import { mountReactHarness } from "./domSelectionTestHarness";
import type { CommitMutationOptions } from "./gsapScriptCommitTypes";
import { useGsapAwareEditing } from "./useGsapAwareEditing";
import { useGestureCommit } from "./useGestureCommit";
vi.mock("../utils/studioTelemetry", () => ({ trackStudioEvent: vi.fn() }));
const gestureRecording = vi.hoisted(() => ({
startRecording: vi.fn(),
stopRecording: vi.fn(() => [
@@ -84,44 +90,88 @@ function makeSelection(element: HTMLElement): DomEditSelection {
};
}
function mountRecording(
writer: (
mutation: Record<string, unknown>,
options: CommitMutationOptions,
selection?: DomEditSelection,
) => Promise<void>,
animations: GsapAnimation[] = [],
readOnlyPreview = false,
) {
const iframe = document.createElement("iframe");
document.body.append(iframe);
const element = document.createElement("div");
element.id = "card";
const session = {
current: {
domEditSelection: makeSelection(element),
selectedGsapAnimations: animations,
commitMutation: writer,
},
};
let hook: ReturnType<typeof useGestureCommit> | null = null;
function Probe() {
const editing = useGsapAwareEditing({
domEditSelection: session.current.domEditSelection,
selectedGsapAnimations: session.current.selectedGsapAnimations,
gsapCommitMutation: (selection, mutation, options) => writer(mutation, options, selection),
previewIframeRef: { current: iframe },
showToast: vi.fn(),
bumpGsapCache: vi.fn(),
makeFetchFallback: () => vi.fn(),
trackGsapInteractionFailure: vi.fn(),
stageElementPositionOffset: vi.fn(),
handleDomBoxSizeCommit: vi.fn(),
handleDomRotationCommit: vi.fn(),
commitPositionPatchToHtml: vi.fn(),
addGsapAnimation: vi.fn(),
convertToKeyframes: vi.fn(),
setArcPath: vi.fn(),
updateArcSegment: vi.fn(),
});
const currentHook = useGestureCommit({
domEditSessionRef: session,
previewIframeRef: { current: iframe },
showToast: vi.fn(),
isGestureRecordingRef: { current: false },
readOnlyPreview,
});
useLayoutEffect(() => {
session.current.commitMutation = editing.commitMutation;
hook = currentHook;
});
return null;
}
const root = mountReactHarness(<Probe />);
cleanup = () => act(() => root.unmount());
return Object.assign(
() => {
if (!hook) throw new Error("hook did not initialize");
return hook;
},
{
select: (selection: DomEditSelection, nextAnimations: GsapAnimation[]) => {
session.current.domEditSelection = selection;
session.current.selectedGsapAnimations = nextAnimations;
act(() => root.render(<Probe />));
},
},
);
}
describe("useGestureCommit", () => {
it("coalesces property-group commits and reloads only the terminal group", async () => {
const iframe = document.createElement("iframe");
document.body.append(iframe);
const element = document.createElement("div");
element.id = "card";
const commitMutation = vi.fn<
const writer = vi.fn<
(mutation: Record<string, unknown>, options: CommitMutationOptions) => Promise<void>
>(async () => {});
const sessionRef = {
current: {
domEditSelection: makeSelection(element),
selectedGsapAnimations: [],
commitMutation,
},
};
const captured: { hook: ReturnType<typeof useGestureCommit> | null } = { hook: null };
function Probe() {
captured.hook = useGestureCommit({
domEditSessionRef: sessionRef,
previewIframeRef: { current: iframe },
showToast: vi.fn(),
isGestureRecordingRef: { current: false },
readOnlyPreview: false,
});
return null;
}
const root = mountReactHarness(<Probe />);
cleanup = () => act(() => root.unmount());
if (!captured.hook) throw new Error("hook did not initialize");
act(() => captured.hook?.handleToggleRecording());
act(() => captured.hook?.handleToggleRecording());
const hook = mountRecording(writer);
act(() => hook().handleToggleRecording());
act(() => hook().handleToggleRecording());
await act(async () => {
await vi.waitFor(() => expect(commitMutation).toHaveBeenCalledTimes(2));
await vi.waitFor(() => expect(writer).toHaveBeenCalledTimes(2));
});
const options = commitMutation.mock.calls.map((call) => call[1]);
const options = writer.mock.calls.map((call) => call[1]);
expect(new Set(options.map((entry) => entry.coalesceKey)).size).toBe(1);
expect(options[0]).toEqual(expect.objectContaining({ coalesceMs: Infinity, skipReload: true }));
expect(options[0]).not.toHaveProperty("softReload");
@@ -130,30 +180,150 @@ describe("useGestureCommit", () => {
});
it("does not start a recording while the preview is read-only", () => {
const element = document.createElement("div");
const commitMutation = vi.fn(async () => {});
const captured: { hook: ReturnType<typeof useGestureCommit> | null } = { hook: null };
function Probe() {
captured.hook = useGestureCommit({
domEditSessionRef: {
current: {
domEditSelection: makeSelection(element),
selectedGsapAnimations: [],
commitMutation,
},
},
previewIframeRef: { current: document.createElement("iframe") },
showToast: vi.fn(),
isGestureRecordingRef: { current: false },
readOnlyPreview: true,
});
return null;
}
const root = mountReactHarness(<Probe />);
cleanup = () => act(() => root.unmount());
act(() => captured.hook?.handleToggleRecording());
const writer = vi.fn(async () => {});
const hook = mountRecording(writer, [], true);
act(() => hook().handleToggleRecording());
expect(gestureRecording.startRecording).not.toHaveBeenCalled();
expect(commitMutation).not.toHaveBeenCalled();
expect(writer).not.toHaveBeenCalled();
});
});
it.each([
["new mixed-property recording", undefined, true, true, 1, "button"],
["keyboard recording", undefined, true, true, 1, "keyboard"],
["static hold replacement", "set", true, true, 1, "button"],
["overlapping tween replacement", "to", true, true, 1, "button"],
["unchanged recording", undefined, true, false, 0, "button"],
["refused recording", undefined, false, true, 0, "button"],
] as const)(
"counts writer receipts once for %s",
async (_name, method, ok, changed, count, inputMethod) => {
const animations = method
? [
{
id: "card-position",
targetSelector: "#card",
propertyGroup: "position",
method,
properties: { x: 0, y: 0 },
resolvedStart: 0,
position: 0,
duration: method === "set" ? 0 : 2,
keyframes: {
keyframes: [
{ percentage: 0, properties: { x: 0 } },
{ percentage: 100, properties: { x: 100 } },
],
},
} as unknown as GsapAnimation,
]
: [];
const writer = vi.fn(
async (mutation: Record<string, unknown>, options: CommitMutationOptions) => {
const result = { ok, changed };
trackKeyframeCommit([mutation], result, options);
options.onResult?.(result);
},
);
const hook = mountRecording(writer, animations);
act(() => hook().handleToggleRecording(inputMethod));
await act(async () => {
hook().handleToggleRecording();
await vi.waitFor(() => expect(writer).toHaveBeenCalled());
});
expect(trackStudioEvent).toHaveBeenCalledTimes(count * 2);
if (count) {
expect(trackStudioEvent).toHaveBeenCalledWith("keyframe", { action: "add" });
expect(trackStudioEvent).toHaveBeenCalledWith("feature_used", {
feature: "gesture_recording",
surface: "preview",
method: inputMethod,
});
}
},
);
it("keeps a pending recording isolated until its writer settles", async () => {
let release!: () => void;
const pending = new Promise<void>((resolve) => {
release = resolve;
});
const writer = vi.fn(
async (_mutation: Record<string, unknown>, options: CommitMutationOptions) => {
await pending;
options.onResult?.({ ok: true, changed: true });
},
);
const hook = mountRecording(writer);
act(() => hook().handleToggleRecording("keyboard"));
act(() => hook().handleToggleRecording());
await vi.waitFor(() => expect(writer).toHaveBeenCalledTimes(1));
act(() => hook().handleToggleRecording("button"));
expect(gestureRecording.startRecording).toHaveBeenCalledTimes(1);
await act(async () => {
release();
await vi.waitFor(() =>
expect(trackStudioEvent).toHaveBeenCalledWith("feature_used", {
feature: "gesture_recording",
surface: "preview",
method: "keyboard",
}),
);
});
expect(gestureRecording.clearSamples).toHaveBeenCalledTimes(1);
act(() => hook().handleToggleRecording("button"));
expect(gestureRecording.startRecording).toHaveBeenCalledTimes(2);
});
it("writes one recording to its original selection after selecting another source", async () => {
const original = {
id: "original-position",
targetSelector: "#card",
propertyGroup: "position",
method: "set",
properties: { x: 0, y: 0 },
resolvedStart: 0,
position: 0,
duration: 0,
} as GsapAnimation;
const writer = vi.fn(
async (
_mutation: Record<string, unknown>,
options: CommitMutationOptions,
_selection?: DomEditSelection,
) => {
options.onResult?.({ ok: true, changed: true });
},
);
const hook = mountRecording(writer, [original]);
act(() => hook().handleToggleRecording("keyboard"));
const other = document.createElement("div");
other.id = "other";
hook.select(
{ ...makeSelection(other), sourceFile: "other.html", compositionPath: "other.html" },
[],
);
await act(async () => {
hook().handleToggleRecording();
await vi.waitFor(() => expect(trackStudioEvent).toHaveBeenCalledTimes(2));
});
expect(writer).toHaveBeenCalledExactlyOnceWith(
expect.objectContaining({
type: "replace-with-keyframes",
animationId: "original-position",
targetSelector: "#card",
}),
expect.objectContaining({ keyframeAction: "add" }),
expect.objectContaining({
id: "card",
sourceFile: "index.html",
compositionPath: "index.html",
}),
);
expect(trackStudioEvent).toHaveBeenCalledWith("keyframe", { action: "add" });
expect(trackStudioEvent).toHaveBeenCalledWith("feature_used", {
feature: "gesture_recording",
surface: "preview",
method: "keyboard",
});
});
+88 -57
View File
@@ -11,6 +11,8 @@ import { usePlayerStore } from "../player";
import type { DomEditSelection } from "../components/editor/domEditing";
import type { GsapAnimation } from "@hyperframes/core/gsap-parser";
import type { CommitMutationOptions } from "./gsapScriptCommitTypes";
import { trackPreviewEditResult } from "../utils/previewFeatureUsage";
import { observeGsapGesture } from "./gsapGestureOutcome";
import { roundTo3 } from "../utils/rounding";
import { classifyPropertyGroup } from "@hyperframes/core/gsap-parser";
import { isInstantHold, idSelector, writeTargetSelector, tweenTargetsElement } from "./gsapShared";
@@ -72,6 +74,7 @@ interface GestureSessionRef {
commitMutation?: (
mutation: Record<string, unknown>,
options: CommitMutationOptions,
selection?: DomEditSelection,
) => Promise<void>;
}
@@ -81,6 +84,12 @@ function reloadOnlyLast(index: number, count: number): Partial<CommitMutationOpt
return index === count - 1 ? { softReload: true } : { skipReload: true };
}
function recordingEnd(selection: DomEditSelection): number | undefined {
const start = Number.parseFloat(selection.dataAttributes?.start ?? "0") || 0;
const duration = Number.parseFloat(selection.dataAttributes?.duration ?? "0") || 0;
return duration > 0 ? start + duration : undefined;
}
let gestureRecordingCommitCounter = 0;
interface UseGestureCommitParams {
@@ -95,7 +104,7 @@ interface UseGestureCommitParams {
export interface UseGestureCommitResult {
gestureState: "idle" | "recording";
gestureRecording: ReturnType<typeof useGestureRecording>;
handleToggleRecording: () => void;
handleToggleRecording: (method?: "button" | "keyboard") => void;
}
// fallow-ignore-next-line complexity
@@ -111,11 +120,13 @@ export function useGestureCommit({
const [gestureState, setGestureState] = useState<"idle" | "recording">("idle");
const gestureStateRef = useRef<"idle" | "recording">("idle");
const recordingAutoStopRef = useRef<ReturnType<typeof setInterval>>(undefined);
const recordingMethodRef = useRef<"button" | "keyboard">("button");
const recordingStartTimeRef = useRef(0);
const commitInFlightRef = useRef(false);
// Capture selection at recording start so commit always targets the recorded element,
// even if the user's selection changes mid-recording.
const capturedSelectionRef = useRef<DomEditSelection | null>(null);
const capturedAnimationsRef = useRef<GsapAnimation[]>([]);
// Unmount: clear auto-stop interval
useEffect(() => () => clearInterval(recordingAutoStopRef.current), []);
@@ -206,6 +217,12 @@ export function useGestureCommit({
return;
}
if (liveSession.commitMutation) {
const writer = liveSession.commitMutation;
const outcome = observeGsapGesture((_selection, mutation, options) =>
writer(mutation, options, sel),
);
const commit = (mutation: Record<string, unknown>, options: CommitMutationOptions) =>
outcome.commit!(sel, mutation, options);
const recStart = recordingStartTimeRef.current;
const rawKeyframes = sortedPcts.map((pct) => ({
percentage: pct,
@@ -216,7 +233,7 @@ export function useGestureCommit({
const hasPositionProps = keyframes.some((kf) =>
Object.keys(kf.properties).some((k) => classifyPropertyGroup(k) === "position"),
);
const allAnims = liveSession.selectedGsapAnimations ?? [];
const allAnims = capturedAnimationsRef.current;
const existingPositionTween = hasPositionProps
? allAnims.find(
(a) =>
@@ -228,7 +245,7 @@ export function useGestureCommit({
if (isInstantHold(existingPositionTween)) {
// An instant hold is not a tween to merge into — replace it with the
// recorded motion (which already starts from the held position).
await liveSession.commitMutation(
await commit(
{
type: "replace-with-keyframes",
animationId: existingPositionTween.id,
@@ -237,7 +254,7 @@ export function useGestureCommit({
duration: roundTo3(duration),
keyframes,
},
{ label: "Gesture recording (replace set)", softReload: true },
{ label: "Gesture recording (replace set)", softReload: true, keyframeAction: "add" },
);
} else {
const tweenStart = existingPositionTween.resolvedStart ?? 0;
@@ -274,7 +291,7 @@ export function useGestureCommit({
const merged = [...preserved, ...mapped].sort((a, b) => a.percentage - b.percentage);
await liveSession.commitMutation(
await commit(
{
type: "replace-with-keyframes",
animationId: existingPositionTween.id,
@@ -286,7 +303,7 @@ export function useGestureCommit({
duration: tweenDur,
keyframes: merged,
},
{ label: "Gesture recording (merge)", softReload: true },
{ label: "Gesture recording (merge)", softReload: true, keyframeAction: "add" },
);
} else {
// Emit one tween per property group so a mixed-prop gesture (e.g.
@@ -294,7 +311,7 @@ export function useGestureCommit({
// tween that the position-only drag intercept can't edit.
const keyframeGroups = partitionKeyframesByGroup(keyframes);
for (const [index, groupKfs] of keyframeGroups.entries()) {
await liveSession.commitMutation(
await commit(
{
type: "add-with-keyframes",
targetSelector: writeSelector,
@@ -320,7 +337,7 @@ export function useGestureCommit({
// No existing tween — same per-group split as the new-range branch above.
const keyframeGroups = partitionKeyframesByGroup(keyframes);
for (const [index, groupKfs] of keyframeGroups.entries()) {
await liveSession.commitMutation(
await commit(
{
type: "add-with-keyframes",
targetSelector: writeSelector,
@@ -338,6 +355,7 @@ export function useGestureCommit({
);
}
}
trackPreviewEditResult("gesture_recording", recordingMethodRef.current, outcome.finish());
}
showToast(`Recorded ${sortedPcts.length} keyframes`, "info");
} catch (err) {
@@ -351,58 +369,71 @@ export function useGestureCommit({
}
}, [gestureRecording, showToast, isGestureRecordingRef, domEditSessionRef]);
// fallow-ignore-next-line complexity
const handleToggleRecording = useCallback(() => {
if (gestureStateRef.current === "recording") {
if (readOnlyPreview) {
cancelRecording();
const armAutoStop = useCallback(
(elementEnd: number | undefined) => {
clearInterval(recordingAutoStopRef.current);
const autoStopAt = elementEnd ?? Infinity;
recordingAutoStopRef.current = setInterval(() => {
const { currentTime: t, duration: d } = usePlayerStore.getState();
const limit = Math.min(autoStopAt, d);
if (limit > 0 && t >= limit - 0.05) {
void stopAndCommitRecording();
}
}, 100);
},
[stopAndCommitRecording],
);
const startRecording = useCallback(
(method: "button" | "keyboard") => {
if (readOnlyPreview) return;
const sel = domEditSessionRef.current.domEditSelection;
if (!sel) {
showToast("Select an element first", "error");
return;
}
void stopAndCommitRecording();
return;
}
if (readOnlyPreview) return;
const sel = domEditSessionRef.current.domEditSelection;
if (!sel) {
showToast("Select an element first", "error");
return;
}
const iframe = previewIframeRef.current;
if (!iframe) {
showToast("Preview not ready — try again", "error");
return;
}
const store = usePlayerStore.getState();
recordingStartTimeRef.current = store.currentTime;
const elStart = Number.parseFloat(sel.dataAttributes?.start ?? "0") || 0;
const elDur = Number.parseFloat(sel.dataAttributes?.duration ?? "0") || 0;
const elementEnd = elDur > 0 ? elStart + elDur : undefined;
capturedSelectionRef.current = sel;
gestureRecording.startRecording(sel.element, iframe, elementEnd);
gestureStateRef.current = "recording";
isGestureRecordingRef.current = true;
setGestureState("recording");
clearInterval(recordingAutoStopRef.current);
const autoStopAt = elementEnd ?? Infinity;
recordingAutoStopRef.current = setInterval(() => {
const { currentTime: t, duration: d } = usePlayerStore.getState();
const limit = Math.min(autoStopAt, d);
if (limit > 0 && t >= limit - 0.05) {
void stopAndCommitRecording();
const iframe = previewIframeRef.current;
if (!iframe) {
showToast("Preview not ready — try again", "error");
return;
}
}, 100);
}, [
gestureRecording,
showToast,
stopAndCommitRecording,
cancelRecording,
previewIframeRef,
domEditSessionRef,
isGestureRecordingRef,
readOnlyPreview,
]);
const store = usePlayerStore.getState();
recordingMethodRef.current = method;
recordingStartTimeRef.current = store.currentTime;
const elementEnd = recordingEnd(sel);
capturedSelectionRef.current = sel;
capturedAnimationsRef.current = domEditSessionRef.current.selectedGsapAnimations ?? [];
gestureRecording.startRecording(sel.element, iframe, elementEnd);
gestureStateRef.current = "recording";
isGestureRecordingRef.current = true;
setGestureState("recording");
armAutoStop(elementEnd);
},
[
gestureRecording,
showToast,
previewIframeRef,
domEditSessionRef,
isGestureRecordingRef,
readOnlyPreview,
armAutoStop,
],
);
const handleToggleRecording = useCallback(
(method: "button" | "keyboard" = "button") => {
if (commitInFlightRef.current) return;
if (gestureStateRef.current === "recording") {
if (readOnlyPreview) cancelRecording();
else void stopAndCommitRecording();
return;
}
startRecording(method);
},
[cancelRecording, readOnlyPreview, startRecording, stopAndCommitRecording],
);
return useStableHandlers({ gestureState, gestureRecording, handleToggleRecording }, projectId);
}
@@ -74,7 +74,7 @@ function mountResizeHandler(
size: { width: number; height: number },
offset?: { x: number; y: number },
restore?: () => void,
) => Promise<void>)
) => Promise<void | import("../utils/previewFeatureUsage").GeometryCommitResult>)
| null = null;
let property: ReturnType<typeof useGsapAwareEditing>["commitAnimatedProperty"] | null = null;
function Harness() {
@@ -131,13 +131,18 @@ function mountGroupHandler({
"trackGsapInteractionFailure" | "stageElementPositionOffset" | "handleDomRotationCommit"
>
>) {
let groupCommit!: (updates: DomEditGroupPathOffsetCommit[]) => Promise<void>;
let groupCommit!: (
updates: DomEditGroupPathOffsetCommit[],
) => Promise<void | import("../utils/previewFeatureUsage").GeometryCommitResult>;
let pathOffsetCommit!: (
selection: DomEditSelection,
next: { x: number; y: number },
route?: { plainTranslate?: boolean },
) => Promise<void>;
let rotationCommit!: (selection: DomEditSelection, next: RotationCommit) => Promise<void>;
) => Promise<void | import("../utils/previewFeatureUsage").GeometryCommitResult>;
let rotationCommit!: (
selection: DomEditSelection,
next: RotationCommit,
) => Promise<void | import("../utils/previewFeatureUsage").GeometryCommitResult>;
function Harness() {
const editing = useGsapAwareEditing({
domEditSelection: null,
@@ -589,7 +594,7 @@ describe("useGsapAwareEditing anchored resize", () => {
h.selection.element.setAttribute("data-hf-drag-initial-offset-x", "0");
h.selection.element.setAttribute("data-hf-drag-initial-offset-y", "0");
let commit!: Promise<void>;
let commit!: Promise<void | import("../utils/previewFeatureUsage").GeometryCommitResult>;
act(() => {
commit = h.resize(h.selection, { width: 300, height: 200 }, { x: -50.2, y: -25.6 });
});
@@ -636,7 +641,7 @@ describe("useGsapAwareEditing anchored resize", () => {
s: DomEditSelection,
m: unknown,
o: { coalesceKey?: string; label?: string; softReload?: boolean },
) => Promise<void>,
) => Promise<void | import("../utils/previewFeatureUsage").GeometryCommitResult>,
_fetch: unknown,
options?: { preflightOnly?: boolean },
) => {
+96 -195
View File
@@ -1,3 +1,6 @@
import { useGsapAwareGroupMove } from "./useGsapAwareGroupMove";
import { observeGsapGesture } from "./gsapGestureOutcome";
import type { DomEditPersistOutcome } from "./domEditCommitTypes";
/**
* GSAP-aware move/resize/rotation wrappers that intercept geometry commits
* for animated elements and route them through script mutation instead of
@@ -21,14 +24,9 @@ import {
useGsapSaveFailureTelemetry,
useSafeGsapCommitMutation,
} from "./useSafeGsapCommitMutation";
import type {
CommitMutation,
CommitMutationCall,
CommitMutationOptions,
} from "./gsapScriptCommitTypes";
import type { CommitMutation, CommitMutationOptions } from "./gsapScriptCommitTypes";
import { setElementGsapPosition } from "../utils/elementGsap";
import { logResize, logResizeSettle } from "../utils/resizeDebug";
import type { DomEditGroupPathOffsetCommit } from "../components/editor/DomEditOverlay";
import type { MoveCommitOptions } from "../components/editor/domEditOverlayGestures";
import { runGestureTransaction } from "./gestureTransaction";
import {
@@ -40,29 +38,13 @@ import {
} from "./gsapRuntimeKeyframes";
import { assertGsapEditPersisted, saveMove } from "./gsapEditOutcome";
import type { GsapAnimationFetchOptions } from "./useGsapAnimationFetchFallback";
import { refuseGsapTakeover, type ElementOffsetStagerDeps } from "./elementOffsetStager";
import { type ElementOffsetStagerDeps } from "./elementOffsetStager";
import {
prepareCropResize,
saveCropResize,
writeSizeWithCrop,
} from "../components/editor/cropResize";
// Distinct coalesceKey per group drag so consecutive group drags don't fold
// into one another's undo entry (module-local counter, not Date.now()).
let groupDragCommitCounter = 0;
function firstPreflightFailure(
results: PromiseSettledResult<void>[],
updates: DomEditGroupPathOffsetCommit[],
): { error: unknown; selection: DomEditSelection } | null {
for (const [index, result] of results.entries()) {
if (result.status !== "rejected") continue;
const selection = updates[index]?.selection;
if (selection) return { error: result.reason, selection };
}
return null;
}
export interface UseGsapAwareEditingParams {
domEditSelection: DomEditSelection | null;
selectedGsapAnimations: GsapAnimation[];
@@ -86,15 +68,18 @@ export interface UseGsapAwareEditingParams {
next: { x: number; y: number },
plainTranslate: boolean,
coalesceKey?: string,
) => { save: () => Promise<void>; rollback: () => void };
handleDomRotationCommit: (selection: DomEditSelection, next: RotationCommit) => Promise<void>;
) => { save: () => Promise<DomEditPersistOutcome | undefined>; rollback: () => void };
handleDomRotationCommit: (
selection: DomEditSelection,
next: RotationCommit,
) => Promise<DomEditPersistOutcome | undefined>;
handleDomBoxSizeCommit: (
selection: DomEditSelection,
next: { width: number; height: number },
offset?: { x: number; y: number },
restore?: () => void,
undoKey?: string,
) => Promise<void>;
) => Promise<DomEditPersistOutcome | undefined>;
commitPositionPatchToHtml: ElementOffsetStagerDeps["commitPositionPatchToHtml"];
// GSAP script commit ops (from useGsapScriptCommits)
addGsapAnimation: (
@@ -162,10 +147,12 @@ export function useGsapAwareEditing({
next: { x: number; y: number },
modifiers?: MoveCommitOptions,
) => {
// A gesture and the panel carry their route; webmcp's moveTo decides here.
if (modifiers?.plainTranslate ?? !gsapWritesPosition(selection.element))
return stageElementPositionOffset(selection, next, true).save();
if (gsapCommitMutation) {
const writes = observeGsapGesture(gsapCommitMutation);
if (modifiers?.plainTranslate ?? !gsapWritesPosition(selection.element)) {
const result = await stageElementPositionOffset(selection, next, true).save();
return writes.finish(result?.changed === true);
}
if (writes.commit) {
try {
const ownedAnimations = getGsapAnimationsForSelection(selection);
const targetAnimations = Array.isArray(ownedAnimations)
@@ -176,11 +163,15 @@ export function useGsapAwareEditing({
next,
targetAnimations,
previewIframeRef.current,
gsapCommitMutation,
writes.commit,
makeFetchFallback(selection),
modifiers,
);
await saveMove(outcome, () => stageElementPositionOffset(selection, next, false).save());
await saveMove(outcome, async () => {
const result = await stageElementPositionOffset(selection, next, false).save();
writes.recordDomResult(result);
});
return writes.finish();
} catch (error) {
trackGsapInteractionFailure(error, selection, "drag", "Move animated layer");
throw error;
@@ -197,137 +188,14 @@ export function useGsapAwareEditing({
],
);
// Multi-select (group) drag: each member takes the single drag's writer, so a member GSAP
// does not position is saved on itself and the rest go through the GSAP intercept.
const handleGsapAwareGroupPathOffsetCommit = useCallback(
async (updates: DomEditGroupPathOffsetCommit[]) => {
if (!gsapCommitMutation || updates.length === 0) return;
// A group drag is ONE user action: fold every member's position write into
// a single undo entry by forcing a shared coalesceKey (infinite window, so
// it survives the N sequential server round-trips) onto each commit —
// otherwise each member records its own entry and it takes N presses to undo.
const coalesceKey = `group-drag:${++groupDragCommitCounter}`;
// Members are written one at a time, and a re-render re-runs the script with the OLD
// position of every member not yet written, so they snap back until their own write
// lands. The drafts are already on screen: hold the render until the last member.
let renderOnCommit = false;
const previewFallbackLatch = { pending: false };
const withGroupOptions = (options: CommitMutationOptions): CommitMutationOptions => ({
...options,
coalesceKey,
coalesceMs: Number.POSITIVE_INFINITY,
deferPreviewSync: !renderOnCommit,
previewFallbackLatch,
});
// Every member writes the same file. Queue their mutations and send them as
// ONE request instead of one round trip per member: the server reads, parses
// and writes the composition once, and the preview patches once.
const queued: CommitMutationCall[] = [];
const flushQueued = async () => {
if (queued.length === 0) return;
const calls = queued.splice(0, queued.length);
if (!gsapCommitMutation.batch) {
for (const call of calls) {
await gsapCommitMutation(call.selection, call.mutation, call.options);
}
return;
}
await gsapCommitMutation.batch(calls, {
...(calls.at(-1)?.options ?? { label: "Move animated layer (group)" }),
label: "Move animated layer (group)",
});
};
const coalescedCommit: typeof gsapCommitMutation = (selection, mutation, options) => {
queued.push({ selection, mutation, options: withGroupOptions(options) });
return Promise.resolve();
};
const preflightAnimations = new Map<DomEditSelection, GsapAnimation[]>();
// Members saved on themselves, each with its route: true for its CSS translate.
const offsetMembers = new Map<DomEditSelection, boolean>();
// Editability is user-atomic: prove every member can be written before the first source
// mutation, so a blocked member never leaves earlier siblings partially moved. Preflights
// write nothing and share one in-flight parse per file, so they run together.
const preflightResults = await Promise.allSettled(
updates.map(async ({ selection, plainTranslate }) => {
if (plainTranslate ?? !gsapWritesPosition(selection.element)) {
refuseGsapTakeover(selection.element, showToast);
return void offsetMembers.set(selection, true);
}
const animations = await makeFetchFallback(selection, { failOnFetchError: true })();
preflightAnimations.set(selection, animations);
const outcome = await tryGsapDragIntercept(
selection,
{ x: 0, y: 0 },
animations,
previewIframeRef.current,
coalescedCommit,
undefined,
{ preflightOnly: true, group: true },
);
if (outcome.status === "element-offset") offsetMembers.set(selection, false);
assertGsapEditPersisted(outcome);
}),
);
const preflightFailure = firstPreflightFailure(preflightResults, updates);
if (preflightFailure) {
trackGsapInteractionFailure(
preflightFailure.error,
preflightFailure.selection,
"drag",
"Move animated layer (group)",
);
throw preflightFailure.error;
}
const lastScriptWrite = updates.findLastIndex(
({ selection }) => !offsetMembers.has(selection),
);
for (const [index, { selection, next }] of updates.entries()) {
renderOnCommit = index === lastScriptWrite;
const plain = offsetMembers.get(selection);
if (plain !== undefined) {
await stageElementPositionOffset(selection, next, plain, coalesceKey).save();
continue;
}
try {
const outcome = await tryGsapDragIntercept(
selection,
next,
preflightAnimations.get(selection) ?? [],
previewIframeRef.current,
coalescedCommit,
// The intercept re-reads the file to resolve a stale or shared tween.
// Anything already queued has to be on disk before that read, or it
// resolves against a file missing writes it is about to build on.
async () => {
await flushQueued();
return makeFetchFallback(selection, { fresh: true })();
},
{ preflightPassed: true },
);
assertGsapEditPersisted(outcome);
} catch (error) {
trackGsapInteractionFailure(error, selection, "drag", "Move animated layer (group)");
throw error;
}
}
try {
await flushQueued();
} catch (error) {
// The aggregate write has no uniquely failing member; do not misattribute
// its telemetry to whichever member happened to be last in the array.
trackGsapInteractionFailure(error, null, "drag", "Move animated layer (group)");
throw error;
}
},
[
gsapCommitMutation,
previewIframeRef,
makeFetchFallback,
trackGsapInteractionFailure,
stageElementPositionOffset,
showToast,
],
);
const handleGsapAwareGroupPathOffsetCommit = useGsapAwareGroupMove({
gsapCommitMutation,
previewIframeRef,
makeFetchFallback,
trackGsapInteractionFailure,
stageElementPositionOffset,
showToast,
});
const handleGsapAwareBoxSizeCommit = useCallback(
async (
@@ -336,8 +204,11 @@ export function useGsapAwareEditing({
offset?: { x: number; y: number },
restore: () => void = () => undefined,
) => {
if (!gsapWritesBox(selection.element))
return handleDomBoxSizeCommit(selection, next, offset, restore);
const writes = observeGsapGesture(gsapCommitMutation);
if (!gsapWritesBox(selection.element)) {
const result = await handleDomBoxSizeCommit(selection, next, offset, restore);
return writes.finish(result?.changed === true);
}
let targetAnimations: GsapAnimation[];
try {
const ownedAnimations = getGsapAnimationsForSelection(selection);
@@ -367,7 +238,28 @@ export function useGsapAwareEditing({
let anchorMove: ReturnType<typeof stageElementPositionOffset> | null = null;
const stageCrop = prepareCropResize(selection.element);
let cropUndoKey: string | null = null;
return runGestureTransaction({
const saveResizeAnchor = async (
ownsDragOffset: boolean,
commitMutation: CommitMutation,
coalesceKey: string,
) => {
if (offset && !ownsDragOffset) {
const dragOutcome = await tryGsapDragIntercept(
selection,
offset,
targetAnimations,
previewIframeRef.current,
commitMutation,
makeFetchFallback(selection),
);
// Saved after the size, under its undo key, so the two are one step.
await saveMove(dragOutcome, async () => {
const plain = !gsapWritesPosition(selection.element);
anchorMove = stageElementPositionOffset(selection, offset, plain, coalesceKey);
});
}
};
await runGestureTransaction({
element: selection.element,
label: "Resize layer",
settle: () => {
@@ -383,8 +275,8 @@ export function useGsapAwareEditing({
setElementGsapPosition(selection.element, newX, newY);
},
persist: async (commit, coalesceKey) => {
if (gsapCommitMutation) {
const commitMutation = commit(gsapCommitMutation);
if (writes.commit) {
const commitMutation = commit(writes.commit);
try {
const outcome = await tryGsapResizeIntercept(
selection,
@@ -396,9 +288,16 @@ export function useGsapAwareEditing({
);
assertGsapEditPersisted(outcome);
// Saved before the buffered GSAP writes, so their reload stays the gesture's last render.
if (outcome.status === "element-size")
await handleDomBoxSizeCommit(selection, next, undefined, undefined, coalesceKey);
else cropUndoKey = coalesceKey;
if (outcome.status === "element-size") {
const result = await handleDomBoxSizeCommit(
selection,
next,
undefined,
undefined,
coalesceKey,
);
writes.recordDomResult(result);
} else cropUndoKey = coalesceKey;
// What the resize did, not what its tweens suggest: a scale hold still commits a size.
const ownsDragOffset =
outcome.status === "persisted" && outcome.ownsDragOffset === true;
@@ -409,21 +308,7 @@ export function useGsapAwareEditing({
});
// A resize that moved the element itself has already written
// where it landed. Everything else leaves the anchor to the drag.
if (offset && !ownsDragOffset) {
const dragOutcome = await tryGsapDragIntercept(
selection,
offset,
targetAnimations,
previewIframeRef.current,
commitMutation,
makeFetchFallback(selection),
);
// Saved after the size, under its undo key, so the two are one step.
await saveMove(dragOutcome, async () => {
const plain = !gsapWritesPosition(selection.element);
anchorMove = stageElementPositionOffset(selection, offset, plain, coalesceKey);
});
}
await saveResizeAnchor(ownsDragOffset, commitMutation, coalesceKey);
logResizeSettle(selection.element, ownsDragOffset ? "gsap-scale" : "gsap-size");
return;
} catch (error) {
@@ -434,10 +319,17 @@ export function useGsapAwareEditing({
throw new Error("Resize of a GSAP-owned box has no GSAP writer");
},
afterBufferedCommitsSaved: async () => {
await anchorMove?.save();
const anchorResult = await anchorMove?.save();
writes.recordDomResult(anchorResult);
// Only now is the size live for every caller, drag or not.
if (cropUndoKey) {
await saveCropResize(stageCrop, selection, commitPositionPatchToHtml, cropUndoKey);
const cropResult = await saveCropResize(
stageCrop,
selection,
commitPositionPatchToHtml,
cropUndoKey,
);
writes.recordDomResult(cropResult);
}
},
restore: () => {
@@ -446,6 +338,7 @@ export function useGsapAwareEditing({
},
skipPixelAssert: hasLivePositionTween,
});
return writes.finish();
},
[
handleDomBoxSizeCommit,
@@ -461,9 +354,12 @@ export function useGsapAwareEditing({
const handleGsapAwareRotationCommit = useCallback(
async (selection: DomEditSelection, next: RotationCommit) => {
if (next.plain || !gsapWritesRotation(selection.element))
return handleDomRotationCommit(selection, next);
if (gsapCommitMutation) {
const writes = observeGsapGesture(gsapCommitMutation);
if (next.plain || !gsapWritesRotation(selection.element)) {
const result = await handleDomRotationCommit(selection, next);
return writes.finish(result?.changed === true);
}
if (writes.commit) {
try {
const targetAnimations = await getGsapAnimationsForSelection(selection);
// A keyframe or a tl.set; a computed source rejects, so the gesture restores its draft.
@@ -472,10 +368,11 @@ export function useGsapAwareEditing({
next.angle,
targetAnimations,
previewIframeRef.current,
gsapCommitMutation,
writes.commit,
makeFetchFallback(selection),
);
assertGsapEditPersisted(outcome);
return writes.finish();
} catch (error) {
trackGsapInteractionFailure(error, selection, "rotation", "Rotate animated layer");
throw error;
@@ -564,12 +461,16 @@ export function useGsapAwareEditing({
);
const commitMutation = useCallback(
async (mutation: Record<string, unknown>, options: { label: string; softReload?: boolean }) => {
if (!domEditSelection) return;
async (
mutation: Record<string, unknown>,
options: CommitMutationOptions,
selection = domEditSelection,
) => {
if (!selection) return;
// Return (await) the safe-commit chain so consumers that `await
// session.commitMutation(...)` (gesture recording, enable-keyframes) run
// their post-actions only after the server save has settled.
await safeGsapCommit(domEditSelection, mutation, options);
await safeGsapCommit(selection, mutation, options);
},
[domEditSelection, safeGsapCommit],
);
@@ -0,0 +1,179 @@
import { useCallback } from "react";
import type { DomEditSelection } from "../components/editor/domEditingTypes";
import type { DomEditGroupPathOffsetCommit } from "../components/editor/DomEditOverlay";
import type { GsapAnimation } from "@hyperframes/core/gsap-parser";
import type { CommitMutationCall, CommitMutationOptions } from "./gsapScriptCommitTypes";
import type { UseGsapAwareEditingParams } from "./useGsapAwareEditing";
import { observeGsapGesture } from "./gsapGestureOutcome";
import { gsapWritesPosition } from "./gsapRuntimeKeyframes";
import { refuseGsapTakeover } from "./elementOffsetStager";
import { tryGsapDragIntercept } from "./gsapRuntimeBridge";
import { assertGsapEditPersisted } from "./gsapEditOutcome";
// A distinct key keeps consecutive group drags in separate undo entries.
let groupDragCommitCounter = 0;
function firstPreflightFailure(
results: PromiseSettledResult<void>[],
updates: DomEditGroupPathOffsetCommit[],
): { error: unknown; selection: DomEditSelection } | null {
for (const [index, result] of results.entries()) {
if (result.status !== "rejected") continue;
const selection = updates[index]?.selection;
if (selection) return { error: result.reason, selection };
}
return null;
}
export function useGsapAwareGroupMove({
gsapCommitMutation,
previewIframeRef,
makeFetchFallback,
trackGsapInteractionFailure,
stageElementPositionOffset,
showToast,
}: Pick<
UseGsapAwareEditingParams,
| "gsapCommitMutation"
| "previewIframeRef"
| "makeFetchFallback"
| "trackGsapInteractionFailure"
| "stageElementPositionOffset"
| "showToast"
>) {
const handleGsapAwareGroupPathOffsetCommit = useCallback(
async (
updates: DomEditGroupPathOffsetCommit[],
): Promise<import("../utils/previewFeatureUsage").GeometryCommitResult> => {
const writes = observeGsapGesture(gsapCommitMutation);
const writer = writes.commit;
if (!writer) return { ok: true, changed: false };
// One coalesce key across slow writes keeps the group drag in one undo entry.
const coalesceKey = `group-drag:${++groupDragCommitCounter}`;
// Hold the render until every member is saved, or unwritten members snap back.
let renderOnCommit = false;
const previewFallbackLatch = { pending: false };
const withGroupOptions = (options: CommitMutationOptions): CommitMutationOptions => ({
...options,
coalesceKey,
coalesceMs: Number.POSITIVE_INFINITY,
deferPreviewSync: !renderOnCommit,
previewFallbackLatch,
});
// Batch members in the same file to avoid repeating its read, parse, write and preview patch.
const queued: CommitMutationCall[] = [];
const flushQueued = async () => {
if (queued.length === 0) return;
const calls = queued.splice(0, queued.length);
if (!writer.batch) {
for (const call of calls) {
await writer(call.selection, call.mutation, call.options);
}
return;
}
await writer.batch(calls, {
...(calls.at(-1)?.options ?? { label: "Move animated layer (group)" }),
label: "Move animated layer (group)",
});
};
const coalescedCommit: typeof gsapCommitMutation = (selection, mutation, options) => {
queued.push({ selection, mutation, options: withGroupOptions(options) });
return Promise.resolve();
};
const preflightAnimations = new Map<DomEditSelection, GsapAnimation[]>();
// Members saved on themselves, each with its route: true for its CSS translate.
const offsetMembers = new Map<DomEditSelection, boolean>();
// Editability is user-atomic: prove every member can be written before the first source
// mutation, so a blocked member never leaves earlier siblings partially moved. Preflights
// write nothing and share one in-flight parse per file, so they run together.
const preflightResults = await Promise.allSettled(
updates.map(async ({ selection, plainTranslate }) => {
if (plainTranslate ?? !gsapWritesPosition(selection.element)) {
refuseGsapTakeover(selection.element, showToast);
return void offsetMembers.set(selection, true);
}
const animations = await makeFetchFallback(selection, { failOnFetchError: true })();
preflightAnimations.set(selection, animations);
const outcome = await tryGsapDragIntercept(
selection,
{ x: 0, y: 0 },
animations,
previewIframeRef.current,
coalescedCommit,
undefined,
{ preflightOnly: true, group: true },
);
if (outcome.status === "element-offset") offsetMembers.set(selection, false);
assertGsapEditPersisted(outcome);
}),
);
const preflightFailure = firstPreflightFailure(preflightResults, updates);
if (preflightFailure) {
trackGsapInteractionFailure(
preflightFailure.error,
preflightFailure.selection,
"drag",
"Move animated layer (group)",
);
throw preflightFailure.error;
}
const lastScriptWrite = updates.findLastIndex(
({ selection }) => !offsetMembers.has(selection),
);
for (const [index, { selection, next }] of updates.entries()) {
renderOnCommit = index === lastScriptWrite;
const plain = offsetMembers.get(selection);
if (plain !== undefined) {
const result = await stageElementPositionOffset(
selection,
next,
plain,
coalesceKey,
).save();
writes.recordDomResult(result);
continue;
}
try {
const outcome = await tryGsapDragIntercept(
selection,
next,
preflightAnimations.get(selection) ?? [],
previewIframeRef.current,
coalescedCommit,
// The intercept re-reads the file to resolve a stale or shared tween.
// Anything already queued has to be on disk before that read, or it
// resolves against a file missing writes it is about to build on.
async () => {
await flushQueued();
return makeFetchFallback(selection, { fresh: true })();
},
{ preflightPassed: true },
);
assertGsapEditPersisted(outcome);
} catch (error) {
trackGsapInteractionFailure(error, selection, "drag", "Move animated layer (group)");
throw error;
}
}
try {
await flushQueued();
return writes.finish();
} catch (error) {
// The aggregate write has no uniquely failing member; do not misattribute
// its telemetry to whichever member happened to be last in the array.
trackGsapInteractionFailure(error, null, "drag", "Move animated layer (group)");
throw error;
}
},
[
gsapCommitMutation,
previewIframeRef,
makeFetchFallback,
trackGsapInteractionFailure,
stageElementPositionOffset,
showToast,
],
);
return handleGsapAwareGroupPathOffsetCommit;
}
@@ -0,0 +1,114 @@
// @vitest-environment happy-dom
import { act } from "react";
import { createRoot, type Root } from "react-dom/client";
import { afterEach, beforeEach, describe, expect, it, vi } from "vitest";
import { useGsapKeyframeOps } from "./useGsapKeyframeOps";
import type { CutoverResult } from "../utils/sdkCutover";
import type { DomEditSelection } from "../components/editor/domEditingTypes";
import { trackStudioEvent } from "../utils/studioTelemetry";
const sdkWrite = vi.hoisted(() => vi.fn<(...args: unknown[]) => Promise<CutoverResult>>());
vi.mock("../utils/sdkCutover", async (original) => ({
...(await original<typeof import("../utils/sdkCutover")>()),
sdkGsapKeyframePersist: sdkWrite,
sdkGsapConvertToKeyframesPersist: sdkWrite,
sdkGsapRemoveAllKeyframesPersist: sdkWrite,
}));
vi.mock("../utils/studioTelemetry", () => ({ trackStudioEvent: vi.fn() }));
Reflect.set(globalThis, "IS_REACT_ACT_ENVIRONMENT", true);
type Params = Parameters<typeof useGsapKeyframeOps>[0];
let root: Root;
function mount() {
let api: ReturnType<typeof useGsapKeyframeOps> | undefined;
const legacy = vi.fn(async () => undefined);
function Probe() {
api = useGsapKeyframeOps({
activeCompPath: "index.html",
commitMutation: legacy,
commitMutationSafely: legacy,
trackGsapSaveFailure: vi.fn(),
sdkSession: {} as NonNullable<Params["sdkSession"]>,
sdkDeps: {
editHistory: { recordEdit: vi.fn(async () => undefined) },
writeProjectFile: vi.fn(async () => undefined),
reloadPreview: vi.fn(),
},
});
return null;
}
root = createRoot(document.createElement("div"));
act(() => root.render(<Probe />));
if (!api) throw new Error("hook did not mount");
return { api, legacy };
}
const selection = { id: "private-target", sourceFile: "private.html" } as DomEditSelection;
beforeEach(() => {
vi.clearAllMocks();
sdkWrite.mockResolvedValue({
status: "committed",
version: "v2",
before: "before",
after: "after",
});
});
afterEach(() => act(() => root.unmount()));
describe("SDK keyframe usage", () => {
it.each(["add", "convert", "remove_all", "reset"] as const)(
"counts a changed committed %s operation once",
async (action) => {
const { api, legacy } = mount();
await act(async () => {
if (action === "add")
await api.addKeyframeBatch(selection, "private-animation", 50, { x: 12, y: 4 });
else if (action === "convert") await api.convertToKeyframes(selection, "private-animation");
else await api.removeAllKeyframes(selection, "private-animation", action);
});
expect(legacy).not.toHaveBeenCalled();
expect(trackStudioEvent).toHaveBeenCalledExactlyOnceWith("keyframe", { action });
},
);
it("preserves property metadata on a single-property SDK add", async () => {
const { api } = mount();
await act(async () => api.addKeyframe(selection, "private-animation", 50, "x", 12));
expect(trackStudioEvent).toHaveBeenCalledExactlyOnceWith("keyframe", {
action: "add",
property: "x",
});
});
it("does not count unchanged SDK source", async () => {
sdkWrite.mockResolvedValue({
status: "committed",
version: "v2",
before: "same",
after: "same",
});
const { api } = mount();
await api.addKeyframeBatch(selection, "private-animation", 50, { x: 12 });
expect(await api.removeAllKeyframes(selection, "private-animation")).toBe(false);
expect(trackStudioEvent).not.toHaveBeenCalled();
});
it("honors intermediate-write suppression in SDK paths", async () => {
const { api } = mount();
await api.convertToKeyframes(selection, "private-animation", undefined, undefined, {
keyframeTelemetry: false,
});
await api.addKeyframeBatch(
selection,
"private-animation",
50,
{ x: 12 },
{ keyframeTelemetry: false },
);
await api.removeAllKeyframes(selection, "private-animation", "remove_all", false);
expect(trackStudioEvent).not.toHaveBeenCalled();
});
it("does not fall back or count after a failed SDK write", async () => {
sdkWrite.mockResolvedValue({ status: "failed", error: new Error("refused") });
const { api, legacy } = mount();
await expect(
api.addKeyframeBatch(selection, "private-animation", 50, { x: 12 }),
).rejects.toThrow("refused");
expect(legacy).not.toHaveBeenCalled();
expect(trackStudioEvent).not.toHaveBeenCalled();
});
});
+36 -10
View File
@@ -1,3 +1,4 @@
import { trackKeyframeUsage } from "../utils/keyframeUsage";
// fallow-ignore-file code-duplication
// Add/remove operation-family transaction shapes stay parallel until SDK graduation.
import { useCallback } from "react";
@@ -139,10 +140,14 @@ export function useGsapKeyframeOps({
coalesceKey: `gsap:${animationId}:kf:${percentage}`,
},
);
if (cutoverCommittedOrThrow(handled)) return;
if (cutoverCommittedOrThrow(handled)) {
if (handled.before !== handled.after) trackKeyframeUsage("add", property);
return;
}
}
await commitMutation(selection, mutation, {
label: `Add keyframe at ${percentage}%`,
keyframeProperty: property,
softReload: true,
});
},
@@ -172,7 +177,11 @@ export function useGsapKeyframeOps({
sdkDeps,
toSdkPersistOptions(`Add keyframe at ${percentage}%`, commitOverrides),
);
if (cutoverCommittedOrThrow(handled)) return;
if (cutoverCommittedOrThrow(handled)) {
if (handled.before !== handled.after && commitOverrides?.keyframeTelemetry !== false)
trackKeyframeUsage("add");
return;
}
}
return commitMutation(
selection,
@@ -319,7 +328,11 @@ export function useGsapKeyframeOps({
sdkDeps,
toSdkPersistOptions("Convert to keyframes", commitOverrides),
);
if (cutoverCommittedOrThrow(handled)) return;
if (cutoverCommittedOrThrow(handled)) {
if (handled.before !== handled.after && commitOverrides.keyframeTelemetry !== false)
trackKeyframeUsage("convert");
return;
}
}
return commitMutation(
selection,
@@ -333,11 +346,19 @@ export function useGsapKeyframeOps({
);
const removeAllKeyframes = useCallback(
async (selection: DomEditSelection, animationId: string) => {
async (
selection: DomEditSelection,
animationId: string,
action: "remove_all" | "reset" = "remove_all",
telemetry = true,
): Promise<boolean> => {
const targetPath = selection.sourceFile || activeCompPath || "index.html";
// A class/descendant selector can resolve a live element whose selection
// deliberately has no id. The cache is still keyed by that DOM id.
const cacheElementId = selection.id || selection.element?.id;
const clearCache = () => {
const cacheElementId = selection.id || selection.element?.id;
if (cacheElementId) clearKeyframeCacheForElement(targetPath, cacheElementId);
};
if (sdkSession && sdkDeps) {
const handled = await sdkGsapRemoveAllKeyframesPersist(
targetPath,
@@ -347,26 +368,31 @@ export function useGsapKeyframeOps({
{ label: "Remove all keyframes" },
);
if (cutoverCommittedOrThrow(handled)) {
if (cacheElementId) clearKeyframeCacheForElement(targetPath, cacheElementId);
return;
clearCache();
const changed = handled.before !== handled.after;
if (changed && telemetry) trackKeyframeUsage(action);
return changed;
}
}
let changed = false;
await commitMutationSafely(
selection,
{ type: "remove-all-keyframes", animationId },
{
label: "Remove all keyframes",
keyframeAction: action,
keyframeTelemetry: telemetry,
softReload: true,
// The committed result is the single success boundary: clearing
// before it makes failed saves lie, while waiting for the reload leaves
// stale diamonds visible during the source round-trip.
onResult: (result) => {
if (result.changed !== false && cacheElementId) {
clearKeyframeCacheForElement(targetPath, cacheElementId);
}
changed = result.ok && result.changed === true;
if (changed) clearCache();
},
},
);
return changed;
},
[commitMutationSafely, activeCompPath, sdkSession, sdkDeps],
);
@@ -590,6 +590,63 @@ describe("runCommit — instantPatch wiring", () => {
vi.unstubAllGlobals();
});
it.each([
["add-keyframe", "add"],
["add-with-keyframes", "add"],
["convert-to-keyframes", "convert"],
["remove-all-keyframes", "remove_all"],
])("counts %s only after a changed durable write", async (type, action) => {
mockFetchResult();
const deps = renderCommitHook();
await deps.api.commitMutation(
selection,
{ type, properties: { text: "private-content" } },
{ label: "Edit" },
);
expect(trackStudioEvent.mock.calls.filter(([event]) => event === "keyframe")).toEqual([
["keyframe", { action }],
]);
});
it("counts reset with its explicit action and suppresses intermediate writes", async () => {
mockFetchResult();
const deps = renderCommitHook();
await deps.api.commitMutation(
selection,
{ type: "convert-to-keyframes" },
{ label: "Convert", keyframeTelemetry: false },
);
await deps.api.commitMutation(
selection,
{ type: "remove-all-keyframes" },
{ label: "Reset", keyframeAction: "reset" },
);
expect(trackStudioEvent.mock.calls.filter(([event]) => event === "keyframe")).toEqual([
["keyframe", { action: "reset" }],
]);
});
it("does not count a successful unchanged keyframe write", async () => {
mockFetchResult({ changed: false });
const deps = renderCommitHook();
await deps.api.commitMutation(selection, { type: "add-keyframe" }, { label: "Add" });
expect(trackStudioEvent.mock.calls.filter(([event]) => event === "keyframe")).toEqual([]);
});
it("does not count a failed write that skipReload suppresses", async () => {
vi.stubGlobal(
"fetch",
vi.fn(async () => ({ ok: false, status: 500 })),
);
const deps = renderCommitHook();
await deps.api.commitMutation(
selection,
{ type: "add-keyframe" },
{ label: "Add", skipReload: true },
);
expect(trackStudioEvent.mock.calls.filter(([event]) => event === "keyframe")).toEqual([]);
});
const NESTED_SCRIPT = 'window.__timelines["root"] = tl;';
const SUB = `<template><div data-composition-id="sub"><div id="nwid" style="left: 40px"></div></div></template>`;
@@ -11,6 +11,7 @@ import {
} from "../utils/gsapSoftReload";
import type { SoftReloadResult } from "../utils/gsapSoftReload";
import { trackStudioEvent } from "../utils/studioTelemetry";
import { trackKeyframeCommit } from "../utils/keyframeUsage";
import { serializeStudioFileMutation } from "../utils/studioFileMutationCoordinator";
import { trackedStudioEdit } from "../utils/studioPendingEdits";
import {
@@ -391,6 +392,7 @@ export function useGsapScriptCommits({ projectIdRef, activeCompPath, previewIfra
mutateGsapScript(pid, targetPath, mutation),
);
if (!result) return;
trackKeyframeCommit([mutation], result, options);
options.onResult?.(result);
await finalizeSuccessfulMutation(pid, compositionPath, selection, mutation, targetPath, result, options);
}, [showToast, finalizeSuccessfulMutation]);
@@ -404,6 +406,7 @@ export function useGsapScriptCommits({ projectIdRef, activeCompPath, previewIfra
mutateGsapScriptBatch(pid, targetPath, mutations),
);
if (!result) return;
trackKeyframeCommit(mutations, result, options, calls.map((call) => call.options));
options.onResult?.(result);
// Each call brings its own fast-path patch; the batch wrote them all, so the
// preview sync applies them all rather than just the last call's.
@@ -42,7 +42,7 @@ function makeParams(overrides: Partial<Params> = {}): Params {
moveKeyframe: vi.fn().mockResolvedValue(true),
resizeKeyframedTween: vi.fn().mockResolvedValue(true),
convertToKeyframes: resolved(),
removeAllKeyframes: resolved(),
removeAllKeyframes: vi.fn().mockResolvedValue(true),
handleDomManualEditsReset: vi.fn(),
selectedGsapAnimations: [],
showToast: vi.fn(),
@@ -109,9 +109,16 @@ export function useGsapSelectionHandlers({
duration?: number,
commitOverrides?: Partial<CommitMutationOptions>,
) => Promise<void>;
removeAllKeyframes: (sel: DomEditSelection, animId: string) => Promise<void>;
removeAllKeyframes: (
sel: DomEditSelection,
animId: string,
action?: "remove_all" | "reset",
telemetry?: boolean,
) => Promise<boolean>;
handleDomManualEditsReset: (sel: DomEditSelection) => Promise<void>;
handleDomManualEditsReset: (
sel: DomEditSelection,
) => Promise<void | import("./domEditCommitTypes").DomEditPersistOutcome>;
selectedGsapAnimations: GsapAnimation[];
showToast: (message: string, tone?: "error" | "info") => void;
}) {
@@ -323,7 +330,6 @@ export function useGsapSelectionHandlers({
) => {
const sel = resolveWriteSelection(selectionOverride);
if (!sel) return;
trackStudioEvent("keyframe", { action: "add", property });
addKeyframe(sel, animId, percentage, property, value);
},
[resolveWriteSelection, addKeyframe],
@@ -441,31 +447,31 @@ export function useGsapSelectionHandlers({
);
const handleGsapRemoveAllKeyframes = useCallback(
(animId: string, selectionOverride?: DomEditSelection | null) => {
(animId: string, selectionOverride?: DomEditSelection | null, telemetry = true) => {
const selection = resolveWriteSelection(selectionOverride);
if (!selection) return Promise.resolve(false);
return observeGsapMutation(
removeAllKeyframes(selection, animId),
selection,
"remove-all-keyframes",
"Remove all keyframes",
);
return removeAllKeyframes(selection, animId, "remove_all", telemetry).catch((error) => {
trackGsapHandlerFailure(error, selection, "remove-all-keyframes", "Remove all keyframes");
return false;
});
},
[resolveWriteSelection, observeGsapMutation, removeAllKeyframes],
[resolveWriteSelection, trackGsapHandlerFailure, removeAllKeyframes],
);
const handleResetSelectedElementKeyframes = useCallback((): boolean => {
if (!domEditSelection) return false;
const withKeyframes = selectedGsapAnimations.find((a) => a.keyframes);
if (!withKeyframes) return false;
observeGsapMutation(
removeAllKeyframes(domEditSelection, withKeyframes.id),
domEditSelection,
"remove-all-keyframes",
"Remove all keyframes",
);
void removeAllKeyframes(domEditSelection, withKeyframes.id, "reset").catch((error) => {
trackGsapHandlerFailure(
error,
domEditSelection,
"remove-all-keyframes",
"Remove all keyframes",
);
});
return true;
}, [domEditSelection, observeGsapMutation, removeAllKeyframes, selectedGsapAnimations]);
}, [domEditSelection, trackGsapHandlerFailure, removeAllKeyframes, selectedGsapAnimations]);
return {
handleGsapUpdateProperty,
@@ -299,3 +299,24 @@ describe("a refused move's toast", () => {
]);
});
});
vi.mock("../utils/studioTelemetry", () => ({ trackStudioEvent: vi.fn() }));
import { trackStudioEvent } from "../utils/studioTelemetry";
it("counts additive preview selection only after resolving a hit", async () => {
vi.mocked(trackStudioEvent).mockClear();
const selection = makeSelection("Card", document.createElement("div"));
const applyDomSelection = vi.fn();
const { canvas, cleanup } = renderHarness({
previewIframe: createPreviewIframe(vi.fn()),
resolveDomSelectionFromPreviewPoint: vi.fn(async () => selection),
applyDomSelection,
});
await dispatchMouseDown(canvas, { shiftKey: true });
expect(applyDomSelection).toHaveBeenCalledWith(selection, { additive: true });
expect(trackStudioEvent).toHaveBeenCalledExactlyOnceWith("feature_used", {
feature: "multi_select",
surface: "preview",
method: "button",
});
cleanup();
});
@@ -1,3 +1,4 @@
import { trackPreviewFeatureUsed } from "../utils/previewFeatureUsage";
import { useCallback, useRef } from "react";
import { liveTime, usePlayerStore } from "../player";
import { pauseStudioPreviewPlayback } from "../utils/studioPreviewHelpers";
@@ -169,6 +170,7 @@ export function usePreviewInteraction({
e.preventDefault();
e.stopPropagation();
applyDomSelection(nextSelection, { additive: true });
trackPreviewFeatureUsed("multi_select", "button");
return;
}
@@ -0,0 +1,79 @@
import type { CommitMutationOptions, MutationResult } from "../hooks/gsapScriptCommitTypes";
import { trackStudioEvent } from "./studioTelemetry";
export type KeyframeUsageAction = "add" | "convert" | "remove_all" | "reset";
export function keyframeUsageActions(
mutations: ReadonlyArray<Record<string, unknown>>,
semanticActions?: ReadonlyArray<KeyframeUsageAction | undefined>,
): Set<KeyframeUsageAction> {
const actions = new Set<KeyframeUsageAction>();
for (const [index, mutation] of mutations.entries()) {
const semanticAction = semanticActions?.[index];
if (semanticAction) {
actions.add(semanticAction);
continue;
}
switch (mutation.type) {
case "add-keyframe":
case "add-with-keyframes":
actions.add("add");
break;
case "convert-to-keyframes":
actions.add("convert");
break;
case "remove-all-keyframes":
actions.add("remove_all");
break;
}
}
return actions;
}
export function trackKeyframeUsage(action: KeyframeUsageAction, property?: string): void {
trackStudioEvent("keyframe", property === undefined ? { action } : { action, property });
}
export function primaryKeyframeAction(
actions: ReadonlySet<KeyframeUsageAction>,
): KeyframeUsageAction | undefined {
return (["add", "convert", "remove_all", "reset"] as const).find((action) => actions.has(action));
}
export function changedMutationIndices(
result: MutationResult,
count: number,
options?: ReadonlyArray<Pick<CommitMutationOptions, "keyframeTelemetry">>,
): number[] {
if (!result.ok || result.changed !== true) return [];
const changes = count === 1 ? [true] : result.mutationChanges;
if (
!Array.isArray(changes) ||
changes.length !== count ||
changes.some((value) => typeof value !== "boolean")
)
return [];
return changes.flatMap((changed, index) =>
changed && options?.[index]?.keyframeTelemetry !== false ? [index] : [],
);
}
export function trackKeyframeCommit(
mutations: ReadonlyArray<Record<string, unknown>>,
result: MutationResult,
options: CommitMutationOptions,
memberOptions?: ReadonlyArray<CommitMutationOptions>,
): void {
if (!result.ok || result.changed !== true || options.keyframeTelemetry === false) return;
const indices = changedMutationIndices(result, mutations.length, memberOptions);
const actions = keyframeUsageActions(
indices.map((index) => mutations[index]!),
indices.map(
(index) =>
memberOptions?.[index]?.keyframeAction ??
(mutations.length === 1 ? options.keyframeAction : undefined),
),
);
const action = primaryKeyframeAction(actions);
if (action) trackKeyframeUsage(action, action === "add" ? options.keyframeProperty : undefined);
}
@@ -0,0 +1,32 @@
import { beforeEach, describe, expect, it, vi } from "vitest";
import { trackPreviewEditResult } from "./previewFeatureUsage";
import { trackStudioEvent } from "./studioTelemetry";
vi.mock("./studioTelemetry", () => ({ trackStudioEvent: vi.fn() }));
beforeEach(() => vi.clearAllMocks());
describe("preview write usage", () => {
it.each([
{ ok: true, changed: true },
{
ok: true,
persistence: { changed: true, sourceFile: "private.html", content: "private text" },
},
])("sends only gesture metadata after a changed result", (result) => {
trackPreviewEditResult("resize", "drag", result);
expect(trackStudioEvent).toHaveBeenCalledExactlyOnceWith("feature_used", {
feature: "resize",
surface: "preview",
method: "drag",
});
});
it.each([
undefined,
null,
{},
{ ok: false, changed: true },
{ changed: false },
{ persistence: { changed: false } },
])("does not count unavailable, failed or unchanged results (%j)", (result) => {
trackPreviewEditResult("move", "drag", result);
expect(trackStudioEvent).not.toHaveBeenCalled();
});
});
@@ -0,0 +1,46 @@
import { trackStudioEvent } from "./studioTelemetry";
export type PreviewFeature =
| "move"
| "resize"
| "rotate"
| "crop"
| "nudge"
| "multi_select"
| "z_order"
| "text_edit"
| "motion_path"
| "gesture_recording"
| "snapping"
| "grid"
| "grid_spacing"
| "snap_to_grid"
| "ruler"
| "safe_margins";
export type PreviewMethod = "drag" | "button" | "keyboard" | "field";
export interface GeometryCommitResult {
ok: true;
changed: boolean;
}
export function trackPreviewFeatureUsed(feature: PreviewFeature, method: PreviewMethod): void {
trackStudioEvent("feature_used", { feature, surface: "preview", method });
}
export function trackPreviewEditResult(
feature: PreviewFeature,
method: PreviewMethod,
result: unknown,
): void {
if (typeof result !== "object" || result === null) return;
if (Reflect.get(result, "ok") === false) return;
const persistence = Reflect.get(result, "persistence");
const changed =
Reflect.get(result, "changed") === true ||
(typeof persistence === "object" &&
persistence !== null &&
Reflect.get(persistence, "changed") === true);
if (changed) trackPreviewFeatureUsed(feature, method);
}
@@ -1,5 +1,6 @@
// @vitest-environment happy-dom
import { afterEach, beforeEach, describe, expect, it, vi } from "vitest";
import { buildProjectHash } from "./projectRouting";
// The `studio:*` path predates telemetry/config.ts and shipped its own
// opt-out key and its own send loop, so it sat outside both contracts the
@@ -25,6 +26,8 @@ describe("studioTelemetry — shared opt-out and canary properties", () => {
beforeEach(async () => {
policyState.allowed = true;
localStorage.clear();
sessionStorage.clear();
window.location.hash = "";
vi.resetModules();
vi.useFakeTimers();
fetchMock = vi.fn(() => Promise.resolve({ ok: true } as Response));
@@ -33,8 +36,10 @@ describe("studioTelemetry — shared opt-out and canary properties", () => {
});
afterEach(() => {
window.location.hash = "";
vi.useRealTimers();
vi.unstubAllGlobals();
vi.restoreAllMocks();
});
/** Drain the queue and return the events the batch would have sent. */
@@ -87,4 +92,106 @@ describe("studioTelemetry — shared opt-out and canary properties", () => {
"$feature/canary-test-one": "false",
});
});
it.each(["fetch", "beacon"] as const)(
"excludes project names from the complete serialized %s batch",
async (transport) => {
const projectName = "Launch #1? v2";
window.location.hash = buildProjectHash(projectName, { selSelector: "#private-element" });
const beacon = vi.fn((_url: string, _body: string) => true);
vi.stubGlobal("navigator", { userAgent: "test", sendBeacon: beacon });
for (const event of ["feature_used", "keyframe", "design_input", "toolbar_action"]) {
trackStudioEvent(event);
}
if (transport === "beacon") {
const { flushViaBeacon } = await import("./studioTelemetry");
flushViaBeacon();
} else {
await vi.runOnlyPendingTimersAsync();
}
const serialized =
transport === "beacon"
? beacon.mock.calls[0]?.[1]
: (fetchMock.mock.calls[0]?.[1] as RequestInit)?.body;
expect(typeof serialized).toBe("string");
const body = serialized as string;
expect(body).not.toContain(projectName);
expect(body).not.toContain(encodeURIComponent(projectName));
expect(body).not.toContain("private-element");
expect(body).not.toContain("7aef12cc");
const payload = JSON.parse(body);
expect(payload.batch).toHaveLength(4);
for (const event of payload.batch) {
expect(event.properties.url_hash).toBe("project");
expect(event.properties.url_route_id).toMatch(/^[0-9a-f]{8}$/);
}
},
);
it.each([
["", "home"],
["#", "home"],
["#unknown/Launch%20%231%3F%20v2?selection=secret", "other"],
])("sends a finite route kind for %s", async (hash, routeKind) => {
window.location.hash = hash;
trackStudioEvent("thing_happened");
const events = await sentEvents();
expect(events[0]?.["properties"]).toMatchObject({ url_hash: routeKind });
});
it("keeps route identity stable across query changes and distinct across projects", async () => {
window.location.hash = buildProjectHash("Launch #1? v2");
trackStudioEvent("session_start");
window.location.hash = buildProjectHash("Launch #1? v2", { selSelector: "#another-element" });
trackStudioEvent("feature_used");
window.location.hash = buildProjectHash("Another launch");
trackStudioEvent("feature_used");
const events = await sentEvents();
const ids = events.map(
(event) => (event["properties"] as Record<string, unknown>)["url_route_id"],
);
expect(ids[0]).toMatch(/^[0-9a-f]{8}$/);
expect(ids[1]).toBe(ids[0]);
expect(ids[2]).not.toBe(ids[0]);
expect(ids[2]).toMatch(/^[0-9a-f]{8}$/);
});
it("reuses a route ID after reloading the same tab", async () => {
window.location.hash = buildProjectHash("Launch #1? v2");
trackStudioEvent("session_start");
const first = (await sentEvents())[0]?.properties as Record<string, unknown>;
vi.clearAllTimers();
vi.resetModules();
fetchMock.mockClear();
({ trackStudioEvent } = await import("./studioTelemetry"));
trackStudioEvent("session_start");
const second = (await sentEvents())[0]?.properties as Record<string, unknown>;
expect(second.url_route_id).toBe(first.url_route_id);
});
it("assigns different random IDs to the same route in two tabs", async () => {
const random = vi.spyOn(crypto, "getRandomValues");
random.mockImplementationOnce((bytes) => {
(bytes as Uint8Array).set([1, 2, 3, 4]);
return bytes;
});
window.location.hash = buildProjectHash("Launch #1? v2");
trackStudioEvent("session_start");
const first = (await sentEvents())[0]?.properties as Record<string, unknown>;
// A second independent tab has its own sessionStorage and module instance.
sessionStorage.clear();
vi.clearAllTimers();
vi.resetModules();
fetchMock.mockClear();
random.mockImplementationOnce((bytes) => {
(bytes as Uint8Array).set([5, 6, 7, 8]);
return bytes;
});
({ trackStudioEvent } = await import("./studioTelemetry"));
trackStudioEvent("session_start");
const second = (await sentEvents())[0]?.properties as Record<string, unknown>;
expect(first.url_route_id).toBe("01020304");
expect(second.url_route_id).toBe("05060708");
expect(second.url_route_id).not.toBe(first.url_route_id);
expect(random).toHaveBeenCalledTimes(2);
});
});
+49 -4
View File
@@ -1,3 +1,4 @@
import { parseProjectHashRoute } from "./projectRouting";
import { resolveStudioDistinctId } from "../telemetry/distinctId";
import { browserTelemetryAllowed } from "../telemetry/policy";
import { canaryEventProperties } from "../telemetry/canary";
@@ -41,6 +42,51 @@ function isEnabled(): boolean {
return browserTelemetryAllowed();
}
function studioRouteKind(hash: string): "project" | "home" | "other" {
if (parseProjectHashRoute(hash)) return "project";
if (hash === "" || hash === "#") return "home";
return "other";
}
const ROUTE_IDS_KEY = "hyperframes-studio:routeIds";
let routeIds: Map<string, string> | undefined;
function isRouteIdEntry(entry: unknown): entry is [string, string] {
return (
Array.isArray(entry) &&
entry.length === 2 &&
typeof entry[0] === "string" &&
typeof entry[1] === "string" &&
/^[0-9a-f]{8}$/.test(entry[1])
);
}
function readRouteIds(): Map<string, string> {
try {
const stored: unknown = JSON.parse(sessionStorage.getItem(ROUTE_IDS_KEY) ?? "[]");
return new Map(Array.isArray(stored) ? stored.filter(isRouteIdEntry) : []);
} catch {
// Storage may be blocked or corrupt. Keep random IDs in memory instead.
return new Map();
}
}
function studioRouteId(hash: string): string {
routeIds ??= readRouteIds();
const route = hash.split("?")[0];
const existing = routeIds.get(route);
if (existing !== undefined) return existing;
const bytes = crypto.getRandomValues(new Uint8Array(4));
const id = Array.from(bytes, (byte) => byte.toString(16).padStart(2, "0")).join("");
routeIds.set(route, id);
try {
sessionStorage.setItem(ROUTE_IDS_KEY, JSON.stringify([...routeIds]));
} catch {
// The in-memory map still preserves equality when storage is unavailable.
}
return id;
}
function getSessionProperties(): EventProperties {
return {
studio_version: typeof __STUDIO_VERSION__ !== "undefined" ? __STUDIO_VERSION__ : "dev",
@@ -57,10 +103,9 @@ function getSessionProperties(): EventProperties {
viewport_width: window.innerWidth,
viewport_height: window.innerHeight,
user_agent: navigator.userAgent,
// Route slug only — drop the query string, which carries the current
// selection (selId / selSelector are the user's own element ids/CSS
// selectors) and other view state we must not send to analytics.
url_hash: location.hash.replace(/#project\//, "").split("?")[0],
// Route names and query parameters are user content. Send only the route kind.
url_hash: studioRouteKind(location.hash),
url_route_id: studioRouteId(location.hash),
};
}
@@ -31,13 +31,13 @@ type ResizeCommit = (
next: { width: number; height: number },
offset?: { x: number; y: number },
restore?: () => void,
) => Promise<void>;
) => Promise<void | import("../utils/previewFeatureUsage").GeometryCommitResult>;
export function resizeSelectionFromAgent(
selection: DomEditSelection,
next: { width: number; height: number },
commit: ResizeCommit,
): Promise<void> {
): Promise<void | import("../utils/previewFeatureUsage").GeometryCommitResult> {
const previous = captureStudioBoxSize(selection.element);
applyStudioBoxSizeDraft(selection.element, next);
return commit(selection, next, undefined, () => {