From c52fd83e06b1faf2a9f719cd539e8791c1de34e0 Mon Sep 17 00:00:00 2001 From: Wassim SAMAD Date: Fri, 3 Jul 2026 09:21:27 -0400 Subject: [PATCH 1/3] feat(editor): stage overlay slot, preselectable snapshot crop, pro capture overlay MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - Editor/EditorLayoutV2 gain a stageOverlay slot rendered over the canvas but under the viewer toolbar (z-10 < z-20), so hosts can swap in a full-stage surface (e.g. studio gallery) without unmounting the WebGL canvas; FloatingLevelSelector hides while a stage overlay is active - CaptureMode 'standard' accepts an optional crop preselection (SnapshotCropMode, exported) so hosts can enter capture with standard/viewport/area already chosen - SnapshotCaptureOverlay restyle: letterboxed 16:9 frame with corner accents + rule-of-thirds for standard mode, thirds grid in viewport and area modes, top-center crop/format HUD chips, shutter button with capture/saved states — all existing crop, drag, resize and preset behaviors preserved Co-Authored-By: Claude Fable 5 --- .../components/editor/editor-layout-v2.tsx | 9 + .../editor/src/components/editor/index.tsx | 10 +- .../editor/snapshot-capture-overlay.tsx | 306 +++++++++++------- packages/editor/src/index.tsx | 2 + packages/editor/src/store/use-editor.tsx | 7 +- 5 files changed, 220 insertions(+), 114 deletions(-) diff --git a/packages/editor/src/components/editor/editor-layout-v2.tsx b/packages/editor/src/components/editor/editor-layout-v2.tsx index e3ed4aa63..f9099c600 100644 --- a/packages/editor/src/components/editor/editor-layout-v2.tsx +++ b/packages/editor/src/components/editor/editor-layout-v2.tsx @@ -150,11 +150,13 @@ function RightColumn({ toolbarRight, children, overlays, + stageOverlay, }: { toolbarLeft?: ReactNode toolbarRight?: ReactNode children: ReactNode overlays?: ReactNode + stageOverlay?: ReactNode }) { return (
{children}
+ {/* Stage overlay — replaces the canvas visually (e.g. studio gallery) + while keeping it mounted. Sits below the viewer toolbar (z-20) so + the stage switch stays reachable. */} + {stageOverlay &&
{stageOverlay}
} {/* Overlays scoped to the viewer column. `data-viewer-bounds` marks the draggable region the floating inspector clamps itself to. */} {overlays && ( @@ -200,6 +206,7 @@ export interface EditorLayoutV2Props { viewerToolbarRight?: ReactNode viewerContent: ReactNode overlays?: ReactNode + stageOverlay?: ReactNode } export function EditorLayoutV2({ @@ -211,6 +218,7 @@ export function EditorLayoutV2({ viewerToolbarRight, viewerContent, overlays, + stageOverlay, }: EditorLayoutV2Props) { const isCaptureMode = useEditor((s) => s.isCaptureMode) const isMobile = useIsMobile() @@ -246,6 +254,7 @@ export function EditorLayoutV2({ )} diff --git a/packages/editor/src/components/editor/index.tsx b/packages/editor/src/components/editor/index.tsx index d3a19d677..d12e33f95 100644 --- a/packages/editor/src/components/editor/index.tsx +++ b/packages/editor/src/components/editor/index.tsx @@ -137,6 +137,12 @@ export interface EditorProps { sidebarTabs?: (SidebarTab & { component: React.ComponentType })[] viewerToolbarLeft?: ReactNode viewerToolbarRight?: ReactNode + /** + * Full-bleed surface swapped in over the 3D canvas (v2) — e.g. the studio + * gallery. The canvas stays mounted underneath (no WebGL re-init) and the + * viewer toolbar stays on top so the host's stage switch remains reachable. + */ + stageOverlay?: ReactNode /** * Docked below the node inspector (v2). Hosts mount the "save as preset" * affordance here so it reads as part of the inspector surface and shows @@ -1077,6 +1083,7 @@ export default function Editor({ sidebarTabs, viewerToolbarLeft, viewerToolbarRight, + stageOverlay, inspectorFooter, projectId, onLoad, @@ -1364,7 +1371,7 @@ export default function Editor({ navbarSlot={navbarSlot} overlays={ <> - {!isCaptureMode && } + {!(isCaptureMode || stageOverlay) && } {!(isVersionPreviewMode || isCaptureMode || isStudioMode) && (
@@ -1392,6 +1399,7 @@ export default function Editor({ renderTabContent={renderTabContent} sidebarOverlay={sidebarOverlay} sidebarTabs={tabBarTabs} + stageOverlay={stageOverlay} viewerContent={viewerCanvas} viewerToolbarLeft={viewerToolbarLeft} viewerToolbarRight={viewerToolbarRight} diff --git a/packages/editor/src/components/editor/snapshot-capture-overlay.tsx b/packages/editor/src/components/editor/snapshot-capture-overlay.tsx index d3732e751..3fd8d1093 100644 --- a/packages/editor/src/components/editor/snapshot-capture-overlay.tsx +++ b/packages/editor/src/components/editor/snapshot-capture-overlay.tsx @@ -1,17 +1,17 @@ 'use client' import { emitter } from '@pascal-app/core' -import { Camera, Check, Crop, Loader2, Maximize2, Monitor, X } from 'lucide-react' +import { Check, Crop, Loader2, Maximize2, Monitor, X } from 'lucide-react' import { useCallback, useEffect, useRef, useState } from 'react' import { useIsMobile } from '../../hooks/use-mobile' import { triggerSFX } from '../../lib/sfx-bus' -import useEditor from '../../store/use-editor' +import useEditor, { type SnapshotCropMode } from '../../store/use-editor' -// Local crop-mode enum — distinct from `useEditor.captureMode` (which -// describes *why* a capture is happening, e.g. `preset`). This one says -// HOW the captured pixels are cropped: full-frame 16:9 (`standard`), -// raw canvas viewport, or user-dragged area. -type CropMode = 'standard' | 'viewport' | 'area' +// Local alias — distinct from `useEditor.captureMode` (which describes *why* +// a capture is happening, e.g. `preset`). This one says HOW the captured +// pixels are cropped: full-frame 16:9 (`standard`), raw canvas viewport, or +// user-dragged area. Hosts can preselect it via `captureMode.crop`. +type CropMode = SnapshotCropMode type CaptureState = 'idle' | 'capturing' | 'saved' interface DragPoint { @@ -49,6 +49,41 @@ function getResolution( return null } +/** Rule-of-thirds guide rendered inside a framing surface. */ +function ThirdsGrid() { + return ( +
+ ) +} + +/** Accented corner brackets on the framing rect. */ +function CornerAccents() { + return ( + <> + + + + ) +} + +const HUD_CHIP_CLASS = + 'flex flex-col gap-px rounded-lg border border-white/10 bg-neutral-950/85 px-3 py-1.5 backdrop-blur-md' + +const CROP_LABELS: Record = { + standard: 'Standard', + viewport: 'Viewport', + area: 'Area', +} + export function SnapshotCaptureOverlay({ projectId }: { projectId: string }) { const isCaptureMode = useEditor((s) => s.isCaptureMode) const captureMode = useEditor((s) => s.captureMode) @@ -58,12 +93,26 @@ export function SnapshotCaptureOverlay({ projectId }: { projectId: string }) { // a transparent background — the user picks framing but not the // crop shape. Matches the unified preset-thumbnail capture flow. const isPreset = captureMode.mode === 'preset' + const requestedCrop = captureMode.mode === 'standard' ? captureMode.crop : undefined const [mode, setMode] = useState('standard') const [drag, setDrag] = useState(null) const [isDragging, setIsDragging] = useState(false) const [captureState, setCaptureState] = useState('idle') const overlayRef = useRef(null) + // Overlay size drives the computed standard-frame rect + resolution HUD. + const [overlaySize, setOverlaySize] = useState<{ w: number; h: number } | null>(null) + + useEffect(() => { + if (!isCaptureMode) return + const el = overlayRef.current + if (!el) return + const update = () => setOverlaySize({ w: el.clientWidth, h: el.clientHeight }) + update() + const observer = new ResizeObserver(update) + observer.observe(el) + return () => observer.disconnect() + }, [isCaptureMode]) // Dismiss on Esc useEffect(() => { @@ -82,7 +131,7 @@ export function SnapshotCaptureOverlay({ projectId }: { projectId: string }) { // tweak the framing, but they don't have to draw the rect first. useEffect(() => { if (!isCaptureMode) return - setMode(isPreset ? 'area' : 'standard') + setMode(isPreset ? 'area' : (requestedCrop ?? 'standard')) setIsDragging(false) setCaptureState('idle') if (isPreset && overlayRef.current) { @@ -97,7 +146,7 @@ export function SnapshotCaptureOverlay({ projectId }: { projectId: string }) { } else { setDrag(null) } - }, [isCaptureMode, isPreset]) + }, [isCaptureMode, isPreset, requestedCrop]) // Listen for snapshot saved to show feedback then exit useEffect(() => { @@ -276,6 +325,23 @@ export function SnapshotCaptureOverlay({ projectId }: { projectId: string }) { const resolution = getResolution(mode, overlayRef.current, drag) + // Standard mode framing: the output is a center-crop of the canvas to 16:9 + // (see ThumbnailGenerator) — show exactly that region as a letterboxed frame. + const standardFrame = + mode === 'standard' && overlaySize + ? (() => { + const targetRatio = 16 / 9 + const w = Math.min(overlaySize.w, overlaySize.h * targetRatio) + const h = w / targetRatio + return { + left: (overlaySize.w - w) / 2, + top: (overlaySize.h - h) / 2, + width: w, + height: h, + } + })() + : null + // Area selection rect (CSS px, relative to overlay) const selectionStyle = mode === 'area' && drag @@ -294,6 +360,23 @@ export function SnapshotCaptureOverlay({ projectId }: { projectId: string }) { return (
+ {/* Standard mode: letterboxed 16:9 frame with thirds + corner accents */} + {standardFrame && ( +
+ + +
+ )} + + {/* Viewport mode: the whole canvas is the frame — thirds only */} + {mode === 'viewport' && } + {/* Area mode: dim layer + crosshair cursor + drag-to-select. * * Preset mode reuses the same DOM but stays click-through: the @@ -336,7 +419,7 @@ export function SnapshotCaptureOverlay({ projectId }: { projectId: string }) { has a pre-staged square, so we never show it there. */} {!selectionStyle && !isPreset && (
- + Drag the area you want to capture
@@ -345,6 +428,7 @@ export function SnapshotCaptureOverlay({ projectId }: { projectId: string }) { {/* Selection rect */} {selectionStyle && (
+ {hasSelection && } + {/* Corner handles — preset mode locks the frame to the auto-staged centered square; the user adjusts the camera instead. */} @@ -390,11 +475,33 @@ export function SnapshotCaptureOverlay({ projectId }: { projectId: string }) {
)} + {/* Top-center HUD — what the shot will be */} + {!isMobile && ( +
+
+ + Crop + + + {isPreset ? 'Preset · square' : CROP_LABELS[mode]} + +
+
+ + Format + + + {resolution ? `${resolution.w} × ${resolution.h}` : '—'} + +
+
+ )} + {/* Top-right dismiss button (icon-only on mobile) */}
- {/* Bottom-center mode toolbar */} -
- {(() => { - // Preset capture mode locks both the crop shape (square) and - // the transparent-background output — hide the per-shape - // mode buttons so the user has nothing to second-guess. - const modeButtons = isPreset ? null : ( - <> - } - label="Standard" - onClick={() => { - setMode('standard') - setDrag(null) - }} - /> - } - label="Viewport" - onClick={() => { - setMode('viewport') - setDrag(null) - }} - /> - } - label="Area" - onClick={() => setMode('area')} - /> - - ) - - const resolutionDisplay = ( - - {resolution ? `${resolution.w} × ${resolution.h}` : '—'} - - ) - - const captureButton = ( - - ) - - if (isMobile) { - return ( -
- {modeButtons && ( -
{modeButtons}
- )} -
- {resolutionDisplay} - {captureButton} -
-
- ) - } + {/* Bottom-center: crop switcher, caption + shutter */} +
+ {!isPreset && ( +
+ } + label={isMobile ? undefined : 'Standard'} + onClick={() => { + setMode('standard') + setDrag(null) + }} + /> + } + label={isMobile ? undefined : 'Viewport'} + onClick={() => { + setMode('viewport') + setDrag(null) + }} + /> + } + label={isMobile ? undefined : 'Area'} + onClick={() => setMode('area')} + /> + {isMobile && ( + + {resolution ? `${resolution.w} × ${resolution.h}` : '—'} + + )} +
+ )} - return ( -
- {modeButtons} - {modeButtons &&
} - {resolutionDisplay} -
- {captureButton} -
- ) - })()} + {!isMobile && ( + + A snapshot freezes this exact camera angle + as a reusable reference for renders & videos. + + )} + + + + {captureState === 'capturing' + ? 'Capturing…' + : captureState === 'saved' + ? 'Saved' + : 'Take snapshot'} +
) @@ -515,7 +597,7 @@ function ModeButton({ }: { active: boolean icon: React.ReactNode - label: string + label?: string badge?: string onClick: () => void }) { diff --git a/packages/editor/src/index.tsx b/packages/editor/src/index.tsx index b06755068..04449955b 100644 --- a/packages/editor/src/index.tsx +++ b/packages/editor/src/index.tsx @@ -365,7 +365,9 @@ export { default as useAlignmentGuides } from './store/use-alignment-guides' export { default as useAudio } from './store/use-audio' export { type CommandAction, useCommandRegistry } from './store/use-command-registry' export type { + CaptureMode, FloorplanSelectionTool, + SnapshotCropMode, SplitOrientation, Tool, ToolDefaults, diff --git a/packages/editor/src/store/use-editor.tsx b/packages/editor/src/store/use-editor.tsx index de237275a..e3ccd8f66 100644 --- a/packages/editor/src/store/use-editor.tsx +++ b/packages/editor/src/store/use-editor.tsx @@ -82,9 +82,14 @@ export type WorkspaceMode = 'edit' | 'studio' // — `ThumbnailGenerator` consults `captureMode.mode === 'preset'` and // applies those constraints. Keeping it a discriminated union lets us // add future modes without surfacing the choice to end users. +// How the captured pixels are cropped: full-frame 16:9, raw canvas viewport, +// or user-dragged area. Hosts (e.g. the studio capture bar) can preselect it +// when entering capture mode. +export type SnapshotCropMode = 'standard' | 'viewport' | 'area' + export type CaptureMode = | { mode: 'idle' } - | { mode: 'standard' } + | { mode: 'standard'; crop?: SnapshotCropMode } | { mode: 'preset' isolated: AnyNodeId[] From c00945ba0e98e36e6d2452bedff40d6e17e33fdf Mon Sep 17 00:00:00 2001 From: Wassim SAMAD Date: Fri, 3 Jul 2026 10:28:49 -0400 Subject: [PATCH 2/3] feat(editor): standard-capture aspect presets, capture scrim, caption fix MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - Standard crop gains aspect presets (16:9, 9:16, 4:3, 3:4, 1:1) — clicking the active Standard chip opens the picker; the letterbox frame, HUD and output resolution follow the choice - ThumbnailGenerateEvent carries standardSize; ThumbnailGenerator center-crops to it (default stays 1920×1080) - Subtle bottom scrim keeps shutter/caption readable on bright scenes - Fix missing space in the snapshot caption Co-Authored-By: Claude Fable 5 --- packages/core/src/events/bus.ts | 5 ++ .../editor/snapshot-capture-overlay.tsx | 70 ++++++++++++++++--- .../components/editor/thumbnail-generator.tsx | 25 ++++--- 3 files changed, 81 insertions(+), 19 deletions(-) diff --git a/packages/core/src/events/bus.ts b/packages/core/src/events/bus.ts index 6374cbb40..42e6ab9ee 100644 --- a/packages/core/src/events/bus.ts +++ b/packages/core/src/events/bus.ts @@ -156,6 +156,11 @@ export interface ThumbnailGenerateEvent { projectId: string captureMode?: 'standard' | 'viewport' | 'area' cropRegion?: { x: number; y: number; width: number; height: number } + /** + * Output size for `standard` captures (center-crop target). Defaults to + * 1920×1080; the capture overlay passes other aspect presets (9:16, 4:3…). + */ + standardSize?: { w: number; h: number } /** * When true, snap levels to their true positions before capturing (for a * consistent auto-thumbnail angle) and defer the capture if the tab is diff --git a/packages/editor/src/components/editor/snapshot-capture-overlay.tsx b/packages/editor/src/components/editor/snapshot-capture-overlay.tsx index 3fd8d1093..c8deb11a2 100644 --- a/packages/editor/src/components/editor/snapshot-capture-overlay.tsx +++ b/packages/editor/src/components/editor/snapshot-capture-overlay.tsx @@ -24,12 +24,23 @@ interface Drag { end: DragPoint } +// Output presets for `standard` captures — long edge stays near 1920. +const STANDARD_SIZES = { + '16:9': { w: 1920, h: 1080 }, + '9:16': { w: 1080, h: 1920 }, + '4:3': { w: 1920, h: 1440 }, + '3:4': { w: 1440, h: 1920 }, + '1:1': { w: 1440, h: 1440 }, +} as const +type StandardAspect = keyof typeof STANDARD_SIZES + function getResolution( mode: CropMode, overlayEl: HTMLDivElement | null, drag: Drag | null, + standardAspect: StandardAspect, ): { w: number; h: number } | null { - if (mode === 'standard') return { w: 1920, h: 1080 } + if (mode === 'standard') return STANDARD_SIZES[standardAspect] if (!overlayEl) return null const rect = overlayEl.getBoundingClientRect() @@ -96,6 +107,8 @@ export function SnapshotCaptureOverlay({ projectId }: { projectId: string }) { const requestedCrop = captureMode.mode === 'standard' ? captureMode.crop : undefined const [mode, setMode] = useState('standard') + const [standardAspect, setStandardAspect] = useState('16:9') + const [aspectMenuOpen, setAspectMenuOpen] = useState(false) const [drag, setDrag] = useState(null) const [isDragging, setIsDragging] = useState(false) const [captureState, setCaptureState] = useState('idle') @@ -132,6 +145,7 @@ export function SnapshotCaptureOverlay({ projectId }: { projectId: string }) { useEffect(() => { if (!isCaptureMode) return setMode(isPreset ? 'area' : (requestedCrop ?? 'standard')) + setAspectMenuOpen(false) setIsDragging(false) setCaptureState('idle') if (isPreset && overlayRef.current) { @@ -314,23 +328,26 @@ export function SnapshotCaptureOverlay({ projectId }: { projectId: string }) { projectId, captureMode: mode, cropRegion, + standardSize: mode === 'standard' ? STANDARD_SIZES[standardAspect] : undefined, // In preset mode, the ThumbnailGenerator should keep the alpha // channel transparent so the saved preset thumbnail composes // cleanly onto any palette background. transparent: isPreset, }) - }, [captureState, mode, drag, projectId, isPreset]) + }, [captureState, mode, drag, projectId, isPreset, standardAspect]) if (!isCaptureMode) return null - const resolution = getResolution(mode, overlayRef.current, drag) + const resolution = getResolution(mode, overlayRef.current, drag, standardAspect) - // Standard mode framing: the output is a center-crop of the canvas to 16:9 - // (see ThumbnailGenerator) — show exactly that region as a letterboxed frame. + // Standard mode framing: the output is a center-crop of the canvas to the + // chosen aspect (see ThumbnailGenerator) — show exactly that region as a + // letterboxed frame. const standardFrame = mode === 'standard' && overlaySize ? (() => { - const targetRatio = 16 / 9 + const size = STANDARD_SIZES[standardAspect] + const targetRatio = size.w / size.h const w = Math.min(overlaySize.w, overlaySize.h * targetRatio) const h = w / targetRatio return { @@ -510,20 +527,47 @@ export function SnapshotCaptureOverlay({ projectId }: { projectId: string }) {
+ {/* Subtle scrim so the bottom controls stay readable on bright scenes */} +
+ {/* Bottom-center: crop switcher, caption + shutter */}
{!isPreset && ( -
+
+ {/* Clicking Standard while it's active opens the aspect picker */} } label={isMobile ? undefined : 'Standard'} onClick={() => { + if (mode === 'standard') setAspectMenuOpen((v) => !v) + else setAspectMenuOpen(false) setMode('standard') setDrag(null) }} /> + {aspectMenuOpen && mode === 'standard' && ( +
+ {(Object.keys(STANDARD_SIZES) as StandardAspect[]).map((aspect) => ( + + ))} +
+ )} } @@ -531,13 +575,17 @@ export function SnapshotCaptureOverlay({ projectId }: { projectId: string }) { onClick={() => { setMode('viewport') setDrag(null) + setAspectMenuOpen(false) }} /> } label={isMobile ? undefined : 'Area'} - onClick={() => setMode('area')} + onClick={() => { + setMode('area') + setAspectMenuOpen(false) + }} /> {isMobile && ( @@ -549,8 +597,8 @@ export function SnapshotCaptureOverlay({ projectId }: { projectId: string }) { {!isMobile && ( - A snapshot freezes this exact camera angle - as a reusable reference for renders & videos. + A snapshot + {' freezes this exact camera angle as a reusable reference for renders & videos.'} )} diff --git a/packages/editor/src/components/editor/thumbnail-generator.tsx b/packages/editor/src/components/editor/thumbnail-generator.tsx index 72929e3f7..72c840a85 100644 --- a/packages/editor/src/components/editor/thumbnail-generator.tsx +++ b/packages/editor/src/components/editor/thumbnail-generator.tsx @@ -151,7 +151,10 @@ export const ThumbnailGenerator = ({ onThumbnailCapture }: ThumbnailGeneratorPro snapLevels: boolean, captureMode?: 'standard' | 'viewport' | 'area', cropRegion?: { x: number; y: number; width: number; height: number }, + standardSize?: { w: number; h: number }, ) => { + const standardW = standardSize?.w ?? THUMBNAIL_WIDTH + const standardH = standardSize?.h ?? THUMBNAIL_HEIGHT if (isGenerating.current) return if (!onThumbnailCaptureRef.current) return @@ -342,9 +345,9 @@ export const ThumbnailGenerator = ({ onThumbnailCapture }: ThumbnailGeneratorPro offscreen.getContext('2d')!.drawImage(srcCanvas, sx, sy, outW, outH, 0, 0, outW, outH) blob = await offscreen.convertToBlob({ type: 'image/png' }) } else { - // Standard: center-crop to 1920×1080 aspect ratio + // Standard: center-crop to the requested aspect (default 1920×1080) const srcAspect = width / height - const dstAspect = THUMBNAIL_WIDTH / THUMBNAIL_HEIGHT + const dstAspect = standardW / standardH let sx = 0, sy = 0, sWidth = width, @@ -356,8 +359,8 @@ export const ThumbnailGenerator = ({ onThumbnailCapture }: ThumbnailGeneratorPro sHeight = Math.round(width / dstAspect) sy = Math.round((height - sHeight) / 2) } - outW = THUMBNAIL_WIDTH - outH = THUMBNAIL_HEIGHT + outW = standardW + outH = standardH const offscreen = new OffscreenCanvas(outW, outH) offscreen .getContext('2d')! @@ -414,7 +417,7 @@ export const ThumbnailGenerator = ({ onThumbnailCapture }: ThumbnailGeneratorPro ) } else { const srcAspect = width / height - const dstAspect = THUMBNAIL_WIDTH / THUMBNAIL_HEIGHT + const dstAspect = standardW / standardH let sx = 0, sy = 0, sWidth = width, @@ -426,8 +429,8 @@ export const ThumbnailGenerator = ({ onThumbnailCapture }: ThumbnailGeneratorPro sHeight = Math.round(width / dstAspect) sy = Math.round((height - sHeight) / 2) } - outW = THUMBNAIL_WIDTH - outH = THUMBNAIL_HEIGHT + outW = standardW + outH = standardH const offscreen = document.createElement('canvas') offscreen.width = outW offscreen.height = outH @@ -468,6 +471,7 @@ export const ThumbnailGenerator = ({ onThumbnailCapture }: ThumbnailGeneratorPro const handleGenerateThumbnail = async (event: { captureMode?: 'standard' | 'viewport' | 'area' cropRegion?: { x: number; y: number; width: number; height: number } + standardSize?: { w: number; h: number } snapLevels?: boolean // `transparent` is informational here — the render pipeline already // captures with alpha (see `setClearAlpha(0)` above) — the flag is @@ -475,7 +479,12 @@ export const ThumbnailGenerator = ({ onThumbnailCapture }: ThumbnailGeneratorPro // background bits) can branch on it without touching the emitter. transparent?: boolean }) => { - await generate(event.snapLevels === true, event.captureMode, event.cropRegion) + await generate( + event.snapLevels === true, + event.captureMode, + event.cropRegion, + event.standardSize, + ) } emitter.on('camera-controls:generate-thumbnail', handleGenerateThumbnail) From 73fffe38a932db0dd1f9f1163bb80af02004ac93 Mon Sep 17 00:00:00 2001 From: Wassim SAMAD Date: Fri, 3 Jul 2026 10:57:24 -0400 Subject: [PATCH 3/3] feat(editor): standard-capture aspect preselect via CaptureMode Hosts (the studio capbar) can now pass standardAspect alongside crop when entering capture mode; the overlay initializes its aspect picker from it. SnapshotStandardAspect exported. Co-Authored-By: Claude Fable 5 --- .../editor/snapshot-capture-overlay.tsx | 15 ++++++++++----- packages/editor/src/index.tsx | 1 + packages/editor/src/store/use-editor.tsx | 4 +++- 3 files changed, 14 insertions(+), 6 deletions(-) diff --git a/packages/editor/src/components/editor/snapshot-capture-overlay.tsx b/packages/editor/src/components/editor/snapshot-capture-overlay.tsx index c8deb11a2..c8710c031 100644 --- a/packages/editor/src/components/editor/snapshot-capture-overlay.tsx +++ b/packages/editor/src/components/editor/snapshot-capture-overlay.tsx @@ -5,7 +5,10 @@ import { Check, Crop, Loader2, Maximize2, Monitor, X } from 'lucide-react' import { useCallback, useEffect, useRef, useState } from 'react' import { useIsMobile } from '../../hooks/use-mobile' import { triggerSFX } from '../../lib/sfx-bus' -import useEditor, { type SnapshotCropMode } from '../../store/use-editor' +import useEditor, { + type SnapshotCropMode, + type SnapshotStandardAspect, +} from '../../store/use-editor' // Local alias — distinct from `useEditor.captureMode` (which describes *why* // a capture is happening, e.g. `preset`). This one says HOW the captured @@ -25,14 +28,14 @@ interface Drag { } // Output presets for `standard` captures — long edge stays near 1920. -const STANDARD_SIZES = { +const STANDARD_SIZES: Record = { '16:9': { w: 1920, h: 1080 }, '9:16': { w: 1080, h: 1920 }, '4:3': { w: 1920, h: 1440 }, '3:4': { w: 1440, h: 1920 }, '1:1': { w: 1440, h: 1440 }, -} as const -type StandardAspect = keyof typeof STANDARD_SIZES +} +type StandardAspect = SnapshotStandardAspect function getResolution( mode: CropMode, @@ -105,6 +108,7 @@ export function SnapshotCaptureOverlay({ projectId }: { projectId: string }) { // crop shape. Matches the unified preset-thumbnail capture flow. const isPreset = captureMode.mode === 'preset' const requestedCrop = captureMode.mode === 'standard' ? captureMode.crop : undefined + const requestedAspect = captureMode.mode === 'standard' ? captureMode.standardAspect : undefined const [mode, setMode] = useState('standard') const [standardAspect, setStandardAspect] = useState('16:9') @@ -145,6 +149,7 @@ export function SnapshotCaptureOverlay({ projectId }: { projectId: string }) { useEffect(() => { if (!isCaptureMode) return setMode(isPreset ? 'area' : (requestedCrop ?? 'standard')) + setStandardAspect(requestedAspect ?? '16:9') setAspectMenuOpen(false) setIsDragging(false) setCaptureState('idle') @@ -160,7 +165,7 @@ export function SnapshotCaptureOverlay({ projectId }: { projectId: string }) { } else { setDrag(null) } - }, [isCaptureMode, isPreset, requestedCrop]) + }, [isCaptureMode, isPreset, requestedCrop, requestedAspect]) // Listen for snapshot saved to show feedback then exit useEffect(() => { diff --git a/packages/editor/src/index.tsx b/packages/editor/src/index.tsx index 04449955b..6c513c759 100644 --- a/packages/editor/src/index.tsx +++ b/packages/editor/src/index.tsx @@ -368,6 +368,7 @@ export type { CaptureMode, FloorplanSelectionTool, SnapshotCropMode, + SnapshotStandardAspect, SplitOrientation, Tool, ToolDefaults, diff --git a/packages/editor/src/store/use-editor.tsx b/packages/editor/src/store/use-editor.tsx index e3ccd8f66..ec9a9eab1 100644 --- a/packages/editor/src/store/use-editor.tsx +++ b/packages/editor/src/store/use-editor.tsx @@ -86,10 +86,12 @@ export type WorkspaceMode = 'edit' | 'studio' // or user-dragged area. Hosts (e.g. the studio capture bar) can preselect it // when entering capture mode. export type SnapshotCropMode = 'standard' | 'viewport' | 'area' +/** Aspect presets available to `standard` crops. */ +export type SnapshotStandardAspect = '16:9' | '9:16' | '4:3' | '3:4' | '1:1' export type CaptureMode = | { mode: 'idle' } - | { mode: 'standard'; crop?: SnapshotCropMode } + | { mode: 'standard'; crop?: SnapshotCropMode; standardAspect?: SnapshotStandardAspect } | { mode: 'preset' isolated: AnyNodeId[]