A fullscreen toggle on every map

b060813 · Gabriel Morell · 2026-09-01 02:46

10 files +279 -0
Message
{commit_body(@commit)}

Files changed

modified apps/room_sanctum/assets/js/app.js
+9 −0
@@ -48,6 +48,7 @@ window.Base64 = Base64;
48 48 import L from 'leaflet'
49 49 import 'leaflet-centermarker'
50 50 import { addBasemap } from './leaflet/basemap'
51 +import { addFullscreenControl } from './leaflet/fullscreen'
51 52 import LeafletMap from './hooks/leaflet_map.js'
52 53
53 54 import LeafletMapBridge from './hooks/leaflet_map_bridge.js'
@@ -158,6 +159,7 @@ Hooks.mkMap = {
158 159 centerMarker.addTo(map);
159 160
160 161 addBasemap(map, { maxZoom: 19 });
162 + addFullscreenControl(map);
161 163
162 164 // Search functionality
163 165 const searchInput = document.getElementById('location-search');
@@ -326,6 +328,7 @@ Hooks.mkTesterMap = {
326 328 // marker.addTo(map);
327 329
328 330 addBasemap(map, { maxZoom: 19 });
331 + addFullscreenControl(map);
329 332
330 333 this.handleEvent("add_marker", ({lat, lon}) => {
331 334 const marker = L.marker(L.latLng(lat, lon))
@@ -365,6 +368,12 @@ Hooks.mkShowMap = {
365 368 }
366 369
367 370 addBasemap(map, { maxZoom: 19 });
371 +
372 + // The show map is deliberately inert in the page -- it is an
373 + // illustration next to the record, not something to explore. Filling
374 + // the screen is the one moment that stops being true, so panning and
375 + // zooming come back for as long as it lasts.
376 + addFullscreenControl(map, { interactive: true });
368 377 }
369 378 }
370 379
modified apps/room_sanctum/assets/js/hooks/leaflet_map.js
+11 −0
@@ -7,6 +7,7 @@
7 7 // L itself comes from the global the Leaflet UMD bundle installs; this one is
8 8 // ours, so it has to be imported.
9 9 import { addBasemap } from '../leaflet/basemap'
10 +import { addFullscreenControl } from '../leaflet/fullscreen'
10 11
11 12 const LeafletMap = {
12 13 mounted() {
@@ -66,6 +67,11 @@ const LeafletMap = {
66 67 this.basemap.remove();
67 68 this.basemap = null;
68 69 }
70 + // Same story: it holds document-level listeners of its own.
71 + if (this.fullscreen) {
72 + this.fullscreen.remove();
73 + this.fullscreen = null;
74 + }
69 75 },
70 76
71 77 initializeMap() {
@@ -106,6 +112,11 @@ const LeafletMap = {
106 112 // Monochrome tiles, washed in the active theme colour.
107 113 this.basemap = addBasemap(this.map, { maxZoom: 18 });
108 114
115 + // this.el, not the map container: the container is absolutely positioned
116 + // inside it, so fullscreening the container alone would leave it sized to
117 + // an element still laid out at its old size.
118 + this.fullscreen = addFullscreenControl(this.map, { target: this.el });
119 +
109 120 // Initialize separate layer groups for queries, vehicles, free bikes, and stations
110 121 this.queriesLayer = L.layerGroup().addTo(this.map);
111 122 this.vehiclesLayer = L.layerGroup().addTo(this.map);
added apps/room_sanctum/assets/js/leaflet/fullscreen.js
+248 −0
@@ -0,0 +1,248 @@
1 +import L from 'leaflet'
2 +
3 +// A fullscreen toggle for any Leaflet map on the page.
4 +//
5 +// The maps are built five different ways -- three hooks in app.js, the
6 +// dashboard hook, and the <leaflet-map> web component -- so this takes a map
7 +// and the element that should fill the screen, and knows nothing else about
8 +// its caller.
9 +//
10 +// Two mechanisms, in order of preference:
11 +//
12 +// The Fullscreen API, which gives a real fullscreen window with the browser
13 +// chrome gone. It can be refused -- iOS Safari has never implemented it for
14 +// elements, and an iframe without allowfullscreen is denied -- and the
15 +// refusal arrives as a rejected promise rather than a missing method, so it
16 +// cannot be feature-detected up front.
17 +//
18 +// Failing that, a fixed-position "maximised" class covering the viewport.
19 +// Not as good (the browser chrome stays) but it works everywhere, and it is
20 +// the same button either way.
21 +const STYLE_MARK = 'data-leaflet-fullscreen-style'
22 +
23 +// The maximised fallback sits above the page but below nothing else in
24 +// particular; 9999 clears the sticky navbar, which is the only thing on these
25 +// pages that competes.
26 +const CSS = `
27 +.leaflet-fullscreen-maximised {
28 + position: fixed !important;
29 + inset: 0 !important;
30 + width: 100% !important;
31 + height: 100% !important;
32 + max-width: none !important;
33 + max-height: none !important;
34 + margin: 0 !important;
35 + border-radius: 0 !important;
36 + z-index: 9999 !important;
37 +}
38 +/* The UA sizes the fullscreen element itself, but these maps carry a height
39 + from a utility class (h-96) or an inline style, and browsers do not agree on
40 + whether the UA rule outranks those. Saying it here settles it: without this
41 + the map keeps its 400px and the rest of the screen is black. */
42 +.leaflet-fullscreen-target:fullscreen {
43 + width: 100% !important;
44 + height: 100% !important;
45 +}
46 +:fullscreen .leaflet-container,
47 +:fullscreen.leaflet-container {
48 + width: 100% !important;
49 + height: 100% !important;
50 +}
51 +.leaflet-fullscreen button {
52 + display: block;
53 + width: 30px;
54 + height: 30px;
55 + padding: 0;
56 + line-height: 0;
57 + color: #334155;
58 + background: #fff;
59 + border: none;
60 + border-radius: 4px;
61 + cursor: pointer;
62 +}
63 +.leaflet-fullscreen button:hover {
64 + background: #f1f5f9;
65 +}
66 +.leaflet-fullscreen svg {
67 + display: inline-block;
68 + vertical-align: middle;
69 +}
70 +`
71 +
72 +// Styles go into whichever root the map lives in -- the document for the hook
73 +// maps, the shadow root for the web component, which document stylesheets
74 +// cannot reach -- and, for a shadow root, into the document as well: the
75 +// button is inside the shadow tree but the element that takes the maximised
76 +// class is the host, which sits in the document and is styled by it.
77 +function injectStyleInto(host) {
78 + if (!host || host.querySelector(`style[${STYLE_MARK}]`)) return
79 + const style = document.createElement('style')
80 + style.setAttribute(STYLE_MARK, '')
81 + style.textContent = CSS
82 + host.appendChild(style)
83 +}
84 +
85 +function injectStyle(root) {
86 + injectStyleInto(root === document ? document.head : root)
87 + if (root !== document) injectStyleInto(document.head)
88 +}
89 +
90 +const ENTER_ICON = `
91 + <svg viewBox="0 0 24 24" width="16" height="16" aria-hidden="true" fill="none"
92 + stroke="currentColor" stroke-width="2.2" stroke-linecap="round" stroke-linejoin="round">
93 + <path d="M3 9V3h6M21 9V3h-6M3 15v6h6M21 15v6h-6" />
94 + </svg>`
95 +
96 +const EXIT_ICON = `
97 + <svg viewBox="0 0 24 24" width="16" height="16" aria-hidden="true" fill="none"
98 + stroke="currentColor" stroke-width="2.2" stroke-linecap="round" stroke-linejoin="round">
99 + <path d="M9 3v6H3M15 3v6h6M9 21v-6H3M15 21v-6h6" />
100 + </svg>`
101 +
102 +// Safari still needs the prefixed names.
103 +function fullscreenElement(doc) {
104 + return doc.fullscreenElement || doc.webkitFullscreenElement || null
105 +}
106 +
107 +function requestFullscreen(el) {
108 + const request = el.requestFullscreen || el.webkitRequestFullscreen
109 + if (!request) return Promise.reject(new Error('unsupported'))
110 + // Chrome's prefixed version returns undefined rather than a promise.
111 + return Promise.resolve(request.call(el, { navigationUI: 'hide' }))
112 +}
113 +
114 +function exitFullscreen(doc) {
115 + const exit = doc.exitFullscreen || doc.webkitExitFullscreen
116 + return exit ? Promise.resolve(exit.call(doc)) : Promise.resolve()
117 +}
118 +
119 +// The interactions a static map turns off. Fullscreen is the one place they
120 +// are worth having back: a map filling the screen that cannot be panned is
121 +// just a bigger picture.
122 +const HANDLERS = ['dragging', 'touchZoom', 'scrollWheelZoom', 'doubleClickZoom', 'boxZoom', 'keyboard']
123 +
124 +/**
125 + * Add a fullscreen toggle to `map`.
126 + *
127 + * Options:
128 + * target element to fill the screen; defaults to the map's container.
129 + * Pass the outer element where the map sits in a wrapper -- the
130 + * shadow host, or the hook's own element.
131 + * position Leaflet control position (default 'topleft', under the zoom
132 + * control)
133 + * interactive true to enable panning and zooming while fullscreen on a map
134 + * whose interactions are otherwise off, restoring them on exit
135 + * title button tooltip (default 'Fullscreen')
136 + */
137 +export function addFullscreenControl(map, opts = {}) {
138 + const container = map.getContainer()
139 + const target = opts.target || container
140 + const doc = container.ownerDocument
141 + const root = container.getRootNode ? container.getRootNode() : document
142 +
143 + injectStyle(root)
144 + target.classList.add('leaflet-fullscreen-target')
145 +
146 + let maximised = false
147 + let restoreHandlers = null
148 + let button = null
149 +
150 + const isOn = () => maximised || fullscreenElement(doc) === target
151 +
152 + const paint = () => {
153 + if (!button) return
154 + const on = isOn()
155 + button.innerHTML = on ? EXIT_ICON : ENTER_ICON
156 + button.title = on ? 'Exit fullscreen' : opts.title || 'Fullscreen'
157 + button.setAttribute('aria-label', button.title)
158 + button.setAttribute('aria-pressed', on ? 'true' : 'false')
159 + }
160 +
161 + // Leaflet measured the container at its old size; nothing lays out
162 + // correctly until it measures again. rAF so the browser has applied the
163 + // new box first.
164 + const resize = () => {
165 + requestAnimationFrame(() => map.invalidateSize({ animate: false }))
166 + }
167 +
168 + const setInteraction = (on) => {
169 + if (!opts.interactive) return
170 + if (on) {
171 + restoreHandlers = HANDLERS.filter((name) => map[name] && !map[name].enabled())
172 + restoreHandlers.forEach((name) => map[name].enable())
173 + } else if (restoreHandlers) {
174 + restoreHandlers.forEach((name) => map[name] && map[name].disable())
175 + restoreHandlers = null
176 + }
177 + }
178 +
179 + const settle = () => {
180 + setInteraction(isOn())
181 + paint()
182 + resize()
183 + }
184 +
185 + // Only for the fallback; a real fullscreen exit is the browser's own.
186 + const onKeydown = (e) => {
187 + if (e.key === 'Escape' && maximised) {
188 + e.preventDefault()
189 + maximise(false)
190 + }
191 + }
192 +
193 + function maximise(on) {
194 + maximised = on
195 + target.classList.toggle('leaflet-fullscreen-maximised', on)
196 + if (on) doc.addEventListener('keydown', onKeydown)
197 + else doc.removeEventListener('keydown', onKeydown)
198 + settle()
199 + }
200 +
201 + const toggle = () => {
202 + if (maximised) return maximise(false)
203 +
204 + if (fullscreenElement(doc) === target) {
205 + exitFullscreen(doc).catch(() => {})
206 + return
207 + }
208 +
209 + requestFullscreen(target).catch(() => maximise(true))
210 + }
211 +
212 + // Both spellings: Safari fires only the prefixed one.
213 + doc.addEventListener('fullscreenchange', settle)
214 + doc.addEventListener('webkitfullscreenchange', settle)
215 +
216 + const control = L.control({ position: opts.position || 'topleft' })
217 +
218 + control.onAdd = () => {
219 + const wrap = L.DomUtil.create('div', 'leaflet-bar leaflet-fullscreen')
220 + button = L.DomUtil.create('button', '', wrap)
221 + button.type = 'button'
222 + paint()
223 +
224 + // Without this a click on the button also reaches the map underneath,
225 + // which reads it as a double click and zooms in behind the change.
226 + L.DomEvent.disableClickPropagation(wrap)
227 + L.DomEvent.on(button, 'click', toggle)
228 +
229 + return wrap
230 + }
231 +
232 + control.addTo(map)
233 +
234 + return {
235 + control,
236 + isFullscreen: isOn,
237 + toggle,
238 + remove() {
239 + if (maximised) maximise(false)
240 + target.classList.remove('leaflet-fullscreen-target')
241 + doc.removeEventListener('fullscreenchange', settle)
242 + doc.removeEventListener('webkitfullscreenchange', settle)
243 + doc.removeEventListener('keydown', onKeydown)
244 + control.remove()
245 + button = null
246 + }
247 + }
248 +}
modified apps/room_sanctum/assets/js/leaflet/leaflet-map.js
+11 −0
@@ -5,6 +5,7 @@ import L from 'leaflet'
5 5 // rules and lay themselves out as a grid of loose images.
6 6 import leafletCss from 'leaflet/dist/leaflet.css'
7 7 import { addBasemap } from './basemap'
8 +import { addFullscreenControl } from './fullscreen'
8 9
9 10 const template = document.createElement('template');
10 11 template.innerHTML = `
@@ -285,6 +286,11 @@ class LeafletMap extends HTMLElement {
285 286 this.watchForUserView();
286 287 this.addResetViewControl();
287 288
289 + // The host, not the map container: the container lives in the shadow
290 + // tree, and fullscreening it would take the map away from the element
291 + // whose height it is sized against.
292 + this.fullscreen = addFullscreenControl(this.map, { target: this });
293 +
288 294 // One more pass after layout settles, for the first paint.
289 295 requestAnimationFrame(() => this.map && this.map.invalidateSize({ animate: false }));
290 296
@@ -395,6 +401,11 @@ class LeafletMap extends HTMLElement {
395 401 this.basemap.remove();
396 402 this.basemap = null;
397 403 }
404 + // Likewise: it listens on the document, not on the map.
405 + if (this.fullscreen) {
406 + this.fullscreen.remove();
407 + this.fullscreen = null;
408 + }
398 409 if (this.map) {
399 410 this.map.remove();
400 411 }

Parents: 608b1f7