Files
maptool/public/js/notes.js
T
randy 465a644365
CI / check (push) Successful in 40s
CI / deploy (push) Successful in 13s
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).
2026-09-09 13:03:42 +02:00

213 lines
7.5 KiB
JavaScript

import { NOTE_PIN_ICON } from "/js/icons.js";
import { notifyGpxStoreChanged } from "/js/panels.js";
const NEW_LAYER_VALUE = "__new__";
// Every hand-drawn note (from here or measure.js's "Save as note") gets one
// of these — the only stable way to find a specific feature again for
// editing/deleting later (see GpxStore#update_feature/#delete_feature):
// array position shifts as other notes are added/removed, and the note's
// own text is exactly the thing an edit changes.
export function generateNoteId() {
return typeof crypto !== "undefined" && crypto.randomUUID ? crypto.randomUUID() : `${Date.now()}-${Math.random().toString(36).slice(2)}`;
}
// The "note text + which .geojson layer" form, shared by NotesControl (a
// single click) and measure.js's "Save as note" button (its current
// distance/area shape) — extracted here rather than duplicated in both,
// since the two differ only in *what* the note attaches a location to,
// not in how it's captured or where it's saved. Renders into whatever
// container it's given (each caller owns where that sits in its own panel);
// hidden until open() is called.
export class NoteSaveForm {
constructor(container, { onSaved, onCancel } = {}) {
this._onSaved = onSaved;
this._onCancel = onCancel;
this._el = document.createElement("div");
this._el.className = "notes-panel__form";
this._el.hidden = true;
this._el.innerHTML = `
<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>
<input type="text" class="notes-panel__new-name" placeholder="Layer name" hidden>
<div class="notes-panel__actions">
<button type="button" class="notes-panel__save">Save</button>
<button type="button" class="notes-panel__cancel">Cancel</button>
</div>
`;
container.appendChild(this._el);
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");
this._target.addEventListener("change", () => {
this._newName.hidden = this._target.value !== NEW_LAYER_VALUE;
if (!this._newName.hidden) this._newName.focus();
});
this._saveButton.addEventListener("click", () => this._save());
this._el.querySelector(".notes-panel__cancel").addEventListener("click", () => this.close(true));
}
get isOpen() {
return !this._el.hidden;
}
async open(geometry) {
this._geometry = geometry;
this._el.hidden = false;
this._title.value = "";
this._body.value = "";
this._title.focus();
await this._populateTargets();
}
close(viaCancel = false) {
this._geometry = null;
this._el.hidden = true;
this._newName.hidden = true;
this._newName.value = "";
if (viaCancel) this._onCancel?.();
}
async _populateTargets() {
const previousValue = this._target.value;
let files = [];
try {
const tree = await (await fetch("/api/gpx/tree")).json();
files = geojsonFiles(tree);
} catch (err) {
// Tree fetch failed — target select just falls back to "new layer"
// only, still usable.
}
this._target.innerHTML =
files.map((file) => `<option value="${escapeAttr(file.path)}">${escapeText(file.path)}</option>`).join("") +
`<option value="${NEW_LAYER_VALUE}">+ New layer…</option>`;
this._target.value = files.some((file) => file.path === previousValue) ? previousValue : NEW_LAYER_VALUE;
this._newName.hidden = this._target.value !== NEW_LAYER_VALUE;
}
async _save() {
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() : "";
if (isNewLayer && !name) {
this._newName.focus();
return;
}
const feature = {
type: "Feature",
properties: {
id: generateNoteId(),
name: title,
category: "Note",
time: new Date().toISOString(),
...(details ? { description: details } : {}),
},
geometry: this._geometry,
};
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: requestBody });
if (!response.ok) throw new Error(`save failed: ${response.status}`);
notifyGpxStoreChanged();
this.close();
this._onSaved?.();
} catch (err) {
window.alert("Could not save the note — check your connection and try again.");
} finally {
this._saveButton.disabled = false;
}
}
}
// A quick "pin it with a note" tool — click the map, write a short note,
// pick which .geojson "layer" file it joins (or start a new one). Point
// only: for a distance/area shape, draw it with the Measure tool and use
// its own "Save as note" button instead of duplicating that click-to-place
// UX here — see measure.js.
export class NotesControl {
constructor() {
this._active = false;
}
onAdd(map) {
this._map = map;
this._container = document.createElement("div");
this._container.className = "maplibregl-ctrl maplibregl-ctrl-group notes-ctrl";
this._container.innerHTML = `
<button type="button" class="notes-ctrl__button" title="Add note" aria-label="Add note">${NOTE_PIN_ICON}</button>
<div class="notes-panel" hidden>
<p class="notes-panel__hint">Click the map to place a note.</p>
</div>
`;
this._button = this._container.querySelector(".notes-ctrl__button");
this._panel = this._container.querySelector(".notes-panel");
this._hint = this._container.querySelector(".notes-panel__hint");
this._form = new NoteSaveForm(this._panel, {
onSaved: () => (this._hint.hidden = false),
onCancel: () => (this._hint.hidden = false),
});
this._button.addEventListener("click", () => this._setActive(!this._active));
this._onClick = (event) => {
if (!this._active || this._form.isOpen) return;
this._hint.hidden = true;
this._form.open({ type: "Point", coordinates: [event.lngLat.lng, event.lngLat.lat] });
};
map.on("click", this._onClick);
return this._container;
}
onRemove() {
this._map.off("click", this._onClick);
this._container.remove();
}
getDefaultPosition() {
return "top-left";
}
_setActive(active) {
this._active = active;
this._button.classList.toggle("active", active);
this._panel.hidden = !active;
this._map.getCanvas().style.cursor = active ? "crosshair" : "";
if (!active) this._form.close();
}
}
function geojsonFiles(node, results = []) {
if (node.type === "file" && node.path.toLowerCase().endsWith(".geojson")) {
results.push({ path: node.path });
}
(node.children || []).forEach((child) => geojsonFiles(child, results));
return results;
}
function escapeAttr(value) {
return value.replace(/&/g, "&amp;").replace(/"/g, "&quot;");
}
function escapeText(value) {
return value.replace(/&/g, "&amp;").replace(/</g, "&lt;");
}