Docstack Architecture

A Technical Deep Dive into the PDF Merger

High-Level Architecture

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.

PDF FilesUser uploads
upload.jsParse & validate
state.jsStore in memory
views.jsRender to DOM

The Flow Explained:

  • Upload: When a user drops PDFs, upload.js reads them as ArrayBuffers, loads them via PDF.js to get a PDFDocumentProxy, and extracts metadata (page count, name, size).
  • State: Each file is stored in state.uploadedFiles[] with its proxy, ArrayBuffer, and page order. The global merge order is tracked in state.globalPageOrder[].
  • Render: views.js creates the UI for both "Files" and "Pages" views, rendering thumbnails progressively to avoid UI freezes.

Module Dependencies

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.jsEntry point & event wiring
state.jsData store
views.jsUI rendering
modals.jsLightbox/Help
components.jsReusable UI
pdf.jsPDF rendering
helpers.jsUtilities

Module Responsibilities:

  • 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.
State Structure

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.

// Core state arrays let uploadedFiles = []; // All loaded PDF files let globalPageOrder = []; // Merge order: [{fileId, pageIndex}, ...] let lightboxState = { fileId: null, orderIndex: 0 }; // Each file object structure: { id: "550e8400-e29b...", // UUID name: "document.pdf", // Original filename size: 1024000, // Bytes pdfProxy: PDFDocumentProxy, // PDF.js document object arrayBuffer: ArrayBuffer, // Raw PDF data pageCount: 5, // Total pages pageOrder: [0, 1, 2, 3, 4], // Current page arrangement pageRotations: { 0: 0, 1: 90 }, // Rotation per page (degrees) importedPages: [ // Pages moved from other files { newIndex: 5, // Index in THIS file sourceFileId: "uuid...", // Original file sourcePageIndex: 2 // Original page index } ] }
Why store ArrayBuffer? While 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.

Global Page Order

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.

// Example: Two files interleaved globalPageOrder = [ { fileId: "file-A", pageIndex: 0 }, // A page 1 { fileId: "file-B", pageIndex: 0 }, // B page 1 { fileId: "file-A", pageIndex: 1 }, // A page 2 { fileId: "file-B", pageIndex: 1 }, // B page 2 { fileId: "file-B", pageIndex: 2 }, // B page 3 ];

How it syncs:

  • When files are uploaded, buildGlobalPageOrder() creates a fresh order by iterating through all files and their pageOrder arrays.
  • When pages are reordered in the Pages view, setGlobalPageOrder() updates the state directly from the DOM order.
  • When pages are reordered in the Files view, syncPagesViewOrder() rebuilds the Pages view to match.
  • The bidirectional sync ensures both views always show the same logical order.

Imported Pages Mechanism

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.

File AimportedPages[0]
ReferencesourceFileId + pageIndex
File BOriginal PDF data

Why use references?

  • Memory efficiency: I don't duplicate PDF data. The source file's pdfProxy is used for rendering.
  • Merge integrity: During merge, pdf-lib extracts the actual page from the source file's ArrayBuffer using the stored indices.
  • Visual tracking: Imported pages keep their original border color so users can see where they came from.
Cross-File Page Import

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.

Scenario: Drop File B's page between two File A pages
File Apage 1
Page Xfrom File B
File Apage 2
Result: Page X is now "owned" by File A. It appears in File A's card in the Files view, and File A's page count increases. File B's page count decreases.

Detection Algorithm

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.

// In the Sortable onEnd handler: const droppedThumb = thumbs[evt.newIndex]; const droppedFileId = droppedThumb.dataset.fileId; // Get neighbors const prevThumb = thumbs[evt.newIndex - 1]; const nextThumb = thumbs[evt.newIndex + 1]; // Check if BOTH neighbors are from the same file (not ours) if (prevThumb && nextThumb) { const prevFileId = prevThumb.dataset.fileId; const nextFileId = nextThumb.dataset.fileId; if (prevFileId === nextFileId && prevFileId !== droppedFileId) { // Trigger cross-file import! performCrossFileImport(droppedThumb, droppedFileId, prevFileId); } }

Edge cases handled:

  • Dropped at start: If there's no previous neighbor but next neighbor is from a different file, I check if import makes sense.
  • Dropped at end: Similar logic for when there's no next neighbor.
  • Same file reorder: If neighbors are from the same file as the dropped page, it's just a simple reorder — no import.

The Import Process

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.

1. Update State
2. Update DOM
3. Clone for Files View

Step 1: Update State

First, I update the in-memory state to reflect the page's new ownership:

  • Increment target's pageCount — The imported page gets a new index in File A.
  • Add to importedPages — Store a reference to the source file and original page index (not a copy of the data).
  • Remove from source's pageOrder — The page no longer belongs to File B.
  • Add to target's pageOrder — The page now belongs to File A.
// Step 1: State updates const newPageIndex = targetFile.pageCount++; targetFile.importedPages.push({ newIndex: newPageIndex, sourceFileId: sourceFileId, sourcePageIndex: originalPageIndex }); sourceFile.pageOrder = sourceFile.pageOrder.filter(i => i !== originalPageIndex); targetFile.pageOrder.push(newPageIndex);

Step 2: Update DOM Attributes

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).
// Step 2: Update the dragged element's data attributes thumb.dataset.fileId = targetFileId; thumb.dataset.pageIndex = newPageIndex; thumb.dataset.sourceFileId = sourceFileId; // Keep reference for rendering

Step 3: Clone for Files View

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:

  • Pixel data copiedcloneNode() doesn't copy canvas pixels!
  • Event handlers re-attachedcloneNode() doesn't copy event listeners!
  • Appended to target grid — The thumbnail appears in File A's expanded card.
