@hyperframes/studio 0.8.142 → 0.8.144
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-Bw_jIaSX.js +473 -0
- package/dist/assets/{index-B-wffzRH.js → index-BVM9NuWl.js} +153 -153
- package/dist/assets/{index-D3Vij9t1.js → index-Cj7W_c7p.js} +1 -1
- package/dist/assets/{index-D5q2_7Gw.js → index-DyH9dT7b.js} +1 -1
- package/dist/{chunk-TFLU2MGI.js → chunk-3GHTHPZD.js} +8 -1
- package/dist/chunk-3GHTHPZD.js.map +1 -0
- package/dist/{chunk-H2DUOXTF.js → chunk-G3EDKRZH.js} +3 -3
- package/dist/{chunk-UYWON5AX.js → chunk-PEAKCOUW.js} +7 -3
- package/dist/chunk-PEAKCOUW.js.map +1 -0
- package/dist/{chunk-LOVBLSMR.js → chunk-Y2OHHNM2.js} +35 -19
- package/dist/chunk-Y2OHHNM2.js.map +1 -0
- package/dist/{domEditingLayers-KBTPQKGA.js → domEditingLayers-PYST4G45.js} +3 -3
- package/dist/index.html +1 -1
- package/dist/index.js +261 -230
- package/dist/index.js.map +1 -1
- package/dist/player.js +3 -3
- package/dist/ui.d.ts +4 -5
- package/dist/ui.js +1 -1
- package/package.json +8 -8
- package/src/components/MediaPreview.tsx +2 -1
- package/src/components/editor/DomEditSelectionChrome.test.tsx +103 -0
- package/src/components/editor/DomEditSelectionChrome.tsx +42 -16
- package/src/components/editor/anchoredResizeCommitFeedsOffset.test.ts +36 -0
- package/src/components/editor/domEditOverlayGestures.ts +1 -0
- package/src/components/editor/domEditOverlayStartGesture.ts +5 -0
- package/src/components/editor/domEditResizeLocal.test.ts +81 -1
- package/src/components/editor/domEditResizeLocal.ts +27 -3
- package/src/components/editor/domEditingLayers.ts +4 -1
- package/src/components/editor/domEditingTextFields.test.ts +18 -0
- package/src/components/editor/manualEditsDom.ts +12 -12
- package/src/components/editor/mediaAudioEdits.test.ts +12 -0
- package/src/components/editor/mediaAudioEdits.ts +5 -5
- package/src/components/editor/previewGestureStarts.ts +14 -5
- package/src/components/editor/propertyPanelFill.tsx +2 -1
- package/src/components/editor/propertyPanelFlatMediaSection.tsx +3 -4
- package/src/components/editor/propertyPanelMediaSection.tsx +3 -4
- package/src/components/editor/resizeDraft.ts +13 -4
- package/src/components/editor/useColorGradingController.ts +2 -1
- package/src/components/editor/useDomEditOverlayGestures.ts +5 -3
- package/src/components/ui/Menu.test.tsx +67 -5
- package/src/components/ui/Menu.tsx +21 -4
- package/src/components/ui/Popover.tsx +11 -5
- package/src/hooks/gsapResizeIntercept.test.ts +46 -11
- package/src/hooks/gsapResizeIntercept.ts +25 -20
- package/src/hooks/useDomEditCommits.ts +2 -1
- package/src/hooks/useFileManager.ts +2 -1
- package/src/hooks/useFileTree.ts +2 -1
- package/src/hooks/useRenderClipContent.ts +15 -3
- package/src/hooks/useThumbnailStripSize.test.tsx +123 -0
- package/src/hooks/useThumbnailStripSize.ts +19 -8
- package/src/player/components/CompositionThumbnail.test.ts +3 -0
- package/src/player/components/ImageThumbnail.test.tsx +4 -1
- package/src/player/components/VideoThumbnail.test.tsx +13 -2
- package/src/player/components/clipMenuNormalize.ts +2 -2
- package/src/player/components/clipPeakMap.ts +2 -2
- package/src/player/components/thumbnailUtils.test.ts +33 -0
- package/src/player/components/thumbnailUtils.ts +5 -0
- package/src/player/hooks/timelineSyncHydration.ts +2 -1
- package/src/player/lib/mediaProbe.test.ts +12 -0
- package/src/player/lib/mediaProbe.ts +4 -2
- package/src/utils/authoredSource.test.ts +25 -0
- package/src/utils/authoredSource.ts +3 -5
- package/src/utils/rounding.ts +7 -0
- package/src/utils/sourcePatcher.test.ts +135 -0
- package/src/utils/sourcePatcher.ts +102 -186
- package/src/utils/studioHelpers.test.ts +23 -0
- package/src/utils/studioHelpers.ts +3 -2
- package/src/utils/studioSelectionSnapshot.test.ts +31 -0
- package/src/utils/timelineAssetDrop.test.ts +14 -0
- package/src/utils/timelineAssetDrop.ts +2 -1
- package/dist/assets/hyperframes-player-sAsRuV73.js +0 -473
- package/dist/chunk-LOVBLSMR.js.map +0 -1
- package/dist/chunk-TFLU2MGI.js.map +0 -1
- package/dist/chunk-UYWON5AX.js.map +0 -1
- /package/dist/{chunk-H2DUOXTF.js.map → chunk-G3EDKRZH.js.map} +0 -0
- /package/dist/{domEditingLayers-KBTPQKGA.js.map → domEditingLayers-PYST4G45.js.map} +0 -0
|
@@ -36,7 +36,7 @@ import {
|
|
|
36
36
|
} from "./manualEditsTypes";
|
|
37
37
|
import { gsapWritesChannels } from "../../hooks/gsapRuntimeKeyframes";
|
|
38
38
|
import { splitTopLevelWhitespace } from "./manualEditsStyleHelpers";
|
|
39
|
-
import { roundTo3,
|
|
39
|
+
import { roundTo3, roundToLayoutSize } from "../../utils/rounding";
|
|
40
40
|
import { BOX_SIZE_STYLE_PROPS } from "./manualEditsDomPatches";
|
|
41
41
|
|
|
42
42
|
/* ── Gesture tracking ─────────────────────────────────────────────── */
|
|
@@ -402,7 +402,7 @@ function readParentFlexBasisPixels(
|
|
|
402
402
|
if (display !== "flex" && display !== "inline-flex") return null;
|
|
403
403
|
|
|
404
404
|
const direction = readStyleOrComputed(parent, "flex-direction").trim();
|
|
405
|
-
return
|
|
405
|
+
return direction.startsWith("column") ? size.height : size.width;
|
|
406
406
|
}
|
|
407
407
|
|
|
408
408
|
function restoreStaleStudioScaleResize(element: HTMLElement): void {
|
|
@@ -477,22 +477,21 @@ function writeStudioBoxSizeVars(
|
|
|
477
477
|
}
|
|
478
478
|
|
|
479
479
|
element.setAttribute(STUDIO_BOX_SIZE_ATTR, "true");
|
|
480
|
-
element.style.setProperty(STUDIO_WIDTH_PROP, `${
|
|
481
|
-
element.style.setProperty(STUDIO_HEIGHT_PROP, `${
|
|
480
|
+
element.style.setProperty(STUDIO_WIDTH_PROP, `${size.width}px`);
|
|
481
|
+
element.style.setProperty(STUDIO_HEIGHT_PROP, `${size.height}px`);
|
|
482
482
|
}
|
|
483
483
|
|
|
484
484
|
function applyStudioBoxSizeDimensions(
|
|
485
485
|
element: HTMLElement,
|
|
486
|
-
|
|
487
|
-
):
|
|
486
|
+
wantedSize: { width: number; height: number },
|
|
487
|
+
): { width: number; height: number } {
|
|
488
|
+
const size = roundToLayoutSize(wantedSize);
|
|
488
489
|
writeStudioBoxSizeVars(element, size);
|
|
489
490
|
restoreStaleStudioScaleResize(element);
|
|
490
491
|
|
|
491
|
-
const width = roundToLayoutPx(Math.max(1, size.width));
|
|
492
|
-
const height = roundToLayoutPx(Math.max(1, size.height));
|
|
493
492
|
element.style.setProperty("box-sizing", "border-box");
|
|
494
|
-
element.style.setProperty("width", `${width}px`);
|
|
495
|
-
element.style.setProperty("height", `${height}px`);
|
|
493
|
+
element.style.setProperty("width", `${size.width}px`);
|
|
494
|
+
element.style.setProperty("height", `${size.height}px`);
|
|
496
495
|
element.style.setProperty("min-width", "0px");
|
|
497
496
|
element.style.setProperty("min-height", "0px");
|
|
498
497
|
element.style.setProperty("max-width", "none");
|
|
@@ -507,6 +506,7 @@ function applyStudioBoxSizeDimensions(
|
|
|
507
506
|
if (computedDisplay === "inline") {
|
|
508
507
|
element.style.setProperty("display", "inline-block");
|
|
509
508
|
}
|
|
509
|
+
return size;
|
|
510
510
|
}
|
|
511
511
|
|
|
512
512
|
export function applyStudioBoxSize(
|
|
@@ -533,12 +533,12 @@ export function forgetStudioBoxSizeDraftBase(element: HTMLElement): void {
|
|
|
533
533
|
export function applyStudioBoxSizeDraft(
|
|
534
534
|
element: HTMLElement,
|
|
535
535
|
size: { width: number; height: number },
|
|
536
|
-
):
|
|
536
|
+
): { width: number; height: number } {
|
|
537
537
|
if (!boxSizeDraftBases.has(element)) {
|
|
538
538
|
boxSizeDraftBases.set(element, { width: element.offsetWidth, height: element.offsetHeight });
|
|
539
539
|
}
|
|
540
540
|
promoteInlineForTransform(element);
|
|
541
|
-
applyStudioBoxSizeDimensions(element, size);
|
|
541
|
+
return applyStudioBoxSizeDimensions(element, size);
|
|
542
542
|
}
|
|
543
543
|
|
|
544
544
|
/* ── Rotation apply ───────────────────────────────────────────────── */
|
|
@@ -239,6 +239,18 @@ describe("buildKeepSoundCutoutEdit", () => {
|
|
|
239
239
|
expect(doc.getElementById("clip")?.getAttribute("src")).toBe("assets/cut.webm");
|
|
240
240
|
});
|
|
241
241
|
|
|
242
|
+
it("preserves an apostrophe in the authored source of the linked audio", () => {
|
|
243
|
+
const authored = source.replace('src="assets/talk.mp4"', `src="assets/it's.mp4"`);
|
|
244
|
+
const edit = buildKeepSoundCutoutEdit({
|
|
245
|
+
video: liveVideo(),
|
|
246
|
+
videoId: "clip",
|
|
247
|
+
target: { id: "clip" },
|
|
248
|
+
cutoutSrc: "assets/cut.webm",
|
|
249
|
+
});
|
|
250
|
+
const doc = parse(applyEdit(authored, edit));
|
|
251
|
+
expect(doc.getElementById("clip-audio")?.getAttribute("src")).toBe("assets/it's.mp4");
|
|
252
|
+
});
|
|
253
|
+
|
|
242
254
|
it("takes the first <source> child when the video has no src attribute", () => {
|
|
243
255
|
const withSource = source.replace(
|
|
244
256
|
/<video id="clip"([^>]*?) src="assets\/talk.mp4"([^>]*)><\/video>/,
|
|
@@ -14,7 +14,7 @@ import {
|
|
|
14
14
|
findTagByTarget,
|
|
15
15
|
applyPatchByTarget,
|
|
16
16
|
readAttributeByTarget,
|
|
17
|
-
|
|
17
|
+
readTagAttribute,
|
|
18
18
|
type PatchOperation,
|
|
19
19
|
type PatchTarget,
|
|
20
20
|
} from "../../utils/sourcePatcher";
|
|
@@ -147,10 +147,10 @@ export function formatAttrs(attrs: Array<[string, string]>): string {
|
|
|
147
147
|
export function readAuthoredSrc(source: string, target: PatchTarget): string {
|
|
148
148
|
const tag = findTagByTarget(source, target);
|
|
149
149
|
if (!tag) return "";
|
|
150
|
-
const own =
|
|
151
|
-
if (own
|
|
152
|
-
const firstSource = /^\s*<source\b[^>]
|
|
153
|
-
return firstSource
|
|
150
|
+
const own = readTagAttribute(tag.tag, "src");
|
|
151
|
+
if (own) return own;
|
|
152
|
+
const firstSource = /^\s*<source\b[^>]*>/i.exec(source.slice(tag.end + 1));
|
|
153
|
+
return (firstSource && readTagAttribute(firstSource[0], "src")) || "";
|
|
154
154
|
}
|
|
155
155
|
|
|
156
156
|
export function insertBeforeTarget(source: string, target: PatchTarget, markup: string): string {
|
|
@@ -99,10 +99,10 @@ export function createPreviewGestureStarts(
|
|
|
99
99
|
const drawPressedBox = (origin: OverlayRect | null): WaitingPressState["draw"] =>
|
|
100
100
|
origin && ((dx, dy) => opts.setOverlayRect(shiftedOverlayRect(origin, dx, dy)));
|
|
101
101
|
|
|
102
|
-
// A corner resize scales about the element's centre by the
|
|
102
|
+
// A corner resize scales about the element's centre by the grabbed corner's distance from it, as the resize does.
|
|
103
103
|
const drawResizedBox = (
|
|
104
104
|
origin: OverlayRect | null,
|
|
105
|
-
|
|
105
|
+
grabbed: { x: number; y: number },
|
|
106
106
|
): WaitingPressState["draw"] => {
|
|
107
107
|
if (!origin) return null;
|
|
108
108
|
const bounds = opts.overlayRef.current?.getBoundingClientRect();
|
|
@@ -111,8 +111,12 @@ export function createPreviewGestureStarts(
|
|
|
111
111
|
y: (bounds?.top ?? 0) + origin.top + origin.height / 2,
|
|
112
112
|
};
|
|
113
113
|
return (dx, dy) => {
|
|
114
|
-
const pointer = { x:
|
|
115
|
-
const scale = resolveCenterResizeScale({
|
|
114
|
+
const pointer = { x: grabbed.x + dx, y: grabbed.y + dy };
|
|
115
|
+
const scale = resolveCenterResizeScale({
|
|
116
|
+
pointer,
|
|
117
|
+
pointerStart: grabbed,
|
|
118
|
+
centerStart: centre,
|
|
119
|
+
});
|
|
116
120
|
const [width, height] = [origin.width * scale, origin.height * scale];
|
|
117
121
|
opts.setOverlayRect({
|
|
118
122
|
...origin,
|
|
@@ -248,6 +252,7 @@ export function createPreviewGestureStarts(
|
|
|
248
252
|
selection?: DomEditSelection;
|
|
249
253
|
rect?: OverlayRect | null;
|
|
250
254
|
resizeHandle?: ResizeHandle;
|
|
255
|
+
resizeCorner?: { x: number; y: number };
|
|
251
256
|
},
|
|
252
257
|
) => {
|
|
253
258
|
// A replay edits what was pressed, though another element may be selected by then.
|
|
@@ -255,7 +260,10 @@ export function createPreviewGestureStarts(
|
|
|
255
260
|
let draw: WaitingPressState["draw"] = null;
|
|
256
261
|
if (kind === "drag") draw = drawPressedBox(opts.overlayRectRef.current);
|
|
257
262
|
if (kind === "resize")
|
|
258
|
-
draw = drawResizedBox(
|
|
263
|
+
draw = drawResizedBox(
|
|
264
|
+
opts.overlayRectRef.current,
|
|
265
|
+
options?.resizeCorner ?? { x: e.clientX, y: e.clientY },
|
|
266
|
+
);
|
|
259
267
|
return startOnShownPreview(
|
|
260
268
|
e,
|
|
261
269
|
() => {
|
|
@@ -272,6 +280,7 @@ export function createPreviewGestureStarts(
|
|
|
272
280
|
selection,
|
|
273
281
|
rect,
|
|
274
282
|
resizeHandle: options?.resizeHandle,
|
|
283
|
+
resizeCorner: options?.resizeCorner,
|
|
275
284
|
at,
|
|
276
285
|
})
|
|
277
286
|
);
|
|
@@ -20,6 +20,7 @@ import {
|
|
|
20
20
|
} from "./propertyPanelPrimitives";
|
|
21
21
|
import { ColorField } from "./propertyPanelColor";
|
|
22
22
|
import { useTrackDesignInput } from "../../contexts/DesignPanelInputContext";
|
|
23
|
+
import { encodeUrlPath } from "@hyperframes/parsers";
|
|
23
24
|
|
|
24
25
|
/* ------------------------------------------------------------------ */
|
|
25
26
|
/* Asset path helpers */
|
|
@@ -181,7 +182,7 @@ export function ImageFillField({
|
|
|
181
182
|
{selectedAsset && (
|
|
182
183
|
<div className="overflow-hidden rounded-xl border border-neutral-800 bg-neutral-900/80">
|
|
183
184
|
<img
|
|
184
|
-
src={buildProjectApiPath(projectId, `/preview/${selectedAsset}`)}
|
|
185
|
+
src={buildProjectApiPath(projectId, `/preview/${encodeUrlPath(selectedAsset)}`)}
|
|
185
186
|
alt={selectedAsset.split("/").pop() ?? selectedAsset}
|
|
186
187
|
className="h-28 w-full object-contain bg-neutral-950/80"
|
|
187
188
|
/>
|
|
@@ -10,7 +10,6 @@ import {
|
|
|
10
10
|
formatTimingValue,
|
|
11
11
|
parseNumericValue,
|
|
12
12
|
readClipInPoint,
|
|
13
|
-
stripQueryAndHash,
|
|
14
13
|
} from "./propertyPanelHelpers";
|
|
15
14
|
import { FlatSelectRow, FlatSlider } from "./propertyPanelFlatPrimitives";
|
|
16
15
|
import { FlatToggle } from "./propertyPanelFlatToggle";
|
|
@@ -37,6 +36,7 @@ import {
|
|
|
37
36
|
import { parseGainInput, parseRateInput, parseSecondsInput } from "./audioInspectorInput";
|
|
38
37
|
import type { CommitDomAttributeBatch } from "../../hooks/domEditCommitTypes";
|
|
39
38
|
import { commitCutout, commitHasAudioToggle, commitMutedToggle } from "./mediaAudioEdits";
|
|
39
|
+
import { authoredSrcPath } from "../../player/components/thumbnailUtils";
|
|
40
40
|
|
|
41
41
|
// fallow-ignore-next-line complexity
|
|
42
42
|
export function FlatMediaSection({
|
|
@@ -126,12 +126,11 @@ export function FlatMediaSection({
|
|
|
126
126
|
const [createPlate, setCreatePlate] = useState(false);
|
|
127
127
|
const [quality, setQuality] = useState<"fast" | "balanced" | "best">("balanced");
|
|
128
128
|
|
|
129
|
-
const absoluteSrc =
|
|
130
|
-
projectDir && srcAttr && !srcAttr.startsWith("http") ? `${projectDir}/${srcAttr}` : srcAttr;
|
|
131
129
|
const projectSrc =
|
|
132
130
|
srcAttr && !/^(?:https?:|data:|blob:)/i.test(srcAttr)
|
|
133
|
-
?
|
|
131
|
+
? authoredSrcPath(srcAttr.startsWith("./") ? srcAttr.slice(2) : srcAttr)
|
|
134
132
|
: "";
|
|
133
|
+
const absoluteSrc = projectDir && projectSrc ? `${projectDir}/${projectSrc}` : srcAttr;
|
|
135
134
|
const canRemoveBackground = Boolean(onRemoveBackground && isVisualMedia && projectSrc);
|
|
136
135
|
|
|
137
136
|
useEffect(() => {
|
|
@@ -11,7 +11,6 @@ import {
|
|
|
11
11
|
parseNumericValue,
|
|
12
12
|
readClipInPoint,
|
|
13
13
|
RESPONSIVE_GRID,
|
|
14
|
-
stripQueryAndHash,
|
|
15
14
|
} from "./propertyPanelHelpers";
|
|
16
15
|
import { Section, SegmentedControl, SelectField, SliderControl } from "./propertyPanelPrimitives";
|
|
17
16
|
import { useTrackDesignInput } from "../../contexts/DesignPanelInputContext";
|
|
@@ -26,6 +25,7 @@ import {
|
|
|
26
25
|
import type { CommitDomAttributeBatch } from "../../hooks/domEditCommitTypes";
|
|
27
26
|
import { useLinkedSpeedCommit, withLinkedPlaybackRate } from "./linkedSpeedEdits";
|
|
28
27
|
import { commitCutout, commitHasAudioToggle, commitMutedToggle } from "./mediaAudioEdits";
|
|
28
|
+
import { authoredSrcPath } from "../../player/components/thumbnailUtils";
|
|
29
29
|
|
|
30
30
|
// fallow-ignore-next-line complexity
|
|
31
31
|
export function MediaSection({
|
|
@@ -98,12 +98,11 @@ export function MediaSection({
|
|
|
98
98
|
const [createPlate, setCreatePlate] = useState(false);
|
|
99
99
|
const [quality, setQuality] = useState<"fast" | "balanced" | "best">("balanced");
|
|
100
100
|
|
|
101
|
-
const absoluteSrc =
|
|
102
|
-
projectDir && srcAttr && !srcAttr.startsWith("http") ? `${projectDir}/${srcAttr}` : srcAttr;
|
|
103
101
|
const projectSrc =
|
|
104
102
|
srcAttr && !/^(?:https?:|data:|blob:)/i.test(srcAttr)
|
|
105
|
-
?
|
|
103
|
+
? authoredSrcPath(srcAttr.startsWith("./") ? srcAttr.slice(2) : srcAttr)
|
|
106
104
|
: "";
|
|
105
|
+
const absoluteSrc = projectDir && projectSrc ? `${projectDir}/${projectSrc}` : srcAttr;
|
|
107
106
|
const canRemoveBackground = Boolean(onRemoveBackground && isVisualMedia && projectSrc);
|
|
108
107
|
const panelTitle = isImage ? "Image" : isVideo ? "Video" : "Audio";
|
|
109
108
|
|
|
@@ -6,14 +6,15 @@ import {
|
|
|
6
6
|
elementCornerOverlayPoints,
|
|
7
7
|
overlayCornersCentroid,
|
|
8
8
|
} from "./domEditOverlayGeometry";
|
|
9
|
-
import { computeNextResizeAnchor } from "./domEditResizeLocal";
|
|
9
|
+
import { computeNextResizeAnchor, resizeRemainderShift } from "./domEditResizeLocal";
|
|
10
|
+
import type { ResizeDraftSizes } from "./domEditResizeLocal";
|
|
10
11
|
import { applyManualOffsetDragDraft } from "./manualOffsetDrag";
|
|
11
12
|
|
|
12
13
|
type Corners = ReturnType<typeof elementCornerOverlayPoints>;
|
|
13
14
|
|
|
14
15
|
/**
|
|
15
16
|
* The residual center-pin offset for this frame. With measurable corners and a
|
|
16
|
-
* fixed-center start, accumulate `fixedStart - centerNow` onto the previous
|
|
17
|
+
* fixed-center start, accumulate `fixedStart + shift - centerNow` onto the previous
|
|
17
18
|
* anchor so it CONVERGES rather than oscillating: `applyManualOffsetDragDraft`
|
|
18
19
|
* treats its argument as the absolute offset, and `centerNow` (measured on the
|
|
19
20
|
* live element) already carries the previous frame's offset, so the difference
|
|
@@ -25,10 +26,15 @@ function resolveResizeAnchor(
|
|
|
25
26
|
g: GestureState,
|
|
26
27
|
corners: Corners | null,
|
|
27
28
|
measureOrientedRect: () => OverlayRect | null,
|
|
29
|
+
sizes: ResizeDraftSizes,
|
|
28
30
|
): { dx: number; dy: number } {
|
|
29
31
|
const fixedStart = g.resizeFixedCenterStart;
|
|
30
32
|
if (corners && fixedStart) {
|
|
31
|
-
|
|
33
|
+
const press = g.resizePressFromCorner ?? { x: 0, y: 0 };
|
|
34
|
+
const grab = { x: g.startX - press.x - g.centerX, y: g.startY - press.y - g.centerY };
|
|
35
|
+
const shift = resizeRemainderShift(corners, grab, sizes);
|
|
36
|
+
const target = { x: fixedStart.x + shift.x, y: fixedStart.y + shift.y };
|
|
37
|
+
return computeNextResizeAnchor(g.lastResizeAnchor, target, overlayCornersCentroid(corners));
|
|
32
38
|
}
|
|
33
39
|
const fallbackRect = measureOrientedRect();
|
|
34
40
|
return resolveResizeCenterAnchorOffset({
|
|
@@ -52,11 +58,12 @@ function resolveAnchoredResizeDraft(
|
|
|
52
58
|
overlayEl: HTMLDivElement | null,
|
|
53
59
|
iframe: HTMLIFrameElement | null,
|
|
54
60
|
measureOrientedRect: () => OverlayRect | null,
|
|
61
|
+
sizes: ResizeDraftSizes,
|
|
55
62
|
): OverlayRect {
|
|
56
63
|
// Measure real corners ONCE — reused for the anchor and the fallback size.
|
|
57
64
|
const corners =
|
|
58
65
|
overlayEl && iframe ? elementCornerOverlayPoints(overlayEl, iframe, element) : null;
|
|
59
|
-
const anchor = resolveResizeAnchor(g, corners, measureOrientedRect);
|
|
66
|
+
const anchor = resolveResizeAnchor(g, corners, measureOrientedRect, sizes);
|
|
60
67
|
g.lastResizeAnchor = anchor;
|
|
61
68
|
applyManualOffsetDragDraft(member, anchor.dx, anchor.dy);
|
|
62
69
|
// Re-measure AFTER the anchor translate so it hugs the element every frame.
|
|
@@ -80,6 +87,7 @@ export function resolveResizeDraftRect(
|
|
|
80
87
|
overlayEl: HTMLDivElement | null,
|
|
81
88
|
iframe: HTMLIFrameElement | null,
|
|
82
89
|
measureOrientedRect: () => OverlayRect | null,
|
|
90
|
+
sizes: ResizeDraftSizes,
|
|
83
91
|
): OverlayRect {
|
|
84
92
|
if (g.pathOffsetMember) {
|
|
85
93
|
return resolveAnchoredResizeDraft(
|
|
@@ -89,6 +97,7 @@ export function resolveResizeDraftRect(
|
|
|
89
97
|
overlayEl,
|
|
90
98
|
iframe,
|
|
91
99
|
measureOrientedRect,
|
|
100
|
+
sizes,
|
|
92
101
|
);
|
|
93
102
|
}
|
|
94
103
|
// Re-measure the element's oriented box AFTER the size write. The size draft
|
|
@@ -30,6 +30,7 @@ import {
|
|
|
30
30
|
type ColorGradingPreviewOptions,
|
|
31
31
|
} from "./useColorGradingPreviews";
|
|
32
32
|
import { studioApiFetch } from "../../utils/studioApiFetch";
|
|
33
|
+
import { authoredSrcPath } from "../../player/components/thumbnailUtils";
|
|
33
34
|
|
|
34
35
|
export type { ColorGradingPresetPreviews, ColorGradingPreviewOptions };
|
|
35
36
|
|
|
@@ -97,7 +98,7 @@ function resolveProjectAssetPath(
|
|
|
97
98
|
const sourceDir = sourceFile.includes("/")
|
|
98
99
|
? sourceFile.slice(0, sourceFile.lastIndexOf("/"))
|
|
99
100
|
: "";
|
|
100
|
-
const parts = `${sourceDir}/${trimmed}`.split("/");
|
|
101
|
+
const parts = `${sourceDir}/${authoredSrcPath(trimmed)}`.split("/");
|
|
101
102
|
const normalized: string[] = [];
|
|
102
103
|
for (const part of parts) {
|
|
103
104
|
if (!part || part === ".") continue;
|
|
@@ -250,14 +250,15 @@ export function createDomEditOverlayGestureHandlers(opts: UseDomEditOverlayGestu
|
|
|
250
250
|
if (g.pathOffsetMember) applyManualOffsetDragDraft(g.pathOffsetMember, dx, dy);
|
|
251
251
|
} else {
|
|
252
252
|
// Corner resize scales proportionally about the center, without edge snapping.
|
|
253
|
+
const grab = g.resizePressFromCorner ?? { x: 0, y: 0 };
|
|
253
254
|
const nextSize = resolveCenterResizeSize({
|
|
254
255
|
baseWidth: g.actualWidth,
|
|
255
256
|
baseHeight: g.actualHeight,
|
|
256
|
-
pointer: { x: e.clientX, y: e.clientY },
|
|
257
|
-
pointerStart: { x: g.startX, y: g.startY },
|
|
257
|
+
pointer: { x: e.clientX - grab.x, y: e.clientY - grab.y },
|
|
258
|
+
pointerStart: { x: g.startX - grab.x, y: g.startY - grab.y },
|
|
258
259
|
centerStart: { x: g.centerX, y: g.centerY },
|
|
259
260
|
});
|
|
260
|
-
applyStudioBoxSizeDraft(sel.element, nextSize);
|
|
261
|
+
const writtenSize = applyStudioBoxSizeDraft(sel.element, nextSize);
|
|
261
262
|
|
|
262
263
|
const overlayEl = opts.overlayRef.current;
|
|
263
264
|
const iframe = opts.iframeRef.current;
|
|
@@ -270,6 +271,7 @@ export function createDomEditOverlayGestureHandlers(opts: UseDomEditOverlayGestu
|
|
|
270
271
|
overlayEl,
|
|
271
272
|
iframe,
|
|
272
273
|
measureOrientedRect,
|
|
274
|
+
{ wanted: nextSize, written: writtenSize },
|
|
273
275
|
);
|
|
274
276
|
logResizeMove({
|
|
275
277
|
pointer: { x: e.clientX, y: e.clientY },
|
|
@@ -48,6 +48,13 @@ function clickWithMouse(target: Element): void {
|
|
|
48
48
|
});
|
|
49
49
|
}
|
|
50
50
|
|
|
51
|
+
function rightClick(target: Element): void {
|
|
52
|
+
const init = { bubbles: true, cancelable: true, composed: true };
|
|
53
|
+
act(() => {
|
|
54
|
+
target.dispatchEvent(new MouseEvent("contextmenu", init));
|
|
55
|
+
});
|
|
56
|
+
}
|
|
57
|
+
|
|
51
58
|
function key(k: string, target: Element | null = document.activeElement): void {
|
|
52
59
|
const init = { key: k, bubbles: true, cancelable: true, composed: true };
|
|
53
60
|
act(() => {
|
|
@@ -241,11 +248,7 @@ describe("ContextMenu", () => {
|
|
|
241
248
|
expect(trigger().textContent).toBe("clip");
|
|
242
249
|
expect(popup()).toBeNull();
|
|
243
250
|
|
|
244
|
-
|
|
245
|
-
trigger().dispatchEvent(
|
|
246
|
-
new MouseEvent("contextmenu", { bubbles: true, cancelable: true, composed: true }),
|
|
247
|
-
);
|
|
248
|
-
});
|
|
251
|
+
rightClick(trigger());
|
|
249
252
|
await settle();
|
|
250
253
|
expect(popup()).not.toBeNull();
|
|
251
254
|
|
|
@@ -257,6 +260,65 @@ describe("ContextMenu", () => {
|
|
|
257
260
|
});
|
|
258
261
|
});
|
|
259
262
|
|
|
263
|
+
describe("container={null}", () => {
|
|
264
|
+
/** Rendered inline means inside the trigger's own parent, not a portal on the body. */
|
|
265
|
+
function expectInlineBeside(menu: HTMLElement | null): void {
|
|
266
|
+
expect(menu).not.toBeNull();
|
|
267
|
+
expect(trigger().parentElement?.contains(menu)).toBe(true);
|
|
268
|
+
}
|
|
269
|
+
|
|
270
|
+
it("opens a Menu inline, next to its trigger, and closes on Escape", async () => {
|
|
271
|
+
render(
|
|
272
|
+
<div>
|
|
273
|
+
<Menu container={null} trigger={<button data-testid="trigger">Actions</button>}>
|
|
274
|
+
<MenuItem>Bring to front</MenuItem>
|
|
275
|
+
</Menu>
|
|
276
|
+
</div>,
|
|
277
|
+
);
|
|
278
|
+
clickWithMouse(trigger());
|
|
279
|
+
await settle();
|
|
280
|
+
expectInlineBeside(popup());
|
|
281
|
+
|
|
282
|
+
key("Escape");
|
|
283
|
+
await settle();
|
|
284
|
+
expect(popup()).toBeNull();
|
|
285
|
+
});
|
|
286
|
+
|
|
287
|
+
it("opens a ContextMenu inline, next to its area", async () => {
|
|
288
|
+
render(
|
|
289
|
+
<div>
|
|
290
|
+
<ContextMenu container={null} trigger={<div data-testid="trigger">clip</div>}>
|
|
291
|
+
<MenuItem>Split</MenuItem>
|
|
292
|
+
</ContextMenu>
|
|
293
|
+
</div>,
|
|
294
|
+
);
|
|
295
|
+
rightClick(trigger());
|
|
296
|
+
await settle();
|
|
297
|
+
expectInlineBeside(popup());
|
|
298
|
+
});
|
|
299
|
+
|
|
300
|
+
it("opens a Popover inline on its first render", async () => {
|
|
301
|
+
render(
|
|
302
|
+
<div>
|
|
303
|
+
<Popover
|
|
304
|
+
container={null}
|
|
305
|
+
defaultOpen
|
|
306
|
+
trigger={<button data-testid="trigger">Speed</button>}
|
|
307
|
+
>
|
|
308
|
+
<span>1x</span>
|
|
309
|
+
</Popover>
|
|
310
|
+
</div>,
|
|
311
|
+
);
|
|
312
|
+
await settle();
|
|
313
|
+
expectInlineBeside(document.querySelector<HTMLElement>("[role=dialog]"));
|
|
314
|
+
});
|
|
315
|
+
|
|
316
|
+
it("still portals to the body when no container is passed", async () => {
|
|
317
|
+
await openActionMenu();
|
|
318
|
+
expect(trigger().parentElement?.contains(popup())).toBe(false);
|
|
319
|
+
});
|
|
320
|
+
});
|
|
321
|
+
|
|
260
322
|
describe("Popover", () => {
|
|
261
323
|
it("leaves the keys of a text field inside it alone", async () => {
|
|
262
324
|
// The Menu owns ArrowDown and typeahead; a Popover must not, or the rename
|
|
@@ -5,6 +5,7 @@
|
|
|
5
5
|
|
|
6
6
|
import { ContextMenu as BaseContextMenu } from "@base-ui/react/context-menu";
|
|
7
7
|
import { Menu as BaseMenu } from "@base-ui/react/menu";
|
|
8
|
+
import { useRef } from "react";
|
|
8
9
|
import type { ComponentPropsWithoutRef, ElementType, ReactElement, ReactNode } from "react";
|
|
9
10
|
import { cn } from "./cn";
|
|
10
11
|
|
|
@@ -20,7 +21,19 @@ type StyledProps<T extends ElementType> = Omit<ComponentPropsWithoutRef<T>, "cla
|
|
|
20
21
|
export type PopupPreviewState = "open";
|
|
21
22
|
|
|
22
23
|
/** Where the portal puts the popup. `null` keeps it inline, next to its trigger. */
|
|
23
|
-
type PortalContainer = ComponentPropsWithoutRef<typeof BaseMenu.Portal>["container"];
|
|
24
|
+
export type PortalContainer = ComponentPropsWithoutRef<typeof BaseMenu.Portal>["container"];
|
|
25
|
+
|
|
26
|
+
const CONTENTS = { display: "contents" } as const;
|
|
27
|
+
|
|
28
|
+
/** Base UI reads a `null` container as "not resolved yet" and renders nothing, so give `null` a host by the trigger. */
|
|
29
|
+
export function useInlinePortal(container: PortalContainer) {
|
|
30
|
+
const host = useRef<HTMLSpanElement>(null);
|
|
31
|
+
if (container !== null) return { host: null, portal: { container } };
|
|
32
|
+
return {
|
|
33
|
+
host: <span ref={host} style={CONTENTS} />,
|
|
34
|
+
portal: { container: host, style: CONTENTS },
|
|
35
|
+
};
|
|
36
|
+
}
|
|
24
37
|
|
|
25
38
|
/** Matches Tooltip's gap from its trigger, and its viewport margin. */
|
|
26
39
|
const SIDE_OFFSET = 6;
|
|
@@ -77,7 +90,7 @@ interface PositionedProps {
|
|
|
77
90
|
side?: "top" | "bottom" | "left" | "right";
|
|
78
91
|
align?: "start" | "center" | "end";
|
|
79
92
|
sideOffset?: number;
|
|
80
|
-
/** Portal target. Pass the shadow root when the trigger lives in one. */
|
|
93
|
+
/** Portal target. Pass the shadow root when the trigger lives in one; `null` keeps it inline. */
|
|
81
94
|
container?: PortalContainer;
|
|
82
95
|
/** Names the popup for assistive tech. A menu with no name is unlabelled. */
|
|
83
96
|
"aria-label"?: string;
|
|
@@ -109,10 +122,12 @@ export function Menu({
|
|
|
109
122
|
"data-preview-state": previewState,
|
|
110
123
|
...root
|
|
111
124
|
}: MenuProps) {
|
|
125
|
+
const inline = useInlinePortal(container);
|
|
112
126
|
return (
|
|
113
127
|
<BaseMenu.Root {...root}>
|
|
114
128
|
<BaseMenu.Trigger render={trigger} />
|
|
115
|
-
|
|
129
|
+
{inline.host}
|
|
130
|
+
<BaseMenu.Portal {...inline.portal}>
|
|
116
131
|
<BaseMenu.Positioner
|
|
117
132
|
side={side}
|
|
118
133
|
align={align}
|
|
@@ -153,10 +168,12 @@ export function ContextMenu({
|
|
|
153
168
|
"data-preview-state": previewState,
|
|
154
169
|
...root
|
|
155
170
|
}: Omit<ContextMenuProps, "side" | "align" | "sideOffset">) {
|
|
171
|
+
const inline = useInlinePortal(container);
|
|
156
172
|
return (
|
|
157
173
|
<BaseContextMenu.Root {...root}>
|
|
158
174
|
<BaseContextMenu.Trigger render={trigger} />
|
|
159
|
-
|
|
175
|
+
{inline.host}
|
|
176
|
+
<BaseContextMenu.Portal {...inline.portal}>
|
|
160
177
|
<BaseContextMenu.Positioner collisionPadding={VIEWPORT_MARGIN} className={POPUP_LAYER}>
|
|
161
178
|
<BaseContextMenu.Popup
|
|
162
179
|
aria-label={ariaLabel}
|
|
@@ -6,9 +6,13 @@
|
|
|
6
6
|
import { Popover as BasePopover } from "@base-ui/react/popover";
|
|
7
7
|
import type { ComponentPropsWithoutRef, ReactElement, ReactNode } from "react";
|
|
8
8
|
import { cn } from "./cn";
|
|
9
|
-
import {
|
|
10
|
-
|
|
11
|
-
|
|
9
|
+
import {
|
|
10
|
+
POPUP_LAYER,
|
|
11
|
+
popupSurface,
|
|
12
|
+
useInlinePortal,
|
|
13
|
+
type PopupPreviewState,
|
|
14
|
+
type PortalContainer,
|
|
15
|
+
} from "./Menu";
|
|
12
16
|
|
|
13
17
|
/** Matches Menu's gap from its trigger, and its viewport margin. */
|
|
14
18
|
const SIDE_OFFSET = 6;
|
|
@@ -33,7 +37,7 @@ interface PopoverProps extends Omit<ComponentPropsWithoutRef<typeof BasePopover.
|
|
|
33
37
|
sideOffset?: number;
|
|
34
38
|
/** Points the popup at its trigger. Off by default; the default gap from the trigger grows by the arrow's height. */
|
|
35
39
|
arrow?: boolean;
|
|
36
|
-
/** Portal target. Pass the shadow root when the trigger lives in one. */
|
|
40
|
+
/** Portal target. Pass the shadow root when the trigger lives in one; `null` keeps it inline. */
|
|
37
41
|
container?: PortalContainer;
|
|
38
42
|
/** Names the popup for assistive tech. */
|
|
39
43
|
"aria-label"?: string;
|
|
@@ -60,10 +64,12 @@ export function Popover({
|
|
|
60
64
|
"data-preview-state": previewState,
|
|
61
65
|
...root
|
|
62
66
|
}: PopoverProps) {
|
|
67
|
+
const inline = useInlinePortal(container);
|
|
63
68
|
return (
|
|
64
69
|
<BasePopover.Root {...root}>
|
|
65
70
|
<BasePopover.Trigger render={trigger} />
|
|
66
|
-
|
|
71
|
+
{inline.host}
|
|
72
|
+
<BasePopover.Portal {...inline.portal}>
|
|
67
73
|
<BasePopover.Positioner
|
|
68
74
|
side={side}
|
|
69
75
|
align={align}
|
|
@@ -455,10 +455,31 @@ it("does not mix the scale shorthand into a tween that speaks longhands", async
|
|
|
455
455
|
* The fixture models the geometry the browser reported: a 630x252 element
|
|
456
456
|
* dragged from x=432 to x=587 with its box drafted down to 320x128, dropped at
|
|
457
457
|
* a committed scale of 0.837. Scaling about the centre puts it back on the drop
|
|
458
|
-
* point at its pre-gesture position, so the
|
|
459
|
-
*
|
|
458
|
+
* point at its pre-gesture position, so the persisted correction is only the
|
|
459
|
+
* drop's sub-pixel offset.
|
|
460
460
|
*/
|
|
461
|
-
it(
|
|
461
|
+
it.each([
|
|
462
|
+
{
|
|
463
|
+
name: "does not move a statically positioned element when a scale resize lands",
|
|
464
|
+
shift: { x: 0, y: 0 },
|
|
465
|
+
},
|
|
466
|
+
{
|
|
467
|
+
name: "puts a statically positioned element on a sub-pixel drop point",
|
|
468
|
+
shift: { x: 0.3, y: -0.2 },
|
|
469
|
+
writes: true,
|
|
470
|
+
},
|
|
471
|
+
{
|
|
472
|
+
name: "leaves a static position alone when the drop is off by less than the file's precision",
|
|
473
|
+
shift: { x: 0.0003, y: 0 },
|
|
474
|
+
writes: false,
|
|
475
|
+
},
|
|
476
|
+
{
|
|
477
|
+
name: "leaves an animated position alone for a sub-pixel drop point",
|
|
478
|
+
shift: { x: 0.3, y: -0.2 },
|
|
479
|
+
animated: true,
|
|
480
|
+
writes: false,
|
|
481
|
+
},
|
|
482
|
+
])("$name", async ({ shift, animated, writes }) => {
|
|
462
483
|
document.body.innerHTML = "";
|
|
463
484
|
const el = document.createElement("div");
|
|
464
485
|
el.id = "clip";
|
|
@@ -476,7 +497,7 @@ it("does not move a statically positioned element when a scale resize lands", as
|
|
|
476
497
|
el.style.height = "128px";
|
|
477
498
|
document.body.append(el);
|
|
478
499
|
|
|
479
|
-
const pos = { x: 587, y: 235 };
|
|
500
|
+
const pos = { x: 587 + shift.x, y: 235 + shift.y };
|
|
480
501
|
const scale = { x: 1.648, y: 1.648 };
|
|
481
502
|
const [LEFT, TOP] = [120, 520];
|
|
482
503
|
el.getBoundingClientRect = () => {
|
|
@@ -517,6 +538,15 @@ it("does not move a statically positioned element when a scale resize lands", as
|
|
|
517
538
|
duration: 0,
|
|
518
539
|
global: true,
|
|
519
540
|
} as unknown as GsapAnimation;
|
|
541
|
+
const positionTween = {
|
|
542
|
+
...positionHold,
|
|
543
|
+
id: "#clip-to-0-position",
|
|
544
|
+
method: "to",
|
|
545
|
+
properties: { x: 500 },
|
|
546
|
+
duration: 4,
|
|
547
|
+
global: false,
|
|
548
|
+
} as unknown as GsapAnimation;
|
|
549
|
+
const position = animated ? positionTween : positionHold;
|
|
520
550
|
const selection = { id: "clip", selector: "#clip", element: el } as DomEditSelection;
|
|
521
551
|
usePlayerStore.setState({ currentTime: 0.5 });
|
|
522
552
|
const commitMutation = vi.fn();
|
|
@@ -524,22 +554,27 @@ it("does not move a statically positioned element when a scale resize lands", as
|
|
|
524
554
|
await tryGsapResizeIntercept(
|
|
525
555
|
selection,
|
|
526
556
|
{ width: 320, height: 128 },
|
|
527
|
-
[keyframedScaleFixture(),
|
|
557
|
+
[keyframedScaleFixture(), position],
|
|
528
558
|
iframe,
|
|
529
559
|
commitMutation,
|
|
530
|
-
async () => [keyframedScaleFixture(),
|
|
560
|
+
async () => [keyframedScaleFixture(), position],
|
|
531
561
|
);
|
|
532
562
|
|
|
533
563
|
const positionWrites = commitMutation.mock.calls
|
|
534
564
|
.map((call) => call[1] as { properties?: Record<string, number> })
|
|
535
565
|
.filter((mutation) => mutation.properties?.x != null || mutation.properties?.y != null);
|
|
536
|
-
//
|
|
566
|
+
// No position write, or one that moves the hold by exactly the drop's sub-pixel offset.
|
|
567
|
+
if (writes === false) expect(positionWrites).toEqual([]);
|
|
568
|
+
if (writes) expect(positionWrites.length).toBeGreaterThan(0);
|
|
537
569
|
for (const write of positionWrites) {
|
|
538
|
-
expect(write.properties?.x).
|
|
539
|
-
expect(write.properties?.y).
|
|
570
|
+
expect(write.properties?.x).toBeCloseTo(432 + shift.x, 3);
|
|
571
|
+
expect(write.properties?.y).toBeCloseTo(173 + shift.y, 3);
|
|
540
572
|
}
|
|
541
|
-
// And the live
|
|
542
|
-
|
|
573
|
+
// And the live box is centred where it was dropped (left 120 + 587 + 160, top 520 + 235 + 64).
|
|
574
|
+
const landed = animated ? { x: 0, y: 0 } : shift;
|
|
575
|
+
const box = el.getBoundingClientRect();
|
|
576
|
+
expect(box.x + box.width / 2).toBeCloseTo(867 + landed.x, 3);
|
|
577
|
+
expect(box.y + box.height / 2).toBeCloseTo(819 + landed.y, 3);
|
|
543
578
|
});
|
|
544
579
|
|
|
545
580
|
function titleSelection(): DomEditSelection {
|