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 = `
`; 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) => ``).join("") + ``; 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 = ` `; 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, "&").replace(/"/g, """); } function escapeText(value) { return value.replace(/&/g, "&").replace(/