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
+5 -1
View File
@@ -47,7 +47,11 @@ form:
- -
name: photos name: photos
label: Photos (max 4) label: Photos (max 4)
type: filepond # Custom controlled picker (theme: forms/fields/photos) — replaces
# filepond so post-form.js can convert HEIC before upload (U4). The
# destination/accept/limit below are still read server-side by the
# form's AJAX uploadFiles() handler.
type: photos
multiple: true multiple: true
destination: '@self' destination: '@self'
limit: 4 limit: 4
File diff suppressed because one or more lines are too long
@@ -0,0 +1 @@
var g=Object.create;var f=Object.defineProperty;var h=Object.getOwnPropertyDescriptor;var i=Object.getOwnPropertyNames;var j=Object.getPrototypeOf,k=Object.prototype.hasOwnProperty;var m=(a=>typeof require<"u"?require:typeof Proxy<"u"?new Proxy(a,{get:(b,c)=>(typeof require<"u"?require:b)[c]}):a)(function(a){if(typeof require<"u")return require.apply(this,arguments);throw Error('Dynamic require of "'+a+'" is not supported')});var n=(a,b)=>()=>(b||a((b={exports:{}}).exports,b),b.exports);var l=(a,b,c,e)=>{if(b&&typeof b=="object"||typeof b=="function")for(let d of i(b))!k.call(a,d)&&d!==c&&f(a,d,{get:()=>b[d],enumerable:!(e=h(b,d))||e.enumerable});return a};var o=(a,b,c)=>(c=a!=null?g(j(a)):{},l(b||!a||!a.__esModule?f(c,"default",{value:a,enumerable:!0}):c,a));export{m as a,n as b,o as c};
File diff suppressed because one or more lines are too long
File diff suppressed because one or more lines are too long
+46
View File
@@ -19,3 +19,49 @@
.EasyMDEContainer .CodeMirror { .EasyMDEContainer .CodeMirror {
min-height: 180px; min-height: 180px;
} }
/* ── Photo picker (U4) — functional states; Field Notes polish in U5 ── */
.photo-picker__list {
list-style: none;
margin: 0.5rem 0 0;
padding: 0;
display: flex;
flex-wrap: wrap;
gap: 0.5rem;
}
.photo-card {
position: relative;
width: 84px;
height: 84px;
border-radius: 6px;
overflow: hidden;
background: rgba(0, 0, 0, 0.06);
display: flex;
align-items: center;
justify-content: center;
}
.photo-card__thumb {
width: 100%;
height: 100%;
object-fit: cover;
display: block;
}
.photo-card__state {
position: absolute;
left: 0;
right: 0;
bottom: 0;
padding: 2px 4px;
font-size: 0.7rem;
line-height: 1.2;
text-align: center;
color: #fff;
background: rgba(0, 0, 0, 0.55);
}
.photo-card.is-done .photo-card__state { background: rgba(31, 107, 90, 0.85); }
.photo-card.is-error {
background: rgba(176, 0, 32, 0.12);
outline: 2px solid rgba(176, 0, 32, 0.5);
}
.photo-card.is-error .photo-card__state { background: rgba(176, 0, 32, 0.85); }
.photo-card.is-converting .photo-card__thumb { opacity: 0.4; }
+164
View File
@@ -49,11 +49,175 @@ function initEditor() {
return editor; return editor;
} }
/* Photo picker + client-side HEICJPEG (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 ────────────────────────────────────────────────── */ /* ── Boot ────────────────────────────────────────────────── */
function boot() { function boot() {
// Exposed for later units (U6 draft restore) and the Playwright specs; // Exposed for later units (U6 draft restore) and the Playwright specs;
// null when this bundle loads on a page without the content field. // null when this bundle loads on a page without the content field.
window.postFormEditor = initEditor(); window.postFormEditor = initEditor();
initPhotoPicker();
} }
if (document.readyState === 'loading') { if (document.readyState === 'loading') {
+7
View File
@@ -9,6 +9,7 @@
"@fontsource/dm-serif-display": "latest", "@fontsource/dm-serif-display": "latest",
"@mapbox/togeojson": "^0.16.2", "@mapbox/togeojson": "^0.16.2",
"easymde": "^2", "easymde": "^2",
"heic-to": "^1",
"maplibre-gl": "^4", "maplibre-gl": "^4",
"photoswipe": "^5", "photoswipe": "^5",
"scrollama": "^3" "scrollama": "^3"
@@ -742,6 +743,12 @@
"node": ">=16" "node": ">=16"
} }
}, },
"node_modules/heic-to": {
"version": "1.5.2",
"resolved": "https://registry.npmjs.org/heic-to/-/heic-to-1.5.2.tgz",
"integrity": "sha512-8Fns+lZHAWmz5U5IUxDeXKwIf3foBoKNPLxxFY4B0MkLjNuomEIHCoDbDE+x/llFK3NCEO1cu4+n3iUKY+Svmw==",
"license": "LGPL-3.0"
},
"node_modules/ieee754": { "node_modules/ieee754": {
"version": "1.2.1", "version": "1.2.1",
"resolved": "https://registry.npmjs.org/ieee754/-/ieee754-1.2.1.tgz", "resolved": "https://registry.npmjs.org/ieee754/-/ieee754-1.2.1.tgz",
+2 -1
View File
@@ -1,13 +1,14 @@
{ {
"private": true, "private": true,
"scripts": { "scripts": {
"build": "node scripts/gen-weather-icons.js && esbuild js/src/main.js --bundle --minify --format=iife --outfile=js/main.js --loader:.woff2=file --loader:.woff=file --asset-names=../fonts/[name] && esbuild js/src/map.js --bundle --minify --format=iife --outfile=js/map.js && esbuild js/src/post-form.js --bundle --minify --format=esm --splitting --outdir=js/post && mkdir -p css-compiled fonts && { mv js/main.css css-compiled/main.css 2>/dev/null || true; } && { mv js/map.css css-compiled/map.css 2>/dev/null || true; } && { mv js/post/post-form.css css-compiled/post-form.css 2>/dev/null || true; }" "build": "node scripts/gen-weather-icons.js && esbuild js/src/main.js --bundle --minify --format=iife --outfile=js/main.js --loader:.woff2=file --loader:.woff=file --asset-names=../fonts/[name] && esbuild js/src/map.js --bundle --minify --format=iife --outfile=js/map.js && rm -rf js/post && esbuild js/src/post-form.js --bundle --minify --format=esm --splitting --outdir=js/post && mkdir -p css-compiled fonts && { mv js/main.css css-compiled/main.css 2>/dev/null || true; } && { mv js/map.css css-compiled/map.css 2>/dev/null || true; } && { mv js/post/post-form.css css-compiled/post-form.css 2>/dev/null || true; }"
}, },
"dependencies": { "dependencies": {
"@fontsource-variable/dm-sans": "latest", "@fontsource-variable/dm-sans": "latest",
"@fontsource/dm-serif-display": "latest", "@fontsource/dm-serif-display": "latest",
"@mapbox/togeojson": "^0.16.2", "@mapbox/togeojson": "^0.16.2",
"easymde": "^2", "easymde": "^2",
"heic-to": "^1",
"maplibre-gl": "^4", "maplibre-gl": "^4",
"photoswipe": "^5", "photoswipe": "^5",
"scrollama": "^3" "scrollama": "^3"
@@ -0,0 +1,28 @@
{#
Custom `photos` form field — a plain, controlled picker that replaces Grav's
managed filepond/dropzone field on /post so post-form.js can convert HEIC
before upload (see U4). It carries the same destination/accept/limit settings
the server's uploadFiles() reads via the blueprint, but renders its own input
and uploads through post-form.js, not FilePond.
#}
{% extends "forms/field.html.twig" %}
{% block input %}
{% set files = config.plugins.form.files|merge(field|default([])) %}
{% set limit = not field.multiple ? 1 : (field.limit ?? files.limit ?? 4) %}
<div class="photo-picker {{ field.classes }}"
data-file-field-name="{{ field.name }}"
data-file-url-add="{{ form.getFileUploadAjaxRoute().getUri()|e('html_attr') }}"
data-limit="{{ limit }}">
<label class="photo-picker__add btn-action">
<span class="photo-picker__add-label">📷 Add photos</span>
<input type="file"
class="photo-picker__input"
accept="image/*,.heic,.heif,.HEIC,.HEIF"
{% if field.multiple %}multiple="multiple"{% endif %}
hidden />
</label>
<ul class="photo-picker__list" aria-live="polite"></ul>
<p class="photo-picker__hint form-status" role="status"></p>
</div>
{% endblock %}