diff --git a/lefthook.yml b/lefthook.yml index 388adf68df..bf5a08090e 100644 --- a/lefthook.yml +++ b/lefthook.yml @@ -6,9 +6,10 @@ pre-commit: run: bunx oxlint --no-error-on-unmatched-pattern {staged_files} format: glob: "*.{js,jsx,ts,tsx,json,md,yaml,yml}" - # --no-error-on-unmatched-pattern: don't fail when staged files all - # fall under .prettierignore (e.g. docs-only changes to docs/docs.json). - run: bunx oxfmt --check --no-error-on-unmatched-pattern {staged_files} + # Auto-format and re-stage so the committed snapshot is always formatted. + # Replaces --check which only reports — that left unformatted files in + # commits when the hook ran after the amend snapshot was taken. + run: bunx oxfmt --no-error-on-unmatched-pattern {staged_files} && git add {staged_files} typecheck: glob: "*.{ts,tsx}" run: cd packages/core && bunx tsc --noEmit && cd ../studio && bunx tsc --noEmit diff --git a/packages/core/src/parsers/gsapParser.test.ts b/packages/core/src/parsers/gsapParser.test.ts index 3d785c6426..5ddc5889a6 100644 --- a/packages/core/src/parsers/gsapParser.test.ts +++ b/packages/core/src/parsers/gsapParser.test.ts @@ -1792,6 +1792,19 @@ describe("keyframe mutations", () => { expect(kf100.properties.y).toBe(50); }); + it("updateKeyframeInScript — ease-only update preserves existing properties", () => { + // Per-keyframe ease editing passes empty properties + an ease. The existing + // property bag must survive (don't wipe x/opacity when only the ease changes). + const id = getAnimId(KF_SCRIPT); + const updated = updateKeyframeInScript(KF_SCRIPT, id, 100, {}, "power2.inOut"); + const kf100 = parseGsapScript(updated).animations[0].keyframes!.keyframes.find( + (k) => k.percentage === 100, + )!; + expect(kf100.ease).toBe("power2.inOut"); + expect(kf100.properties.x).toBe(200); + expect(kf100.properties.opacity).toBe(1); + }); + // Array-form keyframes (`keyframes: [{x,y}, …]`) carry no percentages — GSAP // distributes them evenly. The motion-path overlay drags/adds by percentage, // which used to no-op on array-authored tweens (#puck-b / #shuttle). diff --git a/packages/core/src/parsers/gsapParser.ts b/packages/core/src/parsers/gsapParser.ts index a2f1047128..07f93798bb 100644 --- a/packages/core/src/parsers/gsapParser.ts +++ b/packages/core/src/parsers/gsapParser.ts @@ -1243,13 +1243,17 @@ function applyEaseUpdate(varsArg: AstNode, ease: string): void { } } -function applyUpdatesToCall(call: TweenCallInfo, updates: Partial): void { +function applyUpdatesToCall( + call: TweenCallInfo, + updates: Partial & { easeEach?: string }, +): void { if (updates.properties) reconcileEditableProperties(call.varsArg, updates.properties); if (updates.fromProperties && call.method === "fromTo" && call.fromArg) { reconcileEditableProperties(call.fromArg, updates.fromProperties); } if (updates.duration !== undefined) setVarsKey(call.varsArg, "duration", updates.duration); - if (updates.ease !== undefined) applyEaseUpdate(call.varsArg, updates.ease); + if (updates.easeEach !== undefined) applyEaseUpdate(call.varsArg, updates.easeEach); + else if (updates.ease !== undefined) applyEaseUpdate(call.varsArg, updates.ease); if (updates.position !== undefined) { const posIdx = call.method === "fromTo" ? 3 : 2; call.node.arguments[posIdx] = parseExpr(valueToCode(updates.position)); @@ -1282,10 +1286,13 @@ function insertAfterAnchor(parsed: ParsedGsapAst, newStatement: AstNode): void { function buildTweenStatementCode(timelineVar: string, anim: Omit): string { const selector = JSON.stringify(anim.targetSelector); const props: Record = { ...anim.properties }; - // `set` is instantaneous — GSAP ignores duration on it, so don't emit one. if (anim.method !== "set" && anim.duration !== undefined) props.duration = anim.duration; if (anim.ease) props.ease = anim.ease; const entries = Object.entries(props).map(([k, v]) => `${safeKey(k)}: ${valueToCode(v)}`); + // immediateRender forces GSAP to apply the set when added to the timeline, + // not on the first seek — without it, tl.set at position 0 on a paused + // timeline is invisible until the playhead moves past 0. + if (anim.method === "set") entries.push("immediateRender: true"); if (anim.extras) { for (const [k, v] of Object.entries(anim.extras)) { entries.push(`${safeKey(k)}: ${valueToCode(v as number | string)}`); @@ -1308,7 +1315,7 @@ function buildTweenStatementCode(timelineVar: string, anim: Omit, + updates: Partial & { easeEach?: string }, ): string { let parsed: ParsedGsapAst; try { @@ -1437,6 +1444,7 @@ export function addAnimationWithKeyframesToScript( auto?: boolean; }>, ease?: string, + easeEach?: string, ): { script: string; id: string } { let parsed: ParsedGsapAst; try { @@ -1450,7 +1458,7 @@ export function addAnimationWithKeyframesToScript( } const selector = JSON.stringify(targetSelector); - const kfCode = buildKeyframeObjectCode(keyframes); + const kfCode = buildKeyframeObjectCode(keyframes, easeEach ? { easeEach } : undefined); const varEntries = [`keyframes: ${kfCode}`, `duration: ${valueToCode(duration)}`]; if (ease) varEntries.push(`ease: ${JSON.stringify(ease)}`); const posCode = valueToCode(position); @@ -2216,6 +2224,27 @@ export function updateKeyframeInScript( const match = findKeyframePropByPct(kfNode, percentage); if (!match) return script; + if (Object.keys(properties).length === 0 && ease) { + // Ease-only update: preserve existing properties, just add/replace ease + const existing = match.prop.value; + if (existing?.type === "ObjectExpression") { + const props = (existing.properties ?? []) as AstNode[]; + const easeIdx = props.findIndex( + (p: AstNode) => isObjectProperty(p) && propKeyName(p) === "ease", + ); + const easeNode = parseExpr(`({ ease: ${JSON.stringify(ease)} })`).properties[0]; + if (easeIdx >= 0) { + props[easeIdx] = easeNode; + } else { + props.push(easeNode); + } + return recast.print(loc.parsed.ast).code; + } + // Non-object keyframe value (primitive shorthand, e.g. "50%": "0.5"): there + // is no property bag to merge the ease into. Rebuilding from empty + // `properties` would wipe the primitive — leave the keyframe untouched. + return script; + } match.prop.value = buildKeyframeValueNode(properties, ease); return recast.print(loc.parsed.ast).code; } diff --git a/packages/core/src/parsers/gsapWriterAcorn.ts b/packages/core/src/parsers/gsapWriterAcorn.ts index 568bc255ab..c4ef2998aa 100644 --- a/packages/core/src/parsers/gsapWriterAcorn.ts +++ b/packages/core/src/parsers/gsapWriterAcorn.ts @@ -299,7 +299,7 @@ function findInsertionPoint(parsed: ParsedGsapAcornForWrite): number | null { export function updateAnimationInScript( script: string, animationId: string, - updates: Partial, + updates: Partial & { easeEach?: string }, ): string { if (!Object.keys(updates).length) return script; const parsed = parseGsapScriptAcornForWrite(script); @@ -324,13 +324,11 @@ export function updateAnimationInScript( if (updates.duration !== undefined) { upsertProp(ms, call.varsArg, "duration", updates.duration); } - if (updates.ease !== undefined) { - // For a keyframe tween, easing lives at keyframes.easeEach (per-keyframe), - // not a top-level ease. Writing top-level ease would leave the per-keyframe - // easing unchanged — the user's edit would silently do nothing. + const easeValue = updates.easeEach ?? updates.ease; + if (easeValue !== undefined) { const kfNode = keyframesObjectNode(call.varsArg); - if (kfNode) upsertProp(ms, kfNode, "easeEach", updates.ease); - else upsertProp(ms, call.varsArg, "ease", updates.ease); + if (kfNode) upsertProp(ms, kfNode, "easeEach", easeValue); + else upsertProp(ms, call.varsArg, "ease", easeValue); } if (updates.extras) { for (const [key, value] of Object.entries(updates.extras)) { @@ -1338,6 +1336,7 @@ export function addAnimationWithKeyframesToScript( auto?: boolean; }>, ease?: string, + easeEach?: string, ): { script: string; id: string } { const parsed = parseGsapScriptAcornForWrite(script); if (!parsed) return { script, id: "" }; @@ -1345,7 +1344,7 @@ export function addAnimationWithKeyframesToScript( if (insertionPoint === null) return { script, id: "" }; const sorted = [...keyframes].sort((a, b) => a.percentage - b.percentage); - const kfObjCode = buildKeyframeObjectCode(sorted); + const kfObjCode = buildKeyframeObjectCode(sorted, easeEach); const varParts = [`keyframes: ${kfObjCode}`, `duration: ${valueToCode(duration)}`]; if (ease) varParts.push(`ease: ${JSON.stringify(ease)}`); const stmtCode = `${parsed.timelineVar}.to(${JSON.stringify(targetSelector)}, { ${varParts.join(", ")} }, ${valueToCode(position)});`; diff --git a/packages/core/src/runtime/init.ts b/packages/core/src/runtime/init.ts index d7db8c7782..5ed3dea2d8 100644 --- a/packages/core/src/runtime/init.ts +++ b/packages/core/src/runtime/init.ts @@ -72,6 +72,46 @@ export function initSandboxRuntimeModular(): void { } window.__timelines = window.__timelines || {}; + + // Resolve the root composition element with the same priority the rest of + // the runtime uses (explicit `data-root` marker first, then the topmost + // non-nested composition, then first in DOM order). Defined here so the + // array-normalization + data-start defaults below pick the same root the + // closure-based `resolveRootCompositionElement` does on multi-comp pages. + const findRootCompositionEl = (): HTMLElement | null => { + const explicitRoot = document.querySelector('[data-composition-id][data-root="true"]'); + if (explicitRoot instanceof HTMLElement) return explicitRoot; + const nodes = Array.from(document.querySelectorAll("[data-composition-id]")) as HTMLElement[]; + return ( + nodes.find((node) => !node.parentElement?.closest("[data-composition-id]")) ?? + nodes[0] ?? + null + ); + }; + + // Agents often write `window.__timelines = [tl]` (array) instead of the + // keyed-by-composition-id object the runtime expects. Normalize at init so + // the rest of the pipeline can assume a Record. + if (Array.isArray(window.__timelines)) { + const arr = window.__timelines as unknown[]; + const rootId = findRootCompositionEl()?.getAttribute("data-composition-id") ?? "root"; + const normalized: Record = {}; + if (arr.length === 1) { + normalized[rootId] = arr[0]; + } else { + for (let i = 0; i < arr.length; i++) normalized[`tl-${i}`] = arr[i]; + } + (window as Record).__timelines = normalized; + } + + // Agents sometimes omit data-start on the root composition element. The + // runtime skips timed-visibility for elements without it, making clips + // invisible and timelines non-seekable. Default to 0 for the root. + const rootComp = findRootCompositionEl(); + if (rootComp && !rootComp.hasAttribute("data-start")) { + rootComp.setAttribute("data-start", "0"); + } + const registerRuntimeCleanup = (callback: () => void) => { runtimeCleanupCallbacks.push(callback); }; @@ -218,23 +258,7 @@ export function initSandboxRuntimeModular(): void { return `${parsed}px`; }; - const resolveRootCompositionElement = (): HTMLElement | null => { - // 1. Explicit root marker takes priority - const explicitRoot = document.querySelector('[data-composition-id][data-root="true"]'); - if (explicitRoot instanceof HTMLElement) { - return explicitRoot; - } - // 3. Topmost composition element (not nested inside another) - const compositionNodes = Array.from( - document.querySelectorAll("[data-composition-id]"), - ) as HTMLElement[]; - if (compositionNodes.length === 0) return null; - return ( - compositionNodes.find((node) => !node.parentElement?.closest("[data-composition-id]")) ?? - compositionNodes[0] ?? - null - ); - }; + const resolveRootCompositionElement = (): HTMLElement | null => findRootCompositionEl(); const applyCompositionSizing = () => { const rootEl = resolveRootCompositionElement(); @@ -1003,16 +1027,38 @@ export function initSandboxRuntimeModular(): void { state.capturedTimeline.timeScale(state.playbackRate); } const boundDuration = getSafeTimelineDurationSeconds(state.capturedTimeline, 0); + if (boundDuration <= 0) { + // No resolvable duration (e.g. a set()-only timeline, or one whose + // duration isn't known yet). Kick GSAP off the creation position so the + // set() renders. For a finite-but-zero timeline progress(1) === progress(0); + // for an infinite-repeat timeline this lands on the first iteration's end + // frame, which is the best we can do without a known cycle length. + if (typeof state.capturedTimeline.progress === "function") { + state.capturedTimeline.progress(1, true); + state.capturedTimeline.progress(0, false); + state.capturedTimeline.pause(); + } + } if (boundDuration > 0) { try { clock.setDuration(boundDuration); } catch { // clock not yet initialized — duration will be set during TransportClock setup } - state.capturedTimeline.pause(); - const seekTime = Math.max(0, state.currentTime || 0); + if (typeof state.capturedTimeline.totalTime === "function") { + // GSAP won't render tl.set() at position 0 when the paused timeline + // starts there — play/pause/seek/totalTime are all no-ops at the + // creation position. Force the set to render by cycling progress past + // 0 (when the timeline implements it), then seek to the prior playhead + // (state.currentTime) so a rebind after a user scrub or soft-reload + // restore doesn't snap back to 0. + if (typeof state.capturedTimeline.progress === "function") { + state.capturedTimeline.progress(0.0001, true); + } + const seekTime = Math.max(0, state.currentTime || 0); state.capturedTimeline.totalTime(seekTime, false); + state.capturedTimeline.pause(); } // GSAP bakes the CSS `translate` into style.transform on seek. diff --git a/packages/studio/src/captions/hooks/useCaptionSync.ts b/packages/studio/src/captions/hooks/useCaptionSync.ts index ad5b052215..5fdbf80138 100644 --- a/packages/studio/src/captions/hooks/useCaptionSync.ts +++ b/packages/studio/src/captions/hooks/useCaptionSync.ts @@ -1,6 +1,7 @@ import { useCallback, useRef } from "react"; import { useCaptionStore } from "../store"; import { useMountEffect } from "../../hooks/useMountEffect"; +import { trackEvent } from "../../telemetry/client"; import type { CaptionStyle } from "../types"; interface CaptionOverrideEntry { @@ -78,7 +79,11 @@ export function useCaptionSync(projectId: string | null) { method: "PUT", headers: { "Content-Type": "text/plain" }, body: JSON.stringify(overrides, null, 2), - }).catch((err) => console.warn("[captions] auto-save failed:", err)); + }).catch((error: unknown) => { + // Caption auto-save is a data-loss path; surface failures via telemetry + // so a silently-dropped edit isn't invisible (no console in studio). + trackEvent("studio_caption_autosave_failed", { error: String(error) }); + }); }, []); // Auto-save on model changes with 800ms debounce diff --git a/packages/studio/src/components/StudioPreviewArea.tsx b/packages/studio/src/components/StudioPreviewArea.tsx index b42a06837d..45419a4b14 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 36bcc33ffe..a7842a6f16 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/BlockParamsPanel.tsx b/packages/studio/src/components/editor/BlockParamsPanel.tsx index a3da2772bc..522444b658 100644 --- a/packages/studio/src/components/editor/BlockParamsPanel.tsx +++ b/packages/studio/src/components/editor/BlockParamsPanel.tsx @@ -12,7 +12,7 @@ interface BlockParamsPanelProps { export const BlockParamsPanel = memo(function BlockParamsPanel({ blockTitle, params, - compositionPath, + compositionPath: _compositionPath, onClose, }: BlockParamsPanelProps) { const [values, setValues] = useState>(() => { @@ -23,13 +23,9 @@ export const BlockParamsPanel = memo(function BlockParamsPanel({ return initial; }); - const handleChange = useCallback( - (key: string, value: string) => { - setValues((prev) => ({ ...prev, [key]: value })); - console.log(`[BlockParams] ${compositionPath} ${key}: ${value}`); - }, - [compositionPath], - ); + const handleChange = useCallback((key: string, value: string) => { + setValues((prev) => ({ ...prev, [key]: value })); + }, []); return (
diff --git a/packages/studio/src/components/editor/DomEditOverlay.tsx b/packages/studio/src/components/editor/DomEditOverlay.tsx index 5e0f736dd4..188d474ddb 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 && (