openvibers/openvibe.tools Commits

Use shared upload kit across file tools and local scanners (#308)

OP OpenVibers committed 2 days ago · e4e09e1d02 · 18 files, +155 −60

Changes

ddiff --git a/apps/_shared/frame.js b/apps/_shared/frame.js
iindex 06298f4..b6c5a29 100644
---- a/apps/_shared/frame.js
++++ b/apps/_shared/frame.js
@@@ -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 };
};
ddiff --git a/apps/_shared/jobs/client.js b/apps/_shared/jobs/client.js
iindex 747c886..41f0e54 100644
---- a/apps/_shared/jobs/client.js
++++ b/apps/_shared/jobs/client.js
@@@ -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' },
ddiff --git a/apps/_shared/public/tool-frame.js b/apps/_shared/public/tool-frame.js
iindex 7d93547..694fd7b 100644
---- a/apps/_shared/public/tool-frame.js
++++ b/apps/_shared/public/tool-frame.js
@@@ -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 };
})();
ddiff --git a/apps/audio/public/index.html b/apps/audio/public/index.html
iindex 1d72ef8..8189854 100644
---- a/apps/audio/public/index.html
++++ b/apps/audio/public/index.html
@@@ -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>
ddiff --git a/apps/audio/public/js/app.js b/apps/audio/public/js/app.js
iindex 62b2f65..dfe80eb 100644
---- a/apps/audio/public/js/app.js
++++ b/apps/audio/public/js/app.js
@@@ -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);
}
}
ddiff --git a/apps/audio/server/frame.js b/apps/audio/server/frame.js
iindex 358ebcb..45524b8 100644
---- a/apps/audio/server/frame.js
++++ b/apps/audio/server/frame.js
@@@ -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>`
ddiff --git a/apps/audio/test/frame.test.js b/apps/audio/test/frame.test.js
iindex 06f87b7..21dc3dc 100644
---- a/apps/audio/test/frame.test.js
++++ b/apps/audio/test/frame.test.js
@@@ -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`);
ddiff --git a/apps/docs/public/index.html b/apps/docs/public/index.html
iindex daa6df4..1937e31 100644
---- a/apps/docs/public/index.html
++++ b/apps/docs/public/index.html
@@@ -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>
ddiff --git a/apps/docs/public/js/app.js b/apps/docs/public/js/app.js
iindex 0fe272c..61bb707 100644
---- a/apps/docs/public/js/app.js
++++ b/apps/docs/public/js/app.js
@@@ -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);
}
}
ddiff --git a/apps/docs/server/frame.js b/apps/docs/server/frame.js
iindex 0227037..b9e10be 100644
---- a/apps/docs/server/frame.js
++++ b/apps/docs/server/frame.js
@@@ -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
ddiff --git a/apps/docs/test/frame.test.js b/apps/docs/test/frame.test.js
iindex ac1b449..bc22bd6 100644
---- a/apps/docs/test/frame.test.js
++++ b/apps/docs/test/frame.test.js
@@@ -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`);
ddiff --git a/apps/gateway/public/codes.html b/apps/gateway/public/codes.html
iindex 275bc67..b1f60ba 100644
---- a/apps/gateway/public/codes.html
++++ b/apps/gateway/public/codes.html
@@@ -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>
ddiff --git a/apps/gateway/public/js/codes/app.js b/apps/gateway/public/js/codes/app.js
iindex 4f6df9d..5c87bc9 100644
---- a/apps/gateway/public/js/codes/app.js
++++ b/apps/gateway/public/js/codes/app.js
@@@ -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();
ddiff --git a/apps/gateway/test/codes-pages.test.js b/apps/gateway/test/codes-pages.test.js
iindex 99f9c23..5ba331e 100644
---- a/apps/gateway/test/codes-pages.test.js
++++ b/apps/gateway/test/codes-pages.test.js
@@@ -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 ──
ddiff --git a/apps/img/public/index.html b/apps/img/public/index.html
iindex 432f8c6..a8925b4 100644
---- a/apps/img/public/index.html
++++ b/apps/img/public/index.html
@@@ -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>
ddiff --git a/apps/img/public/js/app.js b/apps/img/public/js/app.js
iindex 24698f9..d477139 100644
---- a/apps/img/public/js/app.js
++++ b/apps/img/public/js/app.js
@@@ -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);
}
}
ddiff --git a/apps/img/server/frame.js b/apps/img/server/frame.js
iindex 6a79713..e66f2a5 100644
---- a/apps/img/server/frame.js
++++ b/apps/img/server/frame.js
@@@ -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>`
ddiff --git a/apps/img/test/frame.test.js b/apps/img/test/frame.test.js
iindex dcd8507..4dd41bd 100644
---- a/apps/img/test/frame.test.js
++++ b/apps/img/test/frame.test.js
@@@ -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); });
--