Code Walkthrough — Every Block Explained
Step by step:
- Find the editor element on the page (
editor-container)
- Calculate how much to shrink it:
scale = 120 / editorWidth (120px is thumbnail width)
- For each thumbnail slot:
- Clear whatever was there before (
innerHTML = '')
- Make a full copy of the editor HTML (
cloneNode(true))
- Hide decorative lines and watermarks in the copy
- Apply CSS to shrink it AND slide it up so the right page slice is visible:
scale(s) → shrink
translateY(-pageIndex × pageHeight) → slide up to the right page
- Put the copy inside the thumbnail
<div>
The shared on/off switch
export const pageNavigator$ = observable({ open: true });
This is a global variable (using Legend State) that stores whether the panel is open or closed.
Any component in the app can read or change it.
observable means: "if this value changes, all components using it will automatically update."
The toggle button
export function PageNavigatorToggle() {
const open = use$(pageNavigator$.open);
...
onClick={() => pageNavigator$.open.set(!open)}
This is the small icon button in the toolbar.
use$ reads the current open/closed state and re-renders the button if it changes.
Clicking it flips the value: true → false or false → true.
The icon changes between BookOpen and BookMarked to show the current state.
Keeping track of things (state and refs)
const [pageCount, setPageCount] = useState(0);
const [activePage, setActivePage] = useState(1);
pageCount — how many pages the document has right now (starts at 0 = no pages detected yet)
activePage — which page has the green border (starts at 1)
const scrollLockRef = useRef(null);
const cloneDebounceRef = useRef(null);
const thumbnailRefs = useRef(new Map());
const pageCountRef = useRef(0);
pageCountRef.current = pageCount;
scrollLockRef — holds the scroll lock timer (see Scroll Lock section above)
cloneDebounceRef — holds a timer that delays thumbnail re-rendering so it doesn't run on every single keystroke
thumbnailRefs — a Map from page number → the actual <div> element of that thumbnail. Used when drawing the clone inside it
pageCountRef — a copy of pageCount that can be read inside timers and callbacks without going stale. Updated every render with pageCountRef.current = pageCount
Why pageCountRef when we already have pageCount?
React state like pageCount gets "frozen" inside callbacks. If a timer reads pageCount after 500ms, it sees the old value. A ref always gives the latest value.
Drawing thumbnails — renderClones
const renderClones = useCallback(() => {
const editorEl = document.getElementById('editor-container');
if (!editorEl || pageCountRef.current === 0) return;
const contentHeightPerPage = dynamicPageOptions.pageHeight - CONTENT_HEIGHT_OFFSET;
const editorWidth = editorEl.offsetWidth || dynamicPageOptions.pageWidth;
const scale = THUMBNAIL_WIDTH / editorWidth;
for (const [pageIndex, container] of thumbnailRefs.current) {
container.innerHTML = '';
const clone = editorEl.cloneNode(true);
clone.removeAttribute('id');
clone.querySelectorAll('.page-break-indicator, .pagination-overlay-canvas')
.forEach(el => { el.style.display = 'none'; });
clone.style.cssText = `
width: ${editorWidth}px;
transform: scale(${scale}) translateY(-${pageIndex * contentHeightPerPage}px);
...
`;
container.appendChild(clone);
}
}, []);
Why translateY?
The entire editor is one tall column. Page 1 is at the top, page 2 is 1059px below, page 3 is 2118px below. translateY(-1059px) slides page 2 into view. Combined with overflow: hidden on the container, only that page slice is visible.
Debounced re-draw — scheduleCloneRender
const scheduleCloneRender = useCallback(() => {
if (cloneDebounceRef.current) clearTimeout(cloneDebounceRef.current);
cloneDebounceRef.current = setTimeout(renderClones, 50);
}, [renderClones]);
When you type, the editor fires dozens of events per second. We don't want to redraw thumbnails on every single one — it would be slow.
Debounce means: "wait until you stop typing for 50ms, then redraw."
If you type the next letter before 50ms, the timer resets. So it only runs once you pause.
Effect 1 — Draw thumbnails after page count changes
useEffect(() => {
if (pageCount === 0) return;
renderClones();
}, [pageCount, renderClones]);
This runs every time pageCount changes (e.g. you go from 1 page to 2 pages).
Why a separate effect instead of calling renderClones directly?
Because React first updates state, then re-renders the component (adding the new thumbnail <div> to the page), and THEN runs effects. By the time this effect runs, the new thumbnail slot is already in the DOM and ready to receive the clone.
If we called renderClones immediately when pageCount changed, the new thumbnail <div> wouldn't exist yet.
Effect 2 — Watching for changes, scrolling, keyboard shortcuts
This is the main effect. It sets up everything that needs to run continuously.
Part A — Reading page count from the DOM
const readPageCount = () => {
const overlay = document.querySelector('.pagination-overlay-canvas');
if (!overlay) return 0;
return Math.max(1, overlay.querySelectorAll('.pagination-break-group').length);
};
Counts .pagination-break-group elements inside the overlay canvas.
The overlay always has one group per page (including the last page watermark).
So: groups.length = pageCount.
Part B — Applying the count safely
const applyCount = (count: number) => {
if (count !== pageCountRef.current) {
setPageCount(count);
}
};
Only calls setPageCount if the number actually changed. Prevents unnecessary re-renders.
Part C — Watching the overlay for page count changes
const startOverlayObserver = (overlay: Element) => {
if (overlayObserver) return;
overlayObserver = new MutationObserver(() => applyCount(readPageCount()));
overlayObserver.observe(overlay, { childList: true });
};
Sets up a MutationObserver on the overlay canvas.
When page breaks are added or removed, the overlay's children change → observer fires → we recount.
childList: true means: "only watch for direct children being added/removed."
if (overlayObserver) return makes sure we only create one observer, not many.
Part D — Watching the editor for content changes (for thumbnails)
const attachEditorObserver = () => {
if (editorObserver) return;
const proseMirror = document.querySelector('.ProseMirror');
editorObserver = new MutationObserver(scheduleCloneRender);
editorObserver.observe(proseMirror, { childList: true, subtree: true });
};
Watches .ProseMirror (the actual editable area) for any changes.
subtree: true means: "watch all descendants, not just direct children."
When anything changes → scheduleCloneRender → thumbnails redraw after 50ms.
Why watch .ProseMirror instead of using window.editor.on('update')?
The TipTap editor can reinitialize (e.g. on route changes). When it does, window.editor becomes a new object and old .on() listeners are lost. A MutationObserver watches the actual DOM element which stays in the page — it survives editor reinitialization.
Part E — The polling safety net
const poll = () => {
applyCount(readPageCount());
const overlay = document.querySelector('.pagination-overlay-canvas');
if (overlay) {
startOverlayObserver(overlay);
attachEditorObserver();
}
};
poll();
pollId = setInterval(poll, 300);
Runs immediately once, then every 300ms.
Why keep it running even after observers are set up?
- On first load, the editor might not be ready yet when the navigator mounts
- The overlay might not exist yet
- The 300ms poll catches any edge case where the observer missed an update
Part F — Tracking which page you're scrolled to
const onScroll = () => {
if (scrollLockRef.current) return;
const page = Math.floor(scrollEl.scrollTop / contentHeightPerPage) + 1;
setActivePage(Math.min(page, pageCountRef.current));
};
scrollEl.addEventListener('scroll', onScroll, { passive: true });
As you scroll, we calculate which page is currently at the top:
scrollTop ÷ pageHeight gives the page index.
if (scrollLockRef.current) return — scroll lock check. If you just clicked a thumbnail, ignore scroll events until the animation finishes.
passive: true — tells the browser this listener won't call preventDefault(), so the browser can optimize scroll performance.
Part G — Ctrl+V and Ctrl+P trigger thumbnail refresh
const onKeyDown = (e: KeyboardEvent) => {
if (e.ctrlKey && (e.key === 'v' || e.key === 'p')) {
scheduleCloneRender();
}
};
window.addEventListener('keydown', onKeyDown);
When you paste (Ctrl+V) or print (Ctrl+P), content might change a lot at once.
We manually schedule a thumbnail redraw to make sure they stay up to date.
Part H — Cleanup when component is removed
return () => {
if (pollId) clearInterval(pollId);
if (cloneDebounceRef.current) clearTimeout(cloneDebounceRef.current);
overlayObserver?.disconnect();
editorObserver?.disconnect();
scrollEl.removeEventListener('scroll', onScroll);
window.removeEventListener('keydown', onKeyDown);
};
When the Page Navigator is removed from the page (e.g. you navigate away):
- Stop the polling interval
- Cancel any pending debounce timer
- Disconnect both MutationObservers (they keep running unless you stop them)
- Remove the scroll and keydown listeners
This prevents memory leaks and ghost listeners that keep running in the background.
Jumping to a page — goToPage
const goToPage = (page: number) => {
setActivePage(page);
if (scrollLockRef.current) clearTimeout(scrollLockRef.current);
scrollLockRef.current = setTimeout(() => {
scrollLockRef.current = null;
}, 900);
scrollEl.scrollTo({ top: (page - 1) * contentHeightPerPage, behavior: 'smooth' });
};
- Set the active page immediately (green border moves right away)
- Reset and restart the scroll lock timer (900ms)
- Smooth scroll the editor to that page's position
The sidebar container
<div style={{
width: open ? '156px' : '0px',
transition: 'width 180ms ease, min-width 180ms ease',
}}>
When open is false, the width becomes 0 — the panel slides out of view.
The CSS transition makes it animate smoothly instead of disappearing instantly.
The inner content stays 156px wide (it just gets clipped by the outer overflow hidden).
Each thumbnail card
<UnstyledButton onClick={() => goToPage(page)}>
<div
style={{ border: isActive ? '2px solid green' : '2px solid gray' }}
ref={(el) => {
if (el) thumbnailRefs.current.set(page - 1, el);
else thumbnailRefs.current.delete(page - 1);
}}
/>
<Text>{page}</Text>
</UnstyledButton>
UnstyledButton — a Mantine button with no default styling, so we control the look fully
isActive — whether this is the currently viewed page → shows green border if yes
ref callback — when React mounts this <div>, it registers it in thumbnailRefs (so renderClones knows where to put the clone). When unmounted, it removes itself from the map
page - 1 is used as the key because pageIndex in renderClones starts at 0, but page numbers start at 1
Code Walkthrough — Every Block Explained
Step by step:
editor-container)scale = 120 / editorWidth(120px is thumbnail width)innerHTML = '')cloneNode(true))scale(s)→ shrinktranslateY(-pageIndex × pageHeight)→ slide up to the right page<div>The shared on/off switch
This is a global variable (using Legend State) that stores whether the panel is open or closed.
Any component in the app can read or change it.
observablemeans: "if this value changes, all components using it will automatically update."The toggle button
This is the small icon button in the toolbar.
use$reads the current open/closed state and re-renders the button if it changes.Clicking it flips the value:
true → falseorfalse → true.The icon changes between
BookOpenandBookMarkedto show the current state.Keeping track of things (state and refs)
pageCount— how many pages the document has right now (starts at 0 = no pages detected yet)activePage— which page has the green border (starts at 1)scrollLockRef— holds the scroll lock timer (see Scroll Lock section above)cloneDebounceRef— holds a timer that delays thumbnail re-rendering so it doesn't run on every single keystrokethumbnailRefs— a Map from page number → the actual<div>element of that thumbnail. Used when drawing the clone inside itpageCountRef— a copy ofpageCountthat can be read inside timers and callbacks without going stale. Updated every render withpageCountRef.current = pageCountDrawing thumbnails —
renderClonesDebounced re-draw —
scheduleCloneRenderWhen you type, the editor fires dozens of events per second. We don't want to redraw thumbnails on every single one — it would be slow.
Debounce means: "wait until you stop typing for 50ms, then redraw."
If you type the next letter before 50ms, the timer resets. So it only runs once you pause.
Effect 1 — Draw thumbnails after page count changes
This runs every time
pageCountchanges (e.g. you go from 1 page to 2 pages).Why a separate effect instead of calling
renderClonesdirectly?Because React first updates state, then re-renders the component (adding the new thumbnail
<div>to the page), and THEN runs effects. By the time this effect runs, the new thumbnail slot is already in the DOM and ready to receive the clone.If we called
renderClonesimmediately whenpageCountchanged, the new thumbnail<div>wouldn't exist yet.Effect 2 — Watching for changes, scrolling, keyboard shortcuts
This is the main effect. It sets up everything that needs to run continuously.
Part A — Reading page count from the DOM
Counts
.pagination-break-groupelements inside the overlay canvas.The overlay always has one group per page (including the last page watermark).
So:
groups.length = pageCount.Part B — Applying the count safely
Only calls
setPageCountif the number actually changed. Prevents unnecessary re-renders.Part C — Watching the overlay for page count changes
Sets up a MutationObserver on the overlay canvas.
When page breaks are added or removed, the overlay's children change → observer fires → we recount.
childList: truemeans: "only watch for direct children being added/removed."if (overlayObserver) returnmakes sure we only create one observer, not many.Part D — Watching the editor for content changes (for thumbnails)
Watches
.ProseMirror(the actual editable area) for any changes.subtree: truemeans: "watch all descendants, not just direct children."When anything changes →
scheduleCloneRender→ thumbnails redraw after 50ms.Part E — The polling safety net
Runs immediately once, then every 300ms.
Why keep it running even after observers are set up?
Part F — Tracking which page you're scrolled to
As you scroll, we calculate which page is currently at the top:
scrollTop ÷ pageHeightgives the page index.if (scrollLockRef.current) return— scroll lock check. If you just clicked a thumbnail, ignore scroll events until the animation finishes.passive: true— tells the browser this listener won't callpreventDefault(), so the browser can optimize scroll performance.Part G — Ctrl+V and Ctrl+P trigger thumbnail refresh
When you paste (
Ctrl+V) or print (Ctrl+P), content might change a lot at once.We manually schedule a thumbnail redraw to make sure they stay up to date.
Part H — Cleanup when component is removed
When the Page Navigator is removed from the page (e.g. you navigate away):
This prevents memory leaks and ghost listeners that keep running in the background.
Jumping to a page —
goToPageThe sidebar container
When
openis false, the width becomes 0 — the panel slides out of view.The CSS transition makes it animate smoothly instead of disappearing instantly.
The inner content stays 156px wide (it just gets clipped by the outer overflow hidden).
Each thumbnail card
UnstyledButton— a Mantine button with no default styling, so we control the look fullyisActive— whether this is the currently viewed page → shows green border if yesrefcallback — when React mounts this<div>, it registers it inthumbnailRefs(sorenderClonesknows where to put the clone). When unmounted, it removes itself from the mappage - 1is used as the key becausepageIndexinrenderClonesstarts at 0, but page numbers start at 1