@hyperframes/studio 0.8.138 → 0.8.140

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 (135) hide show
  1. package/dist/assets/{hyperframes-player-B30apksz.js → hyperframes-player-BLuyxnK7.js} +1 -1
  2. package/dist/assets/{index-DXVvWfQG.js → index-CWfuDCoA.js} +1 -1
  3. package/dist/assets/{index-DIJuIBwI.js → index-Dr3Bo9uH.js} +1 -1
  4. package/dist/assets/{index-BdjA0CLD.js → index-VAHDM_zL.js} +210 -210
  5. package/dist/{chunk-4S3AYCOG.js → chunk-H2DUOXTF.js} +3 -2
  6. package/dist/{chunk-TDWGXBUT.js → chunk-TFLU2MGI.js} +1 -6
  7. package/dist/chunk-TFLU2MGI.js.map +1 -0
  8. package/dist/{chunk-7UQWVN52.js → chunk-UYWON5AX.js} +2 -2
  9. package/dist/{domEditingLayers-IEVLO54Z.js → domEditingLayers-KBTPQKGA.js} +3 -3
  10. package/dist/index.d.ts +20 -3
  11. package/dist/index.html +1 -1
  12. package/dist/index.js +7327 -6265
  13. package/dist/index.js.map +1 -1
  14. package/dist/player.js +2 -2
  15. package/package.json +8 -8
  16. package/src/App.tsx +9 -4
  17. package/src/components/TimelineToolbar.test.tsx +79 -1
  18. package/src/components/TimelineToolbar.tsx +14 -29
  19. package/src/components/editor/DomEditOverlay.tsx +11 -7
  20. package/src/components/editor/PropertyPanel.test.tsx +600 -748
  21. package/src/components/editor/PropertyPanel.tsx +1 -0
  22. package/src/components/editor/PropertyPanelFlat.tsx +1 -0
  23. package/src/components/editor/anchoredResizeCommitFeedsOffset.test.ts +2 -0
  24. package/src/components/editor/domEditOverlayGeometry.ts +7 -7
  25. package/src/components/editor/domEditOverlayGestures.ts +18 -0
  26. package/src/components/editor/domEditOverlayStartGesture.blocked.test.ts +1 -0
  27. package/src/components/editor/domEditOverlayStartGesture.test.ts +3 -0
  28. package/src/components/editor/domEditOverlayStartGesture.ts +9 -1
  29. package/src/components/editor/dragUndoPaint.test.ts +2 -0
  30. package/src/components/editor/gestureHoldsPreviewReload.test.tsx +432 -15
  31. package/src/components/editor/groupDropKeepsSelection.test.ts +1 -0
  32. package/src/components/editor/manualEdits.ts +1 -1
  33. package/src/components/editor/manualEditsDom.ts +7 -7
  34. package/src/components/editor/manualEditsParsing.ts +14 -2
  35. package/src/components/editor/manualEditsTypes.ts +5 -0
  36. package/src/components/editor/manualOffsetDrag.test.ts +23 -0
  37. package/src/components/editor/manualOffsetDrag.ts +5 -0
  38. package/src/components/editor/previewGestureStarts.ts +284 -0
  39. package/src/components/editor/previewReadOnly.overlay.test.tsx +3 -2
  40. package/src/components/editor/propertyPanelFill.tsx +36 -13
  41. package/src/components/editor/propertyPanelFlatStyleSections.test.tsx +4 -0
  42. package/src/components/editor/propertyPanelFlatStyleSections.tsx +13 -7
  43. package/src/components/editor/propertyPanelImageFill.test.tsx +164 -0
  44. package/src/components/editor/propertyPanelStyleSections.tsx +10 -7
  45. package/src/components/editor/rotateInlineSave.test.ts +1 -0
  46. package/src/components/editor/shiftDragAxisLock.test.tsx +1 -0
  47. package/src/components/editor/useDomEditOverlayGestures.ts +67 -71
  48. package/src/components/nle/NLEContext.test.ts +26 -0
  49. package/src/components/nle/NLEContext.tsx +8 -3
  50. package/src/components/nle/NLEPreview.test.ts +44 -1
  51. package/src/components/nle/NLEPreview.tsx +7 -2
  52. package/src/hooks/draggedGsapPosition.ts +8 -0
  53. package/src/hooks/editMoment.ts +13 -0
  54. package/src/hooks/externalFileChangeOwnership.test.ts +25 -4
  55. package/src/hooks/gsapDragCommit.ts +2 -1
  56. package/src/hooks/gsapDragPositionCommit.ts +19 -7
  57. package/src/hooks/gsapPositionDetection.ts +5 -3
  58. package/src/hooks/gsapResizeIntercept.ts +11 -4
  59. package/src/hooks/gsapRuntimeBridge.ts +31 -11
  60. package/src/hooks/gsapValueAtPlayhead.browser.test.ts +3 -22
  61. package/src/hooks/gsapValueAtPlayhead.test.ts +15 -0
  62. package/src/hooks/gsapValueAtPlayhead.ts +12 -5
  63. package/src/hooks/timelineEditingHelpers.test.ts +22 -0
  64. package/src/hooks/timelineEditingHelpers.ts +18 -2
  65. package/src/hooks/useDomSelection.test.ts +21 -2
  66. package/src/hooks/useEditHistoryActions.test.tsx +20 -0
  67. package/src/hooks/useEditHistoryActions.ts +2 -0
  68. package/src/hooks/useExternalFileChangeCoordinator.test.tsx +536 -10
  69. package/src/hooks/useExternalFileChangeCoordinator.ts +72 -45
  70. package/src/hooks/useGsapAwareEditing.test.tsx +38 -0
  71. package/src/hooks/useGsapAwareEditing.ts +148 -136
  72. package/src/hooks/useGsapAwareGroupMove.ts +126 -113
  73. package/src/hooks/useGsapScriptCommits.ts +5 -4
  74. package/src/hooks/useMusicBeatAnalysis.ts +2 -1
  75. package/src/hooks/usePreviewPersistence.history.test.tsx +7 -2
  76. package/src/hooks/usePreviewPersistence.ts +1 -4
  77. package/src/hooks/useSdkSession.lifecycle.test.tsx +20 -0
  78. package/src/hooks/useSdkSession.ts +4 -1
  79. package/src/hooks/useThumbnailStripSize.test.tsx +65 -0
  80. package/src/hooks/useThumbnailStripSize.ts +23 -1
  81. package/src/hooks/useTimelineDeleteOps.test.tsx +10 -0
  82. package/src/hooks/useTimelineDeleteOps.ts +9 -10
  83. package/src/index.ts +2 -0
  84. package/src/player/components/AudioWaveform.test.tsx +153 -1
  85. package/src/player/components/AudioWaveform.tsx +93 -37
  86. package/src/player/components/Timeline.scrollRest.test.tsx +22 -0
  87. package/src/player/components/Timeline.test.ts +3 -1
  88. package/src/player/components/TimelineCanvas.tsx +4 -0
  89. package/src/player/components/TimelineClip.tsx +20 -6
  90. package/src/player/components/TimelineClipFades.test.tsx +11 -0
  91. package/src/player/components/TimelineClipFades.tsx +4 -4
  92. package/src/player/components/TimelineClipJoins.tsx +23 -18
  93. package/src/player/components/TimelineGestureOverlay.tsx +30 -27
  94. package/src/player/components/TimelineGroupRow.tsx +1 -0
  95. package/src/player/components/TimelineLaneClip.tsx +127 -0
  96. package/src/player/components/TimelineLanes.test.tsx +51 -6
  97. package/src/player/components/TimelineLanes.tsx +166 -178
  98. package/src/player/components/TimelineRuler.tsx +2 -0
  99. package/src/player/components/TimelineTimeLayer.tsx +25 -0
  100. package/src/player/components/VideoThumbnail.test.tsx +204 -12
  101. package/src/player/components/VideoThumbnail.tsx +37 -9
  102. package/src/player/components/thumbnailUtils.test.ts +17 -3
  103. package/src/player/components/thumbnailUtils.ts +17 -1
  104. package/src/player/components/timelineLayout.test.ts +3 -3
  105. package/src/player/components/timelineMotion.test.ts +27 -0
  106. package/src/player/components/timelineMotion.ts +62 -0
  107. package/src/player/components/timelineZoomInput.test.ts +438 -0
  108. package/src/player/components/timelineZoomInput.ts +456 -0
  109. package/src/player/components/useTimelineGeometry.ts +0 -3
  110. package/src/player/components/useTimelinePlayhead.test.tsx +97 -30
  111. package/src/player/components/useTimelinePlayhead.ts +53 -59
  112. package/src/player/components/useTimelineProviderState.tsx +16 -21
  113. package/src/player/components/useTimelineZoom.ts +7 -12
  114. package/src/player/hooks/useShadowPreviewReload.ts +6 -6
  115. package/src/player/hooks/useTimelinePlayer.shadowReload.test.ts +14 -0
  116. package/src/player/hooks/useTimelinePlayer.ts +8 -5
  117. package/src/player/lib/thumbnailScheduler.test.ts +20 -1
  118. package/src/player/lib/thumbnailScheduler.ts +1 -1
  119. package/src/player/lib/thumbnailVideoDecoder.test.ts +230 -30
  120. package/src/player/lib/thumbnailVideoDecoder.ts +197 -54
  121. package/src/player/lib/timelineElementHelpers.ts +0 -11
  122. package/src/player/lib/timelineViewportBudgets.test.ts +1 -1
  123. package/src/player/lib/timelineViewportBudgets.ts +1 -1
  124. package/src/player/previewReloading.test.ts +48 -0
  125. package/src/player/previewReloading.ts +48 -0
  126. package/src/utils/gsapUndoRestore.browser.test.ts +4 -17
  127. package/src/utils/rootDuration.ts +5 -4
  128. package/src/utils/studioPendingEdits.test.ts +27 -0
  129. package/src/utils/studioPendingEdits.ts +9 -0
  130. package/src/utils/timelineAssetDrop.test.ts +13 -4
  131. package/src/utils/timelineAssetDrop.ts +6 -2
  132. package/dist/chunk-TDWGXBUT.js.map +0 -1
  133. /package/dist/{chunk-4S3AYCOG.js.map → chunk-H2DUOXTF.js.map} +0 -0
  134. /package/dist/{chunk-7UQWVN52.js.map → chunk-UYWON5AX.js.map} +0 -0
  135. /package/dist/{domEditingLayers-IEVLO54Z.js.map → domEditingLayers-KBTPQKGA.js.map} +0 -0