// Step 3: Clone for Files view (if card is expanded) const clonedThumb = thumb.cloneNode(true); // Copy canvas pixels (cloneNode doesn't do this!) const ctx = clonedCanvas.getContext('2d'); ctx.drawImage(originalCanvas, 0, 0); // Re-attach event handlers previewBtn.addEventListener('click', () => showPreview(targetFileId, newPageIndex)); rotateBtn.addEventListener('click', () => rotatePage(targetFileId, newPageIndex)); targetGrid.appendChild(clonedThumb);

Canvas Cloning Gotcha

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!

// The problem: blank canvas after clone const clonedThumb = thumb.cloneNode(true); // clonedThumb's canvas is EMPTY! // The solution: manually copy pixels const originalCanvas = thumb.querySelector('canvas'); const clonedCanvas = clonedThumb.querySelector('canvas'); const ctx = clonedCanvas.getContext('2d'); ctx.drawImage(originalCanvas, 0, 0); // Copy pixels!
Another gotcha: 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.

Bidirectional Sync
Files View
globalPageOrder
Pages View

Sync functions:

  • 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.
  • Both functions preserve rendered thumbnails — they just move existing DOM elements rather than re-rendering.
Progressive Rendering

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.

Pages queue
createThumb()
renderPdfPage()
requestIdleCallback
Loop continues until all pages rendered

The Render Loop
function startProgressiveRendering(pagesToRender) { let currentIndex = 0; function renderNext() { if (currentIndex >= pagesToRender.length) { completeProgress(); // All done! return; } const { fileId, pageIndex } = pagesToRender[currentIndex]; const file = state.getFile(fileId); // Create DOM element const thumb = createProgressiveThumb(file, pageIndex); allPagesGrid.appendChild(thumb); // Render PDF page to canvas const canvas = thumb.querySelector('canvas'); renderPdfPage(file.pdfProxy, pageIndex + 1, canvas, 0.25) .finally(() => { currentIndex++; renderedCount++; updateProgress(); // Update "Preparing X/Y" // Schedule next render during browser idle time if (window.requestIdleCallback) { requestIdleCallback(renderNext, { timeout: 100 }); } else { setTimeout(renderNext, 10); } }); } renderNext(); // Start the loop }

Why requestIdleCallback?

  • It runs the callback when the browser is idle (between frames), preventing jank.
  • The timeout: 100 ensures it still runs even if the browser is busy.
  • Fallback to setTimeout for browsers that don't support it.

Rendering Imported Pages

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.

Why can't I render from the current file?

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.

  • File A has 2 pages: Its pdfProxy can render pages 1 and 2.
  • I import File B's page 3: File A's pageCount becomes 3, and the imported page gets newIndex: 2.
  • Problem: If I try to render pdfProxy.getPage(3) on File A, it fails or returns wrong content — File A only has 2 real pages!
  • Solution: I check importedPages, find the source file (File B), and render sourceFile.pdfProxy.getPage(3) instead.
Page belongs
to File A?
Is it in
importedPages?
Render from
Source File B
// Check if this page was imported from another file const importedPage = file.importedPages?.find(p => p.newIndex === pageIndex); if (importedPage) { // This page doesn't exist in file.pdfProxy! // I need to get the ACTUAL PDF data from the source file const sourceFile = state.getFile(importedPage.sourceFileId); await renderPdfPage( sourceFile.pdfProxy, // Use SOURCE file's PDF proxy importedPage.sourcePageIndex + 1, // Original page number (1-indexed) canvas, scale ); } else { // Normal page - render from current file as usual await renderPdfPage(file.pdfProxy, pageIndex + 1, canvas, scale); }
Same logic everywhere: This check happens in 4 places:
  • Thumbnail rendering (Files view cards)
  • Pages view rendering (flat grid)
  • Lightbox preview (full-size preview modal)
  • Merge operation (when building the final PDF, pdf-lib copies from source ArrayBuffer)
Why not just copy the PDF data? Duplicating page data would waste memory, especially for large PDFs. By storing references, I keep memory usage constant regardless of how many times pages are moved between files.

Progress Tracking
// Module-level state let isRendering = false; let totalToRender = 0; let renderedCount = 0; function updateProgress() { const progressText = document.getElementById('progress-text'); progressText.textContent = `Preparing ${renderedCount}/${totalToRender}`; const progressBar = document.getElementById('progress-bar'); progressBar.style.width = `${(renderedCount / totalToRender) * 100}%`; } function completeProgress() { isRendering = false; progressBar.classList.add('hidden'); mergeBtn.disabled = false; // Re-enable merge button }
Quality of Life Features

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.

Cross-File Drag (Pages View)

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.

Color-Coded Borders

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.

Bidirectional Sync

Reordering in either view syncs to the other. Files view reorder → syncPagesViewOrder(). Pages view reorder → syncFilesViewOrder(). Both preserve existing rendered thumbnails.

Imported Page Lightbox

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.

Rotation Sync

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].

Clean Deletion

Deleting a file removes its pages from both views. The deleteFile() function queries allPagesGrid for thumbs with matching data-file-id and removes them.

Live Progress Bar

Shows "Preparing X/Y" with animated bar. The merge button is disabled during rendering (isRendering flag) to prevent conflicts.

Event Handler Cloning

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.


File Hue Algorithm
// helpers.js export function getFileHue(fileIndex) { // Golden angle ensures visually distinct colors return (fileIndex * 137.508) % 360; } // Usage in components.js const hue = getFileHue(fileIndex); wrapper.style.borderLeftColor = `hsl(${hue}, 70%, 50%)`;

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.