From 465a6443657d9a5eae412e200d49a2177afc4b88 Mon Sep 17 00:00:00 2001 From: Roman Priesol Date: Wed, 9 Sep 2026 13:03:42 +0200 Subject: [PATCH] Split notes into a title + body, with light self-authored markdown MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Both NoteSaveForm (create) and the popup's inline Edit form now have a title input (-> properties.name, shown in the black header, same as every other waypoint popup) and a separate body textarea (-> properties.description) for more detail than a single line. New formatNoteBody renders that body with a small self-rolled markdown subset — **bold**, *italic*, auto-linked URLs (reusing the existing linkify), newlines to
— deliberately a new function, not a change to formatDescription: that one renders uploaded GPX/park4night content, which is untrusted, and docs/nps-resources.adoc already has a specific call not to add markdown rendering to that path. A note's body is written by the logged-in viewer themselves, not uploaded, so that concern doesn't apply here — the escape-then-selectively-markup order (linkify first, since it does its own escaping, then wrap already-escaped text in real / tags) keeps it safe either way. Verified live: title renders in the popup header, **bold**/*italic*/ a URL in the body all render correctly. Full test suite: 115 runs, 0 failures (unaffected — description is just another property, not something the backend routes inspect). --- TODO.adoc | 1 + public/css/style.css | 21 +++++++++++++++++++-- public/js/gpx.js | 40 +++++++++++++++++++++++++++++++++------- public/js/notes.js | 32 +++++++++++++++++++++----------- 4 files changed, 74 insertions(+), 20 deletions(-) diff --git a/TODO.adoc b/TODO.adoc index 5824c1d..61e6ae1 100644 --- a/TODO.adoc +++ b/TODO.adoc @@ -45,6 +45,7 @@ independence from connectivity. ** [x] Edit/delete individual notes from their popup (logged-in only) — targets a feature by a client-generated `properties.id` (`GpxStore#update_feature`/`#delete_feature`), not array position ** [x] Measure's vertices are draggable (mousedown + move, `event.preventDefault()` to stop the map's own drag-pan) and deletable (click to select — shown in yellow — then Delete/Backspace) ** [x] Fixed: a note saved before `properties.id` existed (the earliest test notes) had no Edit/Delete-able target — the popup no longer offers them without an id, and `rake gpx:backfill_note_ids` assigns one to any pre-existing Note feature missing it +** [x] Title + body split (name in the black header, body in a separate field) with light self-authored markdown (`**bold**`, `*italic*`, auto-linked URLs) in the body — a *new*, separate `formatNoteBody` (not `formatDescription`, which stays plain/untrusted-safe for uploaded GPX/park4night content per docs/nps-resources.adoc's earlier markdown-rendering call) ** [x] Gated behind login: NotesControl isn't added to the map at all when logged out, Measure's "Save as note" button is hidden (Measure itself stays usable either way) ** [ ] The Unigrid Matrix category filter only re-slices the *points* source (`_applyCategoryFilter`) — toggling the "Note" category off hides point notes but not polygon/line ones sharing that category * [ ] Offline satellite tile caching (e.g. ESRI) for a selected route area — key for judging surface quality and obstacles when wild camping diff --git a/public/css/style.css b/public/css/style.css index 0f385bd..f5a4154 100644 --- a/public/css/style.css +++ b/public/css/style.css @@ -869,7 +869,8 @@ html, body { display: none; } -.notes-panel__text { +.notes-panel__title, +.notes-panel__body { width: 100%; box-sizing: border-box; font: inherit; @@ -877,6 +878,13 @@ html, body { padding: 4px 6px; border: 1px solid #ddd; border-radius: 4px; +} + +.notes-panel__title { + font-weight: 600; +} + +.notes-panel__body { resize: vertical; } @@ -1053,7 +1061,8 @@ html, body { color: #fff; } -.gpx-popup__edit-text { +.gpx-popup__edit-title, +.gpx-popup__edit-body { width: 100%; box-sizing: border-box; font: inherit; @@ -1061,6 +1070,14 @@ html, body { padding: 4px 6px; border: 1px solid #ddd; border-radius: 4px; + margin-bottom: 6px; +} + +.gpx-popup__edit-title { + font-weight: 600; +} + +.gpx-popup__edit-body { resize: vertical; } diff --git a/public/js/gpx.js b/public/js/gpx.js index 2b19e8d..9c288b1 100644 --- a/public/js/gpx.js +++ b/public/js/gpx.js @@ -742,23 +742,29 @@ export class GpxPanelControl { const content = popup.getElement().querySelector(".maplibregl-popup-content"); content.innerHTML = `
- + +
`; - const textarea = content.querySelector(".gpx-popup__edit-text"); - textarea.focus(); + const titleInput = content.querySelector(".gpx-popup__edit-title"); + const bodyInput = content.querySelector(".gpx-popup__edit-body"); + titleInput.focus(); content.querySelector(".gpx-popup__edit-cancel").addEventListener("click", () => { content.innerHTML = popupHtml(feature.properties, { editable: true }); this._wireNoteActions(popup, path, feature); }); content.querySelector(".gpx-popup__edit-save").addEventListener("click", async () => { - const text = textarea.value.trim(); - if (!text) return; - const updated = { ...feature, properties: { ...feature.properties, name: text } }; + const title = titleInput.value.trim(); + if (!title) return; + const details = bodyInput.value.trim(); + const properties = { ...feature.properties, name: title }; + if (details) properties.description = details; + else delete properties.description; + const updated = { ...feature, properties }; try { const body = new URLSearchParams({ path, id, feature: JSON.stringify(updated) }); const response = await fetch("/api/gpx/notes", { method: "PUT", body }); @@ -1085,7 +1091,13 @@ function popupHtml({ name, description, time, category, photos, icon }, { editab } if (category) parts.push(`

${escapeHtml(category)}

`); if (photos?.length > 0) parts.push(formatPhotos(photos)); - if (description) parts.push(formatDescription(description)); + // A Note's description is self-authored by the (logged-in) viewer, not + // uploaded/untrusted like every other description here — formatDescription + // is deliberately left alone for that untrusted case (see + // docs/nps-resources.adoc's note on why markdown rendering wasn't adopted + // for it); formatNoteBody is the separate, narrower path that's actually + // safe to add light markdown to. + if (description) parts.push(category === "Note" ? formatNoteBody(description) : formatDescription(description)); if (time) { const parsed = new Date(time); if (!Number.isNaN(parsed.getTime())) { @@ -1233,3 +1245,17 @@ function urlToLink(url) { const href = escapeHtml(core); return `${href}${escapeHtml(trailing)}`; } + +// A Note's body only — never an uploaded file's description (see the +// comment where this is called). linkify() runs first and does its own +// escaping, so what it returns is already safe HTML; **bold**/*italic* are +// then matched against that (already-escaped) text and wrapped in real +// tags — order matters: running it before linkify would have that same +// escaping re-encode the tags this just added. +function formatNoteBody(text) { + let html = linkify(text) + .replace(/\*\*(.+?)\*\*/g, "$1") + .replace(/\*(.+?)\*/g, "$1") + .replace(/\n/g, "
"); + return `

${html}

`; +} diff --git a/public/js/notes.js b/public/js/notes.js index 93eb141..a4ddc1a 100644 --- a/public/js/notes.js +++ b/public/js/notes.js @@ -28,7 +28,8 @@ export class NoteSaveForm { this._el.className = "notes-panel__form"; this._el.hidden = true; this._el.innerHTML = ` - + +
@@ -38,7 +39,8 @@ export class NoteSaveForm { `; container.appendChild(this._el); - this._text = this._el.querySelector(".notes-panel__text"); + this._title = this._el.querySelector(".notes-panel__title"); + this._body = this._el.querySelector(".notes-panel__body"); this._target = this._el.querySelector(".notes-panel__target"); this._newName = this._el.querySelector(".notes-panel__new-name"); this._saveButton = this._el.querySelector(".notes-panel__save"); @@ -58,8 +60,9 @@ export class NoteSaveForm { async open(geometry) { this._geometry = geometry; this._el.hidden = false; - this._text.value = ""; - this._text.focus(); + this._title.value = ""; + this._body.value = ""; + this._title.focus(); await this._populateTargets(); } @@ -90,8 +93,9 @@ export class NoteSaveForm { } async _save() { - const text = this._text.value.trim(); - if (!text || !this._geometry) return; + const title = this._title.value.trim(); + const details = this._body.value.trim(); + if (!title || !this._geometry) return; const isNewLayer = this._target.value === NEW_LAYER_VALUE; const name = isNewLayer ? this._newName.value.trim() : ""; @@ -102,17 +106,23 @@ export class NoteSaveForm { const feature = { type: "Feature", - properties: { id: generateNoteId(), name: text, category: "Note", time: new Date().toISOString() }, + properties: { + id: generateNoteId(), + name: title, + category: "Note", + time: new Date().toISOString(), + ...(details ? { description: details } : {}), + }, geometry: this._geometry, }; - const body = new URLSearchParams({ feature: JSON.stringify(feature) }); - if (isNewLayer) body.set("name", name); - else body.set("path", this._target.value); + const requestBody = new URLSearchParams({ feature: JSON.stringify(feature) }); + if (isNewLayer) requestBody.set("name", name); + else requestBody.set("path", this._target.value); this._saveButton.disabled = true; try { - const response = await fetch("/api/gpx/notes", { method: "POST", body }); + const response = await fetch("/api/gpx/notes", { method: "POST", body: requestBody }); if (!response.ok) throw new Error(`save failed: ${response.status}`); notifyGpxStoreChanged(); this.close();