diff --git a/frontend/canvas.html b/frontend/canvas.html index 67fdcae..a3b6cca 100644 --- a/frontend/canvas.html +++ b/frontend/canvas.html @@ -2246,24 +2246,73 @@ Organization : OptiHK Limited // Displays generated layout SVG previews with zoom and pan controls. const LayoutSvgPreview = ({ page }) => { - const [layoutScale, setLayoutScale] = useState(100); + const [layoutScale, setLayoutScale] = useState(null); + const [previewViewport, setPreviewViewport] = useState({ width: 1, height: 1 }); + const [svgSize, setSvgSize] = useState(null); + const previewCanvasRef = useRef(null); const previewBounds = useMemo( () => page.layoutBounds || calculateLayoutBounds(page), [page.layoutBounds, page.nodes, page.canvasSize] ); - const normalizedScale = Math.min(800, Math.max(10, Number(layoutScale) || 100)); - const stageWidth = Math.max(1, previewBounds.width) * normalizedScale / 100; - const stageHeight = Math.max(1, previewBounds.height) * normalizedScale / 100; + const minLayoutScale = 0.01; + const maxLayoutScale = 800; + const scalePrecision = 100; + const clampLayoutScale = (value, fallback = 100) => { + const numericValue = Number(value); + const scale = Number.isFinite(numericValue) && numericValue > 0 ? numericValue : fallback; + return Number(Math.min(maxLayoutScale, Math.max(minLayoutScale, scale)).toFixed(2)); + }; + const baseWidth = Math.max(1, svgSize?.width || previewBounds.width); + const baseHeight = Math.max(1, svgSize?.height || previewBounds.height); + const availableWidth = Math.max(1, previewViewport.width); + const availableHeight = Math.max(1, previewViewport.height); + const rawFitScalePercent = Math.min(availableWidth / baseWidth, availableHeight / baseHeight) * 100; + const fitScalePercent = clampLayoutScale(Math.floor(rawFitScalePercent * scalePrecision) / scalePrecision); + const normalizedScale = clampLayoutScale(layoutScale ?? fitScalePercent, fitScalePercent); + const stageWidth = baseWidth * normalizedScale / 100; + const stageHeight = baseHeight * normalizedScale / 100; + + useEffect(() => { + const previewCanvas = previewCanvasRef.current; + if (!previewCanvas) return undefined; + + const measurePreviewViewport = () => { + const styles = window.getComputedStyle(previewCanvas); + const paddingX = (parseFloat(styles.paddingLeft) || 0) + (parseFloat(styles.paddingRight) || 0); + const paddingY = (parseFloat(styles.paddingTop) || 0) + (parseFloat(styles.paddingBottom) || 0); + setPreviewViewport({ + width: Math.max(1, previewCanvas.clientWidth - paddingX), + height: Math.max(1, previewCanvas.clientHeight - paddingY) + }); + }; + + measurePreviewViewport(); + if (typeof ResizeObserver === 'undefined') { + window.addEventListener('resize', measurePreviewViewport); + return () => window.removeEventListener('resize', measurePreviewViewport); + } + + const observer = new ResizeObserver(measurePreviewViewport); + observer.observe(previewCanvas); + return () => observer.disconnect(); + }, []); const updateScale = (value) => { - setLayoutScale(Math.min(800, Math.max(10, Number(value) || 100))); + setLayoutScale(clampLayoutScale(value, fitScalePercent)); }; const handleWheel = (event) => { event.preventDefault(); const direction = event.deltaY > 0 ? -1 : 1; const step = event.shiftKey ? 5 : 15; - setLayoutScale(current => Math.min(800, Math.max(10, (Number(current) || 100) + direction * step))); + setLayoutScale(current => clampLayoutScale((current ?? fitScalePercent) + direction * step, fitScalePercent)); + }; + + const handleSvgLoad = (event) => { + const image = event.currentTarget; + if (image.naturalWidth > 0 && image.naturalHeight > 0) { + setSvgSize({ width: image.naturalWidth, height: image.naturalHeight }); + } }; return ( @@ -2273,9 +2322,9 @@ Organization : OptiHK Limited Scale updateScale(event.target.value)} aria-label="Layout SVG preview scale" @@ -2284,9 +2333,9 @@ Organization : OptiHK Limited -
+
diff --git a/tests/layout-ui-wiring.test.js b/tests/layout-ui-wiring.test.js index 0a53352..89440e2 100644 --- a/tests/layout-ui-wiring.test.js +++ b/tests/layout-ui-wiring.test.js @@ -33,16 +33,6 @@ assert( 'Build Layout should use the backend svg_url response' ); assert( -<<<<<<< HEAD - canvasHtml.includes('result.svg_ready && result.svg_url') && - canvasHtml.includes('buildLayoutRequestRef') && - canvasHtml.includes('buildLayoutBusyRef') && - canvasHtml.includes("cache: 'no-store'"), - 'Build Layout should wait for a ready, versioned SVG response and prevent stale duplicate preview updates' -); -assert( -======= ->>>>>>> jingwen_main canvasHtml.includes('result.preview_error') && canvasHtml.includes('Preview skipped: '), 'Build Layout should log when the backend saves YAML but skips SVG preview because the router stack is unavailable' @@ -60,8 +50,18 @@ assert( 'layout SVG preview should expose an editable scale value' ); assert( - canvasHtml.includes('objectFit: \'contain\''), - '100% layout preview scale should fit the full SVG within the screen' + canvasHtml.includes('objectFit: \'contain\'') && + canvasHtml.includes('previewCanvasRef') && + canvasHtml.includes('ResizeObserver') && + canvasHtml.includes('svgSize') && + canvasHtml.includes('naturalWidth') && + canvasHtml.includes('naturalHeight') && + canvasHtml.includes('const fitScalePercent = clampLayoutScale(Math.floor') && + canvasHtml.includes('layoutScale ?? fitScalePercent') && + canvasHtml.includes('const stageWidth = baseWidth * normalizedScale / 100') && + !canvasHtml.includes('useState(100)') && + !canvasHtml.includes('baseWidth * fitScale * normalizedScale / 100'), + 'layout preview should default to the actual fitted SVG scale percent instead of redefining 100% as fit' ); assert( canvasHtml.includes('className="build-gds-btn"'), @@ -354,7 +354,7 @@ assert( canvasHtml.includes('layoutBounds') && canvasHtml.includes('stageWidth') && canvasHtml.includes('stageHeight'), - 'layout preview should mouse-wheel zoom and size 100% from calculated box_size layout bounds' + 'layout preview should mouse-wheel zoom from the actual calculated SVG scale' ); assert( canvasHtml.includes('reactFlowInstance.fitBounds') &&