From 1dbe3bc01f1fbf0371d5f0621231f3ea90154e85 Mon Sep 17 00:00:00 2001 From: Thomas Schmidt Date: Sat, 19 Sep 2026 16:43:30 +0200 Subject: [PATCH 1/3] allow to use features description as map banner --- app/assets/stylesheets/map.css | 12 ++ .../controllers/feature/edit_controller.js | 10 ++ .../controllers/feature/modal_controller.js | 4 +- app/javascript/maplibre/layers/geojson.js | 7 + .../maplibre/layers/geojson/desc_banners.js | 77 +++++++++++ app/javascript/maplibre/map.js | 5 + app/views/maps/modals/feature/_edit_ui.haml | 3 + docs/tutorials/geojson_style_spec.md | 1 + locale/app.pot | 130 +++++++++--------- locale/de/app.po | 3 + spec/features/feature_edit_point_spec.rb | 12 ++ 11 files changed, 200 insertions(+), 64 deletions(-) create mode 100644 app/javascript/maplibre/layers/geojson/desc_banners.js diff --git a/app/assets/stylesheets/map.css b/app/assets/stylesheets/map.css index 6db32fb2..645a8e1b 100644 --- a/app/assets/stylesheets/map.css +++ b/app/assets/stylesheets/map.css @@ -400,6 +400,18 @@ body:has(#map-mode-badge) .layer-popover { opacity: 0.9 !important; } +/* --scale follows marker-scaling, set in desc_banners.js. Maplibre positions the outer + .maplibregl-popup with its own transform, so the scale goes on the content instead. */ +.desc-banner .maplibregl-popup-content { + transform: scale(var(--scale, 1)); + transform-origin: bottom center; +} + +/* marked wraps the text in

, its bootstrap margin would leave an empty strip at the bottom */ +.desc-banner .maplibregl-popup-content > :last-child { + margin-bottom: 0; +} + .elevation-marker { border-radius: 50%; width: 20px; diff --git a/app/javascript/controllers/feature/edit_controller.js b/app/javascript/controllers/feature/edit_controller.js index decdddeb..46709478 100644 --- a/app/javascript/controllers/feature/edit_controller.js +++ b/app/javascript/controllers/feature/edit_controller.js @@ -254,6 +254,16 @@ export default class extends Controller { this.renderFeature({ refreshKmMarkers: true }) } + updateShowDescOnMap () { + const feature = this.getEditFeature() + if (document.querySelector('#feature-show-desc-on-map').checked) { + feature.properties['show-desc'] = true + } else { + delete feature.properties['show-desc'] + } + this.renderFeature() + } + updateShape (e) { const feature = this.getEditFeature() const shape = e.currentTarget.dataset.shape diff --git a/app/javascript/controllers/feature/modal_controller.js b/app/javascript/controllers/feature/modal_controller.js index db0310f0..4075234c 100644 --- a/app/javascript/controllers/feature/modal_controller.js +++ b/app/javascript/controllers/feature/modal_controller.js @@ -13,7 +13,7 @@ import { syncBackground, syncMarkerContent, syncShapeButtons } from 'maplibre/feature' import { EXTRAS_COLOR_CONFIGS } from 'maplibre/layers/geojson/route_extras' -import { getFeature, layers } from 'maplibre/layers/layers' +import { applyFeatureUpdate, getFeature, layers } from 'maplibre/layers/layers' import { convertToRoute } from 'maplibre/routing/gpx_to_route' import { routingEnabled } from 'maplibre/routing/openrouteservice' import { defaultLineWidth, defaultPointSize, defaults } from 'maplibre/styles/defaults' @@ -104,6 +104,7 @@ export default class extends Controller { // init ui input elements document.querySelector('#feature-title-input input').value = feature.properties.title || null document.querySelector('#feature-show-title-on-map').checked = !!feature.properties.label + document.querySelector('#feature-show-desc-on-map').checked = !!feature.properties['show-desc'] dom.hideElements(['.edit-point', '.edit-line', '.edit-polygon']) @@ -319,6 +320,7 @@ export default class extends Controller { try { if (easyMDE && feature.properties.desc !== easyMDE.value()) { feature.properties.desc = easyMDE.value() + if (feature.properties['show-desc']) { applyFeatureUpdate(feature) } functions.debounce(() => { this.saveFeature() }, 'desc', 2000) } } catch (error) { diff --git a/app/javascript/maplibre/layers/geojson.js b/app/javascript/maplibre/layers/geojson.js index a0b9c891..5655f02c 100644 --- a/app/javascript/maplibre/layers/geojson.js +++ b/app/javascript/maplibre/layers/geojson.js @@ -1,4 +1,5 @@ import { draw, select } from 'maplibre/edit' +import { removeDescBanner, renderDescBanners, syncDescBanner } from 'maplibre/layers/geojson/desc_banners' import { buildLineExtrusion } from 'maplibre/layers/geojson/extrusion' import { removeFeatureImageOverlay, @@ -84,6 +85,7 @@ export class GeoJSONLayer extends Layer { removeGeoJSONSource(this.routeExtrasSourceId) removeGeoJSONSource(this.extrusionSourceId) renderImageOverlays([], this.id) + renderDescBanners([], this.id) } initialize() { @@ -137,6 +139,7 @@ export class GeoJSONLayer extends Layer { applyKmMarkerLevelFilter(this.kmMarkerSourceId) applyRouteExtrasLevelFilter(this.routeExtrasSourceId) renderImageOverlays(this.layer.geojson?.features || [], this.id, this.show !== false) + renderDescBanners(this.layer.geojson?.features || [], this.id, this.show !== false) } // setData(url) lets MapLibre fetch AND parse the features in its web worker (off the main @@ -202,6 +205,7 @@ export class GeoJSONLayer extends Layer { renderRouteExtras(features, this.routeExtrasSourceId) this.renderExtrusionLines(features) renderImageOverlays(features, this.id, this.show !== false) + renderDescBanners(features, this.id, this.show !== false) if (sourceLoaded) { // MapLibre's URL load already holds exactly this set; don't re-parse it. @@ -280,6 +284,7 @@ export class GeoJSONLayer extends Layer { } syncImageOverlay(feature, this.id, this.show !== false) + syncDescBanner(feature, this.id, this.show !== false) // Keep the MapboxDraw overlay in sync for geometry edits (no-op when nothing is in draw). if (resetDraw) { this.resetDrawFeatures(true) } @@ -309,6 +314,7 @@ export class GeoJSONLayer extends Layer { } syncImageOverlay(feature, this.id, this.show !== false) + syncDescBanner(feature, this.id, this.show !== false) } // Surgically remove a feature from this layer's source without a full render(). See @@ -332,6 +338,7 @@ export class GeoJSONLayer extends Layer { } removeFeatureImageOverlay(feature, this.id) + removeDescBanner(feature, this.id) // Cheap regardless of draw's contents, so always keep it in sync (e.g. a feature deleted // remotely while selected locally). diff --git a/app/javascript/maplibre/layers/geojson/desc_banners.js b/app/javascript/maplibre/layers/geojson/desc_banners.js new file mode 100644 index 00000000..1f57a094 --- /dev/null +++ b/app/javascript/maplibre/layers/geojson/desc_banners.js @@ -0,0 +1,77 @@ +import { sanitizeMarkdown } from 'helpers/functions' +import { featureOnLevel } from 'maplibre/controls/levels' +import { map } from 'maplibre/map' +import { defaultPointSize } from 'maplibre/styles/defaults' +import { marked } from 'marked' + +// A point with 'show-desc' carries its markdown description as an HTML popup pinned above the +// marker. Popups are DOM elements, not style layers, so this module keeps them by hand, keyed +// by the geojson layer id like the image overlays. +const banners = new Map() +const prefix = layerId => `${layerId}/` +const key = (layerId, feature) => prefix(layerId) + feature.id + +export const hasDescBanner = feature => feature.geometry?.type === 'Point' && + !!feature.properties?.['show-desc'] && !!feature.properties?.desc + +export function renderDescBanners (features, layerId, visible = true) { + const wanted = new Set() + features.filter(hasDescBanner).forEach(feature => { + wanted.add(key(layerId, feature)) + upsertDescBanner(feature, layerId, visible) + }) + Array.from(banners.keys()) + .filter(id => id.startsWith(prefix(layerId)) && !wanted.has(id)) + .forEach(removeBanner) +} + +export function syncDescBanner (feature, layerId, visible = true) { + if (hasDescBanner(feature)) { + upsertDescBanner(feature, layerId, visible) + } else { + removeBanner(key(layerId, feature)) + } +} + +export function removeDescBanner (feature, layerId) { + removeBanner(key(layerId, feature)) +} + +export function rescaleDescBanners () { + banners.forEach(({ popup, feature }) => scaleBanner(popup, feature)) +} + +function upsertDescBanner (feature, layerId, visible) { + marked.use({ gfm: true, breaks: true }) + const html = sanitizeMarkdown(marked(feature.properties.desc)) + let popup = banners.get(key(layerId, feature))?.popup + if (!popup) { + // focusAfterOpen would pull the focus out of the description editor on every keystroke + popup = new window.maplibregl.Popup({ + closeButton: false, closeOnClick: false, focusAfterOpen: false, anchor: 'bottom', className: 'desc-banner', + maxWidth: '40rem' + }).setLngLat(feature.geometry.coordinates).addTo(map) + } + banners.set(key(layerId, feature), { popup, feature }) + popup.setLngLat(feature.geometry.coordinates).setHTML(html) + scaleBanner(popup, feature) + popup.getElement().classList.toggle('hidden', !(visible && featureOnLevel(feature))) +} + +// Same curve as shapeIconSize in styles.js: 1x at zoom 16, doubling with each zoom level. +// A plain circle ignores marker-scaling (see pointSize in styles.js), so its offset stays put. +function scaleBanner (popup, feature) { + const p = feature.properties + const radius = Number(p['marker-size'] || defaultPointSize(feature)) + const zoomFactor = p['marker-scaling'] ? 2 ** (map.getZoom() - 16) : 1 + const plainCircle = !p['marker-symbol'] && !p['marker-image-url'] && (p['marker-shape'] || 'circle') === 'circle' + // marker-size is the radius, so the arrow tip sits on the top edge of the marker + popup.setOffset(radius * (plainCircle ? 1 : zoomFactor)) + // a marker at its default size gets the banner at 1x, twice the size doubles the banner + popup.getElement().style.setProperty('--scale', radius / defaultPointSize(feature) * zoomFactor) +} + +function removeBanner (id) { + banners.get(id)?.popup.remove() + banners.delete(id) +} diff --git a/app/javascript/maplibre/map.js b/app/javascript/maplibre/map.js index c6591084..abe65e58 100644 --- a/app/javascript/maplibre/map.js +++ b/app/javascript/maplibre/map.js @@ -14,6 +14,7 @@ import { hideModals, initCtrlTooltips, initializeDefaultControls, initSettingsMo import { initializeViewControls, removeViewControls } from 'maplibre/controls/view'; import { initializeEditMode, resetEditMode } from 'maplibre/edit'; import { featureLabel, highlightFeature, resetHighlightedFeature } from 'maplibre/feature'; +import { renderDescBanners, rescaleDescBanners } from 'maplibre/layers/geojson/desc_banners'; import { applyFeatureUpdate, getFeature, getLayer, initializeLayers, initializeLayerSources, initializeLayerStyles, layers } from 'maplibre/layers/layers'; import { basemaps, demSource, elevationSource } from 'maplibre/styles/basemaps'; import { applyBasemapDefaults, defaults } from 'maplibre/styles/defaults'; @@ -237,6 +238,7 @@ export async function initializeMap (divId = 'maplibre-map') { }) map.on('zoom', (e) => { limitZoom() + rescaleDescBanners() if (e.originalEvent) { hideModals() } // ignore programmatic zoom (e.g. initial zoom-in effect) }) map.on('online', (_e) => { functions.e('#maplibre-map', e => { e.setAttribute('data-online', true) }) }) @@ -381,6 +383,9 @@ export function setLayerVisibility(sourceName, visible) { if (map.getLayer(l.id)) map.setLayoutProperty(l.id, 'visibility', visible ? 'visible' : 'none') }) } + // description banners are DOM popups, not style layers, see desc_banners.js + const layer = layers.find(l => l.sourceId === sourceName) + if (layer?.type === 'geojson') { renderDescBanners(layer.geojson?.features || [], layer.id, visible) } } export function removeGeoJSONSource(sourceName) { diff --git a/app/views/maps/modals/feature/_edit_ui.haml b/app/views/maps/modals/feature/_edit_ui.haml index f6d31dc7..305a5b59 100644 --- a/app/views/maps/modals/feature/_edit_ui.haml +++ b/app/views/maps/modals/feature/_edit_ui.haml @@ -25,6 +25,9 @@ #feature-desc-section.feature-section-card.hidden #feature-desc %textarea#feature-desc-input + .edit-ui.ms-2.edit-point + %input#feature-show-desc-on-map{ type: "checkbox", data: { action: "input->feature--edit#addUndo input->feature--edit#updateShowDescOnMap change->feature--edit#saveFeature" } } + %label{ for: "feature-show-desc-on-map"}= _("Show description on map") .feature-edit-section.hidden{ 'data-edit-section': "style" } .feature-section-card.pt-2 diff --git a/docs/tutorials/geojson_style_spec.md b/docs/tutorials/geojson_style_spec.md index 4f016c41..7a51ed32 100644 --- a/docs/tutorials/geojson_style_spec.md +++ b/docs/tutorials/geojson_style_spec.md @@ -44,6 +44,7 @@ Extending the [Mapbox Simplestyle Spec](https://github.com/mapbox/simplestyle-sp * `heatmap`: if set, points will be styled as a heatmap * `flat`: if set, marker + label with be projected 'flat' on the map * `marker-rotate`: rotate marker by x degrees clockwise +* `show-desc`: render `desc` as an HTML banner above the marker, scaled by `marker-scaling` (default: false) #### *LineString* geometry: diff --git a/locale/app.pot b/locale/app.pot index 20a73643..e1cfe0dd 100644 --- a/locale/app.pot +++ b/locale/app.pot @@ -8,8 +8,8 @@ msgid "" msgstr "" "Project-Id-Version: app 1.0.0\n" "Report-Msgid-Bugs-To: \n" -"POT-Creation-Date: 2026-09-17 23:52+0200\n" -"PO-Revision-Date: 2026-09-17 23:52+0200\n" +"POT-Creation-Date: 2026-09-19 00:07+0200\n" +"PO-Revision-Date: 2026-09-19 00:07+0200\n" "Last-Translator: FULL NAME \n" "Language-Team: LANGUAGE \n" "Language: \n" @@ -26,7 +26,7 @@ msgstr "" msgid "%{count} views" msgstr "" -#: ../app/javascript/maplibre/map.js:613 ../app/javascript/maplibre/undo.js:58 +#: ../app/javascript/maplibre/map.js:618 ../app/javascript/maplibre/undo.js:58 msgid "%{type} added" msgstr "" @@ -34,7 +34,7 @@ msgstr "" msgid "%{type} analysis" msgstr "" -#: ../app/javascript/controllers/feature/modal_controller.js:401 ../app/javascript/controllers/map/context_menu_controller.js:125 +#: ../app/javascript/controllers/feature/modal_controller.js:403 ../app/javascript/controllers/map/context_menu_controller.js:125 msgid "%{type} copied to clipboard" msgstr "" @@ -50,7 +50,7 @@ msgstr "" msgid "%{type} update" msgstr "" -#: ../app/javascript/maplibre/map.js:641 +#: ../app/javascript/maplibre/map.js:646 msgid "%{type} updated" msgstr "" @@ -82,7 +82,7 @@ msgstr "" msgid "Add OpenStreetMap layers" msgstr "" -#: ../app/javascript/controllers/feature/modal_controller.js:304 ../app/javascript/controllers/map/settings_controller.js:209 +#: ../app/javascript/controllers/feature/modal_controller.js:305 ../app/javascript/controllers/map/settings_controller.js:209 msgid "Add a description text" msgstr "" @@ -158,7 +158,7 @@ msgstr "" msgid "Background map" msgstr "" -#: ../app/views/maps/modals/feature/_edit_ui.haml:81 +#: ../app/views/maps/modals/feature/_edit_ui.haml:84 msgid "Background:" msgstr "" @@ -194,7 +194,7 @@ msgstr "" msgid "Big dots" msgstr "" -#: ../app/views/maps/modals/feature/_edit_ui.haml:194 +#: ../app/views/maps/modals/feature/_edit_ui.haml:197 msgid "Bike" msgstr "" @@ -202,11 +202,11 @@ msgstr "" msgid "Bike route" msgstr "" -#: ../app/views/maps/modals/feature/_edit_ui.haml:63 +#: ../app/views/maps/modals/feature/_edit_ui.haml:66 msgid "Border color:" msgstr "" -#: ../app/views/maps/modals/feature/_edit_ui.haml:50 +#: ../app/views/maps/modals/feature/_edit_ui.haml:53 msgid "Border width:" msgstr "" @@ -250,7 +250,7 @@ msgstr "" msgid "Can Edit" msgstr "" -#: ../app/views/maps/modals/feature/_edit_ui.haml:197 +#: ../app/views/maps/modals/feature/_edit_ui.haml:200 msgid "Car" msgstr "" @@ -278,7 +278,7 @@ msgstr "" msgid "Choose your base map: Streets, Satellite, Topographic, Biking, 3D houses etc." msgstr "" -#: ../app/views/maps/modals/feature/_edit_ui.haml:123 +#: ../app/views/maps/modals/feature/_edit_ui.haml:126 msgid "Circle" msgstr "" @@ -314,11 +314,11 @@ msgstr "" msgid "Collaboration" msgstr "" -#: ../app/views/maps/modals/feature/_edit_ui.haml:65 +#: ../app/views/maps/modals/feature/_edit_ui.haml:68 msgid "Color" msgstr "" -#: ../app/views/maps/modals/feature/_edit_ui.haml:72 +#: ../app/views/maps/modals/feature/_edit_ui.haml:75 msgid "Color:" msgstr "" @@ -346,7 +346,7 @@ msgstr "" msgid "Connection error" msgstr "" -#: ../app/javascript/maplibre/map.js:495 +#: ../app/javascript/maplibre/map.js:499 msgid "Contour lines added to map" msgstr "" @@ -358,7 +358,7 @@ msgstr "" msgid "Contributing" msgstr "" -#: ../app/views/maps/modals/feature/_edit_ui.haml:183 +#: ../app/views/maps/modals/feature/_edit_ui.haml:186 msgid "Convert to route" msgstr "" @@ -370,7 +370,7 @@ msgstr "" msgid "Converting track to %{profile} route..." msgstr "" -#: ../app/javascript/controllers/feature/modal_controller.js:408 ../app/javascript/controllers/map/settings_controller.js:57 +#: ../app/javascript/controllers/feature/modal_controller.js:410 ../app/javascript/controllers/map/settings_controller.js:57 msgid "Coordinates copied to clipboard" msgstr "" @@ -382,7 +382,7 @@ msgstr "" msgid "Copy %{type}" msgstr "" -#: ../app/views/maps/modals/feature/_edit_ui.haml:187 +#: ../app/views/maps/modals/feature/_edit_ui.haml:190 msgid "Copy and transform this line into a routed track with waypoints, elevation and surface info." msgstr "" @@ -466,7 +466,7 @@ msgstr "" msgid "Default view when opening this map." msgstr "" -#: ../app/views/maps/modals/_layers.haml:181 ../app/views/maps/modals/feature/_edit_ui.haml:220 ../app/javascript/maplibre/controls/context_menu.js:157 +#: ../app/views/maps/modals/_layers.haml:181 ../app/views/maps/modals/feature/_edit_ui.haml:223 ../app/javascript/maplibre/controls/context_menu.js:157 msgid "Delete" msgstr "" @@ -474,7 +474,7 @@ msgstr "" msgid "Delete Layer" msgstr "" -#: ../app/views/maps/modals/feature/_edit_ui.haml:218 +#: ../app/views/maps/modals/feature/_edit_ui.haml:221 msgid "Delete feature" msgstr "" @@ -486,7 +486,7 @@ msgstr "" msgid "Delete midpoint" msgstr "" -#: ../app/javascript/maplibre/map.js:651 +#: ../app/javascript/maplibre/map.js:656 msgid "Deleting %{type}" msgstr "" @@ -518,7 +518,7 @@ msgstr "" msgid "Discuss" msgstr "" -#: ../app/views/maps/modals/feature/_edit_ui.haml:175 +#: ../app/views/maps/modals/feature/_edit_ui.haml:178 msgid "Distance markers:" msgstr "" @@ -574,7 +574,7 @@ msgstr "" msgid "Drag the point to move it" msgstr "" -#: ../app/javascript/maplibre/map.js:129 +#: ../app/javascript/maplibre/map.js:130 msgid "Drag to rotate map, click to reset north" msgstr "" @@ -674,7 +674,7 @@ msgstr "" msgid "Error detecting location: %{message}" msgstr "" -#: ../app/javascript/controllers/feature/edit_controller.js:81 ../app/javascript/controllers/feature/modal_controller.js:326 +#: ../app/javascript/controllers/feature/edit_controller.js:81 ../app/javascript/controllers/feature/modal_controller.js:328 msgid "Error updating %{type}" msgstr "" @@ -714,15 +714,15 @@ msgstr "" msgid "File imported" msgstr "" -#: ../app/views/maps/modals/feature/_edit_ui.haml:87 +#: ../app/views/maps/modals/feature/_edit_ui.haml:90 msgid "Fill" msgstr "" -#: ../app/views/maps/modals/feature/_edit_ui.haml:83 +#: ../app/views/maps/modals/feature/_edit_ui.haml:86 msgid "Fill color" msgstr "" -#: ../app/javascript/maplibre/map.js:125 +#: ../app/javascript/maplibre/map.js:126 msgid "Find my location" msgstr "" @@ -750,7 +750,7 @@ msgstr "" msgid "Following position" msgstr "" -#: ../app/views/maps/modals/feature/_edit_ui.haml:191 +#: ../app/views/maps/modals/feature/_edit_ui.haml:194 msgid "Foot" msgstr "" @@ -770,7 +770,7 @@ msgstr "" msgid "Geometry" msgstr "" -#: ../app/views/maps/modals/feature/_edit_ui.haml:212 +#: ../app/views/maps/modals/feature/_edit_ui.haml:215 msgid "Geometry JSON" msgstr "" @@ -806,7 +806,7 @@ msgstr "" msgid "Hatch" msgstr "" -#: ../app/views/maps/modals/feature/_edit_ui.haml:166 +#: ../app/views/maps/modals/feature/_edit_ui.haml:169 msgid "Height:" msgstr "" @@ -834,7 +834,7 @@ msgstr "" msgid "Hiking route" msgstr "" -#: ../app/javascript/maplibre/map.js:433 +#: ../app/javascript/maplibre/map.js:437 msgid "Hillshade added to map" msgstr "" @@ -862,15 +862,15 @@ msgstr "" msgid "Ice" msgstr "" -#: ../app/views/maps/modals/feature/_edit_ui.haml:146 ../app/views/maps/modals/feature/_edit_ui.haml:151 +#: ../app/views/maps/modals/feature/_edit_ui.haml:149 ../app/views/maps/modals/feature/_edit_ui.haml:154 msgid "Icon" msgstr "" -#: ../app/views/maps/modals/_image_viewer.haml:2 ../app/views/maps/modals/feature/_edit_ui.haml:94 ../app/views/maps/modals/feature/_edit_ui.haml:158 +#: ../app/views/maps/modals/_image_viewer.haml:2 ../app/views/maps/modals/feature/_edit_ui.haml:97 ../app/views/maps/modals/feature/_edit_ui.haml:161 msgid "Image" msgstr "" -#: ../app/javascript/controllers/feature/edit_controller.js:398 ../app/javascript/controllers/feature/edit_controller.js:470 +#: ../app/javascript/controllers/feature/edit_controller.js:408 ../app/javascript/controllers/feature/edit_controller.js:480 msgid "Image exceeds 15MB" msgstr "" @@ -958,7 +958,7 @@ msgstr "" msgid "Line Mode: Click on the map to draw a line" msgstr "" -#: ../app/views/maps/modals/feature/_edit_ui.haml:62 +#: ../app/views/maps/modals/feature/_edit_ui.haml:65 msgid "Line color:" msgstr "" @@ -978,7 +978,7 @@ msgstr "" msgid "Load for this area" msgstr "" -#: ../app/javascript/maplibre/map.js:720 +#: ../app/javascript/maplibre/map.js:725 msgid "Loaded base map %{name}" msgstr "" @@ -1002,7 +1002,7 @@ msgstr "" msgid "Location (https only)" msgstr "" -#: ../app/javascript/maplibre/map.js:126 +#: ../app/javascript/maplibre/map.js:127 msgid "Location not available" msgstr "" @@ -1018,11 +1018,11 @@ msgstr "" msgid "Logout" msgstr "" -#: ../app/views/maps/modals/_share.haml:82 ../app/javascript/maplibre/map.js:127 +#: ../app/views/maps/modals/_share.haml:82 ../app/javascript/maplibre/map.js:128 msgid "Map" msgstr "" -#: ../app/views/maps/_map.haml:34 ../app/views/maps/_map.haml:36 +#: ../app/views/maps/_map.haml:31 ../app/views/maps/_map.haml:36 msgid "Map '%{name}'" msgstr "" @@ -1042,7 +1042,7 @@ msgstr "" msgid "Map layers" msgstr "" -#: ../app/javascript/maplibre/map.js:128 +#: ../app/javascript/maplibre/map.js:129 msgid "Map marker" msgstr "" @@ -1050,7 +1050,7 @@ msgstr "" msgid "Map owners have this map listed in their account with full permissions." msgstr "" -#: ../app/javascript/maplibre/map.js:75 +#: ../app/javascript/maplibre/map.js:76 msgid "Map properties updated" msgstr "" @@ -1086,11 +1086,11 @@ msgstr "" msgid "Mapforge: Create and share your own maps online" msgstr "" -#: ../app/views/maps/modals/feature/_edit_ui.haml:153 ../app/javascript/maplibre/feature.js:369 +#: ../app/views/maps/modals/feature/_edit_ui.haml:155 ../app/javascript/maplibre/feature.js:369 msgid "Max. 5MB" msgstr "" -#: ../app/javascript/maplibre/map.js:310 +#: ../app/javascript/maplibre/map.js:311 msgid "Maximum zoom level %{zoom} reached" msgstr "" @@ -1106,7 +1106,7 @@ msgstr "" msgid "Minimalist, neutral vector style with subtle colors and light labels." msgstr "" -#: ../app/javascript/maplibre/map.js:314 +#: ../app/javascript/maplibre/map.js:315 msgid "Minimum zoom level %{zoom} reached" msgstr "" @@ -1166,7 +1166,7 @@ msgstr "" msgid "No route found" msgstr "" -#: ../app/views/maps/modals/feature/_edit_ui.haml:141 ../app/views/maps/modals/feature/_edit_ui.haml:145 +#: ../app/views/maps/modals/feature/_edit_ui.haml:144 ../app/views/maps/modals/feature/_edit_ui.haml:148 msgid "None" msgstr "" @@ -1182,7 +1182,7 @@ msgstr "" msgid "Only LineString tracks can be converted to routes" msgstr "" -#: ../app/views/maps/modals/feature/_edit_ui.haml:112 +#: ../app/views/maps/modals/feature/_edit_ui.haml:115 msgid "Opacity:" msgstr "" @@ -1226,7 +1226,7 @@ msgstr "" msgid "Paste %{type}" msgstr "" -#: ../app/views/maps/modals/feature/_edit_ui.haml:97 ../app/views/maps/modals/feature/_edit_ui.haml:102 +#: ../app/views/maps/modals/feature/_edit_ui.haml:100 ../app/views/maps/modals/feature/_edit_ui.haml:105 msgid "Pattern" msgstr "" @@ -1238,7 +1238,7 @@ msgstr "" msgid "Paving stones" msgstr "" -#: ../app/views/maps/modals/feature/_edit_ui.haml:127 +#: ../app/views/maps/modals/feature/_edit_ui.haml:130 msgid "Pin" msgstr "" @@ -1254,7 +1254,7 @@ msgstr "" msgid "Please log in to import files" msgstr "" -#: ../app/views/maps/modals/feature/_edit_ui.haml:153 ../app/javascript/maplibre/feature.js:368 +#: ../app/views/maps/modals/feature/_edit_ui.haml:155 ../app/javascript/maplibre/feature.js:368 msgid "Please log in to upload images" msgstr "" @@ -1402,7 +1402,7 @@ msgstr "" msgid "Satellite imagery combined with street names and road labels overlay." msgstr "" -#: ../app/javascript/controllers/feature/modal_controller.js:332 +#: ../app/javascript/controllers/feature/modal_controller.js:334 msgid "Saving %{type}" msgstr "" @@ -1442,7 +1442,7 @@ msgstr "" msgid "Set current view as default" msgstr "" -#: ../app/views/maps/modals/feature/_edit_ui.haml:121 +#: ../app/views/maps/modals/feature/_edit_ui.haml:124 msgid "Shape:" msgstr "" @@ -1466,6 +1466,10 @@ msgstr "" msgid "Share view link" msgstr "" +#: ../app/views/maps/modals/feature/_edit_ui.haml:30 +msgid "Show description on map" +msgstr "" + #: ../app/views/maps/modals/_share.haml:63 msgid "Show in public gallery" msgstr "" @@ -1496,7 +1500,7 @@ msgstr[1] "" msgid "Single tracks or points of the map can get exported from their details view." msgstr "" -#: ../app/views/maps/modals/feature/_edit_ui.haml:32 +#: ../app/views/maps/modals/feature/_edit_ui.haml:35 msgid "Size:" msgstr "" @@ -1512,7 +1516,7 @@ msgstr "" msgid "Snapping waypoints to road network..." msgstr "" -#: ../app/views/maps/modals/feature/_edit_ui.haml:131 +#: ../app/views/maps/modals/feature/_edit_ui.haml:134 msgid "Square" msgstr "" @@ -1544,11 +1548,11 @@ msgstr "" msgid "Style Help" msgstr "" -#: ../app/views/maps/modals/feature/_edit_ui.haml:203 +#: ../app/views/maps/modals/feature/_edit_ui.haml:206 msgid "Style JSON" msgstr "" -#: ../app/views/maps/modals/_layers.haml:17 +#: ../app/views/maps/modals/_layers.haml:13 msgid "Supported formats: gpx, kml, kmz, geojson, mapforge json and images. WGS84 format, max. 2.5MB" msgstr "" @@ -1556,7 +1560,7 @@ msgstr "" msgid "Surface" msgstr "" -#: ../app/views/maps/modals/feature/_edit_ui.haml:138 +#: ../app/views/maps/modals/feature/_edit_ui.haml:141 msgid "Symbol:" msgstr "" @@ -1564,7 +1568,7 @@ msgstr "" msgid "Terms of Service" msgstr "" -#: ../app/javascript/maplibre/map.js:416 +#: ../app/javascript/maplibre/map.js:420 msgid "Terrain added to map" msgstr "" @@ -1600,7 +1604,7 @@ msgstr "" msgid "This browser cannot read KMZ archives. Please unzip it and import the .kml" msgstr "" -#: ../app/javascript/controllers/feature/edit_controller.js:655 +#: ../app/javascript/controllers/feature/edit_controller.js:665 msgid "This feature no longer exists" msgstr "" @@ -1624,7 +1628,7 @@ msgstr "" msgid "Title" msgstr "" -#: ../app/javascript/maplibre/map.js:124 +#: ../app/javascript/maplibre/map.js:125 msgid "Toggle attribution" msgstr "" @@ -1768,7 +1772,7 @@ msgstr "" msgid "What do you want to do?" msgstr "" -#: ../app/views/maps/modals/feature/_edit_ui.haml:41 +#: ../app/views/maps/modals/feature/_edit_ui.haml:44 msgid "Width:" msgstr "" @@ -1824,11 +1828,11 @@ msgstr "" msgid "Your open-source map editor built on OpenStreetMap. Create individual maps for your places, tracks and events." msgstr "" -#: ../app/javascript/maplibre/map.js:130 +#: ../app/javascript/maplibre/map.js:131 msgid "Zoom in" msgstr "" -#: ../app/javascript/maplibre/map.js:131 +#: ../app/javascript/maplibre/map.js:132 msgid "Zoom out" msgstr "" @@ -2028,7 +2032,7 @@ msgstr "" msgid "tram|streetcar" msgstr "" -#: ../app/views/maps/modals/feature/_edit_ui.haml:69 ../app/views/maps/modals/feature/_edit_ui.haml:76 +#: ../app/views/maps/modals/feature/_edit_ui.haml:72 ../app/views/maps/modals/feature/_edit_ui.haml:79 msgid "transparent" msgstr "" diff --git a/locale/de/app.po b/locale/de/app.po index 99b50001..3b816025 100644 --- a/locale/de/app.po +++ b/locale/de/app.po @@ -1103,6 +1103,9 @@ msgstr "Eigentümerlink teilen" msgid "Share view link" msgstr "Ansichtslink teilen" +msgid "Show description on map" +msgstr "Beschreibung auf der Karte anzeigen" + msgid "Show in public gallery" msgstr "In der Galerie zeigen" diff --git a/spec/features/feature_edit_point_spec.rb b/spec/features/feature_edit_point_spec.rb index 8242830c..51618ff3 100644 --- a/spec/features/feature_edit_point_spec.rb +++ b/spec/features/feature_edit_point_spec.rb @@ -48,6 +48,18 @@ wait_for { point.reload.properties["label"] }.to be_nil end + it "can toggle description visibility on map" do + click_button "Add description" + find(:css, ".CodeMirror textarea", visible: false).set("**Bold** banner") + check "Show description on map" + wait_for { point.reload.properties["show-desc"] }.to be true + expect(page).to have_css(".desc-banner strong", text: "Bold") + + uncheck "Show description on map" + wait_for { point.reload.properties["show-desc"] }.to be_nil + expect(page).not_to have_css(".desc-banner") + end + it "can update desc" do expect(page).not_to have_selector("#feature-desc-input") click_button "Add description" From 944632946c99a3ada6046a6fd98c10e863c1c346 Mon Sep 17 00:00:00 2001 From: Thomas Schmidt Date: Sat, 19 Sep 2026 23:13:37 +0200 Subject: [PATCH 2/3] refine comment style banner --- app/assets/stylesheets/feature.css | 7 +- app/assets/stylesheets/map.css | 62 ++++++- .../controllers/feature/edit_controller.js | 15 +- .../controllers/feature/modal_controller.js | 4 +- app/javascript/maplibre/feature.js | 8 + .../maplibre/layers/geojson/desc_banners.js | 52 +++++- app/javascript/maplibre/styles/styles.js | 52 +++--- app/views/maps/modals/feature/_edit_ui.haml | 22 ++- docs/tutorials/geojson_style_spec.md | 2 +- locale/app.pot | 156 ++++++++++-------- locale/de/app.po | 15 +- spec/features/feature_edit_point_spec.rb | 13 +- 12 files changed, 276 insertions(+), 132 deletions(-) diff --git a/app/assets/stylesheets/feature.css b/app/assets/stylesheets/feature.css index bdb120ed..0021bfc1 100644 --- a/app/assets/stylesheets/feature.css +++ b/app/assets/stylesheets/feature.css @@ -547,13 +547,13 @@ body:not(.map-mode-rw) .feature-section-card:has(#map-description-view:empty) { } /* the shape buttons hold an icon instead of a word, the width matches an icon-only content button */ -#marker-shape-ui .segmented .btn { +:is(#marker-shape-ui, #desc-shape-ui) .segmented .btn { width: 2.6rem; padding: 0; } /* a solid glyph in the full button color weighs as much as the active cell */ -#marker-shape-ui .segmented .btn:not(.active) { +:is(#marker-shape-ui, #desc-shape-ui) .segmented .btn:not(.active) { color: color-mix(in srgb, var(--ctrl-button-color) 70%, #FFF); } @@ -630,7 +630,8 @@ body:not(.map-mode-rw) .feature-section-card:has(#map-description-view:empty) { } .edit-ui .left-label { - width: 5rem; + min-width: 5rem; + max-width: 6rem; } #feature-show-title-ui { diff --git a/app/assets/stylesheets/map.css b/app/assets/stylesheets/map.css index 645a8e1b..3a4aad41 100644 --- a/app/assets/stylesheets/map.css +++ b/app/assets/stylesheets/map.css @@ -401,12 +401,72 @@ body:has(#map-mode-badge) .layer-popover { } /* --scale follows marker-scaling, set in desc_banners.js. Maplibre positions the outer - .maplibregl-popup with its own transform, so the scale goes on the content instead. */ + .maplibregl-popup with its own transform, so content and tip scale on their own, both around + the anchor: the point of the tip, which sits below the content. */ .desc-banner .maplibregl-popup-content { + transform: scale(var(--scale, 1)); + transform-origin: 50% calc(100% + 10px); +} + +.desc-banner .maplibregl-popup-tip { transform: scale(var(--scale, 1)); transform-origin: bottom center; } +.desc-banner.shape-square .maplibregl-popup-content { + border-radius: 0; + transform-origin: bottom center; +} + +/* A comic bubble: an ellipse with a straight tail from its lower left to the bottom-left anchor. + The tail is the tip of maplibre, an svg stretched over up to half the bubble width + (vector-effect keeps the stroke width). It lies below the ellipse, which hides the part of its base that + reaches inside, and ::after repeats its fill above the outline, so the outline stops at the + tail edges. Both boxes share the tail size and the --overlap into the ellipse. */ +.desc-banner.shape-bubble { + --tail-h: 3.5rem; + --tail-w: min(50%, 10rem); + --overlap: 1.6rem; +} + +.desc-banner.shape-bubble .maplibregl-popup-content { + z-index: 1; + border: 2px solid #000; + border-radius: 50%; + + /* narrower than the 40rem of the popup, a wide ellipse goes flat and its tail with it. + ponytail: fixed padding, a text of more than five lines pokes its corners out of the ellipse */ + max-width: 26rem; + padding: 2rem 3rem; + transform-origin: 0 calc(100% + var(--tail-h) - var(--overlap)); +} + +.desc-banner.shape-bubble .maplibregl-popup-content::after { + content: ""; + position: absolute; + left: -2px; + bottom: calc(var(--overlap) - var(--tail-h) - 2px); + width: var(--tail-w); + height: var(--tail-h); + background: url("data:image/svg+xml,") 0 0 / 100% 100% no-repeat; +} + +.desc-banner.shape-bubble .maplibregl-popup-tip { + z-index: 0; + width: var(--tail-w); + height: var(--tail-h); + margin-top: calc(-1 * var(--overlap)); + border: 0; + background: url("data:image/svg+xml,") 0 0 / 100% 100% no-repeat; + transform-origin: bottom left; +} + +/* an invisible marker has nothing for the tip to point at, a square carries none. The tail of + a comic bubble is part of its drawing, so it stays. */ +.desc-banner:is(.no-tip:not(.shape-bubble), .shape-square) .maplibregl-popup-tip { + display: none; +} + /* marked wraps the text in

, its bootstrap margin would leave an empty strip at the bottom */ .desc-banner .maplibregl-popup-content > :last-child { margin-bottom: 0; diff --git a/app/javascript/controllers/feature/edit_controller.js b/app/javascript/controllers/feature/edit_controller.js index 46709478..0cbb9f92 100644 --- a/app/javascript/controllers/feature/edit_controller.js +++ b/app/javascript/controllers/feature/edit_controller.js @@ -8,8 +8,8 @@ import { flyToFeature } from 'maplibre/animations' import { draw, handleDelete } from 'maplibre/edit' import { backgroundMode, confirmImageLocation, featureIcon, getFeatureTypeName, markerContentMode, - markerMemory, resetHighlightedFeature, syncBackground, syncMarkerContent, syncShapeButtons, - uploadImage, uploadImageToFeature + markerMemory, resetHighlightedFeature, syncBackground, syncDescShapeButtons, syncMarkerContent, + syncShapeButtons, uploadImage, uploadImageToFeature } from 'maplibre/feature' import { hasKmMarkers } from 'maplibre/layers/geojson/km_markers' import { applyFeatureUpdate, getFeature, getLayer, renderLayer } from 'maplibre/layers/layers' @@ -254,14 +254,13 @@ export default class extends Controller { this.renderFeature({ refreshKmMarkers: true }) } - updateShowDescOnMap () { + updateDescShape (e) { const feature = this.getEditFeature() - if (document.querySelector('#feature-show-desc-on-map').checked) { - feature.properties['show-desc'] = true - } else { - delete feature.properties['show-desc'] - } + const shape = e.currentTarget.dataset.descShape + if (shape === 'none') { delete feature.properties['show-desc'] } else { feature.properties['show-desc'] = shape } + syncDescShapeButtons(feature) this.renderFeature() + this.saveFeature() } updateShape (e) { diff --git a/app/javascript/controllers/feature/modal_controller.js b/app/javascript/controllers/feature/modal_controller.js index 4075234c..94878d13 100644 --- a/app/javascript/controllers/feature/modal_controller.js +++ b/app/javascript/controllers/feature/modal_controller.js @@ -10,7 +10,7 @@ import { AnimateLineAnimation, AnimatePolygonAnimation, animateViewFromPropertie import { draw, select, unselect } from 'maplibre/edit' import { getFeatureTypeName, highlightedFeatureId, refreshFeatureMeta, showFeatureDetails, - syncBackground, syncMarkerContent, syncShapeButtons + syncBackground, syncDescShapeButtons, syncMarkerContent, syncShapeButtons } from 'maplibre/feature' import { EXTRAS_COLOR_CONFIGS } from 'maplibre/layers/geojson/route_extras' import { applyFeatureUpdate, getFeature, layers } from 'maplibre/layers/layers' @@ -104,7 +104,7 @@ export default class extends Controller { // init ui input elements document.querySelector('#feature-title-input input').value = feature.properties.title || null document.querySelector('#feature-show-title-on-map').checked = !!feature.properties.label - document.querySelector('#feature-show-desc-on-map').checked = !!feature.properties['show-desc'] + syncDescShapeButtons(feature) dom.hideElements(['.edit-point', '.edit-line', '.edit-polygon']) diff --git a/app/javascript/maplibre/feature.js b/app/javascript/maplibre/feature.js index 37e99238..fda6cb3e 100644 --- a/app/javascript/maplibre/feature.js +++ b/app/javascript/maplibre/feature.js @@ -9,6 +9,7 @@ import { status } from 'helpers/status' import { resetControls } from 'maplibre/controls/shared' import { showElevationChart } from 'maplibre/feature/elevation' import { showExtrasTotals } from 'maplibre/feature/extras_totals' +import { descShape } from 'maplibre/layers/geojson/desc_banners' import { canPinImage } from 'maplibre/layers/geojson/image_overlays' import { getFeature, getFeatureSource, getLayer, layers } from "maplibre/layers/layers" import { wikipediaFeatureDescription } from 'maplibre/layers/wikipedia' @@ -273,6 +274,13 @@ export function syncShapeButtons (feature) { }) } +export function syncDescShapeButtons (feature) { + const shape = descShape(feature) + document.querySelectorAll('#desc-shape-ui [data-desc-shape]').forEach(button => { + button.classList.toggle('active', button.dataset.descShape === shape) + }) +} + // An image covers a symbol, so a point that carries both reads as an image. export function markerContentMode (feature) { if (feature.properties['marker-image-url']) { return 'image' } diff --git a/app/javascript/maplibre/layers/geojson/desc_banners.js b/app/javascript/maplibre/layers/geojson/desc_banners.js index 1f57a094..266e9d35 100644 --- a/app/javascript/maplibre/layers/geojson/desc_banners.js +++ b/app/javascript/maplibre/layers/geojson/desc_banners.js @@ -1,6 +1,9 @@ import { sanitizeMarkdown } from 'helpers/functions' +import { hideContextMenu } from 'maplibre/controls/context_menu' import { featureOnLevel } from 'maplibre/controls/levels' -import { map } from 'maplibre/map' +import { draw } from 'maplibre/edit' +import { highlightFeature } from 'maplibre/feature' +import { frontFeature, map } from 'maplibre/map' import { defaultPointSize } from 'maplibre/styles/defaults' import { marked } from 'marked' @@ -41,32 +44,67 @@ export function rescaleDescBanners () { banners.forEach(({ popup, feature }) => scaleBanner(popup, feature)) } +export const descShape = feature => feature.properties['show-desc'] || 'none' + function upsertDescBanner (feature, layerId, visible) { marked.use({ gfm: true, breaks: true }) const html = sanitizeMarkdown(marked(feature.properties.desc)) + const shape = descShape(feature) + // the tail of a bubble sits at its corner, and maplibre puts the tip where the anchor is + const anchor = shape === 'bubble' ? 'bottom-left' : 'bottom' let popup = banners.get(key(layerId, feature))?.popup + if (popup && popup.options.anchor !== anchor) { + removeBanner(key(layerId, feature)) + popup = null + } if (!popup) { // focusAfterOpen would pull the focus out of the description editor on every keystroke popup = new window.maplibregl.Popup({ - closeButton: false, closeOnClick: false, focusAfterOpen: false, anchor: 'bottom', className: 'desc-banner', - maxWidth: '40rem' + closeButton: false, closeOnClick: false, focusAfterOpen: false, anchor, className: 'desc-banner', maxWidth: '40rem' }).setLngLat(feature.geometry.coordinates).addTo(map) } + const created = !banners.has(key(layerId, feature)) banners.set(key(layerId, feature), { popup, feature }) + // the element of the popup exists only after its first content popup.setLngLat(feature.geometry.coordinates).setHTML(html) + if (created) { bindBannerEvents(popup.getElement(), key(layerId, feature)) } scaleBanner(popup, feature) - popup.getElement().classList.toggle('hidden', !(visible && featureOnLevel(feature))) + const p = feature.properties + const el = popup.getElement() + ;['banner', 'square', 'bubble'].forEach(s => el.classList.toggle(`shape-${s}`, s === shape)) + el.classList.toggle('no-tip', p['marker-color'] === 'transparent' && p.stroke === 'transparent') + el.classList.toggle('hidden', !(visible && featureOnLevel(feature))) +} + +// The popup sits beside the canvas container, so the map sees none of its events: the wheel +// is passed on for zooming, a click selects the feature like a click on the marker does. +function bindBannerEvents (el, id) { + el.addEventListener('wheel', e => { + e.preventDefault() + map.getCanvasContainer().dispatchEvent(new WheelEvent('wheel', e)) + }) + // a drag across the text ends in a click as well, but that one selects text for a copy. + // The selection itself is no clue, it still stands during a click on the selected text. + let downAt = [0, 0] + el.addEventListener('mousedown', e => { downAt = [e.clientX, e.clientY] }) + el.addEventListener('click', e => { + if (Math.hypot(e.clientX - downAt[0], e.clientY - downAt[1]) > 3) { return } + if (window.gon.map_mode === 'static' || (draw && draw.getMode() !== 'simple_select')) { return } + const feature = banners.get(id)?.feature + if (!feature) { return } + hideContextMenu() + highlightFeature(feature, true) + requestAnimationFrame(() => frontFeature(feature)) + }) } // Same curve as shapeIconSize in styles.js: 1x at zoom 16, doubling with each zoom level. -// A plain circle ignores marker-scaling (see pointSize in styles.js), so its offset stays put. function scaleBanner (popup, feature) { const p = feature.properties const radius = Number(p['marker-size'] || defaultPointSize(feature)) const zoomFactor = p['marker-scaling'] ? 2 ** (map.getZoom() - 16) : 1 - const plainCircle = !p['marker-symbol'] && !p['marker-image-url'] && (p['marker-shape'] || 'circle') === 'circle' // marker-size is the radius, so the arrow tip sits on the top edge of the marker - popup.setOffset(radius * (plainCircle ? 1 : zoomFactor)) + popup.setOffset(radius * zoomFactor) // a marker at its default size gets the banner at 1x, twice the size doubles the banner popup.getElement().style.setProperty('--scale', radius / defaultPointSize(feature) * zoomFactor) } diff --git a/app/javascript/maplibre/styles/styles.js b/app/javascript/maplibre/styles/styles.js index 49a66e42..03c9d4a3 100644 --- a/app/javascript/maplibre/styles/styles.js +++ b/app/javascript/maplibre/styles/styles.js @@ -226,22 +226,17 @@ const pointSizeMin = () => ['to-number', ['coalesce', export const pointSizeMax = () => ['to-number', ['coalesce', ...markerSize.slice(1), pointSizeDefault(defaults.pointSizePlain)]] -export const pointSize = () => [ - 'interpolate', - ['linear'], - ['zoom'], - 5, [ - 'case', - ['boolean', ['feature-state', 'active'], false], - ['+', 1, pointSizeMin()], - pointSizeMin() - ], - 17, [ - 'case', - ['boolean', ['feature-state', 'active'], false], - ['+', 1, pointSizeMax()], - pointSizeMax() - ] +// With 'marker-scaling' the radius doubles per zoom level like shapeIconSize, else it grows +// linearly from min at zoom 5 to max at zoom 17. One 'interpolate' has one curve, so the +// exponential one serves both: the extra stops keep the linear case within 0.2px of a line. +const activeBonus = () => ['case', ['boolean', ['feature-state', 'active'], false], 1, 0] +const pointRadiusAt = zoom => ['case', shouldScale, + ['*', 2 ** (zoom - 16), ['+', activeBonus(), pointSizeMax()]], + ['+', activeBonus(), pointSizeMin(), ['*', Math.min(1, (zoom - 5) / 12), ['-', pointSizeMax(), pointSizeMin()]]] +] + +export const pointSize = () => ['interpolate', ['exponential', 2], ['zoom'], + ...[5, 8, 11, 14, 17, 21].flatMap(zoom => [zoom, pointRadiusAt(zoom)]) ] // A point with a shape or a symbol leaves the circle layer and the symbol layer, and gets @@ -275,6 +270,17 @@ const shapeIconSize = () => ['interpolate', ['exponential', 2], ['zoom'], export const pointOutlineSize = () => ['to-number', styleProp(['user_stroke-width', 'stroke-width'], defaults.pointOutlineSize)] export const pointOutlineSizeActive = () => ['+', 1, pointOutlineSize()] + +// The outline of a scaled circle shrinks with it, else it would keep a 2px dot at low zoom +const pointOutlineWidth = () => ['interpolate', ['exponential', 2], ['zoom'], + 0, ['case', shouldScale, 0, pointOutlineSizeCurrent()], + 21, ['case', shouldScale, ['*', 32, pointOutlineSizeCurrent()], pointOutlineSizeCurrent()] +] +const pointOutlineSizeCurrent = () => ['case', + ['boolean', ['feature-state', 'active'], false], + pointOutlineSizeActive(), + pointOutlineSize() +] const pointOutlineColor = () => styleProp(['user_stroke', 'stroke'], defaults.featureOutlineColor) const defaultPointOpacity = () => ['case', ['any', ['has', 'marker-image-url'], ['has', 'marker-symbol']], @@ -754,12 +760,7 @@ export function styles () { defaults.featureOutlineColorActive, pointOutlineColor() ], - 'circle-stroke-width': [ - 'case', - ['boolean', ['feature-state', 'active'], false], - pointOutlineSizeActive(), - pointOutlineSize() - ], + 'circle-stroke-width': pointOutlineWidth(), 'circle-stroke-opacity': ['to-number', ["min", 1, ['+', pointOpacity(), 0.2]]] }, layout: { @@ -801,12 +802,7 @@ export function styles () { defaults.featureOutlineColorActive, pointOutlineColor() ], - 'circle-stroke-width': [ - 'case', - ['boolean', ['feature-state', 'active'], false], - pointOutlineSizeActive(), - pointOutlineSize() - ], + 'circle-stroke-width': pointOutlineWidth(), 'circle-stroke-opacity': ["min", 1, ['+', pointOpacity(), 0.2]] }, layout: { diff --git a/app/views/maps/modals/feature/_edit_ui.haml b/app/views/maps/modals/feature/_edit_ui.haml index 305a5b59..d7e3842b 100644 --- a/app/views/maps/modals/feature/_edit_ui.haml +++ b/app/views/maps/modals/feature/_edit_ui.haml @@ -25,9 +25,25 @@ #feature-desc-section.feature-section-card.hidden #feature-desc %textarea#feature-desc-input - .edit-ui.ms-2.edit-point - %input#feature-show-desc-on-map{ type: "checkbox", data: { action: "input->feature--edit#addUndo input->feature--edit#updateShowDescOnMap change->feature--edit#saveFeature" } } - %label{ for: "feature-show-desc-on-map"}= _("Show description on map") + #desc-shape-ui.edit-ui.edit-point + %label.left-label= _("Show on map:") + .btn-group.segmented{ role: "group" } + %button.btn{ type: "button", title: _("Not shown"), + data: { 'desc-shape': "none", action: "click->feature--edit#addUndo click->feature--edit#updateDescShape", + toggle: "tooltip", 'bs-trigger': "hover" } } + %i.bi.bi-slash-circle + %button.btn{ type: "button", title: _("Banner"), + data: { 'desc-shape': "banner", action: "click->feature--edit#addUndo click->feature--edit#updateDescShape", + toggle: "tooltip", 'bs-trigger': "hover" } } + %i.bi.bi-chat-square-fill + %button.btn{ type: "button", title: _("Square"), + data: { 'desc-shape': "square", action: "click->feature--edit#addUndo click->feature--edit#updateDescShape", + toggle: "tooltip", 'bs-trigger': "hover" } } + %i.bi.bi-square-fill + %button.btn{ type: "button", title: _("Text bubble"), + data: { 'desc-shape': "bubble", action: "click->feature--edit#addUndo click->feature--edit#updateDescShape", + toggle: "tooltip", 'bs-trigger': "hover" } } + %i.bi.bi-chat-fill .feature-edit-section.hidden{ 'data-edit-section': "style" } .feature-section-card.pt-2 diff --git a/docs/tutorials/geojson_style_spec.md b/docs/tutorials/geojson_style_spec.md index 7a51ed32..5f87b237 100644 --- a/docs/tutorials/geojson_style_spec.md +++ b/docs/tutorials/geojson_style_spec.md @@ -44,7 +44,7 @@ Extending the [Mapbox Simplestyle Spec](https://github.com/mapbox/simplestyle-sp * `heatmap`: if set, points will be styled as a heatmap * `flat`: if set, marker + label with be projected 'flat' on the map * `marker-rotate`: rotate marker by x degrees clockwise -* `show-desc`: render `desc` as an HTML banner above the marker, scaled by `marker-scaling` (default: false) +* `show-desc`: render `desc` as HTML above the marker, scaled by `marker-scaling`. One of `banner`, `square` or `bubble` (default: not shown) #### *LineString* geometry: diff --git a/locale/app.pot b/locale/app.pot index e1cfe0dd..ac50c5ce 100644 --- a/locale/app.pot +++ b/locale/app.pot @@ -8,8 +8,8 @@ msgid "" msgstr "" "Project-Id-Version: app 1.0.0\n" "Report-Msgid-Bugs-To: \n" -"POT-Creation-Date: 2026-09-19 00:07+0200\n" -"PO-Revision-Date: 2026-09-19 00:07+0200\n" +"POT-Creation-Date: 2026-09-19 21:09+0200\n" +"PO-Revision-Date: 2026-09-19 21:09+0200\n" "Last-Translator: FULL NAME \n" "Language-Team: LANGUAGE \n" "Language: \n" @@ -26,7 +26,7 @@ msgstr "" msgid "%{count} views" msgstr "" -#: ../app/javascript/maplibre/map.js:618 ../app/javascript/maplibre/undo.js:58 +#: ../app/javascript/maplibre/map.js:619 ../app/javascript/maplibre/undo.js:58 msgid "%{type} added" msgstr "" @@ -50,7 +50,7 @@ msgstr "" msgid "%{type} update" msgstr "" -#: ../app/javascript/maplibre/map.js:646 +#: ../app/javascript/maplibre/map.js:647 msgid "%{type} updated" msgstr "" @@ -158,10 +158,14 @@ msgstr "" msgid "Background map" msgstr "" -#: ../app/views/maps/modals/feature/_edit_ui.haml:84 +#: ../app/views/maps/modals/feature/_edit_ui.haml:100 msgid "Background:" msgstr "" +#: ../app/views/maps/modals/feature/_edit_ui.haml:35 +msgid "Banner" +msgstr "" + #: ../app/views/maps/modals/_layers.haml:135 msgid "Basemap Info (experimental)" msgstr "" @@ -182,7 +186,7 @@ msgstr "" msgid "Bicycle Touring!" msgstr "" -#: ../app/javascript/maplibre/feature.js:378 +#: ../app/javascript/maplibre/feature.js:386 msgid "Bicycle route" msgstr "" @@ -190,11 +194,11 @@ msgstr "" msgid "Bicycle-focused map highlighting cycle routes, paths and bike infrastructure." msgstr "" -#: ../app/javascript/maplibre/feature.js:331 +#: ../app/javascript/maplibre/feature.js:339 msgid "Big dots" msgstr "" -#: ../app/views/maps/modals/feature/_edit_ui.haml:197 +#: ../app/views/maps/modals/feature/_edit_ui.haml:213 msgid "Bike" msgstr "" @@ -202,11 +206,11 @@ msgstr "" msgid "Bike route" msgstr "" -#: ../app/views/maps/modals/feature/_edit_ui.haml:66 +#: ../app/views/maps/modals/feature/_edit_ui.haml:82 msgid "Border color:" msgstr "" -#: ../app/views/maps/modals/feature/_edit_ui.haml:53 +#: ../app/views/maps/modals/feature/_edit_ui.haml:69 msgid "Border width:" msgstr "" @@ -250,11 +254,11 @@ msgstr "" msgid "Can Edit" msgstr "" -#: ../app/views/maps/modals/feature/_edit_ui.haml:200 +#: ../app/views/maps/modals/feature/_edit_ui.haml:216 msgid "Car" msgstr "" -#: ../app/views/maps/modals/_settings.haml:60 ../app/javascript/maplibre/feature.js:381 +#: ../app/views/maps/modals/_settings.haml:60 ../app/javascript/maplibre/feature.js:389 msgid "Car route" msgstr "" @@ -266,7 +270,7 @@ msgstr "" msgid "Changelog" msgstr "" -#: ../app/javascript/maplibre/feature.js:332 +#: ../app/javascript/maplibre/feature.js:340 msgid "Checker" msgstr "" @@ -278,7 +282,7 @@ msgstr "" msgid "Choose your base map: Streets, Satellite, Topographic, Biking, 3D houses etc." msgstr "" -#: ../app/views/maps/modals/feature/_edit_ui.haml:126 +#: ../app/views/maps/modals/feature/_edit_ui.haml:142 msgid "Circle" msgstr "" @@ -302,7 +306,7 @@ msgstr "" msgid "Click to copy html to embed in your website" msgstr "" -#: ../app/javascript/maplibre/feature.js:86 +#: ../app/javascript/maplibre/feature.js:87 msgid "Click to toggle format" msgstr "" @@ -314,11 +318,11 @@ msgstr "" msgid "Collaboration" msgstr "" -#: ../app/views/maps/modals/feature/_edit_ui.haml:68 +#: ../app/views/maps/modals/feature/_edit_ui.haml:84 msgid "Color" msgstr "" -#: ../app/views/maps/modals/feature/_edit_ui.haml:75 +#: ../app/views/maps/modals/feature/_edit_ui.haml:91 msgid "Color:" msgstr "" @@ -346,7 +350,7 @@ msgstr "" msgid "Connection error" msgstr "" -#: ../app/javascript/maplibre/map.js:499 +#: ../app/javascript/maplibre/map.js:500 msgid "Contour lines added to map" msgstr "" @@ -358,7 +362,7 @@ msgstr "" msgid "Contributing" msgstr "" -#: ../app/views/maps/modals/feature/_edit_ui.haml:186 +#: ../app/views/maps/modals/feature/_edit_ui.haml:202 msgid "Convert to route" msgstr "" @@ -382,11 +386,11 @@ msgstr "" msgid "Copy %{type}" msgstr "" -#: ../app/views/maps/modals/feature/_edit_ui.haml:190 +#: ../app/views/maps/modals/feature/_edit_ui.haml:206 msgid "Copy and transform this line into a routed track with waypoints, elevation and surface info." msgstr "" -#: ../app/javascript/maplibre/feature.js:89 +#: ../app/javascript/maplibre/feature.js:90 msgid "Copy coordinates to clipboard" msgstr "" @@ -434,7 +438,7 @@ msgstr "" msgid "Cross platform" msgstr "" -#: ../app/javascript/maplibre/feature.js:326 +#: ../app/javascript/maplibre/feature.js:334 msgid "Crosshatch" msgstr "" @@ -466,7 +470,7 @@ msgstr "" msgid "Default view when opening this map." msgstr "" -#: ../app/views/maps/modals/_layers.haml:181 ../app/views/maps/modals/feature/_edit_ui.haml:223 ../app/javascript/maplibre/controls/context_menu.js:157 +#: ../app/views/maps/modals/_layers.haml:181 ../app/views/maps/modals/feature/_edit_ui.haml:239 ../app/javascript/maplibre/controls/context_menu.js:157 msgid "Delete" msgstr "" @@ -474,7 +478,7 @@ msgstr "" msgid "Delete Layer" msgstr "" -#: ../app/views/maps/modals/feature/_edit_ui.haml:221 +#: ../app/views/maps/modals/feature/_edit_ui.haml:237 msgid "Delete feature" msgstr "" @@ -486,7 +490,7 @@ msgstr "" msgid "Delete midpoint" msgstr "" -#: ../app/javascript/maplibre/map.js:656 +#: ../app/javascript/maplibre/map.js:657 msgid "Deleting %{type}" msgstr "" @@ -518,7 +522,7 @@ msgstr "" msgid "Discuss" msgstr "" -#: ../app/views/maps/modals/feature/_edit_ui.haml:178 +#: ../app/views/maps/modals/feature/_edit_ui.haml:194 msgid "Distance markers:" msgstr "" @@ -542,7 +546,7 @@ msgstr "" msgid "Don't show again for this map" msgstr "" -#: ../app/javascript/maplibre/feature.js:330 +#: ../app/javascript/maplibre/feature.js:338 msgid "Dots" msgstr "" @@ -682,7 +686,7 @@ msgstr "" msgid "Error updating %{type} geometry" msgstr "" -#: ../app/javascript/maplibre/feature.js:498 +#: ../app/javascript/maplibre/feature.js:506 msgid "Error uploading image" msgstr "" @@ -694,7 +698,7 @@ msgstr "" msgid "Failed to load layer %{name}" msgstr "" -#: ../app/javascript/maplibre/feature.js:374 +#: ../app/javascript/maplibre/feature.js:382 msgid "Feature" msgstr "" @@ -714,11 +718,11 @@ msgstr "" msgid "File imported" msgstr "" -#: ../app/views/maps/modals/feature/_edit_ui.haml:90 +#: ../app/views/maps/modals/feature/_edit_ui.haml:106 msgid "Fill" msgstr "" -#: ../app/views/maps/modals/feature/_edit_ui.haml:86 +#: ../app/views/maps/modals/feature/_edit_ui.haml:102 msgid "Fill color" msgstr "" @@ -750,7 +754,7 @@ msgstr "" msgid "Following position" msgstr "" -#: ../app/views/maps/modals/feature/_edit_ui.haml:194 +#: ../app/views/maps/modals/feature/_edit_ui.haml:210 msgid "Foot" msgstr "" @@ -770,7 +774,7 @@ msgstr "" msgid "Geometry" msgstr "" -#: ../app/views/maps/modals/feature/_edit_ui.haml:215 +#: ../app/views/maps/modals/feature/_edit_ui.haml:231 msgid "Geometry JSON" msgstr "" @@ -790,7 +794,7 @@ msgstr "" msgid "Gravel" msgstr "" -#: ../app/javascript/maplibre/feature.js:329 +#: ../app/javascript/maplibre/feature.js:337 msgid "Grid" msgstr "" @@ -802,11 +806,11 @@ msgstr "" msgid "Happy mapping!" msgstr "" -#: ../app/javascript/maplibre/feature.js:325 +#: ../app/javascript/maplibre/feature.js:333 msgid "Hatch" msgstr "" -#: ../app/views/maps/modals/feature/_edit_ui.haml:169 +#: ../app/views/maps/modals/feature/_edit_ui.haml:185 msgid "Height:" msgstr "" @@ -830,11 +834,11 @@ msgstr "" msgid "High-resolution satellite imagery without any labels or overlays." msgstr "" -#: ../app/javascript/maplibre/feature.js:384 +#: ../app/javascript/maplibre/feature.js:392 msgid "Hiking route" msgstr "" -#: ../app/javascript/maplibre/map.js:437 +#: ../app/javascript/maplibre/map.js:438 msgid "Hillshade added to map" msgstr "" @@ -862,15 +866,15 @@ msgstr "" msgid "Ice" msgstr "" -#: ../app/views/maps/modals/feature/_edit_ui.haml:149 ../app/views/maps/modals/feature/_edit_ui.haml:154 +#: ../app/views/maps/modals/feature/_edit_ui.haml:165 ../app/views/maps/modals/feature/_edit_ui.haml:170 msgid "Icon" msgstr "" -#: ../app/views/maps/modals/_image_viewer.haml:2 ../app/views/maps/modals/feature/_edit_ui.haml:97 ../app/views/maps/modals/feature/_edit_ui.haml:161 +#: ../app/views/maps/modals/_image_viewer.haml:2 ../app/views/maps/modals/feature/_edit_ui.haml:113 ../app/views/maps/modals/feature/_edit_ui.haml:177 msgid "Image" msgstr "" -#: ../app/javascript/controllers/feature/edit_controller.js:408 ../app/javascript/controllers/feature/edit_controller.js:480 +#: ../app/javascript/controllers/feature/edit_controller.js:407 ../app/javascript/controllers/feature/edit_controller.js:479 msgid "Image exceeds 15MB" msgstr "" @@ -950,7 +954,7 @@ msgstr "" msgid "Light, minimal grayscale style ideal for data visualization overlays." msgstr "" -#: ../app/javascript/maplibre/feature.js:389 +#: ../app/javascript/maplibre/feature.js:397 msgid "Line" msgstr "" @@ -958,7 +962,7 @@ msgstr "" msgid "Line Mode: Click on the map to draw a line" msgstr "" -#: ../app/views/maps/modals/feature/_edit_ui.haml:65 +#: ../app/views/maps/modals/feature/_edit_ui.haml:81 msgid "Line color:" msgstr "" @@ -966,7 +970,7 @@ msgstr "" msgid "Line cut into 2 segments" msgstr "" -#: ../app/javascript/maplibre/feature.js:327 +#: ../app/javascript/maplibre/feature.js:335 msgid "Lines" msgstr "" @@ -978,11 +982,11 @@ msgstr "" msgid "Load for this area" msgstr "" -#: ../app/javascript/maplibre/map.js:725 +#: ../app/javascript/maplibre/map.js:726 msgid "Loaded base map %{name}" msgstr "" -#: ../app/javascript/maplibre/feature.js:189 +#: ../app/javascript/maplibre/feature.js:190 msgid "Loading description..." msgstr "" @@ -1086,11 +1090,11 @@ msgstr "" msgid "Mapforge: Create and share your own maps online" msgstr "" -#: ../app/views/maps/modals/feature/_edit_ui.haml:155 ../app/javascript/maplibre/feature.js:369 +#: ../app/views/maps/modals/feature/_edit_ui.haml:171 ../app/javascript/maplibre/feature.js:377 msgid "Max. 5MB" msgstr "" -#: ../app/javascript/maplibre/map.js:311 +#: ../app/javascript/maplibre/map.js:312 msgid "Maximum zoom level %{zoom} reached" msgstr "" @@ -1106,7 +1110,7 @@ msgstr "" msgid "Minimalist, neutral vector style with subtle colors and light labels." msgstr "" -#: ../app/javascript/maplibre/map.js:315 +#: ../app/javascript/maplibre/map.js:316 msgid "Minimum zoom level %{zoom} reached" msgstr "" @@ -1138,7 +1142,7 @@ msgstr "" msgid "Name:" msgstr "" -#: ../app/javascript/maplibre/feature.js:369 +#: ../app/javascript/maplibre/feature.js:377 msgid "Needs a polygon with four corners" msgstr "" @@ -1166,10 +1170,14 @@ msgstr "" msgid "No route found" msgstr "" -#: ../app/views/maps/modals/feature/_edit_ui.haml:144 ../app/views/maps/modals/feature/_edit_ui.haml:148 +#: ../app/views/maps/modals/feature/_edit_ui.haml:160 ../app/views/maps/modals/feature/_edit_ui.haml:164 msgid "None" msgstr "" +#: ../app/views/maps/modals/feature/_edit_ui.haml:31 +msgid "Not shown" +msgstr "" + #: ../app/views/maps/modals/_layers.haml:141 msgid "OSM Info (experimental)" msgstr "" @@ -1182,7 +1190,7 @@ msgstr "" msgid "Only LineString tracks can be converted to routes" msgstr "" -#: ../app/views/maps/modals/feature/_edit_ui.haml:115 +#: ../app/views/maps/modals/feature/_edit_ui.haml:131 msgid "Opacity:" msgstr "" @@ -1226,7 +1234,7 @@ msgstr "" msgid "Paste %{type}" msgstr "" -#: ../app/views/maps/modals/feature/_edit_ui.haml:100 ../app/views/maps/modals/feature/_edit_ui.haml:105 +#: ../app/views/maps/modals/feature/_edit_ui.haml:116 ../app/views/maps/modals/feature/_edit_ui.haml:121 msgid "Pattern" msgstr "" @@ -1238,7 +1246,7 @@ msgstr "" msgid "Paving stones" msgstr "" -#: ../app/views/maps/modals/feature/_edit_ui.haml:130 +#: ../app/views/maps/modals/feature/_edit_ui.haml:146 msgid "Pin" msgstr "" @@ -1254,7 +1262,7 @@ msgstr "" msgid "Please log in to import files" msgstr "" -#: ../app/views/maps/modals/feature/_edit_ui.haml:155 ../app/javascript/maplibre/feature.js:368 +#: ../app/views/maps/modals/feature/_edit_ui.haml:171 ../app/javascript/maplibre/feature.js:376 msgid "Please log in to upload images" msgstr "" @@ -1262,7 +1270,7 @@ msgstr "" msgid "Please log in to upload tracks" msgstr "" -#: ../app/javascript/maplibre/feature.js:388 +#: ../app/javascript/maplibre/feature.js:396 msgid "Point" msgstr "" @@ -1270,7 +1278,7 @@ msgstr "" msgid "Point Mode: Click on the map to place a marker" msgstr "" -#: ../app/javascript/maplibre/feature.js:390 +#: ../app/javascript/maplibre/feature.js:398 msgid "Polygon" msgstr "" @@ -1442,7 +1450,7 @@ msgstr "" msgid "Set current view as default" msgstr "" -#: ../app/views/maps/modals/feature/_edit_ui.haml:124 +#: ../app/views/maps/modals/feature/_edit_ui.haml:140 msgid "Shape:" msgstr "" @@ -1466,10 +1474,6 @@ msgstr "" msgid "Share view link" msgstr "" -#: ../app/views/maps/modals/feature/_edit_ui.haml:30 -msgid "Show description on map" -msgstr "" - #: ../app/views/maps/modals/_share.haml:63 msgid "Show in public gallery" msgstr "" @@ -1486,6 +1490,10 @@ msgstr "" msgid "Show navigation" msgstr "" +#: ../app/views/maps/modals/feature/_edit_ui.haml:29 +msgid "Show on map:" +msgstr "" + #: ../app/views/maps/modals/feature/_edit_ui.haml:18 msgid "Show title on map" msgstr "" @@ -1500,7 +1508,7 @@ msgstr[1] "" msgid "Single tracks or points of the map can get exported from their details view." msgstr "" -#: ../app/views/maps/modals/feature/_edit_ui.haml:35 +#: ../app/views/maps/modals/feature/_edit_ui.haml:51 msgid "Size:" msgstr "" @@ -1516,7 +1524,7 @@ msgstr "" msgid "Snapping waypoints to road network..." msgstr "" -#: ../app/views/maps/modals/feature/_edit_ui.haml:134 +#: ../app/views/maps/modals/feature/_edit_ui.haml:39 ../app/views/maps/modals/feature/_edit_ui.haml:150 msgid "Square" msgstr "" @@ -1548,7 +1556,7 @@ msgstr "" msgid "Style Help" msgstr "" -#: ../app/views/maps/modals/feature/_edit_ui.haml:206 +#: ../app/views/maps/modals/feature/_edit_ui.haml:222 msgid "Style JSON" msgstr "" @@ -1560,7 +1568,7 @@ msgstr "" msgid "Surface" msgstr "" -#: ../app/views/maps/modals/feature/_edit_ui.haml:141 +#: ../app/views/maps/modals/feature/_edit_ui.haml:157 msgid "Symbol:" msgstr "" @@ -1568,10 +1576,14 @@ msgstr "" msgid "Terms of Service" msgstr "" -#: ../app/javascript/maplibre/map.js:420 +#: ../app/javascript/maplibre/map.js:421 msgid "Terrain added to map" msgstr "" +#: ../app/views/maps/modals/feature/_edit_ui.haml:43 +msgid "Text bubble" +msgstr "" + #: ../app/javascript/maplibre/import/kml.js:43 msgid "The KMZ archive contains no .kml document" msgstr "" @@ -1588,7 +1600,7 @@ msgstr "" msgid "The file is not valid XML" msgstr "" -#: ../app/javascript/maplibre/feature.js:546 +#: ../app/javascript/maplibre/feature.js:554 msgid "The image contains GPS coordinates (%{coordinates}).
Do you want to place the marker there?" msgstr "" @@ -1596,7 +1608,7 @@ msgstr "" msgid "The map starts centered on your content." msgstr "" -#: ../app/javascript/maplibre/feature.js:328 +#: ../app/javascript/maplibre/feature.js:336 msgid "Thick lines" msgstr "" @@ -1604,7 +1616,7 @@ msgstr "" msgid "This browser cannot read KMZ archives. Please unzip it and import the .kml" msgstr "" -#: ../app/javascript/controllers/feature/edit_controller.js:665 +#: ../app/javascript/controllers/feature/edit_controller.js:664 msgid "This feature no longer exists" msgstr "" @@ -1772,7 +1784,7 @@ msgstr "" msgid "What do you want to do?" msgstr "" -#: ../app/views/maps/modals/feature/_edit_ui.haml:44 +#: ../app/views/maps/modals/feature/_edit_ui.haml:60 msgid "Width:" msgstr "" @@ -2032,7 +2044,7 @@ msgstr "" msgid "tram|streetcar" msgstr "" -#: ../app/views/maps/modals/feature/_edit_ui.haml:72 ../app/views/maps/modals/feature/_edit_ui.haml:79 +#: ../app/views/maps/modals/feature/_edit_ui.haml:88 ../app/views/maps/modals/feature/_edit_ui.haml:95 msgid "transparent" msgstr "" diff --git a/locale/de/app.po b/locale/de/app.po index 3b816025..b4ead971 100644 --- a/locale/de/app.po +++ b/locale/de/app.po @@ -125,6 +125,9 @@ msgstr "Hintergrundkarte" msgid "Background:" msgstr "Hintergrund:" +msgid "Banner" +msgstr "Banner" + msgid "Basemap Info (experimental)" msgstr "Basiskarten-Info (experimentell)" @@ -881,6 +884,9 @@ msgstr "Keine Route gefunden" msgid "None" msgstr "Ohne" +msgid "Not shown" +msgstr "Nicht anzeigen" + msgid "OSM Info (experimental)" msgstr "OSM-Info (experimentell)" @@ -1103,9 +1109,6 @@ msgstr "Eigentümerlink teilen" msgid "Share view link" msgstr "Ansichtslink teilen" -msgid "Show description on map" -msgstr "Beschreibung auf der Karte anzeigen" - msgid "Show in public gallery" msgstr "In der Galerie zeigen" @@ -1118,6 +1121,9 @@ msgstr "Karte in der öffentlichen Mapforge-Galerie zeigen" msgid "Show navigation" msgstr "Navigation anzeigen" +msgid "Show on map:" +msgstr "Auf der Karte:" + msgid "Show title on map" msgstr "Titel auf der Karte anzeigen" @@ -1183,6 +1189,9 @@ msgstr "Nutzungsbedingungen" msgid "Terrain added to map" msgstr "Gelände zur Karte hinzugefügt" +msgid "Text bubble" +msgstr "Sprechblase" + msgid "The KMZ archive contains no .kml document" msgstr "Das KMZ-Archiv enthält kein .kml-Dokument" diff --git a/spec/features/feature_edit_point_spec.rb b/spec/features/feature_edit_point_spec.rb index 51618ff3..c36da888 100644 --- a/spec/features/feature_edit_point_spec.rb +++ b/spec/features/feature_edit_point_spec.rb @@ -51,11 +51,16 @@ it "can toggle description visibility on map" do click_button "Add description" find(:css, ".CodeMirror textarea", visible: false).set("**Bold** banner") - check "Show description on map" - wait_for { point.reload.properties["show-desc"] }.to be true - expect(page).to have_css(".desc-banner strong", text: "Bold") + find("#desc-shape-ui [data-desc-shape='banner']").click + wait_for { point.reload.properties["show-desc"] }.to eq("banner") + expect(page).to have_css(".desc-banner.shape-banner strong", text: "Bold") + expect(page).to have_css(".desc-banner.maplibregl-popup-anchor-bottom") - uncheck "Show description on map" + find("#desc-shape-ui [data-desc-shape='bubble']").click + wait_for { point.reload.properties["show-desc"] }.to eq("bubble") + expect(page).to have_css(".desc-banner.shape-bubble.maplibregl-popup-anchor-bottom-left") + + find("#desc-shape-ui [data-desc-shape='none']").click wait_for { point.reload.properties["show-desc"] }.to be_nil expect(page).not_to have_css(".desc-banner") end From c315951e38e0a2e9c56108ade3113630220d22ba Mon Sep 17 00:00:00 2001 From: Thomas Schmidt Date: Sun, 20 Sep 2026 00:14:01 +0200 Subject: [PATCH 3/3] sanitize banner html --- app/javascript/helpers/functions.js | 5 ++ app/javascript/maplibre/layers/geojson.js | 12 ++--- .../maplibre/layers/geojson/desc_banners.js | 54 +++++++++++-------- app/javascript/maplibre/layers/layer.js | 7 ++- app/javascript/maplibre/map.js | 11 ++-- config/importmap.rb | 2 + spec/features/feature_edit_point_spec.rb | 22 ++++++++ vendor/javascript/dompurify.js | 10 ++++ 8 files changed, 90 insertions(+), 33 deletions(-) create mode 100644 vendor/javascript/dompurify.js diff --git a/app/javascript/helpers/functions.js b/app/javascript/helpers/functions.js index 6a7be88a..85867dfb 100644 --- a/app/javascript/helpers/functions.js +++ b/app/javascript/helpers/functions.js @@ -1,3 +1,5 @@ +import DOMPurify from 'dompurify' + const debounceList = [] const throttleList = [] @@ -159,7 +161,10 @@ export function symbolUrl (symbol) { return symbol.includes('/') ? symbol : '/icon-sets/noto/' + symbol + '.png' } +// marked passes raw html through, so without this a description could run a script for every +// viewer of the map. 'target' is dropped by default, the OSM link of overpassDescription has one. export function sanitizeMarkdown (desc) { + desc = DOMPurify.sanitize(desc, { ADD_ATTR: ['target'] }) // open external and image links in new tab desc = desc.replace(/ `${layerId}/` -const key = (layerId, feature) => prefix(layerId) + feature.id +const prefix = layer => `${layer.id}/` +const key = (layer, feature) => prefix(layer) + feature.id export const hasDescBanner = feature => feature.geometry?.type === 'Point' && !!feature.properties?.['show-desc'] && !!feature.properties?.desc -export function renderDescBanners (features, layerId, visible = true) { +export function renderDescBanners (features, layer, visible = layer.show !== false) { const wanted = new Set() features.filter(hasDescBanner).forEach(feature => { - wanted.add(key(layerId, feature)) - upsertDescBanner(feature, layerId, visible) + wanted.add(key(layer, feature)) + upsertDescBanner(feature, layer, visible) }) Array.from(banners.keys()) - .filter(id => id.startsWith(prefix(layerId)) && !wanted.has(id)) + .filter(id => id.startsWith(prefix(layer)) && !wanted.has(id)) .forEach(removeBanner) } -export function syncDescBanner (feature, layerId, visible = true) { +export function syncDescBanner (feature, layer, visible = layer.show !== false) { if (hasDescBanner(feature)) { - upsertDescBanner(feature, layerId, visible) + upsertDescBanner(feature, layer, visible) } else { - removeBanner(key(layerId, feature)) + removeBanner(key(layer, feature)) } } -export function removeDescBanner (feature, layerId) { - removeBanner(key(layerId, feature)) +export function removeDescBanner (feature, layer) { + removeBanner(key(layer, feature)) } -export function rescaleDescBanners () { - banners.forEach(({ popup, feature }) => scaleBanner(popup, feature)) +// on zoom: the scale follows the marker, and a clustered layer folds its points into clusters +export function refreshDescBanners () { + banners.forEach(entry => { + scaleBanner(entry.popup, entry.feature) + showBanner(entry) + }) } export const descShape = feature => feature.properties['show-desc'] || 'none' -function upsertDescBanner (feature, layerId, visible) { +function upsertDescBanner (feature, layer, visible) { marked.use({ gfm: true, breaks: true }) const html = sanitizeMarkdown(marked(feature.properties.desc)) const shape = descShape(feature) // the tail of a bubble sits at its corner, and maplibre puts the tip where the anchor is const anchor = shape === 'bubble' ? 'bottom-left' : 'bottom' - let popup = banners.get(key(layerId, feature))?.popup + let popup = banners.get(key(layer, feature))?.popup if (popup && popup.options.anchor !== anchor) { - removeBanner(key(layerId, feature)) + removeBanner(key(layer, feature)) popup = null } if (!popup) { @@ -63,17 +67,25 @@ function upsertDescBanner (feature, layerId, visible) { closeButton: false, closeOnClick: false, focusAfterOpen: false, anchor, className: 'desc-banner', maxWidth: '40rem' }).setLngLat(feature.geometry.coordinates).addTo(map) } - const created = !banners.has(key(layerId, feature)) - banners.set(key(layerId, feature), { popup, feature }) + const created = !banners.has(key(layer, feature)) + const entry = { popup, feature, layer, visible } + banners.set(key(layer, feature), entry) // the element of the popup exists only after its first content popup.setLngLat(feature.geometry.coordinates).setHTML(html) - if (created) { bindBannerEvents(popup.getElement(), key(layerId, feature)) } + if (created) { bindBannerEvents(popup.getElement(), key(layer, feature)) } scaleBanner(popup, feature) const p = feature.properties const el = popup.getElement() ;['banner', 'square', 'bubble'].forEach(s => el.classList.toggle(`shape-${s}`, s === shape)) el.classList.toggle('no-tip', p['marker-color'] === 'transparent' && p.stroke === 'transparent') - el.classList.toggle('hidden', !(visible && featureOnLevel(feature))) + showBanner(entry) +} + +// ponytail: a clustered layer hides every banner up to clusterMaxZoom, a point that stands +// alone down there loses its banner too. querySourceFeatures per point if that matters. +function showBanner ({ popup, feature, layer, visible }) { + const clustered = layer.clustered && map.getZoom() < clusterMaxZoom + 1 + popup.getElement().classList.toggle('hidden', !(visible && featureOnLevel(feature)) || clustered) } // The popup sits beside the canvas container, so the map sees none of its events: the wheel diff --git a/app/javascript/maplibre/layers/layer.js b/app/javascript/maplibre/layers/layer.js index 649fa98f..8a6cc97b 100644 --- a/app/javascript/maplibre/layers/layer.js +++ b/app/javascript/maplibre/layers/layer.js @@ -133,8 +133,11 @@ export class Layer { * Called once during initialization; visibility toggles reuse the source. */ createSource() { - const cluster = !!this.layer.cluster && !this.layer.heatmap - addGeoJSONSource(this.sourceId, cluster, this.attribution) + addGeoJSONSource(this.sourceId, this.clustered, this.attribution) + } + + get clustered() { + return !!this.layer.cluster && !this.layer.heatmap } /** diff --git a/app/javascript/maplibre/map.js b/app/javascript/maplibre/map.js index abe65e58..49be3028 100644 --- a/app/javascript/maplibre/map.js +++ b/app/javascript/maplibre/map.js @@ -14,7 +14,7 @@ import { hideModals, initCtrlTooltips, initializeDefaultControls, initSettingsMo import { initializeViewControls, removeViewControls } from 'maplibre/controls/view'; import { initializeEditMode, resetEditMode } from 'maplibre/edit'; import { featureLabel, highlightFeature, resetHighlightedFeature } from 'maplibre/feature'; -import { renderDescBanners, rescaleDescBanners } from 'maplibre/layers/geojson/desc_banners'; +import { refreshDescBanners, renderDescBanners } from 'maplibre/layers/geojson/desc_banners'; import { applyFeatureUpdate, getFeature, getLayer, initializeLayers, initializeLayerSources, initializeLayerStyles, layers } from 'maplibre/layers/layers'; import { basemaps, demSource, elevationSource } from 'maplibre/styles/basemaps'; import { applyBasemapDefaults, defaults } from 'maplibre/styles/defaults'; @@ -41,6 +41,9 @@ let serverProperties = {} export let loadedMapUpdatedAt = null export function setLoadedMapUpdatedAt (value) { loadedMapUpdatedAt = value } +// Above this zoom a clustered source shows every point on its own +export const clusterMaxZoom = 14 + let mapInteracted let backgroundTerrain let backgroundHillshade @@ -238,7 +241,7 @@ export async function initializeMap (divId = 'maplibre-map') { }) map.on('zoom', (e) => { limitZoom() - rescaleDescBanners() + refreshDescBanners() if (e.originalEvent) { hideModals() } // ignore programmatic zoom (e.g. initial zoom-in effect) }) map.on('online', (_e) => { functions.e('#maplibre-map', e => { e.setAttribute('data-online', true) }) }) @@ -341,7 +344,7 @@ export function addGeoJSONSource(sourceName, cluster=false, attribution=null) { promoteId: 'id', data: { type: 'FeatureCollection', features: [] }, cluster: cluster, - clusterMaxZoom: 14, + clusterMaxZoom, clusterRadius: 50, // the attribution control drops a null and de-duplicates the rest, so layers of the // same type credit their data source only once @@ -385,7 +388,7 @@ export function setLayerVisibility(sourceName, visible) { } // description banners are DOM popups, not style layers, see desc_banners.js const layer = layers.find(l => l.sourceId === sourceName) - if (layer?.type === 'geojson') { renderDescBanners(layer.geojson?.features || [], layer.id, visible) } + if (layer?.type === 'geojson') { renderDescBanners(layer.geojson?.features || [], layer, visible) } } export function removeGeoJSONSource(sourceName) { diff --git a/config/importmap.rb b/config/importmap.rb index b894d640..0c11f91b 100644 --- a/config/importmap.rb +++ b/config/importmap.rb @@ -46,6 +46,8 @@ # render markdown pin "marked", preload: false # @18.0.13 +# https://github.com/cure53/DOMPurify, strips scripts and event handlers from rendered markdown +pin "dompurify", preload: false # @3.4.15 # https://github.com/mapbox/mapbox-gl-draw # Unminified + locally patched, see the header of the vendored file diff --git a/spec/features/feature_edit_point_spec.rb b/spec/features/feature_edit_point_spec.rb index c36da888..34f8f7f9 100644 --- a/spec/features/feature_edit_point_spec.rb +++ b/spec/features/feature_edit_point_spec.rb @@ -65,6 +65,17 @@ expect(page).not_to have_css(".desc-banner") end + it "strips scripts and event handlers from the description on the map" do + click_button "Add description" + find(:css, ".CodeMirror textarea", visible: false) + .set(' plain ') + find("#desc-shape-ui [data-desc-shape='banner']").click + expect(page).to have_css(".desc-banner img") + expect(page.evaluate_script("document.querySelector('.desc-banner img').hasAttribute('onerror')")).to be false + expect(page.evaluate_script("window.pwned")).to be_nil + expect(page).to have_css(".desc-banner", text: "plain") + end + it "can update desc" do expect(page).not_to have_selector("#feature-desc-input") click_button "Add description" @@ -223,6 +234,17 @@ end end + context "with a clustered layer" do + let(:point) { create(:feature, :point_middle, desc: "Clustered", properties: { "show-desc" => "banner" }) } + let(:map) { create(:map, features: [ point ]).tap { |m| m.layers.first.update!(cluster: true) } } + + it "shows the banner only above the cluster zoom" do + expect(page).to have_css(".desc-banner.hidden", visible: :all) + page.execute_script("window.map.setZoom(15)") + expect(page).to have_css(".desc-banner:not(.hidden)", text: "Clustered") + end + end + it "can copy feature via context menu" do click_coord("#maplibre-map", 512, 430, button: :right) expect(page).to have_text("Copy") diff --git a/vendor/javascript/dompurify.js b/vendor/javascript/dompurify.js new file mode 100644 index 00000000..88b63bfb --- /dev/null +++ b/vendor/javascript/dompurify.js @@ -0,0 +1,10 @@ +// dompurify@3.4.15 downloaded from https://cdn.jsdelivr.net/npm/dompurify@3.4.15/dist/purify.es.min.mjs + +/** + * Minified by jsDelivr using Terser v5.48.0. + * Original file: /npm/dompurify@3.4.15/dist/purify.es.mjs + * + * Do NOT use SRI with dynamically generated files! More information: https://www.jsdelivr.com/using-sri-with-dynamic-files + */ +/*! @license DOMPurify 3.4.15 | (c) Cure53 and other contributors | Released under the Apache license 2.0 and Mozilla Public License 2.0 | github.com/cure53/DOMPurify/blob/3.4.15/LICENSE */ +function _arrayLikeToArray(e,t){(null==t||t>e.length)&&(t=e.length);for(var n=0,r=Array(t);n2?n-2:0),o=2;o1?t-1:0),r=1;r1?n-1:0),o=1;o2&&void 0!==arguments[2]?arguments[2]:stringToLowerCase;if(setPrototypeOf&&setPrototypeOf(e,null),!arrayIsArray(t))return e;let r=t.length;for(;r--;){let o=t[r];if("string"==typeof o){const e=n(o);e!==o&&(isFrozen(t)||(t[r]=e),o=e)}e[o]=!0}return e}function cleanArray(e){for(let t=0;t/g),TMPLIT_EXPR=seal(/\${[\w\W]*/g),DATA_ATTR=seal(/^data-[\-\w.\u00B7-\uFFFF]+$/),ARIA_ATTR=seal(/^aria-[\-\w]+$/),IS_ALLOWED_URI=seal(/^(?:(?:(?:f|ht)tps?|mailto|tel|callto|sms|cid|xmpp|matrix):|[^a-z]|[a-z+.\-]+(?:[^a-z+.\-:]|$))/i),IS_SCRIPT_OR_DATA=seal(/^(?:\w+script|data):/i),ATTR_WHITESPACE=seal(/[\u0000-\u0020\u00A0\u1680\u180E\u2000-\u2029\u205F\u3000]/g),DOCTYPE_NAME=seal(/^html$/i),CUSTOM_ELEMENT=seal(/^[a-z][.\w]*(-[.\w]+)+$/i),ELEMENT_MARKUP_PROBE=seal(/<[/\w!]/g),COMMENT_MARKUP_PROBE=seal(/<[/\w]/g),FALLBACK_TAG_CLOSE=seal(/<\/no(script|embed|frames)/i),SELF_CLOSING_TAG=seal(/\/>/i),NODE_TYPE={element:1,attribute:2,text:3,cdataSection:4,entityReference:5,entityNode:6,processingInstruction:7,comment:8,document:9,documentType:10,documentFragment:11,notation:12},LITERAL_TEXT_ELEMENT_NAMES=["style","script","xmp","iframe","noembed","noframes","plaintext","noscript"],LITERAL_TEXT_ELEMENTS=freeze(addToSet({},LITERAL_TEXT_ELEMENT_NAMES)),LITERAL_TEXT_CLOSE=function(){const e={};return arrayForEach(LITERAL_TEXT_ELEMENT_NAMES,t=>{e[t]=seal(new RegExp("])","i"))}),freeze(e)}(),getGlobal=function(){return"undefined"==typeof window?null:window},_createTrustedTypesPolicy=function(e,t){if("object"!=typeof e||"function"!=typeof e.createPolicy)return null;let n=null;const r="data-tt-policy-suffix";t&&t.hasAttribute(r)&&(n=t.getAttribute(r));const o="dompurify"+(n?"#"+n:"");try{return e.createPolicy(o,{createHTML:e=>e,createScriptURL:e=>e})}catch(e){return console.warn("TrustedTypes policy "+o+" could not be created."),null}},_createHooksMap=function(){return{afterSanitizeAttributes:[],afterSanitizeElements:[],afterSanitizeShadowDOM:[],beforeSanitizeAttributes:[],beforeSanitizeElements:[],beforeSanitizeShadowDOM:[],uponSanitizeAttribute:[],uponSanitizeElement:[],uponSanitizeShadowNode:[]}},_resolveSetOption=function(e,t,n,r){return objectHasOwnProperty(e,t)&&arrayIsArray(e[t])?addToSet(r.base?clone(r.base):{},e[t],r.transform):n},_resolveObjectOption=function(e,t,n){const r=objectHasOwnProperty(e,t)?e[t]:void 0;return r&&"object"==typeof r?clone(r):n()};function createDOMPurify(){let e=arguments.length>0&&void 0!==arguments[0]?arguments[0]:getGlobal();const t=e=>createDOMPurify(e);if(t.version="3.4.15",t.removed=[],!e||!e.document||e.document.nodeType!==NODE_TYPE.document||!e.Element)return t.isSupported=!1,t;let n=e.document;const r=n,o=r.currentScript;e.DocumentFragment;const a=e.HTMLTemplateElement,i=e.Node,l=e.Element,s=e.NodeFilter,c=e.NamedNodeMap;void 0===c&&(e.NamedNodeMap||e.MozNamedAttrMap),e.HTMLFormElement;const u=e.DOMParser,p=e.trustedTypes,f=l.prototype,m=lookupGetter(f,"cloneNode"),d=lookupGetter(f,"remove"),y=lookupGetter(f,"removeAttributeNode"),g=lookupGetter(f,"nextSibling"),h=lookupGetter(f,"childNodes"),T=lookupGetter(f,"parentNode"),E=lookupGetter(f,"shadowRoot"),b=lookupGetter(f,"attributes"),S=i&&i.prototype?lookupGetter(i.prototype,"nodeType"):null,_=i&&i.prototype?lookupGetter(i.prototype,"nodeName"):null,A=i&&i.prototype?lookupGetter(i.prototype,"ownerDocument"):null,O=function(e){return S?S(e):e.nodeType},w=function(e){return _?_(e):e.nodeName};if("function"==typeof a){const e=n.createElement("template");e.content&&e.content.ownerDocument&&(n=e.content.ownerDocument)}let v,R,N="",x=!1,P=0;const D=function(){if(P>0)throw typeErrorCreate('A configured TRUSTED_TYPES_POLICY callback (createHTML or createScriptURL) must not call DOMPurify.sanitize, as that causes infinite recursion. Do not pass a policy whose callbacks wrap DOMPurify as TRUSTED_TYPES_POLICY; see the "DOMPurify and Trusted Types" section of the README.')},L=function(e){D(),P++;try{return v.createHTML(e)}finally{P--}},C=n,I=C.implementation,M=C.createNodeIterator,k=C.createDocumentFragment,z=C.getElementsByTagName,U=r.importNode;let F=_createHooksMap();t.isSupported="function"==typeof entries&&"function"==typeof T&&I&&void 0!==I.createHTMLDocument;const H=MUSTACHE_EXPR,j=ERB_EXPR,Y=TMPLIT_EXPR,G=DATA_ATTR,B=ARIA_ATTR,W=IS_SCRIPT_OR_DATA,X=ATTR_WHITESPACE,$=CUSTOM_ELEMENT;let K=IS_ALLOWED_URI,q=null;const V=addToSet({},[...html$1,...svg$1,...svgFilters,...mathMl$1,...text]);let Z=null;const J=addToSet({},[...html,...svg,...mathMl,...xml]);let Q=Object.seal(create(null,{tagNameCheck:{writable:!0,configurable:!1,enumerable:!0,value:null},attributeNameCheck:{writable:!0,configurable:!1,enumerable:!0,value:null},allowCustomizedBuiltInElements:{writable:!0,configurable:!1,enumerable:!0,value:!1}})),ee=null,te=null;const ne=Object.seal(create(null,{tagCheck:{writable:!0,configurable:!1,enumerable:!0,value:null},attributeCheck:{writable:!0,configurable:!1,enumerable:!0,value:null}}));let re=!0,oe=!0,ae=!1,ie=!0,le=!1,se=!0,ce=!1,ue=!1,pe=null,fe=null,me=!1,de=!1,ye=!1,ge=!1,he=!0,Te=!1;const Ee="user-content-";let be=!0,Se=!1,_e={},Ae=null;const Oe=addToSet({},["annotation-xml","audio","colgroup","desc","foreignobject","head","iframe","math","mi","mn","mo","ms","mtext","noembed","noframes","noscript","plaintext","script","selectedcontent","style","svg","template","thead","title","video","xmp"]);let we=null;const ve=addToSet({},["audio","video","img","source","image","track"]);let Re=null;const Ne=addToSet({},["alt","class","for","id","label","name","pattern","placeholder","role","summary","title","value","style","xmlns"]),xe="http://www.w3.org/1998/Math/MathML",Pe="http://www.w3.org/2000/svg",De="http://www.w3.org/1999/xhtml";let Le=De,Ce=!1,Ie=null;const Me=addToSet({},[xe,Pe,De],stringToString),ke=freeze(["mi","mo","mn","ms","mtext"]);let ze=addToSet({},ke);const Ue=freeze(["annotation-xml"]);let Fe=addToSet({},Ue);const He=addToSet({},["title","style","font","a","script"]);let je=null;const Ye=["application/xhtml+xml","text/html"];let Ge=null,Be=null;const We=n.createElement("form"),Xe=function(e){return e instanceof RegExp||e instanceof Function},$e=function(){let e=arguments.length>0&&void 0!==arguments[0]?arguments[0]:{};if(Be&&Be===e)return;e&&"object"==typeof e||(e={}),e=clone(e),je=-1===Ye.indexOf(e.PARSER_MEDIA_TYPE)?"text/html":e.PARSER_MEDIA_TYPE,Ge="application/xhtml+xml"===je?stringToString:stringToLowerCase,q=_resolveSetOption(e,"ALLOWED_TAGS",V,{transform:Ge}),Z=_resolveSetOption(e,"ALLOWED_ATTR",J,{transform:Ge}),Ie=_resolveSetOption(e,"ALLOWED_NAMESPACES",Me,{transform:stringToString}),Re=_resolveSetOption(e,"ADD_URI_SAFE_ATTR",Ne,{transform:Ge,base:Ne}),we=_resolveSetOption(e,"ADD_DATA_URI_TAGS",ve,{transform:Ge,base:ve}),Ae=_resolveSetOption(e,"FORBID_CONTENTS",Oe,{transform:Ge}),ee=_resolveSetOption(e,"FORBID_TAGS",clone({}),{transform:Ge}),te=_resolveSetOption(e,"FORBID_ATTR",clone({}),{transform:Ge}),_e=!!objectHasOwnProperty(e,"USE_PROFILES")&&(e.USE_PROFILES&&"object"==typeof e.USE_PROFILES?clone(e.USE_PROFILES):e.USE_PROFILES),re=!1!==e.ALLOW_ARIA_ATTR,oe=!1!==e.ALLOW_DATA_ATTR,ae=e.ALLOW_UNKNOWN_PROTOCOLS||!1,ie=!1!==e.ALLOW_SELF_CLOSE_IN_ATTR,le=e.SAFE_FOR_TEMPLATES||!1,se=!1!==e.SAFE_FOR_XML,ce=e.WHOLE_DOCUMENT||!1,de=e.RETURN_DOM||!1,ye=e.RETURN_DOM_FRAGMENT||!1,ge=e.RETURN_TRUSTED_TYPE||!1,me=e.FORCE_BODY||!1,he=!1!==e.SANITIZE_DOM,Te=e.SANITIZE_NAMED_PROPS||!1,be=!1!==e.KEEP_CONTENT,Se=e.IN_PLACE||!1,K=isRegex(e.ALLOWED_URI_REGEXP)?e.ALLOWED_URI_REGEXP:IS_ALLOWED_URI,Le="string"==typeof e.NAMESPACE?e.NAMESPACE:De,ze=_resolveObjectOption(e,"MATHML_TEXT_INTEGRATION_POINTS",()=>addToSet({},ke)),Fe=_resolveObjectOption(e,"HTML_INTEGRATION_POINTS",()=>addToSet({},Ue));const t=_resolveObjectOption(e,"CUSTOM_ELEMENT_HANDLING",()=>create(null));if(Q=create(null),objectHasOwnProperty(t,"tagNameCheck")&&Xe(t.tagNameCheck)&&(Q.tagNameCheck=t.tagNameCheck),objectHasOwnProperty(t,"attributeNameCheck")&&Xe(t.attributeNameCheck)&&(Q.attributeNameCheck=t.attributeNameCheck),objectHasOwnProperty(t,"allowCustomizedBuiltInElements")&&"boolean"==typeof t.allowCustomizedBuiltInElements&&(Q.allowCustomizedBuiltInElements=t.allowCustomizedBuiltInElements),seal(Q),le&&(oe=!1),ye&&(de=!0),_e&&(q=addToSet({},text),Z=create(null),!0===_e.html&&(addToSet(q,html$1),addToSet(Z,html)),!0===_e.svg&&(addToSet(q,svg$1),addToSet(Z,svg),addToSet(Z,xml)),!0===_e.svgFilters&&(addToSet(q,svgFilters),addToSet(Z,svg),addToSet(Z,xml)),!0===_e.mathMl&&(addToSet(q,mathMl$1),addToSet(Z,mathMl),addToSet(Z,xml))),ne.tagCheck=null,ne.attributeCheck=null,objectHasOwnProperty(e,"ADD_TAGS")&&("function"==typeof e.ADD_TAGS?ne.tagCheck=e.ADD_TAGS:arrayIsArray(e.ADD_TAGS)&&(q===V&&(q=clone(q)),addToSet(q,e.ADD_TAGS,Ge))),objectHasOwnProperty(e,"ADD_ATTR")&&("function"==typeof e.ADD_ATTR?ne.attributeCheck=e.ADD_ATTR:arrayIsArray(e.ADD_ATTR)&&(Z===J&&(Z=clone(Z)),addToSet(Z,e.ADD_ATTR,Ge))),objectHasOwnProperty(e,"ADD_FORBID_CONTENTS")&&arrayIsArray(e.ADD_FORBID_CONTENTS)&&(Ae===Oe&&(Ae=clone(Ae)),addToSet(Ae,e.ADD_FORBID_CONTENTS,Ge)),be&&(q["#text"]=!0),ce&&addToSet(q,["html","head","body"]),q.table&&(addToSet(q,["tbody"]),delete ee.tbody),e.TRUSTED_TYPES_POLICY){if("function"!=typeof e.TRUSTED_TYPES_POLICY.createHTML)throw typeErrorCreate('TRUSTED_TYPES_POLICY configuration option must provide a "createHTML" hook.');if("function"!=typeof e.TRUSTED_TYPES_POLICY.createScriptURL)throw typeErrorCreate('TRUSTED_TYPES_POLICY configuration option must provide a "createScriptURL" hook.');const t=v;v=e.TRUSTED_TYPES_POLICY;try{N=L("")}catch(e){throw v=t,e}}else null===e.TRUSTED_TYPES_POLICY?(v=void 0,N=""):(void 0===v&&(x||(R=_createTrustedTypesPolicy(p,o),x=!0),v=R),v&&"string"==typeof N&&(N=L("")));freeze&&freeze(e),Be=e},Ke=addToSet({},[...svg$1,...svgFilters,...svgDisallowed]),qe=addToSet({},[...mathMl$1,...mathMlDisallowed]),Ve=function(e){let t=T(e);t&&t.tagName||(t={namespaceURI:Le,tagName:"template"});const n=stringToLowerCase(e.tagName),r=stringToLowerCase(t.tagName);return!!Ie[e.namespaceURI]&&(e.namespaceURI===Pe?function(e,t,n){return t.namespaceURI===De?"svg"===e:t.namespaceURI===xe?"svg"===e&&("annotation-xml"===n||ze[n]):Boolean(Ke[e])}(n,t,r):e.namespaceURI===xe?function(e,t,n){return t.namespaceURI===De?"math"===e:t.namespaceURI===Pe?"math"===e&&Fe[n]:Boolean(qe[e])}(n,t,r):e.namespaceURI===De?function(e,t,n){return!(t.namespaceURI===Pe&&!Fe[n])&&!(t.namespaceURI===xe&&!ze[n])&&!qe[e]&&(He[e]||!Ke[e])}(n,t,r):!("application/xhtml+xml"!==je||!Ie[e.namespaceURI]))},Ze=function(e){arrayPush(t.removed,{element:e});try{T(e).removeChild(e)}catch(t){if(d(e),!T(e))throw typeErrorCreate("a node selected for removal could not be detached from its tree and cannot be safely returned; refusing to sanitize in place")}},Je=function(e,t,n){try{y(e,t)}catch(t){try{e.removeAttribute(n)}catch(e){}}},Qe=function(e){nt(e);const t=h(e);if(t){const e=[];arrayForEach(t,t=>{arrayPush(e,t)}),arrayForEach(e,e=>{try{d(e)}catch(e){}})}const n=b(e);if(n)for(let t=n.length-1;t>=0;--t){const r=n[t],o=r&&r.name;"string"==typeof o&&Je(e,r,o)}},et=function(e,n,r){if(!r)try{r=n.getAttributeNode(e)}catch(e){r=null}arrayPush(t.removed,{attribute:r||null,from:n});try{r?y(n,r):n.removeAttribute(e)}catch(t){try{n.removeAttribute(e)}catch(e){}}if("is"===e)if(de||ye)try{Ze(n)}catch(e){}else try{n.setAttribute(e,"")}catch(e){}},tt=function(e){const t=b(e);if(t)for(let n=t.length-1;n>=0;--n){const r=t[n],o=r&&r.name;"string"!=typeof o||Z[Ge(o)]||Je(e,r,o)}},nt=function(e){const t=[e];for(;t.length>0;){const e=t.pop();O(e)===NODE_TYPE.element&&tt(e);const n=h(e);if(n)for(let e=n.length-1;e>=0;--e)t.push(n[e])}},rt=function(e,t){return!!se&&("patchsrc"===e||"for"===e&&"label"!==t&&"output"!==t)},ot=function(e){let t=null,r=null;if(me)e=""+e;else{const t=stringMatch(e,/^[\r\n\t ]+/);r=t&&t[0]}"application/xhtml+xml"===je&&Le===De&&(e=''+e+"");const o=v?L(e):e;if(Le===De)try{t=(new u).parseFromString(o,je)}catch(e){}if(!t||!t.documentElement){t=I.createDocument(Le,"template",null);try{t.documentElement.innerHTML=Ce?N:o}catch(e){}}const a=t.body||t.documentElement;return e&&r&&a.insertBefore(n.createTextNode(r),a.childNodes[0]||null),Le===De?z.call(t,ce?"html":"body")[0]:ce?t.documentElement:a},at=function(e){const t=A?A(e):e.ownerDocument;return M.call(t||e,e,s.SHOW_ELEMENT|s.SHOW_COMMENT|s.SHOW_TEXT|s.SHOW_PROCESSING_INSTRUCTION|s.SHOW_CDATA_SECTION,null)},it=function(e){return e=stringReplace(e,H," "),e=stringReplace(e,j," "),e=stringReplace(e,Y," ")},lt=function(e){var t;e.normalize();const n=A?A(e):e.ownerDocument,r=M.call(n||e,e,s.SHOW_TEXT|s.SHOW_COMMENT|s.SHOW_CDATA_SECTION|s.SHOW_PROCESSING_INSTRUCTION,null);let o=r.nextNode();for(;o;)o.data=it(o.data),o=r.nextNode();const a=null===(t=e.querySelectorAll)||void 0===t?void 0:t.call(e,"template");a&&arrayForEach(a,e=>{ct(e.content)&<(e.content)})},st=function(e){const t=_?_(e):null;return"string"==typeof t&&("form"===Ge(t)&&("string"!=typeof e.nodeName||"string"!=typeof e.textContent||"function"!=typeof e.removeChild||e.attributes!==b(e)||"function"!=typeof e.removeAttribute||"function"!=typeof e.removeAttributeNode||"function"!=typeof e.getAttributeNode||"function"!=typeof e.setAttribute||"string"!=typeof e.namespaceURI||"function"!=typeof e.insertBefore||"function"!=typeof e.hasChildNodes||e.nodeType!==S(e)||e.childNodes!==h(e)))},ct=function(e){if(!S||"object"!=typeof e||null===e)return!1;try{return S(e)===NODE_TYPE.documentFragment}catch(e){return!1}},ut=function(e){if(!S||"object"!=typeof e||null===e)return!1;try{return"number"==typeof S(e)}catch(e){return!1}};function pt(e,n,r){0!==e.length&&arrayForEach(e,e=>{e.call(t,n,r,Be)})}const ft=function(e,t){if(e instanceof RegExp)return regExpTest(e,t);if(e instanceof Function){for(var n=arguments.length,r=new Array(n>2?n-2:0),o=2;o=0;--o){const a=e===n?m(r[o],!0):r[o];t.insertBefore(a,g(e))}}return Ze(e),!0}(e,r,n);return!1===t&&pt(F.afterSanitizeElements,e,null),t}if(O(e)===NODE_TYPE.element&&!Ve(e))return Ze(e),!0;if(("noscript"===r||"noembed"===r||"noframes"===r)&®ExpTest(FALLBACK_TAG_CLOSE,e.innerHTML))return Ze(e),!0;if(le&&e.nodeType===NODE_TYPE.text){const n=it(e.textContent);e.textContent!==n&&(arrayPush(t.removed,{element:e.cloneNode()}),e.textContent=n)}return pt(F.afterSanitizeElements,e,null),!1},gt=function(e,t,r){if(te[t])return!1;if(rt(t,e))return!1;if(he&&("id"===t||"name"===t)&&(r in n||r in We))return!1;const o=Z[t]||ne.attributeCheck instanceof Function&&ne.attributeCheck(t,e);return!(!oe||!regExpTest(G,t))||(!(!re||!regExpTest(B,t))||(o?!!Re[t]||(!!regExpTest(K,stringReplace(r,X,""))||(!("src"!==t&&"xlink:href"!==t&&"href"!==t||"script"===e||0!==stringIndexOf(r,"data:")||!we[e])||(!(!ae||regExpTest(W,stringReplace(r,X,"")))||!r))):Tt(e)&&ft(Q.tagNameCheck,e)&&ft(Q.attributeNameCheck,t,e)||"is"===t&&Q.allowCustomizedBuiltInElements&&ft(Q.tagNameCheck,r)))},ht=addToSet({},["annotation-xml","color-profile","font-face","font-face-format","font-face-name","font-face-src","font-face-uri","missing-glyph"]),Tt=function(e){return!ht[stringToLowerCase(e)]&®ExpTest($,e)},Et=function(e,t,n,r){if(v&&"object"==typeof p&&"function"==typeof p.getAttributeType&&!n)switch(p.getAttributeType(e,t)){case"TrustedHTML":return L(r);case"TrustedScriptURL":return function(e){D(),P++;try{return v.createScriptURL(e)}finally{P--}}(r)}return r},bt=function(e,t,n,r){try{return n?e.setAttributeNS(n,t,r):e.setAttribute(t,r),!st(e)||(Ze(e),!1)}catch(n){return et(t,e),!1}},St=function(e){pt(F.beforeSanitizeAttributes,e,null);const n=e.attributes;if(!n||st(e))return;Z=mt(F.uponSanitizeAttribute,Z,J,fe);const r={attrName:"",attrValue:"",keepAttr:!0,allowedAttributes:Z,forceKeepAttr:void 0};let o=n.length;const a=Ge(e.nodeName);for(;o--;){const i=n[o],l=i.name,s=i.namespaceURI,c=i.value,u=Ge(l),p=c;let f="value"===l?p:stringTrim(p),m=!1;if(r.attrName=u,r.attrValue=f,r.keepAttr=!0,r.forceKeepAttr=void 0,pt(F.uponSanitizeAttribute,e,r),f=r.attrValue,!Te||"id"!==u&&"name"!==u||0===stringIndexOf(f,Ee)||(et(l,e,i),f=Ee+f,m=!0),se&®ExpTest(/((--!?|])>)|<\/(style|script|title|xmp|textarea|noscript|iframe|noembed|noframes)/i,f))et(l,e,i);else if("attributename"===u&&stringMatch(f,"href"))et(l,e,i);else if(!r.forceKeepAttr)if(r.keepAttr)if(ie||!regExpTest(SELF_CLOSING_TAG,f))if(le&&(f=it(f)),gt(a,u,f)){if(f=Et(a,u,s,f),f!==p){bt(e,l,s,f)&&m&&arrayPop(t.removed)}}else et(l,e,i);else et(l,e,i);else et(l,e,i)}pt(F.afterSanitizeAttributes,e,null)},_t=function(e){let t=null;const n=at(e);for(pt(F.beforeSanitizeShadowDOM,e,null);t=n.nextNode();)if(pt(F.uponSanitizeShadowNode,t,null),yt(t,e),St(t),ct(t.content)&&_t(t.content),O(t)===NODE_TYPE.element){const e=E(t);ct(e)&&(At(e),_t(e))}pt(F.afterSanitizeShadowDOM,e,null)},At=function(e){const t=[{node:e,shadow:null}];for(;t.length>0;){const e=t.pop();if(e.shadow){_t(e.shadow);continue}const n=e.node,r=O(n)===NODE_TYPE.element,o=h(n);if(o)for(let e=o.length-1;e>=0;--e)t.push({node:o[e],shadow:null});if(r){const e=_?_(n):null;if("string"==typeof e&&"template"===Ge(e)){const e=n.content;ct(e)&&t.push({node:e,shadow:null})}}if(r){const e=E(n);ct(e)&&t.push({node:null,shadow:e},{node:e,shadow:null})}}};return t.sanitize=function(e){let n=arguments.length>1&&void 0!==arguments[1]?arguments[1]:{},o=null,a=null,i=null,l=null;if(Ce=!e,Ce&&(e="\x3c!--\x3e"),"string"!=typeof e&&!ut(e)&&"string"!=typeof(e=stringifyValue(e)))throw typeErrorCreate("dirty is not a string, aborting");if(!t.isSupported)return e;ue?(q=pe,Z=fe):$e(n),(F.uponSanitizeElement.length>0||F.uponSanitizeAttribute.length>0)&&(q=clone(q)),F.uponSanitizeAttribute.length>0&&(Z=clone(Z)),t.removed=[];const s=Se&&"string"!=typeof e&&ut(e);if(s){!function(e){if(!se)return;const t=[e];for(;t.length>0;){const e=t.pop(),n=O(e);if(n===NODE_TYPE.processingInstruction||n===NODE_TYPE.comment&®ExpTest(COMMENT_MARKUP_PROBE,e.data)){try{d(e)}catch(e){}continue}if(n===NODE_TYPE.element){const t=e,n=Ge(w(e));try{t.hasAttribute&&t.hasAttribute("patchsrc")&&t.removeAttribute("patchsrc"),t.hasAttribute&&t.hasAttribute("for")&&rt("for",n)&&t.removeAttribute("for")}catch(e){}}const r=h(e);if(r)for(let e=r.length-1;e>=0;--e)t.push(r[e])}}(e);const t=w(e);if("string"==typeof t){const n=Ge(t);if(!q[n]||ee[n])throw Qe(e),typeErrorCreate("root node is forbidden and cannot be sanitized in-place")}if(st(e))throw Qe(e),typeErrorCreate("root node is clobbered and cannot be sanitized in-place");try{At(e)}catch(t){throw Qe(e),t}}else if(ut(e))o=ot("\x3c!----\x3e"),a=o.ownerDocument.importNode(e,!0),a.nodeType===NODE_TYPE.element&&"BODY"===a.nodeName||"HTML"===a.nodeName?o=a:o.appendChild(a),At(o);else{if(!de&&!le&&!ce&&-1===e.indexOf("<"))return v&&ge?L(e):e;if(o=ot(e),!o)return de?null:ge?N:""}o&&me&&Ze(o.firstChild);const c=s?e:o;try{const e=at(c);for(;i=e.nextNode();)yt(i,c),St(i),ct(i.content)&&_t(i.content)}catch(n){throw s&&(Qe(e),arrayForEach(t.removed,e=>{e.element&&nt(e.element)})),n}if(s)return arrayForEach(t.removed,e=>{e.element&&nt(e.element)}),le&<(e),e;if(de){if(le&<(o),ye)for(l=k.call(o.ownerDocument);o.firstChild;)l.appendChild(o.firstChild);else l=o;return(Z.shadowroot||Z.shadowrootmode)&&(l=U.call(r,l,!0)),l}let u=ce?o.outerHTML:o.innerHTML;return ce&&q["!doctype"]&&o.ownerDocument&&o.ownerDocument.doctype&&o.ownerDocument.doctype.name&®ExpTest(DOCTYPE_NAME,o.ownerDocument.doctype.name)&&(u="\n"+u),le&&(u=it(u)),v&&ge?L(u):u},t.setConfig=function(){$e(arguments.length>0&&void 0!==arguments[0]?arguments[0]:{}),ue=!0,pe=q,fe=Z},t.clearConfig=function(){Be=null,ue=!1,pe=null,fe=null,v=R,N=""},t.isValidAttribute=function(e,t,n){Be||$e({});const r=Ge(e),o=Ge(t);return gt(r,o,n)},t.addHook=function(e,t){"function"==typeof t&&objectHasOwnProperty(F,e)&&arrayPush(F[e],t)},t.removeHook=function(e,t){if(objectHasOwnProperty(F,e)){if(void 0!==t){const n=arrayLastIndexOf(F[e],t);return-1===n?void 0:arraySplice(F[e],n,1)[0]}return arrayPop(F[e])}},t.removeHooks=function(e){objectHasOwnProperty(F,e)&&(F[e]=[])},t.removeAllHooks=function(){F=_createHooksMap()},t}var purify=createDOMPurify();export{purify as default};