mirror of
https://github.com/ksyasuda/SubMiner.git
synced 2026-05-29 12:55:16 -07:00
Add inline character portraits and dictionary search workflow (#83)
This commit is contained in:
@@ -681,6 +681,7 @@ test('numeric selection start focuses overlay for follow-up digit keys', async (
|
||||
assert.equal(testGlobals.windowFocusCalls() > 0, true);
|
||||
assert.equal(testGlobals.overlayFocusCalls.length > 0, true);
|
||||
} finally {
|
||||
testGlobals.dispatchKeydown({ key: 'Escape', code: 'Escape' });
|
||||
testGlobals.restore();
|
||||
}
|
||||
});
|
||||
|
||||
+17
-1
@@ -22,7 +22,7 @@
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
|
||||
<meta
|
||||
http-equiv="Content-Security-Policy"
|
||||
content="default-src 'self' 'unsafe-inline' chrome-extension:; script-src 'self' 'unsafe-inline' chrome-extension:; style-src 'self' 'unsafe-inline' chrome-extension:; worker-src 'self' blob:;"
|
||||
content="default-src 'self' 'unsafe-inline' chrome-extension:; script-src 'self' 'unsafe-inline' chrome-extension:; style-src 'self' 'unsafe-inline' chrome-extension:; img-src 'self' data: blob: chrome-extension:; worker-src 'self' blob:;"
|
||||
/>
|
||||
<title>SubMiner</title>
|
||||
<link rel="stylesheet" href="style.css" />
|
||||
@@ -205,6 +205,22 @@
|
||||
</div>
|
||||
<div class="modal-body">
|
||||
<div id="characterDictionarySummary" class="runtime-options-hint"></div>
|
||||
<div class="character-dictionary-search">
|
||||
<input
|
||||
id="characterDictionarySearchInput"
|
||||
class="character-dictionary-search-input"
|
||||
type="text"
|
||||
aria-label="Search character dictionary"
|
||||
autocomplete="off"
|
||||
/>
|
||||
<button
|
||||
id="characterDictionarySearchButton"
|
||||
class="character-dictionary-use"
|
||||
type="button"
|
||||
>
|
||||
Search
|
||||
</button>
|
||||
</div>
|
||||
<div id="characterDictionaryCurrent" class="character-dictionary-current"></div>
|
||||
<ul id="characterDictionaryCandidates" class="character-dictionary-candidates"></ul>
|
||||
<div id="characterDictionaryStatus" class="runtime-options-status"></div>
|
||||
|
||||
@@ -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 });
|
||||
}
|
||||
});
|
||||
|
||||
@@ -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 {
|
||||
|
||||
@@ -809,6 +809,28 @@ body.settings-modal-open [data-subminer-yomitan-popup-host='true'] {
|
||||
color: var(--subtitle-name-match-color, #f5bde6);
|
||||
}
|
||||
|
||||
#subtitleRoot .word.word-character-image-token {
|
||||
display: inline-block;
|
||||
position: relative;
|
||||
padding-left: 1.08em;
|
||||
vertical-align: baseline;
|
||||
}
|
||||
|
||||
#subtitleRoot .word-character-image {
|
||||
position: absolute;
|
||||
left: 0;
|
||||
top: 50%;
|
||||
width: 0.9em;
|
||||
height: 0.9em;
|
||||
border-radius: 50%;
|
||||
object-fit: cover;
|
||||
transform: translateY(calc(-50% + 0.05em));
|
||||
pointer-events: none;
|
||||
box-shadow:
|
||||
0 0 0 0.06em rgba(255, 255, 255, 0.32),
|
||||
0 0.08em 0.2em rgba(0, 0, 0, 0.45);
|
||||
}
|
||||
|
||||
#subtitleRoot .word.word-jlpt-n1 {
|
||||
text-decoration-line: none;
|
||||
border-bottom: 2px solid var(--subtitle-jlpt-n1-color, #ed8796);
|
||||
@@ -1551,6 +1573,27 @@ iframe[id^='yomitan-popup'],
|
||||
color: var(--ctp-subtext1);
|
||||
}
|
||||
|
||||
.character-dictionary-search {
|
||||
display: flex;
|
||||
gap: 8px;
|
||||
margin: 10px 0;
|
||||
}
|
||||
|
||||
.character-dictionary-search-input {
|
||||
min-width: 0;
|
||||
flex: 1 1 auto;
|
||||
border: 1px solid rgba(110, 115, 141, 0.28);
|
||||
border-radius: 6px;
|
||||
background: rgba(24, 25, 38, 0.88);
|
||||
color: var(--ctp-text);
|
||||
padding: 7px 9px;
|
||||
}
|
||||
|
||||
.character-dictionary-search-input:focus {
|
||||
border-color: rgba(138, 173, 244, 0.75);
|
||||
outline: none;
|
||||
}
|
||||
|
||||
.character-dictionary-candidates {
|
||||
list-style: none;
|
||||
margin: 0;
|
||||
@@ -1602,6 +1645,11 @@ iframe[id^='yomitan-popup'],
|
||||
background: rgba(91, 96, 120, 0.9);
|
||||
}
|
||||
|
||||
.character-dictionary-use:disabled {
|
||||
cursor: default;
|
||||
opacity: 0.72;
|
||||
}
|
||||
|
||||
.character-dictionary-empty {
|
||||
color: var(--ctp-overlay1);
|
||||
font-size: 13px;
|
||||
|
||||
@@ -259,6 +259,103 @@ test('applySubtitleStyle sets subtitle name-match color variable', () => {
|
||||
}
|
||||
});
|
||||
|
||||
test('renderSubtitle injects circular character image for annotated name matches', () => {
|
||||
const restoreDocument = installFakeDocument();
|
||||
try {
|
||||
const subtitleRoot = new FakeElement('div');
|
||||
const ctx = {
|
||||
state: {
|
||||
...createRendererState(),
|
||||
nameMatchEnabled: true,
|
||||
},
|
||||
dom: {
|
||||
subtitleRoot,
|
||||
subtitleContainer: new FakeElement('div'),
|
||||
secondarySubRoot: new FakeElement('div'),
|
||||
secondarySubContainer: new FakeElement('div'),
|
||||
},
|
||||
} as never;
|
||||
|
||||
const renderer = createSubtitleRenderer(ctx);
|
||||
renderer.renderSubtitle({
|
||||
text: 'アクア',
|
||||
tokens: [
|
||||
{
|
||||
...createToken({ surface: 'アクア', headword: 'アクア', reading: 'あくあ' }),
|
||||
isNameMatch: true,
|
||||
characterImage: {
|
||||
src: 'data:image/png;base64,AAAA',
|
||||
alt: 'アクア',
|
||||
},
|
||||
} as MergedToken,
|
||||
],
|
||||
});
|
||||
|
||||
const [word] = collectWordNodes(subtitleRoot);
|
||||
assert.ok(word);
|
||||
assert.equal(word.className, 'word word-name-match word-character-image-token');
|
||||
assert.equal(word.textContent, 'アクア');
|
||||
const image = word.childNodes[0] as FakeElement & { src?: string; alt?: string };
|
||||
assert.equal(image.tagName, 'img');
|
||||
assert.equal(image.className, 'word-character-image');
|
||||
assert.equal(image.src, 'data:image/png;base64,AAAA');
|
||||
assert.equal(image.alt, 'アクア');
|
||||
} finally {
|
||||
restoreDocument();
|
||||
}
|
||||
});
|
||||
|
||||
test('renderSubtitle skips character image when name-match rendering is disabled', () => {
|
||||
const restoreDocument = installFakeDocument();
|
||||
try {
|
||||
const subtitleRoot = new FakeElement('div');
|
||||
const ctx = {
|
||||
state: {
|
||||
...createRendererState(),
|
||||
nameMatchEnabled: false,
|
||||
},
|
||||
dom: {
|
||||
subtitleRoot,
|
||||
subtitleContainer: new FakeElement('div'),
|
||||
secondarySubRoot: new FakeElement('div'),
|
||||
secondarySubContainer: new FakeElement('div'),
|
||||
},
|
||||
} as never;
|
||||
|
||||
const renderer = createSubtitleRenderer(ctx);
|
||||
renderer.renderSubtitle({
|
||||
text: 'アクア',
|
||||
tokens: [
|
||||
{
|
||||
...createToken({ surface: 'アクア', headword: 'アクア', reading: 'あくあ' }),
|
||||
isNameMatch: true,
|
||||
characterImage: {
|
||||
src: 'data:image/png;base64,AAAA',
|
||||
alt: 'アクア',
|
||||
},
|
||||
} as MergedToken,
|
||||
],
|
||||
});
|
||||
|
||||
const [word] = collectWordNodes(subtitleRoot);
|
||||
assert.ok(word);
|
||||
assert.equal(word.className, 'word');
|
||||
assert.equal(word.textContent, 'アクア');
|
||||
assert.equal(word.childNodes.length, 0);
|
||||
} finally {
|
||||
restoreDocument();
|
||||
}
|
||||
});
|
||||
|
||||
test('renderer content security policy allows data URL character images', () => {
|
||||
const htmlPath = path.join(process.cwd(), 'src', 'renderer', 'index.html');
|
||||
const htmlText = fs.readFileSync(htmlPath, 'utf-8');
|
||||
const cspMatch = htmlText.match(/http-equiv="Content-Security-Policy"[\s\S]*?content="([^"]+)"/);
|
||||
|
||||
assert.ok(cspMatch, 'renderer CSP meta tag should exist');
|
||||
assert.match(cspMatch[1] ?? '', /(?:^|;)\s*img-src\s+[^;]*\bdata:/);
|
||||
});
|
||||
|
||||
test('applySubtitleStyle stores secondary background styles in hover-aware css variables', () => {
|
||||
const restoreDocument = installFakeDocument();
|
||||
try {
|
||||
@@ -869,6 +966,19 @@ test('subtitle annotation CSS underlines JLPT tokens without changing token colo
|
||||
const wordBlock = extractClassBlock(cssText, '#subtitleRoot .word');
|
||||
assert.match(wordBlock, /-webkit-text-fill-color:\s*currentColor\s*!important;/);
|
||||
|
||||
const characterImageTokenBlock = extractClassBlock(
|
||||
cssText,
|
||||
'#subtitleRoot .word.word-character-image-token',
|
||||
);
|
||||
assert.match(characterImageTokenBlock, /display:\s*inline-block;/);
|
||||
assert.match(characterImageTokenBlock, /position:\s*relative;/);
|
||||
assert.match(characterImageTokenBlock, /padding-left:\s*1\.08em;/);
|
||||
|
||||
const characterImageBlock = extractClassBlock(cssText, '#subtitleRoot .word-character-image');
|
||||
assert.match(characterImageBlock, /position:\s*absolute;/);
|
||||
assert.match(characterImageBlock, /top:\s*50%;/);
|
||||
assert.match(characterImageBlock, /transform:\s*translateY\(calc\(-50%\s*\+\s*0\.05em\)\);/);
|
||||
|
||||
const frequencyTooltipBaseBlock = extractClassBlock(
|
||||
cssText,
|
||||
'#subtitleRoot .word[data-frequency-rank]::before',
|
||||
|
||||
@@ -105,6 +105,40 @@ function hasPrioritizedNameMatch(
|
||||
);
|
||||
}
|
||||
|
||||
function hasTokenCharacterImage(token: MergedToken): boolean {
|
||||
return (
|
||||
typeof token.characterImage?.src === 'string' && token.characterImage.src.trim().length > 0
|
||||
);
|
||||
}
|
||||
|
||||
function shouldRenderTokenCharacterImage(
|
||||
token: MergedToken,
|
||||
tokenRenderSettings: Partial<Pick<TokenRenderSettings, 'nameMatchEnabled'>>,
|
||||
): boolean {
|
||||
return hasPrioritizedNameMatch(token, tokenRenderSettings) && hasTokenCharacterImage(token);
|
||||
}
|
||||
|
||||
function appendTokenSurface(
|
||||
span: HTMLSpanElement,
|
||||
token: MergedToken,
|
||||
surface: string,
|
||||
tokenRenderSettings: Partial<Pick<TokenRenderSettings, 'nameMatchEnabled'>>,
|
||||
): void {
|
||||
if (!shouldRenderTokenCharacterImage(token, tokenRenderSettings)) {
|
||||
span.textContent = surface;
|
||||
return;
|
||||
}
|
||||
|
||||
const image = document.createElement('img');
|
||||
image.className = 'word-character-image';
|
||||
image.src = token.characterImage!.src;
|
||||
image.alt = token.characterImage!.alt || token.headword || surface;
|
||||
image.decoding = 'async';
|
||||
image.loading = 'eager';
|
||||
span.appendChild(image);
|
||||
span.appendChild(document.createTextNode(surface));
|
||||
}
|
||||
|
||||
function sanitizeFrequencyTopX(value: unknown, fallback: number): number {
|
||||
if (typeof value !== 'number' || !Number.isFinite(value) || value <= 0) {
|
||||
return fallback;
|
||||
@@ -393,7 +427,7 @@ function renderWithTokens(
|
||||
const token = segment.token;
|
||||
const span = getSpanTemplate().cloneNode(false) as HTMLSpanElement;
|
||||
span.className = computeWordClass(token, resolvedTokenRenderSettings);
|
||||
span.textContent = token.surface;
|
||||
appendTokenSurface(span, token, token.surface, resolvedTokenRenderSettings);
|
||||
span.dataset.tokenIndex = String(segment.tokenIndex);
|
||||
if (token.reading) span.dataset.reading = token.reading;
|
||||
if (token.headword) span.dataset.headword = token.headword;
|
||||
@@ -429,7 +463,7 @@ function renderWithTokens(
|
||||
|
||||
const span = getSpanTemplate().cloneNode(false) as HTMLSpanElement;
|
||||
span.className = computeWordClass(token, resolvedTokenRenderSettings);
|
||||
span.textContent = surface;
|
||||
appendTokenSurface(span, token, surface, resolvedTokenRenderSettings);
|
||||
span.dataset.tokenIndex = String(index);
|
||||
if (token.reading) span.dataset.reading = token.reading;
|
||||
if (token.headword) span.dataset.headword = token.headword;
|
||||
@@ -572,6 +606,10 @@ export function computeWordClass(
|
||||
}
|
||||
}
|
||||
|
||||
if (shouldRenderTokenCharacterImage(token, resolvedTokenRenderSettings)) {
|
||||
classes.push('word-character-image-token');
|
||||
}
|
||||
|
||||
return classes.join(' ');
|
||||
}
|
||||
|
||||
|
||||
@@ -60,6 +60,8 @@ export type RendererDom = {
|
||||
characterDictionaryModal: HTMLDivElement;
|
||||
characterDictionaryClose: HTMLButtonElement;
|
||||
characterDictionarySummary: HTMLDivElement;
|
||||
characterDictionarySearchInput: HTMLInputElement;
|
||||
characterDictionarySearchButton: HTMLButtonElement;
|
||||
characterDictionaryCurrent: HTMLDivElement;
|
||||
characterDictionaryCandidates: HTMLUListElement;
|
||||
characterDictionaryStatus: HTMLDivElement;
|
||||
@@ -187,6 +189,12 @@ export function resolveRendererDom(): RendererDom {
|
||||
characterDictionaryModal: getRequiredElement<HTMLDivElement>('characterDictionaryModal'),
|
||||
characterDictionaryClose: getRequiredElement<HTMLButtonElement>('characterDictionaryClose'),
|
||||
characterDictionarySummary: getRequiredElement<HTMLDivElement>('characterDictionarySummary'),
|
||||
characterDictionarySearchInput: getRequiredElement<HTMLInputElement>(
|
||||
'characterDictionarySearchInput',
|
||||
),
|
||||
characterDictionarySearchButton: getRequiredElement<HTMLButtonElement>(
|
||||
'characterDictionarySearchButton',
|
||||
),
|
||||
characterDictionaryCurrent: getRequiredElement<HTMLDivElement>('characterDictionaryCurrent'),
|
||||
characterDictionaryCandidates: getRequiredElement<HTMLUListElement>(
|
||||
'characterDictionaryCandidates',
|
||||
|
||||
Reference in New Issue
Block a user