openvibers/openvibe.vision Commits
Use shared upload kit across Vision image forms (#155)
Changes
nnew file mode 100644@@@ -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);++ });++ });++})();@@@ -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)}">@@@ -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>`;@@@ -39,6 +39,17 @@ const PLANTED = '<style>body{background:#fff;color:#999}.wide{width:1600px}</styassert.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);
new file mode 100644
new file mode 100644
@@ -0,0 +1,107 @@
@@ -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);
+ });
+ });
+})();
@@ -94,10 +94,14 @@ ${appIcon.headTags({ site: 'vision' })}
@@ -94,10 +94,14 @@ ${appIcon.headTags({ site: 'vision' })}
<link rel="stylesheet" href="${ovServe.url('kit-data.css')}">
<link rel="stylesheet" href="${ovServe.url('kit-data.css')}">
<link rel="stylesheet" href="${ovServe.url('room.css')}">
<link rel="stylesheet" href="${ovServe.url('room.css')}">
<link rel="stylesheet" href="${ovServe.url('showcase.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')}">
<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('motion.js')}" defer></script>
<script src="${ovServe.url('kit-web.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('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)}
${render(scripts)}
${(o.inlineStyles || []).map((css) => `<style>${css}</style>`).join('\n')}
${(o.inlineStyles || []).map((css) => `<style>${css}</style>`).join('\n')}
<meta name="ov-boost" content="vision@${require('./html').esc(RELEASE)}">
<meta name="ov-boost" content="vision@${require('./html').esc(RELEASE)}">
@@ -4,8 +4,8 @@
@@ -4,8 +4,8 @@
* The three task pages: /find, /read and /watch (plan 2026-10-28 §5.8/§5.8b).
* 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.
* 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.
* bottom of the page instead of above the tool.
*/
*/
const { esc } = require('../render/html');
const { esc } = require('../render/html');
@@ -48,9 +48,9 @@ function header({ kicker, title, lede }) {
@@ -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';
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 }) =>
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) {
function examples(links) {
return `<div class="vi-examples"><span class="vi-examples-label">Try an example</span>${links.join('')}</div>`;
return `<div class="vi-examples"><span class="vi-examples-label">Try an example</span>${links.join('')}</div>`;
@@ -39,6 +39,17 @@ const PLANTED = '<style>body{background:#fff;color:#999}.wide{width:1600px}</sty
@@ -39,6 +39,17 @@ const PLANTED = '<style>body{background:#fff;color:#999}.wide{width:1600px}</sty
assert.strictEqual(r.headers.get('vary'), 'Cookie');
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 () => {
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 } });
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);
assert.strictEqual(r.status, 303, r.text);