diff --git a/packages/studio/src/components/StudioPreviewArea.tsx b/packages/studio/src/components/StudioPreviewArea.tsx index b42a06837..45419a4b1 100644 --- a/packages/studio/src/components/StudioPreviewArea.tsx +++ b/packages/studio/src/components/StudioPreviewArea.tsx @@ -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} /> {STUDIO_KEYFRAMES_ENABLED && ( diff --git a/packages/studio/src/components/StudioRightPanel.tsx b/packages/studio/src/components/StudioRightPanel.tsx index 36bcc33ff..a7842a6f1 100644 --- a/packages/studio/src/components/StudioRightPanel.tsx +++ b/packages/studio/src/components/StudioRightPanel.tsx @@ -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} diff --git a/packages/studio/src/components/editor/DomEditOverlay.tsx b/packages/studio/src/components/editor/DomEditOverlay.tsx index 5e0f736dd..188d474dd 100644 --- a/packages/studio/src/components/editor/DomEditOverlay.tsx +++ b/packages/studio/src/components/editor/DomEditOverlay.tsx @@ -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(null); const boxRef = useRef(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>(new Map()); + const [offCanvasRects, setOffCanvasRects] = useState([]); + 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("[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(); + 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) => { @@ -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 && (