@hyperframes/studio 0.8.114 → 0.8.115
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/dist/assets/{hyperframes-player-aXNJKiIg.js → hyperframes-player-C-9N8nk0.js} +1 -1
- package/dist/assets/{index-Bn2RJNTJ.js → index-BoUM6Xqb.js} +1 -1
- package/dist/assets/{index-Dz7doz9l.js → index-CAvZ0AA-.js} +201 -201
- package/dist/assets/{index-C9N-DGjC.js → index-CVuorIMv.js} +1 -1
- package/dist/assets/{index-BYnsJnJV.css → index-t1LH6H5H.css} +1 -1
- package/dist/{chunk-K47E2ZJA.js → chunk-5EKWJZLM.js} +504 -17
- package/dist/chunk-5EKWJZLM.js.map +1 -0
- package/dist/{chunk-VWT7STY3.js → chunk-J6Q572FA.js} +2 -2
- package/dist/{chunk-BIUXP3PG.js → chunk-TXZMMOMA.js} +43 -276
- package/dist/chunk-TXZMMOMA.js.map +1 -0
- package/dist/{domEditingLayers-V67MS4WP.js → domEditingLayers-D7VYFJJD.js} +3 -5
- package/dist/index.d.ts +43 -19
- package/dist/index.html +2 -2
- package/dist/index.js +2939 -2596
- package/dist/index.js.map +1 -1
- package/dist/player.js +2 -2
- package/dist/styles.css +1 -1
- package/package.json +9 -8
- package/src/App.tsx +2 -2
- package/src/components/LinkedSelectionToggle.tsx +3 -2
- package/src/components/dock/Dock.tsx +5 -7
- package/src/components/editor/CanvasContextMenu.tsx +3 -2
- package/src/components/editor/DomEditCropHandles.test.tsx +27 -0
- package/src/components/editor/DomEditCropHandles.tsx +7 -4
- package/src/components/editor/DomEditOverlay.tsx +5 -1
- package/src/components/editor/FileTreeNodes.tsx +13 -10
- package/src/components/editor/MotionPathOverlay.tsx +7 -2
- package/src/components/editor/PropertyPanelEmptyState.tsx +12 -2
- package/src/components/editor/SnapToolbar.test.tsx +100 -1
- package/src/components/editor/SnapToolbar.tsx +43 -5
- package/src/components/editor/anchoredResizeCommitFeedsOffset.test.ts +48 -1
- package/src/components/editor/audioFxTelemetry.ts +2 -3
- package/src/components/editor/commitPreflightHandles.test.tsx +2 -2
- package/src/components/editor/cropResize.ts +4 -3
- package/src/components/editor/domEditingDom.ts +4 -0
- package/src/components/editor/domEditingLayers.ts +1 -5
- package/src/components/editor/manualEdits.ts +1 -0
- package/src/components/editor/manualEditsParsing.ts +7 -0
- package/src/components/editor/marqueeCommit.test.tsx +106 -37
- package/src/components/editor/marqueeCommit.ts +24 -8
- package/src/components/editor/motionPathCommit.test.ts +29 -1
- package/src/components/editor/motionPathCommit.ts +21 -9
- package/src/components/editor/probeSourceElement.test.ts +45 -0
- package/src/components/editor/probeSourceElement.ts +39 -15
- package/src/components/editor/propertyPanelFlatTextSection.tsx +10 -7
- package/src/components/editor/propertyPanelFxAddMenu.tsx +11 -2
- package/src/components/editor/propertyPanelFxCarveModule.tsx +15 -5
- package/src/components/editor/propertyPanelFxNodeOpenBody.tsx +3 -0
- package/src/components/editor/propertyPanelFxNodeParams.tsx +15 -2
- package/src/components/editor/propertyPanelFxNodeRow.tsx +3 -0
- package/src/components/editor/propertyPanelFxPresetRun.tsx +10 -2
- package/src/components/editor/propertyPanelFxSection.test.tsx +56 -0
- package/src/components/editor/propertyPanelFxSection.tsx +30 -28
- package/src/components/editor/propertyPanelTypes.ts +6 -3
- package/src/components/editor/useDomEditNudge.test.tsx +37 -0
- package/src/components/editor/useDomEditNudge.ts +2 -0
- package/src/components/editor/useDomEditOverlayGestures.ts +5 -0
- package/src/components/editor/useDomEditZOrder.ts +12 -2
- package/src/components/nle/useTimelineEditCallbacks.test.tsx +1 -0
- package/src/components/nle/useTimelineEditCallbacks.ts +7 -1
- package/src/components/sidebar/AssetContextMenu.tsx +4 -4
- package/src/components/ui/menuStyle.test.ts +61 -0
- package/src/components/ui/menuStyle.ts +10 -0
- package/src/contexts/DomEditContext.tsx +1 -1
- package/src/contexts/TimelineEditContext.tsx +1 -0
- package/src/domEditExports.test.tsx +9 -1
- package/src/hooks/domEditCommitRunner.ts +1 -1
- package/src/hooks/elementOffsetStager.ts +5 -4
- package/src/hooks/gsapDragCommit.ts +2 -20
- package/src/hooks/gsapDragPositionCommit.ts +18 -3
- package/src/hooks/gsapGestureOutcome.test.ts +161 -0
- package/src/hooks/gsapGestureOutcome.ts +64 -0
- package/src/hooks/gsapKeyframeCommit.ts +3 -12
- package/src/hooks/gsapResizeIntercept.test.ts +33 -0
- package/src/hooks/gsapResizeIntercept.ts +1 -0
- package/src/hooks/gsapScriptCommitTypes.ts +5 -0
- package/src/hooks/gsapShared.writeTarget.test.ts +20 -0
- package/src/hooks/keyframeCacheAstLoad.ts +6 -5
- package/src/hooks/plainRotation.ts +3 -2
- package/src/hooks/previewPromotionSelection.test.tsx +1 -1
- package/src/hooks/refreshTimelineRowText.test.ts +65 -0
- package/src/hooks/refreshTimelineRowText.ts +29 -0
- package/src/hooks/useAnimatedPropertyCommit.test.tsx +46 -5
- package/src/hooks/useAnimatedPropertyCommit.ts +2 -2
- package/src/hooks/useBlockedTimelineEditToast.ts +12 -3
- package/src/hooks/useDomEditPositionPatchCommit.ts +18 -23
- package/src/hooks/useDomEditTextCommits.test.tsx +18 -0
- package/src/hooks/useDomEditTextCommits.ts +5 -1
- package/src/hooks/useDomEditWiring.ts +4 -2
- package/src/hooks/useDomGeometryCommit.test.tsx +25 -6
- package/src/hooks/useDomGeometryCommit.ts +7 -4
- package/src/hooks/useDomGeometryCommits.test.tsx +1 -0
- package/src/hooks/useDomGeometryCommits.ts +3 -2
- package/src/hooks/useDomSelection.test.ts +26 -0
- package/src/hooks/useDomSelection.ts +10 -3
- package/src/hooks/useDomSelectionTypes.ts +6 -1
- package/src/hooks/useEnableKeyframes.test.ts +2 -2
- package/src/hooks/useEnableKeyframes.ts +5 -3
- package/src/hooks/useExternalFileChangeCoordinator.test.tsx +102 -0
- package/src/hooks/useExternalFileChangeCoordinator.ts +31 -6
- package/src/hooks/useGestureCommit.test.tsx +225 -55
- package/src/hooks/useGestureCommit.ts +86 -55
- package/src/hooks/useGsapAwareEditing.test.tsx +11 -6
- package/src/hooks/useGsapAwareEditing.ts +96 -195
- package/src/hooks/useGsapAwareGroupMove.ts +179 -0
- package/src/hooks/useGsapKeyframeOps.sdkUsage.test.tsx +114 -0
- package/src/hooks/useGsapKeyframeOps.ts +36 -10
- package/src/hooks/useGsapScriptCommits.test.tsx +57 -0
- package/src/hooks/useGsapScriptCommits.ts +3 -0
- package/src/hooks/useGsapSelectionHandlers.test.tsx +1 -1
- package/src/hooks/useGsapSelectionHandlers.ts +24 -18
- package/src/hooks/useGsapTweenCache.ts +18 -12
- package/src/hooks/usePopulateKeyframeCacheForFile.test.tsx +76 -2
- package/src/hooks/usePreviewInteraction.test.ts +21 -0
- package/src/hooks/usePreviewInteraction.ts +2 -0
- package/src/hooks/useRenderClipContent.test.ts +18 -0
- package/src/hooks/useRenderClipContent.ts +4 -5
- package/src/hooks/useTimelineDeleteOps.test.tsx +30 -22
- package/src/hooks/useTimelineDeleteOps.ts +21 -27
- package/src/hooks/useTimelineEditing.test.tsx +1 -1
- package/src/player/components/AutomationSelectionMenu.tsx +3 -2
- package/src/player/components/ClipContextMenu.test.tsx +73 -0
- package/src/player/components/ClipContextMenu.tsx +33 -31
- package/src/player/components/KeyframeDiamondContextMenu.tsx +8 -9
- package/src/player/components/OutOfSyncBadge.tsx +5 -5
- package/src/player/components/TextClipContent.test.ts +19 -0
- package/src/player/components/TextClipContent.tsx +27 -0
- package/src/player/components/TrackGapContextMenu.tsx +3 -6
- package/src/player/components/clipMenuToolItems.test.tsx +23 -0
- package/src/player/components/clipMenuToolItems.tsx +13 -9
- package/src/player/components/timelineCallbacks.ts +1 -0
- package/src/player/components/timelineClipDragGestureLifecycle.ts +1 -1
- package/src/player/components/timelineClipGestureHandlers.test.ts +94 -0
- package/src/player/components/timelineClipGestureHandlers.ts +20 -2
- package/src/player/components/timelineEditing.ts +3 -1
- package/src/player/components/timelineLinkSelection.ts +35 -1
- package/src/player/components/useTimelineClipDrag.keyboard.test.tsx +14 -0
- package/src/player/components/useTimelineClipDrag.ts +6 -7
- package/src/player/hooks/timelinePlayerSync.test.ts +6 -0
- package/src/player/hooks/timelinePlayerSync.ts +6 -1
- package/src/player/lib/timelineElementHelpers.test.ts +87 -1
- package/src/player/lib/timelineElementHelpers.ts +6 -15
- package/src/player/lib/timelineText.ts +40 -0
- package/src/player/store/timelineElement.ts +12 -0
- package/src/styles/theme.css +3 -0
- package/src/utils/keyframeUsage.ts +79 -0
- package/src/utils/previewFeatureUsage.test.ts +32 -0
- package/src/utils/previewFeatureUsage.ts +46 -0
- package/src/utils/studioTelemetry.test.ts +107 -0
- package/src/utils/studioTelemetry.ts +49 -4
- package/src/webmcp/StudioAgentTools.tsx +2 -2
- package/dist/chunk-BIUXP3PG.js.map +0 -1
- package/dist/chunk-K47E2ZJA.js.map +0 -1
- /package/dist/{chunk-VWT7STY3.js.map → chunk-J6Q572FA.js.map} +0 -0
- /package/dist/{domEditingLayers-V67MS4WP.js.map → domEditingLayers-D7VYFJJD.js.map} +0 -0
|
@@ -1,6 +1,8 @@
|
|
|
1
1
|
// @vitest-environment happy-dom
|
|
2
2
|
|
|
3
3
|
import { afterEach, describe, expect, it, vi } from "vitest";
|
|
4
|
+
import { trackStudioEvent } from "../../utils/studioTelemetry";
|
|
5
|
+
vi.mock("../../utils/studioTelemetry", () => ({ trackStudioEvent: vi.fn() }));
|
|
4
6
|
import { DomEditSaveQueueOpenError } from "../../utils/domEditSaveQueue";
|
|
5
7
|
import type { DomEditSelection } from "./domEditing";
|
|
6
8
|
import type { GestureState, UseDomEditOverlayGesturesOptions } from "./domEditOverlayGestures";
|
|
@@ -178,7 +180,7 @@ function buildHarness(
|
|
|
178
180
|
};
|
|
179
181
|
|
|
180
182
|
const handlers = createDomEditOverlayGestureHandlers(opts);
|
|
181
|
-
return { handlers, commits, selection };
|
|
183
|
+
return { handlers, commits, selection, opts };
|
|
182
184
|
}
|
|
183
185
|
|
|
184
186
|
type OverlayRectLike = {
|
|
@@ -287,3 +289,48 @@ describe("anchored corner resize — the release commit feeds the center-pin off
|
|
|
287
289
|
expect(consoleError).toHaveBeenCalledWith("resize commit failed", failure);
|
|
288
290
|
});
|
|
289
291
|
});
|
|
292
|
+
|
|
293
|
+
describe("resize usage at pointer release", () => {
|
|
294
|
+
it("counts one completed resize despite multiple pointer moves", async () => {
|
|
295
|
+
vi.mocked(trackStudioEvent).mockClear();
|
|
296
|
+
const h = buildHarness(async () => ({ ok: true, changed: true }));
|
|
297
|
+
await finishResize(h.handlers);
|
|
298
|
+
expect(trackStudioEvent).toHaveBeenCalledExactlyOnceWith("feature_used", {
|
|
299
|
+
feature: "resize",
|
|
300
|
+
surface: "preview",
|
|
301
|
+
method: "drag",
|
|
302
|
+
});
|
|
303
|
+
});
|
|
304
|
+
it.each([undefined, { ok: true as const, changed: false }])(
|
|
305
|
+
"does not count an unchanged or unconfirmed resize",
|
|
306
|
+
async (result) => {
|
|
307
|
+
vi.mocked(trackStudioEvent).mockClear();
|
|
308
|
+
const h = buildHarness(async () => result);
|
|
309
|
+
await finishResize(h.handlers);
|
|
310
|
+
expect(trackStudioEvent).not.toHaveBeenCalled();
|
|
311
|
+
},
|
|
312
|
+
);
|
|
313
|
+
});
|
|
314
|
+
|
|
315
|
+
describe("move and rotation usage", () => {
|
|
316
|
+
it.each(["drag", "rotate"] as const)("counts %s once after pointer release", async (kind) => {
|
|
317
|
+
vi.mocked(trackStudioEvent).mockClear();
|
|
318
|
+
const h = buildHarness();
|
|
319
|
+
h.selection.capabilities.canApplyManualRotation = true;
|
|
320
|
+
const saved = vi.fn(async () => ({ ok: true as const, changed: true }));
|
|
321
|
+
h.opts.onPathOffsetCommitRef.current = saved;
|
|
322
|
+
h.opts.onRotationCommitRef.current = saved;
|
|
323
|
+
h.handlers.startGesture(kind, evt(200, 50));
|
|
324
|
+
h.handlers.onPointerMove(evt(150, 120));
|
|
325
|
+
h.handlers.onPointerMove(evt(100, 150));
|
|
326
|
+
expect(trackStudioEvent).not.toHaveBeenCalled();
|
|
327
|
+
h.handlers.onPointerUp(evt(100, 150));
|
|
328
|
+
await Promise.resolve();
|
|
329
|
+
expect(saved).toHaveBeenCalledTimes(1);
|
|
330
|
+
expect(trackStudioEvent).toHaveBeenCalledExactlyOnceWith("feature_used", {
|
|
331
|
+
feature: kind === "drag" ? "move" : "rotate",
|
|
332
|
+
surface: "preview",
|
|
333
|
+
method: "drag",
|
|
334
|
+
});
|
|
335
|
+
});
|
|
336
|
+
});
|
|
@@ -10,9 +10,8 @@
|
|
|
10
10
|
* filenames, not composition paths, not chain JSON. Every property below is
|
|
11
11
|
* either a fixed identifier from our own catalogue (a preset id, an effect
|
|
12
12
|
* type, a parameter key), a number, or a boolean. `studioTelemetry.ts` already
|
|
13
|
-
*
|
|
14
|
-
*
|
|
15
|
-
* the same line.
|
|
13
|
+
* sends only a route kind in `url_hash` for the same reason. The rack sees
|
|
14
|
+
* the same class of data and must hold the same line.
|
|
16
15
|
*
|
|
17
16
|
* The second rule is about volume: **commit, not preview.** Every control in
|
|
18
17
|
* 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", () => {
|
|
|
287
287
|
{ x: 0, y: 0 },
|
|
288
288
|
commitList,
|
|
289
289
|
preview,
|
|
290
|
-
vi.fn(),
|
|
290
|
+
vi.fn(async () => undefined),
|
|
291
291
|
undefined,
|
|
292
292
|
preflightOnly,
|
|
293
293
|
);
|
|
@@ -401,7 +401,7 @@ describe("handles follow what Studio would commit", () => {
|
|
|
401
401
|
{ x: 0, y: 0 },
|
|
402
402
|
[stagger],
|
|
403
403
|
preview,
|
|
404
|
-
vi.fn(),
|
|
404
|
+
vi.fn(async () => undefined),
|
|
405
405
|
undefined,
|
|
406
406
|
groupPreflight,
|
|
407
407
|
);
|
|
@@ -1,3 +1,4 @@
|
|
|
1
|
+
import type { DomEditPersistOutcome } from "../../hooks/domEditCommitTypes";
|
|
1
2
|
import type { PatchOperation } from "../../utils/sourcePatcher";
|
|
2
3
|
import type { CommitMutation } from "../../hooks/gsapScriptCommitTypes";
|
|
3
4
|
import type { DomEditSelection } from "./domEditingTypes";
|
|
@@ -94,7 +95,7 @@ type PatchCommit = (
|
|
|
94
95
|
selection: DomEditSelection,
|
|
95
96
|
patches: PatchOperation[],
|
|
96
97
|
options: { label: string; coalesceKey: string; coalesceMs?: number },
|
|
97
|
-
) => Promise<
|
|
98
|
+
) => Promise<DomEditPersistOutcome | undefined>;
|
|
98
99
|
|
|
99
100
|
/** Stage the crop and save it under the resize's undo key, taking it back off the element if that fails. */
|
|
100
101
|
export async function saveCropResize(
|
|
@@ -102,11 +103,11 @@ export async function saveCropResize(
|
|
|
102
103
|
selection: DomEditSelection,
|
|
103
104
|
commit: PatchCommit,
|
|
104
105
|
coalesceKey: string,
|
|
105
|
-
): Promise<
|
|
106
|
+
): Promise<DomEditPersistOutcome | undefined> {
|
|
106
107
|
const crop = stage();
|
|
107
108
|
if (!crop) return;
|
|
108
109
|
try {
|
|
109
|
-
await commit(selection, [crop.patch], {
|
|
110
|
+
return await commit(selection, [crop.patch], {
|
|
110
111
|
label: "Resize layer",
|
|
111
112
|
coalesceKey,
|
|
112
113
|
coalesceMs: Number.POSITIVE_INFINITY,
|
|
@@ -30,6 +30,10 @@ export function isTextBearingTag(tagName: string): boolean {
|
|
|
30
30
|
return ["div", "span", "p", "strong", "h1", "h2", "h3", "h4", "h5", "h6"].includes(tagName);
|
|
31
31
|
}
|
|
32
32
|
|
|
33
|
+
export function isEditableTextLeaf(el: HTMLElement): boolean {
|
|
34
|
+
return isTextBearingTag(el.tagName.toLowerCase()) && el.children.length === 0;
|
|
35
|
+
}
|
|
36
|
+
|
|
33
37
|
/** Does this node render AT ALL, ignoring what it inherits? Sole owner of the
|
|
34
38
|
* rule; the walk below only decides which nodes to ask it about. */
|
|
35
39
|
function elementRendersItself(win: Window, el: HTMLElement): boolean {
|
|
@@ -23,8 +23,8 @@ import {
|
|
|
23
23
|
getInlineStyles,
|
|
24
24
|
getSelectorIndex,
|
|
25
25
|
getSourceFileForElement,
|
|
26
|
+
isEditableTextLeaf,
|
|
26
27
|
isHtmlElement,
|
|
27
|
-
isTextBearingTag,
|
|
28
28
|
} from "./domEditingDom";
|
|
29
29
|
import {
|
|
30
30
|
findElementForSelection,
|
|
@@ -35,10 +35,6 @@ import { isCompositionRootLayer } from "./domEditingRootLayer";
|
|
|
35
35
|
import { withSelectorIndexPass } from "../../utils/sourceScopedSelectorIndex";
|
|
36
36
|
import { type DomEditLayerWalkCache, readDomEditLayerWalkEntry } from "./domEditLayerWalkCache";
|
|
37
37
|
|
|
38
|
-
export function isEditableTextLeaf(el: HTMLElement): boolean {
|
|
39
|
-
return isTextBearingTag(el.tagName.toLowerCase()) && el.children.length === 0;
|
|
40
|
-
}
|
|
41
|
-
|
|
42
38
|
function sameTagChildIndex(el: HTMLElement): number {
|
|
43
39
|
let index = 0;
|
|
44
40
|
let sibling = el.previousElementSibling;
|
|
@@ -43,3 +43,10 @@ export function readFileChangeAffectedCompositions(payload: unknown): readonly s
|
|
|
43
43
|
if (!Array.isArray(value) || !value.every((path) => typeof path === "string")) return null;
|
|
44
44
|
return value.map(normalizeStudioFileChangePath);
|
|
45
45
|
}
|
|
46
|
+
|
|
47
|
+
export function mergeFileChangeAffectedCompositions(waiting: unknown, incoming: unknown): unknown {
|
|
48
|
+
const before = readFileChangeAffectedCompositions(waiting);
|
|
49
|
+
const after = readFileChangeAffectedCompositions(incoming);
|
|
50
|
+
const merged = before && after ? [...new Set([...before, ...after])] : null;
|
|
51
|
+
return { ...asPayloadRecord(incoming), affectedCompositions: merged };
|
|
52
|
+
}
|
|
@@ -1,10 +1,14 @@
|
|
|
1
1
|
// @vitest-environment happy-dom
|
|
2
|
-
import { act, useRef } from "react";
|
|
2
|
+
import { act, useRef, type RefObject } from "react";
|
|
3
3
|
import type { Root } from "react-dom/client";
|
|
4
4
|
import { afterEach, beforeEach, expect, it, vi } from "vitest";
|
|
5
5
|
import { installReactActEnvironment, mountReactHarness } from "../../hooks/domSelectionTestHarness";
|
|
6
|
+
import { trackStudioEvent } from "../../utils/studioTelemetry";
|
|
7
|
+
import type { DomSelectionResult } from "../../hooks/useDomSelectionTypes";
|
|
6
8
|
import { useMarqueeGestures } from "./marqueeCommit";
|
|
7
9
|
|
|
10
|
+
vi.mock("../../utils/studioTelemetry", () => ({ trackStudioEvent: vi.fn() }));
|
|
11
|
+
|
|
8
12
|
// Layout stands in for a real preview: every element is visible and sits at the band's start.
|
|
9
13
|
vi.mock("./domEditingElement", async (importOriginal) => ({
|
|
10
14
|
...(await importOriginal<typeof import("./domEditingElement")>()),
|
|
@@ -40,9 +44,53 @@ function Overlay() {
|
|
|
40
44
|
);
|
|
41
45
|
}
|
|
42
46
|
|
|
43
|
-
|
|
47
|
+
function MarqueeSurface({
|
|
48
|
+
overlayRef,
|
|
49
|
+
marquee,
|
|
50
|
+
}: {
|
|
51
|
+
overlayRef: RefObject<HTMLDivElement | null>;
|
|
52
|
+
marquee: ReturnType<typeof useMarqueeGestures>;
|
|
53
|
+
}) {
|
|
54
|
+
return (
|
|
55
|
+
<div
|
|
56
|
+
ref={overlayRef}
|
|
57
|
+
data-band-overlay
|
|
58
|
+
onPointerDown={marquee.begin}
|
|
59
|
+
onPointerMove={marquee.onPointerMove}
|
|
60
|
+
onPointerUp={marquee.onPointerUp}
|
|
61
|
+
/>
|
|
62
|
+
);
|
|
63
|
+
}
|
|
64
|
+
|
|
65
|
+
function TestMarquee({
|
|
66
|
+
iframeRef,
|
|
67
|
+
onSelect,
|
|
68
|
+
resolveHits,
|
|
69
|
+
}: {
|
|
70
|
+
iframeRef: RefObject<HTMLIFrameElement | null>;
|
|
71
|
+
onSelect: (selections: HTMLElement[], additive: boolean) => DomSelectionResult | void;
|
|
72
|
+
resolveHits: (elements: HTMLElement[]) => HTMLElement[] | Promise<HTMLElement[]>;
|
|
73
|
+
}) {
|
|
74
|
+
const overlayRef = useRef<HTMLDivElement>(null);
|
|
75
|
+
const marquee = useMarqueeGestures({
|
|
76
|
+
iframeRef,
|
|
77
|
+
overlayRef,
|
|
78
|
+
activeCompositionPathRef: useRef<string | null>("index.html"),
|
|
79
|
+
onMarqueeSelectRef: { current: onSelect },
|
|
80
|
+
resolveHits,
|
|
81
|
+
});
|
|
82
|
+
return <MarqueeSurface overlayRef={overlayRef} marquee={marquee} />;
|
|
83
|
+
}
|
|
84
|
+
|
|
85
|
+
const pointer = (
|
|
86
|
+
type: string,
|
|
87
|
+
clientX: number,
|
|
88
|
+
clientY: number,
|
|
89
|
+
selector = "[data-overlay]",
|
|
90
|
+
shiftKey = false,
|
|
91
|
+
) =>
|
|
44
92
|
act(() => {
|
|
45
|
-
document.querySelector(
|
|
93
|
+
document.querySelector(selector)!.dispatchEvent(
|
|
46
94
|
new PointerEvent(type, {
|
|
47
95
|
bubbles: true,
|
|
48
96
|
buttons: type === "pointerup" ? 0 : 1,
|
|
@@ -50,6 +98,7 @@ const pointer = (type: string, clientX: number, clientY: number) =>
|
|
|
50
98
|
pointerId: 1,
|
|
51
99
|
clientX,
|
|
52
100
|
clientY,
|
|
101
|
+
shiftKey,
|
|
53
102
|
}),
|
|
54
103
|
);
|
|
55
104
|
});
|
|
@@ -68,6 +117,7 @@ const hostListener = (e: KeyboardEvent) => {
|
|
|
68
117
|
};
|
|
69
118
|
|
|
70
119
|
beforeEach(() => {
|
|
120
|
+
vi.clearAllMocks();
|
|
71
121
|
hostEscapes = [];
|
|
72
122
|
window.addEventListener("keydown", hostListener);
|
|
73
123
|
root = mountReactHarness(<Overlay />);
|
|
@@ -128,42 +178,17 @@ it("a reload promoted mid-band selects from the preview on screen, not the retir
|
|
|
128
178
|
const [retired, live] = [preview(), preview()];
|
|
129
179
|
const picked: HTMLElement[][] = [];
|
|
130
180
|
const iframeRef = { current: retired as HTMLIFrameElement | null };
|
|
131
|
-
|
|
132
|
-
|
|
133
|
-
|
|
134
|
-
|
|
135
|
-
|
|
136
|
-
activeCompositionPathRef: useRef<string | null>("index.html"),
|
|
137
|
-
onMarqueeSelectRef: useRef(() => undefined),
|
|
138
|
-
resolveHits: async (elements: HTMLElement[]) => {
|
|
181
|
+
const band = mountReactHarness(
|
|
182
|
+
<TestMarquee
|
|
183
|
+
iframeRef={iframeRef}
|
|
184
|
+
onSelect={() => undefined}
|
|
185
|
+
resolveHits={async (elements) => {
|
|
139
186
|
picked.push(elements);
|
|
140
187
|
return [];
|
|
141
|
-
}
|
|
142
|
-
|
|
143
|
-
|
|
144
|
-
|
|
145
|
-
ref={overlayRef}
|
|
146
|
-
data-band-overlay
|
|
147
|
-
onPointerDown={marquee.begin}
|
|
148
|
-
onPointerMove={marquee.onPointerMove}
|
|
149
|
-
onPointerUp={marquee.onPointerUp}
|
|
150
|
-
/>
|
|
151
|
-
);
|
|
152
|
-
}
|
|
153
|
-
const band = mountReactHarness(<Band />);
|
|
154
|
-
const fire = (type: string, x: number, y: number) =>
|
|
155
|
-
act(() => {
|
|
156
|
-
document.querySelector("[data-band-overlay]")!.dispatchEvent(
|
|
157
|
-
new PointerEvent(type, {
|
|
158
|
-
bubbles: true,
|
|
159
|
-
button: 0,
|
|
160
|
-
buttons: type === "pointerup" ? 0 : 1,
|
|
161
|
-
pointerId: 1,
|
|
162
|
-
clientX: x,
|
|
163
|
-
clientY: y,
|
|
164
|
-
}),
|
|
165
|
-
);
|
|
166
|
-
});
|
|
188
|
+
}}
|
|
189
|
+
/>,
|
|
190
|
+
);
|
|
191
|
+
const fire = (type: string, x: number, y: number) => pointer(type, x, y, "[data-band-overlay]");
|
|
167
192
|
try {
|
|
168
193
|
fire("pointerdown", 0, 0);
|
|
169
194
|
fire("pointermove", 120, 90);
|
|
@@ -175,3 +200,47 @@ it("a reload promoted mid-band selects from the preview on screen, not the retir
|
|
|
175
200
|
act(() => band.unmount());
|
|
176
201
|
}
|
|
177
202
|
});
|
|
203
|
+
|
|
204
|
+
it.each([
|
|
205
|
+
[2, false, 2, 0],
|
|
206
|
+
[1, true, 2, 1],
|
|
207
|
+
[2, true, 2, 1],
|
|
208
|
+
[1, true, 1, 0],
|
|
209
|
+
])(
|
|
210
|
+
"counts marquee receipt: %s hits, changed=%s, group=%s",
|
|
211
|
+
async (hits, changed, count, events) => {
|
|
212
|
+
const iframe = document.createElement("iframe");
|
|
213
|
+
const doc = document.implementation.createHTMLDocument("preview");
|
|
214
|
+
doc.body.innerHTML =
|
|
215
|
+
'<div data-composition-id="main"><h1 id="title">Title</h1><p id="subtitle">Subtitle</p></div>';
|
|
216
|
+
Object.defineProperty(iframe, "contentDocument", { value: doc });
|
|
217
|
+
const apply = vi.fn((_selections: HTMLElement[], _additive: boolean) => ({ changed, count }));
|
|
218
|
+
const band = mountReactHarness(
|
|
219
|
+
<TestMarquee
|
|
220
|
+
iframeRef={{ current: iframe }}
|
|
221
|
+
onSelect={apply}
|
|
222
|
+
resolveHits={(elements) => elements.slice(0, hits)}
|
|
223
|
+
/>,
|
|
224
|
+
);
|
|
225
|
+
const fire = (type: string, x: number, y: number) =>
|
|
226
|
+
pointer(type, x, y, "[data-band-overlay]", true);
|
|
227
|
+
try {
|
|
228
|
+
fire("pointerdown", 0, 0);
|
|
229
|
+
fire("pointermove", 120, 90);
|
|
230
|
+
await act(async () => {
|
|
231
|
+
fire("pointerup", 120, 90);
|
|
232
|
+
});
|
|
233
|
+
expect(apply).toHaveBeenCalledTimes(1);
|
|
234
|
+
expect(apply.mock.calls[0]![0]).toHaveLength(hits);
|
|
235
|
+
expect(trackStudioEvent).toHaveBeenCalledTimes(events);
|
|
236
|
+
if (events)
|
|
237
|
+
expect(trackStudioEvent).toHaveBeenCalledWith("feature_used", {
|
|
238
|
+
feature: "multi_select",
|
|
239
|
+
surface: "preview",
|
|
240
|
+
method: "drag",
|
|
241
|
+
});
|
|
242
|
+
} finally {
|
|
243
|
+
act(() => band.unmount());
|
|
244
|
+
}
|
|
245
|
+
},
|
|
246
|
+
);
|
|
@@ -1,3 +1,5 @@
|
|
|
1
|
+
import type { DomSelectionResult } from "../../hooks/useDomSelectionTypes";
|
|
2
|
+
import { trackPreviewFeatureUsed } from "../../utils/previewFeatureUsage";
|
|
1
3
|
// fallow-ignore-file code-duplication
|
|
2
4
|
import { useCallback, useEffect, useRef, useState } from "react";
|
|
3
5
|
import type { DomEditSelection } from "./domEditing";
|
|
@@ -18,6 +20,16 @@ interface MarqueeState {
|
|
|
18
20
|
target: Element;
|
|
19
21
|
}
|
|
20
22
|
|
|
23
|
+
function currentMarqueeCandidates(
|
|
24
|
+
cached: MarqueeHit[] | null,
|
|
25
|
+
iframe: HTMLIFrameElement,
|
|
26
|
+
overlay: HTMLDivElement,
|
|
27
|
+
path: string,
|
|
28
|
+
): MarqueeHit[] {
|
|
29
|
+
if (cached?.every((hit) => hit.element.ownerDocument === iframe.contentDocument)) return cached;
|
|
30
|
+
return collectMarqueeCandidates(iframe, overlay, path);
|
|
31
|
+
}
|
|
32
|
+
|
|
21
33
|
const MARQUEE_THRESHOLD_PX = 4;
|
|
22
34
|
|
|
23
35
|
interface MarqueeHit {
|
|
@@ -103,7 +115,9 @@ export interface MarqueeGesturesDeps<T = DomEditSelection> {
|
|
|
103
115
|
iframeRef: React.RefObject<HTMLIFrameElement | null>;
|
|
104
116
|
overlayRef: React.RefObject<HTMLDivElement | null>;
|
|
105
117
|
activeCompositionPathRef: React.RefObject<string | null>;
|
|
106
|
-
onMarqueeSelectRef: React.RefObject<
|
|
118
|
+
onMarqueeSelectRef: React.RefObject<
|
|
119
|
+
((selections: T[], additive: boolean) => DomSelectionResult | void) | undefined
|
|
120
|
+
>;
|
|
107
121
|
/** Turns the elements a drag touched into picks; without it, Studio's edit selections. */
|
|
108
122
|
resolveHits?: (elements: HTMLElement[]) => T[] | Promise<T[]>;
|
|
109
123
|
selectionRef?: React.RefObject<DomEditSelection | null>;
|
|
@@ -127,6 +141,10 @@ export interface MarqueeGestures {
|
|
|
127
141
|
onPointerCancel: () => void;
|
|
128
142
|
}
|
|
129
143
|
|
|
144
|
+
function trackMarqueeSelection(result: DomSelectionResult | void): void {
|
|
145
|
+
if (result?.changed && result.count > 1) trackPreviewFeatureUsed("multi_select", "drag");
|
|
146
|
+
}
|
|
147
|
+
|
|
130
148
|
function releaseCapture(m: MarqueeState): void {
|
|
131
149
|
try {
|
|
132
150
|
m.target.releasePointerCapture(m.pointerId);
|
|
@@ -160,18 +178,16 @@ export function useMarqueeGestures<T>(deps: MarqueeGesturesDeps<T>): MarqueeGest
|
|
|
160
178
|
const overlay = deps.overlayRef.current;
|
|
161
179
|
if (!iframe || !overlay || !deps.onMarqueeSelectRef.current) return;
|
|
162
180
|
const acp = deps.activeCompositionPathRef.current ?? "index.html";
|
|
163
|
-
const
|
|
164
|
-
const measuredOnScreen = cached?.every(
|
|
165
|
-
(hit) => hit.element.ownerDocument === iframe.contentDocument,
|
|
166
|
-
);
|
|
167
|
-
const candidates =
|
|
168
|
-
cached && measuredOnScreen ? cached : collectMarqueeCandidates(iframe, overlay, acp);
|
|
181
|
+
const candidates = currentMarqueeCandidates(candidatesRef.current, iframe, overlay, acp);
|
|
169
182
|
const elements = hitsWithin(rect, candidates).map((hit) => hit.element);
|
|
170
183
|
const resolveHits = deps.resolveHits;
|
|
171
184
|
const picks = resolveHits
|
|
172
185
|
? await resolveHits(elements)
|
|
173
186
|
: ((await resolveDomEditSelections(elements, acp)) as T[]);
|
|
174
|
-
deps.onMarqueeSelectRef.current
|
|
187
|
+
const applySelection = deps.onMarqueeSelectRef.current;
|
|
188
|
+
if (!applySelection) return;
|
|
189
|
+
const result = applySelection(picks, additive);
|
|
190
|
+
trackMarqueeSelection(result);
|
|
175
191
|
},
|
|
176
192
|
[
|
|
177
193
|
deps.iframeRef,
|
|
@@ -1,5 +1,5 @@
|
|
|
1
1
|
// @vitest-environment jsdom
|
|
2
|
-
import { describe, it, expect, vi } from "vitest";
|
|
2
|
+
import { beforeEach, describe, it, expect, vi } from "vitest";
|
|
3
3
|
import type { GsapAnimation } from "@hyperframes/parsers/gsap-parser";
|
|
4
4
|
import { editableAnimationId } from "./motionPathSelection";
|
|
5
5
|
import {
|
|
@@ -148,3 +148,31 @@ describe("commitCreatePath", () => {
|
|
|
148
148
|
);
|
|
149
149
|
});
|
|
150
150
|
});
|
|
151
|
+
|
|
152
|
+
vi.mock("../../utils/studioTelemetry", () => ({ trackStudioEvent: vi.fn() }));
|
|
153
|
+
import { trackStudioEvent } from "../../utils/studioTelemetry";
|
|
154
|
+
import type { CommitFn } from "./motionPathCommit";
|
|
155
|
+
beforeEach(() => vi.clearAllMocks());
|
|
156
|
+
describe("motion path usage at the writer", () => {
|
|
157
|
+
it("counts a changed drag once without coordinates or target ids", async () => {
|
|
158
|
+
const commit: CommitFn = async (_mutation, options) => {
|
|
159
|
+
options.onResult?.({ ok: true, changed: true });
|
|
160
|
+
};
|
|
161
|
+
await commitNode({ type: "keyframe", pct: 50 }, 120, 30, "private-target", commit);
|
|
162
|
+
expect(trackStudioEvent).toHaveBeenCalledExactlyOnceWith("feature_used", {
|
|
163
|
+
feature: "motion_path",
|
|
164
|
+
surface: "preview",
|
|
165
|
+
method: "drag",
|
|
166
|
+
});
|
|
167
|
+
});
|
|
168
|
+
it.each([undefined, { ok: true }, { ok: true, changed: false }, { ok: false, changed: true }])(
|
|
169
|
+
"does not count absent or unsuccessful results (%j)",
|
|
170
|
+
async (result) => {
|
|
171
|
+
const commit: CommitFn = async (_mutation, options) => {
|
|
172
|
+
if (result) options.onResult?.(result);
|
|
173
|
+
};
|
|
174
|
+
await commitAddWaypoint("private-target", 1, 20, 40, commit);
|
|
175
|
+
expect(trackStudioEvent).not.toHaveBeenCalled();
|
|
176
|
+
},
|
|
177
|
+
);
|
|
178
|
+
});
|
|
@@ -1,3 +1,5 @@
|
|
|
1
|
+
import type { CommitMutationOptions } from "../../hooks/gsapScriptCommitTypes";
|
|
2
|
+
import { trackPreviewFeatureUsed, type PreviewMethod } from "../../utils/previewFeatureUsage";
|
|
1
3
|
/**
|
|
2
4
|
* Commit helpers for the motion-path overlay. Each maps a canvas gesture to a
|
|
3
5
|
* GSAP source mutation routed through the (selection-bound) commit facade, which
|
|
@@ -7,11 +9,21 @@ import type { MotionNodeRef } from "./motionPathGeometry";
|
|
|
7
9
|
|
|
8
10
|
export type CommitFn = (
|
|
9
11
|
mutation: Record<string, unknown>,
|
|
10
|
-
options:
|
|
12
|
+
options: CommitMutationOptions,
|
|
11
13
|
) => Promise<void>;
|
|
12
14
|
|
|
13
15
|
const NEW_PATH_DURATION = 1.5;
|
|
14
16
|
|
|
17
|
+
function motionPathCommitOptions(label: string, method: PreviewMethod): CommitMutationOptions {
|
|
18
|
+
return {
|
|
19
|
+
label,
|
|
20
|
+
softReload: true,
|
|
21
|
+
onResult: (result) => {
|
|
22
|
+
if (result.ok && result.changed === true) trackPreviewFeatureUsed("motion_path", method);
|
|
23
|
+
},
|
|
24
|
+
};
|
|
25
|
+
}
|
|
26
|
+
|
|
15
27
|
export function commitNode(
|
|
16
28
|
ref: MotionNodeRef,
|
|
17
29
|
x: number,
|
|
@@ -23,10 +35,10 @@ export function commitNode(
|
|
|
23
35
|
ref.type === "keyframe"
|
|
24
36
|
? { type: "update-keyframe", animationId, percentage: ref.pct, properties: { x, y } }
|
|
25
37
|
: { type: "update-motion-path-point", animationId, pointIndex: ref.index, x, y };
|
|
26
|
-
return commit(
|
|
27
|
-
|
|
28
|
-
|
|
29
|
-
|
|
38
|
+
return commit(
|
|
39
|
+
mutation,
|
|
40
|
+
motionPathCommitOptions(ref.type === "keyframe" ? "Move keyframe" : "Move waypoint", "drag"),
|
|
41
|
+
);
|
|
30
42
|
}
|
|
31
43
|
|
|
32
44
|
export function commitAddWaypoint(
|
|
@@ -38,7 +50,7 @@ export function commitAddWaypoint(
|
|
|
38
50
|
): Promise<void> {
|
|
39
51
|
return commit(
|
|
40
52
|
{ type: "add-motion-path-point", animationId, index, x, y },
|
|
41
|
-
|
|
53
|
+
motionPathCommitOptions("Add waypoint", "button"),
|
|
42
54
|
);
|
|
43
55
|
}
|
|
44
56
|
|
|
@@ -54,7 +66,7 @@ export function commitAddKeyframe(
|
|
|
54
66
|
// at that pct) and converts a flat tween to keyframes form when needed.
|
|
55
67
|
return commit(
|
|
56
68
|
{ type: "add-keyframe", animationId, percentage, properties: { x, y } },
|
|
57
|
-
|
|
69
|
+
motionPathCommitOptions("Add keyframe", "button"),
|
|
58
70
|
);
|
|
59
71
|
}
|
|
60
72
|
|
|
@@ -65,7 +77,7 @@ export function commitRemoveWaypoint(
|
|
|
65
77
|
): Promise<void> {
|
|
66
78
|
return commit(
|
|
67
79
|
{ type: "remove-motion-path-point", animationId, index },
|
|
68
|
-
|
|
80
|
+
motionPathCommitOptions("Remove waypoint", "button"),
|
|
69
81
|
);
|
|
70
82
|
}
|
|
71
83
|
|
|
@@ -78,6 +90,6 @@ export function commitCreatePath(
|
|
|
78
90
|
): Promise<void> {
|
|
79
91
|
return commit(
|
|
80
92
|
{ type: "add-motion-path", targetSelector, position, duration: NEW_PATH_DURATION, x, y },
|
|
81
|
-
|
|
93
|
+
motionPathCommitOptions("Create motion path", "button"),
|
|
82
94
|
);
|
|
83
95
|
}
|
|
@@ -0,0 +1,45 @@
|
|
|
1
|
+
import { afterEach, describe, expect, it, vi } from "vitest";
|
|
2
|
+
import { probeSourceElement } from "./probeSourceElement";
|
|
3
|
+
|
|
4
|
+
afterEach(() => vi.unstubAllGlobals());
|
|
5
|
+
|
|
6
|
+
const answer = (body: unknown, ok = true) =>
|
|
7
|
+
vi.fn(() => Promise.resolve({ ok, json: () => Promise.resolve(body) }));
|
|
8
|
+
|
|
9
|
+
describe("probeSourceElement", () => {
|
|
10
|
+
it("sends the asks for one file in one tick as a single request", async () => {
|
|
11
|
+
const fetchMock = answer({ exists: [true, false, true] });
|
|
12
|
+
vi.stubGlobal("fetch", fetchMock);
|
|
13
|
+
|
|
14
|
+
const results = await Promise.all([
|
|
15
|
+
probeSourceElement("p", "index.html", { id: "a" }),
|
|
16
|
+
probeSourceElement("p", "index.html", { id: "b" }),
|
|
17
|
+
probeSourceElement("p", "index.html", { id: "c" }),
|
|
18
|
+
]);
|
|
19
|
+
|
|
20
|
+
expect(results).toEqual([true, false, true]);
|
|
21
|
+
expect(fetchMock).toHaveBeenCalledOnce();
|
|
22
|
+
const [, init] = fetchMock.mock.calls[0] as unknown as [string, RequestInit];
|
|
23
|
+
expect(JSON.parse(String(init.body)).targets).toEqual([{ id: "a" }, { id: "b" }, { id: "c" }]);
|
|
24
|
+
});
|
|
25
|
+
|
|
26
|
+
it("keeps one request per file", async () => {
|
|
27
|
+
const fetchMock = answer({ exists: [true] });
|
|
28
|
+
vi.stubGlobal("fetch", fetchMock);
|
|
29
|
+
await Promise.all([
|
|
30
|
+
probeSourceElement("p", "index.html", { id: "a" }),
|
|
31
|
+
probeSourceElement("p", "compositions/x.html", { id: "a" }),
|
|
32
|
+
]);
|
|
33
|
+
expect(fetchMock).toHaveBeenCalledTimes(2);
|
|
34
|
+
});
|
|
35
|
+
|
|
36
|
+
it("reads a failed or malformed answer as existing", async () => {
|
|
37
|
+
vi.stubGlobal("fetch", answer({}, false));
|
|
38
|
+
expect(await probeSourceElement("p", "index.html", { id: "a" })).toBe(true);
|
|
39
|
+
vi.stubGlobal(
|
|
40
|
+
"fetch",
|
|
41
|
+
vi.fn(() => Promise.reject(new Error("offline"))),
|
|
42
|
+
);
|
|
43
|
+
expect(await probeSourceElement("p", "index.html", { id: "a" })).toBe(true);
|
|
44
|
+
});
|
|
45
|
+
});
|
|
@@ -1,29 +1,53 @@
|
|
|
1
1
|
import { buildProjectApiPath } from "../../utils/projectRouting";
|
|
2
2
|
|
|
3
|
-
|
|
4
|
-
|
|
5
|
-
|
|
6
|
-
target:
|
|
7
|
-
|
|
3
|
+
type ProbeTarget = { id?: string; hfId?: string; selector?: string; selectorIndex?: number };
|
|
4
|
+
|
|
5
|
+
interface PendingProbe {
|
|
6
|
+
target: ProbeTarget;
|
|
7
|
+
resolve: (exists: boolean) => void;
|
|
8
|
+
}
|
|
9
|
+
|
|
10
|
+
const pending = new Map<string, PendingProbe[]>();
|
|
11
|
+
|
|
12
|
+
async function sendBatch(projectId: string, sourceFile: string, probes: PendingProbe[]) {
|
|
13
|
+
let exists: boolean[] = [];
|
|
8
14
|
try {
|
|
9
15
|
const response = await fetch(
|
|
10
16
|
buildProjectApiPath(
|
|
11
17
|
projectId,
|
|
12
|
-
`/file-mutations/probe-
|
|
18
|
+
`/file-mutations/probe-elements/${encodeURIComponent(sourceFile)}`,
|
|
13
19
|
),
|
|
14
20
|
{
|
|
15
21
|
method: "POST",
|
|
16
22
|
headers: { "Content-Type": "application/json" },
|
|
17
|
-
body: JSON.stringify({ target }),
|
|
23
|
+
body: JSON.stringify({ targets: probes.map((probe) => probe.target) }),
|
|
18
24
|
},
|
|
19
25
|
);
|
|
20
|
-
if (
|
|
21
|
-
|
|
22
|
-
|
|
23
|
-
|
|
26
|
+
if (response.ok) {
|
|
27
|
+
const data = await response.json();
|
|
28
|
+
if (Array.isArray(data?.exists)) exists = data.exists;
|
|
29
|
+
}
|
|
30
|
+
} catch {}
|
|
31
|
+
probes.forEach((probe, index) => probe.resolve(exists[index] !== false));
|
|
32
|
+
}
|
|
33
|
+
|
|
34
|
+
export function probeSourceElement(
|
|
35
|
+
projectId: string,
|
|
36
|
+
sourceFile: string,
|
|
37
|
+
target: ProbeTarget,
|
|
38
|
+
): Promise<boolean> {
|
|
39
|
+
return new Promise((resolve) => {
|
|
40
|
+
const key = `${projectId}\0${sourceFile}`;
|
|
41
|
+
let probes = pending.get(key);
|
|
42
|
+
if (!probes) {
|
|
43
|
+
const batch: PendingProbe[] = [];
|
|
44
|
+
probes = batch;
|
|
45
|
+
pending.set(key, batch);
|
|
46
|
+
setTimeout(() => {
|
|
47
|
+
pending.delete(key);
|
|
48
|
+
void sendBatch(projectId, sourceFile, batch);
|
|
49
|
+
}, 0);
|
|
24
50
|
}
|
|
25
|
-
|
|
26
|
-
}
|
|
27
|
-
return true;
|
|
28
|
-
}
|
|
51
|
+
probes.push({ target, resolve });
|
|
52
|
+
});
|
|
29
53
|
}
|