@hyperframes/studio 0.8.99 → 0.8.101
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-Rml34FRW.js → hyperframes-player-CFJLrHRF.js} +1 -1
- package/dist/assets/{index-CVDArg3m.js → index-BBNVnnyH.js} +179 -179
- package/dist/assets/{index-D9YzDuBx.js → index-C33EDBL2.js} +1 -1
- package/dist/assets/{index-C_fi-jTe.js → index-CJOzbslX.js} +1 -1
- package/dist/{chunk-U3EE4ACX.js → chunk-7X4XRP3L.js} +14 -13
- package/dist/chunk-7X4XRP3L.js.map +1 -0
- package/dist/index.d.ts +91 -21
- package/dist/index.html +1 -1
- package/dist/index.js +826 -538
- package/dist/index.js.map +1 -1
- package/dist/player.js +1 -1
- package/package.json +8 -8
- package/src/App.tsx +2 -0
- package/src/components/dock/Dock.test.tsx +78 -3
- package/src/components/dock/Dock.tsx +45 -18
- package/src/components/dock/dockLayout.ts +27 -14
- package/src/components/dock/dockLayoutSchema.ts +11 -4
- package/src/components/dock/dockLayoutStore.ts +4 -2
- package/src/components/editor/PropertyPanel.tsx +3 -3
- package/src/components/editor/domEditOverlayStartGesture.test.ts +83 -0
- package/src/components/editor/domEditOverlayStartGesture.ts +6 -4
- package/src/components/editor/manualOffsetDrag.test.ts +70 -0
- package/src/components/editor/manualOffsetDrag.ts +50 -40
- package/src/components/editor/plainTranslate.test.ts +62 -0
- package/src/components/editor/plainTranslate.ts +85 -0
- package/src/components/editor/propertyPanelTransformCommit.ts +3 -2
- package/src/components/editor/useDomEditNudge.ts +1 -1
- package/src/hooks/appHotkeysDispatch.ts +1 -7
- package/src/hooks/elementOffsetStager.ts +33 -2
- package/src/hooks/gsapRuntimeBridge.ts +1 -17
- package/src/hooks/gsapRuntimeKeyframes.ts +35 -0
- package/src/hooks/useAppHotkeys.previewForwarding.test.tsx +67 -2
- package/src/hooks/useAppHotkeys.ts +18 -45
- package/src/hooks/useDomEditCommits.test.tsx +12 -3
- package/src/hooks/useDomEditCommits.ts +2 -1
- package/src/hooks/useDomEditTextCommits.test.tsx +62 -5
- package/src/hooks/useDomEditTextCommits.ts +35 -35
- package/src/hooks/useDomGeometryCommit.test.tsx +62 -4
- package/src/hooks/useDomGeometryCommits.test.tsx +4 -2
- package/src/hooks/useEditHistoryActions.paint.test.tsx +166 -0
- package/src/hooks/useEditHistoryActions.test.tsx +67 -9
- package/src/hooks/useEditHistoryActions.ts +36 -8
- package/src/hooks/useGsapAwareEditing.test.tsx +46 -18
- package/src/hooks/useGsapAwareEditing.ts +11 -11
- package/src/hooks/usePersistentEditHistory.test.ts +46 -0
- package/src/hooks/usePersistentEditHistory.ts +101 -19
- package/src/hooks/usePreviewPersistence.ts +25 -2
- package/src/hooks/useStudioExternalFileChanges.ts +7 -3
- package/src/index.ts +4 -0
- package/src/player/components/timelineDragDrop.test.tsx +61 -5
- package/src/player/components/timelineDragDrop.ts +29 -8
- package/src/player/components/useTimelineProviderState.tsx +1 -0
- package/src/utils/domEditSaveQueue.ts +7 -2
- package/src/utils/gsapSoftReload.ts +1 -1
- package/src/utils/gsapUndoRestore.test.ts +46 -0
- package/src/utils/gsapUndoRestore.ts +166 -91
- package/src/utils/studioPendingEdits.ts +14 -5
- package/src/utils/studioUiPreferences.ts +8 -6
- package/dist/chunk-U3EE4ACX.js.map +0 -1
|
@@ -5,6 +5,7 @@ import {
|
|
|
5
5
|
resumeGsapTimelines,
|
|
6
6
|
applyManualOffsetDragDraft,
|
|
7
7
|
applyManualOffsetDragMatrix,
|
|
8
|
+
applyManualOffsetNudgeDraft,
|
|
8
9
|
createManualOffsetDragMember,
|
|
9
10
|
endManualOffsetDragMembers,
|
|
10
11
|
invertManualOffsetDragMatrix,
|
|
@@ -566,3 +567,72 @@ describe("resumeGsapTimelines", () => {
|
|
|
566
567
|
expect(() => resumeGsapTimelines(element)).not.toThrow();
|
|
567
568
|
});
|
|
568
569
|
});
|
|
570
|
+
|
|
571
|
+
describe("a move of an element GSAP does not position", () => {
|
|
572
|
+
function plainBox(options: { gsapLoaded?: boolean } = {}) {
|
|
573
|
+
const window = new Window();
|
|
574
|
+
const element = window.document.createElement("div");
|
|
575
|
+
window.document.body.append(element);
|
|
576
|
+
element.style.setProperty("translate", "40px 30px");
|
|
577
|
+
// A rect that never moves: the probe falls back to the 1:1 preview-scale mapping.
|
|
578
|
+
element.getBoundingClientRect = () => new window.DOMRect(10, 20, 100, 50);
|
|
579
|
+
const gsapCalls: string[] = [];
|
|
580
|
+
if (options.gsapLoaded) {
|
|
581
|
+
Object.assign(window, {
|
|
582
|
+
gsap: { set: () => gsapCalls.push("set"), getProperty: () => gsapCalls.push("read") },
|
|
583
|
+
});
|
|
584
|
+
}
|
|
585
|
+
const member = (gesture: "drag" | "nudge" = "drag") => {
|
|
586
|
+
const result = createManualOffsetDragMember({
|
|
587
|
+
key: "box",
|
|
588
|
+
selection: { element } as never,
|
|
589
|
+
element,
|
|
590
|
+
rect: { left: 10, top: 20, width: 100, height: 50, editScaleX: 1, editScaleY: 1 },
|
|
591
|
+
gesture,
|
|
592
|
+
});
|
|
593
|
+
if (!result.ok) throw new Error(result.reason);
|
|
594
|
+
return result.member;
|
|
595
|
+
};
|
|
596
|
+
return { window, element, gsapCalls, member };
|
|
597
|
+
}
|
|
598
|
+
|
|
599
|
+
it("drafts and drops the element's own translate in plain px, never touching GSAP", () => {
|
|
600
|
+
const { element, gsapCalls, member } = plainBox({ gsapLoaded: true });
|
|
601
|
+
const m = member();
|
|
602
|
+
expect(m.plainTranslate).toBe(true);
|
|
603
|
+
applyManualOffsetDragDraft(m, 90.25, 60);
|
|
604
|
+
expect(element.style.getPropertyValue("translate")).toBe("130.25px 90px");
|
|
605
|
+
expect(applyManualOffsetDragCommit(m, 90.25, 60)).toEqual({ x: 130.25, y: 90 });
|
|
606
|
+
endManualOffsetDragMembers([m]);
|
|
607
|
+
expect(element.style.getPropertyValue("translate")).toBe("130.25px 90px");
|
|
608
|
+
expect(gsapCalls).toEqual([]);
|
|
609
|
+
expect(element.hasAttribute("data-hf-drag-gsap-base-x")).toBe(false);
|
|
610
|
+
});
|
|
611
|
+
|
|
612
|
+
it("starts a second move where the first dropped, while the first save is still in flight", () => {
|
|
613
|
+
const { member } = plainBox();
|
|
614
|
+
const first = member();
|
|
615
|
+
applyManualOffsetDragCommit(first, 90, 60);
|
|
616
|
+
const second = member();
|
|
617
|
+
expect(second.initialOffset).toEqual({ x: 130, y: 90 });
|
|
618
|
+
expect(applyManualOffsetDragCommit(second, 40, 0)).toEqual({ x: 170, y: 90 });
|
|
619
|
+
});
|
|
620
|
+
|
|
621
|
+
it("nudges from the element's translate", () => {
|
|
622
|
+
const { member } = plainBox();
|
|
623
|
+
expect(applyManualOffsetNudgeDraft(member("nudge"), { x: 5, y: -1 })).toEqual({ x: 45, y: 29 });
|
|
624
|
+
});
|
|
625
|
+
|
|
626
|
+
it("keeps the GSAP writer once GSAP renders the element's transform", () => {
|
|
627
|
+
const { element, member } = plainBox({ gsapLoaded: true });
|
|
628
|
+
Object.assign(element, { _gsap: { renderTransform: () => {} } });
|
|
629
|
+
expect(member().plainTranslate).toBe(false);
|
|
630
|
+
});
|
|
631
|
+
|
|
632
|
+
it("keeps the GSAP writer for an element a timeline hold positions", () => {
|
|
633
|
+
const { window, element, member } = plainBox();
|
|
634
|
+
const hold = { targets: () => [element], vars: { x: 40 }, duration: () => 0 };
|
|
635
|
+
Object.assign(window, { __timelines: { main: { getChildren: () => [hold] } } });
|
|
636
|
+
expect(member().plainTranslate).toBe(false);
|
|
637
|
+
});
|
|
638
|
+
});
|
|
@@ -11,6 +11,8 @@ import {
|
|
|
11
11
|
type StudioPathOffsetSnapshot,
|
|
12
12
|
} from "./manualEdits";
|
|
13
13
|
import { computeDraggedGsapPosition } from "../../hooks/draggedGsapPosition";
|
|
14
|
+
import { gsapWritesPosition } from "../../hooks/gsapRuntimeKeyframes";
|
|
15
|
+
import { readTranslatePx, writeTranslatePx } from "./plainTranslate";
|
|
14
16
|
|
|
15
17
|
interface OffsetDragGsap {
|
|
16
18
|
set: (el: Element, vars: Record<string, number | string>) => void;
|
|
@@ -97,6 +99,8 @@ export interface ManualOffsetDragMember {
|
|
|
97
99
|
selection: DomEditSelection;
|
|
98
100
|
element: HTMLElement;
|
|
99
101
|
initialOffset: { x: number; y: number };
|
|
102
|
+
/** The move writes the element's own inline `translate`; offsets are then absolute translate px. */
|
|
103
|
+
plainTranslate: boolean;
|
|
100
104
|
/**
|
|
101
105
|
* The element's GSAP x/y at gesture start, captured in JS so a mid-drag
|
|
102
106
|
* re-render (which reverts inline style + wipes the `data-hf-drag-gsap-base-*`
|
|
@@ -242,22 +246,23 @@ function readTransformWDivisor(element: HTMLElement): number {
|
|
|
242
246
|
export function measureManualOffsetDragScreenToOffsetMatrix(
|
|
243
247
|
element: HTMLElement,
|
|
244
248
|
initialOffset: { x: number; y: number },
|
|
245
|
-
options: { probeSize?: number; scaleX?: number; scaleY?: number } = {},
|
|
249
|
+
options: { probeSize?: number; scaleX?: number; scaleY?: number; plainTranslate?: boolean } = {},
|
|
246
250
|
): { ok: true; matrix: ManualOffsetDragMatrix } | { ok: false; reason: string } {
|
|
247
251
|
const probeSize = options.probeSize ?? DEFAULT_OFFSET_PROBE_PX;
|
|
248
252
|
if (!Number.isFinite(probeSize) || probeSize <= 0) {
|
|
249
253
|
return { ok: false, reason: "Invalid movement probe size." };
|
|
250
254
|
}
|
|
251
255
|
|
|
256
|
+
const draw = options.plainTranslate ? writeTranslatePx : applyStudioPathOffsetDraft;
|
|
252
257
|
const snapshot = captureStudioPathOffset(element);
|
|
253
258
|
try {
|
|
254
|
-
|
|
259
|
+
draw(element, initialOffset);
|
|
255
260
|
const origin = getRectCenter(element);
|
|
256
261
|
if (!origin) {
|
|
257
262
|
return { ok: false, reason: "Element has no measurable box." };
|
|
258
263
|
}
|
|
259
264
|
|
|
260
|
-
|
|
265
|
+
draw(element, {
|
|
261
266
|
x: initialOffset.x + probeSize,
|
|
262
267
|
y: initialOffset.y,
|
|
263
268
|
});
|
|
@@ -266,7 +271,7 @@ export function measureManualOffsetDragScreenToOffsetMatrix(
|
|
|
266
271
|
return { ok: false, reason: "Element X movement could not be measured." };
|
|
267
272
|
}
|
|
268
273
|
|
|
269
|
-
|
|
274
|
+
draw(element, {
|
|
270
275
|
x: initialOffset.x,
|
|
271
276
|
y: initialOffset.y + probeSize,
|
|
272
277
|
});
|
|
@@ -332,24 +337,25 @@ export function createManualOffsetDragMember(input: {
|
|
|
332
337
|
selection: DomEditSelection;
|
|
333
338
|
element: HTMLElement;
|
|
334
339
|
rect: ManualOffsetDragRect;
|
|
340
|
+
gesture?: "drag" | "nudge"; // a resize's anchor member keeps the legacy offset channel
|
|
335
341
|
}): ManualOffsetDragMemberResult {
|
|
336
|
-
|
|
337
|
-
//
|
|
338
|
-
//
|
|
339
|
-
const initialOffset =
|
|
340
|
-
|
|
341
|
-
|
|
342
|
-
|
|
342
|
+
const plainTranslate = !!input.gesture && !gsapWritesPosition(input.element);
|
|
343
|
+
// Base the drag on the offset ACTUALLY applied, never a dormant var (readAppliedStudioPathOffset),
|
|
344
|
+
// so a stale offset can't fling the element off-screen.
|
|
345
|
+
const initialOffset = plainTranslate
|
|
346
|
+
? readTranslatePx(input.element)
|
|
347
|
+
: readAppliedStudioPathOffset(input.element);
|
|
343
348
|
const win = input.element.ownerDocument.defaultView as
|
|
344
349
|
| (Window & {
|
|
345
350
|
gsap?: { getProperty?: (el: Element, prop: string) => number };
|
|
346
351
|
__timelines?: Record<string, { pause?: () => void; paused?: () => boolean }>;
|
|
347
352
|
})
|
|
348
353
|
| null;
|
|
349
|
-
|
|
350
|
-
const
|
|
351
|
-
input.element
|
|
352
|
-
|
|
354
|
+
// getProperty makes GSAP bake the CSS translate into its transform, so a plain move never reads it.
|
|
355
|
+
const gsapX = (!plainTranslate && win?.gsap?.getProperty?.(input.element, "x")) || 0;
|
|
356
|
+
const gsapY = (!plainTranslate && win?.gsap?.getProperty?.(input.element, "y")) || 0;
|
|
357
|
+
const baseGsap = { x: gsapX, y: gsapY };
|
|
358
|
+
if (!plainTranslate) stampGestureBase(input.element, initialOffset, baseGsap);
|
|
353
359
|
|
|
354
360
|
if (win?.__timelines) {
|
|
355
361
|
const paused: string[] = [];
|
|
@@ -373,8 +379,8 @@ export function createManualOffsetDragMember(input: {
|
|
|
373
379
|
const measured = measureManualOffsetDragScreenToOffsetMatrix(input.element, initialOffset, {
|
|
374
380
|
scaleX: input.rect.editScaleX,
|
|
375
381
|
scaleY: input.rect.editScaleY,
|
|
382
|
+
plainTranslate,
|
|
376
383
|
});
|
|
377
|
-
const baseGsap = { x: gsapX, y: gsapY };
|
|
378
384
|
if (!measured.ok) {
|
|
379
385
|
// Fallback: when GSAP transforms interfere with probe measurement, use
|
|
380
386
|
// the preview scale as an approximation. The commit path reads the actual
|
|
@@ -390,6 +396,7 @@ export function createManualOffsetDragMember(input: {
|
|
|
390
396
|
selection: input.selection,
|
|
391
397
|
element: input.element,
|
|
392
398
|
initialOffset,
|
|
399
|
+
plainTranslate,
|
|
393
400
|
baseGsap,
|
|
394
401
|
initialPathOffset,
|
|
395
402
|
gestureToken,
|
|
@@ -406,6 +413,7 @@ export function createManualOffsetDragMember(input: {
|
|
|
406
413
|
selection: input.selection,
|
|
407
414
|
element: input.element,
|
|
408
415
|
initialOffset,
|
|
416
|
+
plainTranslate,
|
|
409
417
|
baseGsap,
|
|
410
418
|
initialPathOffset,
|
|
411
419
|
gestureToken,
|
|
@@ -428,18 +436,25 @@ function resolveManualOffsetDragMemberOffset(
|
|
|
428
436
|
});
|
|
429
437
|
}
|
|
430
438
|
|
|
439
|
+
/** Draws the offset where the commit writes it: the inline translate, else GSAP, else the legacy var. */
|
|
440
|
+
function drawMemberOffset(
|
|
441
|
+
member: ManualOffsetDragMember,
|
|
442
|
+
offset: { x: number; y: number },
|
|
443
|
+
commit: boolean,
|
|
444
|
+
): void {
|
|
445
|
+
if (member.plainTranslate) writeTranslatePx(member.element, offset);
|
|
446
|
+
else if (applyOffsetDragDraftViaGsap(member.element, offset, member.baseGsap)) return;
|
|
447
|
+
else if (commit) applyStudioPathOffset(member.element, offset);
|
|
448
|
+
else applyStudioPathOffsetDraft(member.element, offset);
|
|
449
|
+
}
|
|
450
|
+
|
|
431
451
|
export function applyManualOffsetDragDraft(
|
|
432
452
|
member: ManualOffsetDragMember,
|
|
433
453
|
dx: number,
|
|
434
454
|
dy: number,
|
|
435
455
|
): { x: number; y: number } {
|
|
436
456
|
const offset = resolveManualOffsetDragMemberOffset(member, dx, dy);
|
|
437
|
-
|
|
438
|
-
// the live draft matches the committed `tl.set`/keyframe. CSS draft only when
|
|
439
|
-
// gsap is unavailable (no preview iframe runtime).
|
|
440
|
-
if (!applyOffsetDragDraftViaGsap(member.element, offset, member.baseGsap)) {
|
|
441
|
-
applyStudioPathOffsetDraft(member.element, offset);
|
|
442
|
-
}
|
|
457
|
+
drawMemberOffset(member, offset, false);
|
|
443
458
|
return offset;
|
|
444
459
|
}
|
|
445
460
|
|
|
@@ -451,25 +466,20 @@ export function applyManualOffsetDragDraft(
|
|
|
451
466
|
* the element flies off-screen the instant you drop it. The member holds the
|
|
452
467
|
* true gesture-start values in JS, immune to the re-render.
|
|
453
468
|
*/
|
|
454
|
-
function
|
|
455
|
-
|
|
456
|
-
|
|
457
|
-
|
|
458
|
-
|
|
469
|
+
function stampGestureBase(el: HTMLElement, initialOffset: Point, baseGsap: Point): void {
|
|
470
|
+
el.setAttribute("data-hf-drag-gsap-base-x", String(baseGsap.x));
|
|
471
|
+
el.setAttribute("data-hf-drag-gsap-base-y", String(baseGsap.y));
|
|
472
|
+
el.setAttribute("data-hf-drag-initial-offset-x", String(initialOffset.x));
|
|
473
|
+
el.setAttribute("data-hf-drag-initial-offset-y", String(initialOffset.y));
|
|
459
474
|
}
|
|
460
475
|
|
|
461
476
|
function applyManualOffsetCommitValue(
|
|
462
477
|
member: ManualOffsetDragMember,
|
|
463
478
|
offset: { x: number; y: number },
|
|
464
479
|
): { x: number; y: number } {
|
|
465
|
-
|
|
466
|
-
|
|
467
|
-
|
|
468
|
-
// source mutation soft-reloads — no transient CSS `--hf-studio-offset` write.
|
|
469
|
-
// CSS apply only when gsap is unavailable.
|
|
470
|
-
if (!applyOffsetDragDraftViaGsap(member.element, offset, member.baseGsap)) {
|
|
471
|
-
applyStudioPathOffset(member.element, offset);
|
|
472
|
-
}
|
|
480
|
+
if (!member.plainTranslate)
|
|
481
|
+
stampGestureBase(member.element, member.initialOffset, member.baseGsap);
|
|
482
|
+
drawMemberOffset(member, offset, true);
|
|
473
483
|
return offset;
|
|
474
484
|
}
|
|
475
485
|
|
|
@@ -496,9 +506,7 @@ export function applyManualOffsetNudgeDraft(
|
|
|
496
506
|
x: member.initialOffset.x + delta.x,
|
|
497
507
|
y: member.initialOffset.y + delta.y,
|
|
498
508
|
};
|
|
499
|
-
|
|
500
|
-
applyStudioPathOffsetDraft(member.element, offset);
|
|
501
|
-
}
|
|
509
|
+
drawMemberOffset(member, offset, false);
|
|
502
510
|
return offset;
|
|
503
511
|
}
|
|
504
512
|
|
|
@@ -514,7 +522,9 @@ export function applyManualOffsetNudgeCommit(
|
|
|
514
522
|
|
|
515
523
|
function restoreManualOffsetDragMember(member: ManualOffsetDragMember): void {
|
|
516
524
|
restoreStudioPathOffset(member.element, member.initialPathOffset);
|
|
517
|
-
|
|
525
|
+
if (!member.plainTranslate) {
|
|
526
|
+
getOffsetDragGsap(member.element)?.set(member.element, { ...member.baseGsap });
|
|
527
|
+
}
|
|
518
528
|
endStudioManualEditGesture(member.element, member.gestureToken);
|
|
519
529
|
}
|
|
520
530
|
|
|
@@ -548,7 +558,7 @@ export function endManualOffsetDragMembers(members: ManualOffsetDragMember[]): v
|
|
|
548
558
|
member.element.removeAttribute("data-hf-drag-gsap-base-y");
|
|
549
559
|
// Do NOT clearProps:"transform" — that nukes the committed GSAP position
|
|
550
560
|
// and causes a visual snap-back before the soft reload re-applies it.
|
|
551
|
-
const gsapOwnsPosition = Boolean(getOffsetDragGsap(member.element));
|
|
561
|
+
const gsapOwnsPosition = !member.plainTranslate && Boolean(getOffsetDragGsap(member.element));
|
|
552
562
|
// Migration: when GSAP owns the position (the committed value lives in the
|
|
553
563
|
// GSAP transform), the legacy `--hf-studio-offset` CSS channel is obsolete.
|
|
554
564
|
// Clear it on the LIVE element — otherwise the leftover `translate:
|
|
@@ -0,0 +1,62 @@
|
|
|
1
|
+
// @vitest-environment happy-dom
|
|
2
|
+
import { describe, expect, it } from "vitest";
|
|
3
|
+
import { readTranslatePx } from "./plainTranslate";
|
|
4
|
+
|
|
5
|
+
function box(style: string): HTMLElement {
|
|
6
|
+
const element = document.createElement("div");
|
|
7
|
+
element.style.cssText = `position: absolute; width: 240px; height: 160px; ${style}`;
|
|
8
|
+
document.body.append(element);
|
|
9
|
+
return element;
|
|
10
|
+
}
|
|
11
|
+
|
|
12
|
+
describe("readTranslatePx", () => {
|
|
13
|
+
it.each([
|
|
14
|
+
["40px 30px", { x: 40, y: 30 }],
|
|
15
|
+
["-50% -50%", { x: -120, y: -80 }],
|
|
16
|
+
["25% 25%", { x: 60, y: 40 }],
|
|
17
|
+
["calc(-50% + 12px) calc(10% - 6px)", { x: -108, y: 10 }],
|
|
18
|
+
["30px", { x: 30, y: 0 }],
|
|
19
|
+
])("reads %s against the border box, in px", (translate, want) => {
|
|
20
|
+
expect(readTranslatePx(box(`translate: ${translate}`))).toEqual(want);
|
|
21
|
+
});
|
|
22
|
+
|
|
23
|
+
it.each([
|
|
24
|
+
["calc(50% - 20px) 0px", { x: 100, y: 0 }],
|
|
25
|
+
["calc(50% + 20px) calc(50% - 20px)", { x: 140, y: 60 }],
|
|
26
|
+
])("adds and subtracts the terms of %s", (translate, want) => {
|
|
27
|
+
expect(readTranslatePx(box(`translate: ${translate}`))).toEqual(want);
|
|
28
|
+
});
|
|
29
|
+
|
|
30
|
+
it.each([
|
|
31
|
+
"min(10px, 5%) 0px",
|
|
32
|
+
"calc(2 * 10px) 0px",
|
|
33
|
+
"calc(10px +) 0px",
|
|
34
|
+
"clamp(0px, 10%, 30px) 5px",
|
|
35
|
+
])("hands %s to Chrome's own resolution and puts the element's style back", (translate) => {
|
|
36
|
+
const element = box(`translate: ${translate}; transform: rotate(5deg)`);
|
|
37
|
+
const before = element.getAttribute("style");
|
|
38
|
+
const read = readTranslatePx(element);
|
|
39
|
+
expect(Number.isFinite(read.x) && Number.isFinite(read.y)).toBe(true);
|
|
40
|
+
expect(element.getAttribute("style")).toBe(before);
|
|
41
|
+
});
|
|
42
|
+
|
|
43
|
+
it("counts padding and border in the box a percent resolves against", () => {
|
|
44
|
+
const element = box(
|
|
45
|
+
"translate: 50% 50%; padding: 10px; border: 5px solid; box-sizing: content-box",
|
|
46
|
+
);
|
|
47
|
+
expect(readTranslatePx(element)).toEqual({ x: 135, y: 95 });
|
|
48
|
+
});
|
|
49
|
+
|
|
50
|
+
it("writes nothing to the element it reads", () => {
|
|
51
|
+
const element = box("translate: -50% -50%");
|
|
52
|
+
const before = element.getAttribute("style");
|
|
53
|
+
const writes: MutationRecord[] = [];
|
|
54
|
+
const observer = new MutationObserver((records) => writes.push(...records));
|
|
55
|
+
observer.observe(element, { attributes: true });
|
|
56
|
+
readTranslatePx(element);
|
|
57
|
+
writes.push(...observer.takeRecords());
|
|
58
|
+
observer.disconnect();
|
|
59
|
+
expect(writes).toEqual([]);
|
|
60
|
+
expect(element.getAttribute("style")).toBe(before);
|
|
61
|
+
});
|
|
62
|
+
});
|
|
@@ -0,0 +1,85 @@
|
|
|
1
|
+
import { roundTo3 } from "../../utils/rounding";
|
|
2
|
+
import { gsapWritesPosition } from "../../hooks/gsapRuntimeKeyframes";
|
|
3
|
+
import { readStudioPathOffset } from "./manualEditsDom";
|
|
4
|
+
import { splitTopLevelWhitespace } from "./manualEditsStyleHelpers";
|
|
5
|
+
import type { PatchOperation } from "../../utils/sourcePatcher";
|
|
6
|
+
|
|
7
|
+
type Point = { x: number; y: number };
|
|
8
|
+
|
|
9
|
+
const TERM = /^(-?\d*\.?\d+(?:e[-+]?\d+)?)(px|%)$/i;
|
|
10
|
+
|
|
11
|
+
const SIGN: Record<string, number> = { "+": 1, "-": -1 };
|
|
12
|
+
|
|
13
|
+
function termPx(term: string | undefined, side: number): number | null {
|
|
14
|
+
const match = TERM.exec(term ?? "");
|
|
15
|
+
return match ? Number(match[1]) * (match[2] === "%" ? side / 100 : 1) : null;
|
|
16
|
+
}
|
|
17
|
+
|
|
18
|
+
/** px, %, or Chrome's computed `calc(P% + Lpx)`, against the border-box side; null for min()/max()/clamp(). */
|
|
19
|
+
function resolveLength(value: string, side: number): number | null {
|
|
20
|
+
const parts = (/^calc\((.*)\)$/.exec(value)?.[1] ?? value).split(" ");
|
|
21
|
+
if (parts.length % 2 === 0) return null;
|
|
22
|
+
let total = termPx(parts[0], side);
|
|
23
|
+
for (let i = 1; i < parts.length && total !== null; i += 2) {
|
|
24
|
+
const term = termPx(parts[i + 1], side);
|
|
25
|
+
const sign = SIGN[parts[i] ?? ""];
|
|
26
|
+
total = term === null || !sign ? null : total + sign * term;
|
|
27
|
+
}
|
|
28
|
+
return total;
|
|
29
|
+
}
|
|
30
|
+
|
|
31
|
+
function borderBox(
|
|
32
|
+
cs: CSSStyleDeclaration,
|
|
33
|
+
size: "width" | "height",
|
|
34
|
+
a: string,
|
|
35
|
+
b: string,
|
|
36
|
+
): number {
|
|
37
|
+
const n = (prop: string) => Number.parseFloat(cs.getPropertyValue(prop)) || 0;
|
|
38
|
+
const pad =
|
|
39
|
+
n(`padding-${a}`) + n(`padding-${b}`) + n(`border-${a}-width`) + n(`border-${b}-width`);
|
|
40
|
+
return n(size) + (cs.boxSizing === "border-box" ? 0 : pad);
|
|
41
|
+
}
|
|
42
|
+
|
|
43
|
+
// Chrome resolves min()/max()/clamp() inside a transform function to a matrix; an unrendered box has none.
|
|
44
|
+
function resolveInTransform(el: HTMLElement, x: string, y: string): Point {
|
|
45
|
+
const style = el.style;
|
|
46
|
+
const saved = [style.getPropertyValue("transform"), style.getPropertyPriority("transform")];
|
|
47
|
+
style.setProperty("transform", `translate(${x}, ${y})`, "important");
|
|
48
|
+
try {
|
|
49
|
+
const resolved = el.ownerDocument.defaultView?.getComputedStyle(el).transform ?? "";
|
|
50
|
+
const matrix = new DOMMatrixReadOnly(resolved.startsWith("matrix") ? resolved : undefined);
|
|
51
|
+
return { x: matrix.m41, y: matrix.m42 };
|
|
52
|
+
} finally {
|
|
53
|
+
style.setProperty("transform", saved[0] ?? "", saved[1] ?? "");
|
|
54
|
+
}
|
|
55
|
+
}
|
|
56
|
+
|
|
57
|
+
/** The element's CSS `translate` in px, as it renders now. */
|
|
58
|
+
// ponytail: a 3-value translate loses its z on the next move; keep z when a fixture needs it.
|
|
59
|
+
export function readTranslatePx(el: HTMLElement): Point {
|
|
60
|
+
const cs = el.ownerDocument.defaultView?.getComputedStyle(el);
|
|
61
|
+
const value = el.style.getPropertyValue("translate") || cs?.translate || "none";
|
|
62
|
+
if (value === "none") return { x: 0, y: 0 };
|
|
63
|
+
const [x = "0px", y = "0px"] = splitTopLevelWhitespace(value);
|
|
64
|
+
const px = cs && resolveLength(x, borderBox(cs, "width", "left", "right"));
|
|
65
|
+
const py = cs && resolveLength(y, borderBox(cs, "height", "top", "bottom"));
|
|
66
|
+
return px != null && py != null ? { x: px, y: py } : resolveInTransform(el, x, y);
|
|
67
|
+
}
|
|
68
|
+
|
|
69
|
+
/** Plain px only: GSAP's CSSPlugin splits `translate` on spaces and drops a calc(). */
|
|
70
|
+
function formatTranslatePx(p: Point): string {
|
|
71
|
+
return `${roundTo3(p.x)}px ${roundTo3(p.y)}px`;
|
|
72
|
+
}
|
|
73
|
+
|
|
74
|
+
export function translatePatch(p: Point): PatchOperation & { value: string } {
|
|
75
|
+
return { type: "inline-style", property: "translate", value: formatTranslatePx(p) };
|
|
76
|
+
}
|
|
77
|
+
|
|
78
|
+
export function writeTranslatePx(el: HTMLElement, p: Point): void {
|
|
79
|
+
el.style.setProperty("translate", formatTranslatePx(p));
|
|
80
|
+
}
|
|
81
|
+
|
|
82
|
+
/** The position the panel shows and edits: the translate a move writes, unless GSAP positions it. */
|
|
83
|
+
export function readMoveOffset(el: HTMLElement): Point {
|
|
84
|
+
return gsapWritesPosition(el) ? readStudioPathOffset(el) : readTranslatePx(el);
|
|
85
|
+
}
|
|
@@ -1,5 +1,6 @@
|
|
|
1
1
|
import type { DomEditSelection } from "./domEditingTypes";
|
|
2
|
-
import { readStudioBoxSize
|
|
2
|
+
import { readStudioBoxSize } from "./manualEdits";
|
|
3
|
+
import { readMoveOffset } from "./plainTranslate";
|
|
3
4
|
import { parsePxMetricValue, type PropertyPanelProps } from "./propertyPanelHelpers";
|
|
4
5
|
|
|
5
6
|
interface TransformCommitDeps {
|
|
@@ -73,7 +74,7 @@ export function createTransformCommitHandlers({
|
|
|
73
74
|
)
|
|
74
75
|
)
|
|
75
76
|
return;
|
|
76
|
-
const current =
|
|
77
|
+
const current = readMoveOffset(element.element);
|
|
77
78
|
await Promise.resolve(
|
|
78
79
|
onSetManualOffset(element, {
|
|
79
80
|
x: axis === "x" ? parsed : current.x,
|
|
@@ -189,7 +189,7 @@ export function useDomEditNudge(params: UseDomEditNudgeParams): { flushNudge: ()
|
|
|
189
189
|
if (!targets) return null;
|
|
190
190
|
const members: ManualOffsetDragMember[] = [];
|
|
191
191
|
for (const target of targets) {
|
|
192
|
-
const result = createManualOffsetDragMember(target);
|
|
192
|
+
const result = createManualOffsetDragMember({ ...target, gesture: "nudge" });
|
|
193
193
|
if (!result.ok) {
|
|
194
194
|
restoreManualOffsetDragMembers(members);
|
|
195
195
|
return null;
|
|
@@ -14,13 +14,7 @@ import { STUDIO_PLAIN_KEYS } from "../player/components/studioShortcuts";
|
|
|
14
14
|
// following useTimelineDeleteOps's precedent. Pure functions, no hooks — the
|
|
15
15
|
// hook still owns the actual keydown listeners and calls into these.
|
|
16
16
|
|
|
17
|
-
|
|
18
|
-
* the same undo/redo key arbitration without duplicating it. */
|
|
19
|
-
export function handleUndoRedoKey(
|
|
20
|
-
event: KeyboardEvent,
|
|
21
|
-
onUndo: () => void,
|
|
22
|
-
onRedo: () => void,
|
|
23
|
-
): boolean {
|
|
17
|
+
function handleUndoRedoKey(event: KeyboardEvent, onUndo: () => void, onRedo: () => void): boolean {
|
|
24
18
|
const key = event.key.toLowerCase();
|
|
25
19
|
if (key === "z" && !event.shiftKey) {
|
|
26
20
|
event.preventDefault();
|
|
@@ -5,6 +5,8 @@ import {
|
|
|
5
5
|
} from "../components/editor/elementPositionOffset";
|
|
6
6
|
import { LAYER_REVEAL_PRIOR_POSITION_ATTR } from "../player/lib/timelineElementHelpers";
|
|
7
7
|
import type { PatchOperation } from "../utils/sourcePatcher";
|
|
8
|
+
import { translatePatch } from "../components/editor/plainTranslate";
|
|
9
|
+
import { gsapWritesPosition } from "./gsapRuntimeKeyframes";
|
|
8
10
|
|
|
9
11
|
const ELEMENT_OFFSET_REFUSED: Record<ElementOffsetRefusal, string> = {
|
|
10
12
|
anchored: "This layer is anchored from its right or bottom edge. Move it in the Code tab.",
|
|
@@ -30,8 +32,34 @@ function settleGsapDraftAtGestureStart(el: HTMLElement): void {
|
|
|
30
32
|
if (gsap && Number.isFinite(x) && Number.isFinite(y)) gsap.set(el, { x, y });
|
|
31
33
|
}
|
|
32
34
|
|
|
33
|
-
|
|
34
|
-
|
|
35
|
+
let plainMoveCounter = 0;
|
|
36
|
+
|
|
37
|
+
/** GSAP does not position the element: `next` is its whole translate, live now and saved as drawn. */
|
|
38
|
+
function stagePlainTranslate(
|
|
39
|
+
commitPositionPatchToHtml: ElementOffsetStagerDeps["commitPositionPatchToHtml"],
|
|
40
|
+
selection: DomEditSelection,
|
|
41
|
+
next: { x: number; y: number },
|
|
42
|
+
coalesceKey?: string,
|
|
43
|
+
): { save: () => Promise<void>; rollback: () => void } {
|
|
44
|
+
const style = selection.element.style;
|
|
45
|
+
const before = style.getPropertyValue("translate");
|
|
46
|
+
const patch = translatePatch(next);
|
|
47
|
+
style.setProperty("translate", patch.value);
|
|
48
|
+
const rollback = () => {
|
|
49
|
+
if (style.getPropertyValue("translate") === patch.value) style.setProperty("translate", before);
|
|
50
|
+
};
|
|
51
|
+
const key = coalesceKey ?? `move:${++plainMoveCounter}`;
|
|
52
|
+
const options = { label: "Move layer", coalesceKey: key, coalesceMs: Number.POSITIVE_INFINITY };
|
|
53
|
+
const save = () =>
|
|
54
|
+
commitPositionPatchToHtml(selection, [patch], options).catch((error) => {
|
|
55
|
+
rollback();
|
|
56
|
+
throw error;
|
|
57
|
+
});
|
|
58
|
+
return { save, rollback };
|
|
59
|
+
}
|
|
60
|
+
|
|
61
|
+
/** Applies a move on the element itself live now: its translate when GSAP does not position it,
|
|
62
|
+
* else left/top for a shared-tween element. Throws, after a toast, when it cannot. */
|
|
35
63
|
export function stageElementOffset(
|
|
36
64
|
{ commitPositionPatchToHtml, showToast, readOnlyPreview }: ElementOffsetStagerDeps,
|
|
37
65
|
selection: DomEditSelection,
|
|
@@ -40,6 +68,9 @@ export function stageElementOffset(
|
|
|
40
68
|
): { save: () => Promise<void>; rollback: () => void } {
|
|
41
69
|
const el = selection.element;
|
|
42
70
|
if (readOnlyPreview) return { save: () => Promise.resolve(), rollback: () => undefined };
|
|
71
|
+
if (!gsapWritesPosition(el)) {
|
|
72
|
+
return stagePlainTranslate(commitPositionPatchToHtml, selection, next, coalesceKey);
|
|
73
|
+
}
|
|
43
74
|
const previous = { position: el.style.position, left: el.style.left, top: el.style.top };
|
|
44
75
|
const liftMarker = el.getAttribute(LAYER_REVEAL_PRIOR_POSITION_ATTR);
|
|
45
76
|
const result = applyElementPositionOffset(el, next);
|
|
@@ -35,7 +35,7 @@ import {
|
|
|
35
35
|
pickClosestToPlayhead,
|
|
36
36
|
readGsapPositionFromIframe,
|
|
37
37
|
} from "./gsapPositionDetection";
|
|
38
|
-
import { hasNonHoldTweenForElement } from "./gsapRuntimeKeyframes";
|
|
38
|
+
import { hasNonHoldTweenForElement, POSITION_CHANNELS } from "./gsapRuntimeKeyframes";
|
|
39
39
|
import { getAnimationsForElement } from "./gsapElementMatch";
|
|
40
40
|
import {
|
|
41
41
|
animationWritesAnyProperty,
|
|
@@ -43,22 +43,6 @@ import {
|
|
|
43
43
|
type GsapEditOutcome,
|
|
44
44
|
} from "./gsapEditOutcome";
|
|
45
45
|
|
|
46
|
-
// Position channels — used to scope the "has a live position tween?" check so a
|
|
47
|
-
// sibling rotation/scale animation never forces a static position hold into the
|
|
48
|
-
// keyframe branch (which corrupts it into a frozen duration-0 keyframed tween).
|
|
49
|
-
export const POSITION_CHANNELS: string[] = [
|
|
50
|
-
"x",
|
|
51
|
-
"y",
|
|
52
|
-
"xPercent",
|
|
53
|
-
"yPercent",
|
|
54
|
-
"left",
|
|
55
|
-
"top",
|
|
56
|
-
// GSAP normalizes translateX/Y to x/y at play time, but readTween reads the
|
|
57
|
-
// AUTHORED shape — include them so a hand-authored translateX/Y position tween
|
|
58
|
-
// still counts as a live position tween.
|
|
59
|
-
"translateX",
|
|
60
|
-
"translateY",
|
|
61
|
-
];
|
|
62
46
|
const POSITION_CHANNEL_SET = new Set<string>(POSITION_CHANNELS);
|
|
63
47
|
|
|
64
48
|
const ROTATION_CHANNELS: string[] = ["rotation", "rotationX", "rotationY", "rotationZ"];
|
|
@@ -405,6 +405,41 @@ export function hasNonHoldTweenForElement(
|
|
|
405
405
|
return !!targetEl && hasNonHoldTween(timelinesOf(iframe), targetEl, channels, compositionId);
|
|
406
406
|
}
|
|
407
407
|
|
|
408
|
+
// A sibling rotation/scale tween must never push a static position hold into the keyframe branch.
|
|
409
|
+
export const POSITION_CHANNELS: string[] = [
|
|
410
|
+
"x",
|
|
411
|
+
"y",
|
|
412
|
+
"xPercent",
|
|
413
|
+
"yPercent",
|
|
414
|
+
"left",
|
|
415
|
+
"top",
|
|
416
|
+
// readTween reads the authored translateX/Y; GSAP normalizes them to x/y only at play time.
|
|
417
|
+
"translateX",
|
|
418
|
+
"translateY",
|
|
419
|
+
];
|
|
420
|
+
const MOVE_CHANNELS = [...POSITION_CHANNELS, "motionPath"];
|
|
421
|
+
|
|
422
|
+
/** Whether a live timeline tween or hold writes any of `channels` on `el`. Sync, no fetch. */
|
|
423
|
+
function gsapWritesChannels(el: Element, channels: string[]): boolean {
|
|
424
|
+
const win = el.ownerDocument.defaultView as { __timelines?: Record<string, RuntimeTimeline> };
|
|
425
|
+
return Object.values(win?.__timelines ?? {}).some((tl) =>
|
|
426
|
+
(tl?.getChildren?.(true) ?? []).some(
|
|
427
|
+
(tween) =>
|
|
428
|
+
!!tween.vars &&
|
|
429
|
+
matchesElement(tween, el) &&
|
|
430
|
+
(channels.some((ch) => ch in tween.vars!) ||
|
|
431
|
+
keyframeVarsCarryChannel(tween.vars, channels)),
|
|
432
|
+
),
|
|
433
|
+
);
|
|
434
|
+
}
|
|
435
|
+
|
|
436
|
+
/** GSAP owns this element's position: a tween or hold writes it, or GSAP already renders its
|
|
437
|
+
* transform (a CSS translate would then apply twice). Everything else moves by plain CSS. */
|
|
438
|
+
export function gsapWritesPosition(el: Element): boolean {
|
|
439
|
+
const cache = (el as { _gsap?: { renderTransform?: unknown } })._gsap;
|
|
440
|
+
return !!cache?.renderTransform || gsapWritesChannels(el, MOVE_CHANNELS);
|
|
441
|
+
}
|
|
442
|
+
|
|
408
443
|
/** `hasNonHoldTweenForElement` for an element in hand, read from its own window's timelines. */
|
|
409
444
|
export function elementHasNonHoldTween(el: Element, channels?: string[]): boolean {
|
|
410
445
|
const win = el.ownerDocument.defaultView as {
|