mirror of
https://github.com/bilawalsidhu/gods-eye-view.git
synced 2026-11-05 19:04: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';
|
} from './shareFollow.js';
|
||||||
|
|
||||||
import { runCctvLayerEnableTransition } from '../cctvFocusPolicy.js';
|
import { runCctvLayerEnableTransition } from '../cctvFocusPolicy.js';
|
||||||
|
import { LAYER_RESTORE_ORIGINS } from '../data/layerState.js';
|
||||||
|
|
||||||
import {
|
import {
|
||||||
describeUnsavedWork,
|
describeUnsavedWork,
|
||||||
@@ -1222,6 +1223,14 @@ export class StyleManager extends ShellFacade {
|
|||||||
setPanelCollapsed: (...args) => this.setPanelCollapsed(...args),
|
setPanelCollapsed: (...args) => this.setPanelCollapsed(...args),
|
||||||
showToast: (message) => this._showToast(message),
|
showToast: (message) => this._showToast(message),
|
||||||
syncViewport: () => this._syncCctvPanelViewport(),
|
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,
|
setSplitFlapText,
|
||||||
},
|
},
|
||||||
});
|
});
|
||||||
|
|||||||
@@ -31,6 +31,8 @@ export class CctvControls {
|
|||||||
this._cctvSummaryTypingTimer = null;
|
this._cctvSummaryTypingTimer = null;
|
||||||
this._lastCctvSummaryText = '';
|
this._lastCctvSummaryText = '';
|
||||||
this._lastSeenCctvActiveId = null;
|
this._lastSeenCctvActiveId = null;
|
||||||
|
this._cctvRestoredEnable = false;
|
||||||
|
this._unsubscribeEnableRequests = null;
|
||||||
this._cctvChipHideTimer = null;
|
this._cctvChipHideTimer = null;
|
||||||
this._cctvChipWasBusy = false;
|
this._cctvChipWasBusy = false;
|
||||||
this._cctvFrameRequestToken = 0;
|
this._cctvFrameRequestToken = 0;
|
||||||
@@ -65,6 +67,11 @@ export class CctvControls {
|
|||||||
this._cctvUnsubscribe?.();
|
this._cctvUnsubscribe?.();
|
||||||
this._cctvUnsubscribe = null;
|
this._cctvUnsubscribe = null;
|
||||||
if (this.destroyed) return;
|
if (this.destroyed) return;
|
||||||
|
this._unsubscribeEnableRequests?.();
|
||||||
|
this._unsubscribeEnableRequests =
|
||||||
|
this.actions.subscribeEnableRequests?.((restored) => {
|
||||||
|
this._cctvRestoredEnable = restored;
|
||||||
|
}) || null;
|
||||||
this._cctvUnsubscribe = this.cctv.subscribe?.((state) =>
|
this._cctvUnsubscribe = this.cctv.subscribe?.((state) =>
|
||||||
this._renderCctvState(state),
|
this._renderCctvState(state),
|
||||||
);
|
);
|
||||||
@@ -118,6 +125,8 @@ export class CctvControls {
|
|||||||
this.listeners.abort();
|
this.listeners.abort();
|
||||||
this._cctvUnsubscribe?.();
|
this._cctvUnsubscribe?.();
|
||||||
this._cctvUnsubscribe = null;
|
this._cctvUnsubscribe = null;
|
||||||
|
this._unsubscribeEnableRequests?.();
|
||||||
|
this._unsubscribeEnableRequests = null;
|
||||||
this._calibrationEdit?.(false);
|
this._calibrationEdit?.(false);
|
||||||
this._clearCctvFrame();
|
this._clearCctvFrame();
|
||||||
clearInterval(this._cctvSummaryTypingTimer);
|
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(enables, 1);
|
||||||
assert.equal(focuses, 0);
|
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 (
|
if (
|
||||||
effectiveActiveId &&
|
effectiveActiveId &&
|
||||||
effectiveActiveId !== this._lastSeenCctvActiveId &&
|
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, {
|
this.actions.setPanelCollapsed('cctv-panel', false, {
|
||||||
explicit: Boolean(state?.explicitSelection),
|
explicit: Boolean(state?.explicitSelection),
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
if (effectiveActiveId) this._cctvRestoredEnable = false;
|
||||||
this._lastSeenCctvActiveId = effectiveActiveId;
|
this._lastSeenCctvActiveId = effectiveActiveId;
|
||||||
|
|
||||||
this._updateCctvSyncChip(state?.loading, enabled);
|
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
|
// Reveal Radio's owner before the no-op check: the nested section may
|
||||||
// already be expanded while its Context parent is closed.
|
// already be expanded while its Context parent is closed.
|
||||||
if (
|
if (
|
||||||
document.documentElement?.dataset.uiTheme === 'cyber' &&
|
|
||||||
!nextCollapsed &&
|
!nextCollapsed &&
|
||||||
panelId === 'radio-panel' &&
|
panelId === 'radio-panel' &&
|
||||||
document
|
document
|
||||||
@@ -540,20 +539,6 @@ export class PanelChrome {
|
|||||||
this.cockpitView.setContextCollapsed(true);
|
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') {
|
if (!nextCollapsed && !restore && panelId === 'location-bar') {
|
||||||
const otherPanel = document.getElementById('control-panel');
|
const otherPanel = document.getElementById('control-panel');
|
||||||
if (otherPanel && !otherPanel.classList.contains('dock-pinned')) {
|
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.equal(dock.classList.contains('disclosure-open'), true);
|
||||||
assert.deepEqual(calls, ['button:global-context-panel', 'layout']);
|
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 });
|
this.actions.setPanelCollapsed('radio-panel', false, { explicit: true });
|
||||||
setRadioDisclosure(false);
|
setRadioDisclosure(false);
|
||||||
this._radioEnableBtn?.focus({ preventScroll: true });
|
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', () => {
|
this.listen(this._cockpitRadioToggleBtn, 'click', () => {
|
||||||
const open =
|
const open =
|
||||||
|
|||||||
+40
-17
@@ -59,37 +59,49 @@ export class RadioControls {
|
|||||||
* @returns {Promise<boolean>} Whether the internal scroller moved.
|
* @returns {Promise<boolean>} Whether the internal scroller moved.
|
||||||
*/
|
*/
|
||||||
async _revealRadioControlsAfterExplicitEnable(trigger) {
|
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 contextPanel = document.getElementById('global-context-panel');
|
||||||
const scroller =
|
const scroller =
|
||||||
(document.documentElement?.dataset.uiTheme === 'cyber' &&
|
(document.documentElement?.dataset.uiTheme === 'cyber' &&
|
||||||
contextPanel?.querySelector('.cyber-panel-body')) ||
|
contextPanel?.querySelector('.cyber-panel-body')) ||
|
||||||
contextPanel?.querySelector('.global-context-panel-inner');
|
contextPanel?.querySelector('.global-context-panel-inner');
|
||||||
const directory = this._radioPanel?.querySelector('.radio-directory-row');
|
|
||||||
const transport = this._radioPanel?.querySelector('.radio-transport');
|
|
||||||
if (
|
if (
|
||||||
!contextPanel ||
|
!contextPanel ||
|
||||||
contextPanel.classList.contains('collapsed') ||
|
contextPanel.classList.contains('collapsed') ||
|
||||||
!scroller ||
|
!scroller
|
||||||
!directory ||
|
|
||||||
!transport ||
|
|
||||||
!this._radioState?.enabled
|
|
||||||
)
|
)
|
||||||
return false;
|
return false;
|
||||||
|
|
||||||
await new Promise((resolve) =>
|
await new Promise((resolve) =>
|
||||||
requestAnimationFrame(() => requestAnimationFrame(resolve)),
|
requestAnimationFrame(() => requestAnimationFrame(resolve)),
|
||||||
);
|
);
|
||||||
if (this.destroyed || !this._radioState?.enabled || !trigger?.isConnected)
|
if (this.destroyed || !stillWanted()) return false;
|
||||||
return false;
|
|
||||||
|
|
||||||
const viewport = scroller.getBoundingClientRect();
|
const viewport = scroller.getBoundingClientRect();
|
||||||
const directoryRect = directory.getBoundingClientRect();
|
const topRect = top.getBoundingClientRect();
|
||||||
const transportRect = transport.getBoundingClientRect();
|
const bottomRect = bottom.getBoundingClientRect();
|
||||||
const margin = 10;
|
const margin = 10;
|
||||||
const minimum =
|
const minimum =
|
||||||
scroller.scrollTop + transportRect.bottom - (viewport.bottom - margin);
|
scroller.scrollTop + bottomRect.bottom - (viewport.bottom - margin);
|
||||||
const maximum =
|
const maximum = scroller.scrollTop + topRect.top - (viewport.top + margin);
|
||||||
scroller.scrollTop + directoryRect.top - (viewport.top + margin);
|
|
||||||
const desired =
|
const desired =
|
||||||
minimum <= maximum
|
minimum <= maximum
|
||||||
? Math.min(Math.max(scroller.scrollTop, 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
|
// 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
|
// offset back where it was, which cancels the smooth scroll: land it when
|
||||||
// the scroll ends short.
|
// the scroll ends short, unless the user took the scroll over meanwhile.
|
||||||
if (!reducedMotion)
|
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(
|
scroller.addEventListener(
|
||||||
'scrollend',
|
'scrollend',
|
||||||
() => {
|
() => {
|
||||||
if (!this.destroyed && Math.abs(scroller.scrollTop - next) >= 1)
|
cancel();
|
||||||
|
if (Math.abs(scroller.scrollTop - next) >= 1)
|
||||||
scroller.scrollTop = next;
|
scroller.scrollTop = next;
|
||||||
},
|
},
|
||||||
{ once: true },
|
{ signal },
|
||||||
);
|
);
|
||||||
|
}
|
||||||
return true;
|
return true;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -172,3 +172,59 @@ test('destruction releases an active tuner pointer after revoking listeners', ()
|
|||||||
f.cleanup();
|
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