Most PDF tools handle one operation at a time: merge, split, rotate, delete. But real document assembly rarely fits a single operation. You might need to reorder pages, insert blanks for printing, pull pages from a second file, duplicate a template, and rotate sideways scans — all in the same session.
Building a visual page organizer that combines all of these into one drag-and-drop interface requires careful state management, a flexible page plan model, and precise PDF manipulation. Here's how I built it with Vue 3 and pdf-lib.
The page plan model
Instead of modifying the PDF directly with each action, we use an intermediate page plan — an ordered list that represents what the final PDF will look like:
interface PagePlanItem {
id: string // Unique identifier for drag-and-drop
type: 'original' | 'blank' | 'inserted'
sourcePageIndex?: number // Index in the original PDF (for 'original' type)
sourceFileIndex?: number // Which file (original = 0, inserted = 1+)
rotation?: number // 0 | 90 | 180 | 270
pageSize?: { width: number; height: number } // For blank pages
}
Every action — reorder, duplicate, rotate, remove, add blank, insert from another PDF — is just a transformation on this plan array. Only when the user clicks "Organize PDF" do we execute the plan against the actual PDF documents.
The stack
- Vue 3 with Composition API and reactive state
- pdf-lib for PDF assembly
- HTML5 Drag and Drop API for page reordering
- Vite for bundling
Core implementation
1. The page plan as reactive state
const pagePlan = ref<PagePlanItem[]>([
// Initialized from the uploaded PDF's pages
{ id: 'p1', type: 'original', sourcePageIndex: 0, sourceFileIndex: 0 },
{ id: 'p2', type: 'original', sourcePageIndex: 1, sourceFileIndex: 0 },
// ...
])
// Every operation mutates this array
function addBlankPages(count: number, position: number) {
const blanks: PagePlanItem[] = Array.from({ length: count }, (_, i) => ({
id: `blank-${uuid()}`,
type: 'blank',
pageSize: blankPageSize.value,
rotation: blankRotation.value,
}))
pagePlan.value.splice(position, 0, ...blanks)
}
function duplicateSelected(selectedIds: Set<string>) {
const newItems: PagePlanItem[] = []
for (const item of pagePlan.value) {
newItems.push(item)
if (selectedIds.has(item.id)) {
newItems.push({ ...item, id: `dup-${uuid()}` })
}
}
pagePlan.value = newItems
}
2. Drag-and-drop reordering
Vue 3's reactivity works well with the HTML5 Drag and Drop API:
<template>
<div
v-for="item in pagePlan"
:key="item.id"
draggable="true"
:class="{ 'opacity-50': dragId === item.id }"
@dragstart="handleDragStart($event, item.id)"
@dragover.prevent
@drop="handleDrop($event, item.id)"
@dragend="dragId = null"
>
<!-- Page card UI -->
</div>
</template>
<script setup lang="ts">
const dragId = ref<string | null>(null)
function handleDragStart(e: DragEvent, id: string) {
dragId.value = id
e.dataTransfer!.effectAllowed = 'move'
}
function handleDrop(_e: DragEvent, targetId: string) {
if (!dragId.value || dragId.value === targetId) return
const from = pagePlan.value.findIndex(item => item.id === dragId.value)
const to = pagePlan.value.findIndex(item => item.id === targetId)
const [moved] = pagePlan.value.splice(from, 1)
pagePlan.value.splice(to, 0, moved)
dragId.value = null
}
</script>
3. Inserting pages from another PDF
This is where the page plan model really shines. Inserting pages from a second PDF is just adding entries with a different sourceFileIndex:
const insertedFiles = ref<ArrayBuffer[]>([])
function insertFromPdf(fileIndex: number, position: number, pages: number) {
const entries: PagePlanItem[] = Array.from({ length: pages }, (_, i) => ({
id: `ins-${uuid()}`,
type: 'inserted' as const,
sourcePageIndex: i,
sourceFileIndex: fileIndex + 1, // 0 = original, 1+ = inserted files
}))
pagePlan.value.splice(position, 0, ...entries)
}
4. Executing the plan
When the user clicks "Organize PDF," we iterate the page plan and assemble the final document:
async function executePlan() {
const result = await PDFDocument.create()
const sourceDocs: PDFDocument[] = [
originalPdf.value!,
...insertedDocs.value,
]
for (const item of pagePlan.value) {
if (item.type === 'blank') {
const page = result.addPage(item.pageSize)
if (item.rotation) {
page.setRotation(degrees(item.rotation))
}
} else {
const sourceDoc = sourceDocs[item.sourceFileIndex!]
const [copiedPage] = await result.copyPages(
sourceDoc,
[item.sourcePageIndex!]
)
result.addPage(copiedPage)
if (item.rotation) {
const pages = result.getPages()
pages[pages.length - 1].setRotation(degrees(item.rotation))
}
}
}
return await result.save()
}
Key design decisions
1. Plan-first, not action-first
The plan-first approach has several advantages over applying each action immediately to the PDF:
- Undo is free — just mutate the plan array back
- Preview the final page order before committing
- Batch operations — quick-select, reverse, remove blanks are all plan transforms
- Zero PDF processing until export — better performance, no intermediate saves
2. Blank pages respect PDF dimensions
Blank pages need size and orientation settings. The tool supports:
- Match the original PDF's dimensions
- Standard sizes: A4 (595×842 pt), Letter (612×792 pt)
- Custom portrait or landscape orientation
These are stored in the plan item so different blanks can have different dimensions in the same document.
3. Quick-select for large documents
For 100+ page documents, selecting individual pages is impractical. The toolbar provides:
function selectOdd() {
// Select pages at odd indices (1, 3, 5...)
const ids = pagePlan.value
.filter((_, i) => i % 2 === 0)
.map(item => item.id)
selectedIds.value = new Set(ids)
}
function selectRange(from: number, to: number) {
const ids = pagePlan.value
.slice(from - 1, to)
.map(item => item.id)
selectedIds.value = new Set(ids)
}
4. Export modes
The tool supports two export modes:
- Export all pages: Execute the full plan
- Export selected pages only: Execute only the selected subset of the plan
This is simpler than implementing a "delete everything except selected" workflow — just filter the plan during execution.
Summary
Building a visual PDF page organizer involves:
- Modeling the page plan as a reactive array of typed items
- Implementing all operations as plan transformations (not direct PDF mutations)
- Using HTML5 Drag and Drop for visual reordering
- Supporting multi-file sources with
sourceFileIndex - Executing the plan against pdf-lib only at export time
Try it at en.sotool.top/organize-pdf. Rearrange, insert blanks, pull pages from other files — all in one visual workflow.
Top comments (0)