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
@@ -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,
};
}