mirror of
https://github.com/heygen-com/hyperframes.git
synced 2026-10-30 09:16:55 +00:00
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:
@@ -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,
|
||||
|
||||
@@ -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),
|
||||
[],
|
||||
);
|
||||
|
||||
|
||||
@@ -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);
|
||||
|
||||
@@ -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" });
|
||||
});
|
||||
|
||||
@@ -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();
|
||||
}
|
||||
});
|
||||
|
||||
@@ -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",
|
||||
});
|
||||
});
|
||||
|
||||
@@ -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 },
|
||||
) => {
|
||||
|
||||
@@ -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();
|
||||
});
|
||||
});
|
||||
@@ -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);
|
||||
});
|
||||
});
|
||||
|
||||
@@ -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, () => {
|
||||
|
||||
Reference in New Issue
Block a user