@hyperframes/studio 0.6.88 → 0.6.90

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 (49) hide show
  1. package/dist/assets/index-BKuDHMYl.js +146 -0
  2. package/dist/assets/index-D2NkPomd.css +1 -0
  3. package/dist/index.html +2 -2
  4. package/package.json +4 -4
  5. package/src/App.tsx +33 -193
  6. package/src/components/StudioLeftSidebar.tsx +6 -0
  7. package/src/components/StudioRightPanel.tsx +8 -0
  8. package/src/components/TimelineToolbar.tsx +54 -31
  9. package/src/components/editor/AnimationCard.tsx +15 -3
  10. package/src/components/editor/DomEditOverlay.test.ts +34 -1
  11. package/src/components/editor/FileTree.tsx +5 -1
  12. package/src/components/editor/FileTreeNodes.tsx +17 -3
  13. package/src/components/editor/LayersPanel.tsx +19 -4
  14. package/src/components/editor/PropertyPanel.tsx +82 -170
  15. package/src/components/editor/domEditOverlayStartGesture.ts +1 -0
  16. package/src/components/editor/gsapAnimatesProperty.ts +52 -0
  17. package/src/components/editor/manualEditsDom.ts +11 -57
  18. package/src/components/editor/manualOffsetDrag.test.ts +18 -1
  19. package/src/components/editor/manualOffsetDrag.ts +16 -10
  20. package/src/components/editor/propertyPanel3dTransform.tsx +133 -0
  21. package/src/components/editor/propertyPanelHelpers.ts +76 -0
  22. package/src/components/editor/propertyPanelStyleSections.tsx +1 -9
  23. package/src/components/editor/useDomEditOverlayGestures.ts +3 -0
  24. package/src/components/editor/useLayerDrag.ts +6 -3
  25. package/src/components/renders/RenderQueueItem.tsx +47 -46
  26. package/src/components/sidebar/CompositionsTab.tsx +15 -2
  27. package/src/components/sidebar/LeftSidebar.tsx +11 -0
  28. package/src/hooks/gsapDragCommit.ts +294 -0
  29. package/src/hooks/gsapKeyframeCacheHelpers.ts +88 -0
  30. package/src/hooks/gsapRuntimeBridge.ts +49 -402
  31. package/src/hooks/gsapRuntimeReaders.ts +201 -0
  32. package/src/hooks/timelineEditingHelpers.ts +148 -0
  33. package/src/hooks/useAnimatedPropertyCommit.ts +54 -12
  34. package/src/hooks/useBlockHandlers.ts +150 -0
  35. package/src/hooks/useClipboard.ts +1 -10
  36. package/src/hooks/useDomEditPreviewSync.ts +126 -0
  37. package/src/hooks/useDomEditSession.ts +11 -79
  38. package/src/hooks/useGestureCommit.ts +166 -0
  39. package/src/hooks/useGestureRecording.ts +271 -169
  40. package/src/hooks/useGsapScriptCommits.ts +7 -80
  41. package/src/hooks/useLintModal.ts +97 -25
  42. package/src/hooks/useTimelineEditing.ts +10 -132
  43. package/src/player/components/TimelineCanvas.tsx +24 -7
  44. package/src/player/components/useTimelinePlayhead.ts +2 -1
  45. package/src/player/store/playerStore.ts +12 -0
  46. package/src/utils/gsapSoftReload.ts +18 -1
  47. package/src/utils/studioUrlState.test.ts +9 -0
  48. package/dist/assets/index-B9_ctmee.js +0 -143
  49. package/dist/assets/index-CGlIm_-E.css +0 -1
@@ -32,6 +32,7 @@ import {
32
32
  } from "./manualEditsTypes";
33
33
  import { roundRotationAngle } from "./manualEditsParsing";
34
34
  import { applyStudioMotionFromDom } from "./studioMotion";
35
+ import { gsapAnimatesProperty } from "./gsapAnimatesProperty";
35
36
 
36
37
  /* ── Gesture tracking ─────────────────────────────────────────────── */
37
38
  let studioManualEditGestureId = 0;
