Scrolling With Konva.js and React
This article explores a solution for displaying large HTML Canvases efficiently, focusing on limiting the visible area and scrolling through content.
Join the DZone community and get the full member experience.
Join For FreeLet's assume we have a situation where we have a large HTML Canvas, say it is 2,000 x 10,000 pixels in dimensions, with a considerable number of objects depicted on it. Displaying the entire Сanvas at once would lead to performance issues, especially on mobile devices.
In this article, I would like to explore one of the efficient solutions to this problem. The core idea is to limit the visible area to the screen's dimensions and only display a portion of the content. The remaining content can be viewed by scrolling through the document.
Step 1: Preparing the Document
Let's start with preparing the document, and as an example, we will define a list of pages that we want to display on the HTML Canvas:
import imageURL from "./lorem-ipsum.png";
// ...
const pages = [
{ pageId: 1, width: 2016, height: 2264, imageURL, offsetY: 0 },
{ pageId: 2, width: 2016, height: 2264, imageURL, offsetY: 2264 },
{ pageId: 3, width: 2016, height: 2264, imageURL, offsetY: 4528 }
];
For each page, width, height, and an image URL are specified, which need to be displayed on the HTML Canvas. As you may notice, the total size of the content is quite large and equals 2016 x 6792 = 2016 x 2264 * 3. In this example, the document contains only 3 pages, and in the case of more pages (dozens or even hundreds), the content size increases proportionally.
Step 2: Display the Pages
The next step is to display the pages, and for that, we will add a Page
component responsible for individually rendering each page:
import { Image } from "react-konva";
import useImage from "use-image";
export default function Page({ width, height, imageURL, offsetY }) {
const [image] = useImage(imageURL);
if (!image) {
return null;
}
return (
<Image
x={0}
y={0}
image={image}
width={width}
height={height}
offsetY={offsetY}
/>
);
}
The useImage
hook is utilized within the component for image loading. It loads an image based on the provided URL and creates a DOM element with the same src
value. The loaded image is then passed to the Image
component from the “react-konva” library, thereby displaying the page image on the HTML Canvas.
Step 3: Implement a Component
Next, we'll implement a component responsible for displaying the entire list of pages:
import { Layer, Stage } from "react-konva";
import Page from "./Page.js";
// ...
export default function App() {
return (
<Stage width={window.innerWidth} height={window.innerHeight}>
<Layer>
{pages.map((page) => (
<Page
key={page.pageId}
width={page.width}
height={page.height}
imageURL={page.imageURL}
offsetY={-page.offsetY}
/>
))}
</Layer>
</Stage>
);
}
To display each page, we used the previously implemented Page
component. Then, we passed the list of pages as children to the Layer
and Stage
components from the “react-konva” library.
The Layer
component serves as a graphical container used for displaying and managing a set of shapes and elements. Each Layer
contains a set of Konva.js objects, such as shapes, images, text, etc., which can be added, removed, and modified. Layer
components are used to set the hierarchy of objects and control the rendering order of elements.
The Stage
component is the top-level component that contains all Layer
components and handles events on the HTML Canvas. The Stage
is the main component on which graphical objects are displayed.
Step 4: Implement Ability to Scroll Through Pages
And last but not least, let's implement the ability to scroll through the pages. The library's documentation describes several options for navigation. We will implement one of these options, the idea of which is to display only a portion of the content and scroll the remaining content using an external container. When the user scrolls the container, a CSS style transform: translate(scrollTop, scrollLeft)
is applied to the container to keep it in place, while simultaneously changing the position of the Stage
to scroll the content. This way, we can navigate through the document using scrolling.
Let's implement this approach. For this, we will add a state to store the scroll position and an event handler to keep track of its updated position when scrolling the document:
const [scroll, setScroll] = useState({ left: 0, top: 0 });
...
const handleScroll = useCallback((event) => {
const { scrollLeft, scrollTop } = event.currentTarget;
setScroll({ left: scrollLeft, top: scrollTop });
}, []);
We will need the value of the scroll position to set coordinates in the Stage
component and as a value for the transform CSS property, as we described above.
const stageStyles = useMemo(() => {
return { transform: `translate(${scroll.left}px, ${scroll.top}px)` };
}, [scroll]);
We also need to calculate the dimensions of the content, specifically, its width and height. The content's height will be the sum of the heights of all pages, and the content's width will be the width of the widest page.
const contentStyles = useMemo(() => {
const { width, height } = pages.reduce(
(acc, page) => ({
width: Math.max(acc.width, page.width),
height: acc.height + page.height
}),
{
width: 0,
height: 0
}
);
return { width, height };
}, []);
Now that we have all the necessary calculations and handlers, let's bring them all together into a single component. The final implementation of the application will be as follows:
import { Layer, Stage } from "react-konva";
import Page from "./Page.js";
// ...
export default function App() {
const [scroll, setScroll] = useState({ left: 0, top: 0 });
const handleScroll = useCallback((event) => {
const { scrollLeft, scrollTop } = event.currentTarget;
setScroll({ left: scrollLeft, top: scrollTop });
}, []);
const stageStyles = useMemo(() => {
return { transform: `translate(${scroll.left}px, ${scroll.top}px)` };
}, [scroll]);
const contentStyles = useMemo(() => {
const { width, height } = pages.reduce(
(acc, page) => ({
width: Math.max(acc.width, page.width),
height: acc.height + page.height
}),
{
width: 0,
height: 0
}
);
return { width, height };
}, []);
return (
<div className="Scroll" onScroll={handleScroll}>
<div className="Content" style={contentStyles}>
<Stage
x={-scroll.left}
y={-scroll.top}
width={window.innerWidth}
height={window.innerHeight}
style={stageStyles}
>
<Layer>
{pages.map((page) => (
<Page
key={page.pageId}
width={page.width}
height={page.height}
imageURL={page.imageURL}
offsetY={-page.offsetY}
/>
))}
</Layer>
</Stage>
</div>
</div>
);
}
You can view a functional application and explore it in more detail on the CodeSandbox playground.
Conclusion
In this article, we've discussed the implementation of an application that enables viewing documents with a large number of pages. In the next article, we will continue exploring the “react-konva” library and further enhance the functionality of our viewer.
Opinions expressed by DZone contributors are their own.
Comments