@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.
Files changed (59) hide show
  1. package/dist/assets/{hyperframes-player-Rml34FRW.js → hyperframes-player-CFJLrHRF.js} +1 -1
  2. package/dist/assets/{index-CVDArg3m.js → index-BBNVnnyH.js} +179 -179
  3. package/dist/assets/{index-D9YzDuBx.js → index-C33EDBL2.js} +1 -1
  4. package/dist/assets/{index-C_fi-jTe.js → index-CJOzbslX.js} +1 -1
  5. package/dist/{chunk-U3EE4ACX.js → chunk-7X4XRP3L.js} +14 -13
  6. package/dist/chunk-7X4XRP3L.js.map +1 -0
  7. package/dist/index.d.ts +91 -21
  8. package/dist/index.html +1 -1
  9. package/dist/index.js +826 -538
  10. package/dist/index.js.map +1 -1
  11. package/dist/player.js +1 -1
  12. package/package.json +8 -8
  13. package/src/App.tsx +2 -0
  14. package/src/components/dock/Dock.test.tsx +78 -3
  15. package/src/components/dock/Dock.tsx +45 -18
  16. package/src/components/dock/dockLayout.ts +27 -14
  17. package/src/components/dock/dockLayoutSchema.ts +11 -4
  18. package/src/components/dock/dockLayoutStore.ts +4 -2
  19. package/src/components/editor/PropertyPanel.tsx +3 -3
  20. package/src/components/editor/domEditOverlayStartGesture.test.ts +83 -0
  21. package/src/components/editor/domEditOverlayStartGesture.ts +6 -4
  22. package/src/components/editor/manualOffsetDrag.test.ts +70 -0
  23. package/src/components/editor/manualOffsetDrag.ts +50 -40
  24. package/src/components/editor/plainTranslate.test.ts +62 -0
  25. package/src/components/editor/plainTranslate.ts +85 -0
  26. package/src/components/editor/propertyPanelTransformCommit.ts +3 -2
  27. package/src/components/editor/useDomEditNudge.ts +1 -1
  28. package/src/hooks/appHotkeysDispatch.ts +1 -7
  29. package/src/hooks/elementOffsetStager.ts +33 -2
  30. package/src/hooks/gsapRuntimeBridge.ts +1 -17
  31. package/src/hooks/gsapRuntimeKeyframes.ts +35 -0
  32. package/src/hooks/useAppHotkeys.previewForwarding.test.tsx +67 -2
  33. package/src/hooks/useAppHotkeys.ts +18 -45
  34. package/src/hooks/useDomEditCommits.test.tsx +12 -3
  35. package/src/hooks/useDomEditCommits.ts +2 -1
  36. package/src/hooks/useDomEditTextCommits.test.tsx +62 -5
  37. package/src/hooks/useDomEditTextCommits.ts +35 -35
  38. package/src/hooks/useDomGeometryCommit.test.tsx +62 -4
  39. package/src/hooks/useDomGeometryCommits.test.tsx +4 -2
  40. package/src/hooks/useEditHistoryActions.paint.test.tsx +166 -0
  41. package/src/hooks/useEditHistoryActions.test.tsx +67 -9
  42. package/src/hooks/useEditHistoryActions.ts +36 -8
  43. package/src/hooks/useGsapAwareEditing.test.tsx +46 -18
  44. package/src/hooks/useGsapAwareEditing.ts +11 -11
  45. package/src/hooks/usePersistentEditHistory.test.ts +46 -0
  46. package/src/hooks/usePersistentEditHistory.ts +101 -19
  47. package/src/hooks/usePreviewPersistence.ts +25 -2
  48. package/src/hooks/useStudioExternalFileChanges.ts +7 -3
  49. package/src/index.ts +4 -0
  50. package/src/player/components/timelineDragDrop.test.tsx +61 -5
  51. package/src/player/components/timelineDragDrop.ts +29 -8
  52. package/src/player/components/useTimelineProviderState.tsx +1 -0
  53. package/src/utils/domEditSaveQueue.ts +7 -2
  54. package/src/utils/gsapSoftReload.ts +1 -1
  55. package/src/utils/gsapUndoRestore.test.ts +46 -0
  56. package/src/utils/gsapUndoRestore.ts +166 -91
  57. package/src/utils/studioPendingEdits.ts +14 -5
  58. package/src/utils/studioUiPreferences.ts +8 -6
  59. 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
- applyStudioPathOffsetDraft(element, initialOffset);
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
- applyStudioPathOffsetDraft(element, {
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
- applyStudioPathOffsetDraft(element, {
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
- // Base the drag on the offset ACTUALLY applied, never the raw (possibly dormant)
337
- // var — see readAppliedStudioPathOffset. This keeps the commit purely relative
338
- // (applied + delta) so a stale offset can't fling the element off-screen.
339
- const initialOffset = readAppliedStudioPathOffset(input.element);
340
- input.element.setAttribute("data-hf-drag-initial-offset-x", String(initialOffset.x));
341
- input.element.setAttribute("data-hf-drag-initial-offset-y", String(initialOffset.y));
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
- const gsapX = win?.gsap?.getProperty?.(input.element, "x") || 0;
350
- const gsapY = win?.gsap?.getProperty?.(input.element, "y") || 0;
351
- input.element.setAttribute("data-hf-drag-gsap-base-x", String(gsapX));
352
- input.element.setAttribute("data-hf-drag-gsap-base-y", String(gsapY));
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
- // Position is single-sourced on the GSAP timeline; preview through gsap.set so
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 restampManualOffsetDragGestureBase(member: ManualOffsetDragMember): void {
455
- member.element.setAttribute("data-hf-drag-gsap-base-x", String(member.baseGsap.x));
456
- member.element.setAttribute("data-hf-drag-gsap-base-y", String(member.baseGsap.y));
457
- member.element.setAttribute("data-hf-drag-initial-offset-x", String(member.initialOffset.x));
458
- member.element.setAttribute("data-hf-drag-initial-offset-y", String(member.initialOffset.y));
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
- restampManualOffsetDragGestureBase(member);
466
- // Optimistic visual through the GSAP channel (same as the live draft and the
467
- // committed `tl.set`), so the element holds its dropped position until the
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
- if (!applyOffsetDragDraftViaGsap(member.element, offset, member.baseGsap)) {
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
- getOffsetDragGsap(member.element)?.set(member.element, { ...member.baseGsap });
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, readStudioPathOffset } from "./manualEdits";
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 = readStudioPathOffset(element.element);
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
- /** Exported so useAppHotkeys's own history-only preview listener can reuse
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
- /** Applies a shared-tween element's move live now; `save` persists it as left/top on that
34
- * element, `rollback` takes the live move back. Throws, after a toast, when it cannot. */
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 {