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;