openvibers/openvibe.tools Commits
Use shared upload kit across file tools and local scanners (#308)
Changes
@@@ -37,6 +37,14 @@ module.exports = function createFrame({ kit, serve }) {return `${Math.round(b / 1024)} KB`;}++ /** The file tool's native input is kept for samples; OVUpload draws the visible picker. */++ function upload({ accept, label, hint, multiple = false, maxBytes, maxFiles = 1 }) {++ return `<input type="file" id="file-input" accept="${esc(accept)}"${multiple ? ' multiple' : ''} hidden>`++ + `<div data-ov-tool-upload data-label="${esc(label)}" data-max-bytes="${Number(maxBytes)}" data-max-files="${Number(maxFiles)}"></div>`++ + `<p>${esc(hint || 'Choose a supported file')} · up to ${maxFiles} file${maxFiles === 1 ? '' : 's'}, ${size(maxBytes)} each.</p>`++ + `<p role="alert" data-ov-tool-upload-error hidden></p><div data-ov-tool-upload-progress></div>`;++ }++/*** The terms of a tool, from its real configuration.* @param {object} s@@@ -225,5 +233,5 @@ module.exports = function createFrame({ kit, serve }) {};}-- return { terms, field, labelOf, head, verbButton, folds, page, assets, size, SITE };++ return { terms, field, labelOf, head, verbButton, folds, page, assets, upload, size, SITE };};@@@ -34,6 +34,21 @@var files = o.files || [];var field = o.fileField || (files.length > 1 ? 'files' : 'file');for (var i = 0; i < files.length; i++) fd.append(field, files[i]);++ if (typeof o.onProgress === 'function') return new Promise(function (resolve, reject) {++ var xhr = new XMLHttpRequest();++ xhr.open('POST', '/api/v1/jobs');++ xhr.setRequestHeader('Idempotency-Key', o.idempotencyKey || newKey());++ xhr.setRequestHeader('Accept', 'application/json');++ xhr.upload.onprogress = function (event) { if (event.lengthComputable) o.onProgress({ loaded: event.loaded, total: event.total }); };++ xhr.onload = function () {++ var body;++ try { body = JSON.parse(xhr.responseText); } catch (_) { body = null; }++ if (xhr.status >= 200 && xhr.status < 300) resolve(body);++ else reject(problemError({ status: xhr.status }, body));++ };++ xhr.onerror = function () { reject(new Error('The connection stopped. Try again.')); };++ xhr.send(fd);++ });return fetch('/api/v1/jobs', {method: 'POST', body: fd, credentials: 'same-origin',headers: { 'Idempotency-Key': o.idempotencyKey || newKey(), Accept: 'application/json' },@@@ -12,6 +12,7 @@var kit = window.OpenVibeKit;if (!kit) return;++ var currentUpload = null;/** A file size in the words a person reads: "1.4 MB", "812 KB". */function size(bytes) {@@@ -68,17 +69,67 @@}).then(function (blob) {var name = String(url).split('/').pop() || 'sample';var file = new File([blob], name, { type: blob.type || 'application/octet-stream' });-- var input = document.querySelector('.ovk-drop-input');-- if (input && window.DataTransfer) {-- var dt = new DataTransfer();-- dt.items.add(file);-- input.files = dt.files;-- input.dispatchEvent(new Event('change', { bubbles: true }));-- }-- if (onFile) onFile(file);++ if (currentUpload) currentUpload.add([file]);++ else if (onFile) onFile(file);}).catch(function () { /* the sample is a convenience, not a promise */ });}++ /** Shared picker for image, document and audio tools; their job transport owns the upload. */++ function upload(onFiles) {++ var root = document.querySelector('[data-ov-tool-upload]');++ var input = document.getElementById('file-input');++ var error = document.querySelector('[data-ov-tool-upload-error]');++ var progressRoot = document.querySelector('[data-ov-tool-upload-progress]');++ if (!root || !input || !error || !progressRoot || !window.OVUpload) return null;++ var files = [];++ var clearing = false;++ var maxBytes = Number(root.dataset.maxBytes);++ var maxFiles = Number(root.dataset.maxFiles);++ var meter = OVUpload.progress(progressRoot, { onRetry: function () { document.getElementById('process-btn')?.click(); } });++ function warn(message) { error.textContent = message; error.hidden = false; }++ var picker = OVUpload.picker(root, {++ label: root.dataset.label, accept: input.accept, multiple: input.multiple,++ onFile: function (file, row) {++ var accepted = input.accept.split(',').some(function (part) {++ part = part.trim().toLowerCase();++ return part === file.type.toLowerCase() || (part.endsWith('/*') && file.type.toLowerCase().startsWith(part.slice(0, -1))) || (part.startsWith('.') && file.name.toLowerCase().endsWith(part));++ });++ if (!accepted || (input.multiple && files.length >= maxFiles) || file.size > maxBytes) {++ row.dispatchEvent(new Event('remove')); row.remove();++ warn(!accepted ? 'Choose a supported file format for this tool.' : files.length >= maxFiles ? 'Choose at most ' + maxFiles + ' files.' : file.name + ' is over ' + size(maxBytes) + '.');++ return;++ }++ if (!input.multiple) {++ files = [];++ picker.rows.querySelectorAll('.ov-upload__row').forEach(function (old) {++ if (old !== row) { old.dispatchEvent(new Event('remove')); old.remove(); }++ });++ }++ error.hidden = true;++ files.push(file);++ onFiles(files.slice());++ meter.update({ state: 'waiting', done: 0, total: files.reduce(function (sum, f) { return sum + f.size; }, 0) });++ },++ onRemove: function (file) {++ files = files.filter(function (item) { return item !== file; });++ if (!clearing) onFiles(files.slice());++ }++ });++ function clear() {++ clearing = true;++ files = [];++ picker.rows.querySelectorAll('.ov-upload__row').forEach(function (row) { row.dispatchEvent(new Event('remove')); });++ picker.rows.replaceChildren();++ clearing = false;++ meter.update({ state: 'waiting', done: 0, total: 0 });++ }++ currentUpload = { add: picker.add, clear: clear, start: function () { meter.update({ state: 'sending', done: 0, total: files.reduce(function (sum, f) { return sum + f.size; }, 0) }); },++ progress: function (done, total) { meter.update({ state: 'sending', done: done, total: total }); },++ done: function () { var total = files.reduce(function (sum, f) { return sum + f.size; }, 0); meter.update({ state: 'done', done: total, total: total }); },++ fail: function (message) { meter.update({ state: 'failed', message: message }); } };++ return currentUpload;++ }++/*** A text tool's live output belongs in the frame's answer slot. Only an output that sits directly* in the box moves: one inside a two-pane editor (the Markdown preview) keeps its place.@@@ -122,5 +173,5 @@if (document.readyState === 'loading') document.addEventListener('DOMContentLoaded', boot);else boot();-- window.OVToolFrame = { size: size, fileAnswer: fileAnswer, show: show, sample: sample, moveAnswer: moveAnswer };++ window.OVToolFrame = { size: size, fileAnswer: fileAnswer, show: show, sample: sample, upload: upload, moveAnswer: moveAnswer };})();@@@ -42,6 +42,8 @@<script src="/shared/kit.js" defer></script><script src="/shared/kit-web.js" defer></script><script src="/shared/kit-data-web.js" defer></script>++ <link rel="stylesheet" href="/shared/ov-upload.css">++ <script src="/shared/ov-upload.js" defer></script><script src="/js/ov-jobs.js"></script><script src="/js/tool-frame.js" defer></script><script src="/js/app.js"></script>@@@ -22,6 +22,7 @@const processBtn = $('#process-btn');const clearBtn = $('#clear-btn');const kit = () => window.OpenVibeKit;++ let uploadUI = null;// ── Init ─────────────────────────────────────────────────async function init() {@@@ -105,10 +106,7 @@// ── The box: the drop zone ───────────────────────────────function initDrop() {if (!fileInput) return;-- fileInput.addEventListener('change', () => {-- const files = Array.prototype.slice.call(fileInput.files || []);-- if (files.length) handleFiles(files);-- });++ uploadUI = window.OVToolFrame && OVToolFrame.upload((files) => files.length ? handleFiles(files) : resetUpload(false));if (clearBtn) clearBtn.addEventListener('click', resetUpload);const sampleLink = document.querySelector('[data-ovk-sample]');@@@ -137,9 +135,10 @@hideAnswer();}-- function resetUpload() {++ function resetUpload(clearPicker = true) {selectedFiles = [];if (fileInput) fileInput.value = '';++ if (clearPicker && uploadUI) uploadUI.clear();if (optionsPanel) optionsPanel.hidden = true;if (processBtn) processBtn.disabled = true;hideAnswer();@@@ -181,12 +180,15 @@try {const many = selectedFiles.length > 1;++ if (uploadUI) uploadUI.start();const job = await OVJobs.submit({-- type: 'audio.process', input, files: selectedFiles, fileField: many ? 'files' : 'file',++ type: 'audio.process', input, files: selectedFiles, fileField: many ? 'files' : 'file', onProgress: uploadUI ? (p) => uploadUI.progress(p.loaded, p.total) : null,});++ if (uploadUI) uploadUI.done();OVJobs.remember(job.id);followJob(job);} catch (err) {++ if (uploadUI) uploadUI.fail(err.message);failProcessing(err.message);}}@@@ -141,11 +141,7 @@ function boxFor(rec, host) {const spec = SPEC.get(rec.id === 'hub' ? 'convert' : rec.id);const many = (spec && spec.files && spec.files.max) > 1;const accept = (spec && spec.files && spec.files.accept) || AUDIO_ACCEPT;-- const drop = kit.drop({-- name: 'file', accept: accept.join(','), id: 'file-input', multiple: many,-- label: many ? 'Drop your recordings here' : 'Drop a recording here',-- hint: many ? 'Up to five files at once' : 'MP3, WAV, FLAC, OGG, M4A, AAC, Opus, WMA, AIFF, MP4, WebM',-- });++ const drop = frame.upload({ accept: accept.join(','), label: many ? 'Choose recordings' : 'Choose a recording', hint: 'MP3, WAV, FLAC, OGG, M4A, AAC, Opus, WMA, AIFF, MP4 or WebM', multiple: many, maxBytes: config.upload.maxFileSize, maxFiles: many ? 5 : 1 });return `<form class="ovk-tool-form" id="ovk-audio-form" enctype="multipart/form-data" data-ovk-tool-form>`+ drop+ `<p class="ovk-try"><span>Try</span><a href="/sample.mp3" data-ovk-sample="/sample.mp3">A sample recording</a></p>`@@@ -48,7 +48,7 @@ function factsOf(html) {'class="ovk-family"', 'class="ovk-tool-folds"', 'People also use',]);assert.ok(html.includes('https://audio.openvibe.tools/">Audio tools'), `${host}: the crumb is the family`);-- assert.ok(html.includes('class="ovk-drop"'), `${host}: a drop zone`);++ assert.ok(html.includes('data-ov-tool-upload') && html.includes('data-max-bytes='), `${host}: shared upload picker with limit`);assert.ok(html.includes('aria-current="page"'), `${host}: the family marks this tool`);assert.ok(html.includes('Use it from code') && html.includes('https://openvibe.tools/api/v1/tools/'), `${host}: the API fold`);assert.strictEqual((html.match(/<h1/g) || []).length, 1, `${host}: one h1`);@@@ -42,6 +42,8 @@<script src="/shared/kit.js" defer></script><script src="/shared/kit-web.js" defer></script><script src="/shared/kit-data-web.js" defer></script>++ <link rel="stylesheet" href="/shared/ov-upload.css">++ <script src="/shared/ov-upload.js" defer></script><script src="/js/ov-jobs.js"></script><script src="/js/tool-frame.js" defer></script><script src="/js/app.js"></script>@@@ -22,6 +22,7 @@const processBtn = $('#process-btn');const clearBtn = $('#clear-btn');const kit = () => window.OpenVibeKit;++ let uploadUI = null;// ── Init ─────────────────────────────────────────────────async function init() {@@@ -133,10 +134,7 @@// ── The box: the drop zone ───────────────────────────────function initDrop() {if (!fileInput) return;-- fileInput.addEventListener('change', () => {-- const files = Array.prototype.slice.call(fileInput.files || []);-- if (files.length) handleFiles(files);-- });++ uploadUI = window.OVToolFrame && OVToolFrame.upload((files) => files.length ? handleFiles(files) : resetUpload(false));if (clearBtn) clearBtn.addEventListener('click', resetUpload);const sampleLink = document.querySelector('[data-ovk-sample]');@@@ -166,9 +164,10 @@hideAnswer();}-- function resetUpload() {++ function resetUpload(clearPicker = true) {selectedFiles = [];if (fileInput) fileInput.value = '';++ if (clearPicker && uploadUI) uploadUI.clear();if (optionsPanel) optionsPanel.hidden = true;if (processBtn) processBtn.disabled = true;hideAnswer();@@@ -211,12 +210,15 @@try {const many = selectedFiles.length > 1;++ if (uploadUI) uploadUI.start();const job = await OVJobs.submit({-- type: 'docs.process', input, files: selectedFiles, fileField: many ? 'files' : 'file',++ type: 'docs.process', input, files: selectedFiles, fileField: many ? 'files' : 'file', onProgress: uploadUI ? (p) => uploadUI.progress(p.loaded, p.total) : null,});++ if (uploadUI) uploadUI.done();OVJobs.remember(job.id);followJob(job);} catch (err) {++ if (uploadUI) uploadUI.fail(err.message);failProcessing(err.message);}}@@@ -121,11 +121,7 @@ function boxFor(rec) {const spec = SPEC.get(rec.spec);const many = (spec && spec.files && spec.files.max) > 1;const accept = (spec && spec.files && spec.files.accept) || ['application/pdf'];-- const drop = kit.drop({-- name: 'file', accept: accept.join(','), id: 'file-input', multiple: many,-- label: many ? 'Drop your documents here' : 'Drop a document here',-- hint: many ? 'Up to 50 files at once' : 'A PDF, a picture, or both',-- });++ const drop = frame.upload({ accept: accept.join(','), label: many ? 'Choose documents' : 'Choose a document', hint: 'Accepted formats for this tool: ' + accept.join(', '), multiple: many, maxBytes: config.upload.maxFileSize, maxFiles: many ? 50 : 1 });return `<form class="ovk-tool-form" id="ovk-docs-form" enctype="multipart/form-data" data-ovk-tool-form>`+ `<input type="hidden" name="tool" value="${esc(opOf(rec))}">`+ drop@@@ -47,7 +47,7 @@ function factsOf(html) {'class="ovk-family"', 'class="ovk-tool-folds"', 'People also use',]);assert.ok(html.includes('https://docs.openvibe.tools/">PDF tools'), `${host}: the crumb is the family`);-- assert.ok(html.includes('class="ovk-drop"'), `${host}: a drop zone`);++ assert.ok(html.includes('data-ov-tool-upload') && html.includes('data-max-bytes='), `${host}: shared upload picker with limit`);assert.ok(html.includes('aria-current="page"'), `${host}: the family marks this tool`);assert.ok(html.includes('Use it from code') && html.includes('https://openvibe.tools/api/v1/tools/'), `${host}: the API fold`);assert.strictEqual((html.match(/<h1/g) || []).length, 1, `${host}: one h1`);@@@ -9,6 +9,7 @@<meta name="color-scheme" content="dark light"><link rel="apple-touch-icon" href="/assets/logo-192.png"><link rel="manifest" href="/manifest.webmanifest">++<link rel="stylesheet" href="/shared/ov-upload.css"><meta name="description" content="Make QR codes, barcodes and Data Matrix, scan them with your camera or a picture, and check GTIN, ISBN and GS1 numbers. Free, in your browser."><meta property="og:title" content="Barcode & QR Tools"><meta property="og:description" content="Make, scan and check QR codes, barcodes and Data Matrix, in your browser.">@@@ -303,10 +304,11 @@ table.ais td.v{font-family:var(--mono);word-break:break-all}<details class="panel" id="qr-logo-panel"><summary>Logo</summary><div class="row" style="align-items:center">-- <label class="btn"><svg class="ic"><use href="#i-upload"/></svg>Choose a picture<input type="file" id="qr-logo" accept="image/*" class="sr"></label>++ <div id="qr-logo-picker"></div><button type="button" class="btn ghost" id="qr-logo-clear" hidden><svg class="ic"><use href="#i-trash"/></svg>Remove</button><span class="muted" id="qr-logo-name"></span></div>++ <p class="muted">An image up to 20 MB. The logo is resized in your browser; it is never uploaded.</p><div class="field" style="margin-top:10px"><span class="lbl">Logo size</span><div class="range"><input type="range" id="qr-logo-size" min="12" max="30" value="22" aria-label="Logo size"><output id="qr-logo-size-o">22%</output></div><small>Error correction rises to H (30%) with a logo, and the code is checked after every change.</small></div></details><details class="panel">@@@ -407,7 +409,7 @@ table.ais td.v{font-family:var(--mono);word-break:break-all}<p>Point your camera at a code, or drop, paste (<span class="kbd">Ctrl</span>+<span class="kbd">V</span>) or choose a picture. Every code in it is read.</p><div class="row" style="justify-content:center"><button type="button" class="btn primary" id="sc-start"><svg class="ic"><use href="#i-camera"/></svg>Start camera</button>-- <label class="btn"><svg class="ic"><use href="#i-image"/></svg>Choose a picture<input type="file" id="sc-file" accept="image/*" class="sr"></label>++ <div id="sc-picture-picker"></div></div></div></div>@@@ -415,10 +417,11 @@ table.ais td.v{font-family:var(--mono);word-break:break-all}<button type="button" class="btn" id="sc-stop" hidden><svg class="ic"><use href="#i-stop"/></svg>Stop</button><button type="button" class="btn" id="sc-flip" hidden><svg class="ic"><use href="#i-flip"/></svg>Switch camera</button><button type="button" class="btn" id="sc-torch" hidden aria-pressed="false"><svg class="ic"><use href="#i-bolt"/></svg>Torch</button>-- <label class="btn ghost" id="sc-again" hidden><svg class="ic"><use href="#i-image"/></svg>Another picture<input type="file" id="sc-file2" accept="image/*" class="sr"></label>++ <button type="button" class="btn ghost" id="sc-again" hidden><svg class="ic"><use href="#i-image"/></svg>Another picture</button><label class="check" style="margin-left:auto"><input type="checkbox" id="sc-beep" checked> Sound</label></div><p class="muted" id="sc-engine" style="font-size:.8rem;margin:8px 0 0"></p>++ <p class="muted">Choose or drop an image up to 20 MB. Scanning happens in your browser; the picture is never uploaded.</p></div><div><div class="row" style="justify-content:space-between;align-items:center;margin-bottom:10px"><b>Found</b><button type="button" class="btn small ghost" id="sc-clear"><svg class="ic"><use href="#i-trash"/></svg>Clear history</button></div>@@@ -521,6 +524,7 @@ table.ais td.v{font-family:var(--mono);word-break:break-all}<script src="/js/codes/draw.js"></script><script src="/js/codes/read.js" id="codes-read" data-zxing="/vendor/[email protected]/reader.js" data-zxing-wasm="/vendor/[email protected]/zxing_reader.wasm"></script><script src="/vendor/[email protected]/bwip-js.min.js" defer></script>++<script src="/shared/ov-upload.js" defer></script><script src="/js/codes/app.js" defer></script></body></html>@@@ -242,8 +242,14 @@update();};// Logo: read, shrunk to 512 px, kept as a data URL inside the SVG.-- $('qr-logo').onchange = (e) => {-- const file = e.target.files && e.target.files[0]; if (!file) return;++ const logoRoot = $('qr-logo-picker');++ const logoPicker = OVUpload.picker(logoRoot, { label: 'Choose a logo image', accept: 'image/*', multiple: false,++ onFile: (file, row) => {++ logoPicker.rows.querySelectorAll('.ov-upload__row').forEach((old) => { if (old !== row) { old.dispatchEvent(new Event('remove')); old.remove(); } });++ if (!file.type.startsWith('image/') || file.size > 20 * 1048576) {++ row.dispatchEvent(new Event('remove')); row.remove();++ toast('Choose an image under 20 MB.'); return;++ }const img = new Image();img.onload = () => {const k = Math.min(1, 512 / Math.max(img.width, img.height));@@@ -256,8 +262,10 @@};img.onerror = () => toast('That file is not a picture this browser can read');img.src = URL.createObjectURL(file);-- };-- $('qr-logo-clear').onclick = () => { st.logo = null; $('qr-logo').value = ''; $('qr-logo-name').textContent = ''; $('qr-logo-clear').hidden = true; update(); };++ },++ onRemove: () => { st.logo = null; $('qr-logo-name').textContent = ''; $('qr-logo-clear').hidden = true; update(); },++ });++ $('qr-logo-clear').onclick = () => { logoPicker.rows.querySelectorAll('.ov-upload__row').forEach((row) => row.dispatchEvent(new Event('remove'))); logoPicker.rows.replaceChildren(); st.logo = null; $('qr-logo-name').textContent = ''; $('qr-logo-clear').hidden = true; update(); };bindRange('qr-logo-size', 'qr-logo-size-o', (v) => `${v}%`, (v) => { st.logoSize = v; update(); });seg($('qr-ecc'), [['auto', 'Auto'], ['L', 'L 7%'], ['M', 'M 15%'], ['Q', 'Q 25%'], ['H', 'H 30%']], st.ecc, (v) => { st.ecc = v; update(); });$('qr-quiet').value = st.quiet; $('qr-px').value = st.px; $('qr-dpi').value = String(st.dpi);@@@ -714,18 +722,25 @@};shot.src = url;}++ const scanPicker = OVUpload.picker($('sc-picture-picker'), {++ label: 'Choose a picture to scan', accept: 'image/*', multiple: false,++ onFile: (file, row) => {++ scanPicker.rows.querySelectorAll('.ov-upload__row').forEach((old) => { if (old !== row) { old.dispatchEvent(new Event('remove')); old.remove(); } });++ if (!file.type.startsWith('image/') || file.size > 20 * 1048576) {++ row.dispatchEvent(new Event('remove')); row.remove();++ toast('Choose an image under 20 MB.'); return;++ }++ picture(file);++ },++ onRemove: () => { stop(); shot.hidden = true; drop.hidden = false; },++ });$('sc-start').onclick = () => start();$('sc-stop').onclick = () => { stop(); drop.hidden = false; shot.hidden = true; };$('sc-flip').onclick = () => { if (!devices.length) return; deviceIdx = (deviceIdx + 1) % devices.length; start(devices[deviceIdx].deviceId); };$('sc-torch').onclick = async () => { const t = stream && stream.getVideoTracks()[0]; if (!t) return; torch = !torch; try { await t.applyConstraints({ advanced: [{ torch }] }); $('sc-torch').setAttribute('aria-pressed', String(torch)); } catch { toast('The torch is not available'); } };-- $('sc-file').onchange = (e) => picture(e.target.files[0]);-- $('sc-file2').onchange = (e) => picture(e.target.files[0]);++ $('sc-again').onclick = () => scanPicker.input.click();$('sc-clear').onclick = () => { history = []; store.set('ov_codes_scans', []); renderHistory(); };-- const view = $('sc-view');-- ['dragenter', 'dragover'].forEach((ev) => view.addEventListener(ev, (e) => { e.preventDefault(); drop.classList.add('over'); }));-- ['dragleave', 'drop'].forEach((ev) => view.addEventListener(ev, () => drop.classList.remove('over')));-- view.addEventListener('drop', (e) => { e.preventDefault(); const f = e.dataTransfer && e.dataTransfer.files[0]; if (f) picture(f); });-- document.addEventListener('paste', (e) => { const item = [...(e.clipboardData ? e.clipboardData.items : [])].find((i) => i.type.startsWith('image/')); if (item) picture(item.getAsFile()); });++ document.addEventListener('paste', (e) => { const item = [...(e.clipboardData ? e.clipboardData.items : [])].find((i) => i.type.startsWith('image/')); if (item) scanPicker.add([item.getAsFile()]); });window.addEventListener('pagehide', stop);document.addEventListener('visibilitychange', () => { if (document.hidden && stream) stop(); });renderHistory();@@@ -40,6 +40,8 @@ const bwipjs = require('bwip-js');// ── Vendor files: the page asks for exactly what the server serves ──for (const url of [vendor.urls.bwip, vendor.urls.zxing]) assert.ok(html.includes(`src="${url}"`) || html.includes(`data-zxing="${url}"`), `codes.html loads ${url}`);assert.ok(html.includes(`data-zxing-wasm="${vendor.urls.zxingWasm}"`), 'the reader finds its WebAssembly on this host');++ assert.ok(html.includes('/shared/ov-upload.css') && html.includes('/shared/ov-upload.js'), 'local picture tools use the shared picker');++ assert.ok(html.includes('id="qr-logo-picker"') && html.includes('id="sc-picture-picker"'), 'both picture inputs have a kit mount');for (const f of Object.values(vendor.map)) assert.ok(fs.statSync(f.file).size > 10000, `${f.file} exists`);// ── The image API ──@@@ -42,6 +42,8 @@<script src="/shared/kit.js" defer></script><script src="/shared/kit-web.js" defer></script><script src="/shared/kit-data-web.js" defer></script>++ <link rel="stylesheet" href="/shared/ov-upload.css">++ <script src="/shared/ov-upload.js" defer></script><script src="/js/ov-jobs.js"></script><script src="/js/tool-frame.js" defer></script><script src="/js/app.js"></script>@@@ -23,6 +23,7 @@const processBtn = $('#process-btn');const clearBtn = $('#clear-btn');const kit = () => window.OpenVibeKit;++ let uploadUI = null;// ── Init ─────────────────────────────────────────────────async function init() {@@@ -129,9 +130,7 @@// ── The box: the drop zone ───────────────────────────────function initDrop() {if (!fileInput) return;-- fileInput.addEventListener('change', () => {-- if (fileInput.files && fileInput.files.length) handleFile(fileInput.files[0]);-- });++ uploadUI = window.OVToolFrame && OVToolFrame.upload((files) => files.length ? handleFile(files[0]) : resetUpload(false));if (clearBtn) clearBtn.addEventListener('click', resetUpload);// "Try": the sample picture goes into the drop zone as if it had been chosen.@@@ -162,9 +161,10 @@hideAnswer();}-- function resetUpload() {++ function resetUpload(clearPicker = true) {selectedFile = null;if (fileInput) fileInput.value = '';++ if (clearPicker && uploadUI) uploadUI.clear();if (optionsPanel) optionsPanel.hidden = true;if (processBtn) processBtn.disabled = true;hideAnswer();@@@ -248,10 +248,13 @@showWorking();try {-- const job = await OVJobs.submit({ type: 'img.process', input, files: [selectedFile] });++ if (uploadUI) uploadUI.start();++ const job = await OVJobs.submit({ type: 'img.process', input, files: [selectedFile], onProgress: uploadUI ? (p) => uploadUI.progress(p.loaded, p.total) : null });++ if (uploadUI) uploadUI.done();OVJobs.remember(job.id);followJob(job);} catch (err) {++ if (uploadUI) uploadUI.fail(err.message);failProcessing(err.message);}}@@@ -243,11 +243,7 @@ function verbFor(rec) {*/function boxFor(rec) {const accept = 'image/png,image/jpeg,image/webp,image/avif,image/tiff,image/bmp,image/gif,image/heic,image/heif,image/svg+xml,image/x-icon,image/vnd.microsoft.icon';-- const drop = kit.drop({-- name: 'file', accept, id: 'file-input',-- label: 'Drop an image here',-- hint: 'PNG, JPG, WebP, AVIF, HEIC, SVG, GIF, ICO, TIFF, BMP',-- });++ const drop = frame.upload({ accept, label: 'Choose an image', hint: 'PNG, JPG, WebP, AVIF, HEIC, SVG, GIF, ICO, TIFF or BMP', maxBytes: config.upload.maxFileSize });return `<form class="ovk-tool-form" id="ovk-img-form" enctype="multipart/form-data" data-ovk-tool-form>`+ drop+ `<p class="ovk-try"><span>Try</span><a href="/sample.png" data-ovk-sample="/sample.png">A sample image</a></p>`@@@ -46,7 +46,7 @@ function factsOf(html) {'class="ovk-family"', 'class="ovk-tool-folds"', 'People also use',]);assert.ok(html.includes('https://img.openvibe.tools/">Image tools'), `${host}: the crumb is the family`);-- assert.ok(html.includes('class="ovk-drop"'), `${host}: a drop zone`);++ assert.ok(html.includes('data-ov-tool-upload') && html.includes('data-max-bytes='), `${host}: shared upload picker with limit`);assert.ok(html.includes('data-ovk-sample'), `${host}: a sample to try`);assert.ok(html.includes('aria-current="page"'), `${host}: the family marks this tool`);assert.ok(html.includes('Use it from code') && html.includes('https://openvibe.tools/api/v1/tools/'), `${host}: the API fold`);@@@ -132,4 +132,3 @@ function factsOf(html) {}console.log(`tool frame (img): ${HOSTS.length} image pages framed, the terms from the config, the verb buttons: all checks passed`);})().catch((err) => { console.error(err); process.exit(1); });--
@@ -37,6 +37,14 @@ module.exports = function createFrame({ kit, serve }) {
@@ -37,6 +37,14 @@ module.exports = function createFrame({ kit, serve }) {
return `${Math.round(b / 1024)} KB`;
return `${Math.round(b / 1024)} KB`;
}
}
+ /** The file tool's native input is kept for samples; OVUpload draws the visible picker. */
+ function upload({ accept, label, hint, multiple = false, maxBytes, maxFiles = 1 }) {
+ return `<input type="file" id="file-input" accept="${esc(accept)}"${multiple ? ' multiple' : ''} hidden>`
+ + `<div data-ov-tool-upload data-label="${esc(label)}" data-max-bytes="${Number(maxBytes)}" data-max-files="${Number(maxFiles)}"></div>`
+ + `<p>${esc(hint || 'Choose a supported file')} · up to ${maxFiles} file${maxFiles === 1 ? '' : 's'}, ${size(maxBytes)} each.</p>`
+ + `<p role="alert" data-ov-tool-upload-error hidden></p><div data-ov-tool-upload-progress></div>`;
+ }
+
/**
/**
* The terms of a tool, from its real configuration.
* The terms of a tool, from its real configuration.
* @param {object} s
* @param {object} s
@@ -225,5 +233,5 @@ module.exports = function createFrame({ kit, serve }) {
@@ -225,5 +233,5 @@ module.exports = function createFrame({ kit, serve }) {
};
};
}
}
- return { terms, field, labelOf, head, verbButton, folds, page, assets, size, SITE };
+ return { terms, field, labelOf, head, verbButton, folds, page, assets, upload, size, SITE };
};
};
@@ -34,6 +34,21 @@
@@ -34,6 +34,21 @@
var files = o.files || [];
var files = o.files || [];
var field = o.fileField || (files.length > 1 ? 'files' : 'file');
var field = o.fileField || (files.length > 1 ? 'files' : 'file');
for (var i = 0; i < files.length; i++) fd.append(field, files[i]);
for (var i = 0; i < files.length; i++) fd.append(field, files[i]);
+ if (typeof o.onProgress === 'function') return new Promise(function (resolve, reject) {
+ var xhr = new XMLHttpRequest();
+ xhr.open('POST', '/api/v1/jobs');
+ xhr.setRequestHeader('Idempotency-Key', o.idempotencyKey || newKey());
+ xhr.setRequestHeader('Accept', 'application/json');
+ xhr.upload.onprogress = function (event) { if (event.lengthComputable) o.onProgress({ loaded: event.loaded, total: event.total }); };
+ xhr.onload = function () {
+ var body;
+ try { body = JSON.parse(xhr.responseText); } catch (_) { body = null; }
+ if (xhr.status >= 200 && xhr.status < 300) resolve(body);
+ else reject(problemError({ status: xhr.status }, body));
+ };
+ xhr.onerror = function () { reject(new Error('The connection stopped. Try again.')); };
+ xhr.send(fd);
+ });
return fetch('/api/v1/jobs', {
return fetch('/api/v1/jobs', {
method: 'POST', body: fd, credentials: 'same-origin',
method: 'POST', body: fd, credentials: 'same-origin',
headers: { 'Idempotency-Key': o.idempotencyKey || newKey(), Accept: 'application/json' },
headers: { 'Idempotency-Key': o.idempotencyKey || newKey(), Accept: 'application/json' },
@@ -12,6 +12,7 @@
@@ -12,6 +12,7 @@
var kit = window.OpenVibeKit;
var kit = window.OpenVibeKit;
if (!kit) return;
if (!kit) return;
+ var currentUpload = null;
/** A file size in the words a person reads: "1.4 MB", "812 KB". */
/** A file size in the words a person reads: "1.4 MB", "812 KB". */
function size(bytes) {
function size(bytes) {
@@ -68,17 +69,67 @@
@@ -68,17 +69,67 @@
}).then(function (blob) {
}).then(function (blob) {
var name = String(url).split('/').pop() || 'sample';
var name = String(url).split('/').pop() || 'sample';
var file = new File([blob], name, { type: blob.type || 'application/octet-stream' });
var file = new File([blob], name, { type: blob.type || 'application/octet-stream' });
- var input = document.querySelector('.ovk-drop-input');
- if (input && window.DataTransfer) {
- var dt = new DataTransfer();
- dt.items.add(file);
- input.files = dt.files;
- input.dispatchEvent(new Event('change', { bubbles: true }));
- }
- if (onFile) onFile(file);
+ if (currentUpload) currentUpload.add([file]);
+ else if (onFile) onFile(file);
}).catch(function () { /* the sample is a convenience, not a promise */ });
}).catch(function () { /* the sample is a convenience, not a promise */ });
}
}
+ /** Shared picker for image, document and audio tools; their job transport owns the upload. */
+ function upload(onFiles) {
+ var root = document.querySelector('[data-ov-tool-upload]');
+ var input = document.getElementById('file-input');
+ var error = document.querySelector('[data-ov-tool-upload-error]');
+ var progressRoot = document.querySelector('[data-ov-tool-upload-progress]');
+ if (!root || !input || !error || !progressRoot || !window.OVUpload) return null;
+ var files = [];
+ var clearing = false;
+ var maxBytes = Number(root.dataset.maxBytes);
+ var maxFiles = Number(root.dataset.maxFiles);
+ var meter = OVUpload.progress(progressRoot, { onRetry: function () { document.getElementById('process-btn')?.click(); } });
+ function warn(message) { error.textContent = message; error.hidden = false; }
+ var picker = OVUpload.picker(root, {
+ label: root.dataset.label, accept: input.accept, multiple: input.multiple,
+ onFile: function (file, row) {
+ var accepted = input.accept.split(',').some(function (part) {
+ part = part.trim().toLowerCase();
+ return part === file.type.toLowerCase() || (part.endsWith('/*') && file.type.toLowerCase().startsWith(part.slice(0, -1))) || (part.startsWith('.') && file.name.toLowerCase().endsWith(part));
+ });
+ if (!accepted || (input.multiple && files.length >= maxFiles) || file.size > maxBytes) {
+ row.dispatchEvent(new Event('remove')); row.remove();
+ warn(!accepted ? 'Choose a supported file format for this tool.' : files.length >= maxFiles ? 'Choose at most ' + maxFiles + ' files.' : file.name + ' is over ' + size(maxBytes) + '.');
+ return;
+ }
+ if (!input.multiple) {
+ files = [];
+ picker.rows.querySelectorAll('.ov-upload__row').forEach(function (old) {
+ if (old !== row) { old.dispatchEvent(new Event('remove')); old.remove(); }
+ });
+ }
+ error.hidden = true;
+ files.push(file);
+ onFiles(files.slice());
+ meter.update({ state: 'waiting', done: 0, total: files.reduce(function (sum, f) { return sum + f.size; }, 0) });
+ },
+ onRemove: function (file) {
+ files = files.filter(function (item) { return item !== file; });
+ if (!clearing) onFiles(files.slice());
+ }
+ });
+ function clear() {
+ clearing = true;
+ files = [];
+ picker.rows.querySelectorAll('.ov-upload__row').forEach(function (row) { row.dispatchEvent(new Event('remove')); });
+ picker.rows.replaceChildren();
+ clearing = false;
+ meter.update({ state: 'waiting', done: 0, total: 0 });
+ }
+ currentUpload = { add: picker.add, clear: clear, start: function () { meter.update({ state: 'sending', done: 0, total: files.reduce(function (sum, f) { return sum + f.size; }, 0) }); },
+ progress: function (done, total) { meter.update({ state: 'sending', done: done, total: total }); },
+ done: function () { var total = files.reduce(function (sum, f) { return sum + f.size; }, 0); meter.update({ state: 'done', done: total, total: total }); },
+ fail: function (message) { meter.update({ state: 'failed', message: message }); } };
+ return currentUpload;
+ }
+
/**
/**
* A text tool's live output belongs in the frame's answer slot. Only an output that sits directly
* A text tool's live output belongs in the frame's answer slot. Only an output that sits directly
* in the box moves: one inside a two-pane editor (the Markdown preview) keeps its place.
* in the box moves: one inside a two-pane editor (the Markdown preview) keeps its place.
@@ -122,5 +173,5 @@
@@ -122,5 +173,5 @@
if (document.readyState === 'loading') document.addEventListener('DOMContentLoaded', boot);
if (document.readyState === 'loading') document.addEventListener('DOMContentLoaded', boot);
else boot();
else boot();
- window.OVToolFrame = { size: size, fileAnswer: fileAnswer, show: show, sample: sample, moveAnswer: moveAnswer };
+ window.OVToolFrame = { size: size, fileAnswer: fileAnswer, show: show, sample: sample, upload: upload, moveAnswer: moveAnswer };
})();
})();
@@ -42,6 +42,8 @@
@@ -42,6 +42,8 @@
<script src="/shared/kit.js" defer></script>
<script src="/shared/kit.js" defer></script>
<script src="/shared/kit-web.js" defer></script>
<script src="/shared/kit-web.js" defer></script>
<script src="/shared/kit-data-web.js" defer></script>
<script src="/shared/kit-data-web.js" defer></script>
+ <link rel="stylesheet" href="/shared/ov-upload.css">
+ <script src="/shared/ov-upload.js" defer></script>
<script src="/js/ov-jobs.js"></script>
<script src="/js/ov-jobs.js"></script>
<script src="/js/tool-frame.js" defer></script>
<script src="/js/tool-frame.js" defer></script>
<script src="/js/app.js"></script>
<script src="/js/app.js"></script>
@@ -22,6 +22,7 @@
@@ -22,6 +22,7 @@
const processBtn = $('#process-btn');
const processBtn = $('#process-btn');
const clearBtn = $('#clear-btn');
const clearBtn = $('#clear-btn');
const kit = () => window.OpenVibeKit;
const kit = () => window.OpenVibeKit;
+ let uploadUI = null;
// ── Init ─────────────────────────────────────────────────
// ── Init ─────────────────────────────────────────────────
async function init() {
async function init() {
@@ -105,10 +106,7 @@
@@ -105,10 +106,7 @@
// ── The box: the drop zone ───────────────────────────────
// ── The box: the drop zone ───────────────────────────────
function initDrop() {
function initDrop() {
if (!fileInput) return;
if (!fileInput) return;
- fileInput.addEventListener('change', () => {
- const files = Array.prototype.slice.call(fileInput.files || []);
- if (files.length) handleFiles(files);
- });
+ uploadUI = window.OVToolFrame && OVToolFrame.upload((files) => files.length ? handleFiles(files) : resetUpload(false));
if (clearBtn) clearBtn.addEventListener('click', resetUpload);
if (clearBtn) clearBtn.addEventListener('click', resetUpload);
const sampleLink = document.querySelector('[data-ovk-sample]');
const sampleLink = document.querySelector('[data-ovk-sample]');
@@ -137,9 +135,10 @@
@@ -137,9 +135,10 @@
hideAnswer();
hideAnswer();
}
}
- function resetUpload() {
+ function resetUpload(clearPicker = true) {
selectedFiles = [];
selectedFiles = [];
if (fileInput) fileInput.value = '';
if (fileInput) fileInput.value = '';
+ if (clearPicker && uploadUI) uploadUI.clear();
if (optionsPanel) optionsPanel.hidden = true;
if (optionsPanel) optionsPanel.hidden = true;
if (processBtn) processBtn.disabled = true;
if (processBtn) processBtn.disabled = true;
hideAnswer();
hideAnswer();
@@ -181,12 +180,15 @@
@@ -181,12 +180,15 @@
try {
try {
const many = selectedFiles.length > 1;
const many = selectedFiles.length > 1;
+ if (uploadUI) uploadUI.start();
const job = await OVJobs.submit({
const job = await OVJobs.submit({
- type: 'audio.process', input, files: selectedFiles, fileField: many ? 'files' : 'file',
+ type: 'audio.process', input, files: selectedFiles, fileField: many ? 'files' : 'file', onProgress: uploadUI ? (p) => uploadUI.progress(p.loaded, p.total) : null,
});
});
+ if (uploadUI) uploadUI.done();
OVJobs.remember(job.id);
OVJobs.remember(job.id);
followJob(job);
followJob(job);
} catch (err) {
} catch (err) {
+ if (uploadUI) uploadUI.fail(err.message);
failProcessing(err.message);
failProcessing(err.message);
}
}
}
}
@@ -141,11 +141,7 @@ function boxFor(rec, host) {
@@ -141,11 +141,7 @@ function boxFor(rec, host) {
const spec = SPEC.get(rec.id === 'hub' ? 'convert' : rec.id);
const spec = SPEC.get(rec.id === 'hub' ? 'convert' : rec.id);
const many = (spec && spec.files && spec.files.max) > 1;
const many = (spec && spec.files && spec.files.max) > 1;
const accept = (spec && spec.files && spec.files.accept) || AUDIO_ACCEPT;
const accept = (spec && spec.files && spec.files.accept) || AUDIO_ACCEPT;
- const drop = kit.drop({
- name: 'file', accept: accept.join(','), id: 'file-input', multiple: many,
- label: many ? 'Drop your recordings here' : 'Drop a recording here',
- hint: many ? 'Up to five files at once' : 'MP3, WAV, FLAC, OGG, M4A, AAC, Opus, WMA, AIFF, MP4, WebM',
- });
+ const drop = frame.upload({ accept: accept.join(','), label: many ? 'Choose recordings' : 'Choose a recording', hint: 'MP3, WAV, FLAC, OGG, M4A, AAC, Opus, WMA, AIFF, MP4 or WebM', multiple: many, maxBytes: config.upload.maxFileSize, maxFiles: many ? 5 : 1 });
return `<form class="ovk-tool-form" id="ovk-audio-form" enctype="multipart/form-data" data-ovk-tool-form>`
return `<form class="ovk-tool-form" id="ovk-audio-form" enctype="multipart/form-data" data-ovk-tool-form>`
+ drop
+ drop
+ `<p class="ovk-try"><span>Try</span><a href="/sample.mp3" data-ovk-sample="/sample.mp3">A sample recording</a></p>`
+ `<p class="ovk-try"><span>Try</span><a href="/sample.mp3" data-ovk-sample="/sample.mp3">A sample recording</a></p>`
@@ -48,7 +48,7 @@ function factsOf(html) {
@@ -48,7 +48,7 @@ function factsOf(html) {
'class="ovk-family"', 'class="ovk-tool-folds"', 'People also use',
'class="ovk-family"', 'class="ovk-tool-folds"', 'People also use',
]);
]);
assert.ok(html.includes('https://audio.openvibe.tools/">Audio tools'), `${host}: the crumb is the family`);
assert.ok(html.includes('https://audio.openvibe.tools/">Audio tools'), `${host}: the crumb is the family`);
- assert.ok(html.includes('class="ovk-drop"'), `${host}: a drop zone`);
+ assert.ok(html.includes('data-ov-tool-upload') && html.includes('data-max-bytes='), `${host}: shared upload picker with limit`);
assert.ok(html.includes('aria-current="page"'), `${host}: the family marks this tool`);
assert.ok(html.includes('aria-current="page"'), `${host}: the family marks this tool`);
assert.ok(html.includes('Use it from code') && html.includes('https://openvibe.tools/api/v1/tools/'), `${host}: the API fold`);
assert.ok(html.includes('Use it from code') && html.includes('https://openvibe.tools/api/v1/tools/'), `${host}: the API fold`);
assert.strictEqual((html.match(/<h1/g) || []).length, 1, `${host}: one h1`);
assert.strictEqual((html.match(/<h1/g) || []).length, 1, `${host}: one h1`);
@@ -42,6 +42,8 @@
@@ -42,6 +42,8 @@
<script src="/shared/kit.js" defer></script>
<script src="/shared/kit.js" defer></script>
<script src="/shared/kit-web.js" defer></script>
<script src="/shared/kit-web.js" defer></script>
<script src="/shared/kit-data-web.js" defer></script>
<script src="/shared/kit-data-web.js" defer></script>
+ <link rel="stylesheet" href="/shared/ov-upload.css">
+ <script src="/shared/ov-upload.js" defer></script>
<script src="/js/ov-jobs.js"></script>
<script src="/js/ov-jobs.js"></script>
<script src="/js/tool-frame.js" defer></script>
<script src="/js/tool-frame.js" defer></script>
<script src="/js/app.js"></script>
<script src="/js/app.js"></script>
@@ -22,6 +22,7 @@
@@ -22,6 +22,7 @@
const processBtn = $('#process-btn');
const processBtn = $('#process-btn');
const clearBtn = $('#clear-btn');
const clearBtn = $('#clear-btn');
const kit = () => window.OpenVibeKit;
const kit = () => window.OpenVibeKit;
+ let uploadUI = null;
// ── Init ─────────────────────────────────────────────────
// ── Init ─────────────────────────────────────────────────
async function init() {
async function init() {
@@ -133,10 +134,7 @@
@@ -133,10 +134,7 @@
// ── The box: the drop zone ───────────────────────────────
// ── The box: the drop zone ───────────────────────────────
function initDrop() {
function initDrop() {
if (!fileInput) return;
if (!fileInput) return;
- fileInput.addEventListener('change', () => {
- const files = Array.prototype.slice.call(fileInput.files || []);
- if (files.length) handleFiles(files);
- });
+ uploadUI = window.OVToolFrame && OVToolFrame.upload((files) => files.length ? handleFiles(files) : resetUpload(false));
if (clearBtn) clearBtn.addEventListener('click', resetUpload);
if (clearBtn) clearBtn.addEventListener('click', resetUpload);
const sampleLink = document.querySelector('[data-ovk-sample]');
const sampleLink = document.querySelector('[data-ovk-sample]');
@@ -166,9 +164,10 @@
@@ -166,9 +164,10 @@
hideAnswer();
hideAnswer();
}
}
- function resetUpload() {
+ function resetUpload(clearPicker = true) {
selectedFiles = [];
selectedFiles = [];
if (fileInput) fileInput.value = '';
if (fileInput) fileInput.value = '';
+ if (clearPicker && uploadUI) uploadUI.clear();
if (optionsPanel) optionsPanel.hidden = true;
if (optionsPanel) optionsPanel.hidden = true;
if (processBtn) processBtn.disabled = true;
if (processBtn) processBtn.disabled = true;
hideAnswer();
hideAnswer();
@@ -211,12 +210,15 @@
@@ -211,12 +210,15 @@
try {
try {
const many = selectedFiles.length > 1;
const many = selectedFiles.length > 1;
+ if (uploadUI) uploadUI.start();
const job = await OVJobs.submit({
const job = await OVJobs.submit({
- type: 'docs.process', input, files: selectedFiles, fileField: many ? 'files' : 'file',
+ type: 'docs.process', input, files: selectedFiles, fileField: many ? 'files' : 'file', onProgress: uploadUI ? (p) => uploadUI.progress(p.loaded, p.total) : null,
});
});
+ if (uploadUI) uploadUI.done();
OVJobs.remember(job.id);
OVJobs.remember(job.id);
followJob(job);
followJob(job);
} catch (err) {
} catch (err) {
+ if (uploadUI) uploadUI.fail(err.message);
failProcessing(err.message);
failProcessing(err.message);
}
}
}
}
@@ -121,11 +121,7 @@ function boxFor(rec) {
@@ -121,11 +121,7 @@ function boxFor(rec) {
const spec = SPEC.get(rec.spec);
const spec = SPEC.get(rec.spec);
const many = (spec && spec.files && spec.files.max) > 1;
const many = (spec && spec.files && spec.files.max) > 1;
const accept = (spec && spec.files && spec.files.accept) || ['application/pdf'];
const accept = (spec && spec.files && spec.files.accept) || ['application/pdf'];
- const drop = kit.drop({
- name: 'file', accept: accept.join(','), id: 'file-input', multiple: many,
- label: many ? 'Drop your documents here' : 'Drop a document here',
- hint: many ? 'Up to 50 files at once' : 'A PDF, a picture, or both',
- });
+ const drop = frame.upload({ accept: accept.join(','), label: many ? 'Choose documents' : 'Choose a document', hint: 'Accepted formats for this tool: ' + accept.join(', '), multiple: many, maxBytes: config.upload.maxFileSize, maxFiles: many ? 50 : 1 });
return `<form class="ovk-tool-form" id="ovk-docs-form" enctype="multipart/form-data" data-ovk-tool-form>`
return `<form class="ovk-tool-form" id="ovk-docs-form" enctype="multipart/form-data" data-ovk-tool-form>`
+ `<input type="hidden" name="tool" value="${esc(opOf(rec))}">`
+ `<input type="hidden" name="tool" value="${esc(opOf(rec))}">`
+ drop
+ drop
@@ -47,7 +47,7 @@ function factsOf(html) {
@@ -47,7 +47,7 @@ function factsOf(html) {
'class="ovk-family"', 'class="ovk-tool-folds"', 'People also use',
'class="ovk-family"', 'class="ovk-tool-folds"', 'People also use',
]);
]);
assert.ok(html.includes('https://docs.openvibe.tools/">PDF tools'), `${host}: the crumb is the family`);
assert.ok(html.includes('https://docs.openvibe.tools/">PDF tools'), `${host}: the crumb is the family`);
- assert.ok(html.includes('class="ovk-drop"'), `${host}: a drop zone`);
+ assert.ok(html.includes('data-ov-tool-upload') && html.includes('data-max-bytes='), `${host}: shared upload picker with limit`);
assert.ok(html.includes('aria-current="page"'), `${host}: the family marks this tool`);
assert.ok(html.includes('aria-current="page"'), `${host}: the family marks this tool`);
assert.ok(html.includes('Use it from code') && html.includes('https://openvibe.tools/api/v1/tools/'), `${host}: the API fold`);
assert.ok(html.includes('Use it from code') && html.includes('https://openvibe.tools/api/v1/tools/'), `${host}: the API fold`);
assert.strictEqual((html.match(/<h1/g) || []).length, 1, `${host}: one h1`);
assert.strictEqual((html.match(/<h1/g) || []).length, 1, `${host}: one h1`);
@@ -9,6 +9,7 @@
@@ -9,6 +9,7 @@
<meta name="color-scheme" content="dark light">
<meta name="color-scheme" content="dark light">
<link rel="apple-touch-icon" href="/assets/logo-192.png">
<link rel="apple-touch-icon" href="/assets/logo-192.png">
<link rel="manifest" href="/manifest.webmanifest">
<link rel="manifest" href="/manifest.webmanifest">
+<link rel="stylesheet" href="/shared/ov-upload.css">
<meta name="description" content="Make QR codes, barcodes and Data Matrix, scan them with your camera or a picture, and check GTIN, ISBN and GS1 numbers. Free, in your browser.">
<meta name="description" content="Make QR codes, barcodes and Data Matrix, scan them with your camera or a picture, and check GTIN, ISBN and GS1 numbers. Free, in your browser.">
<meta property="og:title" content="Barcode & QR Tools">
<meta property="og:title" content="Barcode & QR Tools">
<meta property="og:description" content="Make, scan and check QR codes, barcodes and Data Matrix, in your browser.">
<meta property="og:description" content="Make, scan and check QR codes, barcodes and Data Matrix, in your browser.">
@@ -303,10 +304,11 @@ table.ais td.v{font-family:var(--mono);word-break:break-all}
@@ -303,10 +304,11 @@ table.ais td.v{font-family:var(--mono);word-break:break-all}
<details class="panel" id="qr-logo-panel">
<details class="panel" id="qr-logo-panel">
<summary>Logo</summary>
<summary>Logo</summary>
<div class="row" style="align-items:center">
<div class="row" style="align-items:center">
- <label class="btn"><svg class="ic"><use href="#i-upload"/></svg>Choose a picture<input type="file" id="qr-logo" accept="image/*" class="sr"></label>
+ <div id="qr-logo-picker"></div>
<button type="button" class="btn ghost" id="qr-logo-clear" hidden><svg class="ic"><use href="#i-trash"/></svg>Remove</button>
<button type="button" class="btn ghost" id="qr-logo-clear" hidden><svg class="ic"><use href="#i-trash"/></svg>Remove</button>
<span class="muted" id="qr-logo-name"></span>
<span class="muted" id="qr-logo-name"></span>
</div>
</div>
+ <p class="muted">An image up to 20 MB. The logo is resized in your browser; it is never uploaded.</p>
<div class="field" style="margin-top:10px"><span class="lbl">Logo size</span><div class="range"><input type="range" id="qr-logo-size" min="12" max="30" value="22" aria-label="Logo size"><output id="qr-logo-size-o">22%</output></div><small>Error correction rises to H (30%) with a logo, and the code is checked after every change.</small></div>
<div class="field" style="margin-top:10px"><span class="lbl">Logo size</span><div class="range"><input type="range" id="qr-logo-size" min="12" max="30" value="22" aria-label="Logo size"><output id="qr-logo-size-o">22%</output></div><small>Error correction rises to H (30%) with a logo, and the code is checked after every change.</small></div>
</details>
</details>
<details class="panel">
<details class="panel">
@@ -407,7 +409,7 @@ table.ais td.v{font-family:var(--mono);word-break:break-all}
@@ -407,7 +409,7 @@ table.ais td.v{font-family:var(--mono);word-break:break-all}
<p>Point your camera at a code, or drop, paste (<span class="kbd">Ctrl</span>+<span class="kbd">V</span>) or choose a picture. Every code in it is read.</p>
<p>Point your camera at a code, or drop, paste (<span class="kbd">Ctrl</span>+<span class="kbd">V</span>) or choose a picture. Every code in it is read.</p>
<div class="row" style="justify-content:center">
<div class="row" style="justify-content:center">
<button type="button" class="btn primary" id="sc-start"><svg class="ic"><use href="#i-camera"/></svg>Start camera</button>
<button type="button" class="btn primary" id="sc-start"><svg class="ic"><use href="#i-camera"/></svg>Start camera</button>
- <label class="btn"><svg class="ic"><use href="#i-image"/></svg>Choose a picture<input type="file" id="sc-file" accept="image/*" class="sr"></label>
+ <div id="sc-picture-picker"></div>
</div>
</div>
</div>
</div>
</div>
</div>
@@ -415,10 +417,11 @@ table.ais td.v{font-family:var(--mono);word-break:break-all}
@@ -415,10 +417,11 @@ table.ais td.v{font-family:var(--mono);word-break:break-all}
<button type="button" class="btn" id="sc-stop" hidden><svg class="ic"><use href="#i-stop"/></svg>Stop</button>
<button type="button" class="btn" id="sc-stop" hidden><svg class="ic"><use href="#i-stop"/></svg>Stop</button>
<button type="button" class="btn" id="sc-flip" hidden><svg class="ic"><use href="#i-flip"/></svg>Switch camera</button>
<button type="button" class="btn" id="sc-flip" hidden><svg class="ic"><use href="#i-flip"/></svg>Switch camera</button>
<button type="button" class="btn" id="sc-torch" hidden aria-pressed="false"><svg class="ic"><use href="#i-bolt"/></svg>Torch</button>
<button type="button" class="btn" id="sc-torch" hidden aria-pressed="false"><svg class="ic"><use href="#i-bolt"/></svg>Torch</button>
- <label class="btn ghost" id="sc-again" hidden><svg class="ic"><use href="#i-image"/></svg>Another picture<input type="file" id="sc-file2" accept="image/*" class="sr"></label>
+ <button type="button" class="btn ghost" id="sc-again" hidden><svg class="ic"><use href="#i-image"/></svg>Another picture</button>
<label class="check" style="margin-left:auto"><input type="checkbox" id="sc-beep" checked> Sound</label>
<label class="check" style="margin-left:auto"><input type="checkbox" id="sc-beep" checked> Sound</label>
</div>
</div>
<p class="muted" id="sc-engine" style="font-size:.8rem;margin:8px 0 0"></p>
<p class="muted" id="sc-engine" style="font-size:.8rem;margin:8px 0 0"></p>
+ <p class="muted">Choose or drop an image up to 20 MB. Scanning happens in your browser; the picture is never uploaded.</p>
</div>
</div>
<div>
<div>
<div class="row" style="justify-content:space-between;align-items:center;margin-bottom:10px"><b>Found</b><button type="button" class="btn small ghost" id="sc-clear"><svg class="ic"><use href="#i-trash"/></svg>Clear history</button></div>
<div class="row" style="justify-content:space-between;align-items:center;margin-bottom:10px"><b>Found</b><button type="button" class="btn small ghost" id="sc-clear"><svg class="ic"><use href="#i-trash"/></svg>Clear history</button></div>
@@ -521,6 +524,7 @@ table.ais td.v{font-family:var(--mono);word-break:break-all}
@@ -521,6 +524,7 @@ table.ais td.v{font-family:var(--mono);word-break:break-all}
<script src="/js/codes/draw.js"></script>
<script src="/js/codes/draw.js"></script>
<script src="/js/codes/read.js" id="codes-read" data-zxing="/vendor/[email protected]/reader.js" data-zxing-wasm="/vendor/[email protected]/zxing_reader.wasm"></script>
<script src="/js/codes/read.js" id="codes-read" data-zxing="/vendor/[email protected]/reader.js" data-zxing-wasm="/vendor/[email protected]/zxing_reader.wasm"></script>
<script src="/vendor/[email protected]/bwip-js.min.js" defer></script>
<script src="/vendor/[email protected]/bwip-js.min.js" defer></script>
+<script src="/shared/ov-upload.js" defer></script>
<script src="/js/codes/app.js" defer></script>
<script src="/js/codes/app.js" defer></script>
</body>
</body>
</html>
</html>
@@ -242,8 +242,14 @@
@@ -242,8 +242,14 @@
update();
update();
};
};
// Logo: read, shrunk to 512 px, kept as a data URL inside the SVG.
// Logo: read, shrunk to 512 px, kept as a data URL inside the SVG.
- $('qr-logo').onchange = (e) => {
- const file = e.target.files && e.target.files[0]; if (!file) return;
+ const logoRoot = $('qr-logo-picker');
+ const logoPicker = OVUpload.picker(logoRoot, { label: 'Choose a logo image', accept: 'image/*', multiple: false,
+ onFile: (file, row) => {
+ logoPicker.rows.querySelectorAll('.ov-upload__row').forEach((old) => { if (old !== row) { old.dispatchEvent(new Event('remove')); old.remove(); } });
+ if (!file.type.startsWith('image/') || file.size > 20 * 1048576) {
+ row.dispatchEvent(new Event('remove')); row.remove();
+ toast('Choose an image under 20 MB.'); return;
+ }
const img = new Image();
const img = new Image();
img.onload = () => {
img.onload = () => {
const k = Math.min(1, 512 / Math.max(img.width, img.height));
const k = Math.min(1, 512 / Math.max(img.width, img.height));
@@ -256,8 +262,10 @@
@@ -256,8 +262,10 @@
};
};
img.onerror = () => toast('That file is not a picture this browser can read');
img.onerror = () => toast('That file is not a picture this browser can read');
img.src = URL.createObjectURL(file);
img.src = URL.createObjectURL(file);
- };
- $('qr-logo-clear').onclick = () => { st.logo = null; $('qr-logo').value = ''; $('qr-logo-name').textContent = ''; $('qr-logo-clear').hidden = true; update(); };
+ },
+ onRemove: () => { st.logo = null; $('qr-logo-name').textContent = ''; $('qr-logo-clear').hidden = true; update(); },
+ });
+ $('qr-logo-clear').onclick = () => { logoPicker.rows.querySelectorAll('.ov-upload__row').forEach((row) => row.dispatchEvent(new Event('remove'))); logoPicker.rows.replaceChildren(); st.logo = null; $('qr-logo-name').textContent = ''; $('qr-logo-clear').hidden = true; update(); };
bindRange('qr-logo-size', 'qr-logo-size-o', (v) => `${v}%`, (v) => { st.logoSize = v; update(); });
bindRange('qr-logo-size', 'qr-logo-size-o', (v) => `${v}%`, (v) => { st.logoSize = v; update(); });
seg($('qr-ecc'), [['auto', 'Auto'], ['L', 'L 7%'], ['M', 'M 15%'], ['Q', 'Q 25%'], ['H', 'H 30%']], st.ecc, (v) => { st.ecc = v; update(); });
seg($('qr-ecc'), [['auto', 'Auto'], ['L', 'L 7%'], ['M', 'M 15%'], ['Q', 'Q 25%'], ['H', 'H 30%']], st.ecc, (v) => { st.ecc = v; update(); });
$('qr-quiet').value = st.quiet; $('qr-px').value = st.px; $('qr-dpi').value = String(st.dpi);
$('qr-quiet').value = st.quiet; $('qr-px').value = st.px; $('qr-dpi').value = String(st.dpi);
@@ -714,18 +722,25 @@
@@ -714,18 +722,25 @@
};
};
shot.src = url;
shot.src = url;
}
}
+ const scanPicker = OVUpload.picker($('sc-picture-picker'), {
+ label: 'Choose a picture to scan', accept: 'image/*', multiple: false,
+ onFile: (file, row) => {
+ scanPicker.rows.querySelectorAll('.ov-upload__row').forEach((old) => { if (old !== row) { old.dispatchEvent(new Event('remove')); old.remove(); } });
+ if (!file.type.startsWith('image/') || file.size > 20 * 1048576) {
+ row.dispatchEvent(new Event('remove')); row.remove();
+ toast('Choose an image under 20 MB.'); return;
+ }
+ picture(file);
+ },
+ onRemove: () => { stop(); shot.hidden = true; drop.hidden = false; },
+ });
$('sc-start').onclick = () => start();
$('sc-start').onclick = () => start();
$('sc-stop').onclick = () => { stop(); drop.hidden = false; shot.hidden = true; };
$('sc-stop').onclick = () => { stop(); drop.hidden = false; shot.hidden = true; };
$('sc-flip').onclick = () => { if (!devices.length) return; deviceIdx = (deviceIdx + 1) % devices.length; start(devices[deviceIdx].deviceId); };
$('sc-flip').onclick = () => { if (!devices.length) return; deviceIdx = (deviceIdx + 1) % devices.length; start(devices[deviceIdx].deviceId); };
$('sc-torch').onclick = async () => { const t = stream && stream.getVideoTracks()[0]; if (!t) return; torch = !torch; try { await t.applyConstraints({ advanced: [{ torch }] }); $('sc-torch').setAttribute('aria-pressed', String(torch)); } catch { toast('The torch is not available'); } };
$('sc-torch').onclick = async () => { const t = stream && stream.getVideoTracks()[0]; if (!t) return; torch = !torch; try { await t.applyConstraints({ advanced: [{ torch }] }); $('sc-torch').setAttribute('aria-pressed', String(torch)); } catch { toast('The torch is not available'); } };
- $('sc-file').onchange = (e) => picture(e.target.files[0]);
- $('sc-file2').onchange = (e) => picture(e.target.files[0]);
+ $('sc-again').onclick = () => scanPicker.input.click();
$('sc-clear').onclick = () => { history = []; store.set('ov_codes_scans', []); renderHistory(); };
$('sc-clear').onclick = () => { history = []; store.set('ov_codes_scans', []); renderHistory(); };
- const view = $('sc-view');
- ['dragenter', 'dragover'].forEach((ev) => view.addEventListener(ev, (e) => { e.preventDefault(); drop.classList.add('over'); }));
- ['dragleave', 'drop'].forEach((ev) => view.addEventListener(ev, () => drop.classList.remove('over')));
- view.addEventListener('drop', (e) => { e.preventDefault(); const f = e.dataTransfer && e.dataTransfer.files[0]; if (f) picture(f); });
- document.addEventListener('paste', (e) => { const item = [...(e.clipboardData ? e.clipboardData.items : [])].find((i) => i.type.startsWith('image/')); if (item) picture(item.getAsFile()); });
+ document.addEventListener('paste', (e) => { const item = [...(e.clipboardData ? e.clipboardData.items : [])].find((i) => i.type.startsWith('image/')); if (item) scanPicker.add([item.getAsFile()]); });
window.addEventListener('pagehide', stop);
window.addEventListener('pagehide', stop);
document.addEventListener('visibilitychange', () => { if (document.hidden && stream) stop(); });
document.addEventListener('visibilitychange', () => { if (document.hidden && stream) stop(); });
renderHistory();
renderHistory();
@@ -40,6 +40,8 @@ const bwipjs = require('bwip-js');
@@ -40,6 +40,8 @@ const bwipjs = require('bwip-js');
// ── Vendor files: the page asks for exactly what the server serves ──
// ── Vendor files: the page asks for exactly what the server serves ──
for (const url of [vendor.urls.bwip, vendor.urls.zxing]) assert.ok(html.includes(`src="${url}"`) || html.includes(`data-zxing="${url}"`), `codes.html loads ${url}`);
for (const url of [vendor.urls.bwip, vendor.urls.zxing]) assert.ok(html.includes(`src="${url}"`) || html.includes(`data-zxing="${url}"`), `codes.html loads ${url}`);
assert.ok(html.includes(`data-zxing-wasm="${vendor.urls.zxingWasm}"`), 'the reader finds its WebAssembly on this host');
assert.ok(html.includes(`data-zxing-wasm="${vendor.urls.zxingWasm}"`), 'the reader finds its WebAssembly on this host');
+ assert.ok(html.includes('/shared/ov-upload.css') && html.includes('/shared/ov-upload.js'), 'local picture tools use the shared picker');
+ assert.ok(html.includes('id="qr-logo-picker"') && html.includes('id="sc-picture-picker"'), 'both picture inputs have a kit mount');
for (const f of Object.values(vendor.map)) assert.ok(fs.statSync(f.file).size > 10000, `${f.file} exists`);
for (const f of Object.values(vendor.map)) assert.ok(fs.statSync(f.file).size > 10000, `${f.file} exists`);
// ── The image API ──
// ── The image API ──
@@ -42,6 +42,8 @@
@@ -42,6 +42,8 @@
<script src="/shared/kit.js" defer></script>
<script src="/shared/kit.js" defer></script>
<script src="/shared/kit-web.js" defer></script>
<script src="/shared/kit-web.js" defer></script>
<script src="/shared/kit-data-web.js" defer></script>
<script src="/shared/kit-data-web.js" defer></script>
+ <link rel="stylesheet" href="/shared/ov-upload.css">
+ <script src="/shared/ov-upload.js" defer></script>
<script src="/js/ov-jobs.js"></script>
<script src="/js/ov-jobs.js"></script>
<script src="/js/tool-frame.js" defer></script>
<script src="/js/tool-frame.js" defer></script>
<script src="/js/app.js"></script>
<script src="/js/app.js"></script>
@@ -23,6 +23,7 @@
@@ -23,6 +23,7 @@
const processBtn = $('#process-btn');
const processBtn = $('#process-btn');
const clearBtn = $('#clear-btn');
const clearBtn = $('#clear-btn');
const kit = () => window.OpenVibeKit;
const kit = () => window.OpenVibeKit;
+ let uploadUI = null;
// ── Init ─────────────────────────────────────────────────
// ── Init ─────────────────────────────────────────────────
async function init() {
async function init() {
@@ -129,9 +130,7 @@
@@ -129,9 +130,7 @@
// ── The box: the drop zone ───────────────────────────────
// ── The box: the drop zone ───────────────────────────────
function initDrop() {
function initDrop() {
if (!fileInput) return;
if (!fileInput) return;
- fileInput.addEventListener('change', () => {
- if (fileInput.files && fileInput.files.length) handleFile(fileInput.files[0]);
- });
+ uploadUI = window.OVToolFrame && OVToolFrame.upload((files) => files.length ? handleFile(files[0]) : resetUpload(false));
if (clearBtn) clearBtn.addEventListener('click', resetUpload);
if (clearBtn) clearBtn.addEventListener('click', resetUpload);
// "Try": the sample picture goes into the drop zone as if it had been chosen.
// "Try": the sample picture goes into the drop zone as if it had been chosen.
@@ -162,9 +161,10 @@
@@ -162,9 +161,10 @@
hideAnswer();
hideAnswer();
}
}
- function resetUpload() {
+ function resetUpload(clearPicker = true) {
selectedFile = null;
selectedFile = null;
if (fileInput) fileInput.value = '';
if (fileInput) fileInput.value = '';
+ if (clearPicker && uploadUI) uploadUI.clear();
if (optionsPanel) optionsPanel.hidden = true;
if (optionsPanel) optionsPanel.hidden = true;
if (processBtn) processBtn.disabled = true;
if (processBtn) processBtn.disabled = true;
hideAnswer();
hideAnswer();
@@ -248,10 +248,13 @@
@@ -248,10 +248,13 @@
showWorking();
showWorking();
try {
try {
- const job = await OVJobs.submit({ type: 'img.process', input, files: [selectedFile] });
+ if (uploadUI) uploadUI.start();
+ const job = await OVJobs.submit({ type: 'img.process', input, files: [selectedFile], onProgress: uploadUI ? (p) => uploadUI.progress(p.loaded, p.total) : null });
+ if (uploadUI) uploadUI.done();
OVJobs.remember(job.id);
OVJobs.remember(job.id);
followJob(job);
followJob(job);
} catch (err) {
} catch (err) {
+ if (uploadUI) uploadUI.fail(err.message);
failProcessing(err.message);
failProcessing(err.message);
}
}
}
}
@@ -243,11 +243,7 @@ function verbFor(rec) {
@@ -243,11 +243,7 @@ function verbFor(rec) {
*/
*/
function boxFor(rec) {
function boxFor(rec) {
const accept = 'image/png,image/jpeg,image/webp,image/avif,image/tiff,image/bmp,image/gif,image/heic,image/heif,image/svg+xml,image/x-icon,image/vnd.microsoft.icon';
const accept = 'image/png,image/jpeg,image/webp,image/avif,image/tiff,image/bmp,image/gif,image/heic,image/heif,image/svg+xml,image/x-icon,image/vnd.microsoft.icon';
- const drop = kit.drop({
- name: 'file', accept, id: 'file-input',
- label: 'Drop an image here',
- hint: 'PNG, JPG, WebP, AVIF, HEIC, SVG, GIF, ICO, TIFF, BMP',
- });
+ const drop = frame.upload({ accept, label: 'Choose an image', hint: 'PNG, JPG, WebP, AVIF, HEIC, SVG, GIF, ICO, TIFF or BMP', maxBytes: config.upload.maxFileSize });
return `<form class="ovk-tool-form" id="ovk-img-form" enctype="multipart/form-data" data-ovk-tool-form>`
return `<form class="ovk-tool-form" id="ovk-img-form" enctype="multipart/form-data" data-ovk-tool-form>`
+ drop
+ drop
+ `<p class="ovk-try"><span>Try</span><a href="/sample.png" data-ovk-sample="/sample.png">A sample image</a></p>`
+ `<p class="ovk-try"><span>Try</span><a href="/sample.png" data-ovk-sample="/sample.png">A sample image</a></p>`
@@ -46,7 +46,7 @@ function factsOf(html) {
@@ -46,7 +46,7 @@ function factsOf(html) {
'class="ovk-family"', 'class="ovk-tool-folds"', 'People also use',
'class="ovk-family"', 'class="ovk-tool-folds"', 'People also use',
]);
]);
assert.ok(html.includes('https://img.openvibe.tools/">Image tools'), `${host}: the crumb is the family`);
assert.ok(html.includes('https://img.openvibe.tools/">Image tools'), `${host}: the crumb is the family`);
- assert.ok(html.includes('class="ovk-drop"'), `${host}: a drop zone`);
+ assert.ok(html.includes('data-ov-tool-upload') && html.includes('data-max-bytes='), `${host}: shared upload picker with limit`);
assert.ok(html.includes('data-ovk-sample'), `${host}: a sample to try`);
assert.ok(html.includes('data-ovk-sample'), `${host}: a sample to try`);
assert.ok(html.includes('aria-current="page"'), `${host}: the family marks this tool`);
assert.ok(html.includes('aria-current="page"'), `${host}: the family marks this tool`);
assert.ok(html.includes('Use it from code') && html.includes('https://openvibe.tools/api/v1/tools/'), `${host}: the API fold`);
assert.ok(html.includes('Use it from code') && html.includes('https://openvibe.tools/api/v1/tools/'), `${host}: the API fold`);
@@ -132,4 +132,3 @@ function factsOf(html) {
@@ -132,4 +132,3 @@ function factsOf(html) {
}
}
console.log(`tool frame (img): ${HOSTS.length} image pages framed, the terms from the config, the verb buttons: all checks passed`);
console.log(`tool frame (img): ${HOSTS.length} image pages framed, the terms from the config, the verb buttons: all checks passed`);
})().catch((err) => { console.error(err); process.exit(1); });
})().catch((err) => { console.error(err); process.exit(1); });
-