diff --git a/.fallowrc.jsonc b/.fallowrc.jsonc index ba95b95ebe..5b599aecab 100644 --- a/.fallowrc.jsonc +++ b/.fallowrc.jsonc @@ -44,7 +44,7 @@ // Keyframe UI components — wired dynamically via EaseCurveSection/MotionPanel. "packages/studio/src/components/editor/KeyframeDiamond.tsx", "packages/studio/src/components/editor/SpringEaseEditor.tsx", - // NLE notice — rendered conditionally via NLELayout when timeline is first shown. + // NLE notice — rendered conditionally via NLEContext/EditorShell when timeline is first shown. "packages/studio/src/components/nle/TimelineEditorNotice.tsx", // Zoom hook extracted for downstream razor-blade PRs (#1330, #1331). "packages/studio/src/player/components/useTimelineZoom.ts", @@ -53,36 +53,6 @@ "packages/studio/src/hooks/gsapTargetCache.ts", // Preview helper consumed dynamically from the studio iframe bridge. "packages/studio/src/hooks/gsapRuntimePreview.ts", - // TEMP(studio-dnd): shipped unwired ahead of the NLE integration; - // the app-shell swap PR (studio-dnd/pr22) wires the consumers and removes this block. - "packages/studio/src/components/EditorShell.tsx", - "packages/studio/src/components/nle/TimelinePane.tsx", - "packages/studio/src/components/nle/useTimelineEditCallbacks.ts", - // TEMP(studio-dnd): shipped unwired ahead of the NLE integration; - // the app-shell swap PR (studio-dnd/pr22) wires the consumers and removes this block. - "packages/studio/src/components/editor/DomEditSelectionChrome.tsx", - "packages/studio/src/components/editor/useDomEditNudge.ts", - "packages/studio/src/components/nle/PreviewOverlays.tsx", - // TEMP(studio-dnd): shipped unwired ahead of the NLE integration; - // the app-shell swap PR (studio-dnd/pr22) wires the consumers and removes this block. - "packages/studio/src/player/components/TimelineLanes.tsx", - "packages/studio/src/player/components/useTimelineStackingSync.ts", - "packages/studio/src/player/components/useTimelineGeometry.ts", - "packages/studio/src/player/components/useTimelineEditPinning.ts", - "packages/studio/src/hooks/useTimelineEditingDrops.ts", - // TEMP(studio-dnd): shipped unwired ahead of the NLE integration; - // the app-shell swap PR (studio-dnd/pr22) wires the consumers and removes this block. - "packages/studio/src/components/nle/NLEContext.tsx", - "packages/studio/src/components/nle/PreviewPane.tsx", - "packages/studio/src/components/nle/AssetPreviewOverlay.tsx", - "packages/studio/src/player/components/TimelineOverlays.tsx", - "packages/studio/src/player/components/timelineClipChildren.tsx", - "packages/studio/src/player/components/timelineClipDragTypes.ts", - "packages/studio/src/player/hooks/useTimelinePlayerLoop.ts", - "packages/studio/src/utils/assetPreviewStore.ts", - // TEMP(studio-dnd): shipped unwired ahead of the NLE integration; - // the app-shell swap PR (studio-dnd/pr22) wires the consumers and removes this block. - "packages/studio/src/components/editor/CanvasContextMenu.tsx", ], "ignorePatterns": [ "docs/**", @@ -120,124 +90,6 @@ "packages/cli/src/cloud/_gen/**", ], "ignoreExports": [ - // TEMP(studio-dnd): consumers land later in the stack; removed by studio-dnd/pr22. - { - "file": "packages/studio/src/hooks/timelineElementsMove.ts", - "exports": ["useTimelineElementsMove"], - }, - // TEMP(studio-dnd): consumers land later in the stack; removed by studio-dnd/pr22. - { - "file": "packages/studio/src/player/components/timelineClipDragPreview.ts", - "exports": ["computeDragPreview", "computeResizePreview"], - }, - // TEMP(studio-dnd): consumers land later in the stack; removed by studio-dnd/pr22. - { - "file": "packages/studio/src/player/lib/timelineElementHelpers.ts", - "exports": ["furthestClipEndFromDocument"], - }, - // TEMP(studio-dnd): consumers land later in the stack; removed by studio-dnd/pr22. - { - "file": "packages/studio/src/hooks/timelineEditingHelpers.ts", - "exports": ["patchIframeRootDuration"], - }, - // TEMP(studio-dnd): consumers land later in the stack; removed by studio-dnd/pr22. - { - "file": "packages/studio/src/components/sidebar/AssetContextMenu.tsx", - "exports": ["DeleteConfirm"], - }, - // TEMP(studio-dnd): consumers land later in the stack; removed by studio-dnd/pr22. - { - "file": "packages/studio/src/components/editor/domEditOverlayGeometry.ts", - "exports": ["overlayCornersCentroid", "elementCornerOverlayPoints", "orientedOverlayRect"], - }, - // TEMP(studio-dnd): consumers land later in the stack; removed by studio-dnd/pr22. - { - "file": "packages/studio/src/contexts/DomEditContext.tsx", - "exports": ["useDomEditActionsContextOptional"], - }, - // TEMP(studio-dnd): consumers land later in the stack; removed by studio-dnd/pr22. - { - "file": "packages/studio/src/contexts/StudioContext.tsx", - "exports": ["useStudioShellContextOptional"], - }, - // TEMP(studio-dnd): consumers land later in the stack; removed by studio-dnd/pr22. - { - "file": "packages/studio/src/components/editor/domEditOverlayGestures.ts", - "exports": ["ResizeHandle"], - }, - // TEMP(studio-dnd): consumers land later in the stack; removed by studio-dnd/pr22. - { - "file": "packages/studio/src/components/sidebar/assetHelpers.ts", - "exports": ["truncateMiddle", "formatDuration"], - }, - // TEMP(studio-dnd): consumers land later in the stack; removed by studio-dnd/pr22. - { - "file": "packages/studio/src/utils/studioHelpers.ts", - "exports": ["resolveDroppedAssetDimensions"], - }, - // TEMP(studio-dnd): consumers land later in the stack; removed by studio-dnd/pr22. - { - "file": "packages/studio/src/player/lib/timelineElementHelpers.ts", - "exports": ["furthestClipEndFromSource", "readTimelineElementZIndex"], - }, - // TEMP(studio-dnd): consumers land later in the stack; removed by studio-dnd/pr22. - { - "file": "packages/studio/src/components/editor/manualOffsetDrag.ts", - "exports": ["applyManualOffsetNudgeDraft", "applyManualOffsetNudgeCommit"], - }, - // TEMP(studio-dnd): consumers land later in the stack; removed by studio-dnd/pr22. - { - "file": "packages/studio/src/player/components/timelineEditing.ts", - "exports": [ - "applyTimelineAutoScrollStep", - "resolveTimelineAutoScrollLoopAction", - "resolveTimelineDragEscape", - "TimelineDragEscapeInput", - ], - }, - // TEMP(studio-dnd): consumers land later in the stack; removed by studio-dnd/pr22. - { - "file": "packages/studio/src/hooks/timelineEditingHelpers.ts", - "exports": [ - "shiftGsapPositionsBatch", - "syncTimingEditPreview", - "GsapMutationOutcome", - "readGsapMutationScriptText", - ], - }, - // TEMP(studio-dnd): consumers land later in the stack; removed by studio-dnd/pr22. - { - "file": "packages/studio/src/utils/timelineAssetDrop.ts", - "exports": [ - "setCompositionDurationToContent", - "extendCompositionDurationIfNeeded", - "fitTimelineAssetGeometry", - "resolveTimelineAssetCompositionSize", - ], - }, - // TEMP(studio-dnd): consumers land later in the stack; removed by studio-dnd/pr22. - { - "file": "packages/studio/src/player/components/timelineLayout.ts", - "exports": [ - "getTimelineRowTop", - "getTimelineRowFromY", - "DRAG_EXTEND_MARGIN_PX", - "getTimelineFitPps", - "getTimelineDisplayContentWidth", - "TRACKS_TOP_PAD", - "MIN_TIMELINE_EXTENT_S", - ], - }, - // TEMP(studio-dnd): consumers land later in the stack; removed by studio-dnd/pr22. - { - "file": "packages/studio/src/components/editor/domEditNudge.ts", - "exports": ["CANVAS_NUDGE_COMMIT_DEBOUNCE_MS"], - }, - // TEMP(studio-dnd): consumers land later in the stack; removed by studio-dnd/pr22. - { - "file": "packages/studio/src/components/editor/canvasContextMenuZOrder.ts", - "exports": ["readEffectiveZIndex"], - }, // drawElementService is the bottom of the fast-capture Graphite stack // (#1917): its consumers (frameCapture in #1919) land two PRs upstack, so // a per-PR audit diffing against the merge base sees these exports as @@ -456,84 +308,6 @@ // require intrusive middleware changes beyond this PR's scope. "minLines": 6, "ignore": [ - // TEMP(studio-dnd): coexistence-window clone (extraction vs still-live original); - // studio-dnd pr22 removes this with the final config. - "packages/studio/src/components/StudioPreviewArea.tsx", - // TEMP(studio-dnd): coexistence-window clone (extraction vs still-live original); - // studio-dnd pr22 removes this with the final config. - "packages/studio/src/components/nle/useTimelineEditCallbacks.ts", - // TEMP(studio-dnd): coexistence-window clone (extraction vs still-live original); - // studio-dnd pr22 removes this with the final config. - "packages/studio/src/components/EditorShell.tsx", - // TEMP(studio-dnd): coexistence-window clone (extraction vs still-live original); - // studio-dnd pr22 removes this with the final config. - "packages/studio/src/components/nle/PreviewOverlays.tsx", - // TEMP(studio-dnd): coexistence-window clone (extraction vs still-live original); - // studio-dnd pr22 removes this with the final config. - "packages/studio/src/components/editor/DomEditOverlay.tsx", - // TEMP(studio-dnd): coexistence-window clone (extraction vs still-live original); - // studio-dnd pr22 removes this with the final config. - "packages/studio/src/components/editor/DomEditSelectionChrome.tsx", - // TEMP(studio-dnd): coexistence-window clone (extraction vs still-live original); - // studio-dnd pr22 removes this with the final config. - "packages/studio/src/hooks/useTimelineEditing.ts", - // TEMP(studio-dnd): coexistence-window clone (extraction vs still-live original); - // studio-dnd pr22 removes this with the final config. - "packages/studio/src/hooks/useTimelineEditingDrops.ts", - // TEMP(studio-dnd): coexistence-window clone (extraction vs still-live original); - // studio-dnd pr22 removes this with the final config. - "packages/studio/src/utils/timelineAssetDrop.ts", - // TEMP(studio-dnd): coexistence-window clone (extraction vs still-live original); - // studio-dnd pr22 removes this with the final config. - "packages/studio/src/components/nle/NLEContext.test.ts", - // TEMP(studio-dnd): coexistence-window clone (extraction vs still-live original); - // studio-dnd pr22 removes this with the final config. - "packages/studio/src/components/nle/NLELayout.test.ts", - // TEMP(studio-dnd): coexistence-window clone (extraction vs still-live original); - // studio-dnd pr22 removes this with the final config. - "packages/studio/src/components/editor/domEditOverlayGeometry.ts", - // TEMP(studio-dnd): coexistence-window clone (extraction vs still-live original); - // studio-dnd pr22 removes this with the final config. - "packages/studio/src/components/nle/NLEContext.tsx", - // TEMP(studio-dnd): coexistence-window clone (extraction vs still-live original); - // studio-dnd pr22 removes this with the final config. - "packages/studio/src/components/nle/NLELayout.tsx", - // TEMP(studio-dnd): coexistence-window clone (extraction vs still-live original); - // studio-dnd pr22 removes this with the final config. - "packages/studio/src/components/nle/PreviewPane.tsx", - // TEMP(studio-dnd): coexistence-window clone (extraction vs still-live original); - // studio-dnd pr22 removes this with the final config. - "packages/studio/src/components/sidebar/AssetContextMenu.tsx", - // TEMP(studio-dnd): coexistence-window clone (extraction vs still-live original); - // studio-dnd pr22 removes this with the final config. - "packages/studio/src/hooks/timelineEditingHelpers.ts", - // TEMP(studio-dnd): coexistence-window clone (extraction vs still-live original); - // studio-dnd pr22 removes this with the final config. - "packages/studio/src/player/components/Timeline.tsx", - // TEMP(studio-dnd): coexistence-window clone (extraction vs still-live original); - // studio-dnd pr22 removes this with the final config. - "packages/studio/src/player/components/TimelineCanvas.tsx", - // TEMP(studio-dnd): coexistence-window clone (extraction vs still-live original); - // studio-dnd pr22 removes this with the final config. - "packages/studio/src/player/components/TimelineOverlays.tsx", - // TEMP(studio-dnd): coexistence-window clone (extraction vs still-live original); - // studio-dnd pr22 removes this with the final config. - "packages/studio/src/player/components/timelineClipDragTypes.ts", - // TEMP(studio-dnd): coexistence-window clone (extraction vs still-live original); - // studio-dnd pr22 removes this with the final config. - "packages/studio/src/player/components/useTimelineClipDrag.ts", - // TEMP(studio-dnd): coexistence-window clone (extraction vs still-live original); - // studio-dnd pr22 removes this with the final config. - "packages/studio/src/player/components/useTimelineMarqueeSelection.ts", - // TEMP(studio-dnd): coexistence-window clone (extraction vs still-live original); - // studio-dnd pr22 removes this with the final config. - "packages/studio/src/player/hooks/useTimelinePlayer.ts", - // TEMP(studio-dnd): coexistence-window clone (extraction vs still-live original); - // studio-dnd pr22 removes this with the final config. - "packages/studio/src/player/hooks/useTimelinePlayerLoop.ts", - // TEMP(studio-dnd): coexistence-window clone (extraction vs still-live original); - // studio-dnd pr22 removes this with the final config. - "packages/studio/src/player/lib/timelineElementHelpers.ts", // sourcePatcher.ts: pre-existing internal clones between the inline-style // and attribute tag-patchers; only the PatchOperation type gained two // optional fields here, but the line shift makes fallow re-flag them. @@ -659,30 +433,6 @@ // complexity pre-dates the computed-timeline work. Exempted at file level // rather than refactored as scope creep. "ignore": [ - // TEMP(studio-dnd): coexistence-window complexity flare (inherited/CRAP-no-coverage); - // removed by studio-dnd/pr22 when the final config lands. - "packages/studio/src/components/editor/DomEditSelectionChrome.tsx", - "packages/studio/src/components/editor/useDomEditNudge.ts", - // TEMP(studio-dnd): coexistence-window complexity flare (inherited/CRAP-no-coverage); - // removed by studio-dnd/pr22 when the final config lands. - "packages/studio/src/player/components/useTimelineGeometry.ts", - "packages/studio/src/player/components/useTimelineStackingSync.ts", - // TEMP(studio-dnd): coexistence-window complexity flare (inherited/CRAP-no-coverage); - // removed by studio-dnd/pr22 when the final config lands. - "packages/studio/src/components/editor/domEditOverlayGeometry.ts", - "packages/studio/src/components/editor/manualOffsetDrag.ts", - "packages/studio/src/player/components/timelineEditing.ts", - "packages/studio/src/player/lib/timelineElementHelpers.ts", - // TEMP(studio-dnd): coexistence-window complexity flare (inherited/CRAP-no-coverage); - // removed by studio-dnd/pr22 when the final config lands. - "packages/studio/src/player/components/TimelineOverlays.tsx", - // TEMP(studio-dnd): coexistence-window complexity flare (inherited/CRAP-no-coverage); - // removed by studio-dnd/pr22 when the final config lands. - "packages/studio/src/player/hooks/useTimelineSyncCallbacks.ts", - "packages/studio/src/components/editor/CanvasContextMenu.tsx", - "packages/studio/src/components/editor/canvasContextMenuZOrder.test.ts", - "packages/studio/src/player/components/timelineCollision.test.ts", - "packages/studio/src/player/components/timelineStackingSync.test.ts", // sourcePatcher.ts: resolveSourceFile / splitInlineStyleDeclarations / // patch*InTag pre-date this PR; only the PatchOperation type gained two // optional fields, but the line-shift fingerprint re-flags the inherited @@ -822,14 +572,12 @@ "packages/studio/src/components/editor/Transform3DCube.tsx", "packages/studio/src/components/LintModal.tsx", "packages/studio/src/components/MediaPreview.tsx", - "packages/studio/src/components/nle/NLELayout.tsx", "packages/studio/src/components/nle/NLEPreview.tsx", "packages/studio/src/components/sidebar/AudioRow.tsx", "packages/studio/src/components/sidebar/BlocksTab.tsx", "packages/studio/src/components/sidebar/CompositionsTab.tsx", "packages/studio/src/components/sidebar/LeftSidebar.tsx", "packages/studio/src/components/storyboard/StoryboardLoaded.tsx", - "packages/studio/src/components/StudioPreviewArea.tsx", "packages/studio/src/components/StudioRightPanel.tsx", "packages/studio/src/components/StudioToast.tsx", "packages/studio/src/components/ui/Tooltip.tsx", diff --git a/docs/contributing/studio-manual-dom-editing.mdx b/docs/contributing/studio-manual-dom-editing.mdx index e2a343afe0..f76b5ac8af 100644 --- a/docs/contributing/studio-manual-dom-editing.mdx +++ b/docs/contributing/studio-manual-dom-editing.mdx @@ -274,7 +274,7 @@ The main implementation lives in: Supporting Studio shell changes also landed in: -- `packages/studio/src/components/nle/NLELayout.tsx` +- `packages/studio/src/components/EditorShell.tsx` - `packages/studio/src/components/nle/NLEPreview.tsx` - `packages/studio/src/components/sidebar/CompositionsTab.tsx` - `packages/studio/src/components/sidebar/LeftSidebar.tsx` diff --git a/docs/packages/studio.mdx b/docs/packages/studio.mdx index 358910d4e8..f07d58c8da 100644 --- a/docs/packages/studio.mdx +++ b/docs/packages/studio.mdx @@ -61,13 +61,20 @@ Peer dependencies: `react` (19), `react-dom` (19), `zustand` (4 or 5). ### Layout ```typescript -import { NLELayout, NLEPreview, CompositionBreadcrumb } from '@hyperframes/studio'; +import { EditorShell, NLEPreview, CompositionBreadcrumb } from '@hyperframes/studio'; import type { CompositionLevel } from '@hyperframes/studio'; -// Main NLE (Non-Linear Editor) layout container - - {/* Preview, timeline, and editor panels */} - +// Main NLE (Non-Linear Editor) layout container. It arranges the sidebar +// (`left`), inspector (`right`), preview, and timeline, and takes the editor +// callbacks (timeline edits, drops, clip rendering) as props — see StudioApp +// for a fully wired setup. + // Preview panel diff --git a/packages/core/src/runtime/timeline.ts b/packages/core/src/runtime/timeline.ts index d7ea927f7c..5c4c6b4552 100644 --- a/packages/core/src/runtime/timeline.ts +++ b/packages/core/src/runtime/timeline.ts @@ -51,60 +51,6 @@ function maxDefinedNumber(...values: Array): number | null { return Math.max(...finite); } -/** - * When multiple content kinds share the same track number, split them - * onto separate tracks so the timeline UI shows distinct rows. - * - * Preferred kind order (top → bottom): composition, video, image, element, audio. - * Tracks that contain only one kind are left untouched. - */ -const KIND_ORDER: Record = { - composition: 0, - video: 1, - image: 2, - element: 3, - audio: 4, -}; - -function normalizeTrackAssignments(clips: RuntimeTimelineClip[]): void { - if (clips.length === 0) return; - - // Group clips by their raw track number and detect which tracks have mixed kinds - const trackKinds = new Map>(); - for (const clip of clips) { - const kinds = trackKinds.get(clip.track) ?? new Set(); - kinds.add(clip.kind); - trackKinds.set(clip.track, kinds); - } - - const hasMixedTracks = Array.from(trackKinds.values()).some((kinds) => kinds.size > 1); - if (!hasMixedTracks) return; - - // Build new contiguous track numbers, splitting mixed tracks by kind - let nextTrack = 0; - const newTrackMap = new Map(); // "origTrack:kind" → newTrack - - const sortedTracks = [...trackKinds.keys()].sort((a, b) => a - b); - for (const track of sortedTracks) { - const kinds = trackKinds.get(track)!; - if (kinds.size === 1) { - newTrackMap.set(`${track}:${[...kinds][0]}`, nextTrack++); - } else { - // Split by kind in preferred order - const sorted = [...kinds].sort((a, b) => (KIND_ORDER[a] ?? 99) - (KIND_ORDER[b] ?? 99)); - for (const kind of sorted) { - newTrackMap.set(`${track}:${kind}`, nextTrack++); - } - } - } - - for (const clip of clips) { - const key = `${clip.track}:${clip.kind}`; - const newTrack = newTrackMap.get(key); - if (newTrack != null) clip.track = newTrack; - } -} - function toAbsoluteAssetUrl(rawValue: string | null | undefined): string | null { const raw = String(rawValue ?? "").trim(); if (!raw) return null; @@ -638,11 +584,12 @@ export function collectRuntimeTimelinePayload(params: { } } - // ── Track normalization ──────────────────────────────────────────────── - // When multiple content kinds (composition, audio, video, …) share the same - // data-track-index value, split them onto separate tracks so the timeline UI - // shows distinct rows for each kind. - normalizeTrackAssignments(clips); + // Track assignment honors the authored data-track-index verbatim: a clip stays + // on the track it was placed on, regardless of kind. (Previously mixed-kind + // tracks were split onto separate rows, but that renumbered tracks — breaking + // "drop a clip onto an existing track" and causing the written track to drift + // from the displayed one on every move. Track index is display-only; render + // never reads it, so honoring it verbatim is the correct NLE behavior.) for (const compositionNode of compositionNodes) { if (compositionNode === root) continue; diff --git a/packages/studio/src/App.tsx b/packages/studio/src/App.tsx index 2bac2f857c..ef6b4375c2 100644 --- a/packages/studio/src/App.tsx +++ b/packages/studio/src/App.tsx @@ -10,18 +10,16 @@ import { usePersistentEditHistory } from "./hooks/usePersistentEditHistory"; import { usePanelLayout } from "./hooks/usePanelLayout"; import { useFileManager } from "./hooks/useFileManager"; import { usePreviewPersistence } from "./hooks/usePreviewPersistence"; +import { usePreviewDocumentVersion } from "./hooks/usePreviewDocumentVersion"; import { useTimelineEditing } from "./hooks/useTimelineEditing"; -import type { TimelineZIndexReorderCommit } from "./hooks/useTimelineEditingTypes"; import type { BlockPreviewInfo } from "./components/sidebar/BlocksTab"; import { useDomEditSession } from "./hooks/useDomEditSession"; +import { useSdkSession } from "./hooks/useSdkSession"; import { useSdkSelectionSync } from "./hooks/useSdkSelectionSync"; -import { useStudioSdkSessions } from "./hooks/useStudioSdkSessions"; -import { usePreviewDocumentVersion } from "./hooks/usePreviewDocumentVersion"; import { useBlockHandlers } from "./hooks/useBlockHandlers"; import { useAppHotkeys } from "./hooks/useAppHotkeys"; import { useClipboard } from "./hooks/useClipboard"; -import { readStudioUiPreferences, writeStudioUiPreferences } from "./utils/studioUiPreferences"; -import { deleteSelectedKeyframes } from "./hooks/timelineEditingHelpers"; +import { selectedKeyframePercentagesForElement } from "./utils/keyframeSelection"; import { useCaptionDetection } from "./hooks/useCaptionDetection"; import { useRenderClipContent } from "./hooks/useRenderClipContent"; import { useConsoleErrorCapture } from "./hooks/useConsoleErrorCapture"; @@ -33,7 +31,7 @@ import { useCompositionContentLoader } from "./hooks/useCompositionContentLoader import { useStudioUrlState } from "./hooks/useStudioUrlState"; import { buildStudioContextValue, - useDragOverlay, + useGlobalFileDrop, useInspectorState, } from "./hooks/useStudioContextValue"; import type { DomEditSelection } from "./components/editor/domEditing"; @@ -42,7 +40,7 @@ import { useGestureCommit } from "./hooks/useGestureCommit"; import { STUDIO_KEYFRAMES_ENABLED } from "./components/editor/manualEditingAvailability"; import { GestureTrailOverlay } from "./components/editor/GestureTrailOverlay"; import { StudioLeftSidebar } from "./components/StudioLeftSidebar"; -import { StudioPreviewArea } from "./components/StudioPreviewArea"; +import { EditorShell } from "./components/EditorShell"; import { StudioRightPanel } from "./components/StudioRightPanel"; import { TimelineToolbar } from "./components/TimelineToolbar"; import { StudioPlaybackProvider, StudioShellProvider } from "./contexts/StudioContext"; @@ -56,7 +54,6 @@ import { useServerConnection } from "./hooks/useServerConnection"; import { normalizeStudioCompositionPath, readStudioUrlStateFromWindow, - resolveMasterCompositionPath, } from "./utils/studioUrlState"; import { trackStudioSessionStart } from "./telemetry/events"; import { hasFiredSessionStart, markSessionStartFired } from "./telemetry/config"; @@ -108,18 +105,6 @@ export function StudioApp() { : 0; return Math.max(timelineDuration, maxEnd); }, [timelineDuration, timelineElements]); - const [timelineVisible, setTimelineVisible] = useState( - () => - initialUrlStateRef.current.timelineVisible ?? - readStudioUiPreferences().timelineVisible ?? - true, - ); - const toggleTimelineVisibility = useCallback(() => { - setTimelineVisible((v) => { - writeStudioUiPreferences({ timelineVisible: !v }); - return !v; - }); - }, []); const { toasts, showToast, dismissToast } = useToast(); const panelLayout = usePanelLayout({ rightCollapsed: initialUrlStateRef.current.rightCollapsed, @@ -127,7 +112,6 @@ export function StudioApp() { }); const editHistory = usePersistentEditHistory({ projectId }); const domEditSaveTimestampRef = useRef(0); - const handleDomZIndexReorderCommitRef = useRef(null); const pendingTimelineEditPathRef = useRef(new Set()); const isGestureRecordingRef = useRef(false); const reloadPreview = useCallback(() => setRefreshKey((k) => k + 1), []); @@ -138,16 +122,7 @@ export function StudioApp() { domEditSaveTimestampRef, setRefreshKey, }); - const masterCompPath = useMemo( - () => resolveMasterCompositionPath(fileManager.fileTree), - [fileManager.fileTree], - ); - const { sdkHandle, editFlowSdkSession } = useStudioSdkSessions( - projectId, - activeCompPath, - domEditSaveTimestampRef, - masterCompPath, - ); + const sdkHandle = useSdkSession(projectId, activeCompPath, domEditSaveTimestampRef); useEffect(() => { if (activeCompPathHydrated) return; if (!fileManager.fileTreeLoaded) return; @@ -183,9 +158,8 @@ export function StudioApp() { pendingTimelineEditPathRef, uploadProjectFiles: fileManager.uploadProjectFiles, isRecordingRef: isGestureRecordingRef, - sdkSession: editFlowSdkSession, + sdkSession: sdkHandle.session, forceReloadSdkSession: sdkHandle.forceReload, - handleDomZIndexReorderCommitRef, }); const { activeBlockParams, @@ -232,7 +206,6 @@ export function StudioApp() { previewIframeRef, }); const appHotkeys = useAppHotkeys({ - toggleTimelineVisibility, handleTimelineElementDelete: timelineEditing.handleTimelineElementDelete, handleTimelineElementSplit: timelineEditing.handleTimelineElementSplit, handleDomEditElementDelete: domEditDeleteBridge, @@ -300,18 +273,25 @@ export function StudioApp() { openSourceForSelection: fileManager.openSourceForSelection, selectSidebarTab: sidebarTabRef.current.select, getSidebarTab: sidebarTabRef.current.get, - sdkSession: editFlowSdkSession, + sdkSession: sdkHandle.session, forceReloadSdkSession: sdkHandle.forceReload, }); domEditSelectionBridgeRef.current = domEditSession.domEditSelection; - handleDomZIndexReorderCommitRef.current = domEditSession.handleDomZIndexReorderCommit; clearDomSelectionRef.current = domEditSession.clearDomSelection; handleDomEditElementDeleteRef.current = domEditSession.handleDomEditElementDelete; resetKeyframesRef.current = domEditSession.handleResetSelectedElementKeyframes; invalidateGsapCacheRef.current = domEditSession.invalidateGsapCache; - deleteSelectedKeyframesRef.current = () => deleteSelectedKeyframes(domEditSession); + deleteSelectedKeyframesRef.current = () => { + const { selectedKeyframes, selectedElementId } = usePlayerStore.getState(); + const a = domEditSession.selectedGsapAnimations.find((x) => x.keyframes); + if (!a) return; + // Only the active element's keyframes; a stale cross-element selection must not delete here. + for (const p of selectedKeyframePercentagesForElement(selectedKeyframes, selectedElementId)) { + domEditSession.handleGsapRemoveKeyframe(a.id, p); + } + }; useSdkSelectionSync( - editFlowSdkSession, + sdkHandle.session, domEditSession.domEditSelection, domEditSession.domEditGroupSelections, ); @@ -348,7 +328,7 @@ export function StudioApp() { setConsoleErrors, resetErrors: resetConsoleErrors, } = useConsoleErrorCapture(previewIframe); - const dragOverlay = useDragOverlay(fileManager.handleImportFiles); + const dragOverlay = useGlobalFileDrop(timelineEditing.handleTimelineFileDrop); const handleToggleRecordingRef = useRef<() => void>(() => {}); const domEditSessionRef = useRef(domEditSession); domEditSessionRef.current = domEditSession; @@ -369,7 +349,6 @@ export function StudioApp() { const r = previewIframe.getBoundingClientRect(); canvasRectRef.current = { left: r.left, top: r.top, width: r.width, height: r.height }; }, [gestureState, previewIframe]); - const handlePreviewIframeRef = useCallback( (iframe: HTMLIFrameElement | null) => { previewIframeRef.current = iframe; @@ -410,7 +389,6 @@ export function StudioApp() { previewIframeRef, rightPanelTab: panelLayout.rightPanelTab, rightCollapsed: panelLayout.rightCollapsed, - timelineVisible, activeCompPathHydrated, domEditSelection: domEditSession.domEditSelection, buildDomSelectionFromTarget: domEditSession.buildDomSelectionFromTarget, @@ -438,18 +416,15 @@ export function StudioApp() { waitForPendingDomEditSaves: previewPersistence.waitForPendingDomEditSaves, handlePreviewIframeRef, refreshPreviewDocumentVersion, - timelineVisible, - toggleTimelineVisibility, }); const timelineToolbar = useMemo( () => ( ), - [toggleTimelineVisibility, domEditSession, timelineEditing.handleTimelineElementSplit], + [domEditSession, timelineEditing.handleTimelineElementSplit], ); if (resolving || waitingForServer || !projectId) return ; @@ -494,78 +469,76 @@ export function StudioApp() { onSelectComposition={handleSelectComposition} /> )} -
- - - ) : undefined - } - /> - {!panelLayout.rightCollapsed && ( - { - setActiveBlockParams(null); - panelLayout.setRightPanelTab("design"); - }} - recordingState={gestureState} - recordingDuration={gestureRecording.recordingDuration} - onToggleRecording={recordingToggle} - sdkSession={sdkHandle.session} - reloadPreview={reloadPreview} - domEditSaveTimestampRef={domEditSaveTimestampRef} - recordEdit={editHistory.recordEdit} - onToggleElementHidden={timelineEditing.handleToggleElementHidden} +
+ } + right={ + panelLayout.rightCollapsed ? null : ( + { + setActiveBlockParams(null); + panelLayout.setRightPanelTab("design"); + }} + recordingState={gestureState} + recordingDuration={gestureRecording.recordingDuration} + onToggleRecording={recordingToggle} + sdkSession={sdkHandle.session} + reloadPreview={reloadPreview} + domEditSaveTimestampRef={domEditSaveTimestampRef} + recordEdit={editHistory.recordEdit} + onToggleElementHidden={timelineEditing.handleToggleElementHidden} + /> + ) + } + timelineToolbar={timelineToolbar} + renderClipContent={renderClipContent} + handleTimelineElementDelete={timelineEditing.handleTimelineElementDelete} + handleTimelineAssetDrop={timelineEditing.handleTimelineAssetDrop} + handleTimelineBlockDrop={handleTimelineBlockDrop} + handlePreviewBlockDrop={handlePreviewBlockDrop} + handleTimelineFileDrop={timelineEditing.handleTimelineFileDrop} + handleTimelineElementMove={timelineEditing.handleTimelineElementMove} + handleTimelineElementsMove={timelineEditing.handleTimelineElementsMove} + handleTimelineElementResize={timelineEditing.handleTimelineElementResize} + handleToggleTrackHidden={timelineEditing.handleToggleTrackHidden} + handleBlockedTimelineEdit={timelineEditing.handleBlockedTimelineEdit} + handleTimelineElementSplit={timelineEditing.handleTimelineElementSplit} + handleRazorSplit={timelineEditing.handleRazorSplit} + handleRazorSplitAll={timelineEditing.handleRazorSplitAll} + setCompIdToSrc={setCompIdToSrc} + setCompositionLoading={setCompositionLoading} + shouldShowSelectedDomBounds={shouldShowSelectedDomBounds} + isGestureRecording={gestureState === "recording"} + recordingState={gestureState} + onToggleRecording={recordingToggle} + blockPreview={blockPreview} + gestureOverlay={ + gestureState === "recording" && previewIframe ? ( + + ) : undefined + } + /> ; + onAddAssetToTimeline?: (path: string) => void; } // fallow-ignore-next-line complexity @@ -30,6 +31,7 @@ export function StudioLeftSidebar({ linting, lintFindingCount, lintFindingsByFile, + onAddAssetToTimeline, }: StudioLeftSidebarProps) { const { leftCollapsed, @@ -69,7 +71,7 @@ export function StudioLeftSidebar({ if (leftCollapsed) { return ( -
+
@@ -131,46 +154,65 @@ export function TimelineToolbar({ onClick={() => setActiveTool("razor")} aria-label="Razor tool" aria-pressed={activeTool === "razor"} - className={`flex h-6 w-6 items-center justify-center transition-colors active:scale-[0.98] ${ - activeTool === "razor" - ? "bg-neutral-700 text-neutral-200" - : "text-neutral-500 hover:text-neutral-300" - }`} + className={activeTool === "razor" ? flatActive : flatIdle} > - + -
+ {/* Divider: tool-mode | editing-actions */} + -
+
-
- {`${displayedTimelineZoomPercent}%`} -
+ { + setZoomMode("manual"); + setManualZoomPercent(timelineSliderToZoomPercent(Number(e.target.value))); + }} + className="mx-1 w-[96px] cursor-pointer appearance-none bg-transparent [&::-webkit-slider-runnable-track]:h-[2px] [&::-webkit-slider-runnable-track]:rounded-full [&::-webkit-slider-runnable-track]:bg-neutral-700 [&::-webkit-slider-thumb]:appearance-none [&::-webkit-slider-thumb]:w-[10px] [&::-webkit-slider-thumb]:h-[10px] [&::-webkit-slider-thumb]:rounded-full [&::-webkit-slider-thumb]:bg-white [&::-webkit-slider-thumb]:-mt-1 [&::-webkit-slider-thumb]:shadow-[0_0_0_2px_#0a0a0a,0_1px_3px_rgba(0,0,0,0.5)] [&::-webkit-slider-thumb]:cursor-grab [&::-webkit-slider-thumb:active]:cursor-grabbing" + /> - - - + {/* Numeric zoom readout (main-parity): "Fit" in fit mode, N% in manual. */} + + {zoomMode === "fit" ? "Fit" : `${displayedTimelineZoomPercent}%`} +
diff --git a/packages/studio/src/components/editor/DomEditCropHandles.tsx b/packages/studio/src/components/editor/DomEditCropHandles.tsx index 7ac3b485b0..ff90de33af 100644 --- a/packages/studio/src/components/editor/DomEditCropHandles.tsx +++ b/packages/studio/src/components/editor/DomEditCropHandles.tsx @@ -31,12 +31,16 @@ interface DomEditCropHandlesProps { onStyleCommit?: (property: string, value: string) => Promise | void; } -// Gap (px) between an edge handle and the element edge, so the handle sits -// clear of the element body and can't intercept a move-drag. -const EDGE_HANDLE_GAP = 8; +// Hit-strip size (px) for an edge crop handle: THICKNESS extends outward from +// the crop edge (flush against it, never over the element body, so a body +// drag always MOVES), LENGTH runs along the edge. The visible pill is smaller +// and centered inside the strip. +const EDGE_HIT_THICKNESS = 12; +const EDGE_HIT_LENGTH = 32; -/** Place an edge handle just OUTSIDE the given crop edge (translate pushes it - * fully past the boundary). Keeps the element body free for moving. */ +/** Place an edge handle's hit strip just OUTSIDE the given crop edge + * (translate pushes it fully past the boundary). Keeps the element body free + * for moving. Corners stay free for the selection's own resize handles. */ function edgeHandlePlacement( edge: CropEdge, rect: { left: number; top: number; width: number; height: number }, @@ -44,27 +48,36 @@ function edgeHandlePlacement( const cx = rect.left + rect.width / 2; const cy = rect.top + rect.height / 2; if (edge === "top") { - return { left: cx, top: rect.top - EDGE_HANDLE_GAP, transform: "translate(-50%, -100%)" }; + return { left: cx, top: rect.top, transform: "translate(-50%, -100%)" }; } if (edge === "bottom") { - return { - left: cx, - top: rect.top + rect.height + EDGE_HANDLE_GAP, - transform: "translate(-50%, 0)", - }; + return { left: cx, top: rect.top + rect.height, transform: "translate(-50%, 0)" }; } if (edge === "left") { - return { left: rect.left - EDGE_HANDLE_GAP, top: cy, transform: "translate(-100%, -50%)" }; + return { left: rect.left, top: cy, transform: "translate(-100%, -50%)" }; } - return { - left: rect.left + rect.width + EDGE_HANDLE_GAP, - top: cy, - transform: "translate(0, -50%)", - }; + return { left: rect.left + rect.width, top: cy, transform: "translate(0, -50%)" }; } const EDGES: CropEdge[] = ["top", "right", "bottom", "left"]; +/** Hit-strip + pill dimensions for an edge handle, keyed on its orientation. */ +function edgeHandleMetrics(vertical: boolean): { + hitWidth: number; + hitHeight: number; + cursor: string; + pillWidth: number; + pillHeight: number; +} { + return { + hitWidth: vertical ? EDGE_HIT_THICKNESS : EDGE_HIT_LENGTH, + hitHeight: vertical ? EDGE_HIT_LENGTH : EDGE_HIT_THICKNESS, + cursor: vertical ? "ew-resize" : "ns-resize", + pillWidth: vertical ? 4 : 24, + pillHeight: vertical ? 24 : 4, + }; +} + /** * Always-on crop, integrated with the selection (no crop "mode"): while a * croppable element is selected its clip is lifted so the FULL content shows and @@ -83,6 +96,7 @@ export function DomEditCropHandles({ }: DomEditCropHandlesProps) { const gestureRef = useRef(null); const [dragging, setDragging] = useState(false); + const [hotEdge, setHotEdge] = useState(null); // readElementCropInsets returns null for a clip this tool can't represent // (circle/polygon/non-px inset): the crop UI must fully stand down for that // element — no lift, no handles — or select+deselect replaces the authored @@ -304,6 +318,7 @@ export function DomEditCropHandles({ ); })} diff --git a/packages/studio/src/components/editor/DomEditOverlay.test.ts b/packages/studio/src/components/editor/DomEditOverlay.test.ts index fbc8eccb26..736bd7ecb2 100644 --- a/packages/studio/src/components/editor/DomEditOverlay.test.ts +++ b/packages/studio/src/components/editor/DomEditOverlay.test.ts @@ -11,10 +11,10 @@ import { hasDomEditRotationChanged, resolveDomEditCoordinateScale, resolveDomEditGroupOverlayRect, - resolveDomEditResizeGesture, resolveDomEditRotationGesture, } from "./DomEditOverlay"; import type { DomEditSelection } from "./domEditing"; +import { resolveResizeCenterAnchorOffset } from "./domEditOverlayGestures"; // React 19 warns unless the test environment opts into act(). globalThis.IS_REACT_ACT_ENVIRONMENT = true; @@ -84,21 +84,37 @@ vi.mock("./useDomEditOverlayRects", async () => { }; }); +const previewHelperSpies = vi.hoisted(() => ({ + getPreviewTargetFromPointer: vi.fn<() => HTMLElement | null>(() => null), +})); + +vi.mock("../../utils/studioPreviewHelpers", async () => { + const actual = await vi.importActual( + "../../utils/studioPreviewHelpers", + ); + return { + ...actual, + getPreviewTargetFromPointer: previewHelperSpies.getPreviewTargetFromPointer, + }; +}); + vi.mock("./domEditOverlayGeometry", async () => { const actual = await vi.importActual( "./domEditOverlayGeometry", ); + const stubRect = { + left: 24, + top: 36, + width: 180, + height: 72, + editScaleX: 1, + editScaleY: 1, + }; return { ...actual, - toOverlayRect: () => ({ - left: 24, - top: 36, - width: 180, - height: 72, - editScaleX: 1, - editScaleY: 1, - }), + toOverlayRect: () => stubRect, + orientedOverlayRect: () => stubRect, }; }); @@ -126,6 +142,103 @@ function createOverlayProps(args: { }; } +/** + * Stub element-level getBoundingClientRect to a fixed 800×450 rect (happy-dom + * returns all-zeros for unlaid-out elements, which gates the RAF compRect + * update). Returns a restore function to call in teardown. + */ +function stubViewportRect(): () => void { + const original = Element.prototype.getBoundingClientRect; + Element.prototype.getBoundingClientRect = function (): DOMRect { + return { + left: 0, + top: 0, + right: 800, + bottom: 450, + width: 800, + height: 450, + x: 0, + y: 0, + toJSON: () => ({}), + }; + }; + return () => { + Element.prototype.getBoundingClientRect = original; + }; +} + +/** + * Flush the mount's RAF ticks so the compRect update lands. Two animation-frame + * ticks: the first scheduled by useMountEffect's update(), the second by + * update()'s tail recursion. + */ +async function flushOverlayRaf(): Promise { + await act(async () => { + await new Promise((resolve) => { + requestAnimationFrame(() => requestAnimationFrame(() => resolve())); + }); + }); +} + +/** A fully-populated DomEditSelection with per-test overrides (capabilities are + * merged so a test can flip a single flag without restating the whole set). */ +function makeDomEditSelection( + overrides: Partial = {}, + capabilityOverrides: Partial = {}, +): DomEditSelection { + const base: DomEditSelection = { + element: document.createElement("div"), + id: "hero-title", + selector: ".hero-title", + selectorIndex: 0, + sourceFile: "index.html", + tagName: "div", + label: "Hero Title", + textContent: "Hello", + textFields: [], + capabilities: { + canEditText: true, + canEditLayout: true, + canMove: true, + canApplyManualOffset: true, + canApplyManualSize: false, + canApplyManualRotation: false, + canAdjustOpacity: true, + canAdjustFill: true, + canAdjustBorderRadius: true, + canAdjustStroke: true, + canAdjustShadow: true, + canAdjustZIndex: true, + }, + computedStyle: { + display: "block", + position: "absolute", + }, + }; + return { + ...base, + ...overrides, + capabilities: { ...base.capabilities, ...capabilityOverrides }, + }; +} + +/** Query the composition-canvas overlay and assert it mounted. */ +function getOverlay(host: HTMLElement): HTMLDivElement { + const overlay = host.querySelector('[aria-label="Composition canvas"]'); + expect(overlay).toBeTruthy(); + if (!overlay) throw new Error("Expected composition canvas overlay"); + return overlay; +} + +/** Dispatch a left-button pointerdown at (clientX, clientY) inside act(). */ +function dispatchOverlayPointerDown(target: Element, clientX = 120, clientY = 80): void { + act(() => { + target.dispatchEvent( + new PointerEvent("pointerdown", { bubbles: true, button: 0, clientX, clientY }), + ); + }); +} + describe("focusDomEditOverlayElement", () => { it("focuses the canvas overlay without scrolling", () => { const calls: Array = []; @@ -144,41 +257,84 @@ describe("DomEditOverlay", () => { gestureSpies.onPointerMove.mockClear(); gestureSpies.onPointerUp.mockClear(); gestureSpies.clearPointerState.mockClear(); + previewHelperSpies.getPreviewTargetFromPointer.mockReset(); + previewHelperSpies.getPreviewTargetFromPointer.mockReturnValue(null); + }); + + it("selects on the first click over an element even before a hover is resolved", async () => { + // Regression: this used to start a marquee whenever hoverSelectionRef was null. + // The RAF hover loop populates that ref ASYNCHRONOUSLY, so a genuine first + // click over an element read null and was misread as empty canvas — the + // marquee swallowed the selecting onMouseDown, so nothing selected until the + // SECOND click. With a synchronous pointer hit-test finding an element, the + // marquee must NOT start and onCanvasMouseDown must fire on the first click. + const restoreRect = stubViewportRect(); + const originalPointerCapture = HTMLDivElement.prototype.setPointerCapture; + HTMLDivElement.prototype.setPointerCapture = () => {}; + + // An element IS under the pointer, but no hover has been resolved yet. + previewHelperSpies.getPreviewTargetFromPointer.mockReturnValue(document.createElement("div")); + + const host = document.createElement("div"); + document.body.append(host); + const root = createRoot(host); + const iframeRef = { current: document.createElement("iframe") as HTMLIFrameElement | null }; + const onCanvasMouseDown = vi.fn(); + const onMarqueeSelect = vi.fn(); + + function Harness() { + return React.createElement(DomEditOverlay, { + ...createOverlayProps({ + iframeRef, + selection: null, + hoverSelection: null, + onSelectionChange: () => {}, + }), + onCanvasMouseDown, + onMarqueeSelect, + }); + } + + act(() => { + root.render(React.createElement(Harness)); + }); + await flushOverlayRaf(); + + const overlay = getOverlay(host); + + act(() => { + overlay.dispatchEvent( + new PointerEvent("pointerdown", { bubbles: true, button: 0, clientX: 120, clientY: 80 }), + ); + overlay.dispatchEvent( + new MouseEvent("mousedown", { bubbles: true, button: 0, clientX: 120, clientY: 80 }), + ); + }); + + // No marquee started; the click reached the selecting mouse-down handler. + expect(onMarqueeSelect).not.toHaveBeenCalled(); + expect(onCanvasMouseDown).toHaveBeenCalledTimes(1); + + act(() => { + root.unmount(); + }); + HTMLDivElement.prototype.setPointerCapture = originalPointerCapture; + restoreRect(); + host.remove(); }); it("does not start a drag from a stale hover target on canvas pointer-down", () => { const host = document.createElement("div"); document.body.append(host); const root = createRoot(host); - const selection: DomEditSelection = { - element: document.createElement("div"), + const selection = makeDomEditSelection({ id: "cta-label", selector: ".cta-label", - selectorIndex: 0, - sourceFile: "index.html", tagName: "span", label: "CTA Label", textContent: "Add to basket", - textFields: [], - capabilities: { - canEditText: true, - canEditLayout: true, - canMove: true, - canApplyManualOffset: true, - canApplyManualSize: false, - canApplyManualRotation: false, - canAdjustOpacity: true, - canAdjustFill: true, - canAdjustBorderRadius: true, - canAdjustStroke: true, - canAdjustShadow: true, - canAdjustZIndex: true, - }, - computedStyle: { - display: "inline", - position: "static", - }, - }; + computedStyle: { display: "inline", position: "static" }, + }); let currentSelection: DomEditSelection | null = null; const iframeRef = { current: document.createElement("iframe") as HTMLIFrameElement | null }; @@ -202,19 +358,9 @@ describe("DomEditOverlay", () => { root.render(React.createElement(Harness)); }); - const overlay = host.querySelector('[aria-label="Composition canvas"]') as HTMLDivElement; - expect(overlay).toBeTruthy(); + const overlay = getOverlay(host); - act(() => { - overlay.dispatchEvent( - new PointerEvent("pointerdown", { - bubbles: true, - button: 0, - clientX: 120, - clientY: 80, - }), - ); - }); + dispatchOverlayPointerDown(overlay); expect(gestureSpies.startGesture).not.toHaveBeenCalled(); expect(currentSelection).toBe(null); @@ -233,53 +379,12 @@ describe("DomEditOverlay", () => { // box (and other bounded UI) behind `compRect.width > 0` (added in the // keyframes PR a468550f). Stub element-level getBoundingClientRect for // the test so the RAF compRect update produces a real width. - const originalGetBoundingClientRect = Element.prototype.getBoundingClientRect; - Element.prototype.getBoundingClientRect = function (): DOMRect { - return { - left: 0, - top: 0, - right: 800, - bottom: 450, - width: 800, - height: 450, - x: 0, - y: 0, - toJSON: () => ({}), - }; - }; + const restoreRect = stubViewportRect(); const host = document.createElement("div"); document.body.append(host); const root = createRoot(host); - const selection: DomEditSelection = { - element: document.createElement("div"), - id: "hero-title", - selector: ".hero-title", - selectorIndex: 0, - sourceFile: "index.html", - tagName: "div", - label: "Hero Title", - textContent: "Hello", - textFields: [], - capabilities: { - canEditText: true, - canEditLayout: true, - canMove: true, - canApplyManualOffset: true, - canApplyManualSize: false, - canApplyManualRotation: false, - canAdjustOpacity: true, - canAdjustFill: true, - canAdjustBorderRadius: true, - canAdjustStroke: true, - canAdjustShadow: true, - canAdjustZIndex: true, - }, - computedStyle: { - display: "block", - position: "absolute", - }, - }; + const selection = makeDomEditSelection(); let currentSelection: DomEditSelection | null = selection; const iframeRef = { current: document.createElement("iframe") as HTMLIFrameElement | null }; @@ -307,30 +412,16 @@ describe("DomEditOverlay", () => { // Flush the mount's RAF tick so the compRect update lands before the // pointer-down. Two animation-frame ticks: the first scheduled by // useMountEffect's update(), the second by update()'s tail recursion. - await act(async () => { - await new Promise((resolve) => { - requestAnimationFrame(() => requestAnimationFrame(() => resolve())); - }); - }); + await flushOverlayRaf(); - const overlay = host.querySelector('[aria-label="Composition canvas"]') as HTMLDivElement; - expect(overlay).toBeTruthy(); + getOverlay(host); const selectionBox = host.querySelector( '[data-dom-edit-selection-box="true"]', ) as HTMLDivElement; expect(selectionBox).toBeTruthy(); - act(() => { - selectionBox.dispatchEvent( - new PointerEvent("pointerdown", { - bubbles: true, - button: 0, - clientX: 120, - clientY: 80, - }), - ); - }); + dispatchOverlayPointerDown(selectionBox); expect(currentSelection).toBe(selection); expect(gestureSpies.startGesture).toHaveBeenCalledWith( @@ -342,58 +433,17 @@ describe("DomEditOverlay", () => { root.unmount(); }); HTMLDivElement.prototype.setPointerCapture = originalPointerCapture; - Element.prototype.getBoundingClientRect = originalGetBoundingClientRect; + restoreRect(); host.remove(); }); it("passes the tracked hover selection when clicking the existing selection box", async () => { - const originalGetBoundingClientRect = Element.prototype.getBoundingClientRect; - Element.prototype.getBoundingClientRect = function (): DOMRect { - return { - left: 0, - top: 0, - right: 800, - bottom: 450, - width: 800, - height: 450, - x: 0, - y: 0, - toJSON: () => ({}), - }; - }; + const restoreRect = stubViewportRect(); const host = document.createElement("div"); document.body.append(host); const root = createRoot(host); - const selection: DomEditSelection = { - element: document.createElement("div"), - id: "hero-title", - selector: ".hero-title", - selectorIndex: 0, - sourceFile: "index.html", - tagName: "div", - label: "Hero Title", - textContent: "Hello", - textFields: [], - capabilities: { - canEditText: true, - canEditLayout: true, - canMove: false, - canApplyManualOffset: false, - canApplyManualSize: false, - canApplyManualRotation: false, - canAdjustOpacity: true, - canAdjustFill: true, - canAdjustBorderRadius: true, - canAdjustStroke: true, - canAdjustShadow: true, - canAdjustZIndex: true, - }, - computedStyle: { - display: "block", - position: "absolute", - }, - }; + const selection = makeDomEditSelection({}, { canMove: false, canApplyManualOffset: false }); const hoverSelection: DomEditSelection = { ...selection, id: "hovered-sibling" }; const onCanvasMouseDown = vi.fn(); const iframeRef = { current: document.createElement("iframe") as HTMLIFrameElement | null }; @@ -414,11 +464,7 @@ describe("DomEditOverlay", () => { root.render(React.createElement(Harness)); }); - await act(async () => { - await new Promise((resolve) => { - requestAnimationFrame(() => requestAnimationFrame(() => resolve())); - }); - }); + await flushOverlayRaf(); const selectionBox = host.querySelector( '[data-dom-edit-selection-box="true"]', @@ -437,7 +483,7 @@ describe("DomEditOverlay", () => { act(() => { root.unmount(); }); - Element.prototype.getBoundingClientRect = originalGetBoundingClientRect; + restoreRect(); host.remove(); }); }); @@ -513,130 +559,10 @@ describe("filterNestedDomEditGroupItems", () => { }); }); -describe("resolveDomEditResizeGesture", () => { - it("resizes width and height independently by default", () => { - expect( - resolveDomEditResizeGesture({ - originWidth: 240, - originHeight: 120, - actualWidth: 240, - actualHeight: 120, - scaleX: 1, - scaleY: 1, - dx: 30, - dy: 12, - uniform: false, - }), - ).toEqual({ - overlayWidth: 270, - overlayHeight: 132, - width: 270, - height: 132, - }); - }); - - it("divides the cursor delta by the element's content scale (rescaled element)", () => { - // Element renders at 2x via a GSAP scale: a 30px cursor delta must grow the - // CSS box by only 15px so the RENDERED box tracks the pointer 1:1. - const next = resolveDomEditResizeGesture({ - originWidth: 480, // 240 css x 2 content scale (overlay px at editScale 1) - originHeight: 240, - actualWidth: 240, - actualHeight: 120, - scaleX: 1, - scaleY: 1, - contentScaleX: 2, - contentScaleY: 2, - dx: 30, - dy: 12, - uniform: false, - }); - expect(next.width).toBe(255); - expect(next.height).toBe(126); - // The overlay box keeps tracking the raw cursor. - expect(next.overlayWidth).toBe(510); - expect(next.overlayHeight).toBe(252); - }); - - it("treats a missing/invalid content scale as 1 (unscaled element)", () => { - const next = resolveDomEditResizeGesture({ - originWidth: 240, - originHeight: 120, - actualWidth: 240, - actualHeight: 120, - scaleX: 1, - scaleY: 1, - contentScaleX: 0, - contentScaleY: Number.NaN, - dx: 30, - dy: 12, - uniform: false, - }); - expect(next.width).toBe(270); - expect(next.height).toBe(132); - }); - - it("snaps width and height to the same value when Shift is held", () => { - expect( - resolveDomEditResizeGesture({ - originWidth: 240, - originHeight: 120, - actualWidth: 240, - actualHeight: 120, - scaleX: 1, - scaleY: 1, - dx: 30, - dy: 12, - uniform: true, - }), - ).toEqual({ - overlayWidth: 270, - overlayHeight: 270, - width: 270, - height: 270, - }); - }); - - it("uses the dominant pointer delta for uniform shrink", () => { - expect( - resolveDomEditResizeGesture({ - originWidth: 300, - originHeight: 180, - actualWidth: 300, - actualHeight: 180, - scaleX: 1, - scaleY: 1, - dx: 8, - dy: -40, - uniform: true, - }), - ).toMatchObject({ - width: 260, - height: 260, - }); - }); - - it("writes source-local dimensions when the edited source is scaled down in master view", () => { - expect( - resolveDomEditResizeGesture({ - originWidth: 100, - originHeight: 50, - actualWidth: 400, - actualHeight: 200, - scaleX: 0.25, - scaleY: 0.25, - dx: 25, - dy: 10, - uniform: false, - }), - ).toEqual({ - overlayWidth: 125, - overlayHeight: 60, - width: 500, - height: 240, - }); - }); -}); +// Note: the resize SIZE math moved from the AABB screen-space +// resolveDomEditResizeGesture (removed) to the local-space (OBB) model in +// domEditResizeLocal.ts — see domEditResizeLocal.test.ts, which re-covers the +// independent-axis, aspect-lock, and scaled-master-view cases plus rotated axes. describe("resolveDomEditRotationGesture", () => { it("rotates by the pointer angle around the element center", () => { @@ -701,3 +627,43 @@ describe("resolveDomEditRotationGesture", () => { expect(hasDomEditRotationChanged(0, 0)).toBe(false); }); }); + +// resolveResizeCenterAnchorOffset is the UNROTATED (AABB) fallback used only when +// the element's real transformed corners can't be measured. Center-anchored: a +// width/height change grows the box from its top-left, drifting the center by half +// the size change per axis, so the pin translates back by that half-delta. It is +// handle-independent — all four corners scale about the same center. +describe("resolveResizeCenterAnchorOffset", () => { + it("grow: translates back by half the size change on both axes", () => { + expect( + resolveResizeCenterAnchorOffset({ + originWidth: 200, + originHeight: 100, + overlayWidth: 230, + overlayHeight: 112, + }), + ).toEqual({ dx: -15, dy: -6 }); + }); + + it("shrink: translates forward by half the (positive) size change", () => { + expect( + resolveResizeCenterAnchorOffset({ + originWidth: 200, + originHeight: 100, + overlayWidth: 160, + overlayHeight: 80, + }), + ).toEqual({ dx: 20, dy: 10 }); + }); + + it("no size change: zero offset", () => { + expect( + resolveResizeCenterAnchorOffset({ + originWidth: 200, + originHeight: 100, + overlayWidth: 200, + overlayHeight: 100, + }), + ).toEqual({ dx: 0, dy: 0 }); + }); +}); diff --git a/packages/studio/src/components/editor/DomEditOverlay.tsx b/packages/studio/src/components/editor/DomEditOverlay.tsx index 8d89a3637d..e80799f315 100644 --- a/packages/studio/src/components/editor/DomEditOverlay.tsx +++ b/packages/studio/src/components/editor/DomEditOverlay.tsx @@ -1,5 +1,4 @@ -import { memo, useEffect, useMemo, useRef, useState, type RefObject } from "react"; -import { getPreviewTargetFromPointer } from "../../utils/studioPreviewHelpers"; +import { memo, useCallback, useEffect, useMemo, useRef, useState, type RefObject } from "react"; import { type DomEditSelection } from "./domEditing"; import type { PreviewMouseDownOptions } from "../../hooks/usePreviewInteraction"; import { useMarqueeGestures } from "./marqueeCommit"; @@ -16,17 +15,20 @@ import { import { useDomEditOverlayRects } from "./useDomEditOverlayRects"; import { OffCanvasIndicators, type OffCanvasRect } from "./OffCanvasIndicators"; import { createDomEditOverlayGestureHandlers } from "./useDomEditOverlayGestures"; +import { useDomEditNudge } from "./useDomEditNudge"; import { SnapGuideOverlay, type SnapGuidesState } from "./SnapGuideOverlay"; import { GridOverlay } from "./GridOverlay"; import type { GestureRecordingState } from "./GestureRecordControl"; -import { DomEditCropHandles } from "./DomEditCropHandles"; -import { DomEditRotateHandle } from "./DomEditRotateHandle"; +import { DomEditGroupChrome, DomEditSelectionChrome } from "./DomEditSelectionChrome"; import { hugRectForElement } from "./domEditOverlayCrop"; import { useCropOverlay } from "../../hooks/useCropOverlay"; import { readDomEditSelectionShapeStyles, resolveBoxChromeClass } from "./domEditOverlayShape"; import { useDomEditCompositionRect } from "./useDomEditCompositionRect"; import { useMountEffect } from "../../hooks/useMountEffect"; import { startOffCanvasIndicatorRefresh } from "./offCanvasIndicatorRefresh"; +import { CanvasContextMenu } from "./CanvasContextMenu"; +import type { ZOrderPatch } from "./canvasContextMenuZOrder"; +import { getPreviewTargetFromPointer } from "../../utils/studioPreviewHelpers"; // Re-exports for external consumers — preserving existing import paths. export { @@ -37,7 +39,6 @@ export { export { focusDomEditOverlayElement, hasDomEditRotationChanged, - resolveDomEditResizeGesture, resolveDomEditRotationGesture, } from "./domEditOverlayGestures"; export type { DomEditGroupPathOffsetCommit } from "./domEditOverlayGestures"; @@ -82,6 +83,19 @@ interface DomEditOverlayProps { recordingState?: GestureRecordingState; onToggleRecording?: () => void; onMarqueeSelect?: (selections: DomEditSelection[], additive: boolean) => void; + /** + * Delete the selected canvas element. + * Wire to handleDomEditElementDelete from useDomEditActionsContext — + * same handler the Delete/Backspace hotkey uses. + */ + onDeleteSelection?: (selection: DomEditSelection) => void; + /** + * Called with the resolved z-order patch list after an optimistic DOM update. + * The patch list is tie-aware and may include sibling elements (see + * canvasContextMenuZOrder). Wire to handleDomZIndexReorderCommit from + * useDomEditActionsContext. See CanvasContextMenu.tsx module comment. + */ + onApplyZIndex?: (selection: DomEditSelection, patches: ZOrderPatch[]) => void; } // fallow-ignore-next-line complexity @@ -106,6 +120,8 @@ export const DomEditOverlay = memo(function DomEditOverlay({ onRotationCommit, onStyleCommit, onMarqueeSelect, + onDeleteSelection, + onApplyZIndex, }: DomEditOverlayProps) { const overlayRef = useRef(null); const boxRef = useRef(null); @@ -122,8 +138,31 @@ export const DomEditOverlay = memo(function DomEditOverlay({ const snapGuidesRef = useRef(null); const rafPausedRef = useRef(false); + // Context menu state: position of the right-click that opened it. + // contextMenuSelection is the element the menu targets — captured at right-click + // time so the menu can open even before the React selection state settles. + const [contextMenu, setContextMenu] = useState<{ + x: number; + y: number; + sel: DomEditSelection; + } | null>(null); + const selectionRef = useRef(selection); selectionRef.current = selection; + + // Close the context menu whenever the selection moves off the element the menu + // targets (a click that reselects elsewhere, a deselect, or a preview reload + // that rebuilds the selection). Without this the menu can linger — orphaned — + // over a stale target after the underlying element is gone. A right-click that + // OPENS the menu also selects its target, so the common open path keeps the + // menu (same element) rather than immediately dismissing it. + useEffect(() => { + if (!contextMenu) return; + if (!selection || selection.element !== contextMenu.sel.element) { + setContextMenu(null); + } + }, [selection, contextMenu]); + const activeCompositionPathRef = useRef(activeCompositionPath); activeCompositionPathRef.current = activeCompositionPath; const groupSelectionsRef = useRef(groupSelections); @@ -242,6 +281,23 @@ export const DomEditOverlay = memo(function DomEditOverlay({ snapGuidesRef, }); + // Arrow-key nudge (1px, Shift = 10px) — commits through the same + // path-offset callbacks as a drag, one undo entry per key burst. + const { flushNudge } = useDomEditNudge({ + selection, + groupSelections, + allowCanvasMovement, + selectionRef, + overlayRectRef, + groupOverlayItemsRef, + gestureRef, + groupGestureRef, + blockedMoveRef, + onManualDragStartRef, + onPathOffsetCommitRef, + onGroupPathOffsetCommitRef, + }); + const marquee = useMarqueeGestures({ iframeRef, overlayRef, @@ -371,6 +427,38 @@ export const DomEditOverlay = memo(function DomEditOverlay({ e.stopPropagation(); }; + // Right-click: select element first (if not already selected), then open menu. + const handleContextMenu = useCallback( + async (event: React.MouseEvent) => { + event.preventDefault(); + + // If no element is selected yet, resolve it from the pointer position first. + const currentSel = selectionRef.current; + let activeSel: DomEditSelection | null = currentSel; + if (!currentSel) { + const pointerEvent = event as unknown as React.PointerEvent; + const resolved = await onCanvasPointerMoveRef.current(pointerEvent); + if (!resolved) return; // Nothing under the cursor — skip menu. + onSelectionChangeRef.current(resolved, { revealPanel: true }); + // Use `resolved` directly: React state (and therefore selectionRef) won't + // update synchronously after onSelectionChange — we'd be reading stale null. + activeSel = resolved; + } else { + // Check if the user right-clicked on an unselected element (hover target). + const hover = hoverSelectionRef.current; + if (hover && hover.element !== currentSel.element) { + onSelectionChangeRef.current(hover, { revealPanel: true }); + activeSel = hover; + } + } + + if (!activeSel) return; + setContextMenu({ x: event.clientX, y: event.clientY, sel: activeSel }); + }, + // eslint-disable-next-line react-hooks/exhaustive-deps + [], + ); + return (
- focusDomEditOverlayElement(event.currentTarget as FocusableDomEditOverlay) - } + onPointerDownCapture={(event) => { + // A pointer gesture supersedes a pending nudge burst — commit it first + // so the gesture's member snapshot starts from the nudged position. + flushNudge(); + focusDomEditOverlayElement(event.currentTarget as FocusableDomEditOverlay); + }} onPointerDown={handleOverlayPointerDown} onMouseDown={handleOverlayMouseDown} onPointerMove={marquee.onPointerMove} onPointerLeave={() => onCanvasPointerLeaveRef.current()} onPointerUp={marquee.onPointerUp} onPointerCancel={marquee.onPointerCancel} + onContextMenu={handleContextMenu} > {hoverSelection && hoverRect && compRect.width > 0 && (
)} {hasGroupSelection && groupOverlayItems.length > 1 && groupBounds && compRect.width > 0 && ( - <> - {groupOverlayItems.map((item) => ( -