@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.
- package/dist/assets/{hyperframes-player-B30apksz.js → hyperframes-player-BLuyxnK7.js} +1 -1
- package/dist/assets/{index-DXVvWfQG.js → index-CWfuDCoA.js} +1 -1
- package/dist/assets/{index-DIJuIBwI.js → index-Dr3Bo9uH.js} +1 -1
- package/dist/assets/{index-BdjA0CLD.js → index-VAHDM_zL.js} +210 -210
- package/dist/{chunk-4S3AYCOG.js → chunk-H2DUOXTF.js} +3 -2
- package/dist/{chunk-TDWGXBUT.js → chunk-TFLU2MGI.js} +1 -6
- package/dist/chunk-TFLU2MGI.js.map +1 -0
- package/dist/{chunk-7UQWVN52.js → chunk-UYWON5AX.js} +2 -2
- package/dist/{domEditingLayers-IEVLO54Z.js → domEditingLayers-KBTPQKGA.js} +3 -3
- package/dist/index.d.ts +20 -3
- package/dist/index.html +1 -1
- package/dist/index.js +7327 -6265
- package/dist/index.js.map +1 -1
- package/dist/player.js +2 -2
- package/package.json +8 -8
- package/src/App.tsx +9 -4
- package/src/components/TimelineToolbar.test.tsx +79 -1
- package/src/components/TimelineToolbar.tsx +14 -29
- package/src/components/editor/DomEditOverlay.tsx +11 -7
- package/src/components/editor/PropertyPanel.test.tsx +600 -748
- package/src/components/editor/PropertyPanel.tsx +1 -0
- package/src/components/editor/PropertyPanelFlat.tsx +1 -0
- package/src/components/editor/anchoredResizeCommitFeedsOffset.test.ts +2 -0
- package/src/components/editor/domEditOverlayGeometry.ts +7 -7
- package/src/components/editor/domEditOverlayGestures.ts +18 -0
- package/src/components/editor/domEditOverlayStartGesture.blocked.test.ts +1 -0
- package/src/components/editor/domEditOverlayStartGesture.test.ts +3 -0
- package/src/components/editor/domEditOverlayStartGesture.ts +9 -1
- package/src/components/editor/dragUndoPaint.test.ts +2 -0
- package/src/components/editor/gestureHoldsPreviewReload.test.tsx +432 -15
- package/src/components/editor/groupDropKeepsSelection.test.ts +1 -0
- package/src/components/editor/manualEdits.ts +1 -1
- package/src/components/editor/manualEditsDom.ts +7 -7
- package/src/components/editor/manualEditsParsing.ts +14 -2
- package/src/components/editor/manualEditsTypes.ts +5 -0
- package/src/components/editor/manualOffsetDrag.test.ts +23 -0
- package/src/components/editor/manualOffsetDrag.ts +5 -0
- package/src/components/editor/previewGestureStarts.ts +284 -0
- package/src/components/editor/previewReadOnly.overlay.test.tsx +3 -2
- package/src/components/editor/propertyPanelFill.tsx +36 -13
- package/src/components/editor/propertyPanelFlatStyleSections.test.tsx +4 -0
- package/src/components/editor/propertyPanelFlatStyleSections.tsx +13 -7
- package/src/components/editor/propertyPanelImageFill.test.tsx +164 -0
- package/src/components/editor/propertyPanelStyleSections.tsx +10 -7
- package/src/components/editor/rotateInlineSave.test.ts +1 -0
- package/src/components/editor/shiftDragAxisLock.test.tsx +1 -0
- package/src/components/editor/useDomEditOverlayGestures.ts +67 -71
- package/src/components/nle/NLEContext.test.ts +26 -0
- package/src/components/nle/NLEContext.tsx +8 -3
- package/src/components/nle/NLEPreview.test.ts +44 -1
- package/src/components/nle/NLEPreview.tsx +7 -2
- package/src/hooks/draggedGsapPosition.ts +8 -0
- package/src/hooks/editMoment.ts +13 -0
- package/src/hooks/externalFileChangeOwnership.test.ts +25 -4
- package/src/hooks/gsapDragCommit.ts +2 -1
- package/src/hooks/gsapDragPositionCommit.ts +19 -7
- package/src/hooks/gsapPositionDetection.ts +5 -3
- package/src/hooks/gsapResizeIntercept.ts +11 -4
- package/src/hooks/gsapRuntimeBridge.ts +31 -11
- package/src/hooks/gsapValueAtPlayhead.browser.test.ts +3 -22
- package/src/hooks/gsapValueAtPlayhead.test.ts +15 -0
- package/src/hooks/gsapValueAtPlayhead.ts +12 -5
- package/src/hooks/timelineEditingHelpers.test.ts +22 -0
- package/src/hooks/timelineEditingHelpers.ts +18 -2
- package/src/hooks/useDomSelection.test.ts +21 -2
- package/src/hooks/useEditHistoryActions.test.tsx +20 -0
- package/src/hooks/useEditHistoryActions.ts +2 -0
- package/src/hooks/useExternalFileChangeCoordinator.test.tsx +536 -10
- package/src/hooks/useExternalFileChangeCoordinator.ts +72 -45
- package/src/hooks/useGsapAwareEditing.test.tsx +38 -0
- package/src/hooks/useGsapAwareEditing.ts +148 -136
- package/src/hooks/useGsapAwareGroupMove.ts +126 -113
- package/src/hooks/useGsapScriptCommits.ts +5 -4
- package/src/hooks/useMusicBeatAnalysis.ts +2 -1
- package/src/hooks/usePreviewPersistence.history.test.tsx +7 -2
- package/src/hooks/usePreviewPersistence.ts +1 -4
- package/src/hooks/useSdkSession.lifecycle.test.tsx +20 -0
- package/src/hooks/useSdkSession.ts +4 -1
- package/src/hooks/useThumbnailStripSize.test.tsx +65 -0
- package/src/hooks/useThumbnailStripSize.ts +23 -1
- package/src/hooks/useTimelineDeleteOps.test.tsx +10 -0
- package/src/hooks/useTimelineDeleteOps.ts +9 -10
- package/src/index.ts +2 -0
- package/src/player/components/AudioWaveform.test.tsx +153 -1
- package/src/player/components/AudioWaveform.tsx +93 -37
- package/src/player/components/Timeline.scrollRest.test.tsx +22 -0
- package/src/player/components/Timeline.test.ts +3 -1
- package/src/player/components/TimelineCanvas.tsx +4 -0
- package/src/player/components/TimelineClip.tsx +20 -6
- package/src/player/components/TimelineClipFades.test.tsx +11 -0
- package/src/player/components/TimelineClipFades.tsx +4 -4
- package/src/player/components/TimelineClipJoins.tsx +23 -18
- package/src/player/components/TimelineGestureOverlay.tsx +30 -27
- package/src/player/components/TimelineGroupRow.tsx +1 -0
- package/src/player/components/TimelineLaneClip.tsx +127 -0
- package/src/player/components/TimelineLanes.test.tsx +51 -6
- package/src/player/components/TimelineLanes.tsx +166 -178
- package/src/player/components/TimelineRuler.tsx +2 -0
- package/src/player/components/TimelineTimeLayer.tsx +25 -0
- package/src/player/components/VideoThumbnail.test.tsx +204 -12
- package/src/player/components/VideoThumbnail.tsx +37 -9
- package/src/player/components/thumbnailUtils.test.ts +17 -3
- package/src/player/components/thumbnailUtils.ts +17 -1
- package/src/player/components/timelineLayout.test.ts +3 -3
- package/src/player/components/timelineMotion.test.ts +27 -0
- package/src/player/components/timelineMotion.ts +62 -0
- package/src/player/components/timelineZoomInput.test.ts +438 -0
- package/src/player/components/timelineZoomInput.ts +456 -0
- package/src/player/components/useTimelineGeometry.ts +0 -3
- package/src/player/components/useTimelinePlayhead.test.tsx +97 -30
- package/src/player/components/useTimelinePlayhead.ts +53 -59
- package/src/player/components/useTimelineProviderState.tsx +16 -21
- package/src/player/components/useTimelineZoom.ts +7 -12
- package/src/player/hooks/useShadowPreviewReload.ts +6 -6
- package/src/player/hooks/useTimelinePlayer.shadowReload.test.ts +14 -0
- package/src/player/hooks/useTimelinePlayer.ts +8 -5
- package/src/player/lib/thumbnailScheduler.test.ts +20 -1
- package/src/player/lib/thumbnailScheduler.ts +1 -1
- package/src/player/lib/thumbnailVideoDecoder.test.ts +230 -30
- package/src/player/lib/thumbnailVideoDecoder.ts +197 -54
- package/src/player/lib/timelineElementHelpers.ts +0 -11
- package/src/player/lib/timelineViewportBudgets.test.ts +1 -1
- package/src/player/lib/timelineViewportBudgets.ts +1 -1
- package/src/player/previewReloading.test.ts +48 -0
- package/src/player/previewReloading.ts +48 -0
- package/src/utils/gsapUndoRestore.browser.test.ts +4 -17
- package/src/utils/rootDuration.ts +5 -4
- package/src/utils/studioPendingEdits.test.ts +27 -0
- package/src/utils/studioPendingEdits.ts +9 -0
- package/src/utils/timelineAssetDrop.test.ts +13 -4
- package/src/utils/timelineAssetDrop.ts +6 -2
- package/dist/chunk-TDWGXBUT.js.map +0 -1
- /package/dist/{chunk-4S3AYCOG.js.map → chunk-H2DUOXTF.js.map} +0 -0
- /package/dist/{chunk-7UQWVN52.js.map → chunk-UYWON5AX.js.map} +0 -0
- /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
|
|
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
|
-
|
|
31
|
-
|
|
32
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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(
|
|
100
|
-
[imageAssets, sourceFile,
|
|
120
|
+
() => resolveSelectedAsset(imageUrl, sourceFile, imageAssets),
|
|
121
|
+
[imageAssets, sourceFile, imageUrl],
|
|
101
122
|
);
|
|
102
|
-
const externalUrlValue = selectedAsset ? "" :
|
|
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
|
-
|
|
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
|
-
|
|
198
|
+
commitImage("");
|
|
178
199
|
return;
|
|
179
200
|
}
|
|
180
|
-
|
|
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="">
|
|
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) =>
|
|
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
|
-
|
|
61
|
-
|
|
62
|
-
|
|
63
|
-
|
|
64
|
-
|
|
65
|
-
|
|
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
|
-
|
|
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
|
/>
|