A Technical Deep Dive into the PDF Merger
Docstack is a client-side PDF merger built with vanilla JavaScript and ES6 modules. It uses PDF.js for parsing/rendering PDFs and pdf-lib for merging. The architecture follows a clear separation of concerns with distinct modules for state, views, and handlers.
upload.js reads them as ArrayBuffers, loads them via PDF.js to get a PDFDocumentProxy, and extracts metadata (page count, name, size).state.uploadedFiles[] with its proxy, ArrayBuffer, and page order. The global merge order is tracked in state.globalPageOrder[].views.js creates the UI for both "Files" and "Pages" views, rendering thumbnails progressively to avoid UI freezes.
The codebase is organized into logical modules. The entry point (app.js) initializes everything
and wires up event handlers. Each module has a single responsibility.
app.js — Loads PDF.js, initializes modules, sets up Sortable.js for drag-drop, handles file deletion and cross-file drags.state.js — Single source of truth. Stores uploadedFiles[], globalPageOrder[], and lightboxState. Provides getters and event emitting.views.js — The largest module. Handles both Files view (cards) and Pages view (flat grid), progressive rendering, and bidirectional sync.components.js — Factory functions for creating page thumbnails and file cards with attached event handlers.modals.js — Manages the page lightbox (full preview) with keyboard navigation and the help modal.pdf.js — Wrapper around PDF.js. Provides renderPdfPage() to draw a page to a canvas at a given scale.helpers.js — Utility functions like getFileHue() for color-coding and file size formatting.
All application state lives in state.js. This module maintains arrays for uploaded files,
the global page order for merging, and transient UI state like the current lightbox position.
pdfProxy is used for rendering thumbnails and previews,
the raw arrayBuffer is needed by pdf-lib during the merge operation since pdf-lib can't use PDF.js proxies.
The globalPageOrder array defines the final merge order. It's an array of objects,
each pointing to a specific page in a specific file. This allows pages from multiple files to be interleaved.
buildGlobalPageOrder() creates a fresh order by iterating through all files and their pageOrder arrays.setGlobalPageOrder() updates the state directly from the DOM order.syncPagesViewOrder() rebuilds the Pages view to match.
When a page is "moved" from File B to File A via cross-file drag, it's not actually copied. Instead,
File A stores a reference in its importedPages array pointing back to the source.
pdfProxy is used for rendering.One of Docstack's key features is the ability to drag a page from one file and drop it into another file's position — directly in the Pages view. This triggers a "cross-file import" that updates both state and UI.
When Sortable.js fires its onEnd event after a drag operation in the Pages view,
I check if the dropped page is now "surrounded" by pages from a different file.
performCrossFileImport() is a ~80 line function that handles all the state updates,
DOM manipulations, and syncing required when a page moves between files. It's called when the
detection algorithm (above) determines a cross-file import should happen.
First, I update the in-memory state to reflect the page's new ownership:
The dragged thumbnail element needs its data attributes updated so future operations know where it belongs:
data-file-id — Now points to File A (the target).data-page-index — The new index within File A.data-source-file-id — Preserved reference to File B (needed for rendering).The Pages view already has the thumbnail in the correct position (the user just dragged it there). But the Files view needs a copy of that thumbnail inside File A's card. This clone needs:
cloneNode() doesn't copy canvas pixels!cloneNode() doesn't copy event listeners!
When cloning a thumbnail to show in the Files view, I discovered that cloneNode(true)
doesn't copy canvas pixel data. The cloned canvas is blank!
cloneNode() also doesn't copy event listeners!
I have to manually re-attach click handlers for the preview, rotate, and delete buttons on the cloned element.
syncPagesViewOrder() — Called after Files view reorder. Rebuilds globalPageOrder from file states, then reorders Pages view DOM.syncFilesViewOrder() — Called after Pages view reorder. Groups globalPageOrder by file, updates each file's pageOrder, reorders each card's grid.Rendering PDF thumbnails is expensive. A naive approach would freeze the UI while rendering all pages. Docstack uses progressive rendering — thumbnails are created and rendered one at a time with idle callbacks, keeping the UI responsive.
timeout: 100 ensures it still runs even if the browser is busy.setTimeout for browsers that don't support it.When rendering a thumbnail, I need to check if it's an imported page. If so, I render from the source file's PDF proxy, not the current file's. This is a critical piece of logic that applies to thumbnails, lightbox previews, and the final merge.
When a page is "imported" to File A from File B, I don't actually copy any PDF data. I only store a reference
(the source file ID and source page index). File A's pdfProxy still only contains its original pages.
pdfProxy can render pages 1 and 2.pageCount becomes 3, and the imported page gets newIndex: 2.pdfProxy.getPage(3) on File A, it fails or returns wrong content — File A only has 2 real pages!importedPages, find the source file (File B), and render sourceFile.pdfProxy.getPage(3) instead.Beyond the core functionality, Docstack includes many small details that make it feel polished. Here's a deep dive into each feature and how it's implemented.
Drop a page between two pages of another file to import it.
Detection uses neighbor checking in Sortable's onEnd. The page gets a new index in the target file
while storing a reference back to its source.
Each file gets a unique HSL hue based on its index: hue = (index * 137) % 360.
The golden angle (137°) ensures visually distinct colors. Imported pages keep their original color for tracking.
Reordering in either view syncs to the other. Files view reorder → syncPagesViewOrder().
Pages view reorder → syncFilesViewOrder(). Both preserve existing rendered thumbnails.
The lightbox checks file.importedPages to find the source file and page index.
It then renders from sourceFile.pdfProxy at 1.5x scale for crisp preview.
Rotating in Files view syncs to Pages view by querying .page-thumb[data-file-id][data-page-index]
and applying the same CSS transform. Rotation is stored in file.pageRotations[pageIndex].
Deleting a file removes its pages from both views. The deleteFile() function queries
allPagesGrid for thumbs with matching data-file-id and removes them.
Shows "Preparing X/Y" with animated bar. The merge button is disabled during rendering
(isRendering flag) to prevent conflicts.
When cloning thumbnails, event listeners aren't copied by cloneNode().
I manually re-attach click handlers for preview, rotate, and delete using stored handler references.
I was searching for a way to generate colors that stay distinct from each other, and stumbled upon something called the golden angle (137.5°). Turns out it's the same angle sunflowers use to pack seeds. Using it to step through the color wheel keeps consecutive files visually distinct.