@hyperframes/studio 0.6.73 → 0.6.74

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 (60) hide show
  1. package/dist/assets/index-BcJO6Ej5.js +140 -0
  2. package/dist/assets/index-C2gBZ2km.css +1 -0
  3. package/dist/index.html +2 -2
  4. package/package.json +4 -4
  5. package/src/App.tsx +30 -24
  6. package/src/components/StudioPreviewArea.tsx +101 -26
  7. package/src/components/StudioRightPanel.tsx +3 -0
  8. package/src/components/StudioToast.tsx +18 -0
  9. package/src/components/TimelineToolbar.tsx +230 -4
  10. package/src/components/editor/AnimationCard.tsx +68 -4
  11. package/src/components/editor/DomEditOverlay.tsx +70 -1
  12. package/src/components/editor/GridOverlay.tsx +50 -0
  13. package/src/components/editor/KeyframeDiamond.tsx +49 -0
  14. package/src/components/editor/KeyframeNavigation.tsx +139 -0
  15. package/src/components/editor/PropertyPanel.tsx +293 -140
  16. package/src/components/editor/SnapGuideOverlay.tsx +166 -0
  17. package/src/components/editor/SnapToolbar.tsx +163 -0
  18. package/src/components/editor/SpringEaseEditor.tsx +256 -0
  19. package/src/components/editor/domEditOverlayGestures.ts +7 -0
  20. package/src/components/editor/domEditOverlayStartGesture.ts +28 -0
  21. package/src/components/editor/gsapAnimationConstants.ts +42 -0
  22. package/src/components/editor/gsapAnimationHelpers.ts +2 -1
  23. package/src/components/editor/manualEditingAvailability.ts +6 -0
  24. package/src/components/editor/manualEditsDom.ts +56 -2
  25. package/src/components/editor/manualOffsetDrag.ts +19 -3
  26. package/src/components/editor/propertyPanelHelpers.ts +90 -0
  27. package/src/components/editor/propertyPanelTimingSection.tsx +64 -0
  28. package/src/components/editor/snapEngine.test.ts +657 -0
  29. package/src/components/editor/snapEngine.ts +575 -0
  30. package/src/components/editor/snapTargetCollection.ts +147 -0
  31. package/src/components/editor/useDomEditOverlayGestures.ts +137 -10
  32. package/src/components/nle/NLELayout.tsx +18 -0
  33. package/src/contexts/DomEditContext.tsx +24 -0
  34. package/src/hooks/gsapRuntimeBridge.ts +585 -0
  35. package/src/hooks/gsapRuntimeKeyframes.ts +170 -0
  36. package/src/hooks/useAnimatedPropertyCommit.ts +131 -0
  37. package/src/hooks/useAppHotkeys.ts +63 -1
  38. package/src/hooks/useDomEditCommits.ts +39 -4
  39. package/src/hooks/useDomEditSession.ts +177 -63
  40. package/src/hooks/useGsapScriptCommits.ts +144 -7
  41. package/src/hooks/useGsapSelectionHandlers.ts +202 -0
  42. package/src/hooks/useGsapTweenCache.ts +174 -3
  43. package/src/hooks/useTimelineEditing.ts +93 -0
  44. package/src/icons/SystemIcons.tsx +2 -0
  45. package/src/player/components/ClipContextMenu.tsx +99 -0
  46. package/src/player/components/KeyframeDiamondContextMenu.tsx +164 -0
  47. package/src/player/components/Timeline.test.ts +2 -1
  48. package/src/player/components/Timeline.tsx +108 -68
  49. package/src/player/components/TimelineCanvas.tsx +47 -1
  50. package/src/player/components/TimelineClip.tsx +8 -3
  51. package/src/player/components/TimelineClipDiamonds.tsx +174 -0
  52. package/src/player/components/timelineDragDrop.ts +103 -0
  53. package/src/player/components/timelineLayout.ts +1 -1
  54. package/src/player/store/playerStore.ts +42 -0
  55. package/src/utils/editHistory.ts +1 -1
  56. package/src/utils/optimisticUpdate.test.ts +53 -0
  57. package/src/utils/optimisticUpdate.ts +18 -0
  58. package/src/utils/studioUiPreferences.ts +17 -0
  59. package/dist/assets/index-CrxThtSJ.css +0 -1
  60. package/dist/assets/index-Dc2HfqON.js +0 -140
