mirror of
https://github.com/heygen-com/hyperframes.git
synced 2026-09-03 04:38:33 +00:00
feat(studio): marquee multi-selection + off-canvas indicators (#1693)
* chore(studio): remove all console.* calls from studio package * chore(studio): address review — remove dead stubs, restore consent notice - Delete empty if-blocks left after console removal (snapTargetCollection, Player asset-poll, useTimelineSyncCallbacks 5s probe, useGestureRecording dev guard + now-unused isDevBuild) and the stale "surface in dev" comment. - Drop the dangling no-console pragma + dead duplicate-id branch in sourcePatcher. - Restore the one-time telemetry consent disclosure in showNoticeOnce (kept behind a pragma — it is a user-facing notice, not debug noise). - Remove the missed timelineIcons console.warn while preserving the `tag || "div"` null-safety fallback. - Route caption auto-save failures (a data-loss path) through telemetry instead of swallowing silently. - Restore the accidentally-clobbered css-var-fonts output.mp4 fixture. Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com> * fix(runtime): immediateRender for set tweens + array timeline normalization - Set tweens now emit immediateRender:true so they render on page load without requiring the runtime to seek past position 0 - Runtime IIFE normalizes array timelines (window.__timelines = [tl]) to keyed objects, and auto-adds data-start on root elements - Drag teardown clears translate:none to prevent #1673 fly-off - Position-only set tweens hidden from timeline diamonds (3 cache paths) - Parser: ease-only keyframe update preserves existing properties * fix(runtime): address review — restore perf gate, debug surface, scrub restore - Restore the #1651 skipForInjectedVideo gate in media.ts that was dropped on restack — avoids ~2400 wasted per-tick seeks on video-heavy renders. - Restore the console.debug body + docstring bullet of swallow() in diagnostics.ts: the __hfDebug opt-in debug surface had been gutted to an empty if-block. - Rebind: after the progress-cycle set() kick, seek to state.currentTime via totalTime() instead of snapping to 0, so a rebind after scrub / soft-reload restore keeps the playhead. - Array __timelines normalization + data-start default now resolve the root via a shared findRootCompositionEl() that honors data-root="true" first (matches resolveRootCompositionElement, which now delegates to it). - Ease-only keyframe update leaves a primitive (non-object) keyframe value untouched instead of wiping it to {}; add a preservation unit test. - Document the boundDuration<=0 progress(1) kick + restore the STATIC-case comment in gsapRuntimeBridge. Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com> * feat(studio): marquee multi-selection + off-canvas indicators - Click+drag on empty canvas draws dashed selection rectangle - SAT/OBB intersection handles rotated/scaled/skewed elements - Shift+marquee adds to existing selection - Click on empty canvas deselects - Off-canvas elements show dashed outline indicators (clickable) - Dashed border only shows outside canvas, solid inside (clip-path) - 12 geometry unit tests * feat(studio): address review — group-aware off-canvas indicators + fixes - Off-canvas indicator suppression now skips every selected element (primary AND marquee group members), not just the primary, so group members no longer render a doubled overlay (group rect + dashed indicator). - Drop selection from the off-canvas layout effect deps; the selected-element filter runs at render time. Avoids re-walking geometry on each selection change. - applyMarqueeSelection now honors STUDIO_INSPECTOR_PANELS_ENABLED. - Restore the stale-selection clear in useDomEditPreviewSync when the selected element no longer resolves after a re-sync. Drag-release stays handled by suppressNextBoxClickRef. - Off-canvas indicator is keyboard-accessible; canvas cursor driven by marquee rect state, not a render-time ref read. - Rename partiallyOutside -> extendsOutsideComp + comment the clip-path hit-test. - Extract OffCanvasIndicators into its own component (DomEditOverlay was already over the 600-LOC cap on this branch; extraction brings it under). - Declare onUpdateKeyframeEase on PropertyPanelProps so this branch typechecks standalone (handler + wiring already here; only the type had leaked upstack). Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com> --------- Co-authored-by: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
This commit is contained in:
co-authored by
Claude Opus 4.8
parent
6987447a75
commit
8ae010bf51
@@ -0,0 +1,123 @@
|
||||
import { describe, expect, it } from "vitest";
|
||||
import { marqueeIntersectsObb, rectsOverlap, type Point, type Rect } from "./marqueeGeometry";
|
||||
|
||||
type Corners = [Point, Point, Point, Point];
|
||||
|
||||
function rotateCorners(cx: number, cy: number, w: number, h: number, deg: number): Corners {
|
||||
const rad = (deg * Math.PI) / 180;
|
||||
const cos = Math.cos(rad);
|
||||
const sin = Math.sin(rad);
|
||||
const hw = w / 2;
|
||||
const hh = h / 2;
|
||||
const local: [number, number][] = [
|
||||
[-hw, -hh],
|
||||
[hw, -hh],
|
||||
[hw, hh],
|
||||
[-hw, hh],
|
||||
];
|
||||
return local.map(([lx, ly]) => ({
|
||||
x: cx + cos * lx - sin * ly,
|
||||
y: cy + sin * lx + cos * ly,
|
||||
})) as Corners;
|
||||
}
|
||||
|
||||
function aabbCorners(r: Rect): Corners {
|
||||
return [
|
||||
{ x: r.left, y: r.top },
|
||||
{ x: r.left + r.width, y: r.top },
|
||||
{ x: r.left + r.width, y: r.top + r.height },
|
||||
{ x: r.left, y: r.top + r.height },
|
||||
];
|
||||
}
|
||||
|
||||
describe("rectsOverlap", () => {
|
||||
it("overlapping rects", () => {
|
||||
expect(
|
||||
rectsOverlap(
|
||||
{ left: 0, top: 0, width: 10, height: 10 },
|
||||
{ left: 5, top: 5, width: 10, height: 10 },
|
||||
),
|
||||
).toBe(true);
|
||||
});
|
||||
|
||||
it("non-overlapping rects", () => {
|
||||
expect(
|
||||
rectsOverlap(
|
||||
{ left: 0, top: 0, width: 10, height: 10 },
|
||||
{ left: 20, top: 20, width: 10, height: 10 },
|
||||
),
|
||||
).toBe(false);
|
||||
});
|
||||
});
|
||||
|
||||
describe("marqueeIntersectsObb", () => {
|
||||
it("axis-aligned overlap", () => {
|
||||
const marquee: Rect = { left: 0, top: 0, width: 100, height: 100 };
|
||||
const corners = aabbCorners({ left: 50, top: 50, width: 80, height: 80 });
|
||||
expect(marqueeIntersectsObb(marquee, corners)).toBe(true);
|
||||
});
|
||||
|
||||
it("axis-aligned no overlap", () => {
|
||||
const marquee: Rect = { left: 0, top: 0, width: 50, height: 50 };
|
||||
const corners = aabbCorners({ left: 100, top: 100, width: 50, height: 50 });
|
||||
expect(marqueeIntersectsObb(marquee, corners)).toBe(false);
|
||||
});
|
||||
|
||||
it("marquee fully contains element", () => {
|
||||
const marquee: Rect = { left: 0, top: 0, width: 200, height: 200 };
|
||||
const corners = aabbCorners({ left: 50, top: 50, width: 20, height: 20 });
|
||||
expect(marqueeIntersectsObb(marquee, corners)).toBe(true);
|
||||
});
|
||||
|
||||
it("element fully contains marquee", () => {
|
||||
const marquee: Rect = { left: 50, top: 50, width: 10, height: 10 };
|
||||
const corners = aabbCorners({ left: 0, top: 0, width: 200, height: 200 });
|
||||
expect(marqueeIntersectsObb(marquee, corners)).toBe(true);
|
||||
});
|
||||
|
||||
it("45-degree rotated square: AABB overlaps but OBB does not", () => {
|
||||
// 100x100 square rotated 45° centered at (200,200)
|
||||
// Its AABB extends to ~(129,129)-(271,271)
|
||||
// A marquee at (0,0)-(135,135) overlaps the AABB but NOT the diamond
|
||||
const corners = rotateCorners(200, 200, 100, 100, 45);
|
||||
const marquee: Rect = { left: 0, top: 0, width: 135, height: 135 };
|
||||
expect(marqueeIntersectsObb(marquee, corners)).toBe(false);
|
||||
});
|
||||
|
||||
it("45-degree rotated square: OBB overlaps", () => {
|
||||
// Same rotated square, marquee reaches the diamond's left point
|
||||
const corners = rotateCorners(200, 200, 100, 100, 45);
|
||||
const marquee: Rect = { left: 0, top: 150, width: 155, height: 100 };
|
||||
expect(marqueeIntersectsObb(marquee, corners)).toBe(true);
|
||||
});
|
||||
|
||||
it("zero-width marquee returns false", () => {
|
||||
const corners = aabbCorners({ left: 0, top: 0, width: 100, height: 100 });
|
||||
expect(marqueeIntersectsObb({ left: 50, top: 50, width: 0, height: 50 }, corners)).toBe(false);
|
||||
});
|
||||
|
||||
it("zero-area element returns false for degenerate OBB", () => {
|
||||
const corners: Corners = [
|
||||
{ x: 50, y: 50 },
|
||||
{ x: 50, y: 50 },
|
||||
{ x: 50, y: 50 },
|
||||
{ x: 50, y: 50 },
|
||||
];
|
||||
const marquee: Rect = { left: 0, top: 0, width: 100, height: 100 };
|
||||
// Degenerate point — SAT still works (projections are zero-length intervals)
|
||||
// A point inside the marquee should still intersect
|
||||
expect(marqueeIntersectsObb(marquee, corners)).toBe(true);
|
||||
});
|
||||
|
||||
it("30-degree rotated rectangle clips marquee corner", () => {
|
||||
const corners = rotateCorners(150, 150, 200, 50, 30);
|
||||
const marquee: Rect = { left: 0, top: 0, width: 80, height: 130 };
|
||||
expect(marqueeIntersectsObb(marquee, corners)).toBe(true);
|
||||
});
|
||||
|
||||
it("30-degree rotated rectangle misses marquee", () => {
|
||||
const corners = rotateCorners(300, 300, 50, 50, 30);
|
||||
const marquee: Rect = { left: 0, top: 0, width: 100, height: 100 };
|
||||
expect(marqueeIntersectsObb(marquee, corners)).toBe(false);
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,172 @@
|
||||
export interface Point {
|
||||
x: number;
|
||||
y: number;
|
||||
}
|
||||
|
||||
export interface Rect {
|
||||
left: number;
|
||||
top: number;
|
||||
width: number;
|
||||
height: number;
|
||||
}
|
||||
|
||||
type Corners = [Point, Point, Point, Point];
|
||||
|
||||
function isIdentityMatrix(m: DOMMatrix): boolean {
|
||||
const e = 1e-6;
|
||||
return Math.abs(m.a - 1) < e && Math.abs(m.b) < e && Math.abs(m.c) < e && Math.abs(m.d - 1) < e;
|
||||
}
|
||||
|
||||
function rectsOverlap(a: Rect, b: Rect): boolean {
|
||||
return (
|
||||
a.left < b.left + b.width &&
|
||||
a.left + a.width > b.left &&
|
||||
a.top < b.top + b.height &&
|
||||
a.top + a.height > b.top
|
||||
);
|
||||
}
|
||||
|
||||
function projectOntoAxis(corners: Corners, ax: number, ay: number): [number, number] {
|
||||
let min = Infinity;
|
||||
let max = -Infinity;
|
||||
for (const c of corners) {
|
||||
const dot = c.x * ax + c.y * ay;
|
||||
if (dot < min) min = dot;
|
||||
if (dot > max) max = dot;
|
||||
}
|
||||
return [min, max];
|
||||
}
|
||||
|
||||
function projectionsOverlap(a: [number, number], b: [number, number]): boolean {
|
||||
return a[0] <= b[1] && b[0] <= a[1];
|
||||
}
|
||||
|
||||
/**
|
||||
* SAT intersection test between an axis-aligned marquee rect and a
|
||||
* convex quadrilateral (the element's OBB corners in overlay space).
|
||||
*
|
||||
* Separating axes: 2 from the AABB (horizontal, vertical) + 2 from
|
||||
* the OBB's edge normals. If projections overlap on all 4 axes, the
|
||||
* shapes intersect.
|
||||
*/
|
||||
export function marqueeIntersectsObb(marquee: Rect, corners: Corners): boolean {
|
||||
if (marquee.width <= 0 || marquee.height <= 0) return false;
|
||||
|
||||
const mCorners: Corners = [
|
||||
{ x: marquee.left, y: marquee.top },
|
||||
{ x: marquee.left + marquee.width, y: marquee.top },
|
||||
{ x: marquee.left + marquee.width, y: marquee.top + marquee.height },
|
||||
{ x: marquee.left, y: marquee.top + marquee.height },
|
||||
];
|
||||
|
||||
// AABB axes: (1,0) and (0,1)
|
||||
const mProjX: [number, number] = [marquee.left, marquee.left + marquee.width];
|
||||
const mProjY: [number, number] = [marquee.top, marquee.top + marquee.height];
|
||||
|
||||
const oProjX = projectOntoAxis(corners, 1, 0);
|
||||
const oProjY = projectOntoAxis(corners, 0, 1);
|
||||
|
||||
if (!projectionsOverlap(mProjX, oProjX)) return false;
|
||||
if (!projectionsOverlap(mProjY, oProjY)) return false;
|
||||
|
||||
// OBB edge normals (only need 2 — edges 0→1 and 1→2)
|
||||
for (let i = 0; i < 2; i++) {
|
||||
const edge = {
|
||||
x: corners[i + 1].x - corners[i].x,
|
||||
y: corners[i + 1].y - corners[i].y,
|
||||
};
|
||||
const len = Math.hypot(edge.x, edge.y);
|
||||
if (len < 1e-9) continue;
|
||||
const ax = -edge.y / len;
|
||||
const ay = edge.x / len;
|
||||
|
||||
const mProj = projectOntoAxis(mCorners, ax, ay);
|
||||
const oProj = projectOntoAxis(corners, ax, ay);
|
||||
if (!projectionsOverlap(mProj, oProj)) return false;
|
||||
}
|
||||
|
||||
return true;
|
||||
}
|
||||
|
||||
/**
|
||||
* Compute the four corners of an element's OBB in overlay-pixel space.
|
||||
*
|
||||
* For elements with an identity transform, returns the axis-aligned
|
||||
* corners from the element's BCR mapped to overlay space (fast path).
|
||||
*/
|
||||
// fallow-ignore-next-line complexity
|
||||
export function elementObbCorners(
|
||||
element: HTMLElement,
|
||||
overlayEl: HTMLDivElement,
|
||||
iframe: HTMLIFrameElement,
|
||||
): Corners | null {
|
||||
const doc = iframe.contentDocument;
|
||||
if (!doc) return null;
|
||||
|
||||
const iframeRect = iframe.getBoundingClientRect();
|
||||
const overlayRect = overlayEl.getBoundingClientRect();
|
||||
const root = doc.querySelector<HTMLElement>("[data-composition-id]") ?? doc.documentElement;
|
||||
const declaredW = Number.parseFloat(root?.getAttribute("data-width") ?? "");
|
||||
const declaredH = Number.parseFloat(root?.getAttribute("data-height") ?? "");
|
||||
const rootW = declaredW > 0 ? declaredW : root?.getBoundingClientRect().width || 1;
|
||||
const rootH = declaredH > 0 ? declaredH : root?.getBoundingClientRect().height || 1;
|
||||
|
||||
const scaleX = iframeRect.width / rootW;
|
||||
const scaleY = iframeRect.height / rootH;
|
||||
const offsetX = iframeRect.left - overlayRect.left;
|
||||
const offsetY = iframeRect.top - overlayRect.top;
|
||||
|
||||
const win = element.ownerDocument.defaultView;
|
||||
if (!win) return null;
|
||||
|
||||
const transform = win.getComputedStyle(element).transform;
|
||||
const m = transform && transform !== "none" ? new DOMMatrix(transform) : new DOMMatrix();
|
||||
|
||||
if (isIdentityMatrix(m)) {
|
||||
const r = element.getBoundingClientRect();
|
||||
const left = offsetX + r.left * scaleX;
|
||||
const top = offsetY + r.top * scaleY;
|
||||
const w = r.width * scaleX;
|
||||
const h = r.height * scaleY;
|
||||
return [
|
||||
{ x: left, y: top },
|
||||
{ x: left + w, y: top },
|
||||
{ x: left + w, y: top + h },
|
||||
{ x: left, y: top + h },
|
||||
];
|
||||
}
|
||||
|
||||
// Walk offsetParent chain for pre-transform position
|
||||
let ox = 0;
|
||||
let oy = 0;
|
||||
let el: HTMLElement | null = element;
|
||||
while (el && el !== doc.body && el !== doc.documentElement) {
|
||||
ox += el.offsetLeft;
|
||||
oy += el.offsetTop;
|
||||
el = el.offsetParent as HTMLElement | null;
|
||||
}
|
||||
|
||||
const w = element.offsetWidth;
|
||||
const h = element.offsetHeight;
|
||||
// ponytail: center-based transform — CSS transforms originate at 50% 50%
|
||||
const cx = ox + w / 2;
|
||||
const cy = oy + h / 2;
|
||||
|
||||
const localCorners: [number, number][] = [
|
||||
[-w / 2, -h / 2],
|
||||
[w / 2, -h / 2],
|
||||
[w / 2, h / 2],
|
||||
[-w / 2, h / 2],
|
||||
];
|
||||
|
||||
return localCorners.map(([lx, ly]) => {
|
||||
const tx = m.a * lx + m.c * ly + cx;
|
||||
const ty = m.b * lx + m.d * ly + cy;
|
||||
return {
|
||||
x: offsetX + tx * scaleX,
|
||||
y: offsetY + ty * scaleY,
|
||||
};
|
||||
}) as Corners;
|
||||
}
|
||||
|
||||
export { rectsOverlap };
|
||||
Reference in New Issue
Block a user