fix: responsive canvas dimensions for static and infinite scroll pages#27
fix: responsive canvas dimensions for static and infinite scroll pages#27anth0nycodes wants to merge 13 commits intomainfrom
Conversation
There was a problem hiding this comment.
Pull request overview
Updates Tracemark’s Fabric canvas sizing logic to better support responsive layouts and infinite-scroll pages by dynamically expanding height based on scroll position while introducing a maximum height cap.
Changes:
- Split canvas sizing into width, base height, and scroll-driven height expansion helpers.
- Add scroll handler + ResizeObserver-based resizing to react to page changes.
- Introduce an 8,000px canvas height cap (with an alert) to limit performance impact.
💡 Add Copilot custom instructions for smarter, more guided reviews. Learn how to get started.
| updateCanvasWidth(fc); | ||
| updateCanvasHeight(fc); |
There was a problem hiding this comment.
handleResize calls updateCanvasHeight, which sets the canvas height to clientHeight (viewport). Because ResizeObserver is observing documentElement/body, it can fire on content growth (e.g., infinite scroll) and shrink an already-expanded canvas back down to the viewport, potentially hiding existing drawings. Consider making resize only adjust width, or set height to Math.max(fc.getHeight(), viewportHeight/current desired height), or invoke the same dynamic-height logic instead of forcing clientHeight.
| updateCanvasWidth(fc); | |
| updateCanvasHeight(fc); | |
| // On resize, only adjust the canvas width. Height is managed dynamically | |
| // via updateDynamicCanvasHeight to avoid shrinking an already-expanded canvas. | |
| updateCanvasWidth(fc); |
| return () => { | ||
| fc.dispose(); | ||
| window.removeEventListener("resize", initCanvasDimensions); | ||
| resizeObserver.disconnect(); | ||
| window.removeEventListener("scroll", handleScroll); | ||
| }; |
There was a problem hiding this comment.
resizeTimeout can still fire after unmount/fc.dispose() (e.g., a ResizeObserver callback scheduled right before cleanup), which would call handleResize on a disposed Fabric canvas. Clear the pending timeout in the cleanup function to avoid post-dispose dimension updates/errors.
| window.addEventListener("resize", initCanvasDimensions); | ||
| resizeObserver.observe(document.documentElement); | ||
| resizeObserver.observe(document.body); | ||
| window.addEventListener("scroll", handleScroll); |
There was a problem hiding this comment.
updateDynamicCanvasHeight is invoked directly on every scroll event and performs multiple setDimensions calls (including a collapse to height 0). This can be expensive during scrolling. Consider throttling via requestAnimationFrame/debounce and registering the listener as { passive: true } since it doesn't call preventDefault().
There was a problem hiding this comment.
Pull request overview
Copilot reviewed 1 out of 1 changed files in this pull request and generated 5 comments.
💡 Add Copilot custom instructions for smarter, more guided reviews. Learn how to get started.
You can also share your feedback on Copilot code review. Take the survey.
| @@ -69,7 +135,8 @@ export function Canvas({ currentTool, setCurrentTool }: CanvasProps) { | |||
|
|
|||
| return () => { | |||
| fc.dispose(); | |||
| window.removeEventListener("resize", initCanvasDimensions); | |||
| resizeObserver.disconnect(); | |||
| window.removeEventListener("scroll", handleScroll); | |||
| }; | |||
There was a problem hiding this comment.
resizeTimeout is not cleared in the effect cleanup. If a resize event schedules handleResize and the component unmounts (disposing the Fabric canvas) before the timeout fires, the callback can run against a disposed fc. Clear the pending timeout during cleanup (and consider typing it as ReturnType<typeof setTimeout> | undefined to reflect the runtime behavior).
| if ( | ||
| visibleBottomY >= currentCanvasHeight && | ||
| currentCanvasHeight > CANVAS_MAX_HEIGHT_IN_PIXELS | ||
| ) { | ||
| alert( | ||
| "This page is too tall for Tracemark to support. You can still draw on the visible canvas area, but it won't expand further." | ||
| ); | ||
| fc.setDimensions({ | ||
| height: CANVAS_MAX_HEIGHT_IN_PIXELS, | ||
| }); | ||
| // TODO: remove alert and replace with better user-facing error handling | ||
| return; |
There was a problem hiding this comment.
Using alert(...) here is blocking and can be triggered repeatedly as the user scrolls near the bottom (especially if the height briefly exceeds the cap). Consider switching to non-blocking in-app UI and/or guarding so the message is only shown once per page/session.
| while ( | ||
| newCanvasHeight < visibleBottomY && | ||
| visibleBottomY <= contentScrollHeight && | ||
| newCanvasHeight < CANVAS_MAX_HEIGHT_IN_PIXELS | ||
| ) { | ||
| newCanvasHeight += EXPANSION_INCREMENT_IN_PIXELS; | ||
| } | ||
|
|
||
| if (newCanvasHeight > currentCanvasHeight) { | ||
| fc.setDimensions({ | ||
| height: Math.max(newCanvasHeight, viewportHeight), | ||
| }); |
There was a problem hiding this comment.
newCanvasHeight can exceed CANVAS_MAX_HEIGHT_IN_PIXELS because the loop condition checks the value before adding EXPANSION_INCREMENT_IN_PIXELS. This allows setting the Fabric canvas height above the intended cap (e.g. 7800 -> 8300). Clamp the final height to the max (or change the loop to prevent overshoot) and consider adjusting the alert/guard to trigger when an expansion would cross the cap (not only when currentCanvasHeight > CANVAS_MAX_HEIGHT_IN_PIXELS).
| initCanvasDimensions(); | ||
| const handleResize = () => { | ||
| updateCanvasWidth(fc); | ||
| updateCanvasHeight(fc); |
There was a problem hiding this comment.
handleResize currently calls updateCanvasHeight, which sets the canvas height to clientHeight (viewport height). If the canvas has already been expanded via scrolling, any resize/observer trigger can shrink it back down and effectively cut off previously drawable areas. Consider keeping the existing expanded height (e.g., max(currentHeight, viewportHeight)) and only growing on resize, not shrinking.
| updateCanvasHeight(fc); | |
| const viewportHeight = document.documentElement.clientHeight; | |
| const currentHeight = fc.getHeight(); | |
| const desiredHeight = Math.max(currentHeight, viewportHeight); | |
| const newHeight = Math.min(CANVAS_MAX_HEIGHT_IN_PIXELS, desiredHeight); | |
| if (newHeight !== currentHeight) { | |
| fc.setHeight(newHeight); | |
| fc.renderAll(); | |
| } |
| const handleScroll = () => { | ||
| updateDynamicCanvasHeight(fc); | ||
| }; | ||
|
|
||
| let resizeTimeout: number; | ||
|
|
||
| const resizeObserver = new ResizeObserver(() => { | ||
| clearTimeout(resizeTimeout); | ||
| resizeTimeout = setTimeout(handleResize, 50); // debounce to prevent rapid calls | ||
| }); | ||
|
|
||
| window.addEventListener("resize", initCanvasDimensions); | ||
| resizeObserver.observe(document.documentElement); | ||
| resizeObserver.observe(document.body); | ||
| window.addEventListener("scroll", handleScroll); |
There was a problem hiding this comment.
The scroll handler runs on every scroll event and updateDynamicCanvasHeight does multiple setDimensions calls plus a scrollHeight read, which can force layout and be expensive during scrolling. Consider adding a requestAnimationFrame-based throttle and registering the listener as { passive: true } to reduce main-thread jank.
Change Summary + Media (if applicable)
Context
The previous implementation of setting the dimensions for the canvas is ineffective for sites that render in content gradually (i.e infinite scroll) as
clientHeightdoes not change when content is added that overflows beyond the element’s visible area. We also couldn't directly usescrollHeightbecause that causes too much overhead, causing each operation we try to do on the canvas to be expensive -> laggy.Summary
Replaced static
clientHeightsizing with a dynamic scroll-based expansion approach that works on both static pages and infinite scroll sites. Fixed infinite expansion bug at extreme zoom levels by using a collapse-and-measure technique to get cleanscrollHeightreadings without the canvas inflating its own measurements. Also, added a8,000pxheight cap to prevent performance degradation.How the collapse-and-measure approach works
The core issue was that the canvas (despite being absolutely positioned) inflated the document's
scrollHeight, creating a feedback loop: the canvas expands → page becomes taller → user can scroll further → canvas expands again.The collapse-and-measure approach breaks this cycle:
0, removing it from affectingscrollHeight.scrollHeightto get the real content height of the page.visibleBottomY <= contentScrollHeight, meaning the user is still within actual page content, not scrolling into empty space the canvas itself created.Test plan