@@ -3,25 +3,251 @@ import {
3
3
  getTimelineZoomPercent,
4
4
  } from "../player/components/timelineZoom";
5
5
  import { getTimelineToggleTitle } from "../utils/timelineDiscovery";
6
- import { usePlayerStore } from "../player";
6
+ import { usePlayerStore, type TimelineElement } from "../player";
7
+ import { STUDIO_KEYFRAMES_ENABLED } from "./editor/manualEditingAvailability";
7
8
  import { Tooltip } from "./ui";
9
+ import { Scissors } from "../icons/SystemIcons";
10
+ import type { GsapAnimation, GsapPercentageKeyframe } from "@hyperframes/core/gsap-parser";
11
+ import type { DomEditSelection } from "./editor/domEditingTypes";
12
+
13
+ function interpolateKeyframeProperties(
14
+ keyframes: GsapPercentageKeyframe[],
15
+ pct: number,
16
+ ): Record<string, number> {
17
+ const sorted = keyframes.slice().sort((a, b) => a.percentage - b.percentage);
18
+ const allProps = new Set<string>();
19
+ for (const kf of sorted) {
20
+ for (const p of Object.keys(kf.properties)) {
21
+ if (typeof kf.properties[p] === "number") allProps.add(p);
22
+ }
23
+ }
24
+ const result: Record<string, number> = {};
25
+ for (const prop of allProps) {
26
+ let prev: { pct: number; val: number } | null = null;
27
+ let next: { pct: number; val: number } | null = null;
28
+ for (const kf of sorted) {
29
+ const v = kf.properties[prop];
30
+ if (typeof v !== "number") continue;
31
+ if (kf.percentage <= pct) prev = { pct: kf.percentage, val: v };
32
+ if (kf.percentage >= pct && !next) next = { pct: kf.percentage, val: v };
33
+ }
34
+ if (prev && next && prev.pct !== next.pct) {
35
+ const t = (pct - prev.pct) / (next.pct - prev.pct);
36
+ result[prop] = Math.round(prev.val + t * (next.val - prev.val));
37
+ } else if (prev) {
38
+ result[prop] = Math.round(prev.val);
39
+ } else if (next) {
40
+ result[prop] = Math.round(next.val);
41
+ }
42
+ }
43
+ return result;
44
+ }
45
+
46
+ function readRuntimeKeyframeValues(
47
+ iframe: HTMLIFrameElement | null,
48
+ sel: DomEditSelection,
49
+ keyframes: GsapPercentageKeyframe[],
50
+ ): Record<string, number> {
51
+ if (!iframe?.contentWindow) return {};
52
+ let gsap: { getProperty?: (el: Element, prop: string) => number } | undefined;
53
+ try {
54
+ gsap = (iframe.contentWindow as Window & { gsap?: typeof gsap }).gsap;
55
+ } catch {
56
+ return {};
57
+ }
58
+ if (!gsap?.getProperty) return {};
59
+ const selector = sel.id ? `#${sel.id}` : sel.selector;
60
+ if (!selector) return {};
61
+ let doc: Document | null = null;
62
+ try {
63
+ doc = iframe.contentDocument;
64
+ } catch {
65
+ return {};
66
+ }
67
+ const element = doc?.querySelector(selector);
68
+ if (!element) return {};
69
+ const allProps = new Set<string>();
70
+ for (const kf of keyframes) {
71
+ for (const p of Object.keys(kf.properties)) {
72
+ if (typeof kf.properties[p] === "number") allProps.add(p);
73
+ }
74
+ }
75
+ const result: Record<string, number> = {};
76
+ for (const prop of allProps) {
77
+ const val = Number(gsap.getProperty(element, prop));
78
+ if (Number.isFinite(val)) result[prop] = Math.round(val);
79
+ }
80
+ return result;
81
+ }
82
+
83
+ interface DomEditSessionSlice {
84
+ domEditSelection: DomEditSelection | null;
85
+ selectedGsapAnimations: GsapAnimation[];
86
+ handleGsapRemoveKeyframe: (animId: string, pct: number) => void;
87
+ handleGsapAddKeyframe: (animId: string, pct: number, prop: string, val: number | string) => void;
88
+ handleGsapConvertToKeyframes: (animId: string) => void;
89
+ handleGsapMaterializeKeyframes?: (animId: string) => Promise<void>;
90
+ handleGsapAddAnimation: (method: "to" | "from" | "set" | "fromTo") => void;
91
+ previewIframeRef?: React.RefObject<HTMLIFrameElement | null>;
92
+ }
8
93
 
