openvibers/openvibe.vision Commits

Use shared upload kit across Vision image forms (#155)

OP OpenVibers committed 2 days ago · ebd3bca2d2 · 4 files, +126 −4

Changes

ddiff --git a/public/js/upload-picker.js b/public/js/upload-picker.js
nnew file mode 100644
iindex 0000000..1bf42c1
---- /dev/null
++++ b/public/js/upload-picker.js
@@@ -0,0 +1,107 @@
++/* Enhance Vision's native multipart inputs with the shared picker and progress. */
++(function () {
++ 'use strict';
++ if (!window.OVUpload || !window.DataTransfer) return;
++ var maxBytes = Number(document.querySelector('meta[name="vision-image-max-bytes"]')?.content) || 8 * 1024 * 1024;
++ var states = new WeakMap();
++ var count = 0;
++
++ document.querySelectorAll('form input[type="file"]').forEach(function (input) {
++ var label = input.closest('label');
++ var form = input.form;
++ if (!label || !form) return;
++ var root = document.createElement('div');
++ var hint = document.createElement('p');
++ var error = document.createElement('p');
++ hint.className = 'vi-fine';
++ hint.textContent = (input.accept === 'image/*' ? 'Images' : 'PNG, JPEG, WebP, GIF, BMP, TIFF or AVIF') + ' · up to ' + (maxBytes / 1024 / 1024) + ' MB.';
++ hint.id = (input.id || 'vision-file-' + (++count)) + '-limit';
++ error.setAttribute('role', 'alert');
++ error.hidden = true;
++ label.after(root, hint, error);
++ var state = { file: null, row: null, meter: null, error: error };
++ states.set(input, state);
++ function clear() {
++ state.file = null; state.row = null; input.value = '';
++ if (state.meter) state.meter.destroy();
++ state.meter = null;
++ }
++ var picker = OVUpload.picker(root, {
++ label: label.querySelector('span')?.textContent || 'Choose an image',
++ accept: input.accept, multiple: false, onRemove: clear,
++ onFile: function (file, row) {
++ if (state.row) { state.row.dispatchEvent(new Event('remove')); state.row.remove(); }
++ error.hidden = true;
++ var allowed = input.accept === 'image/*' ? /^image\//.test(file.type) : input.accept.split(',').includes(file.type);
++ if (!allowed && !(file.type === '' && /\.(png|jpe?g|webp|gif|bmp|tiff?|avif)$/i.test(file.name))) {
++ row.dispatchEvent(new Event('remove')); row.remove();
++ error.textContent = 'Choose a supported image file.'; error.hidden = false; return;
++ }
++ if (file.size > maxBytes) {
++ row.dispatchEvent(new Event('remove')); row.remove();
++ error.textContent = 'The image is larger than ' + (maxBytes / 1024 / 1024) + ' MB.'; error.hidden = false; return;
++ }
++ var transfer = new DataTransfer(); transfer.items.add(file); input.files = transfer.files;
++ state.file = file; state.row = row;
++ var meterRoot = document.createElement('div'); row.append(meterRoot);
++ state.meter = OVUpload.progress(meterRoot, { compact: true, onRetry: function () { send(form); } });
++ state.meter.update({ state: 'waiting', total: file.size });
++ if (input.hasAttribute('data-upload-auto')) form.requestSubmit();
++ }
++ });
++ root.querySelector('.ov-upload__picker').setAttribute('aria-describedby', hint.id);
++ root.addEventListener('paste', function (event) {
++ if (event.clipboardData && event.clipboardData.files.length) { event.preventDefault(); picker.add(event.clipboardData.files); }
++ });
++ if (input.accept.startsWith('image/')) picker.input.setAttribute('capture', 'environment');
++ input.required = false;
++ input.hidden = true;
++ });
++
++ function send(form) {
++ if (form.dataset.uploading === '1') return;
++ var picked = Array.from(form.querySelectorAll('input[type="file"]')).map(function (input) { return states.get(input); }).filter(function (s) { return s && s.file; });
++ if (!picked.length) return;
++ form.dataset.uploading = '1';
++ var buttons = Array.from(form.querySelectorAll('button[type="submit"]'));
++ buttons.forEach(function (button) { button.disabled = true; });
++ var total = picked.reduce(function (sum, s) { return sum + s.file.size; }, 0);
++ var meter = picked[0].meter;
++ var xhr = new XMLHttpRequest();
++ xhr.open('POST', form.action);
++ xhr.upload.onprogress = function (event) {
++ if (event.lengthComputable) meter.update({ state: 'sending', done: event.loaded, total: event.total });
++ };
++ xhr.upload.onload = function () { meter.update({ state: 'processing', done: total, total: total, message: 'Reading the image' }); };
++ function retry(message) {
++ form.dataset.uploading = '';
++ buttons.forEach(function (button) { button.disabled = false; });
++ meter.update({ state: 'failed', message: message });
++ }
++ xhr.onerror = function () { retry('The connection stopped. Try again.'); };
++ xhr.onload = function () {
++ if (xhr.status < 200 || xhr.status >= 400) {
++ var page = new DOMParser().parseFromString(xhr.responseText || '', 'text/html');
++ retry(page.querySelector('[role="alert"], h1')?.textContent || 'The image could not be sent. Try again.');
++ return;
++ }
++ meter.update({ state: 'done', done: total, total: total });
++ if (xhr.responseURL && new URL(xhr.responseURL).pathname + new URL(xhr.responseURL).search !== new URL(form.action).pathname + new URL(form.action).search) {
++ window.location.assign(xhr.responseURL);
++ } else {
++ document.open(); document.write(xhr.responseText); document.close();
++ }
++ };
++ meter.update({ state: 'sending', done: 0, total: total });
++ xhr.send(new FormData(form));
++ }
++ document.querySelectorAll('form').forEach(function (form) {
++ if (!form.querySelector('input[type="file"]')) return;
++ form.addEventListener('submit', function (event) {
++ var picked = Array.from(form.querySelectorAll('input[type="file"]')).some(function (input) { var s = states.get(input); return s && s.file; });
++ if (!picked) return;
++ event.preventDefault();
++ send(form);
++ });
++ });
++})();
ddiff --git a/server/render/layout.js b/server/render/layout.js
iindex a3fcedc..f1d643f 100644
---- a/server/render/layout.js
++++ b/server/render/layout.js
@@@ -94,10 +94,14 @@ ${appIcon.headTags({ site: 'vision' })}
<link rel="stylesheet" href="${ovServe.url('kit-data.css')}">
<link rel="stylesheet" href="${ovServe.url('room.css')}">
<link rel="stylesheet" href="${ovServe.url('showcase.css')}">
++<link rel="stylesheet" href="${ovServe.url('ov-upload.css')}">
<link rel="stylesheet" href="${asset('css/vision.css')}">
++<meta name="vision-image-max-bytes" content="${o.config.images.maxMb * 1024 * 1024}">
<script src="${ovServe.url('motion.js')}" defer></script>
<script src="${ovServe.url('kit-web.js')}" defer></script>
<script src="${ovServe.url('kit-data-web.js')}" defer></script>
++<script src="${ovServe.url('ov-upload.js')}" defer></script>
++<script src="${asset('js/upload-picker.js')}" defer></script>
${render(scripts)}
${(o.inlineStyles || []).map((css) => `<style>${css}</style>`).join('\n')}
<meta name="ov-boost" content="vision@${require('./html').esc(RELEASE)}">
ddiff --git a/server/views/tools.js b/server/views/tools.js
iindex 74803f3..9761190 100644
---- a/server/views/tools.js
++++ b/server/views/tools.js
@@@ -4,8 +4,8 @@
* The three task pages: /find, /read and /watch (plan 2026-10-28 §5.8/§5.8b).
*
* Each page is one job: one input or drop zone, the result directly under it, and examples a person can tap.
-- * Everything is a plain server-rendered form, so it works without JavaScript. The drop zone is the kit's (kit.drop):
-- * kit-web.js adds drag and drop, paste and, on a phone, "Take a photo"; /read reads a picture as soon as it is chosen. Power options sit inside a <details> one tap down, and the API notes live at the
++ * Everything is a plain server-rendered form, so it works without JavaScript. The shared upload kit
++ * enhances each native file input; /read reads a picture as soon as it is chosen. Power options sit inside a <details> one tap down, and the API notes live at the
* bottom of the page instead of above the tool.
*/
const { esc } = require('../render/html');
@@@ -48,9 +48,9 @@ function header({ kicker, title, lede }) {
}
const ACCEPT = 'image/png,image/jpeg,image/webp,image/gif,image/bmp,image/tiff,image/avif';
--/** The picture's way in (kit.drop): choose, drop, paste, or take a photo on a phone. auto: send the form at once. */
++/** Native fallback enhanced by OVUpload.picker; auto sends /read when a file is chosen. */
const dropZone = ({ id, name = 'image', label = 'Drop a picture here', hint = 'or choose a file', auto = false }) =>
-- kit.drop({ id, name, accept: ACCEPT, label, hint, camera: true, auto });
++ `<label class="vi-field"><span>${esc(label)}</span><input type="file" id="${esc(id)}" name="${esc(name)}" accept="${ACCEPT}"${auto ? ' data-upload-auto' : ''}></label><p class="vi-fine">${esc(hint)}</p>`;
function examples(links) {
return `<div class="vi-examples"><span class="vi-examples-label">Try an example</span>${links.join('')}</div>`;
ddiff --git a/test/pages.test.js b/test/pages.test.js
iindex 327b351..9ac95f6 100644
---- a/test/pages.test.js
++++ b/test/pages.test.js
@@@ -39,6 +39,17 @@ const PLANTED = '<style>body{background:#fff;color:#999}.wide{width:1600px}</sty
assert.strictEqual(r.headers.get('vary'), 'Cookie');
});
++ await check('image forms serve the shared upload picker with the server image limit', async () => {
++ for (const path of ['/ask', '/ocr', '/compare', '/ui-check', '/find', '/read', '/watch', '/people']) {
++ const page = await t.get(path, { as: alice });
++ assert.strictEqual(page.status, 200, path);
++ assert.match(page.text, /ov-upload\.css/, path);
++ assert.match(page.text, /ov-upload\.js/, path);
++ assert.match(page.text, /js\/upload-picker\.js/, path);
++ assert.match(page.text, /name="vision-image-max-bytes"/, path);
++ }
++ });
++
await check('POST /ask saves an answer and redirects to its private page', async () => {
const r = await t.get('/ask', { as: alice, multipart: { fields: { task: 'describe', question: 'What is this?' }, image: png } });
assert.strictEqual(r.status, 303, r.text);