mirror of
https://github.com/heygen-com/hyperframes.git
synced 2026-09-08 02:36:10 +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
@@ -17,7 +17,7 @@ import {
|
||||
STUDIO_PREVIEW_SELECTION_ENABLED,
|
||||
} from "./editor/manualEditingAvailability";
|
||||
import { useStudioPlaybackContext, useStudioShellContext } from "../contexts/StudioContext";
|
||||
import { useDomEditContext } from "../contexts/DomEditContext";
|
||||
import { useDomEditActionsContext, useDomEditSelectionContext } from "../contexts/DomEditContext";
|
||||
import { TimelineEditProvider } from "../contexts/TimelineEditContext";
|
||||
import type { BlockPreviewInfo } from "./sidebar/BlocksTab";
|
||||
import { readStudioUiPreferences } from "../utils/studioUiPreferences";
|
||||
@@ -117,6 +117,9 @@ export function StudioPreviewArea({
|
||||
domEditHoverSelection,
|
||||
domEditSelection,
|
||||
domEditGroupSelections,
|
||||
selectedGsapAnimations,
|
||||
} = useDomEditSelectionContext();
|
||||
const {
|
||||
handleTimelineElementSelect,
|
||||
handlePreviewCanvasMouseDown,
|
||||
handlePreviewCanvasPointerMove,
|
||||
@@ -128,15 +131,16 @@ export function StudioPreviewArea({
|
||||
handleDomGroupPathOffsetCommit,
|
||||
handleDomBoxSizeCommit,
|
||||
handleDomRotationCommit,
|
||||
selectedGsapAnimations,
|
||||
handleGsapRemoveKeyframe,
|
||||
handleGsapUpdateMeta,
|
||||
handleGsapAddKeyframe,
|
||||
handleGsapConvertToKeyframes,
|
||||
handleGsapDeleteAllForElement,
|
||||
buildDomSelectionForTimelineElement,
|
||||
} = useDomEditContext();
|
||||
applyMarqueeSelection,
|
||||
} = useDomEditActionsContext();
|
||||
|
||||
// fallow-ignore-next-line complexity
|
||||
const [snapPrefs, setSnapPrefs] = useState(() => {
|
||||
const p = readStudioUiPreferences();
|
||||
return {
|
||||
@@ -160,6 +164,7 @@ export function StudioPreviewArea({
|
||||
const rawId = elId.includes("#") ? (elId.split("#").pop() ?? elId) : elId;
|
||||
handleGsapDeleteAllForElement(`#${rawId}`);
|
||||
},
|
||||
// fallow-ignore-next-line complexity
|
||||
onDeleteKeyframe: (_elId: string, pct: number) => {
|
||||
const cacheKey = domEditSelection?.id ?? "";
|
||||
const cached = usePlayerStore.getState().keyframeCache.get(cacheKey);
|
||||
@@ -215,6 +220,7 @@ export function StudioPreviewArea({
|
||||
}
|
||||
}
|
||||
},
|
||||
// fallow-ignore-next-line complexity
|
||||
onToggleKeyframeAtPlayhead: (el: TimelineElement) => {
|
||||
const currentTime = usePlayerStore.getState().currentTime;
|
||||
const pct =
|
||||
@@ -339,6 +345,7 @@ export function StudioPreviewArea({
|
||||
gridSpacing={snapPrefs.gridSpacing}
|
||||
recordingState={recordingState}
|
||||
onToggleRecording={onToggleRecording}
|
||||
onMarqueeSelect={applyMarqueeSelection}
|
||||
/>
|
||||
<SnapToolbar onSnapChange={setSnapPrefs} />
|
||||
{STUDIO_KEYFRAMES_ENABLED && (
|
||||
|
||||
@@ -121,6 +121,7 @@ export function StudioRightPanel({
|
||||
handleSetArcPath,
|
||||
handleUpdateArcSegment,
|
||||
handleUnroll,
|
||||
handleUpdateKeyframeEase,
|
||||
handleGsapAddKeyframe,
|
||||
handleGsapRemoveKeyframe,
|
||||
handleGsapConvertToKeyframes,
|
||||
@@ -274,6 +275,7 @@ export function StudioRightPanel({
|
||||
onSetArcPath={handleSetArcPath}
|
||||
onUpdateArcSegment={handleUpdateArcSegment}
|
||||
onUnroll={handleUnroll}
|
||||
onUpdateKeyframeEase={handleUpdateKeyframeEase}
|
||||
recordingState={recordingState}
|
||||
recordingDuration={recordingDuration}
|
||||
onToggleRecording={onToggleRecording}
|
||||
|
||||
@@ -1,7 +1,10 @@
|
||||
import { memo, useMemo, useRef, useState, type RefObject } from "react";
|
||||
import { memo, useEffect, useMemo, useRef, useState, type RefObject } from "react";
|
||||
import { useMountEffect } from "../../hooks/useMountEffect";
|
||||
import { type DomEditSelection } from "./domEditing";
|
||||
import { resolveDomEditGroupOverlayRect } from "./domEditOverlayGeometry";
|
||||
import { useMarqueeGestures } from "./marqueeCommit";
|
||||
import { resolveDomEditGroupOverlayRect, toOverlayRect } from "./domEditOverlayGeometry";
|
||||
import { collectDomEditLayerItems } from "./domEditingLayers";
|
||||
import { isElementComputedVisible } from "./domEditingElement";
|
||||
import {
|
||||
type BlockedMoveState,
|
||||
type DomEditGroupPathOffsetCommit,
|
||||
@@ -11,6 +14,7 @@ import {
|
||||
focusDomEditOverlayElement,
|
||||
} from "./domEditOverlayGestures";
|
||||
import { useDomEditOverlayRects } from "./useDomEditOverlayRects";
|
||||
import { OffCanvasIndicators, type OffCanvasRect } from "./OffCanvasIndicators";
|
||||
import { createDomEditOverlayGestureHandlers } from "./useDomEditOverlayGestures";
|
||||
import { SnapGuideOverlay, type SnapGuidesState } from "./SnapGuideOverlay";
|
||||
import { GridOverlay } from "./GridOverlay";
|
||||
@@ -67,8 +71,10 @@ interface DomEditOverlayProps {
|
||||
gridSpacing?: number;
|
||||
recordingState?: GestureRecordingState;
|
||||
onToggleRecording?: () => void;
|
||||
onMarqueeSelect?: (selections: DomEditSelection[], additive: boolean) => void;
|
||||
}
|
||||
|
||||
// fallow-ignore-next-line complexity
|
||||
export const DomEditOverlay = memo(function DomEditOverlay({
|
||||
iframeRef,
|
||||
activeCompositionPath,
|
||||
@@ -88,13 +94,16 @@ export const DomEditOverlay = memo(function DomEditOverlay({
|
||||
onGroupPathOffsetCommit,
|
||||
onBoxSizeCommit,
|
||||
onRotationCommit,
|
||||
onMarqueeSelect,
|
||||
}: DomEditOverlayProps) {
|
||||
const overlayRef = useRef<HTMLDivElement | null>(null);
|
||||
const boxRef = useRef<HTMLDivElement | null>(null);
|
||||
const onMarqueeSelectRef = useRef(onMarqueeSelect);
|
||||
onMarqueeSelectRef.current = onMarqueeSelect;
|
||||
|
||||
const selectionShapeStyles = (() => {
|
||||
const fallback = {
|
||||
borderRadius: 4 as string | number,
|
||||
borderRadius: 8 as string | number,
|
||||
clipPath: undefined as string | undefined,
|
||||
};
|
||||
if (!selection?.element) return fallback;
|
||||
@@ -213,6 +222,50 @@ export const DomEditOverlay = memo(function DomEditOverlay({
|
||||
return () => cancelAnimationFrame(frame);
|
||||
});
|
||||
|
||||
// Off-canvas element indicators — dashed outlines for elements positioned
|
||||
// outside the composition bounds so users can find them.
|
||||
const offCanvasElementsRef = useRef<Map<string, HTMLElement>>(new Map());
|
||||
const [offCanvasRects, setOffCanvasRects] = useState<OffCanvasRect[]>([]);
|
||||
useEffect(() => {
|
||||
const iframe = iframeRef.current;
|
||||
const overlay = overlayRef.current;
|
||||
if (!iframe || !overlay || compRect.width <= 0) {
|
||||
setOffCanvasRects([]);
|
||||
return;
|
||||
}
|
||||
const doc = iframe.contentDocument;
|
||||
if (!doc) return;
|
||||
const root = doc.querySelector<HTMLElement>("[data-composition-id]") ?? doc.body;
|
||||
const acp = activeCompositionPath ?? "index.html";
|
||||
const items = collectDomEditLayerItems(root, {
|
||||
activeCompositionPath: acp,
|
||||
isMasterView: !acp || acp === "index.html",
|
||||
});
|
||||
const rects: typeof offCanvasRects = [];
|
||||
const elMap = new Map<string, HTMLElement>();
|
||||
for (const item of items) {
|
||||
if (!isElementComputedVisible(item.element)) continue;
|
||||
const r = toOverlayRect(overlay, iframe, item.element);
|
||||
if (!r) continue;
|
||||
// Any edge crossing the composition border → gray-zone indicator (the
|
||||
// in-canvas portion is clipped away below, so only the sliver shows).
|
||||
const extendsOutsideComp =
|
||||
r.left < compRect.left ||
|
||||
r.left + r.width > compRect.left + compRect.width ||
|
||||
r.top < compRect.top ||
|
||||
r.top + r.height > compRect.top + compRect.height;
|
||||
if (extendsOutsideComp) {
|
||||
rects.push({ key: item.key, left: r.left, top: r.top, width: r.width, height: r.height });
|
||||
elMap.set(item.key, item.element);
|
||||
}
|
||||
}
|
||||
offCanvasElementsRef.current = elMap;
|
||||
setOffCanvasRects(rects);
|
||||
// Positions depend on layout, not selection — the selected-element
|
||||
// suppression is a render-time filter, so selection/groupSelections stay
|
||||
// out of the deps to avoid re-walking geometry on each selection change.
|
||||
}, [iframeRef, compRect, activeCompositionPath]);
|
||||
|
||||
const gestures = createDomEditOverlayGestureHandlers({
|
||||
overlayRef,
|
||||
iframeRef,
|
||||
@@ -238,6 +291,15 @@ export const DomEditOverlay = memo(function DomEditOverlay({
|
||||
snapGuidesRef,
|
||||
});
|
||||
|
||||
const marquee = useMarqueeGestures({
|
||||
iframeRef,
|
||||
overlayRef,
|
||||
activeCompositionPathRef,
|
||||
onMarqueeSelectRef,
|
||||
selectionRef,
|
||||
gestures,
|
||||
});
|
||||
|
||||
const selectionKey = useMemo(() => {
|
||||
if (!selection) return "none";
|
||||
return `${selection.sourceFile}:${selection.id ?? selection.selector ?? selection.label}:${selection.selectorIndex ?? 0}`;
|
||||
@@ -265,22 +327,9 @@ export const DomEditOverlay = memo(function DomEditOverlay({
|
||||
}
|
||||
const target = event.target as HTMLElement | null;
|
||||
if (target?.closest('[data-dom-edit-selection-box="true"]')) return;
|
||||
// Don't re-resolve selection when clicking outside the composition bounds —
|
||||
// the iframe can't resolve elements there, so it would clear the selection.
|
||||
if (selection && compRect.width > 0) {
|
||||
const overlayEl = overlayRef.current;
|
||||
if (overlayEl) {
|
||||
const overlayRect = overlayEl.getBoundingClientRect();
|
||||
const clickX = event.clientX - overlayRect.left;
|
||||
const clickY = event.clientY - overlayRect.top;
|
||||
const outsideComp =
|
||||
clickX < compRect.left ||
|
||||
clickX > compRect.left + compRect.width ||
|
||||
clickY < compRect.top ||
|
||||
clickY > compRect.top + compRect.height;
|
||||
if (outsideComp) 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.
|
||||
onCanvasMouseDown(event, { preferClipAncestor: false });
|
||||
if (event.shiftKey) {
|
||||
suppressNextBoxMouseDownRef.current = true;
|
||||
@@ -306,6 +355,36 @@ export const DomEditOverlay = memo(function DomEditOverlay({
|
||||
|
||||
const target = event.target as HTMLElement | null;
|
||||
if (target?.closest('[data-dom-edit-selection-box="true"]')) return;
|
||||
|
||||
// Start marquee if clicking on empty canvas (no element under pointer)
|
||||
if (!hoverSelectionRef.current && onMarqueeSelectRef.current && compRect.width > 0) {
|
||||
const overlayEl = overlayRef.current;
|
||||
if (overlayEl) {
|
||||
const oRect = overlayEl.getBoundingClientRect();
|
||||
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;
|
||||
}
|
||||
}
|
||||
}
|
||||
};
|
||||
|
||||
const handleBoxClick = (event: React.MouseEvent<HTMLDivElement>) => {
|
||||
@@ -332,44 +411,29 @@ export const DomEditOverlay = memo(function DomEditOverlay({
|
||||
className="absolute inset-0 z-10 pointer-events-auto outline-none"
|
||||
tabIndex={-1}
|
||||
aria-label="Composition canvas"
|
||||
// Cursor follows marquee rect *state* (re-renders), not the mutable ref.
|
||||
style={marquee.marqueeRect ? { cursor: "crosshair" } : undefined}
|
||||
onPointerDownCapture={(event) =>
|
||||
focusDomEditOverlayElement(event.currentTarget as FocusableDomEditOverlay)
|
||||
}
|
||||
onPointerDown={handleOverlayPointerDown}
|
||||
onMouseDown={handleOverlayMouseDown}
|
||||
onPointerMove={gestures.onPointerMove}
|
||||
onPointerMove={marquee.onPointerMove}
|
||||
onPointerLeave={() => onCanvasPointerLeaveRef.current()}
|
||||
onPointerUp={gestures.onPointerUp}
|
||||
onPointerCancel={() => gestures.clearPointerState(selectionRef)}
|
||||
onPointerUp={marquee.onPointerUp}
|
||||
onPointerCancel={marquee.onPointerCancel}
|
||||
>
|
||||
{hoverSelection && hoverRect && compRect.width > 0 && (
|
||||
<div
|
||||
aria-hidden="true"
|
||||
data-dom-edit-hover-box="true"
|
||||
className="pointer-events-none absolute border border-studio-accent/80 bg-studio-accent/5 shadow-[0_0_0_1px_rgba(60,230,172,0.25)]"
|
||||
style={(() => {
|
||||
let br: string | number = 4;
|
||||
let cp: string | undefined;
|
||||
try {
|
||||
const el = hoverSelection.element;
|
||||
const tag = el.tagName.toLowerCase();
|
||||
if (tag !== "svg" && tag !== "img" && tag !== "video" && tag !== "canvas") {
|
||||
const cs = el.ownerDocument.defaultView?.getComputedStyle(el);
|
||||
if (cs?.borderRadius && cs.borderRadius !== "0px") br = cs.borderRadius;
|
||||
if (cs?.clipPath && cs.clipPath !== "none") cp = cs.clipPath;
|
||||
}
|
||||
} catch {
|
||||
/* cross-origin guard */
|
||||
}
|
||||
return {
|
||||
left: hoverRect.left,
|
||||
top: hoverRect.top,
|
||||
width: hoverRect.width,
|
||||
height: hoverRect.height,
|
||||
borderRadius: br,
|
||||
clipPath: cp,
|
||||
};
|
||||
})()}
|
||||
className="pointer-events-none absolute rounded-md border border-studio-accent/80 bg-studio-accent/5 shadow-[0_0_0_1px_rgba(60,230,172,0.25)]"
|
||||
style={{
|
||||
left: hoverRect.left,
|
||||
top: hoverRect.top,
|
||||
width: hoverRect.width,
|
||||
height: hoverRect.height,
|
||||
}}
|
||||
/>
|
||||
)}
|
||||
{hasGroupSelection && groupOverlayItems.length > 1 && groupBounds && compRect.width > 0 && (
|
||||
@@ -437,13 +501,12 @@ export const DomEditOverlay = memo(function DomEditOverlay({
|
||||
key={selectionKey}
|
||||
ref={boxRef}
|
||||
data-dom-edit-selection-box="true"
|
||||
className={`pointer-events-auto absolute ${selectionShapeStyles.clipPath ? "shadow-[inset_0_0_0_2px_rgba(60,230,172,0.6)]" : "border border-studio-accent/80 shadow-[0_0_0_1px_rgba(60,230,172,0.25)]"} bg-studio-accent/5`}
|
||||
className={`pointer-events-auto absolute rounded-md ${selectionShapeStyles.clipPath ? "shadow-[inset_0_0_0_2px_rgba(60,230,172,0.6)]" : "border border-studio-accent/80 shadow-[0_0_0_1px_rgba(60,230,172,0.25)]"} bg-studio-accent/5`}
|
||||
style={{
|
||||
left: overlayRect.left,
|
||||
top: overlayRect.top,
|
||||
width: overlayRect.width,
|
||||
height: overlayRect.height,
|
||||
borderRadius: selectionShapeStyles.borderRadius,
|
||||
clipPath: selectionShapeStyles.clipPath,
|
||||
cursor:
|
||||
allowCanvasMovement && selection.capabilities.canApplyManualOffset
|
||||
@@ -496,6 +559,27 @@ export const DomEditOverlay = memo(function DomEditOverlay({
|
||||
}}
|
||||
/>
|
||||
))}
|
||||
<OffCanvasIndicators
|
||||
rects={offCanvasRects}
|
||||
elements={offCanvasElementsRef}
|
||||
compRect={compRect}
|
||||
selection={selection}
|
||||
groupSelections={groupSelections}
|
||||
activeCompositionPathRef={activeCompositionPathRef}
|
||||
onSelectionChangeRef={onSelectionChangeRef}
|
||||
/>
|
||||
{marquee.marqueeRect && (
|
||||
<div
|
||||
aria-hidden="true"
|
||||
className="pointer-events-none absolute border border-dashed border-studio-accent bg-studio-accent/10"
|
||||
style={{
|
||||
left: marquee.marqueeRect.left,
|
||||
top: marquee.marqueeRect.top,
|
||||
width: marquee.marqueeRect.width,
|
||||
height: marquee.marqueeRect.height,
|
||||
}}
|
||||
/>
|
||||
)}
|
||||
<GridOverlay
|
||||
visible={gridVisible}
|
||||
spacing={gridSpacing}
|
||||
|
||||
@@ -0,0 +1,111 @@
|
||||
import React from "react";
|
||||
import { type DomEditSelection } from "./domEditing";
|
||||
|
||||
export interface OffCanvasRect {
|
||||
key: string;
|
||||
left: number;
|
||||
top: number;
|
||||
width: number;
|
||||
height: number;
|
||||
}
|
||||
|
||||
interface OffCanvasIndicatorsProps {
|
||||
rects: OffCanvasRect[];
|
||||
elements: React.MutableRefObject<Map<string, HTMLElement>>;
|
||||
compRect: { left: number; top: number; width: number; height: number };
|
||||
selection: DomEditSelection | null;
|
||||
groupSelections: DomEditSelection[];
|
||||
activeCompositionPathRef: React.MutableRefObject<string | null>;
|
||||
onSelectionChangeRef: React.MutableRefObject<
|
||||
(selection: DomEditSelection, options?: { revealPanel?: boolean; additive?: boolean }) => void
|
||||
>;
|
||||
}
|
||||
|
||||
/**
|
||||
* Dashed teal indicators for elements whose bounds extend past the composition
|
||||
* (the "gray zone"). The in-canvas portion is clipped away so only the
|
||||
* protruding sliver is dashed; the inside portion gets a solid outline.
|
||||
* Extracted from DomEditOverlay to keep that file under the 600-LOC cap.
|
||||
*/
|
||||
export function OffCanvasIndicators({
|
||||
rects,
|
||||
elements,
|
||||
compRect,
|
||||
selection,
|
||||
groupSelections,
|
||||
activeCompositionPathRef,
|
||||
onSelectionChangeRef,
|
||||
}: OffCanvasIndicatorsProps): React.ReactElement {
|
||||
return (
|
||||
<>
|
||||
{rects
|
||||
.filter((r) => {
|
||||
// Suppress the indicator for any currently-selected element (primary
|
||||
// OR a marquee group member) — those already render a selection box.
|
||||
const el = elements.current.get(r.key);
|
||||
if (!el) return true;
|
||||
if (selection?.element === el) return false;
|
||||
return !groupSelections.some((g) => g.element === el);
|
||||
})
|
||||
.map((r) => {
|
||||
const pos = { left: r.left, top: r.top, width: r.width, height: r.height };
|
||||
const cL = Math.max(0, compRect.left - r.left);
|
||||
const cT = Math.max(0, compRect.top - r.top);
|
||||
const cR = Math.min(r.width, compRect.left + compRect.width - r.left);
|
||||
const cB = Math.min(r.height, compRect.top + compRect.height - r.top);
|
||||
const hasInside = cL < cR && cT < cB;
|
||||
const clipOutside = hasInside
|
||||
? `polygon(evenodd, 0 0, ${r.width}px 0, ${r.width}px ${r.height}px, 0 ${r.height}px, 0 0, ${cL}px ${cT}px, ${cR}px ${cT}px, ${cR}px ${cB}px, ${cL}px ${cB}px, ${cL}px ${cT}px)`
|
||||
: undefined;
|
||||
const clipInside = `inset(${cT}px ${Math.max(0, r.width - cR)}px ${Math.max(0, r.height - cB)}px ${cL}px round 6px)`;
|
||||
const selectOffCanvas = async () => {
|
||||
const el = elements.current.get(r.key);
|
||||
if (!el) return;
|
||||
const { resolveDomEditSelection } = await import("./domEditingLayers");
|
||||
const acp = activeCompositionPathRef.current ?? "index.html";
|
||||
const sel = await resolveDomEditSelection(el, {
|
||||
activeCompositionPath: acp,
|
||||
isMasterView: !acp || acp === "index.html",
|
||||
skipSourceProbe: true,
|
||||
});
|
||||
if (sel) onSelectionChangeRef.current(sel, { revealPanel: true });
|
||||
};
|
||||
const handleClick = (e: React.MouseEvent) => {
|
||||
e.stopPropagation();
|
||||
void selectOffCanvas();
|
||||
};
|
||||
return (
|
||||
<div key={`offcanvas-${r.key}`} className="pointer-events-none absolute" style={pos}>
|
||||
{/* Dashed layer — clipped to exclude canvas area.
|
||||
Note: clip-path is visual only — hit-testing still covers the
|
||||
full bounding rect, so clicking the in-canvas portion selects
|
||||
via this handler. That's acceptable: it resolves the same
|
||||
element the normal canvas path would, just with
|
||||
skipSourceProbe (the element is already known here). */}
|
||||
<div
|
||||
role="button"
|
||||
tabIndex={0}
|
||||
aria-label={`Select off-canvas element ${r.key}`}
|
||||
className="pointer-events-auto absolute inset-0 border-2 border-dashed border-studio-accent/60 rounded-md cursor-pointer hover:border-studio-accent hover:bg-studio-accent/10 transition-colors"
|
||||
style={clipOutside ? { clipPath: clipOutside } : undefined}
|
||||
title={`Off-canvas: ${r.key} — click to select`}
|
||||
onClick={handleClick}
|
||||
onKeyDown={(e) => {
|
||||
if (e.key === "Enter" || e.key === " ") {
|
||||
e.preventDefault();
|
||||
e.stopPropagation();
|
||||
void selectOffCanvas();
|
||||
}
|
||||
}}
|
||||
/>
|
||||
{/* Solid layer — clipped to canvas bounds, covers inside portion */}
|
||||
<div
|
||||
className="pointer-events-none absolute inset-0 border border-studio-accent/80 rounded-md bg-studio-accent/5 shadow-[0_0_0_1px_rgba(60,230,172,0.25)]"
|
||||
style={{ clipPath: clipInside }}
|
||||
/>
|
||||
</div>
|
||||
);
|
||||
})}
|
||||
</>
|
||||
);
|
||||
}
|
||||
@@ -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 };
|
||||
}
|
||||
@@ -66,6 +66,7 @@ export interface PropertyPanelProps {
|
||||
value: number | string,
|
||||
) => void;
|
||||
onRemoveKeyframe?: (animationId: string, percentage: number) => void;
|
||||
onUpdateKeyframeEase?: (animationId: string, percentage: number, ease: string) => void;
|
||||
onConvertToKeyframes?: (animationId: string) => void;
|
||||
onCommitAnimatedProperty?: (
|
||||
selection: DomEditSelection,
|
||||
|
||||
Reference in New Issue
Block a user