Skip to content
Closed
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
17 commits
Select commit Hold shift + click to select a range
bd03f20
fix(studio): size the selection box by the transform the element actu…
miguel-heygen Aug 7, 2026
cf75f6e
fix(studio): drag by the movement the element actually makes, not the…
miguel-heygen Aug 8, 2026
f15a677
fix(studio): shift-click adds the element under the pointer, not the …
miguel-heygen Aug 8, 2026
27275ba
fix(studio): keep every element a marquee caught, not just the first
miguel-heygen Aug 8, 2026
bccc222
fix(studio): stop a group selection from erasing itself on the timeline
miguel-heygen Aug 8, 2026
fbc892c
chore(studio): trace what moves a dragged group and when
miguel-heygen Aug 8, 2026
e6cbb22
chore(studio): name the path that clears a selection after a group move
miguel-heygen Aug 8, 2026
23dcdd8
fix(studio): losing one member of a group no longer deselects all of it
miguel-heygen Aug 8, 2026
d939b7d
feat(studio): carry a multi-selection in the URL, and name the member…
miguel-heygen Aug 8, 2026
215d920
fix(studio): stop snapping from moving a selection you have not dragg…
miguel-heygen Aug 8, 2026
33d363d
fix(studio): a dropped group stays selected
miguel-heygen Aug 8, 2026
0de5a0f
feat(studio): marquee from anywhere on the canvas, including outside …
miguel-heygen Aug 8, 2026
44a2690
refactor(studio): keep the selection files under the size cap
miguel-heygen Aug 9, 2026
d94c70c
fix(studio): preserve selector groups in share URLs
miguel-heygen Aug 9, 2026
32769f7
fix(studio): close multi-selection review gaps
miguel-heygen Aug 9, 2026
59e864c
fix(studio): stabilize selection store reads
miguel-heygen Aug 9, 2026
560ff33
fix(studio): preserve canvas-only group anchors
miguel-heygen Aug 9, 2026
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
8 changes: 6 additions & 2 deletions packages/studio/src/App.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -65,6 +65,7 @@ import {
readStudioUrlStateFromWindow,
resolveMasterCompositionPath,
} from "./utils/studioUrlState";
const getTimelineSelectionSet = () => usePlayerStore.getState().selectedElementIds;
// fallow-ignore-next-line complexity
export function StudioApp() {
const { projectId, resolving, waitingForServer } = useServerConnection();
Expand All @@ -91,9 +92,9 @@ export function StudioApp() {
const captionSync = useCaptionSync(projectId);
const timelineElements = usePlayerStore((s) => s.elements);
const setSelectedTimelineElementId = usePlayerStore((s) => s.setSelectedElementId);
const setTimelineSelectionSet = usePlayerStore((s) => s.setSelectedElementIds);
const timelineDuration = usePlayerStore((s) => s.duration);
const isPlaying = usePlayerStore((s) => s.isPlaying);
const isMasterView = !activeCompPath || activeCompPath === "index.html";
const effectiveTimelineDuration = useMemo(() => {
const maxEnd =
timelineElements.length > 0
Expand Down Expand Up @@ -270,13 +271,14 @@ export function StudioApp() {
const domEditSession = useDomEditSession({
projectId,
activeCompPath,
isMasterView,
compIdToSrc,
captionEditMode,
compositionLoading,
previewIframeRef,
timelineElements,
getTimelineSelectionSet,
setSelectedTimelineElementId,
setTimelineSelectionSet,
setRightCollapsed: panelLayout.setRightCollapsed,
setRightPanelTab: panelLayout.setRightPanelTab,
showToast,
Expand Down Expand Up @@ -416,6 +418,8 @@ export function StudioApp() {
rightCollapsed: panelLayout.rightCollapsed,
activeCompPathHydrated,
domEditSelection: domEditSession.domEditSelection,
domEditGroupSelections: domEditSession.domEditGroupSelections,
applyMarqueeSelection: domEditSession.applyMarqueeSelection,
buildDomSelectionFromTarget: domEditSession.buildDomSelectionFromTarget,
applyDomSelection: domEditSession.applyDomSelection,
setRightPanelTab: panelLayout.setRightPanelTab,
Expand Down
91 changes: 89 additions & 2 deletions packages/studio/src/components/editor/DomEditOverlay.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -14,7 +14,10 @@ import {
resolveDomEditRotationGesture,
} from "./DomEditOverlay";
import type { DomEditSelection } from "./domEditing";
import { resolveResizeCenterAnchorOffset } from "./domEditOverlayGestures";
import {
hoverCacheDescribesPoint,
resolveResizeCenterAnchorOffset,
} from "./domEditOverlayGestures";

// React 19 warns unless the test environment opts into act().
globalThis.IS_REACT_ACT_ENVIRONMENT = true;
Expand Down Expand Up @@ -278,7 +281,9 @@ describe("DomEditOverlay", () => {
const host = document.createElement("div");
document.body.append(host);
const root = createRoot(host);
const iframeRef = { current: document.createElement("iframe") as HTMLIFrameElement | null };
const iframeRef: { current: HTMLIFrameElement | null } = {
current: document.createElement("iframe"),
};
const onCanvasMouseDown = vi.fn();
const onMarqueeSelect = vi.fn();

Expand Down Expand Up @@ -323,6 +328,44 @@ describe("DomEditOverlay", () => {
host.remove();
});

it("starts a marquee from outside the composition frame", async () => {
const restoreRect = stubViewportRect();
const originalPointerCapture = HTMLDivElement.prototype.setPointerCapture;
const setPointerCapture = vi.fn();
HTMLDivElement.prototype.setPointerCapture = setPointerCapture;
const host = document.createElement("div");
document.body.append(host);
const root = createRoot(host);
const iframeRef: { current: HTMLIFrameElement | null } = {
current: document.createElement("iframe"),
};

act(() => {
root.render(
React.createElement(DomEditOverlay, {
...createOverlayProps({
iframeRef,
selection: null,
hoverSelection: null,
onSelectionChange: () => {},
}),
onMarqueeSelect: vi.fn(),
}),
);
});
await flushOverlayRaf();

// Negative x is outside the 0..800 composition frame but still reaches the
// overlay in a real pointer event when the user starts in the grey margin.
dispatchOverlayPointerDown(getOverlay(host), -40, 100);
expect(setPointerCapture).toHaveBeenCalledTimes(1);

act(() => root.unmount());
HTMLDivElement.prototype.setPointerCapture = originalPointerCapture;
restoreRect();
host.remove();
});

it("does not start a drag from a stale hover target on canvas pointer-down", () => {
const host = document.createElement("div");
document.body.append(host);
Expand Down Expand Up @@ -628,6 +671,50 @@ describe("resolveDomEditRotationGesture", () => {
});
});

/**
* Shift-click reads the hover cache instead of hit-testing, and the cache is
* filled asynchronously as the pointer moves. Pass over one element on the way to
* another and the cache still names the one you left, so the shift-click added
* THAT element and the click looked like it selected something at random. The
* guard is what makes the cache usable only when it is about the point clicked.
*/
describe("hoverCacheDescribesPoint", () => {
const doc = new Window().document;

it("rejects a cache left behind by an element the pointer passed over", () => {
const passedOver = doc.createElement("div");
const clicked = doc.createElement("div");
doc.body.append(passedOver, clicked);

expect(hoverCacheDescribesPoint(passedOver, clicked)).toBe(false);
});

it("accepts the cache when it names the element at the point", () => {
const clicked = doc.createElement("div");
doc.body.append(clicked);

expect(hoverCacheDescribesPoint(clicked, clicked)).toBe(true);
});

// The resolver is allowed to hand back a clip ancestor of the raw target, which
// still describes the same click — rejecting it would drop the fast path on
// every element that has children.
it("accepts an ancestor of the element at the point", () => {
const clip = doc.createElement("div");
const child = doc.createElement("span");
clip.append(child);
doc.body.append(clip);

expect(hoverCacheDescribesPoint(clip, child)).toBe(true);
});

it("rejects a missing cache or an empty point", () => {
const el = doc.createElement("div");
expect(hoverCacheDescribesPoint(null, el)).toBe(false);
expect(hoverCacheDescribesPoint(el, null)).toBe(false);
});
});

// resolveResizeCenterAnchorOffset is the UNROTATED (AABB) fallback used only when
// the element's real transformed corners can't be measured. Center-anchored: a
// width/height change grows the box from its top-left, drifting the center by half
Expand Down
62 changes: 39 additions & 23 deletions packages/studio/src/components/editor/DomEditOverlay.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -13,6 +13,7 @@ import {
type GestureState,
type GroupGestureState,
focusDomEditOverlayElement,
resolveShiftClickCandidate,
} from "./domEditOverlayGestures";
import { useDomEditOverlayRects } from "./useDomEditOverlayRects";
import { OffCanvasIndicators, type OffCanvasRect } from "./OffCanvasIndicators";
Expand All @@ -31,6 +32,7 @@ import { startOffCanvasIndicatorRefresh } from "./offCanvasIndicatorRefresh";
import { CanvasContextMenu } from "./CanvasContextMenu";
import type { ZOrderAction, ZOrderPatch } from "./canvasContextMenuZOrder";
import { getPreviewTargetFromPointer } from "../../utils/studioPreviewHelpers";
import { logSelect } from "../../utils/selectDebug";

// Re-exports for external consumers — preserving existing import paths.
export {
Expand Down Expand Up @@ -318,6 +320,7 @@ export const DomEditOverlay = memo(function DomEditOverlay({
const handleOverlayMouseDown = (event: React.MouseEvent<HTMLDivElement>) => {
if (!allowCanvasMovement) return;
if (suppressNextOverlayMouseDownRef.current) {
logSelect("mousedown-suppressed", { shift: event.shiftKey });
suppressNextOverlayMouseDownRef.current = false;
suppressNextBoxMouseDownRef.current = false;
suppressNextBoxClickRef.current = false;
Expand All @@ -326,7 +329,9 @@ export const DomEditOverlay = memo(function DomEditOverlay({
return;
}
const target = event.target as HTMLElement | null;
if (target?.closest('[data-dom-edit-selection-box="true"]')) return;
const onBox = Boolean(target?.closest('[data-dom-edit-selection-box="true"]'));
logSelect("mousedown", { shift: event.shiftKey, onBox });
if (onBox) return;
// Allow clicks anywhere on the overlay — GSAP-translated elements can
// extend beyond the composition rect into the gray zone, and users need
// to select/deselect them by clicking there.
Expand All @@ -341,8 +346,20 @@ export const DomEditOverlay = memo(function DomEditOverlay({
const handleOverlayPointerDown = (event: React.PointerEvent<HTMLDivElement>) => {
if (!allowCanvasMovement || event.button !== 0) return;
if (event.shiftKey) {
// Use the already-updated hover selection rather than re-resolving async
const candidate = hoverSelectionRef.current;
const shiftIframe = iframeRef.current;
const candidate = resolveShiftClickCandidate({
cached: hoverSelectionRef.current,
elementAtPoint: shiftIframe
? getPreviewTargetFromPointer(
shiftIframe,
event.clientX,
event.clientY,
activeCompositionPathRef.current,
)
: null,
});
// Not confident: fall through untouched — no preventDefault, no suppression —
// so the mousedown path resolves this point instead of guessing here.
if (!candidate) return;
event.preventDefault();
event.stopPropagation();
Expand Down Expand Up @@ -376,28 +393,27 @@ export const DomEditOverlay = memo(function DomEditOverlay({
const overlayEl = overlayRef.current;
if (overlayEl) {
const oRect = overlayEl.getBoundingClientRect();
// Anywhere empty on the overlay starts one, not just inside the frame.
// An element dragged past the edge sits OUT there in the grey, and a
// rubber band that refuses to start there cannot reach it — which left
// the timeline as the only way to select something you can plainly see.
// The hit test collects in overlay space and never clipped to the frame,
// so those elements were always selectable once the band could begin.
event.preventDefault();
event.stopPropagation();
suppressNextOverlayMouseDownRef.current = true;
(event.currentTarget as HTMLElement).setPointerCapture(event.pointerId);
const cx = event.clientX - oRect.left;
const cy = event.clientY - oRect.top;
const inComp =
cx >= compRect.left &&
cx <= compRect.left + compRect.width &&
cy >= compRect.top &&
cy <= compRect.top + compRect.height;
if (inComp) {
event.preventDefault();
event.stopPropagation();
suppressNextOverlayMouseDownRef.current = true;
(event.currentTarget as HTMLElement).setPointerCapture(event.pointerId);
marquee.marqueeRef.current = {
startX: cx,
startY: cy,
currentX: cx,
currentY: cy,
pointerId: event.pointerId,
pastThreshold: false,
};
return;
}
marquee.marqueeRef.current = {
startX: cx,
startY: cy,
currentX: cx,
currentY: cy,
pointerId: event.pointerId,
pastThreshold: false,
};
return;
}
}
};
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -67,6 +67,17 @@ describe("orientedOverlayRect — rotation gate (perf fix, V15 18a/18b)", () =>
number,
];
}
/** `this` applied outside `other`, the way an ancestor composes over a child. */
multiply(other: { a: number; b: number; c: number; d: number; e: number; f: number }) {
const out = new (this.constructor as new (init?: string) => this)();
out.a = this.a * other.a + this.c * other.b;
out.b = this.b * other.a + this.d * other.b;
out.c = this.a * other.c + this.c * other.d;
out.d = this.b * other.c + this.d * other.d;
out.e = this.a * other.e + this.c * other.f + this.e;
out.f = this.b * other.e + this.d * other.f + this.f;
return out;
}
transformPoint(pt: { x: number; y: number }) {
return {
x: this.a * pt.x + this.c * pt.y + this.e,
Expand Down Expand Up @@ -156,6 +167,64 @@ describe("orientedOverlayRect — rotation gate (perf fix, V15 18a/18b)", () =>
expect(rect!.angle).toBeCloseTo(30, 3);
});

/**
* The selection box is drawn at the size the element PAINTS, which is the
* product of every transform between it and the composition root.
*
* A text layer inside a card carrying `scale(1.2)` was drawn at 1/1.2 of the
* text: the top-left was right, because the caller anchors that to the real
* bounding rect, and the right and bottom edges fell short. The same read
* decides whether to draw the box rotated, so an element inside a rotated
* parent got an upright box.
*/
const SCALE_1_2_MATRIX = "matrix(1.2, 0, 0, 1.2, 0, 0)";
const MIRROR_X_MATRIX = "matrix(-1, 0, 0, 1, 0, 0)";

it("sizes the box by the accumulated transform, not the element's own", () => {
const { overlayEl, iframe, el } = buildHarness();
// The element carries no transform; its parent scales it by 1.2, so it
// paints at 240x120 and its bounding rect says so.
el.parentElement!.style.transform = SCALE_1_2_MATRIX;
el.style.transform = ROTATE_30DEG_MATRIX;
stubRect(el, { left: 400, top: 450, width: 240, height: 120 });

const rect = orientedOverlayRect(overlayEl, iframe, el);

expect(rect).not.toBeNull();
// 200x100 local, scaled by the ancestor, then rotated: the oriented box is
// the scaled local box, and the AABB it is anchored to is wider again.
expect(rect!.width).toBeCloseTo(240, 3);
expect(rect!.height).toBeCloseTo(120, 3);
expect(rect!.angle).toBeCloseTo(30, 3);
});

it("takes the rotated path when only an ANCESTOR is rotated", () => {
const { overlayEl, iframe, el } = buildHarness();
el.parentElement!.style.transform = ROTATE_30DEG_MATRIX;

const rect = orientedOverlayRect(overlayEl, iframe, el);

expect(rect!.angle).toBeCloseTo(30, 3);
});

it("does not misread a mirrored ancestor as a 180-degree rotation", () => {
const { overlayEl, iframe, el } = buildHarness();
el.parentElement!.style.transform = MIRROR_X_MATRIX;

const rect = orientedOverlayRect(overlayEl, iframe, el);

expect(rect?.angle ?? 0).toBe(0);
});

it("stops transform composition at the composition root", () => {
const { overlayEl, iframe, el } = buildHarness();
iframe.contentDocument!.body.style.transform = ROTATE_30DEG_MATRIX;

const rect = orientedOverlayRect(overlayEl, iframe, el);

expect(rect?.angle ?? 0).toBe(0);
});

it("preserves an ordinary element's rotation through the group-aware entry point", () => {
const { overlayEl, iframe, el } = buildHarness();
el.style.transform = ROTATE_30DEG_MATRIX;
Expand Down
Loading
Loading