@@ -60,7 +60,8 @@ import { buildLocalizedSlugPath, buildLocalizedDynamicPageUrl } from '@/lib/page
6060import { getTranslationValue } from '@/lib/localisation-utils' ;
6161import { cn } from '@/lib/utils' ;
6262import { getCollectionVariable , canDeleteLayer , findLayerById , findParentCollectionLayer , canLayerHaveLink , updateLayerProps , removeRichTextSublayer } from '@/lib/layer-utils' ;
63- import { CANVAS_BORDER , CANVAS_PADDING } from '@/lib/canvas-utils' ;
63+ import { CANVAS_BORDER , CANVAS_PADDING , updateViewportOverrides } from '@/lib/canvas-utils' ;
64+ import { BREAKPOINTS } from '@/lib/breakpoint-utils' ;
6465import { buildFieldGroupsForLayer , flattenFieldGroups , filterFieldGroupsByType , SIMPLE_TEXT_FIELD_TYPES } from '@/lib/collection-field-utils' ;
6566import { buildFieldVariableData } from '@/lib/variable-format-utils' ;
6667import { getRichTextValue } from '@/lib/tiptap-utils' ;
@@ -105,9 +106,13 @@ interface CenterCanvasProps {
105106 liveComponentUpdates ?: UseLiveComponentUpdatesReturn | null ;
106107}
107108
109+ // Viewport widths are derived from BREAKPOINTS to avoid sitting on exact
110+ // breakpoint boundaries where CSS zoom sub-pixel rounding can toggle styles.
111+ const MOBILE_MAX_WIDTH = BREAKPOINTS . find ( bp => bp . value === 'mobile' ) ! . maxWidth ! ;
112+
108113const viewportSizes : Record < ViewportMode , { width : string ; label : string ; icon : string } > = {
109114 desktop : { width : '1366px' , label : 'Desktop' , icon : '🖥️' } ,
110- tablet : { width : '768px' , label : 'Tablet' , icon : '📱' } ,
115+ tablet : { width : ` ${ MOBILE_MAX_WIDTH + 10 } px` , label : 'Tablet' , icon : '📱' } ,
111116 mobile : { width : '375px' , label : 'Mobile' , icon : '📱' } ,
112117} ;
113118
@@ -813,6 +818,7 @@ const CenterCanvas = React.memo(function CenterCanvas({
813818 const previewContentWidth = parseInt ( viewportSizes [ viewportMode ] . width ) ;
814819 const {
815820 zoom : previewZoom ,
821+ zoomMode : previewZoomMode ,
816822 zoomIn : previewZoomIn ,
817823 zoomOut : previewZoomOut ,
818824 resetZoom : previewResetZoom ,
@@ -821,7 +827,7 @@ const CenterCanvas = React.memo(function CenterCanvas({
821827 } = useZoom ( {
822828 containerRef : previewContainerRef ,
823829 contentWidth : previewContentWidth ,
824- contentHeight : previewContentHeight || previewContentWidth ,
830+ contentHeight : previewContentHeight || defaultCanvasHeight ,
825831 minZoom : 10 ,
826832 maxZoom : 1000 ,
827833 zoomStep : 10 ,
@@ -833,40 +839,133 @@ const CenterCanvas = React.memo(function CenterCanvas({
833839 const shouldCenter = zoom < zoomToFitLevel ;
834840
835841 // Calculate final iframe height - ensure it fills the visible canvas at any zoom level
836- // When zoomed out (e.g. 52%), the iframe must be taller so that scaled it still fills the canvas
837- // When switching viewports (Desktop → Phone), zoom changes and this recalculates automatically
842+ // When zoomed in or at fit level, stretch the iframe so the scaled result fills the canvas.
843+ // When zoomed out beyond fit, use content height directly — centering handles the gap.
838844 const finalIframeHeight = useMemo ( ( ) => {
839- // For component editing, use content-based height directly (don't force-fill container)
840845 if ( editingComponentId ) return iframeContentHeight ;
841-
842846 if ( ! containerHeight || zoom <= 0 ) return iframeContentHeight ;
847+ if ( shouldCenter ) return iframeContentHeight ;
843848
844- // Minimum iframe height so that scaled iframe fills the visible canvas area
845849 const minHeightForZoom = ( containerHeight - CANVAS_PADDING ) / ( zoom / 100 ) ;
846-
847- // Use the larger of: content height or minimum height for current zoom
848850 return Math . max ( iframeContentHeight , minHeightForZoom ) ;
849- } , [ iframeContentHeight , containerHeight , zoom , editingComponentId ] ) ;
851+ } , [ iframeContentHeight , containerHeight , zoom , editingComponentId , shouldCenter ] ) ;
852+
853+ const previewObserverRef = useRef < ResizeObserver | null > ( null ) ;
854+
855+ /** Measure the preview iframe content and set up a ResizeObserver for re-measurement */
856+ const setupPreviewMeasurement = useCallback ( ( ) => {
857+ previewObserverRef . current ?. disconnect ( ) ;
858+ previewObserverRef . current = null ;
859+
860+ try {
861+ const iframe = iframeRef . current ;
862+ const doc = iframe ?. contentDocument ;
863+ if ( ! iframe || ! doc ?. body ) return ;
864+
865+ const wrapper = iframe . parentElement as HTMLElement | null ;
866+ const containerEl = previewContainerRef . current ;
867+ const refHeight = containerEl
868+ ? containerEl . clientHeight - CANVAS_PADDING
869+ : 0 ;
870+
871+ if ( refHeight > 0 ) {
872+ updateViewportOverrides ( doc , refHeight ) ;
873+ }
874+
875+ let debounceTimer : ReturnType < typeof setTimeout > | undefined ;
876+
877+ const observeBodyChildren = ( ) => {
878+ Array . from ( doc . body . children ) . forEach ( el => {
879+ if ( el instanceof HTMLElement ) observer . observe ( el ) ;
880+ } ) ;
881+ } ;
850882
851- // Recalculate autofit when viewport/breakpoint changes
883+ const observer = new ResizeObserver ( ( ) => {
884+ clearTimeout ( debounceTimer ) ;
885+ debounceTimer = setTimeout ( ( ) => remeasure ( ) , 100 ) ;
886+ } ) ;
887+
888+ const remeasure = ( ) => {
889+ try {
890+ if ( ! wrapper ) return ;
891+
892+ const freshContainerEl = previewContainerRef . current ;
893+ const freshRefHeight = freshContainerEl
894+ ? freshContainerEl . clientHeight - CANVAS_PADDING
895+ : refHeight ;
896+
897+ if ( freshRefHeight <= 0 ) return ;
898+
899+ updateViewportOverrides ( doc , freshRefHeight ) ;
900+
901+ // Disconnect observer before temporary style changes — setting
902+ // body/html height to auto causes h-full children to resize,
903+ // which fires the observer and creates a feedback loop.
904+ observer . disconnect ( ) ;
905+
906+ const prevBodyH = doc . body . style . height ;
907+ const prevHtmlH = doc . documentElement . style . height ;
908+ doc . body . style . height = 'auto' ;
909+ doc . documentElement . style . height = 'auto' ;
910+ void doc . body . offsetHeight ;
911+
912+ const bodyScrollH = doc . body . scrollHeight ;
913+
914+ doc . body . style . height = prevBodyH ;
915+ doc . documentElement . style . height = prevHtmlH ;
916+ void doc . body . offsetHeight ;
917+
918+ observeBodyChildren ( ) ;
919+
920+ if ( bodyScrollH > 0 ) {
921+ setPreviewContentHeight ( bodyScrollH ) ;
922+ }
923+ } catch { /* cross-origin */ }
924+ } ;
925+
926+ remeasure ( ) ;
927+
928+ const images = Array . from ( doc . querySelectorAll ( 'img' ) ) ;
929+ const pendingImages = images . filter ( img => ! img . complete ) ;
930+
931+ if ( pendingImages . length > 0 ) {
932+ let remaining = pendingImages . length ;
933+ const onImageReady = ( ) => {
934+ remaining -- ;
935+ if ( remaining <= 0 ) remeasure ( ) ;
936+ } ;
937+ pendingImages . forEach ( img => {
938+ img . addEventListener ( 'load' , onImageReady , { once : true } ) ;
939+ img . addEventListener ( 'error' , onImageReady , { once : true } ) ;
940+ } ) ;
941+ }
942+
943+ observeBodyChildren ( ) ;
944+
945+ previewObserverRef . current = observer ;
946+ } catch {
947+ // Cross-origin — fall back to 0
948+ }
949+ } , [ ] ) ;
950+
951+ // Re-measure and recalculate zoom when viewport changes
852952 const prevViewportMode = useRef ( viewportMode ) ;
853953 useEffect ( ( ) => {
854954 if ( prevViewportMode . current !== viewportMode ) {
855- // Notify SelectionOverlay to hide outlines during viewport transition
856955 window . dispatchEvent ( new CustomEvent ( 'viewportChange' ) ) ;
857956
858- // Small delay to ensure container dimensions are updated
957+ // Small delay to ensure container dimensions are updated after width change.
958+ // useZoom auto-recalculates for the current mode (fit/autofit/custom) when
959+ // content dimensions change, so we only need to re-measure here.
859960 setTimeout ( ( ) => {
860961 if ( isPreviewMode ) {
861- previewAutofit ( ) ;
862- } else {
863- autofit ( ) ;
962+ setupPreviewMeasurement ( ) ;
864963 }
865964 } , 50 ) ;
866965
867966 prevViewportMode . current = viewportMode ;
868967 }
869- } , [ viewportMode , autofit , isPreviewMode , previewAutofit ] ) ;
968+ } , [ viewportMode , isPreviewMode , setupPreviewMeasurement ] ) ;
870969
871970 // Scroll canvas to selected element if it's off-screen
872971 const prevCanvasLayerIdRef = useRef < string | null > ( null ) ;
@@ -1640,6 +1739,11 @@ const CenterCanvas = React.memo(function CenterCanvas({
16401739 if ( ! iframe ) return ;
16411740 setIsPreviewLoading ( true ) ;
16421741 iframe . src = previewUrl ;
1742+
1743+ return ( ) => {
1744+ previewObserverRef . current ?. disconnect ( ) ;
1745+ previewObserverRef . current = null ;
1746+ } ;
16431747 } , [ isPreviewMode , previewUrl ] ) ;
16441748
16451749 // Autofit when entering preview mode (not on every breakpoint change)
@@ -1653,15 +1757,8 @@ const CenterCanvas = React.memo(function CenterCanvas({
16531757
16541758 const handlePreviewLoad = useCallback ( ( ) => {
16551759 setIsPreviewLoading ( false ) ;
1656- try {
1657- const doc = iframeRef . current ?. contentDocument ;
1658- if ( doc ) {
1659- setPreviewContentHeight ( doc . documentElement . scrollHeight ) ;
1660- }
1661- } catch {
1662- // Cross-origin — fall back to 0
1663- }
1664- } , [ ] ) ;
1760+ setupPreviewMeasurement ( ) ;
1761+ } , [ setupPreviewMeasurement ] ) ;
16651762
16661763 // Load collection items when dynamic page is selected
16671764 useEffect ( ( ) => {
@@ -2321,6 +2418,7 @@ const CenterCanvas = React.memo(function CenterCanvas({
23212418 editingComponentVariables = { editingComponentVariables }
23222419 disableEditorHiddenLayers = { ! ! activeInteractionTriggerLayerId }
23232420 zoom = { zoom }
2421+ referenceViewportHeight = { defaultCanvasHeight }
23242422 />
23252423
23262424 { /* Drop indicator overlay - subscribes to store directly */ }
@@ -2579,11 +2677,15 @@ const CenterCanvas = React.memo(function CenterCanvas({
25792677 </ div >
25802678 ) }
25812679 < div
2582- className = "bg-white shadow-3xl relative mx-auto"
2680+ className = "bg-white shadow-3xl relative mx-auto my-auto "
25832681 style = { {
25842682 zoom : previewZoom / 100 ,
2585- width : viewportMode === 'desktop' ? '100%' : viewportSizes [ viewportMode ] . width ,
2586- minWidth : viewportMode === 'desktop' ? viewportSizes [ viewportMode ] . width : undefined ,
2683+ width : viewportMode === 'desktop' && previewZoomMode === 'autofit'
2684+ ? '100%'
2685+ : viewportSizes [ viewportMode ] . width ,
2686+ minWidth : viewportMode === 'desktop' && previewZoomMode === 'autofit'
2687+ ? viewportSizes [ viewportMode ] . width
2688+ : undefined ,
25872689 height : previewContentHeight > 0 ? `${ previewContentHeight } px` : '100%' ,
25882690 flexShrink : 0 ,
25892691 transition : 'none' ,
0 commit comments