@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.
Files changed (76) hide show
  1. package/dist/assets/hyperframes-player-Bw_jIaSX.js +473 -0
  2. package/dist/assets/{index-B-wffzRH.js → index-BVM9NuWl.js} +153 -153
  3. package/dist/assets/{index-D3Vij9t1.js → index-Cj7W_c7p.js} +1 -1
  4. package/dist/assets/{index-D5q2_7Gw.js → index-DyH9dT7b.js} +1 -1
  5. package/dist/{chunk-TFLU2MGI.js → chunk-3GHTHPZD.js} +8 -1
  6. package/dist/chunk-3GHTHPZD.js.map +1 -0
  7. package/dist/{chunk-H2DUOXTF.js → chunk-G3EDKRZH.js} +3 -3
  8. package/dist/{chunk-UYWON5AX.js → chunk-PEAKCOUW.js} +7 -3
  9. package/dist/chunk-PEAKCOUW.js.map +1 -0
  10. package/dist/{chunk-LOVBLSMR.js → chunk-Y2OHHNM2.js} +35 -19
  11. package/dist/chunk-Y2OHHNM2.js.map +1 -0
  12. package/dist/{domEditingLayers-KBTPQKGA.js → domEditingLayers-PYST4G45.js} +3 -3
  13. package/dist/index.html +1 -1
  14. package/dist/index.js +261 -230
  15. package/dist/index.js.map +1 -1
  16. package/dist/player.js +3 -3
  17. package/dist/ui.d.ts +4 -5
  18. package/dist/ui.js +1 -1
  19. package/package.json +8 -8
  20. package/src/components/MediaPreview.tsx +2 -1
  21. package/src/components/editor/DomEditSelectionChrome.test.tsx +103 -0
  22. package/src/components/editor/DomEditSelectionChrome.tsx +42 -16
  23. package/src/components/editor/anchoredResizeCommitFeedsOffset.test.ts +36 -0
  24. package/src/components/editor/domEditOverlayGestures.ts +1 -0
  25. package/src/components/editor/domEditOverlayStartGesture.ts +5 -0
  26. package/src/components/editor/domEditResizeLocal.test.ts +81 -1
  27. package/src/components/editor/domEditResizeLocal.ts +27 -3
  28. package/src/components/editor/domEditingLayers.ts +4 -1
  29. package/src/components/editor/domEditingTextFields.test.ts +18 -0
  30. package/src/components/editor/manualEditsDom.ts +12 -12
  31. package/src/components/editor/mediaAudioEdits.test.ts +12 -0
  32. package/src/components/editor/mediaAudioEdits.ts +5 -5
  33. package/src/components/editor/previewGestureStarts.ts +14 -5
  34. package/src/components/editor/propertyPanelFill.tsx +2 -1
  35. package/src/components/editor/propertyPanelFlatMediaSection.tsx +3 -4
  36. package/src/components/editor/propertyPanelMediaSection.tsx +3 -4
  37. package/src/components/editor/resizeDraft.ts +13 -4
  38. package/src/components/editor/useColorGradingController.ts +2 -1
  39. package/src/components/editor/useDomEditOverlayGestures.ts +5 -3
  40. package/src/components/ui/Menu.test.tsx +67 -5
  41. package/src/components/ui/Menu.tsx +21 -4
  42. package/src/components/ui/Popover.tsx +11 -5
  43. package/src/hooks/gsapResizeIntercept.test.ts +46 -11
  44. package/src/hooks/gsapResizeIntercept.ts +25 -20
  45. package/src/hooks/useDomEditCommits.ts +2 -1
  46. package/src/hooks/useFileManager.ts +2 -1
  47. package/src/hooks/useFileTree.ts +2 -1
  48. package/src/hooks/useRenderClipContent.ts +15 -3
  49. package/src/hooks/useThumbnailStripSize.test.tsx +123 -0
  50. package/src/hooks/useThumbnailStripSize.ts +19 -8
  51. package/src/player/components/CompositionThumbnail.test.ts +3 -0
  52. package/src/player/components/ImageThumbnail.test.tsx +4 -1
  53. package/src/player/components/VideoThumbnail.test.tsx +13 -2
  54. package/src/player/components/clipMenuNormalize.ts +2 -2
  55. package/src/player/components/clipPeakMap.ts +2 -2
  56. package/src/player/components/thumbnailUtils.test.ts +33 -0
  57. package/src/player/components/thumbnailUtils.ts +5 -0
  58. package/src/player/hooks/timelineSyncHydration.ts +2 -1
  59. package/src/player/lib/mediaProbe.test.ts +12 -0
  60. package/src/player/lib/mediaProbe.ts +4 -2
  61. package/src/utils/authoredSource.test.ts +25 -0
  62. package/src/utils/authoredSource.ts +3 -5
  63. package/src/utils/rounding.ts +7 -0
  64. package/src/utils/sourcePatcher.test.ts +135 -0
  65. package/src/utils/sourcePatcher.ts +102 -186
  66. package/src/utils/studioHelpers.test.ts +23 -0
  67. package/src/utils/studioHelpers.ts +3 -2
  68. package/src/utils/studioSelectionSnapshot.test.ts +31 -0
  69. package/src/utils/timelineAssetDrop.test.ts +14 -0
  70. package/src/utils/timelineAssetDrop.ts +2 -1
  71. package/dist/assets/hyperframes-player-sAsRuV73.js +0 -473
  72. package/dist/chunk-LOVBLSMR.js.map +0 -1
  73. package/dist/chunk-TFLU2MGI.js.map +0 -1
  74. package/dist/chunk-UYWON5AX.js.map +0 -1
  75. /package/dist/{chunk-H2DUOXTF.js.map → chunk-G3EDKRZH.js.map} +0 -0
  76. /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, roundToLayoutPx } from "../../utils/rounding";
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 roundToLayoutPx(Math.max(1, direction.startsWith("column") ? size.height : size.width));
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, `${roundToLayoutPx(Math.max(1, size.width))}px`);
481
- element.style.setProperty(STUDIO_HEIGHT_PROP, `${roundToLayoutPx(Math.max(1, size.height))}px`);
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
- size: { width: number; height: number },
487
- ): void {
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
- ): void {
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
- unescapeHtmlAttribute,
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 = /\bsrc=(["'])([^"']*)\1/.exec(tag.tag);
151
- if (own?.[2]) return unescapeHtmlAttribute(own[2]);
152
- const firstSource = /^\s*<source\b[^>]*?\bsrc=(["'])([^"']*)\1/i.exec(source.slice(tag.end + 1));
153
- return firstSource?.[2] ? unescapeHtmlAttribute(firstSource[2]) : "";
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 pointer's distance from it, as the resize does.
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
- press: { x: number; y: number },
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: press.x + dx, y: press.y + dy };
115
- const scale = resolveCenterResizeScale({ pointer, pointerStart: press, centerStart: centre });
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(opts.overlayRectRef.current, { x: e.clientX, y: e.clientY });
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
- ? stripQueryAndHash(srcAttr.startsWith("./") ? srcAttr.slice(2) : srcAttr)
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
- ? stripQueryAndHash(srcAttr.startsWith("./") ? srcAttr.slice(2) : srcAttr)
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
- return computeNextResizeAnchor(g.lastResizeAnchor, fixedStart, overlayCornersCentroid(corners));
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
- act(() => {
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
- <BaseMenu.Portal container={container}>
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
- <BaseContextMenu.Portal container={container}>
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 { POPUP_LAYER, popupSurface, type PopupPreviewState } from "./Menu";
10
-
11
- type PortalContainer = ComponentPropsWithoutRef<typeof BasePopover.Portal>["container"];
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
- <BasePopover.Portal container={container}>
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 correct persisted correction is
459
- * NONE.
458
+ * point at its pre-gesture position, so the persisted correction is only the
459
+ * drop's sub-pixel offset.
460
460
  */
461
- it("does not move a statically positioned element when a scale resize lands", async () => {
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(), positionHold],
557
+ [keyframedScaleFixture(), position],
528
558
  iframe,
529
559
  commitMutation,
530
- async () => [keyframedScaleFixture(), positionHold],
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
- // Either it left the position alone, or it rewrote the same value.
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).toBe(432);
539
- expect(write.properties?.y).toBe(173);
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 element ends on the drop point, not a drag away from it.
542
- expect(el.getBoundingClientRect().x).toBeCloseTo(603.3, 0);
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 {