@hyperframes/studio 0.7.39 → 0.7.40

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 (76) hide show
  1. package/dist/assets/{index-312a3Ceu.js → index-89kPtC4s.js} +1 -1
  2. package/dist/assets/{index-B9YvRJz1.js → index-B2Utv-2b.js} +195 -195
  3. package/dist/assets/index-BpM6cnfP.css +1 -0
  4. package/dist/assets/{index-CQHiecE7.js → index-CjX8Ljc8.js} +1 -1
  5. package/dist/{chunk-RCLGSZ7C.js → chunk-BA66NM4L.js} +1 -1
  6. package/dist/{chunk-RCLGSZ7C.js.map → chunk-BA66NM4L.js.map} +1 -1
  7. package/dist/{domEditingLayers-S6YOLVRG.js → domEditingLayers-H7LDFIJ7.js} +2 -2
  8. package/dist/index.d.ts +34 -29
  9. package/dist/index.html +2 -2
  10. package/dist/index.js +2749 -2075
  11. package/dist/index.js.map +1 -1
  12. package/package.json +7 -7
  13. package/src/App.tsx +2 -1
  14. package/src/components/StudioPreviewArea.tsx +6 -0
  15. package/src/components/StudioRightPanel.tsx +3 -0
  16. package/src/components/editor/DomEditOverlay.tsx +33 -47
  17. package/src/components/editor/PropertyPanel.test.ts +25 -0
  18. package/src/components/editor/PropertyPanel.tsx +33 -7
  19. package/src/components/editor/domEditingElement.ts +8 -1
  20. package/src/components/editor/manualEditsDom.ts +1 -18
  21. package/src/components/editor/manualEditsStyleHelpers.ts +18 -0
  22. package/src/components/editor/offCanvasIndicatorGeometry.ts +74 -0
  23. package/src/components/editor/offCanvasIndicatorRefresh.test.tsx +154 -0
  24. package/src/components/editor/offCanvasIndicatorRefresh.ts +99 -0
  25. package/src/components/editor/propertyPanelHelpers.ts +11 -0
  26. package/src/components/editor/propertyPanelTypes.ts +1 -0
  27. package/src/contexts/TimelineEditContext.tsx +1 -0
  28. package/src/hooks/gsapDragCommit.test.ts +56 -0
  29. package/src/hooks/gsapDragCommit.ts +41 -109
  30. package/src/hooks/gsapDragPositionCommit.ts +3 -0
  31. package/src/hooks/gsapDragStaticSetHelpers.ts +109 -0
  32. package/src/hooks/gsapRuntimeBridge.ts +23 -2
  33. package/src/hooks/gsapRuntimeKeyframes.test.ts +34 -0
  34. package/src/hooks/gsapRuntimeKeyframes.ts +13 -1
  35. package/src/hooks/gsapWholePropertyOffsetCommit.ts +1 -0
  36. package/src/hooks/timelineEditingHelpers.ts +17 -5
  37. package/src/hooks/timelineTrackVisibility.test.ts +202 -0
  38. package/src/hooks/timelineTrackVisibility.ts +371 -0
  39. package/src/hooks/useRenderClipContent.test.ts +68 -2
  40. package/src/hooks/useRenderClipContent.ts +5 -6
  41. package/src/hooks/useTimelineEditing.ts +35 -30
  42. package/src/hooks/useTimelineEditingTypes.ts +30 -0
  43. package/src/icons/SystemIcons.tsx +12 -4
  44. package/src/player/components/AudioWaveform.tsx +11 -8
  45. package/src/player/components/CompositionThumbnail.tsx +14 -11
  46. package/src/player/components/PlayheadIndicator.tsx +21 -11
  47. package/src/player/components/Timeline.test.ts +105 -5
  48. package/src/player/components/Timeline.tsx +37 -47
  49. package/src/player/components/TimelineCanvas.tsx +275 -256
  50. package/src/player/components/TimelineClip.test.tsx +105 -0
  51. package/src/player/components/TimelineClip.tsx +44 -57
  52. package/src/player/components/TimelineRuler.tsx +17 -23
  53. package/src/player/components/TimelineShortcutHint.tsx +26 -0
  54. package/src/player/components/TimelineTypes.ts +18 -0
  55. package/src/player/components/VideoThumbnail.tsx +16 -13
  56. package/src/player/components/timelineCallbacks.ts +1 -0
  57. package/src/player/components/timelineIcons.tsx +2 -44
  58. package/src/player/components/timelineLayout.ts +18 -22
  59. package/src/player/components/timelineMotionStyles.test.ts +101 -0
  60. package/src/player/components/timelineTheme.test.ts +21 -4
  61. package/src/player/components/timelineTheme.ts +15 -15
  62. package/src/player/components/useTimelineActiveClips.test.ts +93 -0
  63. package/src/player/components/useTimelineActiveClips.ts +125 -0
  64. package/src/player/components/useTimelinePlayhead.ts +6 -0
  65. package/src/player/hooks/useExpandedTimelineElements.test.ts +110 -3
  66. package/src/player/hooks/useExpandedTimelineElements.ts +106 -19
  67. package/src/player/lib/time.test.ts +1 -0
  68. package/src/player/lib/timelineDOM.test.ts +48 -1
  69. package/src/player/lib/timelineDOM.ts +7 -0
  70. package/src/player/lib/timelineIframeHelpers.ts +1 -0
  71. package/src/player/store/playerStore.ts +5 -1
  72. package/src/styles/studio.css +108 -0
  73. package/src/utils/sourcePatcher.test.ts +11 -0
  74. package/src/utils/sourcePatcher.ts +6 -4
  75. package/dist/assets/index-0P10SwC_.css +0 -1
  76. /package/dist/{domEditingLayers-S6YOLVRG.js.map → domEditingLayers-H7LDFIJ7.js.map} +0 -0
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@hyperframes/studio",
3
- "version": "0.7.39",
3
+ "version": "0.7.40",
4
4
  "description": "",
