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:
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 {
|
||||
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;
|
||||
}
|
||||
|
||||
/* ── 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') {
|
||||
|
||||
Generated
+7
@@ -9,6 +9,7 @@
|
||||
"@fontsource/dm-serif-display": "latest",
|
||||
"@mapbox/togeojson": "^0.16.2",
|
||||
"easymde": "^2",
|
||||
"heic-to": "^1",
|
||||
"maplibre-gl": "^4",
|
||||
"photoswipe": "^5",
|
||||
"scrollama": "^3"
|
||||
@@ -742,6 +743,12 @@
|
||||
"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": {
|
||||
"version": "1.2.1",
|
||||
"resolved": "https://registry.npmjs.org/ieee754/-/ieee754-1.2.1.tgz",
|
||||
|
||||
@@ -1,13 +1,14 @@
|
||||
{
|
||||
"private": true,
|
||||
"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": {
|
||||
"@fontsource-variable/dm-sans": "latest",
|
||||
"@fontsource/dm-serif-display": "latest",
|
||||
"@mapbox/togeojson": "^0.16.2",
|
||||
"easymde": "^2",
|
||||
"heic-to": "^1",
|
||||
"maplibre-gl": "^4",
|
||||
"photoswipe": "^5",
|
||||
"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