Add inline character portraits and dictionary search workflow (#83)

This commit is contained in:
2026-05-25 03:16:25 -07:00
committed by GitHub
parent 7e6f9672cf
commit 807c0ff3db
54 changed files with 2306 additions and 178 deletions
@@ -28,6 +28,8 @@ function createElementStub() {
className: '',
textContent: '',
type: '',
value: '',
disabled: false,
children: [] as unknown[],
classList: createClassList(),
append(...children: unknown[]) {
@@ -38,17 +40,25 @@ function createElementStub() {
}
function createNodeStub(hidden = false) {
const listeners = new Map<string, Array<() => void>>();
const listeners = new Map<string, Array<(event?: { preventDefault?: () => void }) => void>>();
return {
textContent: '',
value: '',
disabled: false,
children: [] as unknown[],
classList: createClassList(hidden ? ['hidden'] : []),
setAttribute: () => {},
addEventListener: (event: string, listener: () => void) => {
addEventListener: (
event: string,
listener: (event?: { preventDefault?: () => void }) => void,
) => {
listeners.set(event, [...(listeners.get(event) ?? []), listener]);
},
dispatchEvent: (event: string) => {
for (const listener of listeners.get(event) ?? []) listener();
dispatchEvent: (event: string, payload?: { preventDefault?: () => void }) => {
for (const listener of listeners.get(event) ?? []) listener(payload);
},
append(...children: unknown[]) {
this.children.push(...children);
},
replaceChildren(...children: unknown[]) {
this.children = [...children];
@@ -207,6 +217,8 @@ test('character dictionary modal loads candidates and applies selected override'
characterDictionaryClose: closeButton,
characterDictionarySummary: createNodeStub(),
characterDictionaryCurrent: createNodeStub(),
characterDictionarySearchInput: createNodeStub(),
characterDictionarySearchButton: createNodeStub(),
characterDictionaryCandidates: candidates,
characterDictionaryStatus: status,
},
@@ -283,6 +295,8 @@ test('character dictionary modal shows refresh errors without rejecting open', a
characterDictionaryClose: createNodeStub(),
characterDictionarySummary: createNodeStub(),
characterDictionaryCurrent: createNodeStub(),
characterDictionarySearchInput: createNodeStub(),
characterDictionarySearchButton: createNodeStub(),
characterDictionaryCandidates: createNodeStub(),
characterDictionaryStatus: status,
},
@@ -302,3 +316,255 @@ test('character dictionary modal shows refresh errors without rejecting open', a
Object.defineProperty(globalThis, 'window', { configurable: true, value: previousWindow });
}
});
test('character dictionary modal seeds search input and waits for manual search', async () => {
const previousWindow = globalThis.window;
const previousDocument = globalThis.document;
const initialSnapshot: CharacterDictionarySelectionSnapshot = {
seriesKey: 'kage-no-jitsuryokusha-ni-naritakute-2022',
guessTitle: 'Kage no Jitsuryokusha ni Naritakute!',
current: null,
override: null,
candidates: [],
};
const searchedSnapshot: CharacterDictionarySelectionSnapshot = {
...initialSnapshot,
candidates: [{ id: 130298, title: 'The Eminence in Shadow', episodes: 20 }],
};
const searches: Array<string | undefined> = [];
const overlay = createNodeStub();
const searchInput = createNodeStub();
const searchButton = createNodeStub();
const candidates = createNodeStub();
const status = createNodeStub();
const state = createRendererState();
Object.defineProperty(globalThis, 'window', {
configurable: true,
value: {
electronAPI: {
getCharacterDictionarySelection: async (searchText?: string) => {
searches.push(searchText);
return searchText ? searchedSnapshot : initialSnapshot;
},
setCharacterDictionarySelection: async () => ({
ok: true,
seriesKey: initialSnapshot.seriesKey,
selected: searchedSnapshot.candidates[0]!,
staleMediaIds: [],
}),
notifyOverlayModalClosed: () => {},
notifyOverlayModalOpened: () => {},
} satisfies Pick<
ElectronAPI,
| 'getCharacterDictionarySelection'
| 'setCharacterDictionarySelection'
| 'notifyOverlayModalClosed'
| 'notifyOverlayModalOpened'
>,
},
});
Object.defineProperty(globalThis, 'document', {
configurable: true,
value: {
createElement: () => createElementStub(),
},
});
try {
const modal = createCharacterDictionaryModal(
{
state,
dom: {
overlay,
characterDictionaryModal: createNodeStub(true),
characterDictionaryClose: createNodeStub(),
characterDictionarySummary: createNodeStub(),
characterDictionaryCurrent: createNodeStub(),
characterDictionarySearchInput: searchInput,
characterDictionarySearchButton: searchButton,
characterDictionaryCandidates: candidates,
characterDictionaryStatus: status,
},
} as never,
{
modalStateReader: { isAnyModalOpen: () => false },
syncSettingsModalSubtitleSuppression: () => {},
},
);
modal.wireDomEvents();
await modal.openCharacterDictionaryModal();
assert.deepEqual(searches, ['']);
assert.equal(searchInput.value, 'Kage no Jitsuryokusha ni Naritakute!');
assert.equal(candidates.children.length, 1);
assert.match(status.textContent, /Enter a title/);
searchInput.value = 'Eminence in Shadow';
searchButton.dispatchEvent('click');
await flushAsyncWork();
assert.deepEqual(searches, ['', 'Eminence in Shadow']);
assert.equal(candidates.children.length, 1);
assert.match(status.textContent, /Select the correct AniList entry/);
} finally {
Object.defineProperty(globalThis, 'window', { configurable: true, value: previousWindow });
Object.defineProperty(globalThis, 'document', { configurable: true, value: previousDocument });
}
});
test('character dictionary modal marks override candidate as selected', async () => {
const previousWindow = globalThis.window;
const previousDocument = globalThis.document;
const snapshot: CharacterDictionarySelectionSnapshot = {
seriesKey: 'konosuba-gods-blessing-on-this-wonderful-world-2016',
guessTitle: "KonoSuba - God's blessing on this wonderful world!",
current: null,
override: {
id: 21202,
title: "KONOSUBA -God's blessing on this wonderful world!",
episodes: 10,
},
candidates: [
{ id: 21202, title: "KONOSUBA -God's blessing on this wonderful world!", episodes: 10 },
],
};
const state = createRendererState();
const candidates = createNodeStub();
Object.defineProperty(globalThis, 'window', {
configurable: true,
value: {
electronAPI: {
getCharacterDictionarySelection: async () => snapshot,
setCharacterDictionarySelection: async () => ({
ok: true,
seriesKey: snapshot.seriesKey,
selected: snapshot.candidates[0]!,
staleMediaIds: [],
}),
notifyOverlayModalClosed: () => {},
notifyOverlayModalOpened: () => {},
} satisfies Pick<
ElectronAPI,
| 'getCharacterDictionarySelection'
| 'setCharacterDictionarySelection'
| 'notifyOverlayModalClosed'
| 'notifyOverlayModalOpened'
>,
},
});
Object.defineProperty(globalThis, 'document', {
configurable: true,
value: {
createElement: () => createElementStub(),
},
});
try {
const modal = createCharacterDictionaryModal(
{
state,
dom: {
overlay: createNodeStub(),
characterDictionaryModal: createNodeStub(true),
characterDictionaryClose: createNodeStub(),
characterDictionarySummary: createNodeStub(),
characterDictionaryCurrent: createNodeStub(),
characterDictionarySearchInput: createNodeStub(),
characterDictionarySearchButton: createNodeStub(),
characterDictionaryCandidates: candidates,
characterDictionaryStatus: createNodeStub(),
},
} as never,
{
modalStateReader: { isAnyModalOpen: () => false },
syncSettingsModalSubtitleSuppression: () => {},
},
);
await modal.openCharacterDictionaryModal();
const item = candidates.children[0] as { children: unknown[] };
const button = item.children[1] as { textContent: string; disabled: boolean };
assert.equal(button.textContent, 'Selected');
assert.equal(button.disabled, true);
} finally {
Object.defineProperty(globalThis, 'window', { configurable: true, value: previousWindow });
Object.defineProperty(globalThis, 'document', { configurable: true, value: previousDocument });
}
});
test('character dictionary modal does not resave the active override from keyboard apply', async () => {
const previousWindow = globalThis.window;
const snapshot: CharacterDictionarySelectionSnapshot = {
seriesKey: 're-zero-starting-life-in-another-world-2016',
guessTitle: 'Re ZERO, Starting Life in Another World',
current: { id: 21355, title: 'Re:ZERO -Starting Life in Another World-', episodes: 25 },
override: { id: 21355, title: 'Re:ZERO -Starting Life in Another World-', episodes: 25 },
candidates: [{ id: 21355, title: 'Re:ZERO -Starting Life in Another World-', episodes: 25 }],
};
const calls: number[] = [];
const state = createRendererState();
Object.defineProperty(globalThis, 'window', {
configurable: true,
value: {
electronAPI: {
getCharacterDictionarySelection: async () => snapshot,
setCharacterDictionarySelection: async (mediaId: number) => {
calls.push(mediaId);
return {
ok: true,
seriesKey: snapshot.seriesKey,
selected: snapshot.candidates[0]!,
staleMediaIds: [],
};
},
notifyOverlayModalClosed: () => {},
notifyOverlayModalOpened: () => {},
} satisfies Pick<
ElectronAPI,
| 'getCharacterDictionarySelection'
| 'setCharacterDictionarySelection'
| 'notifyOverlayModalClosed'
| 'notifyOverlayModalOpened'
>,
},
});
try {
const modal = createCharacterDictionaryModal(
{
state,
dom: {
overlay: createNodeStub(),
characterDictionaryModal: createNodeStub(true),
characterDictionaryClose: createNodeStub(),
characterDictionarySummary: createNodeStub(),
characterDictionaryCurrent: createNodeStub(),
characterDictionarySearchInput: createNodeStub(),
characterDictionarySearchButton: createNodeStub(),
characterDictionaryCandidates: createNodeStub(),
characterDictionaryStatus: createNodeStub(),
},
} as never,
{
modalStateReader: { isAnyModalOpen: () => false },
syncSettingsModalSubtitleSuppression: () => {},
},
);
await modal.openCharacterDictionaryModal();
modal.handleCharacterDictionaryKeydown({
key: 'Enter',
preventDefault: () => {},
} as KeyboardEvent);
await flushAsyncWork();
assert.deepEqual(calls, []);
} finally {
Object.defineProperty(globalThis, 'window', { configurable: true, value: previousWindow });
}
});
+64 -12
View File
@@ -27,17 +27,25 @@ export function createCharacterDictionaryModal(
syncSettingsModalSubtitleSuppression: () => void;
},
) {
let hasSearched = false;
function setStatus(message: string, isError = false): void {
ctx.state.characterDictionaryStatus = message;
ctx.dom.characterDictionaryStatus.textContent = message;
ctx.dom.characterDictionaryStatus.classList.toggle('error', isError);
}
function setSelection(snapshot: CharacterDictionarySelectionSnapshot): void {
function setSelection(
snapshot: CharacterDictionarySelectionSnapshot,
seedSearchInput = false,
): void {
const previousId =
ctx.state.characterDictionarySelection?.candidates[ctx.state.characterDictionarySelectedIndex]
?.id;
ctx.state.characterDictionarySelection = snapshot;
if (seedSearchInput) {
ctx.dom.characterDictionarySearchInput.value = snapshot.guessTitle ?? '';
}
const nextIndex = snapshot.candidates.findIndex((candidate) => candidate.id === previousId);
ctx.state.characterDictionarySelectedIndex = clampIndex(
nextIndex >= 0 ? nextIndex : 0,
@@ -47,6 +55,7 @@ export function createCharacterDictionaryModal(
}
function renderCandidate(candidate: CharacterDictionaryCandidate, index: number): HTMLLIElement {
const isOverride = candidate.id === ctx.state.characterDictionarySelection?.override?.id;
const item = document.createElement('li');
item.className = 'character-dictionary-candidate';
item.classList.toggle('active', index === ctx.state.characterDictionarySelectedIndex);
@@ -63,9 +72,11 @@ export function createCharacterDictionaryModal(
const button = document.createElement('button');
button.className = 'character-dictionary-use';
button.type = 'button';
button.textContent = 'Use';
button.textContent = isOverride ? 'Selected' : 'Use';
button.disabled = isOverride;
button.addEventListener('click', (event) => {
event.stopPropagation();
if (isOverride) return;
ctx.state.characterDictionarySelectedIndex = index;
void applySelectedCandidate();
});
@@ -104,7 +115,9 @@ export function createCharacterDictionaryModal(
if (snapshot.candidates.length === 0) {
const empty = document.createElement('li');
empty.className = 'character-dictionary-empty';
empty.textContent = 'No AniList candidates found.';
empty.textContent = hasSearched
? 'No AniList candidates found.'
: 'Search AniList to show candidates.';
ctx.dom.characterDictionaryCandidates.append(empty);
return;
}
@@ -114,20 +127,41 @@ export function createCharacterDictionaryModal(
);
}
async function refreshSelection(): Promise<void> {
const snapshot = await window.electronAPI.getCharacterDictionarySelection();
setSelection(snapshot);
async function refreshSelection(searchTitle?: string): Promise<void> {
const snapshot = await window.electronAPI.getCharacterDictionarySelection(searchTitle);
hasSearched = searchTitle !== '';
setSelection(snapshot, searchTitle === '');
setStatus(
snapshot.override
? `Override active: ${formatCandidate(snapshot.override)}`
: 'Select the correct AniList entry.',
searchTitle === ''
? 'Enter a title to search AniList.'
: snapshot.override
? `Override active: ${formatCandidate(snapshot.override)}`
: 'Select the correct AniList entry.',
);
}
async function searchCandidates(): Promise<void> {
const searchTitle = ctx.dom.characterDictionarySearchInput.value.trim();
if (!searchTitle) {
setStatus('Enter a title to search AniList.', true);
return;
}
ctx.dom.characterDictionarySearchButton.disabled = true;
setStatus(`Searching AniList for ${searchTitle}...`);
try {
await refreshSelection(searchTitle);
} catch (error) {
setStatus(error instanceof Error ? error.message : String(error), true);
} finally {
ctx.dom.characterDictionarySearchButton.disabled = false;
}
}
async function applySelectedCandidate(): Promise<void> {
const snapshot = ctx.state.characterDictionarySelection;
const candidate = snapshot?.candidates[ctx.state.characterDictionarySelectedIndex];
if (!candidate) return;
if (candidate.id === snapshot?.override?.id) return;
setStatus(`Saving override for ${candidate.title}...`);
try {
@@ -136,7 +170,7 @@ export function createCharacterDictionaryModal(
setStatus('Failed to save override', true);
return;
}
await refreshSelection();
await refreshSelection(ctx.dom.characterDictionarySearchInput.value.trim());
const staleLabel =
result.staleMediaIds.length > 0
? ` Removed stale: ${result.staleMediaIds.join(', ')}.`
@@ -154,7 +188,7 @@ export function createCharacterDictionaryModal(
ctx.dom.characterDictionaryModal.classList.remove('hidden');
ctx.dom.characterDictionaryModal.setAttribute('aria-hidden', 'false');
window.electronAPI.notifyOverlayModalOpened('character-dictionary');
setStatus('Loading AniList candidates...');
setStatus('Loading character dictionary selector...');
}
async function openCharacterDictionaryModal(): Promise<void> {
@@ -165,7 +199,7 @@ export function createCharacterDictionaryModal(
setStatus('Refreshing AniList candidates...');
}
try {
await refreshSelection();
await refreshSelection('');
} catch (error) {
setStatus(error instanceof Error ? error.message : String(error), true);
}
@@ -179,6 +213,7 @@ export function createCharacterDictionaryModal(
ctx.dom.characterDictionaryModal.classList.add('hidden');
ctx.dom.characterDictionaryModal.setAttribute('aria-hidden', 'true');
ctx.dom.characterDictionaryCandidates.replaceChildren();
hasSearched = false;
window.electronAPI.notifyOverlayModalClosed('character-dictionary');
setStatus('');
if (!ctx.state.isOverSubtitle && !options.modalStateReader.isAnyModalOpen()) {
@@ -202,6 +237,14 @@ export function createCharacterDictionaryModal(
closeCharacterDictionaryModal();
return true;
}
if (e.target === ctx.dom.characterDictionarySearchInput) {
if (e.key === 'Enter') {
e.preventDefault();
void searchCandidates();
return true;
}
return false;
}
if (e.key === 'ArrowDown' || e.key === 'j' || e.key === 'J') {
e.preventDefault();
moveSelection(1);
@@ -222,6 +265,15 @@ export function createCharacterDictionaryModal(
function wireDomEvents(): void {
ctx.dom.characterDictionaryClose.addEventListener('click', closeCharacterDictionaryModal);
ctx.dom.characterDictionarySearchButton.addEventListener('click', () => {
void searchCandidates();
});
ctx.dom.characterDictionarySearchInput.addEventListener('keydown', (event) => {
if (event.key === 'Enter') {
event.preventDefault();
void searchCandidates();
}
});
}
return {