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
@@ -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] 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] 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] 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)
|
** [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
|
** [ ] 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
|
* [ ] Offline satellite tile caching (e.g. ESRI) for a selected route area — key for judging surface quality and obstacles when wild camping
|
||||||
|
|||||||
+19
-2
@@ -869,7 +869,8 @@ html, body {
|
|||||||
display: none;
|
display: none;
|
||||||
}
|
}
|
||||||
|
|
||||||
.notes-panel__text {
|
.notes-panel__title,
|
||||||
|
.notes-panel__body {
|
||||||
width: 100%;
|
width: 100%;
|
||||||
box-sizing: border-box;
|
box-sizing: border-box;
|
||||||
font: inherit;
|
font: inherit;
|
||||||
@@ -877,6 +878,13 @@ html, body {
|
|||||||
padding: 4px 6px;
|
padding: 4px 6px;
|
||||||
border: 1px solid #ddd;
|
border: 1px solid #ddd;
|
||||||
border-radius: 4px;
|
border-radius: 4px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.notes-panel__title {
|
||||||
|
font-weight: 600;
|
||||||
|
}
|
||||||
|
|
||||||
|
.notes-panel__body {
|
||||||
resize: vertical;
|
resize: vertical;
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -1053,7 +1061,8 @@ html, body {
|
|||||||
color: #fff;
|
color: #fff;
|
||||||
}
|
}
|
||||||
|
|
||||||
.gpx-popup__edit-text {
|
.gpx-popup__edit-title,
|
||||||
|
.gpx-popup__edit-body {
|
||||||
width: 100%;
|
width: 100%;
|
||||||
box-sizing: border-box;
|
box-sizing: border-box;
|
||||||
font: inherit;
|
font: inherit;
|
||||||
@@ -1061,6 +1070,14 @@ html, body {
|
|||||||
padding: 4px 6px;
|
padding: 4px 6px;
|
||||||
border: 1px solid #ddd;
|
border: 1px solid #ddd;
|
||||||
border-radius: 4px;
|
border-radius: 4px;
|
||||||
|
margin-bottom: 6px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.gpx-popup__edit-title {
|
||||||
|
font-weight: 600;
|
||||||
|
}
|
||||||
|
|
||||||
|
.gpx-popup__edit-body {
|
||||||
resize: vertical;
|
resize: vertical;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
+33
-7
@@ -742,23 +742,29 @@ export class GpxPanelControl {
|
|||||||
const content = popup.getElement().querySelector(".maplibregl-popup-content");
|
const content = popup.getElement().querySelector(".maplibregl-popup-content");
|
||||||
content.innerHTML = `
|
content.innerHTML = `
|
||||||
<div class="gpx-popup__edit-form">
|
<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">
|
<div class="gpx-popup__edit-actions">
|
||||||
<button type="button" class="gpx-popup__edit-save">Save</button>
|
<button type="button" class="gpx-popup__edit-save">Save</button>
|
||||||
<button type="button" class="gpx-popup__edit-cancel">Cancel</button>
|
<button type="button" class="gpx-popup__edit-cancel">Cancel</button>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
`;
|
`;
|
||||||
const textarea = content.querySelector(".gpx-popup__edit-text");
|
const titleInput = content.querySelector(".gpx-popup__edit-title");
|
||||||
textarea.focus();
|
const bodyInput = content.querySelector(".gpx-popup__edit-body");
|
||||||
|
titleInput.focus();
|
||||||
content.querySelector(".gpx-popup__edit-cancel").addEventListener("click", () => {
|
content.querySelector(".gpx-popup__edit-cancel").addEventListener("click", () => {
|
||||||
content.innerHTML = popupHtml(feature.properties, { editable: true });
|
content.innerHTML = popupHtml(feature.properties, { editable: true });
|
||||||
this._wireNoteActions(popup, path, feature);
|
this._wireNoteActions(popup, path, feature);
|
||||||
});
|
});
|
||||||
content.querySelector(".gpx-popup__edit-save").addEventListener("click", async () => {
|
content.querySelector(".gpx-popup__edit-save").addEventListener("click", async () => {
|
||||||
const text = textarea.value.trim();
|
const title = titleInput.value.trim();
|
||||||
if (!text) return;
|
if (!title) return;
|
||||||
const updated = { ...feature, properties: { ...feature.properties, name: text } };
|
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 {
|
try {
|
||||||
const body = new URLSearchParams({ path, id, feature: JSON.stringify(updated) });
|
const body = new URLSearchParams({ path, id, feature: JSON.stringify(updated) });
|
||||||
const response = await fetch("/api/gpx/notes", { method: "PUT", body });
|
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 (category) parts.push(`<p class="gpx-popup__category">${escapeHtml(category)}</p>`);
|
||||||
if (photos?.length > 0) parts.push(formatPhotos(photos));
|
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) {
|
if (time) {
|
||||||
const parsed = new Date(time);
|
const parsed = new Date(time);
|
||||||
if (!Number.isNaN(parsed.getTime())) {
|
if (!Number.isNaN(parsed.getTime())) {
|
||||||
@@ -1233,3 +1245,17 @@ function urlToLink(url) {
|
|||||||
const href = escapeHtml(core);
|
const href = escapeHtml(core);
|
||||||
return `<a href="${href}" target="_blank" rel="noopener noreferrer">${href}</a>${escapeHtml(trailing)}`;
|
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>`;
|
||||||
|
}
|
||||||
+21
-11
@@ -28,7 +28,8 @@ export class NoteSaveForm {
|
|||||||
this._el.className = "notes-panel__form";
|
this._el.className = "notes-panel__form";
|
||||||
this._el.hidden = true;
|
this._el.hidden = true;
|
||||||
this._el.innerHTML = `
|
this._el.innerHTML = `
|
||||||
<textarea class="notes-panel__text" rows="2" placeholder="Note…"></textarea>
|
<input type="text" class="notes-panel__title" placeholder="Title">
|
||||||
|
<textarea class="notes-panel__body" rows="3" placeholder="Details (optional) — **bold**, *italic*, links"></textarea>
|
||||||
<select class="notes-panel__target"></select>
|
<select class="notes-panel__target"></select>
|
||||||
<input type="text" class="notes-panel__new-name" placeholder="Layer name" hidden>
|
<input type="text" class="notes-panel__new-name" placeholder="Layer name" hidden>
|
||||||
<div class="notes-panel__actions">
|
<div class="notes-panel__actions">
|
||||||
@@ -38,7 +39,8 @@ export class NoteSaveForm {
|
|||||||
`;
|
`;
|
||||||
container.appendChild(this._el);
|
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._target = this._el.querySelector(".notes-panel__target");
|
||||||
this._newName = this._el.querySelector(".notes-panel__new-name");
|
this._newName = this._el.querySelector(".notes-panel__new-name");
|
||||||
this._saveButton = this._el.querySelector(".notes-panel__save");
|
this._saveButton = this._el.querySelector(".notes-panel__save");
|
||||||
@@ -58,8 +60,9 @@ export class NoteSaveForm {
|
|||||||
async open(geometry) {
|
async open(geometry) {
|
||||||
this._geometry = geometry;
|
this._geometry = geometry;
|
||||||
this._el.hidden = false;
|
this._el.hidden = false;
|
||||||
this._text.value = "";
|
this._title.value = "";
|
||||||
this._text.focus();
|
this._body.value = "";
|
||||||
|
this._title.focus();
|
||||||
await this._populateTargets();
|
await this._populateTargets();
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -90,8 +93,9 @@ export class NoteSaveForm {
|
|||||||
}
|
}
|
||||||
|
|
||||||
async _save() {
|
async _save() {
|
||||||
const text = this._text.value.trim();
|
const title = this._title.value.trim();
|
||||||
if (!text || !this._geometry) return;
|
const details = this._body.value.trim();
|
||||||
|
if (!title || !this._geometry) return;
|
||||||
|
|
||||||
const isNewLayer = this._target.value === NEW_LAYER_VALUE;
|
const isNewLayer = this._target.value === NEW_LAYER_VALUE;
|
||||||
const name = isNewLayer ? this._newName.value.trim() : "";
|
const name = isNewLayer ? this._newName.value.trim() : "";
|
||||||
@@ -102,17 +106,23 @@ export class NoteSaveForm {
|
|||||||
|
|
||||||
const feature = {
|
const feature = {
|
||||||
type: "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,
|
geometry: this._geometry,
|
||||||
};
|
};
|
||||||
|
|
||||||
const body = new URLSearchParams({ feature: JSON.stringify(feature) });
|
const requestBody = new URLSearchParams({ feature: JSON.stringify(feature) });
|
||||||
if (isNewLayer) body.set("name", name);
|
if (isNewLayer) requestBody.set("name", name);
|
||||||
else body.set("path", this._target.value);
|
else requestBody.set("path", this._target.value);
|
||||||
|
|
||||||
this._saveButton.disabled = true;
|
this._saveButton.disabled = true;
|
||||||
try {
|
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}`);
|
if (!response.ok) throw new Error(`save failed: ${response.status}`);
|
||||||
notifyGpxStoreChanged();
|
notifyGpxStoreChanged();
|
||||||
this.close();
|
this.close();
|
||||||
|
|||||||
Reference in new issue
Block a user