mirror of
https://github.com/bilawalsidhu/gods-eye-view.git
synced 2026-11-04 10:24:02 +00:00
fix(ui): right rail review follow-ups for Radio reveals and CCTV restores
- A Radio pick reopens a closed Context around an expanded Radio section in every HUD layout, not only Cyber. - The Radio reveal's scrollend landing gives way to a user scroll, key or pointer, and stops after two seconds. - The compact player's Details button scrolls the Radio section and its Enable into view inside Context. - A camera layer switched on by a share link or a saved session no longer opens the CCTV panel over the restored tab; a user switch-on still does. Co-Authored-By: Claude Opus 5.5 <[email protected]>
This commit is contained in:
1 parent
8c788474db
commit
33ccdad744
9 files changed
+191
-33
No files matched your search
@@ -41,6 +41,7 @@ import {
|
||||
} from './shareFollow.js';
|
||||
|
||||
import { runCctvLayerEnableTransition } from '../cctvFocusPolicy.js';
|
||||
import { LAYER_RESTORE_ORIGINS } from '../data/layerState.js';
|
||||
|
||||
import {
|
||||
describeUnsavedWork,
|
||||
@@ -1222,6 +1223,14 @@ export class StyleManager extends ShellFacade {
|
||||
setPanelCollapsed: (...args) => this.setPanelCollapsed(...args),
|
||||
showToast: (message) => this._showToast(message),
|
||||
syncViewport: () => this._syncCctvPanelViewport(),
|
||||
// Tells the panel whether a switch-on restores saved or shared state.
|
||||
subscribeEnableRequests: (listener) =>
|
||||
this._dataManager?.subscribeVisibilityRequests?.((change) => {
|
||||
if (change?.layerId === 'cctv' && change.enabled)
|
||||
listener(
|
||||
Object.values(LAYER_RESTORE_ORIGINS).includes(change.origin),
|
||||
);
|
||||
}) || null,
|
||||
setSplitFlapText,
|
||||
},
|
||||
});
|
||||
|
||||
@@ -31,6 +31,8 @@ export class CctvControls {
|
||||
this._cctvSummaryTypingTimer = null;
|
||||
this._lastCctvSummaryText = '';
|
||||
this._lastSeenCctvActiveId = null;
|
||||
this._cctvRestoredEnable = false;
|
||||
this._unsubscribeEnableRequests = null;
|
||||
this._cctvChipHideTimer = null;
|
||||
this._cctvChipWasBusy = false;
|
||||
this._cctvFrameRequestToken = 0;
|
||||
@@ -65,6 +67,11 @@ export class CctvControls {
|
||||
this._cctvUnsubscribe?.();
|
||||
this._cctvUnsubscribe = null;
|
||||
if (this.destroyed) return;
|
||||
this._unsubscribeEnableRequests?.();
|
||||
this._unsubscribeEnableRequests =
|
||||
this.actions.subscribeEnableRequests?.((restored) => {
|
||||
this._cctvRestoredEnable = restored;
|
||||
}) || null;
|
||||
this._cctvUnsubscribe = this.cctv.subscribe?.((state) =>
|
||||
this._renderCctvState(state),
|
||||
);
|
||||
@@ -118,6 +125,8 @@ export class CctvControls {
|
||||
this.listeners.abort();
|
||||
this._cctvUnsubscribe?.();
|
||||
this._cctvUnsubscribe = null;
|
||||
this._unsubscribeEnableRequests?.();
|
||||
this._unsubscribeEnableRequests = null;
|
||||
this._calibrationEdit?.(false);
|
||||
this._clearCctvFrame();
|
||||
clearInterval(this._cctvSummaryTypingTimer);
|
||||
|
||||
@@ -221,3 +221,23 @@ test('disposing during camera enable prevents the delayed focus and future click
|
||||
assert.equal(enables, 1);
|
||||
assert.equal(focuses, 0);
|
||||
});
|
||||
|
||||
test('a restored camera layer leaves its panel to the restored panel state', (t) => {
|
||||
const { controls } = fixture(t);
|
||||
let request;
|
||||
const opened = [];
|
||||
controls.actions.subscribeEnableRequests = (listener) => {
|
||||
request = listener;
|
||||
return () => {};
|
||||
};
|
||||
controls.actions.setPanelCollapsed = (...args) => opened.push(args);
|
||||
controls.actions.syncViewport = () => {};
|
||||
controls.connect();
|
||||
|
||||
request(true);
|
||||
controls._renderCctvState({ enabled: true, activeCameraId: 'a' });
|
||||
assert.deepEqual(opened, []);
|
||||
|
||||
controls._renderCctvState({ enabled: true, activeCameraId: 'b' });
|
||||
assert.deepEqual(opened, [['cctv-panel', false, { explicit: false }]]);
|
||||
});
|
||||
@@ -43,12 +43,16 @@ export function _renderCctvState(state) {
|
||||
if (
|
||||
effectiveActiveId &&
|
||||
effectiveActiveId !== this._lastSeenCctvActiveId &&
|
||||
(!state?.autoHop || isFirstActivation)
|
||||
(!state?.autoHop || isFirstActivation) &&
|
||||
// A restored layer leaves the panel to the restored panel state, so its
|
||||
// first camera does not take the rail from the restored tab.
|
||||
!this._cctvRestoredEnable
|
||||
) {
|
||||
this.actions.setPanelCollapsed('cctv-panel', false, {
|
||||
explicit: Boolean(state?.explicitSelection),
|
||||
});
|
||||
}
|
||||
if (effectiveActiveId) this._cctvRestoredEnable = false;
|
||||
this._lastSeenCctvActiveId = effectiveActiveId;
|
||||
|
||||
this._updateCctvSyncChip(state?.loading, enabled);
|
||||
|
||||
@@ -501,7 +501,6 @@ export class PanelChrome {
|
||||
// Reveal Radio's owner before the no-op check: the nested section may
|
||||
// already be expanded while its Context parent is closed.
|
||||
if (
|
||||
document.documentElement?.dataset.uiTheme === 'cyber' &&
|
||||
!nextCollapsed &&
|
||||
panelId === 'radio-panel' &&
|
||||
document
|
||||
@@ -540,20 +539,6 @@ export class PanelChrome {
|
||||
this.cockpitView.setContextCollapsed(true);
|
||||
}
|
||||
}
|
||||
if (
|
||||
!nextCollapsed &&
|
||||
panelId === 'radio-panel' &&
|
||||
document.documentElement?.dataset.uiTheme !== 'cyber' &&
|
||||
document
|
||||
.getElementById('global-context-panel')
|
||||
?.classList.contains('collapsed')
|
||||
) {
|
||||
this.setPanelCollapsed('global-context-panel', false, {
|
||||
restore,
|
||||
persist,
|
||||
syncShare,
|
||||
});
|
||||
}
|
||||
if (!nextCollapsed && !restore && panelId === 'location-bar') {
|
||||
const otherPanel = document.getElementById('control-panel');
|
||||
if (otherPanel && !otherPanel.classList.contains('dock-pinned')) {
|
||||
|
||||
@@ -129,3 +129,49 @@ test('adaptive rail restore reveals Context without touching the compact player'
|
||||
assert.equal(dock.classList.contains('disclosure-open'), true);
|
||||
assert.deepEqual(calls, ['button:global-context-panel', 'layout']);
|
||||
});
|
||||
|
||||
// A globe station pick opens Radio, which may already be expanded inside a
|
||||
// closed Context.
|
||||
for (const theme of ['tactical', 'cyber']) {
|
||||
test(`${theme}: a radio pick reopens the closed Context around an expanded Radio section`, (t) => {
|
||||
const priorDocument = globalThis.document;
|
||||
const contextPanel = {
|
||||
id: 'global-context-panel',
|
||||
classList: classList(['collapsed']),
|
||||
};
|
||||
const panels = {
|
||||
'global-context-panel': contextPanel,
|
||||
'radio-panel': { id: 'radio-panel', classList: classList() },
|
||||
};
|
||||
globalThis.document = {
|
||||
documentElement: { dataset: { uiTheme: theme } },
|
||||
body: { classList: classList() },
|
||||
getElementById: (id) => panels[id] || null,
|
||||
};
|
||||
t.after(() => {
|
||||
globalThis.document = priorDocument;
|
||||
});
|
||||
const owner = {
|
||||
_leftPanelStack: null,
|
||||
_rightPanelStack: { contains: (el) => el === contextPanel },
|
||||
_panelLayout: {
|
||||
_leftStackPreferredPanelId: null,
|
||||
_rightStackPreferredPanelId: 'pp-toggles',
|
||||
},
|
||||
_syncPanelCollapseButton() {},
|
||||
_scheduleLeftPanelLayout() {},
|
||||
_scheduleRightPanelLayout() {},
|
||||
_savePanelCollapsedState() {},
|
||||
_syncCctvPanelViewport() {},
|
||||
_updateCommandDockTrayStack() {},
|
||||
_lifetime: { frame() {} },
|
||||
shareLinkManager: null,
|
||||
cockpitView: null,
|
||||
};
|
||||
owner.setPanelCollapsed = PanelChrome.prototype.setPanelCollapsed;
|
||||
|
||||
owner.setPanelCollapsed('radio-panel', false);
|
||||
|
||||
assert.equal(contextPanel.classList.contains('collapsed'), false);
|
||||
});
|
||||
}
|
||||
@@ -425,6 +425,12 @@ export function bindRadioControls() {
|
||||
this.actions.setPanelCollapsed('radio-panel', false, { explicit: true });
|
||||
setRadioDisclosure(false);
|
||||
this._radioEnableBtn?.focus({ preventScroll: true });
|
||||
if (this._radioPanel && this._radioEnableBtn)
|
||||
void this._revealInsideContext(
|
||||
this._radioPanel,
|
||||
this._radioEnableBtn,
|
||||
() => !this._radioPanel.classList.contains('collapsed'),
|
||||
);
|
||||
});
|
||||
this.listen(this._cockpitRadioToggleBtn, 'click', () => {
|
||||
const open =
|
||||
|
||||
+40
-17
@@ -59,37 +59,49 @@ export class RadioControls {
|
||||
* @returns {Promise<boolean>} Whether the internal scroller moved.
|
||||
*/
|
||||
async _revealRadioControlsAfterExplicitEnable(trigger) {
|
||||
const directory = this._radioPanel?.querySelector('.radio-directory-row');
|
||||
const transport = this._radioPanel?.querySelector('.radio-transport');
|
||||
if (!directory || !transport || !this._radioState?.enabled) return false;
|
||||
return this._revealInsideContext(
|
||||
directory,
|
||||
transport,
|
||||
() => this._radioState?.enabled && trigger?.isConnected,
|
||||
);
|
||||
}
|
||||
|
||||
/**
|
||||
* Scroll Context so `top` through `bottom` show, preferring `bottom` when
|
||||
* both do not fit, once the rail has laid out the opened panels.
|
||||
* @param {HTMLElement} top
|
||||
* @param {HTMLElement} bottom
|
||||
* @param {() => boolean} stillWanted Rechecked after the layout frames.
|
||||
* @returns {Promise<boolean>} Whether the internal scroller moved.
|
||||
*/
|
||||
async _revealInsideContext(top, bottom, stillWanted) {
|
||||
const contextPanel = document.getElementById('global-context-panel');
|
||||
const scroller =
|
||||
(document.documentElement?.dataset.uiTheme === 'cyber' &&
|
||||
contextPanel?.querySelector('.cyber-panel-body')) ||
|
||||
contextPanel?.querySelector('.global-context-panel-inner');
|
||||
const directory = this._radioPanel?.querySelector('.radio-directory-row');
|
||||
const transport = this._radioPanel?.querySelector('.radio-transport');
|
||||
if (
|
||||
!contextPanel ||
|
||||
contextPanel.classList.contains('collapsed') ||
|
||||
!scroller ||
|
||||
!directory ||
|
||||
!transport ||
|
||||
!this._radioState?.enabled
|
||||
!scroller
|
||||
)
|
||||
return false;
|
||||
|
||||
await new Promise((resolve) =>
|
||||
requestAnimationFrame(() => requestAnimationFrame(resolve)),
|
||||
);
|
||||
if (this.destroyed || !this._radioState?.enabled || !trigger?.isConnected)
|
||||
return false;
|
||||
if (this.destroyed || !stillWanted()) return false;
|
||||
|
||||
const viewport = scroller.getBoundingClientRect();
|
||||
const directoryRect = directory.getBoundingClientRect();
|
||||
const transportRect = transport.getBoundingClientRect();
|
||||
const topRect = top.getBoundingClientRect();
|
||||
const bottomRect = bottom.getBoundingClientRect();
|
||||
const margin = 10;
|
||||
const minimum =
|
||||
scroller.scrollTop + transportRect.bottom - (viewport.bottom - margin);
|
||||
const maximum =
|
||||
scroller.scrollTop + directoryRect.top - (viewport.top + margin);
|
||||
scroller.scrollTop + bottomRect.bottom - (viewport.bottom - margin);
|
||||
const maximum = scroller.scrollTop + topRect.top - (viewport.top + margin);
|
||||
const desired =
|
||||
minimum <= maximum
|
||||
? Math.min(Math.max(scroller.scrollTop, minimum), maximum)
|
||||
@@ -108,16 +120,27 @@ export class RadioControls {
|
||||
});
|
||||
// A rail layout pass that measures the panels mid-scroll puts the scroll
|
||||
// offset back where it was, which cancels the smooth scroll: land it when
|
||||
// the scroll ends short.
|
||||
if (!reducedMotion)
|
||||
// the scroll ends short, unless the user took the scroll over meanwhile.
|
||||
if (!reducedMotion) {
|
||||
const landing = new AbortController();
|
||||
const signal = AbortSignal.any([
|
||||
this.listeners.signal,
|
||||
landing.signal,
|
||||
AbortSignal.timeout(2000),
|
||||
]);
|
||||
const cancel = () => landing.abort();
|
||||
for (const type of ['keydown', 'wheel', 'pointerdown', 'touchstart'])
|
||||
scroller.addEventListener(type, cancel, { signal, passive: true });
|
||||
scroller.addEventListener(
|
||||
'scrollend',
|
||||
() => {
|
||||
if (!this.destroyed && Math.abs(scroller.scrollTop - next) >= 1)
|
||||
cancel();
|
||||
if (Math.abs(scroller.scrollTop - next) >= 1)
|
||||
scroller.scrollTop = next;
|
||||
},
|
||||
{ once: true },
|
||||
{ signal },
|
||||
);
|
||||
}
|
||||
return true;
|
||||
}
|
||||
|
||||
|
||||
@@ -172,3 +172,59 @@ test('destruction releases an active tuner pointer after revoking listeners', ()
|
||||
f.cleanup();
|
||||
}
|
||||
});
|
||||
|
||||
test('a user scroll during the Radio reveal is not undone by the scrollend landing', async () => {
|
||||
const prior = {
|
||||
document: globalThis.document,
|
||||
window: globalThis.window,
|
||||
requestAnimationFrame: globalThis.requestAnimationFrame,
|
||||
};
|
||||
const rect = (top, bottom) => ({ top, bottom });
|
||||
const scroller = Object.assign(new EventTarget(), {
|
||||
scrollTop: 0,
|
||||
scrollHeight: 1200,
|
||||
clientHeight: 300,
|
||||
getBoundingClientRect: () => rect(100, 400),
|
||||
scrollTo() {},
|
||||
});
|
||||
const below = (top) => ({
|
||||
getBoundingClientRect: () =>
|
||||
rect(top - scroller.scrollTop, top + 30 - scroller.scrollTop),
|
||||
});
|
||||
const parts = {
|
||||
'.radio-directory-row': below(500),
|
||||
'.radio-transport': below(700),
|
||||
};
|
||||
const context = {
|
||||
classList: { contains: () => false },
|
||||
querySelector: (sel) =>
|
||||
sel === '.global-context-panel-inner' ? scroller : null,
|
||||
};
|
||||
globalThis.document = {
|
||||
documentElement: { dataset: {} },
|
||||
getElementById: (id) => (id === 'global-context-panel' ? context : null),
|
||||
};
|
||||
globalThis.window = { matchMedia: () => ({ matches: false }) };
|
||||
globalThis.requestAnimationFrame = (callback) => setTimeout(callback, 0);
|
||||
const controls = Object.assign(Object.create(RadioControls.prototype), {
|
||||
destroyed: false,
|
||||
listeners: new AbortController(),
|
||||
_radioPanel: { querySelector: (sel) => parts[sel] ?? null },
|
||||
_radioState: { enabled: true },
|
||||
});
|
||||
try {
|
||||
assert.equal(
|
||||
await controls._revealRadioControlsAfterExplicitEnable({
|
||||
isConnected: true,
|
||||
}),
|
||||
true,
|
||||
);
|
||||
scroller.dispatchEvent(new Event('keydown'));
|
||||
scroller.scrollTop = 0;
|
||||
scroller.dispatchEvent(new Event('scrollend'));
|
||||
assert.equal(scroller.scrollTop, 0);
|
||||
} finally {
|
||||
controls.listeners.abort();
|
||||
Object.assign(globalThis, prior);
|
||||
}
|
||||
});
|
||||
Reference in new issue
Block a user