Split notes into a title + body, with light self-authored markdown
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 <br> — 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 <strong>/<em> 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).
This commit is contained in:
1 parent
2a22906a68
commit
465a644365
4 files changed
+74
-20
No files matched your search
+33
-7
@@ -742,23 +742,29 @@ export class GpxPanelControl {
|
||||
const content = popup.getElement().querySelector(".maplibregl-popup-content");
|
||||
content.innerHTML = `
|
||||
<div class="gpx-popup__edit-form">
|
||||
<textarea class="gpx-popup__edit-text" rows="2">${escapeHtml(feature.properties.name || "")}</textarea>
|
||||
<input type="text" class="gpx-popup__edit-title" value="${escapeHtml(feature.properties.name || "")}">
|
||||
<textarea class="gpx-popup__edit-body" rows="3" placeholder="Details (optional) — **bold**, *italic*, links">${escapeHtml(feature.properties.description || "")}</textarea>
|
||||
<div class="gpx-popup__edit-actions">
|
||||
<button type="button" class="gpx-popup__edit-save">Save</button>
|
||||
<button type="button" class="gpx-popup__edit-cancel">Cancel</button>
|
||||
</div>
|
||||
</div>
|
||||
`;
|
||||
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(`<p class="gpx-popup__category">${escapeHtml(category)}</p>`);
|
||||
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 `<a href="${href}" target="_blank" rel="noopener noreferrer">${href}</a>${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, "<strong>$1</strong>")
|
||||
.replace(/\*(.+?)\*/g, "<em>$1</em>")
|
||||
.replace(/\n/g, "<br>");
|
||||
return `<p class="gpx-popup__description">${html}</p>`;
|
||||
}
|
||||
Reference in new issue
Block a user