This content originally appeared on DEV Community and was authored by Xiao Ling
In today’s digital world, we often need to convert, merge, and edit documents from various sources. Whether you’re working with PDFs, Word documents, images from your phone, or scanned documents from a physical scanner, having a unified tool that handles all these formats is invaluable.
In this tutorial, we’ll build a free, web-based document converter that runs entirely in your browser, ensuring your files remain private and secure. Most features are completely free, with only the advanced scanner integration requiring a license for use.
Demo: Free Online Document Converter with Scanner Support
Live Demo
https://yushulx.me/javascript-barcode-qr-code-scanner/examples/document_converter/
Why This Tool is Useful
Real-World Usage Scenarios
-
Home Office Document Management
- Merge multiple scanned receipts into a single PDF for expense reports
- Convert Word documents to PDF before submitting applications
- Combine photos of handwritten notes with typed documents
-
Remote Work & Collaboration
- Quickly edit and merge documents without uploading to cloud services (privacy-focused)
- Create document packages from mixed sources (camera, scanner, files)
- Edit Word documents directly in the browser without Microsoft Office
-
Education
- Students can merge lecture notes, screenshots, and scanned materials
- Teachers can create combined study materials from various sources
- No software installation required on school computers
-
Small Business
- Process invoices and receipts from physical documents using a scanner
- Create professional PDF portfolios from mixed media
- Edit and merge contracts without expensive software
Key Advantages
- 100% Browser-Based: No server uploads, no privacy concerns
- Free Core Features: Document viewing, editing, merging, and export
- Multi-Format Support: PDF, Word, images, TIFF, text files
- Physical Scanner Support: Optional integration with TWAIN/WIA/SANE/ICA scanners
- Offline Capable: Works without internet after initial load
Free vs. Paid Features
Completely FREE Features (No License Required)
- File upload and viewing (PDF, DOCX, images, TIFF, TXT)
- Camera capture
- Document editing (text and images)
- Image editing (crop, rotate, filters, resize)
- Drag-and-drop reordering
- Merge documents
- Export to PDF and Word
- Undo/Redo
- Thumbnail navigation
- Zoom and pan
Scanner Integration
The Dynamic Web TWAIN trial license is required.
Technologies Overview
We’ll use these powerful libraries:
| Library | Purpose |
|---|---|
| PDF.js | PDF rendering |
| Mammoth.js | DOCX to HTML conversion |
| jsPDF | PDF generation |
| html-docx-js | Word document generation |
| html2canvas | HTML to image rendering |
| UTIF.js | TIFF decoding |
| Dynamic Web TWAIN | Scanner integration |
Step 1: HTML Structure
Create index.html with the basic structure:
<!DOCTYPE html>
<html lang="en">
<head>
<meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1.0">
<title>Document Reader & Editor</title>
<link rel="icon" type="image/png" href="favicon.png">
<link rel="stylesheet" href="style.css">
<link rel="stylesheet" href="lib/fontawesome.min.css">
<!-- Core Libraries -->
<script src="lib/jquery.min.js"></script>
<script src="lib/jszip.min.js"></script>
<script src="lib/mammoth.browser.min.js"></script>
<script src="lib/html-docx.js"></script>
<!-- PDF & Image Tools -->
<script src="lib/pdf.min.js"></script>
<script>window.pdfjsLib.GlobalWorkerOptions.workerSrc = 'lib/pdf.worker.min.js';</script>
<script src="lib/jspdf.umd.min.js"></script>
<script src="lib/html2canvas.min.js"></script>
<script src="lib/UTIF.js"></script>
</head>
<body>
<header>
<h1>Document Converter & Gallery</h1>
<p>Convert Word/PDF/Images, Merge, and Save.</p>
</header>
<main>
<!-- Controls toolbar -->
<div id="controls">
<div class="controls-group">
<label for="file-input" class="button btn-primary btn-icon-only" title="Add Files">
<i class="fas fa-file-upload"></i>
</label>
<button id="camera-button" class="button btn-primary btn-icon-only" title="Open Camera">
<i class="fas fa-camera"></i>
</button>
<button id="scanner-button" class="button btn-primary btn-icon-only" title="Scan from Scanner">
<i class="fas fa-print"></i>
</button>
<button id="add-page-button" class="button btn-primary btn-icon-only" title="Create Blank Page">
<i class="fas fa-file-medical"></i>
</button>
<input type="file" id="file-input" accept=".docx,.pdf,.jpg,.jpeg,.png,.bmp,.webp,.tiff,.txt" multiple>
</div>
<div class="controls-group">
<button id="save-pdf-button" class="button btn-success btn-icon-only" title="Save as PDF">
<i class="fas fa-file-pdf"></i>
</button>
<button id="save-word-button" class="button btn-success btn-icon-only" title="Save as Word">
<i class="fas fa-file-word"></i>
</button>
</div>
</div>
<!-- Workspace with thumbnails and viewer -->
<div id="workspace">
<aside id="thumbnails-panel"></aside>
<section id="viewer-panel">
<div id="scroll-wrapper">
<div id="large-view-container"></div>
</div>
</section>
</div>
</main>
<!-- Loading Overlay for DOCX Processing -->
<div id="loading-overlay" class="modal-overlay" style="display: none; z-index: 2000;">
<div class="loading-content" style="text-align: center; color: white;">
<i class="fas fa-spinner fa-spin fa-3x"></i>
<p style="margin-top: 15px; font-size: 1.2rem;">Processing Document...</p>
</div>
</div>
<script src="app.js" defer></script>
</body>
</html>
Key Elements:
- File input for uploading files
- Camera button for capturing images
- Scanner button for physical document scanning
- Thumbnails panel for navigation
- Viewer panel for displaying selected page
- Loading overlay for feedback during DOCX processing
Step 2: CSS Styling
Create style.css for a modern, responsive interface:
body {
font-family: -apple-system, BlinkMacSystemFont, "Segoe UI", Roboto, Helvetica, Arial, sans-serif;
margin: 0;
padding: 0;
background-color: #f4f7f9;
color: #333;
display: flex;
flex-direction: column;
height: 100vh;
overflow: hidden;
}
header {
background-color: #fff;
border-bottom: 1px solid #dde3e8;
padding: 20px 0;
text-align: center;
}
header h1 {
margin: 0;
font-size: 1.8rem;
color: #007bff;
}
#controls {
display: flex;
align-items: center;
gap: 12px;
padding: 15px 20px;
background: #f8f9fa;
border-bottom: 2px solid #e0e0e0;
}
.button {
padding: 10px 16px;
font-size: 0.95rem;
color: #fff;
background-color: #007bff;
border: none;
border-radius: 5px;
cursor: pointer;
transition: all 0.2s ease;
}
.button:hover {
background-color: #0056b3;
}
#workspace {
display: flex;
flex: 1;
overflow: hidden;
}
#thumbnails-panel {
width: 200px;
background-color: #f9f9f9;
overflow-y: auto;
padding: 10px;
}
.thumbnail {
background: white;
margin-bottom: 10px;
padding: 5px;
border: 2px solid transparent;
border-radius: 4px;
cursor: pointer;
}
.thumbnail.active {
border-color: #007bff;
}
#viewer-panel {
flex: 1;
position: relative;
overflow: hidden;
}
/* Loading Overlay */
#loading-overlay {
position: fixed;
top: 0;
left: 0;
width: 100%;
height: 100%;
background: rgba(0, 0, 0, 0.7);
display: flex;
align-items: center;
justify-content: center;
}
.loading-content {
text-align: center;
color: white;
}
Step 3: IndexedDB for Local Storage
We’ll use IndexedDB to store pages locally, ensuring data persists even after page refresh:
const dbName = 'DocScannerDB';
const storeName = 'images';
let db;
function initDB() {
return new Promise((resolve, reject) => {
const request = indexedDB.open(dbName, 1);
request.onerror = (e) => reject(e);
request.onsuccess = (e) => {
db = e.target.result;
resolve(db);
};
request.onupgradeneeded = (e) => {
const db = e.target.result;
if (!db.objectStoreNames.contains(storeName)) {
db.createObjectStore(storeName, { keyPath: 'id' });
}
};
});
}
// Initialize on page load
initDB().then(() => {
loadSavedPages();
}).catch(console.error);
Why IndexedDB?
- Stores large binary data (images, documents)
- Asynchronous (doesn’t block UI)
- Works offline
- No size limits like localStorage
Step 4: File Upload and Processing
Handle multiple file formats:
const fileInput = document.getElementById('file-input');
fileInput.addEventListener('change', (event) => {
handleFiles(Array.from(event.target.files));
fileInput.value = ''; // Reset input
});
async function handleFiles(files) {
for (const file of files) {
const ext = file.name.split('.').pop().toLowerCase();
if (ext === 'pdf') {
await handlePDF(file);
} else if (ext === 'docx') {
await handleDOCX(file);
} else if (ext === 'txt') {
await handleTXT(file);
} else if (['tiff', 'tif'].includes(ext)) {
await handleTIFF(file);
} else if (['jpg', 'jpeg', 'png', 'bmp', 'webp'].includes(ext)) {
await handleImage(file);
}
}
}
Processing PDF Files
async function handlePDF(file) {
const arrayBuffer = await file.arrayBuffer();
const pdf = await pdfjsLib.getDocument({ data: arrayBuffer }).promise;
for (let i = 1; i <= pdf.numPages; i++) {
const page = await pdf.getPage(i);
const viewport = page.getViewport({ scale: 1.5 });
const canvas = document.createElement('canvas');
const context = canvas.getContext('2d');
canvas.height = viewport.height;
canvas.width = viewport.width;
await page.render({
canvasContext: context,
viewport: viewport
}).promise;
await addPage({
dataUrl: canvas.toDataURL('image/jpeg', 0.9),
width: viewport.width,
height: viewport.height,
sourceFile: `${file.name} (Page ${i})`
});
}
}
Processing DOCX Files
async function handleDOCX(file) {
const loadingOverlay = document.getElementById('loading-overlay');
loadingOverlay.style.display = 'flex';
try {
const arrayBuffer = await file.arrayBuffer();
const result = await mammoth.convertToHtml({ arrayBuffer: arrayBuffer });
const html = result.value;
// Generate thumbnail
const tempContainer = document.createElement('div');
tempContainer.style.width = '800px';
tempContainer.style.background = 'white';
tempContainer.style.padding = '40px';
tempContainer.style.position = 'absolute';
tempContainer.style.left = '-9999px';
tempContainer.innerHTML = html;
document.body.appendChild(tempContainer);
let thumbnailDataUrl;
try {
const canvas = await html2canvas(tempContainer, {
scale: 0.5,
height: 1100,
windowHeight: 1100,
useCORS: true,
ignoreElements: (element) => {
return element.tagName === 'VIDEO' || element.id === 'camera-overlay';
}
});
thumbnailDataUrl = canvas.toDataURL('image/jpeg', 0.8);
} catch (e) {
console.error("Thumbnail generation failed:", e);
thumbnailDataUrl = 'data:image/png;base64,iVBORw0KGgoAAAANSUhEUgAAAAEAAAABCAQAAAC1HAwCAAAAC0lEQVR42mP8/x8AAwMCAO+ip1sAAAAASUVORK5CYII=';
} finally {
document.body.removeChild(tempContainer);
}
await addPage({
dataUrl: thumbnailDataUrl,
width: 800,
height: 1100,
sourceFile: file.name,
htmlContent: html // Store for editing
});
} finally {
loadingOverlay.style.display = 'none';
}
}
Key Feature: We show a loading animation while processing large DOCX files, improving user experience.
Step 5: Camera Capture
Add the ability to capture images directly from the device camera:
const cameraButton = document.getElementById('camera-button');
const cameraOverlay = document.getElementById('camera-overlay');
const cameraVideo = document.getElementById('camera-video');
const captureBtn = document.getElementById('capture-btn');
const closeCameraBtn = document.getElementById('close-camera-btn');
let mediaStream = null;
cameraButton.addEventListener('click', async () => {
try {
mediaStream = await navigator.mediaDevices.getUserMedia({ video: true });
cameraVideo.srcObject = mediaStream;
cameraOverlay.style.display = 'flex';
} catch (err) {
alert('Camera access denied or not available.');
}
});
closeCameraBtn.addEventListener('click', stopCamera);
function stopCamera() {
if (mediaStream) {
mediaStream.getTracks().forEach(track => track.stop());
}
cameraOverlay.style.display = 'none';
}
captureBtn.addEventListener('click', async () => {
const canvas = document.createElement('canvas');
canvas.width = cameraVideo.videoWidth;
canvas.height = cameraVideo.videoHeight;
const ctx = canvas.getContext('2d');
ctx.drawImage(cameraVideo, 0, 0, canvas.width, canvas.height);
await addPage({
dataUrl: canvas.toDataURL('image/jpeg', 0.9),
width: canvas.width,
height: canvas.height,
sourceFile: `Camera Capture ${new Date().toLocaleTimeString()}`
});
selectPage(pages.length - 1);
});
Step 6: Scanner Integration with Dynamic Web TWAIN
Now let’s add the optional scanner feature using Dynamic Web TWAIN. This is the only paid feature in the entire application.
6.1: Add Scanner HTML Modal
<!-- Scanner Modal -->
<div id="scanner-modal" class="modal" style="display:none;">
<div class="modal-content">
<div class="modal-header">
<h3>Scan from Scanner</h3>
</div>
<div class="modal-body">
<div class="control-group">
<label>License Key:</label>
<input type="text" id="dwt-license" placeholder="Enter Dynamic Web TWAIN license">
<small>Get a <a href="https://www.dynamsoft.com/customer/license/trialLicense/?product=dcv&package=cross-platform" target="_blank">free trial license</a>.</small>
</div>
<div class="control-group">
<label>Select Scanner:</label>
<select id="scanner-source">
<option value="">No scanners found</option>
</select>
<button id="refresh-scanners" class="button btn-secondary">
<i class="fas fa-sync-alt"></i>
</button>
</div>
<div class="control-group">
<label>Resolution (DPI):</label>
<select id="scan-resolution">
<option value="100">100</option>
<option value="200" selected>200</option>
<option value="300">300</option>
<option value="600">600</option>
</select>
</div>
<div class="control-group">
<input type="checkbox" id="scan-adf">
<label for="scan-adf">Use ADF (Feeder)</label>
</div>
</div>
<div class="modal-footer">
<button id="scanner-cancel" class="button btn-secondary">Cancel</button>
<button id="scanner-scan" class="button btn-primary">Scan Now</button>
</div>
</div>
</div>
6.2: Scanner JavaScript Logic
const scannerButton = document.getElementById('scanner-button');
const scannerModal = document.getElementById('scanner-modal');
const host = 'http://127.0.0.1:18622'; // Local DWT service
scannerButton.addEventListener('click', () => {
openModal(scannerModal);
fetchScanners();
});
async function fetchScanners() {
try {
const response = await fetch(`${host}/api/device/scanners`);
const data = await response.json();
const select = document.getElementById('scanner-source');
select.innerHTML = '';
if (data.length === 0) {
select.innerHTML = '<option>No scanners found</option>';
} else {
data.forEach(scanner => {
const option = document.createElement('option');
option.value = JSON.stringify(scanner);
option.textContent = scanner.name;
select.appendChild(option);
});
}
} catch (error) {
console.error('Scanner fetch error:', error);
alert('Scanner service not running. Please install Dynamic Web TWAIN service.');
}
}
document.getElementById('scanner-scan').addEventListener('click', async () => {
const scanner = document.getElementById('scanner-source').value;
const license = document.getElementById('dwt-license').value.trim();
if (!license) {
alert('Please enter a license key.');
return;
}
const parameters = {
license: license,
device: JSON.parse(scanner).device,
config: {
PixelType: 2,
Resolution: parseInt(document.getElementById('scan-resolution').value),
IfFeederEnabled: document.getElementById('scan-adf').checked
}
};
try {
// Create scan job
const jobResponse = await fetch(`${host}/api/device/scanners/jobs`, {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify(parameters)
});
const jobData = await jobResponse.json();
const jobId = jobData.jobId;
// Poll for results
let imageId = '';
while (true) {
await new Promise(resolve => setTimeout(resolve, 1000));
const statusResponse = await fetch(`${host}/api/device/scanners/jobs/${jobId}`);
const statusData = await statusResponse.json();
if (statusData.state === 'Transferred') {
imageId = statusData.imageId;
break;
} else if (statusData.state === 'Failed') {
throw new Error('Scan failed');
}
}
// Get scanned images
const imageResponse = await fetch(`${host}/api/buffer/${imageId}`);
const imageData = await imageResponse.json();
for (const img of imageData.images) {
const dataUrl = `data:image/png;base64,${img.data}`;
await addPage({
dataUrl: dataUrl,
width: img.width,
height: img.height,
sourceFile: `Scanned Document ${new Date().toLocaleString()}`
});
}
closeModal();
alert(`Successfully scanned ${imageData.images.length} page(s)!`);
} catch (error) {
console.error('Scan error:', error);
alert('Scanning failed. Check console for details.');
}
});
Step 7: Adding Pages to the Gallery
Implement the core page management system:
let pages = [];
let currentPageIndex = -1;
async function addPage(pageData) {
const id = Date.now() + Math.random();
const blob = dataURLtoBlob(pageData.dataUrl);
const thumbnailDataUrl = await createThumbnail(pageData.dataUrl);
const pageObject = {
id,
blob,
originalBlob: blob,
history: [blob],
historyIndex: 0,
width: pageData.width,
height: pageData.height,
sourceFile: pageData.sourceFile,
thumbnailDataUrl: thumbnailDataUrl,
htmlContent: pageData.htmlContent
};
await storeImageInDB(pageObject);
pages.push({
id,
width: pageData.width,
height: pageData.height,
sourceFile: pageData.sourceFile,
thumbnailDataUrl: thumbnailDataUrl,
historyIndex: 0,
historyLength: 1,
htmlContent: pageData.htmlContent
});
renderAllThumbnails();
}
function renderAllThumbnails() {
const thumbnailsPanel = document.getElementById('thumbnails-panel');
thumbnailsPanel.innerHTML = '';
pages.forEach((page, index) => {
const div = document.createElement('div');
div.className = 'thumbnail';
div.dataset.index = index;
div.onclick = () => selectPage(index);
const img = document.createElement('img');
img.src = page.thumbnailDataUrl;
const num = document.createElement('div');
num.className = 'thumbnail-number';
num.textContent = index + 1;
div.appendChild(img);
div.appendChild(num);
thumbnailsPanel.appendChild(div);
});
}
function createThumbnail(dataUrl, maxWidth = 300) {
return new Promise((resolve) => {
const img = new Image();
img.onload = () => {
const canvas = document.createElement('canvas');
const scale = maxWidth / img.width;
canvas.width = maxWidth;
canvas.height = img.height * scale;
const ctx = canvas.getContext('2d');
ctx.drawImage(img, 0, 0, canvas.width, canvas.height);
resolve(canvas.toDataURL('image/jpeg', 0.8));
};
img.src = dataUrl;
});
}
Step 8: Image Editing Features
Add crop, rotate, and filter capabilities:
// Rotate
rotateBtn.addEventListener('click', () => {
currentRotation = 0;
rotateSlider.value = 0;
openModal(rotateModal);
});
rotateApply.addEventListener('click', async () => {
const img = document.getElementById('large-image');
const canvas = document.createElement('canvas');
const ctx = canvas.getContext('2d');
const rad = currentRotation * Math.PI / 180;
const sin = Math.abs(Math.sin(rad));
const cos = Math.abs(Math.cos(rad));
const w = img.naturalWidth;
const h = img.naturalHeight;
canvas.width = w * cos + h * sin;
canvas.height = w * sin + h * cos;
ctx.translate(canvas.width / 2, canvas.height / 2);
ctx.rotate(rad);
ctx.drawImage(img, -w / 2, -h / 2);
await saveEditedImage(canvas.toDataURL('image/jpeg', 0.9));
closeModal();
});
// Filters
filterApply.addEventListener('click', async () => {
const img = document.getElementById('large-image');
const canvas = document.createElement('canvas');
canvas.width = img.naturalWidth;
canvas.height = img.naturalHeight;
const ctx = canvas.getContext('2d');
ctx.drawImage(img, 0, 0);
const imageData = ctx.getImageData(0, 0, canvas.width, canvas.height);
const data = imageData.data;
const brightness = parseInt(brightnessSlider.value);
const contrast = parseInt(contrastSlider.value);
const factor = (259 * (contrast + 255)) / (255 * (259 - contrast));
for (let i = 0; i < data.length; i += 4) {
// Apply brightness and contrast
data[i] = factor * (data[i] - 128) + 128 + brightness;
data[i+1] = factor * (data[i+1] - 128) + 128 + brightness;
data[i+2] = factor * (data[i+2] - 128) + 128 + brightness;
// Apply grayscale if selected
if (filterType.value === 'grayscale') {
const gray = 0.299 * data[i] + 0.587 * data[i+1] + 0.114 * data[i+2];
data[i] = data[i+1] = data[i+2] = gray;
}
}
ctx.putImageData(imageData, 0, 0);
await saveEditedImage(canvas.toDataURL('image/jpeg', 0.9));
closeModal();
});
Step 9: Export to PDF and Word
Export to PDF
We use the browser’s print functionality because jsPDF requires large custom font files to support non-Latin characters (like Chinese/Japanese) correctly. The system print dialog ensures all characters are rendered correctly and remain editable/selectable.
savePdfButton.addEventListener('click', async () => {
if (pages.length === 0) return alert('No pages to save!');
const iframe = document.createElement('iframe');
iframe.style.position = 'fixed';
iframe.style.width = '0';
iframe.style.height = '0';
document.body.appendChild(iframe);
let htmlContent = '<!DOCTYPE html><html><head><title>Document</title>';
htmlContent += `
<style>
@page { size: A4; margin: 20mm; }
body { margin: 0; padding: 0; font-family: Arial, sans-serif; }
img { max-width: 100%; }
.page-break { page-break-after: always; }
</style>`;
htmlContent += '</head><body>';
for (let i = 0; i < pages.length; i++) {
const page = pages[i];
if (page.htmlContent) {
htmlContent += `<div class="page-content">${page.htmlContent}</div>`;
} else {
const blob = await getImageFromDB(page.id);
const dataUrl = await blobToDataURL(blob);
htmlContent += `<img src="${dataUrl}" alt="Page ${i+1}">`;
}
if (i < pages.length - 1) {
htmlContent += '<div class="page-break"></div>';
}
}
htmlContent += '</body></html>';
const doc = iframe.contentWindow.document;
doc.open();
doc.write(htmlContent);
doc.close();
iframe.onload = () => {
setTimeout(() => {
iframe.contentWindow.print();
setTimeout(() => document.body.removeChild(iframe), 100);
}, 500);
};
});
Export to Word
saveWordButton.addEventListener('click', async () => {
if (pages.length === 0) return alert('No pages to save!');
let htmlContent = '<!DOCTYPE html><html><head><meta charset="utf-8"><title>Document</title></head><body>';
for (const page of pages) {
if (page.htmlContent) {
htmlContent += page.htmlContent;
} else {
const blob = await getImageFromDB(page.id);
const dataUrl = await blobToDataURL(blob);
htmlContent += `<p><img src="${dataUrl}" /></p>`;
}
}
htmlContent += '</body></html>';
const converted = htmlDocx.asBlob(htmlContent);
const link = document.createElement('a');
link.href = URL.createObjectURL(converted);
link.download = 'combined_document.docx';
document.body.appendChild(link);
link.click();
document.body.removeChild(link);
});
Note: To avoid garbled characters in Word export, ensure all text is properly encoded in UTF-8.
Step 10: Undo and Redo
Implement history tracking for image edits:
async function saveEditedImage(dataUrl) {
const page = pages[currentPageIndex];
const blob = dataURLtoBlob(dataUrl);
const thumb = await createThumbnail(dataUrl);
const img = new Image();
img.onload = async () => {
// Truncate future history if we're not at the end
const historyIndex = page.historyIndex !== undefined ? page.historyIndex : 0;
// Get existing history
const existingData = await getImageFromDB(page.id);
let history = existingData.history || [existingData.originalBlob];
// Remove future states
history = history.slice(0, historyIndex + 1);
// Add new state
history.push(blob);
// Update DB
await storeImageInDB({
id: page.id,
blob: blob,
originalBlob: existingData.originalBlob,
history: history,
historyIndex: history.length - 1,
width: img.width,
height: img.height,
sourceFile: page.sourceFile,
thumbnailDataUrl: thumb
});
// Update in-memory
page.width = img.width;
page.height = img.height;
page.thumbnailDataUrl = thumb;
page.historyIndex = history.length - 1;
page.historyLength = history.length;
renderAllThumbnails();
renderLargeView();
updateUndoRedoButtons();
};
img.src = dataUrl;
}
undoBtn.addEventListener('click', async () => {
const page = pages[currentPageIndex];
if (page.historyIndex > 0) {
await loadHistoryState(page, page.historyIndex - 1);
}
});
redoBtn.addEventListener('click', async () => {
const page = pages[currentPageIndex];
if (page.historyIndex < page.historyLength - 1) {
await loadHistoryState(page, page.historyIndex + 1);
}
});
Testing Your Application
Source Code
https://github.com/yushulx/javascript-barcode-qr-code-scanner/tree/main/examples/document_converter
This content originally appeared on DEV Community and was authored by Xiao Ling
