mirror of
https://github.com/ksyasuda/SubMiner.git
synced 2026-08-05 19:21:35 -07:00
4e8abcc25e
- Extract shared modal-focus-guard module reused by changelog and session-help modals - Only fold the selected changelog entry on Enter/Space; leave close/refresh buttons and nested folds to their own activation - Stop re-stealing focus after a mouse click already selected an entry - Parse prerelease and build metadata separately in version headings (e.g. 0.15.0-rc.1+build.2) - Resolve tray menu test assertions by label instead of index
270 lines
8.9 KiB
TypeScript
270 lines
8.9 KiB
TypeScript
import type { ChangelogSnapshot } from '../../types/changelog';
|
|
import type { ModalStateReader, RendererContext } from '../context';
|
|
import {
|
|
createChangelogEntryNode,
|
|
describeChangelogSource,
|
|
resolveEntryBadge,
|
|
shouldEntryStartExpanded,
|
|
} from './changelog-render';
|
|
import { createModalFocusGuard } from './modal-focus-guard';
|
|
|
|
export function createChangelogModal(
|
|
ctx: RendererContext,
|
|
options: {
|
|
modalStateReader: Pick<ModalStateReader, 'isAnyModalOpen'>;
|
|
syncSettingsModalSubtitleSuppression: () => void;
|
|
},
|
|
) {
|
|
let priorFocus: Element | null = null;
|
|
let loadToken = 0;
|
|
|
|
function getSummaries(): HTMLElement[] {
|
|
return Array.from(
|
|
ctx.dom.changelogList.querySelectorAll('.changelog-entry-summary'),
|
|
) as HTMLElement[];
|
|
}
|
|
|
|
function applySelectionStyles(index: number): HTMLElement[] {
|
|
const summaries = getSummaries();
|
|
ctx.state.changelogSelectedIndex = index;
|
|
summaries.forEach((summary, idx) => {
|
|
summary.classList.toggle('active', idx === index);
|
|
summary.tabIndex = idx === index ? 0 : -1;
|
|
});
|
|
return summaries;
|
|
}
|
|
|
|
function setSelected(index: number): void {
|
|
const count = getSummaries().length;
|
|
if (count === 0) return;
|
|
|
|
const wrapped = index % count;
|
|
const next = wrapped < 0 ? wrapped + count : wrapped;
|
|
|
|
// Only the keyboard path moves focus; clicking already focused the summary.
|
|
const active = applySelectionStyles(next)[next];
|
|
if (!active) return;
|
|
active.focus({ preventScroll: true });
|
|
active.scrollIntoView({ block: 'nearest', inline: 'nearest' });
|
|
}
|
|
|
|
function getSelectedEntry(): HTMLDetailsElement | null {
|
|
const summary = getSummaries()[ctx.state.changelogSelectedIndex];
|
|
const entry = summary?.parentElement;
|
|
return entry instanceof HTMLDetailsElement ? entry : null;
|
|
}
|
|
|
|
const focus = createModalFocusGuard({
|
|
isOpen: () => ctx.state.changelogModalOpen,
|
|
getModalRoot: () => ctx.dom.changelogModal,
|
|
getPreferredFocusTargets: () => getSummaries(),
|
|
getFallbackFocusTarget: () => ctx.dom.changelogClose,
|
|
isModalLayer: ctx.platform.isModalLayer,
|
|
});
|
|
|
|
function renderSnapshot(snapshot: ChangelogSnapshot): void {
|
|
ctx.dom.changelogList.innerHTML = '';
|
|
ctx.dom.changelogList.classList.remove('changelog-list-empty');
|
|
|
|
ctx.dom.changelogInstalled.textContent = `Installed v${snapshot.installedVersion}`;
|
|
ctx.dom.changelogSource.textContent = describeChangelogSource(snapshot);
|
|
ctx.dom.changelogWarning.textContent = snapshot.warning ?? '';
|
|
|
|
if (snapshot.entries.length === 0) {
|
|
ctx.dom.changelogList.classList.add('changelog-list-empty');
|
|
ctx.dom.changelogList.textContent =
|
|
snapshot.error ?? 'No changelog entries are available right now.';
|
|
ctx.state.changelogSelectedIndex = 0;
|
|
return;
|
|
}
|
|
|
|
snapshot.entries.forEach((entry, index) => {
|
|
ctx.dom.changelogList.appendChild(
|
|
createChangelogEntryNode(entry, {
|
|
expanded: shouldEntryStartExpanded(entry, snapshot),
|
|
badge: resolveEntryBadge(entry.version, snapshot.installedVersion),
|
|
index,
|
|
}),
|
|
);
|
|
});
|
|
|
|
setSelected(0);
|
|
}
|
|
|
|
async function load(options?: { refresh?: boolean }): Promise<void> {
|
|
const token = ++loadToken;
|
|
ctx.dom.changelogStatus.textContent = options?.refresh
|
|
? 'Refreshing changelog...'
|
|
: 'Loading changelog...';
|
|
|
|
try {
|
|
const snapshot = await window.electronAPI.getChangelogSnapshot(options);
|
|
if (token !== loadToken || !ctx.state.changelogModalOpen) return;
|
|
renderSnapshot(snapshot);
|
|
ctx.dom.changelogStatus.textContent =
|
|
'J/K or arrows to move, Enter to fold, R to refresh, Esc closes.';
|
|
} catch (error) {
|
|
if (token !== loadToken || !ctx.state.changelogModalOpen) return;
|
|
const message = error instanceof Error ? error.message : 'Unknown error.';
|
|
ctx.dom.changelogList.innerHTML = '';
|
|
ctx.dom.changelogList.classList.add('changelog-list-empty');
|
|
// A failed refresh replaces an already-rendered snapshot, so the metadata
|
|
// line has to be cleared here too or it keeps describing stale entries.
|
|
ctx.dom.changelogInstalled.textContent = '';
|
|
ctx.dom.changelogSource.textContent = '';
|
|
ctx.dom.changelogWarning.textContent = '';
|
|
ctx.dom.changelogList.textContent = `Changelog failed to load: ${message}`;
|
|
ctx.dom.changelogStatus.textContent = 'Press Esc to close.';
|
|
}
|
|
}
|
|
|
|
function openChangelogModal(): void {
|
|
if (ctx.state.changelogModalOpen) return;
|
|
priorFocus = document.activeElement;
|
|
|
|
ctx.state.changelogModalOpen = true;
|
|
ctx.state.changelogSelectedIndex = 0;
|
|
options.syncSettingsModalSubtitleSuppression();
|
|
ctx.dom.overlay.classList.add('interactive');
|
|
ctx.dom.changelogModal.classList.remove('hidden');
|
|
ctx.dom.changelogModal.setAttribute('aria-hidden', 'false');
|
|
ctx.dom.changelogModal.setAttribute('tabindex', '-1');
|
|
ctx.dom.changelogList.innerHTML = '';
|
|
ctx.dom.changelogWarning.textContent = '';
|
|
// Reset the metadata line too, so a failed load can't leave the previous
|
|
// session's installed/source values on screen.
|
|
ctx.dom.changelogInstalled.textContent = '';
|
|
ctx.dom.changelogSource.textContent = '';
|
|
if (ctx.platform.shouldToggleMouseIgnore) {
|
|
window.electronAPI.setIgnoreMouseEvents(false);
|
|
}
|
|
|
|
focus.attach();
|
|
focus.requestOverlayFocus();
|
|
window.focus();
|
|
focus.enforceModalFocus();
|
|
|
|
void load();
|
|
}
|
|
|
|
function closeChangelogModal(): void {
|
|
if (!ctx.state.changelogModalOpen) return;
|
|
|
|
ctx.state.changelogModalOpen = false;
|
|
loadToken += 1;
|
|
options.syncSettingsModalSubtitleSuppression();
|
|
ctx.dom.changelogModal.classList.add('hidden');
|
|
ctx.dom.changelogModal.setAttribute('aria-hidden', 'true');
|
|
window.electronAPI.notifyOverlayModalClosed('changelog');
|
|
if (!ctx.state.isOverSubtitle && !options.modalStateReader.isAnyModalOpen()) {
|
|
ctx.dom.overlay.classList.remove('interactive');
|
|
}
|
|
|
|
focus.detach();
|
|
|
|
if (priorFocus instanceof HTMLElement && priorFocus.isConnected) {
|
|
priorFocus.focus({ preventScroll: true });
|
|
} else if (ctx.dom.overlay instanceof HTMLElement) {
|
|
ctx.dom.overlay.focus({ preventScroll: true });
|
|
}
|
|
|
|
if (ctx.platform.shouldToggleMouseIgnore) {
|
|
if (!ctx.state.isOverSubtitle && !options.modalStateReader.isAnyModalOpen()) {
|
|
window.electronAPI.setIgnoreMouseEvents(true, { forward: true });
|
|
} else {
|
|
window.electronAPI.setIgnoreMouseEvents(false);
|
|
}
|
|
}
|
|
window.focus();
|
|
}
|
|
|
|
function handleChangelogKeydown(e: KeyboardEvent): boolean {
|
|
if (!ctx.state.changelogModalOpen) return false;
|
|
|
|
if (e.key === 'Escape') {
|
|
e.preventDefault();
|
|
closeChangelogModal();
|
|
return true;
|
|
}
|
|
|
|
const key = e.key.toLowerCase();
|
|
|
|
if (key === 'r' && !e.ctrlKey && !e.metaKey && !e.altKey) {
|
|
e.preventDefault();
|
|
void load({ refresh: true });
|
|
return true;
|
|
}
|
|
|
|
const summaries = getSummaries();
|
|
if (summaries.length === 0) return true;
|
|
|
|
if (key === 'arrowdown' || key === 'j') {
|
|
e.preventDefault();
|
|
setSelected(ctx.state.changelogSelectedIndex + 1);
|
|
return true;
|
|
}
|
|
|
|
if (key === 'arrowup' || key === 'k') {
|
|
e.preventDefault();
|
|
setSelected(ctx.state.changelogSelectedIndex - 1);
|
|
return true;
|
|
}
|
|
|
|
if (key === 'enter' || key === ' ') {
|
|
// Only the selected release summary folds from here. The Close/Refresh
|
|
// buttons and the nested "Internal changes" fold activate themselves, and
|
|
// swallowing Enter/Space would make them unreachable by keyboard.
|
|
if (e.target !== summaries[ctx.state.changelogSelectedIndex]) {
|
|
return true;
|
|
}
|
|
e.preventDefault();
|
|
const entry = getSelectedEntry();
|
|
if (entry) entry.open = !entry.open;
|
|
return true;
|
|
}
|
|
|
|
if (key === 'arrowleft' || key === 'h') {
|
|
e.preventDefault();
|
|
const entry = getSelectedEntry();
|
|
if (entry) entry.open = false;
|
|
return true;
|
|
}
|
|
|
|
if (key === 'arrowright' || key === 'l') {
|
|
e.preventDefault();
|
|
const entry = getSelectedEntry();
|
|
if (entry) entry.open = true;
|
|
return true;
|
|
}
|
|
|
|
return true;
|
|
}
|
|
|
|
function wireDomEvents(): void {
|
|
ctx.dom.changelogClose.addEventListener('click', () => {
|
|
closeChangelogModal();
|
|
});
|
|
|
|
ctx.dom.changelogRefresh.addEventListener('click', () => {
|
|
void load({ refresh: true });
|
|
});
|
|
|
|
ctx.dom.changelogList.addEventListener('click', (event: MouseEvent) => {
|
|
const target = event.target;
|
|
if (!(target instanceof Element)) return;
|
|
const summary = target.closest('.changelog-entry-summary') as HTMLElement | null;
|
|
if (!summary) return;
|
|
const index = Number.parseInt(summary.dataset.changelogIndex ?? '', 10);
|
|
if (!Number.isFinite(index)) return;
|
|
applySelectionStyles(index);
|
|
});
|
|
}
|
|
|
|
return {
|
|
closeChangelogModal,
|
|
handleChangelogKeydown,
|
|
openChangelogModal,
|
|
wireDomEvents,
|
|
};
|
|
}
|