mirror of
https://github.com/bilawalsidhu/gods-eye-view.git
synced 2026-11-04 10:24:02 +00:00
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 <body> 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 <[email protected]>
This commit is contained in:
1 parent
8706cafdd1
commit
07a239fd82
5 files changed
+231
-29
No files matched your search
@@ -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 <server>` runs against real
|
||||
Mapillary with a token; `npm run qa:street-level:fixtures` answers every
|
||||
|
||||
@@ -5,6 +5,8 @@ import {
|
||||
} from './streetLevelPresentation.js';
|
||||
|
||||
const RENDER_MODE_KEY = 'gev:street-level:render-mode';
|
||||
/** <body> 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 <body>.
|
||||
@@ -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;
|
||||
|
||||
@@ -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)', () =>
|
||||
|
||||
@@ -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…'
|
||||
|
||||
@@ -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;
|
||||
|
||||
Reference in new issue
Block a user