feat(post-form): EasyMDE editor + /post-scoped ESM bundle (U3)

Add a page-scoped js/src/post-form.js bundle (built ESM + code-splitting
so U4's heic-to becomes a deferred chunk) loaded only by post-form.html.twig
via a new base head_assets block. EasyMDE replaces the bare textarea with a
minimal toolbar (bold/italic/list/link/preview), FA-free via CSS glyphs, and
syncs codemirror.save() on change + capture-phase submit so the inline
required-field validator and payload see the live value.

Refs R7, R15, KTD2, KTD3.
This commit is contained in:
2026-07-04 15:40:28 +02:00
parent 87e216594e
commit 6282528dd8
8 changed files with 275 additions and 1 deletions
File diff suppressed because one or more lines are too long
File diff suppressed because one or more lines are too long
+21
View File
@@ -0,0 +1,21 @@
/*
* /post form bundle CSS. U3 scope: make the EasyMDE editor usable without the
* FontAwesome dependency (toolbar glyphs supplied here). Field Notes theming,
* mobile layout, and the "More options" disclosure land in U5.
*/
/* EasyMDE toolbar glyphs — replace the FontAwesome icons EasyMDE expects. */
.editor-toolbar .mde-btn::before {
font-style: normal;
font-weight: 400;
font-family: inherit;
}
.editor-toolbar .mde-bold::before { content: 'B'; font-weight: 700; }
.editor-toolbar .mde-italic::before { content: 'I'; font-style: italic; }
.editor-toolbar .mde-ul::before { content: '\2022\2002\2014'; } /* • — */
.editor-toolbar .mde-link::before { content: '\1F517'; } /* 🔗 */
.editor-toolbar .mde-preview::before { content: '\1F441'; } /* 👁 */
.EasyMDEContainer .CodeMirror {
min-height: 180px;
}
+63
View File
@@ -0,0 +1,63 @@
/*
* /post journal form bundle — page-scoped (loaded only by post-form.html.twig).
* Kept out of the global main.js so EasyMDE (+ the U4 HEIC converter, lazy-loaded)
* never ship on any other page. Built as ESM with code-splitting so the heic-to
* dynamic import becomes a separately-fetched chunk (see package.json / KTD2).
*
* Every init function presence-guards its own DOM, mirroring main.js's
* initTripStats pattern, so this is a no-op if an element is absent.
*/
import EasyMDE from 'easymde';
import 'easymde/dist/easymde.min.css';
import './post-form.css';
/* ── Markdown editor (EasyMDE) ───────────────────────────── */
function initEditor() {
var textarea = document.querySelector('textarea[name="data[content]"]');
if (!textarea) return null;
var editor = new EasyMDE({
element: textarea,
spellChecker: false,
autoDownloadFontAwesome: false, // toolbar glyphs come from post-form.css, no FA CDN
status: false,
placeholder: 'What happened today?',
minHeight: '180px',
// Minimal toolbar; custom classNames drive the CSS glyphs in post-form.css.
toolbar: [
{ name: 'bold', action: EasyMDE.toggleBold, className: 'mde-btn mde-bold', title: 'Bold' },
{ name: 'italic', action: EasyMDE.toggleItalic, className: 'mde-btn mde-italic', title: 'Italic' },
{ name: 'unordered-list', action: EasyMDE.toggleUnorderedList, className: 'mde-btn mde-ul', title: 'Bulleted list' },
{ name: 'link', action: EasyMDE.drawLink, className: 'mde-btn mde-link', title: 'Insert link' },
'|',
{ name: 'preview', action: EasyMDE.togglePreview, className: 'mde-btn mde-preview', title: 'Toggle preview' },
],
});
// KTD3 / R15: keep the underlying <textarea> in sync with the editor so the
// form's custom required-field validator (which reads [name="data[content]"])
// and the submitted payload both see the live value.
editor.codemirror.on('change', function () { editor.codemirror.save(); });
var form = textarea.closest('form');
if (form) {
// Capture phase fires before the inline bubble-phase validator, so the
// textarea already holds the current value when validation reads it.
form.addEventListener('submit', function () { editor.codemirror.save(); }, true);
}
return editor;
}
/* ── 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();
}
if (document.readyState === 'loading') {
document.addEventListener('DOMContentLoaded', boot);
} else {
boot();
}
+77
View File
@@ -8,6 +8,7 @@
"@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",
"maplibre-gl": "^4", "maplibre-gl": "^4",
"photoswipe": "^5", "photoswipe": "^5",
"scrollama": "^3" "scrollama": "^3"
@@ -523,6 +524,21 @@
"integrity": "sha512-RKJ22hX8mHe3Y6wH/N3wCM6BWtjaxIyyUIkpHOvfFnxdI4yD4tBXEBKSbriGujF6jnSVkJrffuo6vxACiSSxIw==", "integrity": "sha512-RKJ22hX8mHe3Y6wH/N3wCM6BWtjaxIyyUIkpHOvfFnxdI4yD4tBXEBKSbriGujF6jnSVkJrffuo6vxACiSSxIw==",
"license": "ISC" "license": "ISC"
}, },
"node_modules/@types/codemirror": {
"version": "5.60.17",
"resolved": "https://registry.npmjs.org/@types/codemirror/-/codemirror-5.60.17.tgz",
"integrity": "sha512-AZq2FIsUHVMlp7VSe2hTfl5w4pcUkoFkM3zVsRKsn1ca8CXRDYvnin04+HP2REkwsxemuHqvDofdlhUWNpbwfw==",
"license": "MIT",
"dependencies": {
"@types/tern": "*"
}
},
"node_modules/@types/estree": {
"version": "1.0.9",
"resolved": "https://registry.npmjs.org/@types/estree/-/estree-1.0.9.tgz",
"integrity": "sha512-GhdPgy1el4/ImP05X05Uw4cw2/M93BCUmnEvWZNStlCzEKME4Fkk+YpoA5OiHNQmoS7Cafb8Xa3Pya8m1Qrzeg==",
"license": "MIT"
},
"node_modules/@types/geojson": { "node_modules/@types/geojson": {
"version": "7946.0.16", "version": "7946.0.16",
"resolved": "https://registry.npmjs.org/@types/geojson/-/geojson-7946.0.16.tgz", "resolved": "https://registry.npmjs.org/@types/geojson/-/geojson-7946.0.16.tgz",
@@ -555,6 +571,12 @@
"@types/pbf": "*" "@types/pbf": "*"
} }
}, },
"node_modules/@types/marked": {
"version": "4.3.2",
"resolved": "https://registry.npmjs.org/@types/marked/-/marked-4.3.2.tgz",
"integrity": "sha512-a79Yc3TOk6dGdituy8hmTTJXjOkZ7zsFYV10L337ttq/rec8lRMDBpV7fL3uLx6TgbFCa5DU/h8FmIBQPSbU0w==",
"license": "MIT"
},
"node_modules/@types/pbf": { "node_modules/@types/pbf": {
"version": "3.0.5", "version": "3.0.5",
"resolved": "https://registry.npmjs.org/@types/pbf/-/pbf-3.0.5.tgz", "resolved": "https://registry.npmjs.org/@types/pbf/-/pbf-3.0.5.tgz",
@@ -570,6 +592,15 @@
"@types/geojson": "*" "@types/geojson": "*"
} }
}, },
"node_modules/@types/tern": {
"version": "0.23.9",
"resolved": "https://registry.npmjs.org/@types/tern/-/tern-0.23.9.tgz",
"integrity": "sha512-ypzHFE/wBzh+BlH6rrBgS5I/Z7RD21pGhZ2rltb/+ZrVM1awdZwjx7hE5XfuYgHWk9uvV5HLZN3SloevCAp3Bw==",
"license": "MIT",
"dependencies": {
"@types/estree": "*"
}
},
"node_modules/@xmldom/xmldom": { "node_modules/@xmldom/xmldom": {
"version": "0.8.13", "version": "0.8.13",
"resolved": "https://registry.npmjs.org/@xmldom/xmldom/-/xmldom-0.8.13.tgz", "resolved": "https://registry.npmjs.org/@xmldom/xmldom/-/xmldom-0.8.13.tgz",
@@ -585,6 +616,21 @@
"integrity": "sha512-E+XQCRwSbaaiChtv6k6Dwgc+bx+Bs6vuKJHHl5kox/BaKbhiXzqQOwK4cO22yElGp2OCmjwVhT3HmxgyPGnJfQ==", "integrity": "sha512-E+XQCRwSbaaiChtv6k6Dwgc+bx+Bs6vuKJHHl5kox/BaKbhiXzqQOwK4cO22yElGp2OCmjwVhT3HmxgyPGnJfQ==",
"license": "MIT" "license": "MIT"
}, },
"node_modules/codemirror": {
"version": "5.65.21",
"resolved": "https://registry.npmjs.org/codemirror/-/codemirror-5.65.21.tgz",
"integrity": "sha512-6teYk0bA0nR3QP0ihGMoxuKzpl5W80FpnHpBJpgy66NK3cZv5b/d/HY8PnRvfSsCG1MTfr92u2WUl+wT0E40mQ==",
"license": "MIT"
},
"node_modules/codemirror-spell-checker": {
"version": "1.1.2",
"resolved": "https://registry.npmjs.org/codemirror-spell-checker/-/codemirror-spell-checker-1.1.2.tgz",
"integrity": "sha512-2Tl6n0v+GJRsC9K3MLCdLaMOmvWL0uukajNJseorZJsslaxZyZMgENocPU8R0DyoTAiKsyqiemSOZo7kjGV0LQ==",
"license": "MIT",
"dependencies": {
"typo-js": "*"
}
},
"node_modules/concat-stream": { "node_modules/concat-stream": {
"version": "2.0.0", "version": "2.0.0",
"resolved": "https://registry.npmjs.org/concat-stream/-/concat-stream-2.0.0.tgz", "resolved": "https://registry.npmjs.org/concat-stream/-/concat-stream-2.0.0.tgz",
@@ -606,6 +652,19 @@
"integrity": "sha512-X7hshQbLyMJ/3RPhyObLARM2sNxxmRALLKx1+NVFFnQ9gKzmCrxm9+uLIAdBcvc8FNLpctqlQ2V6AE92Ol9UDQ==", "integrity": "sha512-X7hshQbLyMJ/3RPhyObLARM2sNxxmRALLKx1+NVFFnQ9gKzmCrxm9+uLIAdBcvc8FNLpctqlQ2V6AE92Ol9UDQ==",
"license": "ISC" "license": "ISC"
}, },
"node_modules/easymde": {
"version": "2.21.0",
"resolved": "https://registry.npmjs.org/easymde/-/easymde-2.21.0.tgz",
"integrity": "sha512-5uE7I/DEN8gvGRwxaqAv7h1PMEK2ykNXVX5zL0dK3nCYROGja3AMbdQz8eCEELnfvCfy7tRkTmLuvyJG8uSWjQ==",
"license": "MIT",
"dependencies": {
"@types/codemirror": "^5.60.10",
"@types/marked": "^4.0.7",
"codemirror": "^5.65.15",
"codemirror-spell-checker": "1.1.2",
"marked": "^4.1.0"
}
},
"node_modules/esbuild": { "node_modules/esbuild": {
"version": "0.21.5", "version": "0.21.5",
"resolved": "https://registry.npmjs.org/esbuild/-/esbuild-0.21.5.tgz", "resolved": "https://registry.npmjs.org/esbuild/-/esbuild-0.21.5.tgz",
@@ -796,6 +855,18 @@
"url": "https://github.com/maplibre/maplibre-gl-js?sponsor=1" "url": "https://github.com/maplibre/maplibre-gl-js?sponsor=1"
} }
}, },
"node_modules/marked": {
"version": "4.3.0",
"resolved": "https://registry.npmjs.org/marked/-/marked-4.3.0.tgz",
"integrity": "sha512-PRsaiG84bK+AMvxziE/lCFss8juXjNaWzVbN5tXAm4XjeaS9NAHhop+PjQxz2A9h8Q4M/xGmzP8vqNwy6JeK0A==",
"license": "MIT",
"bin": {
"marked": "bin/marked.js"
},
"engines": {
"node": ">= 12"
}
},
"node_modules/minimist": { "node_modules/minimist": {
"version": "1.2.8", "version": "1.2.8",
"resolved": "https://registry.npmjs.org/minimist/-/minimist-1.2.8.tgz", "resolved": "https://registry.npmjs.org/minimist/-/minimist-1.2.8.tgz",
@@ -936,6 +1007,12 @@
"integrity": "sha512-/aCDEGatGvZ2BIk+HmLf4ifCJFwvKFNb9/JeZPMulfgFracn9QFcAf5GO8B/mweUjSoblS5In0cWhqpfs/5PQA==", "integrity": "sha512-/aCDEGatGvZ2BIk+HmLf4ifCJFwvKFNb9/JeZPMulfgFracn9QFcAf5GO8B/mweUjSoblS5In0cWhqpfs/5PQA==",
"license": "MIT" "license": "MIT"
}, },
"node_modules/typo-js": {
"version": "1.3.2",
"resolved": "https://registry.npmjs.org/typo-js/-/typo-js-1.3.2.tgz",
"integrity": "sha512-Z1YkJ7IIYNrFeOxAlHUercY4Q2I+PhYD/3VkWpJGy/Oqudy3bFpNcQxnv6Oa9fTSXCHPGz1eDoX1bZYm2Z891A==",
"license": "BSD-3-Clause"
},
"node_modules/util-deprecate": { "node_modules/util-deprecate": {
"version": "1.0.2", "version": "1.0.2",
"resolved": "https://registry.npmjs.org/util-deprecate/-/util-deprecate-1.0.2.tgz", "resolved": "https://registry.npmjs.org/util-deprecate/-/util-deprecate-1.0.2.tgz",
+2 -1
View File
@@ -1,12 +1,13 @@
{ {
"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 && 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; }" "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; }"
}, },
"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",
"maplibre-gl": "^4", "maplibre-gl": "^4",
"photoswipe": "^5", "photoswipe": "^5",
"scrollama": "^3" "scrollama": "^3"
@@ -10,6 +10,7 @@
{% do assets.addJs('theme://js/main.js', {group: 'bottom'}) %} {% do assets.addJs('theme://js/main.js', {group: 'bottom'}) %}
{% do assets.addJs('theme://js/nav.js', {group: 'bottom'}) %} {% do assets.addJs('theme://js/nav.js', {group: 'bottom'}) %}
{% block map_assets %}{% endblock %} {% block map_assets %}{% endblock %}
{% block head_assets %}{% endblock %}
{{ assets.css()|raw }} {{ assets.css()|raw }}
{{ assets.js()|raw }} {{ assets.js()|raw }}
</head> </head>
@@ -1,5 +1,10 @@
{% extends 'default.html.twig' %} {% extends 'default.html.twig' %}
{% block head_assets %}
{% do assets.addCss('theme://css-compiled/post-form.css') %}
<script type="module" src="{{ url('theme://js/post/post-form.js') }}"></script>
{% endblock %}
{% block content %} {% block content %}
<div class="post-form-wrap"> <div class="post-form-wrap">
<h1>New Entry</h1> <h1>New Entry</h1>