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:
Miguel Ángel
2026-06-24 17:53:18 -04:00
committed by GitHub
co-authored by Claude Opus 4.8
parent 6987447a75
commit 8ae010bf51
12 changed files with 801 additions and 51 deletions
@@ -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);
});
});