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

Top comments (0)