feat(post-form): client-side HEIC->JPEG photo picker (U4)

Replace /post's managed filepond field with a controlled 'photos' field
(theme forms/fields/photos) + picker logic in post-form.js: magic-byte
sniff (ISO-BMFF ftyp brands, not filename/MIME), lazy import('heic-to')
only for real HEIC (deferred 3MB chunk via ESM splitting), per-thumbnail
converting/uploading/done/error states, in-flight counter gating Submit,
and fail-closed skip on conversion failure. Converted JPEGs POST to Grav's
AJAX file-upload route into the form flash (the only path copyFiles reads),
so add-page-by-form attaches them on submit. Web-format photos pass through.

Refs R8, R9, R16, R17, AE1, AE4, KTD4.
This commit is contained in:
2026-07-04 16:38:13 +02:00
parent 6282528dd8
commit bf5b5c2f3c
10 changed files with 318 additions and 66 deletions
+164
View File
@@ -49,11 +49,175 @@ function initEditor() {
return editor;
}
/* ── Photo picker + client-side HEIC→JPEG (U4) ───────────────
* We replaced Grav's managed filepond field with a plain, controlled picker
* (see the `photos` field template) because filepond instant-uploads each file
* the moment it is added and its FilePond.create config is not ours to hook —
* so HEIC could never be converted before upload. Here we own the flow: sniff,
* lazy-convert HEIC, then POST each web-safe JPEG to Grav's AJAX file-upload
* route (into the form flash, exactly as filepond's server.process did) so
* add-page-by-form's copyFiles() picks them up on submit.
*/
// ISO-BMFF 'ftyp' brands that indicate HEIC/HEIF. Sniffed from bytes, never
// from the filename/MIME (R16) — iOS sometimes hands us .heic with an image/*
// MIME, and a renamed .jpg can still be HEIC underneath.
var HEIC_BRANDS = ['heic', 'heix', 'heif', 'hevc', 'hevx', 'mif1', 'msf1', 'heim', 'heis', 'hevm', 'hevs'];
function looksLikeHeic(file) {
return file.slice(0, 64).arrayBuffer().then(function (buf) {
var b = new Uint8Array(buf);
if (b.length < 12) return false;
// bytes 4..8 must be the 'ftyp' box type
if (String.fromCharCode(b[4], b[5], b[6], b[7]) !== 'ftyp') return false;
// scan major brand + compatible brands (4-byte codes from offset 8)
var brands = '';
for (var i = 8; i + 4 <= b.length; i += 4) {
brands += String.fromCharCode(b[i], b[i + 1], b[i + 2], b[i + 3]).toLowerCase();
}
return HEIC_BRANDS.some(function (brand) { return brands.indexOf(brand) !== -1; });
}).catch(function () { return false; });
}
function slugifyJpegName(name) {
var base = String(name || 'photo').replace(/\.[^.]+$/, '');
base = base.toLowerCase().replace(/[^a-z0-9]+/g, '-').replace(/^-+|-+$/g, '');
return (base || 'photo') + '.jpg';
}
function initPhotoPicker() {
var root = document.querySelector('.photo-picker');
if (!root) return;
var input = root.querySelector('.photo-picker__input');
var list = root.querySelector('.photo-picker__list');
var hint = root.querySelector('.photo-picker__hint');
var form = root.closest('form');
if (!input || !form) return;
var fieldName = root.getAttribute('data-file-field-name') || 'photos';
var uploadUrl = root.getAttribute('data-file-url-add');
var limit = parseInt(root.getAttribute('data-limit'), 10) || 4;
var attached = 0; // photos that converted + uploaded (or are reserved) OK
var inFlight = 0; // conversions/uploads still running — gates Submit (R17)
function hiddenVal(name) {
var el = form.querySelector('[name="' + name + '"]');
return el ? el.value : '';
}
function setHint(msg, kind) {
if (!hint) return;
hint.textContent = msg || '';
hint.className = 'photo-picker__hint form-status' + (kind ? ' form-status--' + kind : '');
}
function updateSubmitState() {
var btn = form.querySelector('button[type="submit"], input[type="submit"]');
if (btn) btn.disabled = inFlight > 0;
root.classList.toggle('is-busy', inFlight > 0);
}
function addCard(file) {
var li = document.createElement('li');
li.className = 'photo-card is-converting';
var img = document.createElement('img');
img.className = 'photo-card__thumb';
img.alt = file.name || 'photo';
var state = document.createElement('span');
state.className = 'photo-card__state';
state.textContent = 'converting…';
li.appendChild(img);
li.appendChild(state);
if (list) list.appendChild(li);
return { li: li, img: img, state: state };
}
function uploadToFlash(blob, filename) {
var fd = new FormData();
fd.append('__form-name__', hiddenVal('__form-name__'));
fd.append('__unique_form_id__', hiddenVal('__unique_form_id__'));
fd.append('__form-file-uploader__', '1');
fd.append('form-nonce', hiddenVal('form-nonce'));
fd.append('name', fieldName);
fd.append('data[' + fieldName + '][]', blob, filename);
return fetch(uploadUrl, {
method: 'POST',
headers: { 'X-Requested-With': 'XMLHttpRequest' },
body: fd,
credentials: 'same-origin'
}).then(function (r) { return r.json(); }).then(function (j) {
if (!j || j.status === 'error') {
throw new Error((j && j.message) || 'Upload failed');
}
return j;
});
}
function processFile(file) {
if (attached >= limit) {
setHint('You can attach up to ' + limit + ' photos.', 'err');
return;
}
attached++; // reserve a slot; released if this photo fails
inFlight++;
updateSubmitState();
var card = addCard(file);
looksLikeHeic(file).then(function (isHeic) {
if (!isHeic) return file; // already web-renderable (R9) — pass through
// Lazy-load the converter only when a HEIC is actually picked (KTD4);
// this is the separately-fetched code-split chunk.
return import('heic-to').then(function (mod) {
var heicTo = mod.heicTo || (mod.default && mod.default.heicTo);
return heicTo({ blob: file, type: 'image/jpeg', quality: 0.85 });
});
}).then(function (out) {
var passedThrough = out === file;
var filename = (passedThrough && /\.(jpe?g|png)$/i.test(file.name)) ? file.name : slugifyJpegName(file.name);
try { card.img.src = URL.createObjectURL(out); } catch (e) { /* no preview */ }
card.state.textContent = 'uploading…';
return uploadToFlash(out, filename);
}).then(function () {
card.li.classList.remove('is-converting');
card.li.classList.add('is-done');
card.state.textContent = '✓';
}).catch(function () {
// Fail closed (R16): drop this photo, keep the others + Submit usable,
// never upload the original HEIC.
attached--;
card.li.classList.remove('is-converting');
card.li.classList.add('is-error');
card.state.textContent = '✗ skipped';
setHint('A photo could not be processed and was skipped — the others are fine.', 'err');
}).then(function () {
inFlight--;
updateSubmitState();
});
}
input.addEventListener('change', function () {
setHint('');
Array.prototype.slice.call(input.files || []).forEach(processFile);
input.value = ''; // let the same file be re-picked after a removal/error
});
// Backstop for the Submit gate (button is also disabled while in flight).
form.addEventListener('submit', function (e) {
if (inFlight > 0) {
e.preventDefault();
setHint('Hang on — photos are still uploading.', 'err');
}
}, true);
}
/* ── Boot ────────────────────────────────────────────────── */
function boot() {
// Exposed for later units (U6 draft restore) and the Playwright specs;
// null when this bundle loads on a page without the content field.
window.postFormEditor = initEditor();
initPhotoPicker();
}
if (document.readyState === 'loading') {