@@ -519,70 +520,23 @@ function queryStudioElements(doc: Document, attr: string): HTMLElement[] {
519
520
 
520
521
  function reapplyPathOffsets(doc: Document): void {
521
522
  for (const el of queryStudioElements(doc, STUDIO_PATH_OFFSET_ATTR)) {
522
- // Skip elements where GSAP actively animates position — GSAP bakes the
523
- // CSS translate into its transform and sets translate: none every tick.
524
- // Stripping/restoring would oscillate against GSAP's rendering.
525
- if (gsapAnimatesProperty(el, "x", "y")) continue;
523
+ const gsapSkip = gsapAnimatesProperty(el, "x", "y");
526
524
  const x = el.style.getPropertyValue(STUDIO_OFFSET_X_PROP);
527
525
  const y = el.style.getPropertyValue(STUDIO_OFFSET_Y_PROP);
526
+ if (gsapSkip) continue;
528
527
  if (x || y) {
529
- applyStudioPathOffset(el, {
530
- x: Number.parseFloat(x) || 0,
531
- y: Number.parseFloat(y) || 0,
532
- });
528
+ applyStudioPathOffset(
529
+ el,
530
+ {
531
+ x: Number.parseFloat(x) || 0,
532
+ y: Number.parseFloat(y) || 0,
533
+ },
534
+ { updateBase: false },
535
+ );
533
536
  }
534
537
  }
535
538
  }
536
539
 
537
- function gsapAnimatesProperty(el: HTMLElement, ...props: string[]): boolean {
538
- const win = el.ownerDocument.defaultView as
539
- | (Window & {
540
- __timelines?: Record<
541
- string,
542
- {
543
- getChildren?: (
544
- deep: boolean,
545
- ) => Array<{ targets?: () => Element[]; vars?: Record<string, unknown> }>;
546
- }
547
- >;
548
- })
549
- | null;
550
- if (!win?.__timelines) return false;
551
- const propSet = new Set(props);
552
- for (const tl of Object.values(win.__timelines)) {
553
- if (!tl?.getChildren) continue;
554
- try {
555
- for (const child of tl.getChildren(true)) {
556
- if (!child.targets || !child.vars) continue;
557
- let targetsEl = false;
558
- for (const t of child.targets()) {
559
- if (t === el || (el.id && t.id === el.id)) {
560
- targetsEl = true;
561
- break;
562
- }
563
- }
564
- if (!targetsEl) continue;
565
- const vars = child.vars;
566
- for (const p of propSet) {
567
- if (p in vars) return true;
568
- }
569
- if (vars.keyframes && typeof vars.keyframes === "object") {
570
- for (const kfVal of Object.values(vars.keyframes as Record<string, unknown>)) {
571
- if (kfVal && typeof kfVal === "object") {
572
- for (const p of propSet) {
573
- if (p in (kfVal as Record<string, unknown>)) return true;
574
- }
575
- }
576
- }
577
- }
578
- }
579
- } catch {
580
- /* */
581
- }
582
- }
583
- return false;
584
- }
585
-
586
540
  function reapplyBoxSizes(doc: Document): void {
587
541
  for (const el of queryStudioElements(doc, STUDIO_BOX_SIZE_ATTR)) {
588
542
  if (gsapAnimatesProperty(el, "width", "height")) continue;
@@ -66,6 +66,7 @@ describe("measureManualOffsetDragScreenToOffsetMatrix", () => {
66
66
  it("measures the element center response and restores probe styles", () => {
67
67
  const window = new Window();
68
68
  const element = window.document.createElement("div");
69
+ element.setAttribute("data-hf-studio-path-offset", "true");
69
70
  window.document.body.append(element);
70
71
 
71
72
  element.getBoundingClientRect = () => {
@@ -109,6 +110,7 @@ describe("measureManualOffsetDragScreenToOffsetMatrix", () => {
109
110
  iframe.getBoundingClientRect = () => new window.DOMRect(50, 40, 100, 50);
110
111
 
111
112
  const element = iframeDocument.createElement("div");
113
+ element.setAttribute("data-hf-studio-path-offset", "true");
112
114
  iframeDocument.body.append(element);
113
115
  element.getBoundingClientRect = () => {
114
116
  const offsetX = Number.parseFloat(element.style.getPropertyValue(STUDIO_OFFSET_X_PROP)) || 0;
@@ -130,7 +132,7 @@ describe("measureManualOffsetDragScreenToOffsetMatrix", () => {
130
132
  expect(nextOffset).toEqual({ x: 100, y: 50 });
131
133
  });
132
134
 
133
- it("rejects elements whose movement response cannot be measured", () => {
135
+ it("returns identity matrix for non-path-offset elements with zero initial offset", () => {
134
136
  const window = new Window();
135
137
  const element = window.document.createElement("div");
136
138
  window.document.body.append(element);
@@ -138,6 +140,21 @@ describe("measureManualOffsetDragScreenToOffsetMatrix", () => {
138
140
 
139
141
  const measured = measureManualOffsetDragScreenToOffsetMatrix(element, { x: 0, y: 0 });
140
142
 
143
+ expect(measured.ok).toBe(true);
144
+ if (measured.ok) {
145
+ expectMatrixClose(measured.matrix, { a: 1, b: 0, c: 0, d: 1 });
146
+ }
147
+ });
148
+
149
+ it("rejects path-offset elements whose movement response cannot be measured", () => {
150
+ const window = new Window();
151
+ const element = window.document.createElement("div");
152
+ element.setAttribute("data-hf-studio-path-offset", "true");
153
+ window.document.body.append(element);
154
+ element.getBoundingClientRect = () => new window.DOMRect(10, 20, 12, 8);
155
+
156
+ const measured = measureManualOffsetDragScreenToOffsetMatrix(element, { x: 0, y: 0 });
157
+
141
158
  expect(measured.ok).toBe(false);
142
159
  });
143
160
  });
@@ -142,8 +142,18 @@ export function applyManualOffsetDragMatrix(matrix: ManualOffsetDragMatrix, poin
142
142
  export function measureManualOffsetDragScreenToOffsetMatrix(
143
143
  element: HTMLElement,
144
144
  initialOffset: { x: number; y: number },
145
- options: { probeSize?: number } = {},
145
+ options: { probeSize?: number; scaleX?: number; scaleY?: number } = {},
146
146
  ): { ok: true; matrix: ManualOffsetDragMatrix } | { ok: false; reason: string } {
147
+ if (
148
+ !element.hasAttribute("data-hf-studio-path-offset") &&
149
+ initialOffset.x === 0 &&
150
+ initialOffset.y === 0
151
+ ) {
152
+ const sx = options.scaleX || 1;
153
+ const sy = options.scaleY || 1;
154
+ return { ok: true, matrix: { a: 1 / sx, b: 0, c: 0, d: 1 / sy } };
155
+ }
156
+
147
157
  const probeSize = options.probeSize ?? DEFAULT_OFFSET_PROBE_PX;
148
158
  if (!Number.isFinite(probeSize) || probeSize <= 0) {
149
159
  return { ok: false, reason: "Invalid movement probe size." };
@@ -235,8 +245,6 @@ export function createManualOffsetDragMember(input: {
235
245
  input.element.setAttribute("data-hf-drag-initial-offset-x", String(initialOffset.x));
236
246
  input.element.setAttribute("data-hf-drag-initial-offset-y", String(initialOffset.y));
237
247
 
238
- // Capture GSAP's x/y BEFORE any draft applies gsap.set — the commit path
239
- // needs the original (uncorrupted) GSAP position to compute the new keyframe value.
240
248
  const win = input.element.ownerDocument.defaultView as
241
249
  | (Window & {
242
250
  gsap?: { getProperty?: (el: Element, prop: string) => number };
@@ -248,8 +256,6 @@ export function createManualOffsetDragMember(input: {
248
256
  input.element.setAttribute("data-hf-drag-gsap-base-x", String(gsapX));
249
257
  input.element.setAttribute("data-hf-drag-gsap-base-y", String(gsapY));
250
258
 
251
- // Pause GSAP timelines during drag to prevent the tween from overwriting
252
- // the draft's gsap.set on every tick. Track which we paused to resume later.
253
259
  if (win?.__timelines) {
254
260
  const paused: string[] = [];
255
261
  for (const [id, tl] of Object.entries(win.__timelines)) {
@@ -269,7 +275,10 @@ export function createManualOffsetDragMember(input: {
269
275
 
270
276
  const initialPathOffset = captureStudioPathOffset(input.element);
271
277
  const gestureToken = beginStudioManualEditGesture(input.element);
272
- const measured = measureManualOffsetDragScreenToOffsetMatrix(input.element, initialOffset);
278
+ const measured = measureManualOffsetDragScreenToOffsetMatrix(input.element, initialOffset, {
279
+ scaleX: input.rect.editScaleX,
280
+ scaleY: input.rect.editScaleY,
281
+ });
273
282
  if (!measured.ok) {
274
283
  // Fallback: when GSAP transforms interfere with probe measurement, use
275
284
  // the preview scale as an approximation. The commit path reads the actual
@@ -363,7 +372,7 @@ export function endManualOffsetDragMembers(members: ManualOffsetDragMember[]): v
363
372
  }
364
373
  }
365
374
 
366
- function resumeGsapTimelines(element: HTMLElement): void {
375
+ export function resumeGsapTimelines(element: HTMLElement): void {
367
376
  const ids = element.getAttribute("data-hf-drag-paused-timelines");
368
377
  element.removeAttribute("data-hf-drag-paused-timelines");
369
378
  if (!ids) return;
@@ -374,9 +383,6 @@ function resumeGsapTimelines(element: HTMLElement): void {
374
383
  })
375
384
  | null;
376
385
  if (!win) return;
377
- // Re-seek to the current time to restore the paused timeline's render state.
378
- // play() would start playback; pause() already stops. Seek re-renders at the
379
- // current position without starting playback.
380
386
  const t = win.__player?.getTime?.() ?? 0;
381
387
  win.__player?.seek?.(t);
382
388
  }
@@ -0,0 +1,133 @@
1
+ import type { DomEditSelection } from "./domEditingTypes";
2
+ import { STUDIO_KEYFRAMES_ENABLED } from "./manualEditingAvailability";
3
+ import { MetricField } from "./propertyPanelPrimitives";
4
+ import { KeyframeNavigation } from "./KeyframeNavigation";
5
+ import { formatPxMetricValue, parsePxMetricValue, RESPONSIVE_GRID } from "./propertyPanelHelpers";
6
+
7
+ type KeyframeEntry = Array<{
8
+ percentage: number;
9
+ properties: Record<string, number | string>;
10
+ ease?: string;
11
+ }> | null;
12
+
13
+ interface PropertyPanel3dTransformProps {
14
+ gsapRuntimeValues: Record<string, number>;
15
+ gsapAnimId: string | null;
16
+ gsapKeyframes: KeyframeEntry;
17
+ currentPct: number;
18
+ elStart: number;
19
+ elDuration: number;
20
+ element: DomEditSelection;
21
+ onCommitAnimatedProperty?: (
22
+ element: DomEditSelection,
23
+ property: string,
24
+ value: number,
25
+ ) => Promise<void>;
26
+ onSeekToTime?: (time: number) => void;
27
+ onRemoveKeyframe?: (animId: string, pct: number) => void;
28
+ onConvertToKeyframes?: (animId: string) => void;
29
+ }
30
+
31
+ export function PropertyPanel3dTransform({
32
+ gsapRuntimeValues,
33
+ gsapAnimId,
34
+ gsapKeyframes,
35
+ currentPct,
36
+ elStart,
37
+ elDuration,
38
+ element,
39
+ onCommitAnimatedProperty,
40
+ onSeekToTime,
41
+ onRemoveKeyframe,
42
+ onConvertToKeyframes,
43
+ }: PropertyPanel3dTransformProps) {
44
+ return (
45
+ <div className="mt-3 border-t border-neutral-800/40 pt-3">
46
+ <div className="mb-2 text-[10px] font-medium uppercase tracking-wider text-neutral-600">
47
+ 3D Transform
48
+ </div>
49
+ <div className={RESPONSIVE_GRID}>
50
+ <div className="flex items-center gap-1">
51
+ <div className="flex-1">
52
+ <MetricField
53
+ label="Z"
54
+ value={formatPxMetricValue(gsapRuntimeValues.z ?? 0)}
55
+ scrub
56
+ onCommit={(next) => {
57
+ const v = parsePxMetricValue(next);
58
+ if (v != null && onCommitAnimatedProperty) {
59
+ void onCommitAnimatedProperty(element, "z", v);
60
+ }
61
+ }}
62
+ />
63
+ </div>
64
+ {STUDIO_KEYFRAMES_ENABLED && (gsapAnimId || onCommitAnimatedProperty) && (
65
+ <KeyframeNavigation
66
+ property="z"
67
+ keyframes={gsapKeyframes}
68
+ currentPercentage={currentPct}
69
+ onSeek={(pct) => onSeekToTime?.(elStart + (pct / 100) * elDuration)}
70
+ onAddKeyframe={() => {
71
+ if (onCommitAnimatedProperty) {
72
+ void onCommitAnimatedProperty(element, "z", gsapRuntimeValues?.z ?? 0);
73
+ }
74
+ }}
75
+ onRemoveKeyframe={(pct) => gsapAnimId && onRemoveKeyframe?.(gsapAnimId, pct)}
76
+ onConvertToKeyframes={() => gsapAnimId && onConvertToKeyframes?.(gsapAnimId)}
77
+ />
78
+ )}
79
+ </div>
80
+ <div className="flex items-center gap-1">
81
+ <div className="flex-1">
82
+ <MetricField
83
+ label="Scale"
84
+ value={String(gsapRuntimeValues.scale ?? 1)}
85
+ scrub
86
+ onCommit={(next) => {
87
+ const v = Number.parseFloat(next);
88
+ if (Number.isFinite(v) && onCommitAnimatedProperty) {
89
+ void onCommitAnimatedProperty(element, "scale", v);
90
+ }
91
+ }}
92
+ />
93
+ </div>
94
+ {STUDIO_KEYFRAMES_ENABLED && (gsapAnimId || onCommitAnimatedProperty) && (
95
+ <KeyframeNavigation
96
+ property="scale"
97
+ keyframes={gsapKeyframes}
98
+ currentPercentage={currentPct}
99
+ onSeek={(pct) => onSeekToTime?.(elStart + (pct / 100) * elDuration)}
100
+ onAddKeyframe={() => {
101
+ if (onCommitAnimatedProperty) {
102
+ void onCommitAnimatedProperty(element, "scale", gsapRuntimeValues?.scale ?? 1);
103
+ }
104
+ }}
105
+ onRemoveKeyframe={(pct) => gsapAnimId && onRemoveKeyframe?.(gsapAnimId, pct)}
106
+ onConvertToKeyframes={() => gsapAnimId && onConvertToKeyframes?.(gsapAnimId)}
107
+ />
108
+ )}
109
+ </div>
110
+ <MetricField
111
+ label="RotX"
112
+ value={`${gsapRuntimeValues.rotationX ?? 0}°`}
113
+ onCommit={(next) => {
114
+ const v = Number.parseFloat(next.replace("°", ""));
115
+ if (Number.isFinite(v) && onCommitAnimatedProperty) {
116
+ void onCommitAnimatedProperty(element, "rotationX", v);
117
+ }
118
+ }}
119
+ />
120
+ <MetricField
121
+ label="RotY"
122
+ value={`${gsapRuntimeValues.rotationY ?? 0}°`}
123
+ onCommit={(next) => {
124
+ const v = Number.parseFloat(next.replace("°", ""));
125
+ if (Number.isFinite(v) && onCommitAnimatedProperty) {
126
+ void onCommitAnimatedProperty(element, "rotationY", v);
127
+ }
128
+ }}
129
+ />
130
+ </div>
131
+ </div>
132
+ );
133
+ }
@@ -2,6 +2,7 @@ import { parseCssColor, type ParsedColor } from "./colorValue";
2
2
  import { COMMON_LOCAL_FONT_FAMILIES } from "./fontCatalog";
3
3
  import type { DomEditSelection } from "./domEditing";
4
4
  import type { ImportedFontAsset } from "./fontAssets";
5
+ import type { GsapAnimation } from "@hyperframes/core/gsap-parser";
5
6
 
6
7
  export interface PropertyPanelProps {
7
8
  projectId: string;
@@ -505,3 +506,78 @@ export function computeFitToChildrenSize(
505
506
  const height = Math.round((maxY - minY) * scaleY);
506
507
  return width > 0 && height > 0 ? { width, height } : null;
507
508
  }
509
+
510
+ // ── GSAP runtime value readers (used by PropertyPanel) ────────────────────
511
+
512
+ export function readGsapRuntimeValuesForPanel(
513
+ gsapAnimId: string | null,
514
+ gsapAnimations: GsapAnimation[],
515
+ element: DomEditSelection,
516
+ previewIframeRef: React.RefObject<HTMLIFrameElement | null>,
517
+ ): Record<string, number> | null {
518
+ if (!gsapAnimId || gsapAnimations.length === 0) return null;
519
+ const iframe = previewIframeRef?.current;
520
+ if (!iframe?.contentWindow) return null;
521
+ const selector = element.id ? `#${element.id}` : element.selector;
522
+ if (!selector) return null;
523
+ try {
524
+ const gsap = (
525
+ iframe.contentWindow as unknown as {
526
+ gsap?: { getProperty: (el: Element, prop: string) => number | string };
527
+ }
528
+ ).gsap;
529
+ if (!gsap?.getProperty) return null;
530
+ const el = iframe.contentDocument?.querySelector(selector);
531
+ if (!el) return null;
532
+ const propKeys = new Set<string>();
533
+ for (const anim of gsapAnimations) {
534
+ if (anim.keyframes) {
535
+ for (const kf of anim.keyframes.keyframes) {
536
+ for (const p of Object.keys(kf.properties)) propKeys.add(p);
537
+ }
538
+ }
539
+ for (const p of Object.keys(anim.properties)) propKeys.add(p);
540
+ }
541
+ const result: Record<string, number> = {};
542
+ for (const prop of propKeys) {
543
+ const v = Number(gsap.getProperty(el, prop));
544
+ if (Number.isFinite(v)) result[prop] = Math.round(v * 100) / 100;
545
+ }
546
+ return Object.keys(result).length > 0 ? result : null;
547
+ } catch {
548
+ return null;
549
+ }
550
+ }
551
+
552
+ export function readGsapBorderRadiusForPanel(
553
+ gsapRuntimeValues: Record<string, number> | null,
554
+ gsapAnimations: GsapAnimation[],
555
+ element: DomEditSelection,
556
+ previewIframeRef: React.RefObject<HTMLIFrameElement | null>,
557
+ ): { tl: number; tr: number; br: number; bl: number } | null {
558
+ if (!gsapRuntimeValues || !("borderRadius" in gsapRuntimeValues)) {
559
+ const hasBRProp = gsapAnimations.some(
560
+ (a) =>
561
+ "borderRadius" in a.properties ||
562
+ a.keyframes?.keyframes.some((kf) => "borderRadius" in kf.properties),
563
+ );
564
+ if (!hasBRProp) return null;
565
+ }
566
+ const iframe = previewIframeRef?.current;
567
+ const selector = element.id ? `#${element.id}` : element.selector;
568
+ if (!iframe?.contentDocument || !selector) return null;
569
+ try {
570
+ const el = iframe.contentDocument.querySelector(selector);
571
+ if (!el) return null;
572
+ const cs = iframe.contentWindow!.getComputedStyle(el);
573
+ const parse = (v: string) => Number.parseFloat(v) || 0;
574
+ return {
575
+ tl: parse(cs.borderTopLeftRadius),
576
+ tr: parse(cs.borderTopRightRadius),
577
+ br: parse(cs.borderBottomRightRadius),
578
+ bl: parse(cs.borderBottomLeftRadius),
579
+ };
580
+ } catch {
581
+ return null;
582
+ }
583
+ }
@@ -369,15 +369,7 @@ export function StyleSections({
369
369
  </div>
370
370
  </Section>
371
371
 
372
- <Section
373
- title="Fill"
374
- icon={<Palette size={15} />}
375
- accessory={
376
- <div className="rounded-full border border-neutral-700 bg-neutral-900 px-2.5 py-1 text-[10px] font-medium uppercase tracking-[0.16em] text-neutral-400">
377
- {preferredFillMode}
378
- </div>
379
- }
380
- >
372
+ <Section title="Fill" icon={<Palette size={15} />}>
381
373
  <div className="space-y-4">
382
374
  <SegmentedControl
383
375
  disabled={styleEditingDisabled}
@@ -11,6 +11,7 @@ import {
11
11
  applyManualOffsetDragDraft,
12
12
  endManualOffsetDragMembers,
13
13
  restoreManualOffsetDragMembers,
14
+ resumeGsapTimelines,
14
15
  } from "./manualOffsetDrag";
15
16
  import {
16
17
  applyStudioBoxSize,
@@ -401,6 +402,7 @@ export function createDomEditOverlayGestureHandlers(opts: UseDomEditOverlayGestu
401
402
  if (g.kind === "drag" && movedDistance < BLOCKED_MOVE_THRESHOLD_PX) {
402
403
  restoreStudioPathOffset(sel.element, g.initialPathOffset);
403
404
  endStudioManualEditGesture(sel.element, g.manualEditDragToken);
405
+ resumeGsapTimelines(sel.element);
404
406
  if (box) {
405
407
  box.style.left = `${g.originLeft}px`;
406
408
  box.style.top = `${g.originTop}px`;
@@ -507,6 +509,7 @@ export function createDomEditOverlayGestureHandlers(opts: UseDomEditOverlayGestu
507
509
  if (g?.mode === "path-offset" && sel) {
508
510
  restoreStudioPathOffset(sel.element, g.initialPathOffset);
509
511
  endStudioManualEditGesture(sel.element, g.manualEditDragToken);
512
+ resumeGsapTimelines(sel.element);
510
513
  restoreGestureOverlayRect(g);
511
514
  }
512
515
  if (g?.mode === "box-size" && sel) {
@@ -138,9 +138,6 @@ export function useLayerDrag({
138
138
  const container = scrollContainerRef.current;
139
139
  if (!container) return;
140
140
 
141
- e.preventDefault();
142
- container.setPointerCapture(e.pointerId);
143
-
144
141
  dragRef.current = {
145
142
  pointerId: e.pointerId,
146
143
  startY: e.clientY,
@@ -163,6 +160,12 @@ export function useLayerDrag({
163
160
  if (!drag.activated) {
164
161
  if (Math.abs(e.clientY - drag.startY) < DRAG_THRESHOLD_PX) return;
165
162
  drag.activated = true;
163
+ const container = scrollContainerRef.current;
164
+ if (container && drag.pointerId != null) {
165
+ try {
166
+ container.setPointerCapture(drag.pointerId);
167
+ } catch {}
168
+ }
166
169
  setDragKey(visibleLayers[drag.dragLayerIndex]?.key ?? null);
167
170
  }
168
171
 
@@ -142,53 +142,54 @@ export const RenderQueueItem = memo(function RenderQueueItem({
142
142
  )}
143
143
  </div>
144
144
 
145
- {/* Actions */}
146
- {hovered && (
147
- <div className="flex items-center gap-1 flex-shrink-0">
148
- {isComplete && (
149
- <button
150
- onClick={handleDownload}
151
- className="p-1 rounded text-panel-text-4 hover:text-panel-accent transition-colors"
152
- title="Download"
153
- >
154
- <svg
155
- width="12"
156
- height="12"
157
- viewBox="0 0 24 24"
158
- fill="none"
159
- stroke="currentColor"
160
- strokeWidth="2"
161
- strokeLinecap="round"
162
- strokeLinejoin="round"
163
- >
164
- <path d="M21 15v4a2 2 0 01-2 2H5a2 2 0 01-2-2v-4" />
165
- <polyline points="7 10 12 15 17 10" />
166
- <line x1="12" y1="15" x2="12" y2="3" />
167
- </svg>
168
- </button>
169
- )}
170
- <button
171
- onClick={(e) => {
172
- e.stopPropagation();
173
- onDelete();
174
- }}
175
- className="p-1 rounded text-panel-text-4 hover:text-red-400 transition-colors"
176
- title="Remove"
145
+ {/* Actions — always visible to prevent layout shifts */}
146
+ <div className="flex items-center gap-1 flex-shrink-0">
147
+ <button
148
+ onClick={isComplete ? handleDownload : undefined}
149
+ className={`p-1 rounded transition-colors ${
150
+ isComplete
151
+ ? "text-panel-text-5 hover:text-panel-accent"
152
+ : "text-panel-text-5/30 pointer-events-none"
153
+ }`}
154
+ title={isComplete ? "Download" : "Rendering..."}
155
+ disabled={!isComplete}
156
+ >
157
+ <svg
158
+ width="12"
159
+ height="12"
160
+ viewBox="0 0 24 24"
161
+ fill="none"
162
+ stroke="currentColor"
163
+ strokeWidth="2"
164
+ strokeLinecap="round"
165
+ strokeLinejoin="round"
177
166
  >
178
- <svg
179
- width="12"
180
- height="12"
181
- viewBox="0 0 24 24"
182
- fill="none"
183
- stroke="currentColor"
184
- strokeWidth="2"
185
- strokeLinecap="round"
186
- >
187
- <path d="M18 6L6 18M6 6l12 12" />
188
- </svg>
189
- </button>
190
- </div>
191
- )}
167
+ <path d="M21 15v4a2 2 0 01-2 2H5a2 2 0 01-2-2v-4" />
168
+ <polyline points="7 10 12 15 17 10" />
169
+ <line x1="12" y1="15" x2="12" y2="3" />
170
+ </svg>
171
+ </button>
172
+ <button
173
+ onClick={(e) => {
174
+ e.stopPropagation();
175
+ onDelete();
176
+ }}
177
+ className="p-1 rounded text-panel-text-5 hover:text-red-400 transition-colors"
178
+ title="Remove"
179
+ >
180
+ <svg
181
+ width="12"
182
+ height="12"
183
+ viewBox="0 0 24 24"
184
+ fill="none"
185
+ stroke="currentColor"
186
+ strokeWidth="2"
187
+ strokeLinecap="round"
188
+ >
189
+ <path d="M18 6L6 18M6 6l12 12" />
190
+ </svg>
191
+ </button>
192
+ </div>
192
193
  </div>
193
194
  </div>
194
195
  );
@@ -7,6 +7,7 @@ interface CompositionsTabProps {
7
7
  onSelect: (comp: string) => void;
8
8
  onRenderComposition?: (comp: string) => void;
9
9
  isRendering?: boolean;
10
+ lintFindingsByFile?: Map<string, { count: number; messages: string[] }>;
10
11
  }
11
12
 
12
13
  const DEFAULT_PREVIEW_STAGE = { width: 1920, height: 1080 };
@@ -111,6 +112,7 @@ function CompCard({
111
112
  onSelect,
112
113
  onRender,
113
114
  isRendering,
115
+ lintInfo,
114
116
  }: {
115
117
  projectId: string;
116
118
  comp: string;
@@ -118,6 +120,7 @@ function CompCard({
118
120
  onSelect: () => void;
119
121
  onRender?: () => void;
120
122
  isRendering?: boolean;
123
+ lintInfo?: { count: number; messages: string[] };
121
124
  }) {
122
125
  const [hovered, setHovered] = useState(false);
123
126
  const [stageSize, setStageSize] = useState(DEFAULT_PREVIEW_STAGE);
@@ -215,8 +218,16 @@ function CompCard({
215
218
  tabIndex={-1}
216
219
  />
217
220
  </div>
218
- <div className="min-w-0 flex-1">
219
- <span className="text-[11px] font-medium text-neutral-300 truncate block">{name}</span>
221
+ <div
222
+ className="min-w-0 flex-1"
223
+ title={lintInfo && lintInfo.count > 0 ? lintInfo.messages.join("\n") : undefined}
224
+ >
225
+ <div className="flex items-center gap-1">
226
+ <span className="text-[11px] font-medium text-neutral-300 truncate">{name}</span>
227
+ {lintInfo && lintInfo.count > 0 && (
228
+ <span className="flex-shrink-0 w-2 h-2 rounded-full bg-amber-400" />
229
+ )}
230
+ </div>
220
231
  <span className="text-[9px] text-neutral-600 truncate block">{comp}</span>
221
232
  </div>
222
233
  {onRender && (
@@ -262,6 +273,7 @@ export const CompositionsTab = memo(function CompositionsTab({
262
273
  onSelect,
263
274
  onRenderComposition,
264
275
  isRendering,
276
+ lintFindingsByFile,
265
277
  }: CompositionsTabProps) {
266
278
  if (compositions.length === 0) {
267
279
  return (
@@ -282,6 +294,7 @@ export const CompositionsTab = memo(function CompositionsTab({
282
294
  onSelect={() => onSelect(comp)}
283
295
  onRender={onRenderComposition ? () => onRenderComposition(comp) : undefined}
284
296
  isRendering={isRendering}
297
+ lintInfo={lintFindingsByFile?.get(comp)}
285
298
  />
286
299
  ))}
287
300
  </div>