5
5
  "repository": {
6
6
  "type": "git",
@@ -46,11 +46,11 @@
46
46
  "gsap": "^3.13.0",
47
47
  "marked": "^14.1.4",
48
48
  "mediabunny": "^1.45.3",
49
- "@hyperframes/core": "0.7.39",
50
- "@hyperframes/parsers": "0.7.39",
51
- "@hyperframes/sdk": "0.7.39",
52
- "@hyperframes/player": "0.7.39",
53
- "@hyperframes/studio-server": "0.7.39"
49
+ "@hyperframes/parsers": "0.7.40",
50
+ "@hyperframes/studio-server": "0.7.40",
51
+ "@hyperframes/sdk": "0.7.40",
52
+ "@hyperframes/core": "0.7.40",
53
+ "@hyperframes/player": "0.7.40"
54
54
  },
55
55
  "devDependencies": {
56
56
  "@types/react": "19",
@@ -65,7 +65,7 @@
65
65
  "vite": "^6.4.2",
66
66
  "vitest": "^3.2.4",
67
67
  "zustand": "^5.0.0",
68
- "@hyperframes/producer": "0.7.39"
68
+ "@hyperframes/producer": "0.7.40"
69
69
  },
70
70
  "peerDependencies": {
71
71
  "react": "19",
package/src/App.tsx CHANGED
@@ -360,7 +360,6 @@ export function StudioApp() {
360
360
  resetErrors: resetConsoleErrors,
361
361
  } = useConsoleErrorCapture(previewIframe);
362
362
  const dragOverlay = useDragOverlay(fileManager.handleImportFiles);
363
- // Gesture recording
364
363
  const handleToggleRecordingRef = useRef<() => void>(() => {});
365
364
  const domEditSessionRef = useRef(domEditSession);
366
365
  domEditSessionRef.current = domEditSession;
@@ -531,6 +530,7 @@ export function StudioApp() {
531
530
  handleTimelineFileDrop={timelineEditing.handleTimelineFileDrop}
532
531
  handleTimelineElementMove={timelineEditing.handleTimelineElementMove}
533
532
  handleTimelineElementResize={timelineEditing.handleTimelineElementResize}
533
+ handleToggleTrackHidden={timelineEditing.handleToggleTrackHidden}
534
534
  handleBlockedTimelineEdit={timelineEditing.handleBlockedTimelineEdit}
535
535
  handleTimelineElementSplit={timelineEditing.handleTimelineElementSplit}
536
536
  handleRazorSplit={timelineEditing.handleRazorSplit}
@@ -572,6 +572,7 @@ export function StudioApp() {
572
572
  domEditSaveTimestampRef={domEditSaveTimestampRef}
573
573
  recordEdit={editHistory.recordEdit}
574
574
  {...cropModeProps}
575
+ onToggleElementHidden={timelineEditing.handleToggleElementHidden}
575
576
  />
576
577
  )}
577
578
  </div>
@@ -58,6 +58,7 @@ export interface StudioPreviewAreaProps {
58
58
  element: TimelineElement,
59
59
  updates: Pick<TimelineElement, "start" | "duration" | "playbackStart">,
60
60
  ) => Promise<void> | void;
61
+ handleToggleTrackHidden: (track: number, hidden: boolean) => Promise<void> | void;
61
62
  handleBlockedTimelineEdit: (element: TimelineElement, intent: BlockedTimelineEditIntent) => void;
62
63
  handleTimelineElementSplit: (element: TimelineElement, splitTime: number) => Promise<void> | void;
63
64
  handleRazorSplit: (element: TimelineElement, splitTime: number) => Promise<void> | void;
@@ -85,6 +86,7 @@ export function StudioPreviewArea({
85
86
  handleTimelineFileDrop,
86
87
  handleTimelineElementMove,
87
88
  handleTimelineElementResize,
89
+ handleToggleTrackHidden,
88
90
  handleBlockedTimelineEdit,
89
91
  handleTimelineElementSplit,
90
92
  handleRazorSplit,
@@ -165,6 +167,7 @@ export function StudioPreviewArea({
165
167
  // diamond reports a clip-% but the script ops key on the tween-%. Prefers the
166
168
  // anim in the keyframe's property group, falling back to the first keyframed one.
167
169
  const resolveKeyframeTarget = useCallback(
170
+ // fallow-ignore-next-line complexity
168
171
  (pct: number): { animId: string; tweenPct: number } | null => {
169
172
  const cached = usePlayerStore.getState().keyframeCache.get(domEditSelection?.id ?? "");
170
173
  const kf = cached?.keyframes.find((k) => Math.abs(k.percentage - pct) < 0.2);
@@ -182,6 +185,7 @@ export function StudioPreviewArea({
182
185
  () => ({
183
186
  onMoveElement: handleTimelineElementMove,
184
187
  onResizeElement: handleTimelineElementResize,
188
+ onToggleTrackHidden: handleToggleTrackHidden,
185
189
  onBlockedEditAttempt: handleBlockedTimelineEdit,
186
190
  onSplitElement: handleTimelineElementSplit,
187
191
  onRazorSplit: handleRazorSplit,
@@ -210,6 +214,7 @@ export function StudioPreviewArea({
210
214
  // drop past the boundary (last keyframe past the end, first before the start)
211
215
  // resizes the tween — position/duration grow so the dragged keyframe lands at
212
216
  // the drop while every other keyframe keeps its absolute time (value+ease too).
217
+ // fallow-ignore-next-line complexity
213
218
  onMoveKeyframe: (_elId: string, fromClipPct: number, toClipPct: number) => {
214
219
  const target = resolveKeyframeTarget(fromClipPct);
215
220
  const sel = domEditSelection;
@@ -280,6 +285,7 @@ export function StudioPreviewArea({
280
285
  [
281
286
  handleTimelineElementMove,
282
287
  handleTimelineElementResize,
288
+ handleToggleTrackHidden,
283
289
  handleBlockedTimelineEdit,
284
290
  handleTimelineElementSplit,
285
291
  handleRazorSplit,
@@ -62,6 +62,7 @@ export interface StudioRightPanelProps {
62
62
  kind: EditHistoryKind;
63
63
  files: Record<string, { before: string; after: string }>;
64
64
  }) => Promise<void>;
65
+ onToggleElementHidden?: (elementKey: string, hidden: boolean) => Promise<void> | void;
65
66
  }
66
67
 
67
68
  // fallow-ignore-next-line complexity
@@ -78,6 +79,7 @@ export function StudioRightPanel({
78
79
  reloadPreview,
79
80
  domEditSaveTimestampRef,
80
81
  recordEdit,
82
+ onToggleElementHidden,
81
83
  }: StudioRightPanelProps) {
82
84
  const {
83
85
  rightWidth,
@@ -350,6 +352,7 @@ export function StudioRightPanel({
350
352
  multiSelectCount={domEditGroupSelections.length}
351
353
  copiedAgentPrompt={copiedAgentPrompt}
352
354
  onClearSelection={clearDomSelection}
355
+ onToggleElementHidden={onToggleElementHidden}
353
356
  onUngroup={handleUngroupSelection}
354
357
  onSetStyle={handleDomStyleCommit}
355
358
  onSetAttribute={handleDomAttributeCommit}
@@ -3,9 +3,7 @@ import { type DomEditSelection } from "./domEditing";
3
3
  import type { PreviewMouseDownOptions } from "../../hooks/usePreviewInteraction";
4
4
  import { useMarqueeGestures } from "./marqueeCommit";
5
5
  import { MarqueeOverlay } from "./MarqueeOverlay";
6
- import { groupAwareOverlayRect, resolveDomEditGroupOverlayRect } from "./domEditOverlayGeometry";
7
- import { collectDomEditLayerItems } from "./domEditingLayers";
8
- import { isElementComputedVisible } from "./domEditingElement";
6
+ import { resolveDomEditGroupOverlayRect } from "./domEditOverlayGeometry";
9
7
  import {
10
8
  type BlockedMoveState,
11
9
  type DomEditGroupPathOffsetCommit,
@@ -26,6 +24,8 @@ import { hugRectForElement } from "./domEditOverlayCrop";
26
24
  import { useCropOverlay } from "../../hooks/useCropMode";
27
25
  import { readDomEditSelectionShapeStyles, resolveBoxChromeClass } from "./domEditOverlayShape";
28
26
  import { useDomEditCompositionRect } from "./useDomEditCompositionRect";
27
+ import { useMountEffect } from "../../hooks/useMountEffect";
28
+ import { startOffCanvasIndicatorRefresh } from "./offCanvasIndicatorRefresh";
29
29
 
30
30
  // Re-exports for external consumers — preserving existing import paths.
31
31
  export {
@@ -173,6 +173,8 @@ export const DomEditOverlay = memo(function DomEditOverlay({
173
173
  });
174
174
 
175
175
  const compRect = useDomEditCompositionRect({ iframeRef, overlayRef });
176
+ const compRectRef = useRef(compRect);
177
+ compRectRef.current = compRect;
176
178
 
177
179
  const { hasCropInsets, cropOutlineInsetPx } = useCropOverlay({
178
180
  selection,
@@ -189,51 +191,35 @@ export const DomEditOverlay = memo(function DomEditOverlay({
189
191
  // outside the composition bounds so users can find them.
190
192
  const offCanvasElementsRef = useRef<Map<string, HTMLElement>>(new Map());
191
193
  const [offCanvasRects, setOffCanvasRects] = useState<OffCanvasRect[]>([]);
192
- // fallow-ignore-next-line complexity
194
+ const offCanvasDirtyRef = useRef(true);
195
+ const offCanvasSigRef = useRef("");
196
+ const offCanvasObserverRef = useRef<MutationObserver | null>(null);
197
+ const offCanvasObservedDocRef = useRef<Document | null>(null);
198
+
199
+ // Positions depend on live iframe layout, not selection — the selected-element
200
+ // suppression is a render-time filter, so selection/groupSelections stay out
201
+ // of the geometry walk.
202
+ useMountEffect(() =>
203
+ startOffCanvasIndicatorRefresh({
204
+ iframeRef,
205
+ overlayRef,
206
+ compRectRef,
207
+ activeCompositionPathRef,
208
+ dirtyRef: offCanvasDirtyRef,
209
+ sigRef: offCanvasSigRef,
210
+ observerRef: offCanvasObserverRef,
211
+ observedDocRef: offCanvasObservedDocRef,
212
+ elementsRef: offCanvasElementsRef,
213
+ setRects: setOffCanvasRects,
214
+ }),
215
+ );
216
+
217
+ // Switching compositions may not swap the iframe document (so the observer's
218
+ // doc-swap detection wouldn't fire) yet changes which elements are off-canvas.
219
+ // Force a recompute explicitly on comp change.
193
220
  useEffect(() => {
194
- const iframe = iframeRef.current;
195
- const overlay = overlayRef.current;
196
- if (!iframe || !overlay || compRect.width <= 0) {
197
- setOffCanvasRects([]);
198
- return;
199
- }
200
- const doc = iframe.contentDocument;
201
- if (!doc) return;
202
- const root = doc.querySelector<HTMLElement>("[data-composition-id]") ?? doc.body;
203
- const acp = activeCompositionPath ?? "index.html";
204
- const items = collectDomEditLayerItems(root, {
205
- activeCompositionPath: acp,
206
- isMasterView: !acp || acp === "index.html",
207
- });
208
- const rects: typeof offCanvasRects = [];
209
- const elMap = new Map<string, HTMLElement>();
210
- for (const item of items) {
211
- if (!isElementComputedVisible(item.element)) continue;
212
- // Groups use their members' union (where they actually render), so a group
213
- // whose members sit inside the canvas isn't flagged off-canvas by a stale
214
- // wrapper box. Crop-hug the result so an inset crop that keeps the visible
215
- // part on-canvas doesn't flag the element either.
216
- const base = groupAwareOverlayRect(overlay, iframe, item.element);
217
- const r = base ? { ...base, ...hugRectForElement(base, item.element) } : null;
218
- if (!r) continue;
219
- // Any edge crossing the composition border → gray-zone indicator (the
220
- // in-canvas portion is clipped away below, so only the sliver shows).
221
- const extendsOutsideComp =
222
- r.left < compRect.left ||
223
- r.left + r.width > compRect.left + compRect.width ||
224
- r.top < compRect.top ||
225
- r.top + r.height > compRect.top + compRect.height;
226
- if (extendsOutsideComp) {
227
- rects.push({ key: item.key, left: r.left, top: r.top, width: r.width, height: r.height });
228
- elMap.set(item.key, item.element);
229
- }
230
- }
231
- offCanvasElementsRef.current = elMap;
232
- setOffCanvasRects(rects);
233
- // Positions depend on layout, not selection — the selected-element
234
- // suppression is a render-time filter, so selection/groupSelections stay
235
- // out of the deps to avoid re-walking geometry on each selection change.
236
- }, [iframeRef, compRect, activeCompositionPath]);
221
+ offCanvasDirtyRef.current = true;
222
+ }, [activeCompositionPath]);
237
223
 
238
224
  const gestures = createDomEditOverlayGestureHandlers({
239
225
  overlayRef,
@@ -1,4 +1,5 @@
1
1
  import { describe, expect, it } from "vitest";
2
+ import type { TimelineElement } from "../../player";
2
3
  import {
3
4
  buildInsetClipPathSides,
4
5
  buildStrokeStyleUpdates,
@@ -11,6 +12,7 @@ import {
11
12
  parseInsetClipPathSides,
12
13
  setCssFilterFunctionPx,
13
14
  } from "./PropertyPanel";
15
+ import { isSelectedElementHidden } from "./propertyPanelHelpers";
14
16
 
15
17
  describe("PropertyPanel style helpers", () => {
16
18
  it("normalizes bounded pixel values without accepting incompatible units", () => {
@@ -113,3 +115,26 @@ describe("PropertyPanel style helpers", () => {
113
115
  expect(buildStrokeStyleUpdates("solid", "4px")).toEqual([["border-style", "solid"]]);
114
116
  });
115
117
  });
118
+
119
+ describe("isSelectedElementHidden", () => {
120
+ it("reads hidden state by selected timeline id or key", () => {
121
+ const elements: TimelineElement[] = [
122
+ { id: "visible", tag: "div", start: 0, duration: 1, track: 0 },
123
+ { id: "hidden", tag: "div", start: 0, duration: 1, track: 0, hidden: true },
124
+ {
125
+ id: "keyed-hidden",
126
+ key: "scene.html:#keyed-hidden",
127
+ tag: "div",
128
+ start: 0,
129
+ duration: 1,
130
+ track: 0,
131
+ hidden: true,
132
+ },
133
+ ];
134
+
135
+ expect(isSelectedElementHidden(elements, null)).toBe(false);
136
+ expect(isSelectedElementHidden(elements, "visible")).toBe(false);
137
+ expect(isSelectedElementHidden(elements, "hidden")).toBe(true);
138
+ expect(isSelectedElementHidden(elements, "scene.html:#keyed-hidden")).toBe(true);
139
+ });
140
+ });
@@ -1,5 +1,6 @@
1
1
  import { memo, useEffect, useMemo, useRef, useState } from "react";
2
2
  import { Move } from "../../icons/SystemIcons";
3
+ import { Eye, EyeSlash } from "@phosphor-icons/react";
3
4
  import { InspectorHeaderActions } from "./InspectorHeaderActions";
4
5
  import { useStudioShellContext } from "../../contexts/StudioContext";
5
6
  import { readStudioBoxSize, readStudioPathOffset, readStudioRotation } from "./manualEdits";
@@ -10,6 +11,7 @@ import {
10
11
  RESPONSIVE_GRID,
11
12
  readGsapRuntimeValuesForPanel,
12
13
  readGsapBorderRadiusForPanel,
14
+ isSelectedElementHidden,
13
15
  } from "./propertyPanelHelpers";
14
16
  import { MetricField, Section } from "./propertyPanelPrimitives";
15
17
  import { createTransformCommitHandlers } from "./propertyPanelTransformCommit";
@@ -67,6 +69,7 @@ export const PropertyPanel = memo(function PropertyPanel({
67
69
  onAddTextField,
68
70
  onRemoveTextField,
69
71
  onAskAgent: _onAskAgent,
72
+ onToggleElementHidden,
70
73
  onImportAssets,
71
74
  fontAssets = [],
72
75
  onImportFonts,
@@ -106,6 +109,10 @@ export const PropertyPanel = memo(function PropertyPanel({
106
109
  const clipboardTimerRef = useRef<ReturnType<typeof setTimeout>>(undefined);
107
110
  const storeTime = usePlayerStore((s) => s.currentTime);
108
111
  const isPlaying = usePlayerStore((s) => s.isPlaying);
112
+ const timelineElements = usePlayerStore((s) => s.elements);
113
+ const selectedElementId = usePlayerStore((s) => s.selectedElementId);
114
+ const selectedElementHidden = isSelectedElementHidden(timelineElements, selectedElementId);
115
+ const visibilityToggleLabel = selectedElementHidden ? "Show element" : "Hide element";
109
116
  const liveTimeRef = useRef(storeTime);
110
117
  const [, forceRender] = useState(0);
111
118
  useEffect(() => {
@@ -288,13 +295,32 @@ export const PropertyPanel = memo(function PropertyPanel({
288
295
  </div>
289
296
  <div className="mt-0.5 truncate text-[11px] text-neutral-500">{sourceLabel}</div>
290
297
  </div>
291
- <InspectorHeaderActions
292
- element={element}
293
- copied={clipboardCopied}
294
- onCopy={handleCopyElementInfo}
295
- onClear={onClearSelection}
296
- onUngroup={onUngroup}
297
- />
298
+ <div className="flex items-center gap-1">
299
+ {selectedElementId && onToggleElementHidden && (
300
+ <button
301
+ type="button"
302
+ aria-label={visibilityToggleLabel}
303
+ title={visibilityToggleLabel}
304
+ onClick={() => {
305
+ void onToggleElementHidden(selectedElementId, !selectedElementHidden);
306
+ }}
307
+ className="flex h-6 w-6 items-center justify-center rounded text-neutral-500 transition-colors hover:bg-neutral-800 hover:text-neutral-300"
308
+ >
309
+ {selectedElementHidden ? (
310
+ <EyeSlash size={13} weight="bold" aria-hidden="true" />
311
+ ) : (
312
+ <Eye size={13} weight="bold" aria-hidden="true" />
313
+ )}
314
+ </button>
315
+ )}
316
+ <InspectorHeaderActions
317
+ element={element}
318
+ copied={clipboardCopied}
319
+ onCopy={handleCopyElementInfo}
320
+ onClear={onClearSelection}
321
+ onUngroup={onUngroup}
322
+ />
323
+ </div>
298
324
  </div>
299
325
  </div>
300
326
  <div className="flex-1 overflow-y-auto">
@@ -28,6 +28,7 @@ export function isElementComputedVisible(el: HTMLElement): boolean {
28
28
 
29
29
  const VISUAL_LEAF_TAGS = new Set(["img", "video", "canvas", "svg", "audio"]);
30
30
 
31
+ // fallow-ignore-next-line complexity
31
32
  function hasVisualPresence(el: HTMLElement): boolean {
32
33
  const win = el.ownerDocument.defaultView;
33
34
  if (!win) return false;
@@ -236,9 +237,13 @@ export function isLargeRasterDomEditSelection(
236
237
 
237
238
  // ─── Element finders ──────────────────────────────────────────────────────────
238
239
 
240
+ type FindElementSelection = Pick<DomEditSelection, "id" | "hfId" | "selector" | "selectorIndex"> & {
241
+ sourceFile?: string;
242
+ };
243
+
239
244
  export function findElementForSelection(
240
245
  doc: Document,
241
- selection: Pick<DomEditSelection, "id" | "hfId" | "selector" | "selectorIndex" | "sourceFile">,
246
+ selection: FindElementSelection,
242
247
  activeCompositionPath: string | null = null,
243
248
  ): HTMLElement | null {
244
249
  if (selection.hfId) {
@@ -259,6 +264,7 @@ export function findElementForSelection(
259
264
 
260
265
  if (!selection.selector) return null;
261
266
 
267
+ // fallow-ignore-next-line code-duplication
262
268
  if (selection.selector.startsWith(".") && selection.selectorIndex != null) {
263
269
  const matches = querySelectorAllSafely(doc, selection.selector).filter(
264
270
  (candidate): candidate is HTMLElement =>
@@ -270,6 +276,7 @@ export function findElementForSelection(
270
276
  return matches[selection.selectorIndex] ?? null;
271
277
  }
272
278
 
279
+ // fallow-ignore-next-line code-duplication
273
280
  const matches = querySelectorAllSafely(doc, selection.selector).filter(
274
281
  (candidate): candidate is HTMLElement =>
275
282
  isHtmlElement(candidate) &&
@@ -33,6 +33,7 @@ import {
33
33
  import { roundRotationAngle } from "./manualEditsParsing";
34
34
  import { applyStudioMotionFromDom } from "./studioMotion";
35
35
  import { gsapAnimatesProperty } from "./gsapAnimatesProperty";
36
+ import { splitTopLevelWhitespace } from "./manualEditsStyleHelpers";
36
37
 
37
38
  /* ── Gesture tracking ─────────────────────────────────────────────── */
38
39
  let studioManualEditGestureId = 0;
@@ -162,24 +163,6 @@ export function restoreInlineDisplay(element: HTMLElement): void {
162
163
  }
163
164
 
164
165
  /* ── Translate helpers ────────────────────────────────────────────── */
165
- function splitTopLevelWhitespace(value: string): string[] {
166
- const parts: string[] = [];
167
- let depth = 0;
168
- let current = "";
169
- for (const char of value.trim()) {
170
- if (char === "(") depth += 1;
171
- if (char === ")") depth = Math.max(0, depth - 1);
172
- if (/\s/.test(char) && depth === 0) {
173
- if (current) parts.push(current);
174
- current = "";
175
- } else {
176
- current += char;
177
- }
178
- }
179
- if (current) parts.push(current);
180
- return parts;
181
- }
182
-
183
166
  function composeTranslateValue(element: HTMLElement, x: string, y: string): string {
184
167
  const original = element.getAttribute(STUDIO_ORIGINAL_TRANSLATE_ATTR)?.trim();
185
168
  if (!original || original === "none") return `${x} ${y}`;
@@ -0,0 +1,18 @@
1
+ export function splitTopLevelWhitespace(value: string): string[] {
2
+ const parts: string[] = [];
3
+ let depth = 0;
4
+ // fallow-ignore-next-line code-duplication
5
+ let current = "";
6
+ for (const char of value.trim()) {
7
+ if (char === "(") depth += 1;
8
+ if (char === ")") depth = Math.max(0, depth - 1);
9
+ if (/\s/.test(char) && depth === 0) {
10
+ if (current) parts.push(current);
11
+ current = "";
12
+ } else {
13
+ current += char;
14
+ }
15
+ }
16
+ if (current) parts.push(current);
17
+ return parts;
18
+ }
@@ -0,0 +1,74 @@
1
+ import type React from "react";
2
+ import type { OffCanvasRect } from "./OffCanvasIndicators";
3
+ import { hugRectForElement } from "./domEditOverlayCrop";
4
+ import { groupAwareOverlayRect } from "./domEditOverlayGeometry";
5
+ import { isElementComputedVisible } from "./domEditingElement";
6
+ import { collectDomEditLayerItems } from "./domEditingLayers";
7
+
8
+ function rounded(value: number): number {
9
+ return Math.round(value * 100) / 100;
10
+ }
11
+
12
+ function offCanvasSignature(rects: OffCanvasRect[]): string {
13
+ return rects
14
+ .map(
15
+ (rect) =>
16
+ `${rect.key}:${rounded(rect.left)},${rounded(rect.top)},${rounded(rect.width)},${rounded(rect.height)}`,
17
+ )
18
+ .join("|");
19
+ }
20
+
21
+ // fallow-ignore-next-line complexity
22
+ export function recomputeOffCanvasIndicators(
23
+ iframe: HTMLIFrameElement,
24
+ overlay: HTMLDivElement,
25
+ doc: Document | null | undefined,
26
+ comp: { left: number; top: number; width: number; height: number },
27
+ activeCompositionPath: string | null,
28
+ sigRef: React.MutableRefObject<string>,
29
+ elementsRef: React.MutableRefObject<Map<string, HTMLElement>>,
30
+ setRects: (rects: OffCanvasRect[]) => void,
31
+ ): void {
32
+ if (comp.width <= 0 || !doc) {
33
+ sigRef.current = "";
34
+ elementsRef.current = new Map();
35
+ setRects([]);
36
+ return;
37
+ }
38
+
39
+ const root = doc.querySelector<HTMLElement>("[data-composition-id]") ?? doc.body;
40
+ const acp = activeCompositionPath ?? "index.html";
41
+ const items = collectDomEditLayerItems(root, {
42
+ activeCompositionPath: acp,
43
+ isMasterView: !acp || acp === "index.html",
44
+ });
45
+ const rects: OffCanvasRect[] = [];
46
+ const elMap = new Map<string, HTMLElement>();
47
+ for (const item of items) {
48
+ if (!isElementComputedVisible(item.element)) continue;
49
+ // Groups use their members' union (where they actually render), so a group
50
+ // whose members sit inside the canvas isn't flagged off-canvas by a stale
51
+ // wrapper box. Crop-hug the result so an inset crop that keeps the visible
52
+ // part on-canvas doesn't flag the element either.
53
+ const base = groupAwareOverlayRect(overlay, iframe, item.element);
54
+ const r = base ? { ...base, ...hugRectForElement(base, item.element) } : null;
55
+ if (!r) continue;
56
+ // Any edge crossing the composition border → gray-zone indicator (the
57
+ // in-canvas portion is clipped away below, so only the sliver shows).
58
+ const extendsOutsideComp =
59
+ r.left < comp.left ||
60
+ r.left + r.width > comp.left + comp.width ||
61
+ r.top < comp.top ||
62
+ r.top + r.height > comp.top + comp.height;
63
+ if (extendsOutsideComp) {
64
+ rects.push({ key: item.key, left: r.left, top: r.top, width: r.width, height: r.height });
65
+ elMap.set(item.key, item.element);
66
+ }
67
+ }
68
+
69
+ const nextSig = offCanvasSignature(rects);
70
+ if (nextSig === sigRef.current) return;
71
+ sigRef.current = nextSig;
72
+ elementsRef.current = elMap;
73
+ setRects(rects);
74
+ }
@@ -0,0 +1,154 @@
1
+ // @vitest-environment happy-dom
2
+
3
+ import React, { act } from "react";
4
+ import { createRoot, type Root } from "react-dom/client";
5
+ import { describe, expect, it } from "vitest";
6
+ import { DomEditOverlay } from "./DomEditOverlay";
7
+
8
+ Reflect.set(globalThis, "IS_REACT_ACT_ENVIRONMENT", true);
9
+
10
+ const INDICATOR = '[aria-label="Select off-canvas element index.html:headline:0"]';
11
+
12
+ function domRect(left: number, top: number, width: number, height: number): DOMRect {
13
+ return {
14
+ left,
15
+ top,
16
+ right: left + width,
17
+ bottom: top + height,
18
+ width,
19
+ height,
20
+ x: left,
21
+ y: top,
22
+ toJSON: () => ({}),
23
+ };
24
+ }
25
+
26
+ async function flushAnimationFrames(): Promise<void> {
27
+ await new Promise<void>((resolve) => {
28
+ requestAnimationFrame(() => requestAnimationFrame(() => resolve()));
29
+ });
30
+ }
31
+
32
+ interface OverlayHarness {
33
+ host: HTMLElement;
34
+ movedElement: HTMLElement;
35
+ cleanup: () => void;
36
+ }
37
+
38
+ // Mount DomEditOverlay over an iframe whose #headline sits at `initialLeft`, with a
39
+ // getBoundingClientRect stub that reads the element's live inline geometry (so a
40
+ // style mutation moves it) and reports the composition/overlay as 800x450 at origin.
41
+ function mountOverlayWithHeadline(initialLeft: number): OverlayHarness {
42
+ const originalGetBoundingClientRect = Element.prototype.getBoundingClientRect;
43
+ const host = document.createElement("div");
44
+ document.body.append(host);
45
+ const root: Root = createRoot(host);
46
+ const iframe = document.createElement("iframe");
47
+ document.body.append(iframe);
48
+ const doc = iframe.contentDocument;
49
+ if (!doc) throw new Error("Expected iframe content document");
50
+
51
+ doc.body.innerHTML = `
52
+ <div data-composition-id="root" data-width="800" data-height="450">
53
+ <div id="headline" style="position:absolute; left:${initialLeft}px; top:40px; width:100px; height:40px;">Headline</div>
54
+ </div>
55
+ `;
56
+ const movedElement = doc.getElementById("headline");
57
+ if (!movedElement) throw new Error("Expected test element");
58
+
59
+ Element.prototype.getBoundingClientRect = function (): DOMRect {
60
+ if (this === movedElement) {
61
+ return domRect(
62
+ Number.parseFloat(movedElement.style.left),
63
+ Number.parseFloat(movedElement.style.top),
64
+ Number.parseFloat(movedElement.style.width),
65
+ Number.parseFloat(movedElement.style.height),
66
+ );
67
+ }
68
+ return domRect(0, 0, 800, 450);
69
+ };
70
+
71
+ act(() => {
72
+ root.render(
73
+ <DomEditOverlay
74
+ iframeRef={{ current: iframe }}
75
+ activeCompositionPath={null}
76
+ selection={null}
77
+ hoverSelection={null}
78
+ groupSelections={[]}
79
+ onCanvasMouseDown={() => {}}
80
+ onCanvasPointerMove={() => Promise.resolve(null)}
81
+ onCanvasPointerLeave={() => {}}
82
+ onSelectionChange={() => {}}
83
+ onBlockedMove={() => {}}
84
+ onPathOffsetCommit={() => {}}
85
+ onGroupPathOffsetCommit={() => {}}
86
+ onBoxSizeCommit={() => {}}
87
+ onRotationCommit={() => {}}
88
+ />,
89
+ );
90
+ });
91
+
92
+ return {
93
+ host,
94
+ movedElement,
95
+ cleanup: () => {
96
+ act(() => root.unmount());
97
+ Element.prototype.getBoundingClientRect = originalGetBoundingClientRect;
98
+ iframe.remove();
99
+ host.remove();
100
+ },
101
+ };
102
+ }
103
+
104
+ describe("off-canvas indicator refresh", () => {
105
+ it("removes the indicator when an off-canvas element moves in-canvas (off->on)", async () => {
106
+ const h = mountOverlayWithHeadline(760);
107
+ try {
108
+ await act(async () => {
109
+ await flushAnimationFrames();
110
+ });
111
+ expect(h.host.querySelector(INDICATOR)).toBeTruthy();
112
+
113
+ act(() => {
114
+ h.movedElement.style.left = "120px";
115
+ });
116
+ await act(async () => {
117
+ await Promise.resolve();
118
+ await flushAnimationFrames();
119
+ });
120
+
121
+ expect(h.host.querySelector(INDICATOR)).toBeNull();
122
+ } finally {
123
+ h.cleanup();
124
+ }
125
+ });
126
+
127
+ it("tracks the indicator to the new position when it stays off-canvas (off->off)", async () => {
128
+ const h = mountOverlayWithHeadline(760);
129
+ try {
130
+ await act(async () => {
131
+ await flushAnimationFrames();
132
+ });
133
+ const before = h.host.querySelector(INDICATOR);
134
+ expect(before).toBeTruthy();
135
+ const leftBefore = (before!.parentElement as HTMLElement).style.left;
136
+
137
+ // Move further off-canvas (still outside the 800px-wide composition).
138
+ act(() => {
139
+ h.movedElement.style.left = "1200px";
140
+ });
141
+ await act(async () => {
142
+ await Promise.resolve();
143
+ await flushAnimationFrames();
144
+ });
145
+
146
+ const after = h.host.querySelector(INDICATOR);
147
+ expect(after).toBeTruthy();
148
+ const leftAfter = (after!.parentElement as HTMLElement).style.left;
149
+ expect(leftAfter).not.toEqual(leftBefore);
150
+ } finally {
151
+ h.cleanup();
152
+ }
153
+ });
154
+ });