From 07a239fd82263d86e15fbb199f5f9b8baec56309 Mon Sep 17 00:00:00 2001 From: German Khokhlov Date: Wed, 7 Oct 2026 23:57:06 +0400 Subject: [PATCH] fix(street-level): expanded photo leaves with panel-hiding modes; EXPAND on iPhone; pill when Mapillary is off Three regressions from the slimming, found in review: - Clean View, recording and the cockpit hid the old expanded viewer with CSS; native fullscreen sits above all of that, so the photo stayed over the globe (and in recordings). While expanded, the panel now watches the classes of those modes and leaves full screen when one starts. - iPhone Safari has no element fullscreen, so EXPAND had disappeared there. Where requestFullscreen is missing but popovers exist, EXPAND opens the viewer as a top-layer popover sized to the viewport (it stays in the panel's DOM; the popover attribute is set only while open, since a closed popover is hidden). Checked in Chrome: the popover fills the viewport over the panel's backdrop filter. - Without the provider chips, a layer that was on with Mapillary switched off by a share link or tool (`0.m.0`) showed ON and drew nothing. The pill now reads OFF with a hint, and pressing it brings Mapillary back without switching the layer off. Co-Authored-By: Claude Opus 5.5 --- docs/CURRENT-STATE.md | 10 ++- src/ui/streetLevelControls.js | 105 ++++++++++++++++++++----- src/ui/streetLevelControls.test.mjs | 117 +++++++++++++++++++++++++++- src/ui/streetLevelPresentation.js | 7 +- src/ui/styles/street-level.css | 21 ++++- 5 files changed, 231 insertions(+), 29 deletions(-) diff --git a/docs/CURRENT-STATE.md b/docs/CURRENT-STATE.md index bcc6503ab..3e648a62e 100644 --- a/docs/CURRENT-STATE.md +++ b/docs/CURRENT-STATE.md @@ -2963,9 +2963,13 @@ MapillaryJS loads on the first photo. Opening one frames it with a single camera flight through the application's deferred navigation (`begin` / `reassert`): it releases tracking, is refused in the cockpit, and is skipped if newer navigation took the camera while the photo loaded; closing the photo -cancels it. EXPAND is the browser's Fullscreen API on the viewer element -(hidden where the API is missing). At phone width the viewer height comes from -the rail band so the whole photo fits. +cancels it. EXPAND is the browser's Fullscreen API on the viewer element; on +iPhone, which has no element fullscreen, the viewer opens as a top-layer +popover instead (hidden where neither API exists). Clean View, recording and +the cockpit take an expanded photo out of full screen. With the layer on but +Mapillary switched off by a link or tool, the header pill reads OFF and turns +Mapillary back on. At phone width the viewer height comes from the rail band +so the whole photo fits. Browser gates: `npm run qa:street-level -- --url ` runs against real Mapillary with a token; `npm run qa:street-level:fixtures` answers every diff --git a/src/ui/streetLevelControls.js b/src/ui/streetLevelControls.js index df302ce6c..c8f0c18e2 100644 --- a/src/ui/streetLevelControls.js +++ b/src/ui/streetLevelControls.js @@ -5,6 +5,8 @@ import { } from './streetLevelPresentation.js'; const RENDER_MODE_KEY = 'gev:street-level:render-mode'; +/** classes of the modes that hide every panel; an expanded photo leaves with them. */ +const PANEL_HIDING_MODES = ['ui-clean-view', 'recording-mode', 'cockpit-mode']; const RADIO_STEPS = Object.freeze({ ArrowRight: 1, ArrowDown: 1, @@ -108,10 +110,24 @@ export class StreetLevelControls { ); this.listen(el.viewerClose, 'click', () => this.layer.closeViewer?.()); // EXPAND is the browser's own fullscreen: it handles Esc, focus and the - // rest of the page. Without the API the button is simply not offered. - if (typeof el.viewerWrap?.requestFullscreen === 'function') { - this.listen(el.viewerExpand, 'click', () => this._toggleFullscreen()); - this.listen(document, 'fullscreenchange', () => this._syncFullscreen()); + // rest of the page. iPhone Safari has no element fullscreen, so there the + // viewer opens as a top-layer popover over the page instead (Esc and the + // button close it). With neither, the button is not offered. + this._expandMode = + typeof el.viewerWrap?.requestFullscreen === 'function' + ? 'fullscreen' + : typeof el.viewerWrap?.showPopover === 'function' + ? 'popover' + : null; + this._popoverOpen = false; + if (this._expandMode) { + this.listen(el.viewerExpand, 'click', () => this._toggleExpanded()); + if (this._expandMode === 'fullscreen') + this.listen(document, 'fullscreenchange', () => this._syncExpanded()); + else + this.listen(el.viewerWrap, 'toggle', (event) => + this._onPopoverToggle(event), + ); } else setProp(el.viewerExpand, 'hidden', true); for (const button of el.renderButtons) { this.listen(button, 'click', () => { @@ -180,10 +196,13 @@ export class StreetLevelControls { async _toggleEnabled() { const enabled = this.actions.isEnabled?.() === true; - // The pill is the only switch: switching on also brings back Mapillary - // if a share link or a tool turned it off, or the layer would draw nothing. - if (!enabled && this._state?.providerOn === false) + // The pill is the only switch. A share link or tool can switch Mapillary + // off under a layer that is on; the pill then reads OFF, and switching on + // brings Mapillary back (and the layer, if it is off too). + if (this._state?.providerOn === false) { this._setParams({ mapillary: true }); + if (enabled) return; + } try { await this.actions.setEnabled?.(!enabled); } catch (error) { @@ -198,27 +217,74 @@ export class StreetLevelControls { else this.layer.setParams?.(params); } - isViewerFullscreen() { + /** Whether EXPAND has the viewer full screen (or, on iPhone, open over the page). */ + isViewerExpanded() { const wrap = this._elements.viewerWrap; - return Boolean(wrap) && document.fullscreenElement === wrap; + if (!wrap) return false; + return this._expandMode === 'popover' + ? this._popoverOpen + : document.fullscreenElement === wrap; } - async _toggleFullscreen() { + async _toggleExpanded() { + const wrap = this._elements.viewerWrap; try { - if (this.isViewerFullscreen()) await document.exitFullscreen(); - else await this._elements.viewerWrap.requestFullscreen(); + if (this.isViewerExpanded()) this._exitExpanded(); + else if (this._expandMode === 'fullscreen') + await wrap.requestFullscreen(); + else { + // Only while open: a closed popover is hidden even inside the panel. + wrap.popover = 'auto'; + wrap.showPopover(); + } } catch { /* refused (no user gesture, policy): the panel view stays */ } } - _exitFullscreen() { - if (this.isViewerFullscreen()) document.exitFullscreen().catch(() => {}); + _exitExpanded() { + if (!this.isViewerExpanded()) return; + if (this._expandMode === 'popover') this._elements.viewerWrap.hidePopover(); + else document.exitFullscreen().catch(() => {}); } - /** Mirror the browser's fullscreen state on EXPAND and refit the viewer. */ - _syncFullscreen() { - const on = this.isViewerFullscreen(); + _onPopoverToggle(event) { + this._popoverOpen = event.newState === 'open'; + if (!this._popoverOpen) + this._elements.viewerWrap.removeAttribute('popover'); + this._syncExpanded(); + } + + /** + * While expanded, leave when Clean View, recording or the cockpit starts: + * they hide every panel, and the photo would stay over the globe (and in a + * recording). + */ + _watchPanelHidingModes(on) { + const body = document.body; + if (!on || !body) { + this._modesObserver?.disconnect(); + this._modesObserver = null; + return; + } + const leaveIfHiding = () => { + if (PANEL_HIDING_MODES.some((mode) => body.classList.contains(mode))) + this._exitExpanded(); + }; + if (!this._modesObserver && typeof MutationObserver === 'function') { + this._modesObserver = new MutationObserver(leaveIfHiding); + this._modesObserver.observe(body, { + attributes: true, + attributeFilter: ['class'], + }); + } + leaveIfHiding(); + } + + /** Mirror the expanded state on EXPAND, watch the panel-hiding modes, refit. */ + _syncExpanded() { + const on = this.isViewerExpanded(); + this._watchPanelHidingModes(on); const button = this._elements.viewerExpand; if (button) { setProp( @@ -344,7 +410,7 @@ export class StreetLevelControls { const el = this._elements; const { viewer } = view; const wrap = el.viewerWrap; - if (!viewer.open) this._exitFullscreen(); + if (!viewer.open) this._exitExpanded(); if (wrap) { // Closing the image (its × button) hides the wrap: move focus out // first, or it drops to . @@ -398,7 +464,8 @@ export class StreetLevelControls { destroy() { if (this.destroyed) return; this.destroyed = true; - this._exitFullscreen(); + this._exitExpanded(); + this._watchPanelHidingModes(false); this.listeners.abort(); this._resizeObserver?.disconnect(); this._resizeObserver = null; diff --git a/src/ui/streetLevelControls.test.mjs b/src/ui/streetLevelControls.test.mjs index a89fe4f78..1cf0b5f7c 100644 --- a/src/ui/streetLevelControls.test.mjs +++ b/src/ui/streetLevelControls.test.mjs @@ -73,6 +73,10 @@ class FakeNode { getAttribute(key) { return this.attributes.get(key) ?? null; } + removeAttribute(key) { + mutations.count++; + this.attributes.delete(key); + } appendChild(child) { child.parent = this; this.children.push(child); @@ -131,6 +135,7 @@ class FakeNode { type: event.type, target: this, key: event.key, + newState: event.newState, preventDefault() {}, }; for (let node = this; node; node = event.bubbles ? node.parent : null) @@ -145,9 +150,10 @@ class FakeNode { /** * The Street Level panel's body, as layer-panels.html lays it out, on a page - * with the Fullscreen API unless `fullscreen` is false. + * with the Fullscreen API unless `fullscreen` is false, and the Popover API + * if `popover` is true (iPhone Safari: popovers, no element fullscreen). */ -function panelDom({ fullscreen = true } = {}) { +function panelDom({ fullscreen = true, popover = false } = {}) { const document = new FakeNode(null, '#document'); Object.assign(document, { ownerDocument: document, @@ -170,6 +176,19 @@ function panelDom({ fullscreen = true } = {}) { add(root, 'button', { id: 'sl-status' }); const wrap = add(root, 'div', { id: 'sl-viewer-wrap' }); if (fullscreen) wrap.requestFullscreen = async () => fullscreenTo(wrap); + if (popover) { + // The browser fires `toggle` after the popover opens or closes. + const toggleTo = (newState) => + setTimeout(() => wrap.dispatchEvent({ type: 'toggle', newState }), 0); + wrap.showPopover = () => { + wrap.popoverOpen = true; + toggleTo('open'); + }; + wrap.hidePopover = () => { + wrap.popoverOpen = false; + toggleTo('closed'); + }; + } const expand = add(wrap, 'button', { id: 'sl-viewer-expand' }); add(expand, 'span', { classes: ['sl-btn-icon'] }); add(expand, 'span', { classes: ['sl-btn-text'] }); @@ -423,6 +442,25 @@ test('the header pill switches the layer, bringing back Mapillary switched off e dark.controls.destroy(); })); +test('a layer that is on with Mapillary switched off reads OFF, and the pill brings Mapillary back', () => + withDom(async (dom) => { + const status = dom.root.querySelector('#sl-status'); + const { calls, controls } = stubPanel(dom, uiState({ on: false })); + assert.equal(status.textContent, 'OFF'); + assert.equal(status.getAttribute('aria-pressed'), 'false'); + assert.match( + dom.root.querySelector('#sl-coverage-meta').textContent, + /Mapillary is off in this view/, + ); + status.click(); + await settle(); + assert.deepEqual(calls.setParams, [ + [{ mapillary: true }, { origin: 'user' }], + ]); + assert.deepEqual(calls.setEnabled, [], 'the layer itself stays on'); + controls.destroy(); + })); + test('under KEY REQUIRED the filters are gated and the error line says how to add the key', () => withDom(async (dom) => { const { controls } = stubPanel(dom, uiState({ keyRequired: true })); @@ -511,7 +549,7 @@ test('an image closed while full screen leaves full screen', () => controls.destroy(); })); -test('without the Fullscreen API there is no EXPAND button', () => +test('without the Fullscreen or Popover API there is no EXPAND button', () => withDom( async (dom) => { const { controls } = stubPanel(dom, uiState({ open: true })); @@ -521,6 +559,79 @@ test('without the Fullscreen API there is no EXPAND button', () => { fullscreen: false }, )); +test('on iPhone (no element fullscreen) EXPAND opens the viewer as a popover over the page', () => + withDom( + async (dom) => { + const { controls } = stubPanel(dom, uiState({ open: true })); + const expand = dom.root.querySelector('#sl-viewer-expand'); + assert.equal(expand.hidden, false, 'offered'); + expand.click(); + await settle(); + await settle(); + assert.equal(dom.wrap.popoverOpen, true); + assert.equal(dom.root.contains(dom.wrap), true, 'never leaves the panel'); + assert.deepEqual(expandLabel(dom), ['SHRINK', 'true']); + expand.click(); + await settle(); + await settle(); + assert.equal(dom.wrap.popoverOpen, false); + assert.equal( + dom.wrap.getAttribute('popover'), + null, + 'a closed popover would be hidden inside the panel', + ); + assert.deepEqual(expandLabel(dom), ['EXPAND', 'false']); + controls.destroy(); + }, + { fullscreen: false, popover: true }, + )); + +/** A MutationObserver stand-in whose callback the test fires. */ +function fakeMutationObserver() { + const observers = new Set(); + class FakeObserver { + constructor(callback) { + this.callback = callback; + } + observe() { + observers.add(this); + } + disconnect() { + observers.delete(this); + } + } + return { + FakeObserver, + observers, + fire: () => [...observers].forEach((o) => o.callback([])), + }; +} + +for (const mode of ['ui-clean-view', 'recording-mode', 'cockpit-mode']) + test(`entering ${mode} takes the photo out of full screen`, () => + withDom(async (dom) => { + const saved = globalThis.MutationObserver; + const watch = fakeMutationObserver(); + globalThis.MutationObserver = watch.FakeObserver; + try { + const { controls } = stubPanel(dom, uiState({ open: true })); + dom.root.querySelector('#sl-viewer-expand').click(); + await settle(); + await settle(); + assert.equal(dom.document.fullscreenElement, dom.wrap); + assert.equal(watch.observers.size, 1, 'watching while expanded'); + dom.document.body.classList.toggle(mode, true); + watch.fire(); + await settle(); + await settle(); + assert.equal(dom.document.fullscreenElement, null); + assert.equal(watch.observers.size, 0, 'and not after'); + controls.destroy(); + } finally { + globalThis.MutationObserver = saved; + } + })); + /* ── Restores and keyboard rules ───────────────────────────────────────── */ test('a restored layer does not reopen a panel the user collapsed, and automatic opens are never stored (P2-1)', () => diff --git a/src/ui/streetLevelPresentation.js b/src/ui/streetLevelPresentation.js index 155ec2ce1..63cb52183 100644 --- a/src/ui/streetLevelPresentation.js +++ b/src/ui/streetLevelPresentation.js @@ -53,8 +53,11 @@ function formatDate(ms) { } } +/** Whether the layer draws: on, and Mapillary not switched off by a link or tool. */ +const drawing = (state) => state.enabled === true && state.providerOn !== false; + function presentStatus(state) { - const pressed = state.enabled === true; + const pressed = drawing(state); const title = pressed ? 'Turn Street Level off' : 'Turn Street Level on'; if (state.keyRejected) return { text: 'KEY REJECTED', tone: 'warn', pressed, title }; @@ -93,6 +96,8 @@ function presentViewer(state) { function presentMeta(state) { if (!state.enabled) return 'Switch Street Level on to draw its coverage.'; + if (!drawing(state)) + return 'Mapillary is off in this view. Switch Street Level on to show it.'; if (state.sequence.selectedId) return state.sequence.loading ? 'Loading this sequence…' diff --git a/src/ui/styles/street-level.css b/src/ui/styles/street-level.css index f004b9b5f..5f39c8aaa 100644 --- a/src/ui/styles/street-level.css +++ b/src/ui/styles/street-level.css @@ -415,14 +415,29 @@ } /* ── Full screen (EXPAND) ────────────────────────────────────────────────── */ -/* The browser lifts the viewer into the top layer; the photo takes the rest. */ +/* The browser lifts the viewer into the top layer; the photo takes the rest. + iPhone Safari has no element fullscreen, so there the viewer is a popover + sized to the viewport instead. */ .sl-viewer-wrap:fullscreen { border: 0; border-radius: 0; } -.sl-viewer-wrap:fullscreen .sl-viewer-stage, -.sl-viewer-wrap:fullscreen .sl-viewer { +.sl-viewer-wrap:popover-open { + position: fixed; + inset: 0; + width: 100vw; + height: 100dvh; + max-width: none; + max-height: none; + margin: 0; + padding: 0; + border: 0; + border-radius: 0; +} + +.sl-viewer-wrap:is(:fullscreen, :popover-open) .sl-viewer-stage, +.sl-viewer-wrap:is(:fullscreen, :popover-open) .sl-viewer { flex: 1 1 auto; height: auto; min-height: 0;