@@ -7,10 +7,12 @@ import {
7
7
  captureStudioPathOffset,
8
8
  clearStudioPathOffset,
9
9
  endStudioManualEditGesture,
10
+ isStudioManualEditGestureCurrent,
10
11
  readAppliedStudioPathOffset,
11
12
  restoreStudioPathOffset,
12
13
  type StudioPathOffsetSnapshot,
13
14
  } from "./manualEdits";
15
+ import type { EditMoment } from "./manualEditsTypes";
14
16
  import { computeDraggedGsapPosition } from "../../hooks/draggedGsapPosition";
15
17
  import { editsPlainCss } from "../../hooks/gsapRuntimeKeyframes";
16
18
  import { readTranslatePx, UNREADABLE_TRANSLATE, writeTranslatePx } from "./plainTranslate";
@@ -315,6 +317,7 @@ export function createManualOffsetDragMember(input: {
315
317
  element: HTMLElement;
316
318
  rect: ManualOffsetDragRect;
317
319
  gesture?: "drag" | "nudge" | "resize"; // resize: the anchor that keeps its centre planted
320
+ at?: EditMoment;
318
321
  }): ManualOffsetDragMemberResult {
319
322
  const plainTranslate =
320
323
  !!input.gesture && editsPlainCss(input.element, input.gesture === "resize" ? "resize" : "move");
@@ -357,6 +360,7 @@ export function createManualOffsetDragMember(input: {
357
360
  const gestureToken = beginStudioManualEditGesture(
358
361
  input.element,
359
362
  input.gesture === "resize" ? "resize" : "move",
363
+ input.at,
360
364
  );
361
365
  const measured = measureManualOffsetDragScreenToOffsetMatrix(input.element, initialOffset, {
362
366
  scaleX: input.rect.editScaleX,
@@ -531,6 +535,7 @@ function settleDraftTranslate(element: HTMLElement, gsapOwnsPosition: boolean):
531
535
  /** Teardown after a COMMITTED drag. */
532
536
  export function endManualOffsetDragMembers(members: ManualOffsetDragMember[]): void {
533
537
  for (const member of members) {
538
+ if (!isStudioManualEditGestureCurrent(member.element, member.gestureToken)) continue;
534
539
  endStudioManualEditGesture(member.element, member.gestureToken);
535
540
  member.element.removeAttribute("data-hf-drag-initial-offset-x");
536
541
  member.element.removeAttribute("data-hf-drag-initial-offset-y");
@@ -0,0 +1,284 @@
1
+ import {
2
+ orientedGroupAwareOverlayRect,
3
+ shiftedOverlayRect,
4
+ type OverlayRect,
5
+ } from "./domEditOverlayGeometry";
6
+ import { findElementForSelection, type DomEditSelection } from "./domEditing";
7
+ import { computeOverlayRootScale } from "./domEditOverlayBasis";
8
+ import { resolveCenterResizeScale } from "./domEditResizeLocal";
9
+ import {
10
+ PRESS_WAITING_ATTR,
11
+ type WaitingPressState,
12
+ type GestureKind,
13
+ type ResizeHandle,
14
+ type UseDomEditOverlayGesturesOptions,
15
+ } from "./domEditOverlayGestures";
16
+ import {
17
+ startGesture as _startGesture,
18
+ startGroupDrag as _startGroupDrag,
19
+ } from "./domEditOverlayStartGesture";
20
+ import { giveUpOnPreviewChange, isPreviewChanging } from "../../player/previewReloading";
21
+ import { setStudioWaitingPressCancel } from "../../utils/studioPendingEdits";
22
+ import { playheadMoment } from "../../hooks/editMoment";
23
+ import type { EditMoment } from "./manualEditsTypes";
24
+
25
+ const MAX_SETTLED_WAIT_FRAMES = 60;
26
+ export const PRESS_WAIT_MAX_MS = 10_000;
27
+
28
+ export function createPreviewGestureStarts(
29
+ opts: UseDomEditOverlayGesturesOptions,
30
+ moveActiveGesture: (e: React.PointerEvent<HTMLDivElement>) => void,
31
+ releaseActiveGesture: (e: React.PointerEvent<HTMLDivElement>) => void,
32
+ ) {
33
+ /** Points Cmd+Z at the newest released press still waiting, if any. */
34
+ const offerUndo = () => {
35
+ let p = opts.waitingPressRef.current;
36
+ while (p && !p.released) p = p.after;
37
+ const press = p;
38
+ setStudioWaitingPressCancel(press && (() => dropPress(press)));
39
+ };
40
+
41
+ const clearWaitingMark = () => {
42
+ opts.boxRef.current?.removeAttribute(PRESS_WAITING_ATTR);
43
+ opts.rafPausedRef.current = false;
44
+ };
45
+
46
+ /** Points the press queued right behind `press` at `next` instead. */
47
+ const unlink = (press: WaitingPressState, next: WaitingPressState | null) => {
48
+ for (let p = opts.waitingPressRef.current; p; p = p.after)
49
+ if (p.after === press) p.after = next;
50
+ };
51
+
52
+ const endWaitingPress = (press: WaitingPressState | null = opts.waitingPressRef.current) => {
53
+ for (let p = press; p && !p.ended; p = p.after) {
54
+ p.ended = true;
55
+ cancelAnimationFrame(p.frame);
56
+ }
57
+ if (press === opts.waitingPressRef.current) {
58
+ opts.waitingPressRef.current = null;
59
+ clearWaitingMark();
60
+ }
61
+ // A press queued behind this one keeps waiting, on nothing that has ended.
62
+ else if (press) unlink(press, null);
63
+ offerUndo();
64
+ };
65
+
66
+ /** The newest press goes: the outline shows the press before it, or the box before this one. */
67
+ const showPressBefore = (press: WaitingPressState) => {
68
+ const before = press.after;
69
+ opts.waitingPressRef.current = before;
70
+ const shown = before ?? press;
71
+ const at = before?.moved;
72
+ const [dx, dy] = at ? [at.clientX - shown.startX, at.clientY - shown.startY] : [0, 0];
73
+ shown.draw?.(dx, dy);
74
+ if (!before) clearWaitingMark();
75
+ };
76
+
77
+ /** Takes one press out of the queue. */
78
+ const dropPress = (press: WaitingPressState): boolean => {
79
+ if (press.ended) return false;
80
+ press.ended = true;
81
+ cancelAnimationFrame(press.frame);
82
+ if (press === opts.waitingPressRef.current) showPressBefore(press);
83
+ else unlink(press, press.after);
84
+ offerUndo();
85
+ return true;
86
+ };
87
+
88
+ /** Ends the press still held, if any; presses already released are finished edits and still land. */
89
+ const endHeldPress = () => {
90
+ const held = opts.waitingPressRef.current;
91
+ if (held && !held.released) dropPress(held);
92
+ };
93
+
94
+ const releaseWaitingPress = (press: WaitingPressState, e: React.PointerEvent<HTMLDivElement>) => {
95
+ press.released = e;
96
+ offerUndo();
97
+ };
98
+
99
+ const drawPressedBox = (origin: OverlayRect | null): WaitingPressState["draw"] =>
100
+ origin && ((dx, dy) => opts.setOverlayRect(shiftedOverlayRect(origin, dx, dy)));
101
+
102
+ // A corner resize scales about the element's centre by the pointer's distance from it, as the resize does.
103
+ const drawResizedBox = (
104
+ origin: OverlayRect | null,
105
+ press: { x: number; y: number },
106
+ ): WaitingPressState["draw"] => {
107
+ if (!origin) return null;
108
+ const bounds = opts.overlayRef.current?.getBoundingClientRect();
109
+ const centre = {
110
+ x: (bounds?.left ?? 0) + origin.left + origin.width / 2,
111
+ y: (bounds?.top ?? 0) + origin.top + origin.height / 2,
112
+ };
113
+ return (dx, dy) => {
114
+ const pointer = { x: press.x + dx, y: press.y + dy };
115
+ const scale = resolveCenterResizeScale({ pointer, pointerStart: press, centerStart: centre });
116
+ const [width, height] = [origin.width * scale, origin.height * scale];
117
+ opts.setOverlayRect({
118
+ ...origin,
119
+ left: origin.left + (origin.width - width) / 2,
120
+ top: origin.top + (origin.height - height) / 2,
121
+ width,
122
+ height,
123
+ });
124
+ };
125
+ };
126
+
127
+ // A press on the page a reload is replacing would edit it by its old rules: it starts on the new page instead.
128
+ const startOnShownPreview = (
129
+ e: React.PointerEvent<HTMLElement>,
130
+ pressed: () => HTMLElement[],
131
+ start: (e: React.PointerEvent<HTMLElement>, at: EditMoment, waited: boolean) => boolean,
132
+ draw: WaitingPressState["draw"] = null,
133
+ ): boolean => {
134
+ const at = playheadMoment();
135
+ if (!isPreviewChanging() && !opts.waitingPressRef.current) return start(e, at, false);
136
+ e.preventDefault();
137
+ e.stopPropagation();
138
+ e.currentTarget.setPointerCapture(e.pointerId);
139
+ // The press already holds the pointer, which may be up by the time the gesture starts.
140
+ const held = { setPointerCapture() {} };
141
+ const down = { ...e, currentTarget: held, preventDefault() {}, stopPropagation() {} };
142
+ const press: WaitingPressState = {
143
+ pointerId: e.pointerId,
144
+ startX: e.clientX,
145
+ startY: e.clientY,
146
+ draw,
147
+ frame: 0,
148
+ moved: null,
149
+ released: null,
150
+ after: opts.waitingPressRef.current,
151
+ ended: false,
152
+ };
153
+ opts.waitingPressRef.current = press;
154
+ opts.boxRef.current?.setAttribute(PRESS_WAITING_ATTR, "true");
155
+ opts.rafPausedRef.current = true;
156
+ let shownFrames = 0;
157
+ let settledFrames = 0;
158
+ const since = performance.now();
159
+ /** Counts this frame toward the shown and settled streaks; returns how many pressed elements remain. */
160
+ const countFrame = () => {
161
+ const elements = pressed();
162
+ const live = opts.iframeRef.current?.contentDocument;
163
+ const settled = !isPreviewChanging();
164
+ const shown = settled && elements.every((el) => el.ownerDocument === live);
165
+ shownFrames = shown ? shownFrames + 1 : 0;
166
+ settledFrames = settled ? settledFrames + 1 : 0;
167
+ return elements.length;
168
+ };
169
+ const waitStep = (): "wait" | "lost" | "run" => {
170
+ if (press.after && !press.after.ended) return "wait";
171
+ const found = countFrame();
172
+ // A change that never ends (a save with no reply) would hold every press: run on the page shown.
173
+ const overdue = performance.now() - since > PRESS_WAIT_MAX_MS;
174
+ if (overdue) giveUpOnPreviewChange();
175
+ // The waiting outline stays drawn while replay measures the live element separately.
176
+ if (found === 0 || settledFrames > MAX_SETTLED_WAIT_FRAMES) return "lost";
177
+ return overdue || shownFrames >= 2 ? "run" : "wait";
178
+ };
179
+ const poll = () => {
180
+ if (press.ended) return;
181
+ const step = waitStep();
182
+ if (step === "wait") {
183
+ press.frame = requestAnimationFrame(poll);
184
+ return;
185
+ }
186
+ endWaitingPress(press);
187
+ if (step === "lost" || !start(down as unknown as React.PointerEvent<HTMLElement>, at, true))
188
+ return;
189
+ if (press.moved) moveActiveGesture(press.moved);
190
+ if (press.released) releaseActiveGesture(press.released);
191
+ };
192
+ press.frame = requestAnimationFrame(poll);
193
+ return true;
194
+ };
195
+
196
+ const shownSelection = (selection: DomEditSelection) => {
197
+ const doc = opts.iframeRef.current?.contentDocument;
198
+ if (!doc) return null;
199
+ const element = findElementForSelection(doc, selection, opts.activeCompositionPathRef.current);
200
+ return element && { ...selection, element };
201
+ };
202
+ const shownRect = (element: HTMLElement) => {
203
+ const overlay = opts.overlayRef.current;
204
+ const iframe = opts.iframeRef.current;
205
+ return overlay && iframe && orientedGroupAwareOverlayRect(overlay, iframe, element);
206
+ };
207
+
208
+ const startGroupDrag = (e: React.PointerEvent<HTMLElement>) => {
209
+ const items = opts.groupOverlayItemsRef.current;
210
+ return startOnShownPreview(
211
+ e,
212
+ () =>
213
+ items.flatMap((item) => {
214
+ const selection = shownSelection(item.selection);
215
+ return selection ? [selection.element] : [];
216
+ }),
217
+ (pressed, at, waited) => {
218
+ if (!waited) return _startGroupDrag(pressed, opts, at);
219
+ const overlay = opts.overlayRef.current;
220
+ const iframe = opts.iframeRef.current;
221
+ if (!overlay || !iframe) return false;
222
+ const scale = computeOverlayRootScale(overlay, iframe, iframe.contentDocument);
223
+ const measured = items.flatMap((item) => {
224
+ const selection = shownSelection(item.selection);
225
+ const rect =
226
+ selection && orientedGroupAwareOverlayRect(overlay, iframe, selection.element, scale);
227
+ return selection && rect
228
+ ? [{ ...item, selection, element: selection.element, rect }]
229
+ : [];
230
+ });
231
+ if (measured.length !== items.length) return false;
232
+ return _startGroupDrag(
233
+ pressed,
234
+ { ...opts, groupOverlayItemsRef: { current: measured } },
235
+ at,
236
+ );
237
+ },
238
+ (dx, dy) =>
239
+ opts.setGroupOverlayItems(
240
+ items.map((item) => ({ ...item, rect: shiftedOverlayRect(item.rect, dx, dy) })),
241
+ ),
242
+ );
243
+ };
244
+ const startGesture = (
245
+ kind: GestureKind,
246
+ e: React.PointerEvent<HTMLElement>,
247
+ options?: {
248
+ selection?: DomEditSelection;
249
+ rect?: OverlayRect | null;
250
+ resizeHandle?: ResizeHandle;
251
+ },
252
+ ) => {
253
+ // A replay edits what was pressed, though another element may be selected by then.
254
+ const target = options?.selection ?? opts.selectionRef.current;
255
+ let draw: WaitingPressState["draw"] = null;
256
+ if (kind === "drag") draw = drawPressedBox(opts.overlayRectRef.current);
257
+ if (kind === "resize")
258
+ draw = drawResizedBox(opts.overlayRectRef.current, { x: e.clientX, y: e.clientY });
259
+ return startOnShownPreview(
260
+ e,
261
+ () => {
262
+ const shown = target && shownSelection(target);
263
+ return shown ? [shown.element] : [];
264
+ },
265
+ (pressed, at, waited) => {
266
+ if (!waited) return _startGesture(kind, pressed, opts, { ...options, at });
267
+ const selection = target && shownSelection(target);
268
+ const rect = selection && shownRect(selection.element);
269
+ return (
270
+ !!(selection && rect) &&
271
+ _startGesture(kind, pressed, opts, {
272
+ selection,
273
+ rect,
274
+ resizeHandle: options?.resizeHandle,
275
+ at,
276
+ })
277
+ );
278
+ },
279
+ draw,
280
+ );
281
+ };
282
+
283
+ return { startGesture, startGroupDrag, endWaitingPress, endHeldPress, releaseWaitingPress };
284
+ }
@@ -311,7 +311,7 @@ describe("DomEditOverlay with canvasInput host", () => {
311
311
  expect(fixture(HOST).overlay.className).toContain("pointer-events-none");
312
312
  });
313
313
 
314
- it("draws no hover box", () => {
314
+ it("draws the hover box for the selection the host hovers, and none without one", () => {
315
315
  layout.hover = RECT;
316
316
  const hovered = (props = {}) => {
317
317
  const { overlay } = fixture(props);
@@ -321,7 +321,8 @@ describe("DomEditOverlay with canvasInput host", () => {
321
321
  };
322
322
  const selection = makeSelection("Hover", textElement("hover"));
323
323
  expect(hovered({ hoverSelection: selection })).not.toBeNull();
324
- expect(hovered({ ...HOST, hoverSelection: selection })).toBeNull();
324
+ expect(hovered({ ...HOST, hoverSelection: selection })).not.toBeNull();
325
+ expect(hovered({ ...HOST, hoverSelection: null })).toBeNull();
325
326
  });
326
327
 
327
328
  it("starts no marquee and makes no selection from a press on empty canvas", () => {
@@ -10,6 +10,7 @@ import {
10
10
  } from "./gradientValue";
11
11
  import { ReverseGradientIcon } from "../icons/ReverseGradientIcon";
12
12
  import { IMAGE_EXT } from "../../utils/mediaTypes";
13
+ import type { DomEditSelection } from "./domEditing";
13
14
  import { FIELD, LABEL, RESPONSIVE_GRID } from "./propertyPanelHelpers";
14
15
  import {
15
16
  DetailField,
@@ -27,9 +28,13 @@ import { useTrackDesignInput } from "../../contexts/DesignPanelInputContext";
27
28
  function normalizeProjectPath(value: string): string {
28
29
  const trimmed = value.trim();
29
30
  const maybeUrl = /^[a-z]+:\/\//i.test(trimmed) ? new URL(trimmed).pathname : trimmed;
30
- return decodeURIComponent(maybeUrl)
31
- .replace(/\\/g, "/")
32
- .replace(/^\.?\//, "");
31
+ let decodedPath = maybeUrl;
32
+ try {
33
+ decodedPath = decodeURIComponent(maybeUrl);
34
+ } catch (error) {
35
+ if (!(error instanceof URIError)) throw error;
36
+ }
37
+ return decodedPath.replace(/\\/g, "/").replace(/^\.?\//, "");
33
38
  }
34
39
 
35
40
  function toRelativeProjectAssetPath(sourceFile: string, assetPath: string): string {
@@ -75,7 +80,8 @@ function resolveSelectedAsset(
75
80
 
76
81
  export function ImageFillField({
77
82
  projectId,
78
- sourceFile,
83
+ element,
84
+ onSetHtmlAttribute,
79
85
  value,
80
86
  assets,
81
87
  disabled,
@@ -83,7 +89,8 @@ export function ImageFillField({
83
89
  onImportAssets,
84
90
  }: {
85
91
  projectId: string;
86
- sourceFile: string;
92
+ element: DomEditSelection;
93
+ onSetHtmlAttribute: (attr: string, value: string | null) => void | Promise<void>;
87
94
  value: string;
88
95
  assets: string[];
89
96
  disabled?: boolean;
@@ -91,15 +98,29 @@ export function ImageFillField({
91
98
  onImportAssets?: (files: FileList) => Promise<string[]>;
92
99
  }) {
93
100
  const track = useTrackDesignInput();
101
+ const sourceFile = element.sourceFile;
102
+ const isImage = element.tagName === "img";
103
+ const imageUrl = isImage ? (element.element.getAttribute("src") ?? "") : value;
104
+ const commitImage = (next: string, projectAsset = false) => {
105
+ if (isImage) {
106
+ if (next)
107
+ void onSetHtmlAttribute(
108
+ "src",
109
+ projectAsset ? toRelativeProjectAssetPath(sourceFile, next) : next,
110
+ );
111
+ return;
112
+ }
113
+ onCommit(next ? `url("${next}")` : "none");
114
+ };
94
115
  const fileInputRef = useRef<HTMLInputElement | null>(null);
95
116
  const [uploading, setUploading] = useState(false);
96
117
  const [uploadError, setUploadError] = useState<string | null>(null);
97
118
  const imageAssets = useMemo(() => assets.filter((a) => IMAGE_EXT.test(a)), [assets]);
98
119
  const selectedAsset = useMemo(
99
- () => resolveSelectedAsset(value, sourceFile, imageAssets),
100
- [imageAssets, sourceFile, value],
120
+ () => resolveSelectedAsset(imageUrl, sourceFile, imageAssets),
121
+ [imageAssets, sourceFile, imageUrl],
101
122
  );
102
- const externalUrlValue = selectedAsset ? "" : value;
123
+ const externalUrlValue = selectedAsset ? "" : imageUrl;
103
124
 
104
125
  const handleUpload = async (files: FileList | null) => {
105
126
  if (!files?.length || !onImportAssets) return;
@@ -110,7 +131,7 @@ export function ImageFillField({
110
131
  const nextImage = uploaded.find((a) => IMAGE_EXT.test(a));
111
132
  if (nextImage) {
112
133
  track("button", "Upload image");
113
- onCommit(`url("${toProjectRootAssetPath(nextImage)}")`);
134
+ commitImage(toProjectRootAssetPath(nextImage), true);
114
135
  }
115
136
  } catch {
116
137
  setUploadError("Upload failed — check the file and try again.");
@@ -174,14 +195,16 @@ export function ImageFillField({
174
195
  const next = e.target.value;
175
196
  track("select", "Project asset");
176
197
  if (!next) {
177
- onCommit("none");
198
+ commitImage("");
178
199
  return;
179
200
  }
180
- onCommit(`url("${toProjectRootAssetPath(next)}")`);
201
+ commitImage(toProjectRootAssetPath(next), true);
181
202
  }}
182
203
  className="min-w-0 w-full appearance-none bg-transparent text-[11px] font-medium text-neutral-100 outline-hidden disabled:cursor-not-allowed disabled:text-neutral-600"
183
204
  >
184
- <option value="">None</option>
205
+ <option value="" disabled={isImage}>
206
+ None
207
+ </option>
185
208
  {imageAssets.map((asset) => (
186
209
  <option key={asset} value={asset}>
187
210
  {asset}
@@ -201,7 +224,7 @@ export function ImageFillField({
201
224
  label="External URL"
202
225
  value={externalUrlValue}
203
226
  disabled={disabled}
204
- onCommit={(next) => onCommit(next.trim() ? `url("${next.trim()}")` : "none")}
227
+ onCommit={(next) => commitImage(next.trim())}
205
228
  />
206
229
  </div>
207
230
  );
@@ -63,6 +63,7 @@ function renderSection(
63
63
  styles={mergedStyles}
64
64
  assets={[]}
65
65
  onSetStyle={onSetStyle}
66
+ onSetHtmlAttribute={vi.fn()}
66
67
  gsapBorderRadius={gsapBorderRadius}
67
68
  />,
68
69
  );
@@ -375,6 +376,7 @@ describe("FlatStyleSection — blur sliders", () => {
375
376
  styles={{ filter: "blur(4px)" }}
376
377
  assets={[]}
377
378
  onSetStyle={onSetStyle}
379
+ onSetHtmlAttribute={vi.fn()}
378
380
  gsapBorderRadius={null}
379
381
  />,
380
382
  );
@@ -483,6 +485,7 @@ describe("FlatStyleSection — Overflow and Mask", () => {
483
485
  styles={{}}
484
486
  assets={[]}
485
487
  onSetStyle={onSetStyle}
488
+ onSetHtmlAttribute={vi.fn()}
486
489
  gsapBorderRadius={null}
487
490
  />,
488
491
  );
@@ -593,6 +596,7 @@ describe("FlatStyleSection — Opacity", () => {
593
596
  styles={{}}
594
597
  assets={[]}
595
598
  onSetStyle={onSetStyle}
599
+ onSetHtmlAttribute={vi.fn()}
596
600
  gsapBorderRadius={null}
597
601
  />,
598
602
  );
@@ -43,6 +43,7 @@ function FlatFillFields({
43
43
  styles,
44
44
  assets,
45
45
  onSetStyle,
46
+ onSetHtmlAttribute,
46
47
  onPreviewStyle,
47
48
  onImportAssets,
48
49
  }: {
@@ -51,18 +52,19 @@ function FlatFillFields({
51
52
  styles: Record<string, string>;
52
53
  assets: string[];
53
54
  onSetStyle: (prop: string, value: string) => void | Promise<unknown>;
55
+ onSetHtmlAttribute: (attr: string, value: string | null) => void | Promise<void>;
54
56
  onPreviewStyle?: (prop: string, value: string) => void;
55
57
  onImportAssets?: (files: FileList) => Promise<string[]>;
56
58
  }) {
57
59
  const styleEditingDisabled = !element.capabilities.canEditStyles;
58
60
  const backgroundImage = styles["background-image"] ?? "none";
59
61
  const hasTextControls = isTextEditableSelection(element);
60
- const fillMode =
61
- backgroundImage && backgroundImage !== "none"
62
- ? backgroundImage.includes("gradient")
63
- ? "Gradient"
64
- : "Image"
65
- : "Solid";
62
+ let fillMode = "Solid";
63
+ if (element.tagName === "img") {
64
+ fillMode = "Image";
65
+ } else if (backgroundImage && backgroundImage !== "none") {
66
+ fillMode = backgroundImage.includes("gradient") ? "Gradient" : "Image";
67
+ }
66
68
  const [preferredFillMode, setPreferredFillMode] = useState(fillMode);
67
69
  const imageUrl = extractBackgroundImageUrl(backgroundImage);
68
70
 
@@ -124,7 +126,8 @@ function FlatFillFields({
124
126
  ) : (
125
127
  <ImageFillField
126
128
  projectId={projectId}
127
- sourceFile={element.sourceFile}
129
+ element={element}
130
+ onSetHtmlAttribute={onSetHtmlAttribute}
128
131
  value={imageUrl}
129
132
  assets={assets}
130
133
  disabled={styleEditingDisabled}
@@ -456,6 +459,7 @@ export function FlatStyleSection({
456
459
  styles,
457
460
  assets,
458
461
  onSetStyle,
462
+ onSetHtmlAttribute,
459
463
  onPreviewStyle,
460
464
  onImportAssets,
461
465
  gsapBorderRadius,
@@ -465,6 +469,7 @@ export function FlatStyleSection({
465
469
  styles: Record<string, string>;
466
470
  assets: string[];
467
471
  onSetStyle: (prop: string, value: string) => void | Promise<unknown>;
472
+ onSetHtmlAttribute: (attr: string, value: string | null) => void | Promise<void>;
468
473
  onPreviewStyle?: (prop: string, value: string) => void;
469
474
  onImportAssets?: (files: FileList) => Promise<string[]>;
470
475
  gsapBorderRadius?: { tl: number; tr: number; br: number; bl: number } | null;
@@ -478,6 +483,7 @@ export function FlatStyleSection({
478
483
  styles={styles}
479
484
  assets={assets}
480
485
  onSetStyle={onSetStyle}
486
+ onSetHtmlAttribute={onSetHtmlAttribute}
481
487
  onPreviewStyle={onPreviewStyle}
482
488
  onImportAssets={onImportAssets}
483
489
  />