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,168 @@
import { useCallback, useRef, useState } from "react";
import type { DomEditSelection } from "./domEditing";
import { collectDomEditLayerItems, resolveDomEditSelection } from "./domEditingLayers";
import { isElementComputedVisible } from "./domEditingElement";
import { coversComposition } from "../../utils/studioPreviewHelpers";
import { elementObbCorners, marqueeIntersectsObb } from "../../utils/marqueeGeometry";
interface MarqueeState {
startX: number;
startY: number;
currentX: number;
currentY: number;
pointerId: number;
pastThreshold: boolean;
}
const MARQUEE_THRESHOLD_PX = 4;
// fallow-ignore-next-line complexity
async function runMarqueeIntersection(
rect: { left: number; top: number; width: number; height: number },
iframe: HTMLIFrameElement,
overlayEl: HTMLDivElement,
activeCompositionPath: string,
): Promise<DomEditSelection[]> {
const doc = iframe.contentDocument;
if (!doc) return [];
const root = doc.querySelector<HTMLElement>("[data-composition-id]") ?? doc.body;
const isMasterView = !activeCompositionPath || activeCompositionPath === "index.html";
const items = collectDomEditLayerItems(root, { activeCompositionPath, isMasterView });
const rootEl = doc.querySelector<HTMLElement>("[data-composition-id]") ?? doc.documentElement;
const declW = Number.parseFloat(rootEl?.getAttribute("data-width") ?? "");
const declH = Number.parseFloat(rootEl?.getAttribute("data-height") ?? "");
const viewport = {
width: declW > 0 ? declW : rootEl.getBoundingClientRect().width || 1,
height: declH > 0 ? declH : rootEl.getBoundingClientRect().height || 1,
};
const hits: DomEditSelection[] = [];
for (const item of items) {
const el = item.element;
if (!isElementComputedVisible(el)) continue;
if (coversComposition(el.getBoundingClientRect(), viewport)) continue;
const corners = elementObbCorners(el, overlayEl, iframe);
if (!corners) continue;
if (!marqueeIntersectsObb(rect, corners)) continue;
const sel = await resolveDomEditSelection(el, {
activeCompositionPath,
isMasterView,
skipSourceProbe: true,
});
if (sel) hits.push(sel);
}
return hits;
}
interface MarqueeGesturesDeps {
iframeRef: React.RefObject<HTMLIFrameElement | null>;
overlayRef: React.RefObject<HTMLDivElement | null>;
activeCompositionPathRef: React.RefObject<string | null>;
onMarqueeSelectRef: React.RefObject<
((selections: DomEditSelection[], additive: boolean) => void) | undefined
>;
selectionRef: React.RefObject<DomEditSelection | null>;
gestures: {
onPointerMove: (event: React.PointerEvent<HTMLDivElement>) => void;
onPointerUp: (event: React.PointerEvent<HTMLDivElement>) => void;
clearPointerState: (ref: React.RefObject<DomEditSelection | null>) => void;
};
}
// fallow-ignore-next-line complexity
export function useMarqueeGestures(deps: MarqueeGesturesDeps) {
const marqueeRef = useRef<MarqueeState | null>(null);
const [marqueeRect, setMarqueeRect] = useState<{
left: number;
top: number;
width: number;
height: number;
} | null>(null);
const commitMarquee = useCallback(
async (
rect: { left: number; top: number; width: number; height: number },
additive: boolean,
) => {
const iframe = deps.iframeRef.current;
const overlay = deps.overlayRef.current;
if (!iframe || !overlay || !deps.onMarqueeSelectRef.current) return;
const acp = deps.activeCompositionPathRef.current ?? "index.html";
const hits = await runMarqueeIntersection(rect, iframe, overlay, acp);
deps.onMarqueeSelectRef.current(hits, additive);
},
[deps.iframeRef, deps.overlayRef, deps.onMarqueeSelectRef, deps.activeCompositionPathRef],
);
const onPointerMove = useCallback(
(event: React.PointerEvent<HTMLDivElement>) => {
const m = marqueeRef.current;
if (m) {
const oRect = deps.overlayRef.current?.getBoundingClientRect();
if (!oRect) return;
m.currentX = event.clientX - oRect.left;
m.currentY = event.clientY - oRect.top;
if (!m.pastThreshold) {
const dx = m.currentX - m.startX;
const dy = m.currentY - m.startY;
if (Math.hypot(dx, dy) < MARQUEE_THRESHOLD_PX) return;
m.pastThreshold = true;
}
setMarqueeRect({
left: Math.min(m.startX, m.currentX),
top: Math.min(m.startY, m.currentY),
width: Math.abs(m.currentX - m.startX),
height: Math.abs(m.currentY - m.startY),
});
return;
}
deps.gestures.onPointerMove(event);
},
[deps.gestures, deps.overlayRef],
);
const onPointerUp = useCallback(
(event: React.PointerEvent<HTMLDivElement>) => {
const m = marqueeRef.current;
if (m) {
marqueeRef.current = null;
try {
(event.currentTarget as HTMLElement).releasePointerCapture(m.pointerId);
} catch {
/* already released */
}
if (m.pastThreshold) {
commitMarquee(
{
left: Math.min(m.startX, m.currentX),
top: Math.min(m.startY, m.currentY),
width: Math.abs(m.currentX - m.startX),
height: Math.abs(m.currentY - m.startY),
},
event.shiftKey,
);
} else {
deps.onMarqueeSelectRef.current?.([], false);
}
setMarqueeRect(null);
return;
}
deps.gestures.onPointerUp(event);
},
[deps.gestures, commitMarquee, deps.onMarqueeSelectRef],
);
const onPointerCancel = useCallback(() => {
if (marqueeRef.current) {
marqueeRef.current = null;
setMarqueeRect(null);
return;
}
deps.gestures.clearPointerState(deps.selectionRef);
}, [deps.gestures, deps.selectionRef]);
return { marqueeRef, marqueeRect, onPointerMove, onPointerUp, onPointerCancel };
}