- The clicked location now stays marked (a plain maplibre-gl Marker, removed on save/cancel/deactivate) while the note form is open — before, nothing on the map showed where the pending note would land. - POI_ICONS gains a dozen more symbols (water, dump station, toilets, playground, viewpoint, swimming, fishing, climbing, hiking, …) via Object.assign after PARK4NIGHT_TAGS' own icon constants are declared (they're below POI_ICONS in the file, so the literal itself can't reference them directly) — reused as real marker-badge glyphs, not just PARK4NIGHT_TAGS' inline description-tag icons. NoteSaveForm gets a picker grid for these, shown only for a Point note (a Measure-drawn line/polygon has no one spot for a badge). - Fixed: .notes-panel__save's blue background was losing to .notes-panel__actions button's gray — same class-vs-class+element specificity trap .gpx-panel__action[hidden] already has a comment about elsewhere in this file. The button was never actually blue. Verified live (properly simulating a logged-in session this time, via Page.addScriptToEvaluateOnNewDocument rather than a post-load DOM poke, since NotesControl's mount decision happens once, synchronously, at page load): marker appears and clears correctly, 19 icons + "none" render and select, Save renders #1a5fb4. Full suite: 115 runs, 0 failures.
278 lines
10 KiB
JavaScript
278 lines
10 KiB
JavaScript
import { Marker } from "/vendor/maplibre-gl/maplibre-gl.mjs";
|
|
import { NOTE_PIN_ICON, POI_ICONS } from "/js/icons.js";
|
|
import { notifyGpxStoreChanged } from "/js/panels.js";
|
|
|
|
// Human labels for the icon picker's title attribute — POI_ICONS' own keys
|
|
// are internal (OSM-tag-shaped) ids, not display text.
|
|
const ICON_LABELS = {
|
|
amenity_parking: "Parking",
|
|
tourism_camp_site: "Camp site",
|
|
leisure_picnic_table: "Picnic table",
|
|
amenity_fuel: "Fuel",
|
|
tourism_hotel: "Lodging",
|
|
forest: "Forest",
|
|
sport_cycling: "Cycling",
|
|
amenity_drinking_water: "Drinking water",
|
|
amenity_sanitary_dump_station: "Dump station",
|
|
amenity_toilets: "Toilets",
|
|
shop_gas: "Gas/LPG refill",
|
|
amenity_electricity: "Electricity",
|
|
shop_laundry: "Laundry",
|
|
leisure_playground: "Playground",
|
|
tourism_viewpoint: "Viewpoint",
|
|
leisure_swimming: "Swimming",
|
|
leisure_fishing: "Fishing",
|
|
sport_climbing: "Climbing",
|
|
route_hiking: "Hiking",
|
|
};
|
|
|
|
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">
|
|
<div class="notes-panel__icons">
|
|
<button type="button" class="notes-panel__icon-option active" data-icon="" title="No icon">${NOTE_PIN_ICON}</button>
|
|
${Object.keys(POI_ICONS)
|
|
.map(
|
|
(key) =>
|
|
`<button type="button" class="notes-panel__icon-option" data-icon="${key}" title="${escapeAttr(ICON_LABELS[key] || key)}">${POI_ICONS[key]}</button>`,
|
|
)
|
|
.join("")}
|
|
</div>
|
|
<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._iconPicker = this._el.querySelector(".notes-panel__icons");
|
|
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._selectedIcon = "";
|
|
|
|
this._iconPicker.querySelectorAll(".notes-panel__icon-option").forEach((button) => {
|
|
button.addEventListener("click", () => {
|
|
this._selectedIcon = button.dataset.icon;
|
|
this._iconPicker.querySelectorAll(".notes-panel__icon-option").forEach((b) => b.classList.toggle("active", b === button));
|
|
});
|
|
});
|
|
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._selectedIcon = "";
|
|
this._iconPicker.querySelectorAll(".notes-panel__icon-option").forEach((b, i) => b.classList.toggle("active", i === 0));
|
|
// An icon only makes sense on a single point — Measure's "Save as
|
|
// note" can hand this a LineString/Polygon, which has no one spot for
|
|
// a badge glyph to sit at.
|
|
this._iconPicker.hidden = geometry.type !== "Point";
|
|
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 } : {}),
|
|
...(this._selectedIcon ? { icon: this._selectedIcon } : {}),
|
|
},
|
|
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;
|
|
this._marker = null; // where the pending note will land — visible until saved/cancelled
|
|
}
|
|
|
|
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;
|
|
this._removeMarker();
|
|
},
|
|
onCancel: () => {
|
|
this._hint.hidden = false;
|
|
this._removeMarker();
|
|
},
|
|
});
|
|
|
|
this._button.addEventListener("click", () => this._setActive(!this._active));
|
|
|
|
this._onClick = (event) => {
|
|
if (!this._active || this._form.isOpen) return;
|
|
this._hint.hidden = true;
|
|
this._marker = new Marker({ color: "#c0392b" }).setLngLat(event.lngLat).addTo(this._map);
|
|
this._form.open({ type: "Point", coordinates: [event.lngLat.lng, event.lngLat.lat] });
|
|
};
|
|
map.on("click", this._onClick);
|
|
|
|
return this._container;
|
|
}
|
|
|
|
_removeMarker() {
|
|
this._marker?.remove();
|
|
this._marker = null;
|
|
}
|
|
|
|
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();
|
|
this._removeMarker();
|
|
}
|
|
}
|
|
}
|
|
|
|
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(/</g, "<");
|
|
}
|