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:
@@ -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
@@ -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; }
|
||||||
|
|||||||
@@ -49,11 +49,175 @@ function initEditor() {
|
|||||||
return editor;
|
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 ────────────────────────────────────────────────── */
|
/* ── 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') {
|
||||||
|
|||||||
Generated
+7
@@ -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",
|
||||||
|
|||||||
@@ -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 %}
|
||||||
Reference in New Issue
Block a user