fix: Kiku field grouping, frequency particles, sidebar media, Yomitan popup visibility (#91)

This commit is contained in:
2026-05-27 01:40:48 -07:00
committed by GitHub
parent efe50ed1e4
commit 1dcfed86ab
52 changed files with 1695 additions and 368 deletions
+83
View File
@@ -970,6 +970,89 @@ test('window blur reclaims overlay focus while a yomitan popup remains visible o
}
});
test('yomitan popup visibility marks primary subtitle hover hold while enabled', () => {
const ctx = createMouseTestContext();
(ctx.state as { primaryVisibleOnYomitanPopup?: boolean }).primaryVisibleOnYomitanPopup = true;
const previousWindow = (globalThis as { window?: unknown }).window;
const previousDocument = (globalThis as { document?: unknown }).document;
const previousMutationObserver = (globalThis as { MutationObserver?: unknown }).MutationObserver;
const previousNode = (globalThis as { Node?: unknown }).Node;
const windowListeners = new Map<string, Array<() => void>>();
const bodyClassList = createClassList();
Object.defineProperty(globalThis, 'window', {
configurable: true,
value: {
addEventListener: (type: string, listener: () => void) => {
const bucket = windowListeners.get(type) ?? [];
bucket.push(listener);
windowListeners.set(type, bucket);
},
electronAPI: {
setIgnoreMouseEvents: () => {},
},
focus: () => {},
},
});
Object.defineProperty(globalThis, 'document', {
configurable: true,
value: {
body: { classList: bodyClassList },
querySelectorAll: () => [],
querySelector: () => null,
visibilityState: 'visible',
},
});
Object.defineProperty(globalThis, 'MutationObserver', {
configurable: true,
value: class {
observe() {}
},
});
Object.defineProperty(globalThis, 'Node', {
configurable: true,
value: {
ELEMENT_NODE: 1,
},
});
try {
const handlers = createMouseHandlers(ctx as never, {
modalStateReader: {
isAnySettingsModalOpen: () => false,
isAnyModalOpen: () => false,
},
applyYPercent: () => {},
getCurrentYPercent: () => 10,
persistSubtitlePositionPatch: () => {},
getSubtitleHoverAutoPauseEnabled: () => false,
getYomitanPopupAutoPauseEnabled: () => false,
getPlaybackPaused: async () => false,
sendMpvCommand: () => {},
});
handlers.setupYomitanObserver();
for (const listener of windowListeners.get(YOMITAN_POPUP_SHOWN_EVENT) ?? []) {
listener();
}
assert.equal(bodyClassList.contains('primary-sub-visible-on-yomitan-popup'), true);
for (const listener of windowListeners.get(YOMITAN_POPUP_HIDDEN_EVENT) ?? []) {
listener();
}
assert.equal(bodyClassList.contains('primary-sub-visible-on-yomitan-popup'), false);
} finally {
Object.defineProperty(globalThis, 'window', { configurable: true, value: previousWindow });
Object.defineProperty(globalThis, 'document', { configurable: true, value: previousDocument });
Object.defineProperty(globalThis, 'MutationObserver', {
configurable: true,
value: previousMutationObserver,
});
Object.defineProperty(globalThis, 'Node', { configurable: true, value: previousNode });
}
});
test('restorePointerInteractionState re-enables subtitle hover when pointer is already over subtitles', () => {
const ctx = createMouseTestContext();
const originalWindow = globalThis.window;
+13
View File
@@ -5,6 +5,7 @@ import {
YOMITAN_POPUP_MOUSE_ENTER_EVENT,
YOMITAN_POPUP_MOUSE_LEAVE_EVENT,
YOMITAN_POPUP_SHOWN_EVENT,
PRIMARY_SUB_VISIBLE_ON_YOMITAN_POPUP_CLASS,
isYomitanPopupVisible,
isYomitanPopupIframe,
} from '../yomitan-popup.js';
@@ -44,10 +45,21 @@ export function createMouseHandlers(
return typeof document !== 'undefined' && isYomitanPopupVisible(document);
}
function syncPrimaryVisibleOnYomitanPopupClass(popupVisible: boolean): void {
if (typeof document === 'undefined') {
return;
}
document.body?.classList?.toggle(
PRIMARY_SUB_VISIBLE_ON_YOMITAN_POPUP_CLASS,
popupVisible && ctx.state.primaryVisibleOnYomitanPopup,
);
}
function syncPopupVisibilityState(assumeVisible = false): boolean {
const popupVisible = assumeVisible || getPopupVisibilityFromDom();
yomitanPopupVisible = popupVisible;
ctx.state.yomitanPopupVisible = popupVisible;
syncPrimaryVisibleOnYomitanPopupClass(popupVisible);
return popupVisible;
}
@@ -293,6 +305,7 @@ export function createMouseHandlers(
yomitanPopupVisible = false;
ctx.state.yomitanPopupVisible = false;
syncPrimaryVisibleOnYomitanPopupClass(false);
popupPauseRequestId += 1;
maybeResumeYomitanPopupPause();
maybeResumeHoverPause();
@@ -113,6 +113,88 @@ test('findActiveSubtitleCueIndex prefers current subtitle timing over near-futur
assert.equal(findActiveSubtitleCueIndex(cues, { text: 'previous', startTime: 231 }, 233, 0), 0);
});
test('subtitle sidebar mining context resolves selected row cue timing', () => {
const globals = globalThis as typeof globalThis & {
Element?: unknown;
Node?: unknown;
window?: unknown;
};
const previousElement = globals.Element;
const previousNode = globals.Node;
const previousWindow = globals.window;
class FakeNode {
parentElement: FakeElement | null = null;
}
class FakeElement extends FakeNode {
dataset: Record<string, string> = {};
closest(selector: string) {
return selector === '.subtitle-sidebar-item' ? this : null;
}
}
const row = new FakeElement();
row.dataset.index = '1';
const textNode = new FakeNode();
textNode.parentElement = row;
Object.defineProperty(globalThis, 'Node', { configurable: true, value: FakeNode });
Object.defineProperty(globalThis, 'Element', { configurable: true, value: FakeElement });
Object.defineProperty(globalThis, 'window', {
configurable: true,
value: {
getSelection: () => ({ anchorNode: textNode, focusNode: null }),
},
});
try {
const state = createRendererState();
state.subtitleSidebarModalOpen = true;
state.subtitleSidebarCues = [
{ startTime: 1, endTime: 2, text: 'current line' },
{ startTime: 3, endTime: 5, text: 'sidebar previous line' },
];
const modal = createSubtitleSidebarModal(
{
dom: {
overlay: { classList: createClassList() },
subtitleSidebarModal: {
classList: createClassList(),
setAttribute: () => {},
style: { setProperty: () => {} },
addEventListener: () => {},
},
subtitleSidebarContent: {
classList: createClassList(),
getBoundingClientRect: () => ({ width: 420 }),
style: { setProperty: () => {} },
},
subtitleSidebarClose: { addEventListener: () => {} },
subtitleSidebarStatus: { textContent: '' },
subtitleSidebarList: createListStub(),
},
state,
} as never,
{
modalStateReader: { isAnyModalOpen: () => false },
},
);
const context = modal.getSubtitleSidebarMiningContext();
assert.equal(context?.source, 'subtitle-sidebar');
assert.equal(context?.text, 'sidebar previous line');
assert.equal(context?.startTime, 3);
assert.equal(context?.endTime, 5);
assert.equal(typeof context?.capturedAtMs, 'number');
} finally {
Object.defineProperty(globalThis, 'Element', { configurable: true, value: previousElement });
Object.defineProperty(globalThis, 'Node', { configurable: true, value: previousNode });
Object.defineProperty(globalThis, 'window', { configurable: true, value: previousWindow });
}
});
test('applySidebarCssDeclarations clears declarations removed by config reload', () => {
const removed: string[] = [];
const style = {
+84 -1
View File
@@ -1,4 +1,9 @@
import type { SubtitleCue, SubtitleData, SubtitleSidebarSnapshot } from '../../types';
import type {
SubtitleCue,
SubtitleData,
SubtitleMiningContext,
SubtitleSidebarSnapshot,
} from '../../types';
import type { ModalStateReader, RendererContext } from '../context';
import { syncOverlayMouseIgnoreState } from '../overlay-mouse-ignore.js';
import {
@@ -201,6 +206,7 @@ export function createSubtitleSidebarModal(
let subtitleSidebarFocusedWithin = false;
let subtitleSidebarYomitanPopupVisible = false;
let subtitleSidebarPauseHeldByYomitanPopup = false;
let lastSubtitleSidebarLookupCueIndex = -1;
function restoreEmbeddedSidebarPassthrough(): void {
syncOverlayMouseIgnoreState(ctx);
@@ -213,9 +219,75 @@ export function createSubtitleSidebarModal(
function clearSidebarInteractionState(): void {
subtitleSidebarHovered = false;
subtitleSidebarFocusedWithin = false;
lastSubtitleSidebarLookupCueIndex = -1;
syncSidebarInteractionState();
}
function findCueIndexFromNode(node: Node | null): number | null {
if (!node || typeof Element === 'undefined') {
return null;
}
const element = node instanceof Element ? node : node.parentElement;
const row = element?.closest<HTMLElement>('.subtitle-sidebar-item') ?? null;
if (!row) {
return null;
}
const index = Number.parseInt(row.dataset.index ?? '', 10);
if (!Number.isInteger(index) || index < 0 || index >= ctx.state.subtitleSidebarCues.length) {
return null;
}
return index;
}
function rememberLookupCueFromTarget(target: EventTarget | null): void {
if (typeof Node === 'undefined') {
return;
}
if (!(target instanceof Node)) {
return;
}
const index = findCueIndexFromNode(target);
if (index === null) {
return;
}
lastSubtitleSidebarLookupCueIndex = index;
}
function getSubtitleSidebarMiningContext(): SubtitleMiningContext | null {
if (!ctx.state.subtitleSidebarModalOpen) {
return null;
}
const selection = window.getSelection?.() ?? null;
const selectionIndex =
findCueIndexFromNode(selection?.anchorNode ?? null) ??
findCueIndexFromNode(selection?.focusNode ?? null);
const index =
selectionIndex ??
(lastSubtitleSidebarLookupCueIndex >= 0 ? lastSubtitleSidebarLookupCueIndex : null);
if (index === null) {
return null;
}
const cue = ctx.state.subtitleSidebarCues[index];
if (
!cue ||
!Number.isFinite(cue.startTime) ||
!Number.isFinite(cue.endTime) ||
cue.endTime <= cue.startTime
) {
return null;
}
return {
source: 'subtitle-sidebar',
text: cue.text,
startTime: cue.startTime,
endTime: cue.endTime,
capturedAtMs: Date.now(),
};
}
function setStatus(message: string): void {
ctx.dom.subtitleSidebarStatus.textContent = message;
}
@@ -653,6 +725,12 @@ export function createSubtitleSidebarModal(
ctx.dom.subtitleSidebarList.addEventListener('wheel', () => {
ctx.state.subtitleSidebarManualScrollUntilMs = nowForUiTiming() + MANUAL_SCROLL_HOLD_MS;
});
ctx.dom.subtitleSidebarList.addEventListener('pointerover', (event) => {
rememberLookupCueFromTarget(event.target);
});
ctx.dom.subtitleSidebarList.addEventListener('focusin', (event) => {
rememberLookupCueFromTarget(event.target);
});
ctx.dom.subtitleSidebarContent.addEventListener('mouseenter', async () => {
subtitleSidebarHovered = true;
syncSidebarInteractionState();
@@ -677,6 +755,9 @@ export function createSubtitleSidebarModal(
});
ctx.dom.subtitleSidebarContent.addEventListener('mouseleave', () => {
subtitleSidebarHovered = false;
if (!subtitleSidebarFocusedWithin) {
lastSubtitleSidebarLookupCueIndex = -1;
}
syncSidebarInteractionState();
if (ctx.state.isOverSubtitleSidebar) {
restoreEmbeddedSidebarPassthrough();
@@ -700,6 +781,7 @@ export function createSubtitleSidebarModal(
}
subtitleSidebarFocusedWithin = false;
lastSubtitleSidebarLookupCueIndex = -1;
syncSidebarInteractionState();
if (ctx.state.isOverSubtitleSidebar) {
restoreEmbeddedSidebarPassthrough();
@@ -736,5 +818,6 @@ export function createSubtitleSidebarModal(
},
handleSubtitleUpdated,
seekToCue,
getSubtitleSidebarMiningContext,
};
}
+1 -1
View File
@@ -580,7 +580,7 @@ registerModalOpenHandlers();
registerKeyboardCommandHandlers();
registerYomitanLookupListener(window, () => {
runGuarded('yomitan:lookup', () => {
window.electronAPI.recordYomitanLookup();
window.electronAPI.recordYomitanLookup(subtitleSidebarModal.getSubtitleSidebarMiningContext());
});
});
+2
View File
@@ -114,6 +114,7 @@ export type RendererState = {
preserveSubtitleLineBreaks: boolean;
autoPauseVideoOnSubtitleHover: boolean;
autoPauseVideoOnYomitanPopup: boolean;
primaryVisibleOnYomitanPopup: boolean;
frequencyDictionaryEnabled: boolean;
frequencyDictionaryTopX: number;
frequencyDictionaryMode: 'single' | 'banded';
@@ -225,6 +226,7 @@ export function createRendererState(): RendererState {
preserveSubtitleLineBreaks: false,
autoPauseVideoOnSubtitleHover: false,
autoPauseVideoOnYomitanPopup: false,
primaryVisibleOnYomitanPopup: true,
frequencyDictionaryEnabled: false,
frequencyDictionaryTopX: 1000,
frequencyDictionaryMode: 'single',
+4
View File
@@ -694,6 +694,10 @@ body.subtitle-sidebar-embedded-open #subtitleContainer {
opacity: 1;
}
body.primary-sub-visible-on-yomitan-popup #subtitleContainer.primary-sub-hover {
opacity: 1;
}
#subtitleContainer.primary-sub-hidden {
display: none;
pointer-events: none;
+6
View File
@@ -1205,6 +1205,12 @@ test('subtitle annotation CSS underlines JLPT tokens without changing token colo
);
assert.match(primaryHoverVisibleBlock, /opacity:\s*1;/);
const primaryHoverYomitanPopupVisibleBlock = extractClassBlock(
cssText,
'body.primary-sub-visible-on-yomitan-popup #subtitleContainer.primary-sub-hover',
);
assert.match(primaryHoverYomitanPopupVisibleBlock, /opacity:\s*1;/);
const secondaryEmbeddedHoverBlock = extractClassBlock(
cssText,
'body.subtitle-sidebar-embedded-open #secondarySubContainer.secondary-sub-hover',
+10
View File
@@ -6,6 +6,7 @@ import type {
SubtitleRendererStyleConfig,
} from '../types';
import type { RendererContext } from './context';
import { PRIMARY_SUB_VISIBLE_ON_YOMITAN_POPUP_CLASS } from './yomitan-popup.js';
type FrequencyRenderSettings = {
enabled: boolean;
@@ -259,6 +260,13 @@ function applySubtitleCssDeclarations(
);
}
function syncPrimaryVisibleOnYomitanPopupClass(ctx: RendererContext): void {
document.body?.classList?.toggle(
PRIMARY_SUB_VISIBLE_ON_YOMITAN_POPUP_CLASS,
ctx.state.yomitanPopupVisible && ctx.state.primaryVisibleOnYomitanPopup,
);
}
function pickInlineStyleDeclarations(
declarations: Record<string, unknown>,
includedKeys: ReadonlySet<string>,
@@ -805,6 +813,8 @@ export function createSubtitleRenderer(ctx: RendererContext) {
ctx.state.preserveSubtitleLineBreaks = style.preserveLineBreaks ?? false;
ctx.state.autoPauseVideoOnSubtitleHover = style.autoPauseVideoOnHover ?? false;
ctx.state.autoPauseVideoOnYomitanPopup = style.autoPauseVideoOnYomitanPopup ?? false;
ctx.state.primaryVisibleOnYomitanPopup = style.primaryVisibleOnYomitanPopup ?? true;
syncPrimaryVisibleOnYomitanPopupClass(ctx);
ctx.dom.subtitleRoot.style.setProperty('--subtitle-jlpt-n1-color', jlptColors.N1);
ctx.dom.subtitleRoot.style.setProperty('--subtitle-jlpt-n2-color', jlptColors.N2);
ctx.dom.subtitleRoot.style.setProperty('--subtitle-jlpt-n3-color', jlptColors.N3);
+1
View File
@@ -9,6 +9,7 @@ export const YOMITAN_POPUP_MOUSE_ENTER_EVENT = 'yomitan-popup-mouse-enter';
export const YOMITAN_POPUP_MOUSE_LEAVE_EVENT = 'yomitan-popup-mouse-leave';
export const YOMITAN_POPUP_COMMAND_EVENT = 'subminer-yomitan-popup-command';
export const YOMITAN_LOOKUP_EVENT = 'subminer-yomitan-lookup';
export const PRIMARY_SUB_VISIBLE_ON_YOMITAN_POPUP_CLASS = 'primary-sub-visible-on-yomitan-popup';
export function registerYomitanLookupListener(
target: EventTarget = window,