mirror of
https://github.com/ksyasuda/SubMiner.git
synced 2026-03-20 12:11:28 -07:00
fix: preserve keyboard subtitle navigation state
This commit is contained in:
@@ -44,6 +44,8 @@ function installKeyboardTestGlobals() {
|
||||
|
||||
const documentListeners = new Map<string, Array<(event: unknown) => void>>();
|
||||
const commandEvents: CommandEventDetail[] = [];
|
||||
const mpvCommands: Array<Array<string | number>> = [];
|
||||
let playbackPausedResponse: boolean | null = false;
|
||||
|
||||
let popupVisible = false;
|
||||
|
||||
@@ -112,7 +114,10 @@ function installKeyboardTestGlobals() {
|
||||
},
|
||||
electronAPI: {
|
||||
getKeybindings: async () => [],
|
||||
sendMpvCommand: () => {},
|
||||
sendMpvCommand: (command: Array<string | number>) => {
|
||||
mpvCommands.push(command);
|
||||
},
|
||||
getPlaybackPaused: async () => playbackPausedResponse,
|
||||
toggleDevTools: () => {},
|
||||
focusMainWindow: () => {
|
||||
focusMainWindowCalls += 1;
|
||||
@@ -211,6 +216,7 @@ function installKeyboardTestGlobals() {
|
||||
|
||||
return {
|
||||
commandEvents,
|
||||
mpvCommands,
|
||||
overlay,
|
||||
overlayFocusCalls,
|
||||
focusMainWindowCalls: () => focusMainWindowCalls,
|
||||
@@ -220,6 +226,10 @@ function installKeyboardTestGlobals() {
|
||||
setPopupVisible: (value: boolean) => {
|
||||
popupVisible = value;
|
||||
},
|
||||
getPlaybackPaused: async () => playbackPausedResponse,
|
||||
setPlaybackPausedResponse: (value: boolean | null) => {
|
||||
playbackPausedResponse = value;
|
||||
},
|
||||
restore,
|
||||
};
|
||||
}
|
||||
@@ -228,23 +238,12 @@ function createKeyboardHandlerHarness() {
|
||||
const testGlobals = installKeyboardTestGlobals();
|
||||
const subtitleRootClassList = createClassList();
|
||||
|
||||
const wordNodes = [
|
||||
{
|
||||
classList: createClassList(),
|
||||
getBoundingClientRect: () => ({ left: 10, top: 10, width: 30, height: 20 }),
|
||||
dispatchEvent: () => true,
|
||||
},
|
||||
{
|
||||
classList: createClassList(),
|
||||
getBoundingClientRect: () => ({ left: 80, top: 10, width: 30, height: 20 }),
|
||||
dispatchEvent: () => true,
|
||||
},
|
||||
{
|
||||
classList: createClassList(),
|
||||
getBoundingClientRect: () => ({ left: 150, top: 10, width: 30, height: 20 }),
|
||||
dispatchEvent: () => true,
|
||||
},
|
||||
];
|
||||
const createWordNode = (left: number) => ({
|
||||
classList: createClassList(),
|
||||
getBoundingClientRect: () => ({ left, top: 10, width: 30, height: 20 }),
|
||||
dispatchEvent: () => true,
|
||||
});
|
||||
let wordNodes = [createWordNode(10), createWordNode(80), createWordNode(150)];
|
||||
|
||||
const ctx = {
|
||||
dom: {
|
||||
@@ -273,9 +272,17 @@ function createKeyboardHandlerHarness() {
|
||||
handleSessionHelpKeydown: () => false,
|
||||
openSessionHelpModal: () => {},
|
||||
appendClipboardVideoToQueue: () => {},
|
||||
getPlaybackPaused: () => testGlobals.getPlaybackPaused(),
|
||||
});
|
||||
|
||||
return { ctx, handlers, testGlobals };
|
||||
return {
|
||||
ctx,
|
||||
handlers,
|
||||
testGlobals,
|
||||
setWordCount: (count: number) => {
|
||||
wordNodes = Array.from({ length: count }, (_, index) => createWordNode(10 + index * 70));
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
test('keyboard mode: left and right move token selection while popup remains open', async () => {
|
||||
@@ -306,7 +313,7 @@ test('keyboard mode: left and right move token selection while popup remains ope
|
||||
}
|
||||
});
|
||||
|
||||
test('keyboard mode: up and j open yomitan lookup for selected token', async () => {
|
||||
test('keyboard mode: up/down/j/k do not open or close lookup when popup is closed', async () => {
|
||||
const { ctx, handlers, testGlobals } = createKeyboardHandlerHarness();
|
||||
|
||||
try {
|
||||
@@ -314,19 +321,25 @@ test('keyboard mode: up and j open yomitan lookup for selected token', async ()
|
||||
handlers.handleKeyboardModeToggleRequested();
|
||||
|
||||
testGlobals.dispatchKeydown({ key: 'ArrowUp', code: 'ArrowUp' });
|
||||
testGlobals.dispatchKeydown({ key: 'ArrowDown', code: 'ArrowDown' });
|
||||
testGlobals.dispatchKeydown({ key: 'j', code: 'KeyJ' });
|
||||
testGlobals.dispatchKeydown({ key: 'k', code: 'KeyK' });
|
||||
|
||||
await wait(80);
|
||||
await wait(0);
|
||||
|
||||
const openEvents = testGlobals.commandEvents.filter((event) => event.type === 'scanSelectedText');
|
||||
assert.equal(openEvents.length, 2);
|
||||
assert.equal(openEvents.length, 0);
|
||||
const closeEvents = testGlobals.commandEvents.filter(
|
||||
(event) => event.type === 'setVisible' && event.visible === false,
|
||||
);
|
||||
assert.equal(closeEvents.length, 0);
|
||||
} finally {
|
||||
ctx.state.keyboardDrivenModeEnabled = false;
|
||||
testGlobals.restore();
|
||||
}
|
||||
});
|
||||
|
||||
test('keyboard mode: down closes yomitan lookup window', async () => {
|
||||
test('keyboard mode: up/down/j/k forward keydown to yomitan popup when open', async () => {
|
||||
const { ctx, handlers, testGlobals } = createKeyboardHandlerHarness();
|
||||
|
||||
try {
|
||||
@@ -336,13 +349,26 @@ test('keyboard mode: down closes yomitan lookup window', async () => {
|
||||
ctx.state.yomitanPopupVisible = true;
|
||||
testGlobals.setPopupVisible(true);
|
||||
|
||||
testGlobals.dispatchKeydown({ key: 'ArrowUp', code: 'ArrowUp' });
|
||||
testGlobals.dispatchKeydown({ key: 'ArrowDown', code: 'ArrowDown' });
|
||||
await wait(0);
|
||||
testGlobals.dispatchKeydown({ key: 'j', code: 'KeyJ' });
|
||||
testGlobals.dispatchKeydown({ key: 'k', code: 'KeyK' });
|
||||
|
||||
const forwarded = testGlobals.commandEvents.filter(
|
||||
(event) => event.type === 'forwardKeyDown',
|
||||
);
|
||||
assert.equal(forwarded.length, 4);
|
||||
assert.equal(forwarded.some((event) => event.code === 'ArrowUp'), true);
|
||||
assert.equal(forwarded.some((event) => event.code === 'ArrowDown'), true);
|
||||
assert.equal(forwarded.some((event) => event.code === 'KeyJ'), true);
|
||||
assert.equal(forwarded.some((event) => event.code === 'KeyK'), true);
|
||||
|
||||
const openEvents = testGlobals.commandEvents.filter((event) => event.type === 'scanSelectedText');
|
||||
assert.equal(openEvents.length, 0);
|
||||
const closeEvents = testGlobals.commandEvents.filter(
|
||||
(event) => event.type === 'setVisible' && event.visible === false,
|
||||
);
|
||||
assert.equal(closeEvents.length, 1);
|
||||
assert.equal(closeEvents.length, 0);
|
||||
} finally {
|
||||
ctx.state.keyboardDrivenModeEnabled = false;
|
||||
testGlobals.restore();
|
||||
@@ -407,7 +433,7 @@ test('keyboard mode: opening lookup restores overlay keyboard focus', async () =
|
||||
await handlers.setupMpvInputForwarding();
|
||||
handlers.handleKeyboardModeToggleRequested();
|
||||
|
||||
testGlobals.dispatchKeydown({ key: 'ArrowUp', code: 'ArrowUp' });
|
||||
testGlobals.dispatchKeydown({ key: 'y', code: 'KeyY', ctrlKey: true });
|
||||
await wait(0);
|
||||
|
||||
assert.equal(testGlobals.focusMainWindowCalls() > 0, true);
|
||||
@@ -419,6 +445,134 @@ test('keyboard mode: opening lookup restores overlay keyboard focus', async () =
|
||||
}
|
||||
});
|
||||
|
||||
test('keyboard mode: moving right beyond end jumps next subtitle and resets selector to start', async () => {
|
||||
const { ctx, handlers, testGlobals, setWordCount } = createKeyboardHandlerHarness();
|
||||
|
||||
try {
|
||||
await handlers.setupMpvInputForwarding();
|
||||
handlers.handleKeyboardModeToggleRequested();
|
||||
|
||||
setWordCount(3);
|
||||
ctx.state.keyboardSelectedWordIndex = 2;
|
||||
handlers.syncKeyboardTokenSelection();
|
||||
|
||||
testGlobals.dispatchKeydown({ key: 'ArrowRight', code: 'ArrowRight' });
|
||||
await wait(0);
|
||||
assert.deepEqual(testGlobals.mpvCommands.at(-1), ['sub-seek', 1]);
|
||||
|
||||
setWordCount(2);
|
||||
handlers.syncKeyboardTokenSelection();
|
||||
assert.equal(ctx.state.keyboardSelectedWordIndex, 0);
|
||||
} finally {
|
||||
ctx.state.keyboardDrivenModeEnabled = false;
|
||||
testGlobals.restore();
|
||||
}
|
||||
});
|
||||
|
||||
test('keyboard mode: moving left beyond start jumps previous subtitle and sets selector to end', async () => {
|
||||
const { ctx, handlers, testGlobals, setWordCount } = createKeyboardHandlerHarness();
|
||||
|
||||
try {
|
||||
await handlers.setupMpvInputForwarding();
|
||||
handlers.handleKeyboardModeToggleRequested();
|
||||
|
||||
setWordCount(3);
|
||||
ctx.state.keyboardSelectedWordIndex = 0;
|
||||
handlers.syncKeyboardTokenSelection();
|
||||
|
||||
testGlobals.dispatchKeydown({ key: 'ArrowLeft', code: 'ArrowLeft' });
|
||||
await wait(0);
|
||||
assert.deepEqual(testGlobals.mpvCommands.at(-1), ['sub-seek', -1]);
|
||||
|
||||
setWordCount(4);
|
||||
handlers.syncKeyboardTokenSelection();
|
||||
assert.equal(ctx.state.keyboardSelectedWordIndex, 3);
|
||||
} finally {
|
||||
ctx.state.keyboardDrivenModeEnabled = false;
|
||||
testGlobals.restore();
|
||||
}
|
||||
});
|
||||
|
||||
test('keyboard mode: popup-open edge jump refreshes lookup on the new subtitle selection', async () => {
|
||||
const { ctx, handlers, testGlobals, setWordCount } = createKeyboardHandlerHarness();
|
||||
|
||||
try {
|
||||
await handlers.setupMpvInputForwarding();
|
||||
handlers.handleKeyboardModeToggleRequested();
|
||||
|
||||
setWordCount(2);
|
||||
ctx.state.keyboardSelectedWordIndex = 1;
|
||||
ctx.state.yomitanPopupVisible = true;
|
||||
testGlobals.setPopupVisible(true);
|
||||
handlers.syncKeyboardTokenSelection();
|
||||
|
||||
testGlobals.dispatchKeydown({ key: 'ArrowRight', code: 'ArrowRight' });
|
||||
await wait(0);
|
||||
assert.deepEqual(testGlobals.mpvCommands.at(-1), ['sub-seek', 1]);
|
||||
|
||||
setWordCount(3);
|
||||
handlers.syncKeyboardTokenSelection();
|
||||
await wait(80);
|
||||
|
||||
assert.equal(ctx.state.keyboardSelectedWordIndex, 0);
|
||||
const openEvents = testGlobals.commandEvents.filter((event) => event.type === 'scanSelectedText');
|
||||
assert.equal(openEvents.length > 0, true);
|
||||
} finally {
|
||||
ctx.state.keyboardDrivenModeEnabled = false;
|
||||
testGlobals.restore();
|
||||
}
|
||||
});
|
||||
|
||||
test('keyboard mode: edge jump while paused re-applies paused state after subtitle seek', async () => {
|
||||
const { ctx, handlers, testGlobals, setWordCount } = createKeyboardHandlerHarness();
|
||||
|
||||
try {
|
||||
await handlers.setupMpvInputForwarding();
|
||||
handlers.handleKeyboardModeToggleRequested();
|
||||
|
||||
setWordCount(2);
|
||||
ctx.state.keyboardSelectedWordIndex = 1;
|
||||
handlers.syncKeyboardTokenSelection();
|
||||
testGlobals.setPlaybackPausedResponse(true);
|
||||
|
||||
testGlobals.dispatchKeydown({ key: 'ArrowRight', code: 'ArrowRight' });
|
||||
await wait(0);
|
||||
|
||||
assert.deepEqual(testGlobals.mpvCommands.slice(-2), [
|
||||
['sub-seek', 1],
|
||||
['set_property', 'pause', 'yes'],
|
||||
]);
|
||||
} finally {
|
||||
ctx.state.keyboardDrivenModeEnabled = false;
|
||||
testGlobals.restore();
|
||||
}
|
||||
});
|
||||
|
||||
test('keyboard mode: edge jump with unknown pause state re-applies pause conservatively', async () => {
|
||||
const { ctx, handlers, testGlobals, setWordCount } = createKeyboardHandlerHarness();
|
||||
|
||||
try {
|
||||
await handlers.setupMpvInputForwarding();
|
||||
handlers.handleKeyboardModeToggleRequested();
|
||||
|
||||
setWordCount(2);
|
||||
ctx.state.keyboardSelectedWordIndex = 1;
|
||||
handlers.syncKeyboardTokenSelection();
|
||||
testGlobals.setPlaybackPausedResponse(null);
|
||||
|
||||
testGlobals.dispatchKeydown({ key: 'ArrowRight', code: 'ArrowRight' });
|
||||
await wait(0);
|
||||
|
||||
assert.deepEqual(testGlobals.mpvCommands.slice(-2), [
|
||||
['sub-seek', 1],
|
||||
['set_property', 'pause', 'yes'],
|
||||
]);
|
||||
} finally {
|
||||
ctx.state.keyboardDrivenModeEnabled = false;
|
||||
testGlobals.restore();
|
||||
}
|
||||
});
|
||||
|
||||
test('keyboard mode: popup iframe focusin reclaims overlay keyboard focus', async () => {
|
||||
const { ctx, handlers, testGlobals } = createKeyboardHandlerHarness();
|
||||
|
||||
|
||||
@@ -22,11 +22,14 @@ export function createKeyboardHandlers(
|
||||
fallbackUnavailable: boolean;
|
||||
}) => void;
|
||||
appendClipboardVideoToQueue: () => void;
|
||||
getPlaybackPaused: () => Promise<boolean | null>;
|
||||
},
|
||||
) {
|
||||
// Timeout for the modal chord capture window (e.g. Y followed by H/K).
|
||||
const CHORD_TIMEOUT_MS = 1000;
|
||||
const KEYBOARD_SELECTED_WORD_CLASS = 'keyboard-selected';
|
||||
let pendingSelectionAnchorAfterSubtitleSeek: 'start' | 'end' | null = null;
|
||||
let pendingLookupRefreshAfterSubtitleSeek = false;
|
||||
|
||||
const CHORD_MAP = new Map<
|
||||
string,
|
||||
@@ -138,9 +141,28 @@ export function createKeyboardHandlers(
|
||||
|
||||
if (!ctx.state.keyboardDrivenModeEnabled || wordNodes.length === 0) {
|
||||
ctx.state.keyboardSelectedWordIndex = null;
|
||||
if (!ctx.state.keyboardDrivenModeEnabled) {
|
||||
pendingSelectionAnchorAfterSubtitleSeek = null;
|
||||
pendingLookupRefreshAfterSubtitleSeek = false;
|
||||
}
|
||||
return;
|
||||
}
|
||||
|
||||
if (pendingSelectionAnchorAfterSubtitleSeek) {
|
||||
ctx.state.keyboardSelectedWordIndex =
|
||||
pendingSelectionAnchorAfterSubtitleSeek === 'start' ? 0 : wordNodes.length - 1;
|
||||
pendingSelectionAnchorAfterSubtitleSeek = null;
|
||||
const shouldRefreshLookup =
|
||||
pendingLookupRefreshAfterSubtitleSeek &&
|
||||
(ctx.state.yomitanPopupVisible || isYomitanPopupVisible(document));
|
||||
pendingLookupRefreshAfterSubtitleSeek = false;
|
||||
if (shouldRefreshLookup) {
|
||||
queueMicrotask(() => {
|
||||
triggerLookupForSelectedWord();
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
const selectedIndex = Math.min(
|
||||
Math.max(ctx.state.keyboardSelectedWordIndex ?? 0, 0),
|
||||
wordNodes.length - 1,
|
||||
@@ -156,6 +178,8 @@ export function createKeyboardHandlers(
|
||||
ctx.state.keyboardDrivenModeEnabled = enabled;
|
||||
if (!enabled) {
|
||||
ctx.state.keyboardSelectedWordIndex = null;
|
||||
pendingSelectionAnchorAfterSubtitleSeek = null;
|
||||
pendingLookupRefreshAfterSubtitleSeek = false;
|
||||
}
|
||||
syncKeyboardTokenSelection();
|
||||
}
|
||||
@@ -164,19 +188,45 @@ export function createKeyboardHandlers(
|
||||
setKeyboardDrivenModeEnabled(!ctx.state.keyboardDrivenModeEnabled);
|
||||
}
|
||||
|
||||
function moveKeyboardSelection(delta: -1 | 1): boolean {
|
||||
function moveKeyboardSelection(delta: -1 | 1): 'moved' | 'start-boundary' | 'end-boundary' | 'no-words' {
|
||||
const wordNodes = getSubtitleWordNodes();
|
||||
if (wordNodes.length === 0) {
|
||||
ctx.state.keyboardSelectedWordIndex = null;
|
||||
syncKeyboardTokenSelection();
|
||||
return true;
|
||||
return 'no-words';
|
||||
}
|
||||
|
||||
const currentIndex = ctx.state.keyboardSelectedWordIndex ?? 0;
|
||||
const nextIndex = Math.min(Math.max(currentIndex + delta, 0), wordNodes.length - 1);
|
||||
const currentIndex = Math.min(
|
||||
Math.max(ctx.state.keyboardSelectedWordIndex ?? 0, 0),
|
||||
wordNodes.length - 1,
|
||||
);
|
||||
if (delta < 0 && currentIndex <= 0) {
|
||||
return 'start-boundary';
|
||||
}
|
||||
if (delta > 0 && currentIndex >= wordNodes.length - 1) {
|
||||
return 'end-boundary';
|
||||
}
|
||||
|
||||
const nextIndex = currentIndex + delta;
|
||||
ctx.state.keyboardSelectedWordIndex = nextIndex;
|
||||
syncKeyboardTokenSelection();
|
||||
return true;
|
||||
return 'moved';
|
||||
}
|
||||
|
||||
function seekAdjacentSubtitleAndQueueSelection(delta: -1 | 1, popupVisible: boolean): void {
|
||||
pendingSelectionAnchorAfterSubtitleSeek = delta > 0 ? 'start' : 'end';
|
||||
pendingLookupRefreshAfterSubtitleSeek = popupVisible;
|
||||
void options
|
||||
.getPlaybackPaused()
|
||||
.then((paused) => {
|
||||
window.electronAPI.sendMpvCommand(['sub-seek', delta]);
|
||||
if (paused !== false) {
|
||||
window.electronAPI.sendMpvCommand(['set_property', 'pause', 'yes']);
|
||||
}
|
||||
})
|
||||
.catch(() => {
|
||||
window.electronAPI.sendMpvCommand(['sub-seek', delta]);
|
||||
});
|
||||
}
|
||||
|
||||
type ScanModifierState = {
|
||||
@@ -346,10 +396,18 @@ export function createKeyboardHandlers(
|
||||
|
||||
const key = e.code;
|
||||
if (key === 'ArrowLeft') {
|
||||
return moveKeyboardSelection(-1);
|
||||
const result = moveKeyboardSelection(-1);
|
||||
if (result === 'start-boundary') {
|
||||
seekAdjacentSubtitleAndQueueSelection(-1, false);
|
||||
}
|
||||
return result !== 'no-words';
|
||||
}
|
||||
if (key === 'ArrowRight' || key === 'KeyL') {
|
||||
return moveKeyboardSelection(1);
|
||||
const result = moveKeyboardSelection(1);
|
||||
if (result === 'end-boundary') {
|
||||
seekAdjacentSubtitleAndQueueSelection(1, false);
|
||||
}
|
||||
return result !== 'no-words';
|
||||
}
|
||||
return false;
|
||||
}
|
||||
@@ -364,32 +422,21 @@ export function createKeyboardHandlers(
|
||||
|
||||
const key = e.code;
|
||||
const popupVisible = ctx.state.yomitanPopupVisible || isYomitanPopupVisible(document);
|
||||
if (key === 'ArrowUp' || key === 'KeyJ') {
|
||||
triggerLookupForSelectedWord();
|
||||
return true;
|
||||
}
|
||||
|
||||
if (key === 'ArrowDown') {
|
||||
if (popupVisible) {
|
||||
dispatchYomitanPopupVisibility(false);
|
||||
queueMicrotask(() => {
|
||||
restoreOverlayKeyboardFocus();
|
||||
});
|
||||
}
|
||||
return true;
|
||||
}
|
||||
|
||||
if (key === 'ArrowLeft' || key === 'KeyH') {
|
||||
moveKeyboardSelection(-1);
|
||||
if (popupVisible) {
|
||||
const result = moveKeyboardSelection(-1);
|
||||
if (result === 'start-boundary') {
|
||||
seekAdjacentSubtitleAndQueueSelection(-1, popupVisible);
|
||||
} else if (popupVisible && result === 'moved') {
|
||||
triggerLookupForSelectedWord();
|
||||
}
|
||||
return true;
|
||||
}
|
||||
|
||||
if (key === 'ArrowRight' || key === 'KeyL') {
|
||||
moveKeyboardSelection(1);
|
||||
if (popupVisible) {
|
||||
const result = moveKeyboardSelection(1);
|
||||
if (result === 'end-boundary') {
|
||||
seekAdjacentSubtitleAndQueueSelection(1, popupVisible);
|
||||
} else if (popupVisible && result === 'moved') {
|
||||
triggerLookupForSelectedWord();
|
||||
}
|
||||
return true;
|
||||
|
||||
Reference in New Issue
Block a user