Skip to content

Code Explanation #31

Description

@Zack-Rider

Code Walkthrough — Every Block Explained

Step by step:

  1. Find the editor element on the page (editor-container)
  2. Calculate how much to shrink it: scale = 120 / editorWidth (120px is thumbnail width)
  3. 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' });
};
  1. Set the active page immediately (green border moves right away)
  2. Reset and restart the scroll lock timer (900ms)
  3. 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

Activity

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Metadata

Metadata

Assignees

No one assigned

    Labels

    No labels
    No labels

    Projects

    No projects

      Milestone

      No milestone

      Relationships

      None yet

      Development

      No branches or pull requests

      Issue actions