9
94
  interface TimelineToolbarProps {
10
95
  toggleTimelineVisibility: () => void;
96
+ domEditSession?: DomEditSessionSlice;
97
+ onSplitElement?: (element: TimelineElement, splitTime: number) => void;
11
98
  }
12
99
 
13
- export function TimelineToolbar({ toggleTimelineVisibility }: TimelineToolbarProps) {
100
+ // fallow-ignore-next-line complexity
101
+ function useKeyframeToggle(session?: DomEditSessionSlice) {
102
+ const currentTime = usePlayerStore((s) => s.currentTime);
103
+ if (!session) return { state: "none" as const, onToggle: undefined };
104
+
105
+ const sel = session.domEditSelection;
106
+ const anims = session.selectedGsapAnimations;
107
+ const kfAnim = anims.find((a) => a.keyframes);
108
+ const flatAnim = anims.find((a) => !a.keyframes);
109
+
110
+ let state: "active" | "inactive" | "none" = "none";
111
+ if (kfAnim?.keyframes && sel) {
112
+ const elStart = Number.parseFloat(sel.dataAttributes?.start ?? "0") || 0;
113
+ const elDuration = Number.parseFloat(sel.dataAttributes?.duration ?? "1") || 1;
114
+ const pct =
115
+ elDuration > 0
116
+ ? Math.max(0, Math.min(100, Math.round(((currentTime - elStart) / elDuration) * 1000) / 10))
117
+ : 0;
118
+ state = kfAnim.keyframes.keyframes.some((k) => Math.abs(k.percentage - pct) <= 1)
119
+ ? "active"
120
+ : "inactive";
121
+ }
122
+
123
+ // fallow-ignore-next-line complexity
124
+ const onToggle = sel
125
+ ? async () => {
126
+ const t = usePlayerStore.getState().currentTime;
127
+ if (kfAnim?.keyframes) {
128
+ if (kfAnim.hasUnresolvedKeyframes) {
129
+ await session.handleGsapMaterializeKeyframes?.(kfAnim.id);
130
+ }
131
+ const elStart = Number.parseFloat(sel.dataAttributes?.start ?? "0") || 0;
132
+ const elDuration = Number.parseFloat(sel.dataAttributes?.duration ?? "1") || 1;
133
+ const pct =
134
+ elDuration > 0
135
+ ? Math.max(0, Math.min(100, Math.round(((t - elStart) / elDuration) * 1000) / 10))
136
+ : 0;
137
+ const existing = kfAnim.keyframes.keyframes.find(
138
+ (k) => Math.abs(k.percentage - pct) <= 1,
139
+ );
140
+ if (existing) {
141
+ session.handleGsapRemoveKeyframe(kfAnim.id, existing.percentage);
142
+ } else {
143
+ const runtimeValues = readRuntimeKeyframeValues(
144
+ session.previewIframeRef?.current ?? null,
145
+ sel,
146
+ kfAnim.keyframes.keyframes,
147
+ );
148
+ const values =
149
+ Object.keys(runtimeValues).length > 0
150
+ ? runtimeValues
151
+ : interpolateKeyframeProperties(kfAnim.keyframes.keyframes, pct);
152
+ for (const [prop, val] of Object.entries(values)) {
153
+ session.handleGsapAddKeyframe(kfAnim.id, pct, prop, val);
154
+ }
155
+ }
156
+ } else if (flatAnim) {
157
+ session.handleGsapConvertToKeyframes(flatAnim.id);
158
+ } else {
159
+ session.handleGsapAddAnimation("to");
160
+ }
161
+ }
162
+ : undefined;
163
+
164
+ return { state, onToggle };
165
+ }
166
+
167
+ export function TimelineToolbar({
168
+ toggleTimelineVisibility,
169
+ domEditSession,
170
+ onSplitElement,
171
+ }: TimelineToolbarProps) {
14
172
  const zoomMode = usePlayerStore((s) => s.zoomMode);
15
173
  const manualZoomPercent = usePlayerStore((s) => s.manualZoomPercent);
16
174
  const setZoomMode = usePlayerStore((s) => s.setZoomMode);
17
175
  const setManualZoomPercent = usePlayerStore((s) => s.setManualZoomPercent);
18
176
  const displayedTimelineZoomPercent = getTimelineZoomPercent(zoomMode, manualZoomPercent);
177
+ const { state: keyframeState, onToggle: onToggleKeyframe } = useKeyframeToggle(domEditSession);
19
178
 
20
179
  return (
21
180
  <div className="border-b border-neutral-800/40 bg-neutral-950/96">
22
181
  <div className="flex items-center justify-between px-3 py-2">
23
- <div className="text-[10px] font-medium uppercase tracking-[0.16em] text-neutral-500">
24
- Timeline
182
+ <div className="flex items-center gap-3">
183
+ <div className="text-[10px] font-medium uppercase tracking-[0.16em] text-neutral-500">
184
+ Timeline
185
+ </div>
186
+ {STUDIO_KEYFRAMES_ENABLED && onToggleKeyframe && (
187
+ <Tooltip
188
+ label={
189
+ keyframeState === "active"
190
+ ? "Remove keyframe at playhead"
191
+ : keyframeState === "inactive"
192
+ ? "Add keyframe at playhead"
193
+ : "Enable keyframes"
194
+ }
195
+ >
196
+ <button
197
+ type="button"
198
+ onClick={onToggleKeyframe}
199
+ className={`flex h-7 w-7 items-center justify-center rounded transition-colors ${
200
+ keyframeState === "active"
201
+ ? "text-studio-accent"
202
+ : keyframeState === "inactive"
203
+ ? "text-neutral-400 hover:text-studio-accent"
204
+ : "text-neutral-600 hover:text-neutral-400"
205
+ }`}
206
+ >
207
+ <svg width="18" height="18" viewBox="0 0 10 10" fill="currentColor">
208
+ {keyframeState === "active" ? (
209
+ <path d="M5 0.5L9.5 5L5 9.5L0.5 5Z" />
210
+ ) : (
211
+ <path
212
+ d="M5 1.2L8.8 5L5 8.8L1.2 5Z"
213
+ fill="none"
214
+ stroke="currentColor"
215
+ strokeWidth="1.2"
216
+ />
217
+ )}
218
+ </svg>
219
+ </button>
220
+ </Tooltip>
221
+ )}
222
+ {onSplitElement &&
223
+ (() => {
224
+ const { selectedElementId, elements, currentTime } = usePlayerStore.getState();
225
+ const el = selectedElementId
226
+ ? elements.find((e) => (e.key ?? e.id) === selectedElementId)
227
+ : null;
228
+ const splittable =
229
+ el && !el.compositionSrc && ["video", "audio", "img"].includes(el.tag);
230
+ if (!splittable) return null;
231
+ const canSplit = currentTime > el.start && currentTime < el.start + el.duration;
232
+ return (
233
+ <Tooltip label="Split clip at playhead (S)">
234
+ <button
235
+ type="button"
236
+ disabled={!canSplit}
237
+ onClick={() => {
238
+ if (canSplit) onSplitElement(el, currentTime);
239
+ }}
240
+ className={`flex h-7 w-7 items-center justify-center rounded transition-colors ${
241
+ canSplit
242
+ ? "text-neutral-500 hover:text-neutral-200"
243
+ : "text-neutral-700 cursor-not-allowed"
244
+ }`}
245
+ >
246
+ <Scissors size={15} />
247
+ </button>
248
+ </Tooltip>
249
+ );
250
+ })()}
25
251
  </div>
26
252
  <div className="flex items-center gap-1">
27
253
  <Tooltip label="Fit timeline to width">
@@ -9,13 +9,29 @@ import {
9
9
  METHOD_LABELS,
10
10
  METHOD_TOOLTIPS,
11
11
  PERCENT_PROPS,
12
+ PROP_CONSTRAINTS,
12
13
  PROP_LABELS,
13
14
  PROP_TOOLTIPS,
14
15
  PROP_UNITS,
16
+ clampPropertyValue,
15
17
  } from "./gsapAnimationConstants";
16
18
  import { buildTweenSummary } from "./gsapAnimationHelpers";
17
19
  import { EaseCurveSection } from "./EaseCurveSection";
18
20
  const BOOLEAN_PROPS = new Set(["visibility"]);
21
+ const STRING_PROPS = new Set(["filter", "clipPath"]);
22
+
23
+ const FILTER_PRESETS = [
24
+ { label: "Blur", value: "blur(4px)" },
25
+ { label: "Bright", value: "brightness(1.5)" },
26
+ { label: "Gray", value: "grayscale(1)" },
27
+ { label: "None", value: "none" },
28
+ ];
29
+
30
+ const CLIP_PATH_PRESETS = [
31
+ { label: "Circle", value: "circle(50% at 50% 50%)" },
32
+ { label: "Inset", value: "inset(10%)" },
33
+ { label: "None", value: "none" },
34
+ ];
19
35
 
20
36
  function isPercentProp(prop: string): boolean {
21
37
  return PERCENT_PROPS.has(prop);
@@ -27,7 +43,11 @@ function displayValue(prop: string, val: number | string): string {
27
43
  }
28
44
 
29
45
  function adjustedValue(prop: string, raw: string): string {
30
- if (isPercentProp(prop)) return String(Math.max(0, Math.min(1, Number(raw) / 100)));
46
+ if (isPercentProp(prop)) return String(clampPropertyValue(prop, Number(raw) / 100));
47
+ const num = Number(raw);
48
+ if (!Number.isNaN(num) && PROP_CONSTRAINTS[prop]) {
49
+ return String(clampPropertyValue(prop, num));
50
+ }
31
51
  return raw;
32
52
  }
33
53
 
@@ -90,6 +110,48 @@ function PropertyRow({
90
110
  );
91
111
  }
92
112
 
113
+ if (STRING_PROPS.has(prop)) {
114
+ const presets =
115
+ prop === "filter" ? FILTER_PRESETS : prop === "clipPath" ? CLIP_PATH_PRESETS : [];
116
+ return (
117
+ <div className="flex flex-col gap-1">
118
+ <div className="flex items-center gap-1">
119
+ <div className="min-w-0 flex-1 flex items-center gap-2 px-2 py-1 rounded-lg bg-neutral-900 border border-neutral-800">
120
+ <span className="flex-shrink-0 text-[11px] font-medium text-neutral-500">
121
+ {PROP_LABELS[prop] ?? prop}
122
+ </span>
123
+ <input
124
+ type="text"
125
+ defaultValue={String(val)}
126
+ className="flex-1 bg-transparent text-[11px] text-neutral-200 outline-none"
127
+ onBlur={(e) => onCommit(e.currentTarget.value)}
128
+ onKeyDown={(e) => {
129
+ if (e.key === "Enter") {
130
+ e.currentTarget.blur();
131
+ }
132
+ }}
133
+ />
134
+ </div>
135
+ <RemoveButton onClick={onRemove} title={removeTitle} />
136
+ </div>
137
+ {presets.length > 0 && (
138
+ <div className="flex gap-1 pl-1">
139
+ {presets.map((p) => (
140
+ <button
141
+ key={p.value}
142
+ type="button"
143
+ onClick={() => onCommit(p.value)}
144
+ className="px-1.5 py-0.5 rounded text-[9px] font-medium text-neutral-500 bg-neutral-800/50 hover:bg-neutral-800 hover:text-neutral-300 transition-colors"
145
+ >
146
+ {p.label}
147
+ </button>
148
+ ))}
149
+ </div>
150
+ )}
151
+ </div>
152
+ );
153
+ }
154
+
93
155
  return (
94
156
  <div className="flex items-center gap-1">
95
157
  <div className="min-w-0 flex-1">
@@ -292,8 +354,10 @@ export const AnimationCard = memo(function AnimationCard({
292
354
  {methodLabel}
293
355
  </span>
294
356
  <span className="text-[11px] font-medium text-neutral-400" title="When this effect plays">
295
- {typeof animation.position === "number" ? `${animation.position}s` : animation.position} –{" "}
296
- {typeof endTime === "number" ? `${endTime.toFixed(1)}s` : endTime}
357
+ {typeof animation.position === "number"
358
+ ? `${parseFloat(animation.position.toFixed(3))}s`
359
+ : animation.position}{" "}
360
+ – {typeof endTime === "number" ? `${parseFloat(endTime.toFixed(3))}s` : endTime}
297
361
  </span>
298
362
  <span className="ml-auto text-[10px] text-neutral-500" title={easeName}>
299
363
  {easeLabel}
@@ -344,7 +408,7 @@ export const AnimationCard = memo(function AnimationCard({
344
408
  value={
345
409
  typeof animation.position === "string"
346
410
  ? animation.position
347
- : String(Math.max(0, animation.position))
411
+ : String(parseFloat(Math.max(0, animation.position).toFixed(3)))
348
412
  }
349
413
  suffix={typeof animation.position === "number" ? "s" : undefined}
350
414
  tooltip="When this effect begins on the timeline"
@@ -1,4 +1,5 @@
1
- import { memo, useMemo, useRef, type RefObject } from "react";
1
+ import { memo, useMemo, useRef, useState, type RefObject } from "react";
2
+ import { useMountEffect } from "../../hooks/useMountEffect";
2
3
  import { type DomEditSelection } from "./domEditing";
3
4
  import { resolveDomEditGroupOverlayRect, toOverlayRect } from "./domEditOverlayGeometry";
4
5
  import {
@@ -10,6 +11,8 @@ import {
10
11
  } from "./domEditOverlayGestures";
11
12
  import { useDomEditOverlayRects } from "./useDomEditOverlayRects";
12
13
  import { createDomEditOverlayGestureHandlers } from "./useDomEditOverlayGestures";
14
+ import { SnapGuideOverlay, type SnapGuidesState } from "./SnapGuideOverlay";
15
+ import { GridOverlay } from "./GridOverlay";
13
16
 
14
17
  // Re-exports for external consumers — preserving existing import paths.
15
18
  export {
@@ -61,6 +64,8 @@ interface DomEditOverlayProps {
61
64
  next: { width: number; height: number },
62
65
  ) => Promise<void> | void;
63
66
  onRotationCommit: (selection: DomEditSelection, next: { angle: number }) => Promise<void> | void;
67
+ gridVisible?: boolean;
68
+ gridSpacing?: number;
64
69
  }
65
70
 
66
71
  export const DomEditOverlay = memo(function DomEditOverlay({
@@ -75,6 +80,8 @@ export const DomEditOverlay = memo(function DomEditOverlay({
75
80
  onCanvasPointerLeave,
76
81
  onSelectionChange,
77
82
  onBlockedMove,
83
+ gridVisible = false,
84
+ gridSpacing = 50,
78
85
  onManualDragStart,
79
86
  onPathOffsetCommit,
80
87
  onGroupPathOffsetCommit,
@@ -89,6 +96,7 @@ export const DomEditOverlay = memo(function DomEditOverlay({
89
96
  const suppressNextBoxClickRef = useRef(false);
90
97
  const suppressNextBoxMouseDownRef = useRef(false);
91
98
  const suppressNextOverlayMouseDownRef = useRef(false);
99
+ const snapGuidesRef = useRef<SnapGuidesState | null>(null);
92
100
  const rafPausedRef = useRef(false);
93
101
 
94
102
  const selectionRef = useRef(selection);
@@ -136,6 +144,50 @@ export const DomEditOverlay = memo(function DomEditOverlay({
136
144
  rafPausedRef,
137
145
  });
138
146
 
147
+ const [compRect, setCompRect] = useState({
148
+ left: 0,
149
+ top: 0,
150
+ width: 0,
151
+ height: 0,
152
+ scaleX: 1,
153
+ scaleY: 1,
154
+ });
155
+ useMountEffect(() => {
156
+ let frame = 0;
157
+ // fallow-ignore-next-line complexity
158
+ const update = () => {
159
+ frame = requestAnimationFrame(update);
160
+ const iframe = iframeRef.current;
161
+ const overlayEl = overlayRef.current;
162
+ if (!iframe || !overlayEl) return;
163
+ const iRect = iframe.getBoundingClientRect();
164
+ const oRect = overlayEl.getBoundingClientRect();
165
+ const left = iRect.left - oRect.left;
166
+ const top = iRect.top - oRect.top;
167
+ if (iRect.width <= 0 || iRect.height <= 0) return;
168
+ const doc = iframe.contentDocument;
169
+ const root = doc?.querySelector<HTMLElement>("[data-composition-id]") ?? doc?.documentElement;
170
+ const dw = Number.parseFloat(root?.getAttribute("data-width") ?? "");
171
+ const dh = Number.parseFloat(root?.getAttribute("data-height") ?? "");
172
+ const scaleX = dw > 0 ? iRect.width / dw : 1;
173
+ const scaleY = dh > 0 ? iRect.height / dh : 1;
174
+ setCompRect((prev) => {
175
+ if (
176
+ Math.abs(prev.left - left) < 0.5 &&
177
+ Math.abs(prev.top - top) < 0.5 &&
178
+ Math.abs(prev.width - iRect.width) < 0.5 &&
179
+ Math.abs(prev.height - iRect.height) < 0.5 &&
180
+ Math.abs(prev.scaleX - scaleX) < 0.001 &&
181
+ Math.abs(prev.scaleY - scaleY) < 0.001
182
+ )
183
+ return prev;
184
+ return { left, top, width: iRect.width, height: iRect.height, scaleX, scaleY };
185
+ });
186
+ };
187
+ frame = requestAnimationFrame(update);
188
+ return () => cancelAnimationFrame(frame);
189
+ });
190
+
139
191
  const gestures = createDomEditOverlayGestureHandlers({
140
192
  overlayRef,
141
193
  iframeRef,
@@ -158,6 +210,7 @@ export const DomEditOverlay = memo(function DomEditOverlay({
158
210
  onRotationCommitRef,
159
211
  onCanvasPointerMoveRef,
160
212
  onCanvasMouseDown,
213
+ snapGuidesRef,
161
214
  });
162
215
 
163
216
  const selectionKey = useMemo(() => {
@@ -192,6 +245,7 @@ export const DomEditOverlay = memo(function DomEditOverlay({
192
245
  }
193
246
  };
194
247
 
248
+ // fallow-ignore-next-line complexity
195
249
  const handleOverlayPointerDown = (event: React.PointerEvent<HTMLDivElement>) => {
196
250
  if (!allowCanvasMovement || event.button !== 0) return;
197
251
  if (event.shiftKey) {
@@ -387,6 +441,21 @@ export const DomEditOverlay = memo(function DomEditOverlay({
387
441
  </div>
388
442
  </>
389
443
  )}
444
+ <GridOverlay
445
+ visible={gridVisible}
446
+ spacing={gridSpacing}
447
+ scaleX={compRect.scaleX}
448
+ scaleY={compRect.scaleY}
449
+ compositionLeft={compRect.left}
450
+ compositionTop={compRect.top}
451
+ compositionWidth={compRect.width}
452
+ compositionHeight={compRect.height}
453
+ />
454
+ <SnapGuideOverlay
455
+ snapGuidesRef={snapGuidesRef}
456
+ overlayWidth={compRect.width}
457
+ overlayHeight={compRect.height}
458
+ />
390
459
  </div>
391
460
  );
392
461
  });
@@ -0,0 +1,50 @@
1
+ // fallow-ignore-file unused-file
2
+ import { memo } from "react";
3
+
4
+ interface GridOverlayProps {
5
+ visible: boolean;
6
+ spacing: number;
7
+ scaleX: number;
8
+ scaleY: number;
9
+ compositionLeft: number;
10
+ compositionTop: number;
11
+ compositionWidth: number;
12
+ compositionHeight: number;
13
+ }
14
+
15
+ // fallow-ignore-next-line complexity
16
+ export const GridOverlay = memo(function GridOverlay({
17
+ visible,
18
+ spacing,
19
+ scaleX,
20
+ scaleY,
21
+ compositionLeft,
22
+ compositionTop,
23
+ compositionWidth,
24
+ compositionHeight,
25
+ }: GridOverlayProps) {
26
+ if (!visible || spacing <= 0) return null;
27
+
28
+ const overlaySpacingX = spacing * scaleX;
29
+ const overlaySpacingY = spacing * scaleY;
30
+
31
+ if (overlaySpacingX < 4 || overlaySpacingY < 4) return null;
32
+
33
+ return (
34
+ <div
35
+ aria-hidden="true"
36
+ className="pointer-events-none absolute"
37
+ style={{
38
+ left: compositionLeft,
39
+ top: compositionTop,
40
+ width: compositionWidth,
41
+ height: compositionHeight,
42
+ backgroundImage: [
43
+ `repeating-linear-gradient(90deg, rgba(255,255,255,0.12) 0px, rgba(255,255,255,0.12) 1px, transparent 1px, transparent ${overlaySpacingX}px)`,
44
+ `repeating-linear-gradient(0deg, rgba(255,255,255,0.12) 0px, rgba(255,255,255,0.12) 1px, transparent 1px, transparent ${overlaySpacingY}px)`,
45
+ ].join(", "),
46
+ backgroundSize: `${overlaySpacingX}px ${overlaySpacingY}px`,
47
+ }}
48
+ />
49
+ );
50
+ });
@@ -0,0 +1,49 @@
1
+ import { memo } from "react";
2
+
3
+ export type DiamondState = "active" | "inactive" | "ghost";
4
+
5
+ interface KeyframeDiamondProps {
6
+ state: DiamondState;
7
+ onClick: () => void;
8
+ title?: string;
9
+ size?: number;
10
+ }
11
+
12
+ // fallow-ignore-next-line complexity
13
+ export const KeyframeDiamond = memo(function KeyframeDiamond({
14
+ state,
15
+ onClick,
16
+ title,
17
+ size = 10,
18
+ }: KeyframeDiamondProps) {
19
+ const isFilled = state === "active";
20
+ const opacity = state === "ghost" ? 0.25 : state === "inactive" ? 0.6 : 1;
21
+ const color = state === "active" ? "#3b82f6" : "#a3a3a3";
22
+
23
+ return (
24
+ <button
25
+ type="button"
26
+ onClick={(e) => {
27
+ e.stopPropagation();
28
+ onClick();
29
+ }}
30
+ className="flex-shrink-0 p-0.5 transition-opacity hover:opacity-100"
31
+ style={{ color, opacity }}
32
+ title={title}
33
+ >
34
+ <svg width={size} height={size} viewBox="0 0 10 10">
35
+ <rect
36
+ x="5"
37
+ y="0.7"
38
+ width="6"
39
+ height="6"
40
+ rx="1"
41
+ transform="rotate(45 5 0.7)"
42
+ fill={isFilled ? "currentColor" : "none"}
43
+ stroke="currentColor"
44
+ strokeWidth="1.2"
45
+ />
46
+ </svg>
47
+ </button>
48
+ );
49
+ });