Skip to content

Commit 868027c

Browse files
Merge branch 'fix/canvas-screen-units' into develop
2 parents 9d787f7 + 7f94d2d commit 868027c

File tree

3 files changed

+282
-41
lines changed

3 files changed

+282
-41
lines changed

app/(builder)/ycode/components/Canvas.tsx

Lines changed: 38 additions & 11 deletions
Original file line numberDiff line numberDiff line change
@@ -18,7 +18,7 @@ import { createRoot, Root } from 'react-dom/client';
1818
import LayerRenderer from '@/components/LayerRenderer';
1919
import { serializeLayers, getClassesString } from '@/lib/layer-utils';
2020
import { collectEditorHiddenLayerIds } from '@/lib/animation-utils';
21-
import { getCanvasIframeHtml } from '@/lib/canvas-utils';
21+
import { getCanvasIframeHtml, updateViewportOverrides, measureContentExtent } from '@/lib/canvas-utils';
2222
import { CanvasPortalProvider } from '@/lib/canvas-portal-context';
2323
import { cn } from '@/lib/utils';
2424
import { loadSwiperCss } from '@/lib/slider-utils';
@@ -104,6 +104,8 @@ interface CanvasProps {
104104
disableEditorHiddenLayers?: boolean;
105105
/** Current canvas zoom percentage (100 = 100%) */
106106
zoom?: number;
107+
/** Fixed viewport height for stable measurement of content using vh/svh/dvh units */
108+
referenceViewportHeight?: number;
107109
}
108110

109111
/**
@@ -283,6 +285,7 @@ export default function Canvas({
283285
editingComponentVariables,
284286
disableEditorHiddenLayers = false,
285287
zoom = 100,
288+
referenceViewportHeight,
286289
}: CanvasProps) {
287290
// Refs
288291
const iframeRef = useRef<HTMLIFrameElement>(null);
@@ -649,6 +652,10 @@ export default function Canvas({
649652
const doc = iframeRef.current.contentDocument;
650653
if (!doc) return;
651654

655+
// Reset so the first measurement after a breakpoint switch reports immediately
656+
// instead of being delayed by the shrink timer
657+
lastReportedHeightRef.current = 0;
658+
652659
let shrinkTimer: ReturnType<typeof setTimeout> | undefined;
653660

654661
const reportHeight = (height: number) => {
@@ -694,18 +701,27 @@ export default function Canvas({
694701
}
695702
}
696703

697-
// Page mode: measure full document height
698-
const height = Math.max(
699-
body.scrollHeight,
700-
body.offsetHeight,
701-
doc.documentElement?.scrollHeight || 0,
702-
doc.documentElement?.offsetHeight || 0
703-
);
704-
reportHeight(height);
704+
// Override viewport-height units (vh, svh, dvh, lvh) with fixed pixel
705+
// values so layers using these units don't grow with the iframe height.
706+
if (referenceViewportHeight && referenceViewportHeight > 0) {
707+
updateViewportOverrides(doc, referenceViewportHeight);
708+
}
709+
710+
// Page mode: use content extent (actual child bounds) rather than
711+
// scrollHeight, which inflates when body h-full fills the iframe.
712+
const extent = measureContentExtent(doc);
713+
if (extent > 0) {
714+
reportHeight(extent);
715+
}
705716
};
706717

707-
// Measure after render
718+
// Measure after render — multiple passes to handle Tailwind CDN race.
719+
// Tailwind Browser CDN processes classes asynchronously via CSSOM APIs
720+
// (not DOM mutations), so the MutationObserver alone can't detect when
721+
// styles are applied. measureContentExtent is immune to iframe inflation,
722+
// so later passes safely converge to the correct value.
708723
const timeoutId = setTimeout(measureContent, 100);
724+
const lateTimeoutId = setTimeout(measureContent, 500);
709725

710726
// Debounce observer to avoid measuring during transient DOM states
711727
let observerTimer: ReturnType<typeof setTimeout> | undefined;
@@ -722,13 +738,24 @@ export default function Canvas({
722738
attributes: true,
723739
});
724740

741+
// Also watch <head> for Tailwind CDN style injections that change layout
742+
// Without this, the initial measurement fires before CSS is applied,
743+
// and no body mutation triggers a re-measure after styles settle.
744+
if (doc.head) {
745+
observer.observe(doc.head, {
746+
childList: true,
747+
subtree: true,
748+
});
749+
}
750+
725751
return () => {
726752
clearTimeout(timeoutId);
753+
clearTimeout(lateTimeoutId);
727754
clearTimeout(shrinkTimer);
728755
clearTimeout(observerTimer);
729756
observer.disconnect();
730757
};
731-
}, [iframeReady, onContentHeightChange, onContentWidthChange, resolvedLayers]);
758+
}, [iframeReady, onContentHeightChange, onContentWidthChange, resolvedLayers, referenceViewportHeight, breakpoint]);
732759

733760
// Handle zoom gestures from iframe (Ctrl+wheel, trackpad pinch)
734761
useEffect(() => {

app/(builder)/ycode/components/CenterCanvas.tsx

Lines changed: 132 additions & 30 deletions
Original file line numberDiff line numberDiff line change
@@ -60,7 +60,8 @@ import { buildLocalizedSlugPath, buildLocalizedDynamicPageUrl } from '@/lib/page
6060
import { getTranslationValue } from '@/lib/localisation-utils';
6161
import { cn } from '@/lib/utils';
6262
import { 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';
6465
import { buildFieldGroupsForLayer, flattenFieldGroups, filterFieldGroupsByType, SIMPLE_TEXT_FIELD_TYPES } from '@/lib/collection-field-utils';
6566
import { buildFieldVariableData } from '@/lib/variable-format-utils';
6667
import { 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+
108113
const 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

Comments
 (0)