mirror of
https://github.com/ksyasuda/SubMiner.git
synced 2026-05-26 12:55:16 -07:00
fix: disable macOS mpv menu shortcuts, buffer latest subtitle IPC state
- Pass --macos-menu-shortcuts=no on Darwin so SubMiner bindings reach mpv - Replace queued IPC listener with latest-value variant for subtitle channels - Skip JSONC line/block comments in duplicate-key offset helpers - Preserve configured Anki note model name in selectPreferredNoteFieldModelName - Guard known-words deck rename against collision; add chooseKnownWordsDeckRenameValue - Apply asCssColor on hover token CSS compat reads
This commit is contained in:
@@ -31,12 +31,14 @@ const SUBTITLE_HOVER_TOKEN_BACKGROUND_CSS_PROPERTY = '--subtitle-hover-token-bac
|
||||
function applySubtitleHoverTokenCssCompatibility(
|
||||
subtitleStyle: ResolvedConfig['subtitleStyle'],
|
||||
): void {
|
||||
const hoverTokenColor = subtitleStyle.css[SUBTITLE_HOVER_TOKEN_COLOR_CSS_PROPERTY];
|
||||
const hoverTokenColor = asCssColor(subtitleStyle.css[SUBTITLE_HOVER_TOKEN_COLOR_CSS_PROPERTY]);
|
||||
if (hoverTokenColor !== undefined) {
|
||||
subtitleStyle.hoverTokenColor = hoverTokenColor;
|
||||
}
|
||||
|
||||
const hoverTokenBackgroundColor = subtitleStyle.css[SUBTITLE_HOVER_TOKEN_BACKGROUND_CSS_PROPERTY];
|
||||
const hoverTokenBackgroundColor = asCssColor(
|
||||
subtitleStyle.css[SUBTITLE_HOVER_TOKEN_BACKGROUND_CSS_PROPERTY],
|
||||
);
|
||||
if (hoverTokenBackgroundColor !== undefined) {
|
||||
subtitleStyle.hoverTokenBackgroundColor = hoverTokenBackgroundColor;
|
||||
}
|
||||
|
||||
@@ -74,6 +74,22 @@ test('subtitleStyle css declarations accept string declaration maps and warn on
|
||||
assert.ok(invalid.warnings.some((warning) => warning.path === 'subtitleStyle.secondary.css'));
|
||||
});
|
||||
|
||||
test('subtitleStyle hover css compatibility ignores invalid color declarations', () => {
|
||||
const { context } = createResolveContext({
|
||||
subtitleStyle: {
|
||||
css: {
|
||||
'--subtitle-hover-token-color': 'purple',
|
||||
'--subtitle-hover-token-background-color': '#363a4fd6',
|
||||
},
|
||||
},
|
||||
});
|
||||
|
||||
applySubtitleDomainConfig(context);
|
||||
|
||||
assert.equal(context.resolved.subtitleStyle.hoverTokenColor, '#f4dbd6');
|
||||
assert.equal(context.resolved.subtitleStyle.hoverTokenBackgroundColor, '#363a4fd6');
|
||||
});
|
||||
|
||||
test('subtitleStyle autoPauseVideoOnHover falls back on invalid value', () => {
|
||||
const { context, warnings } = createResolveContext({
|
||||
subtitleStyle: {
|
||||
|
||||
@@ -65,6 +65,37 @@ test('applyConfigSettingsPatchToContent updates effective duplicate object path'
|
||||
assert.equal(parsed.ankiConnect.nPlusOne.minSentenceWords, 3);
|
||||
});
|
||||
|
||||
test('applyConfigSettingsPatchToContent removes duplicate properties across JSONC trivia', () => {
|
||||
const input = `{
|
||||
"ankiConnect": {
|
||||
"nPlusOne": {
|
||||
"enabled": false
|
||||
} /* old value */ ,
|
||||
// effective value follows
|
||||
"nPlusOne": {
|
||||
"minSentenceWords": 3
|
||||
}
|
||||
}
|
||||
}`;
|
||||
|
||||
const result = applyConfigSettingsPatchToContent({
|
||||
content: input,
|
||||
operations: [
|
||||
{
|
||||
op: 'set',
|
||||
path: 'ankiConnect.nPlusOne.enabled',
|
||||
value: true,
|
||||
},
|
||||
],
|
||||
previousWarnings: [],
|
||||
});
|
||||
|
||||
assert.equal(result.ok, true);
|
||||
const parsed = parse(result.content);
|
||||
assert.equal(parsed.ankiConnect.nPlusOne.enabled, true);
|
||||
assert.equal(parsed.ankiConnect.nPlusOne.minSentenceWords, 3);
|
||||
});
|
||||
|
||||
test('applyConfigSettingsPatchToContent reset removes explicit path', () => {
|
||||
const input = `{
|
||||
"subtitleStyle": {
|
||||
|
||||
@@ -125,16 +125,55 @@ function isWhitespace(value: string | undefined): boolean {
|
||||
|
||||
function nextNonWhitespaceOffset(content: string, offset: number): number {
|
||||
let index = offset;
|
||||
while (index < content.length && isWhitespace(content[index])) {
|
||||
index += 1;
|
||||
while (index < content.length) {
|
||||
if (isWhitespace(content[index])) {
|
||||
index += 1;
|
||||
continue;
|
||||
}
|
||||
if (content[index] === '/' && content[index + 1] === '/') {
|
||||
index += 2;
|
||||
while (index < content.length && content[index] !== '\n') index += 1;
|
||||
continue;
|
||||
}
|
||||
if (content[index] === '/' && content[index + 1] === '*') {
|
||||
index += 2;
|
||||
while (
|
||||
index + 1 < content.length &&
|
||||
!(content[index] === '*' && content[index + 1] === '/')
|
||||
) {
|
||||
index += 1;
|
||||
}
|
||||
index = Math.min(content.length, index + 2);
|
||||
continue;
|
||||
}
|
||||
break;
|
||||
}
|
||||
return index;
|
||||
}
|
||||
|
||||
function previousNonWhitespaceOffset(content: string, offset: number): number {
|
||||
let index = offset;
|
||||
while (index >= 0 && isWhitespace(content[index])) {
|
||||
index -= 1;
|
||||
while (index >= 0) {
|
||||
if (isWhitespace(content[index])) {
|
||||
index -= 1;
|
||||
continue;
|
||||
}
|
||||
const lineStart = content.lastIndexOf('\n', index) + 1;
|
||||
const linePrefix = content.slice(lineStart, index + 1);
|
||||
const lineCommentStart = linePrefix.lastIndexOf('//');
|
||||
if (lineCommentStart >= 0 && /^[ \t]*$/.test(linePrefix.slice(0, lineCommentStart))) {
|
||||
index = lineStart - 1;
|
||||
continue;
|
||||
}
|
||||
if (content[index] === '/' && content[index - 1] === '*') {
|
||||
index -= 2;
|
||||
while (index > 0 && !(content[index - 1] === '/' && content[index] === '*')) {
|
||||
index -= 1;
|
||||
}
|
||||
index -= 2;
|
||||
continue;
|
||||
}
|
||||
break;
|
||||
}
|
||||
return index;
|
||||
}
|
||||
|
||||
@@ -23,12 +23,12 @@ test('settings preload exposes Anki lookup helpers', () => {
|
||||
}
|
||||
});
|
||||
|
||||
test('overlay preload queues subtitle updates until renderer listener registration', () => {
|
||||
test('overlay preload buffers only latest subtitle state until renderer listener registration', () => {
|
||||
const source = fs.readFileSync(path.join(process.cwd(), 'src', 'preload.ts'), 'utf8');
|
||||
|
||||
assert.match(
|
||||
source,
|
||||
/const onSubtitleSetEvent =\s*createQueuedIpcListenerWithPayload<SubtitleData>\(\s*IPC_CHANNELS\.event\.subtitleSet,/,
|
||||
/const onSubtitleSetEvent =\s*createLatestValueIpcListenerWithPayload<SubtitleData>\(\s*IPC_CHANNELS\.event\.subtitleSet,/,
|
||||
);
|
||||
assert.match(source, /onSubtitle:\s*\(callback:[\s\S]+?onSubtitleSetEvent\(callback\);/);
|
||||
});
|
||||
|
||||
+36
-5
@@ -122,6 +122,37 @@ function createQueuedIpcListenerWithPayload<T>(
|
||||
};
|
||||
}
|
||||
|
||||
function createLatestValueIpcListenerWithPayload<T>(
|
||||
channel: string,
|
||||
normalize: (payload: unknown) => T,
|
||||
): (listener: PayloadedListener<T>) => void {
|
||||
let pending: T | undefined;
|
||||
const listeners: PayloadedListener<T>[] = [];
|
||||
|
||||
const dispatch = (payload: T): void => {
|
||||
if (listeners.length === 0) {
|
||||
pending = payload;
|
||||
return;
|
||||
}
|
||||
for (const listener of listeners) {
|
||||
listener(payload);
|
||||
}
|
||||
};
|
||||
|
||||
ipcRenderer.on(channel, (_event: IpcRendererEvent, payloadArg: unknown) => {
|
||||
dispatch(normalize(payloadArg));
|
||||
});
|
||||
|
||||
return (listener: PayloadedListener<T>): void => {
|
||||
listeners.push(listener);
|
||||
if (pending !== undefined) {
|
||||
const payload = pending;
|
||||
pending = undefined;
|
||||
listener(payload);
|
||||
}
|
||||
};
|
||||
}
|
||||
|
||||
const onOpenRuntimeOptionsEvent = createQueuedIpcListener(IPC_CHANNELS.event.runtimeOptionsOpen);
|
||||
const onOpenSessionHelpEvent = createQueuedIpcListener(IPC_CHANNELS.event.sessionHelpOpen);
|
||||
const onOpenCharacterDictionaryEvent = createQueuedIpcListener(
|
||||
@@ -161,23 +192,23 @@ const onKikuFieldGroupingRequestEvent =
|
||||
IPC_CHANNELS.event.kikuFieldGroupingRequest,
|
||||
(payload) => payload as KikuFieldGroupingRequestData,
|
||||
);
|
||||
const onSubtitleSetEvent = createQueuedIpcListenerWithPayload<SubtitleData>(
|
||||
const onSubtitleSetEvent = createLatestValueIpcListenerWithPayload<SubtitleData>(
|
||||
IPC_CHANNELS.event.subtitleSet,
|
||||
(payload) => payload as SubtitleData,
|
||||
);
|
||||
const onSubtitleVisibilityEvent = createQueuedIpcListenerWithPayload<boolean>(
|
||||
const onSubtitleVisibilityEvent = createLatestValueIpcListenerWithPayload<boolean>(
|
||||
IPC_CHANNELS.event.subtitleVisibility,
|
||||
(payload) => payload === true,
|
||||
);
|
||||
const onSubtitlePositionSetEvent = createQueuedIpcListenerWithPayload<SubtitlePosition | null>(
|
||||
const onSubtitlePositionSetEvent = createLatestValueIpcListenerWithPayload<SubtitlePosition | null>(
|
||||
IPC_CHANNELS.event.subtitlePositionSet,
|
||||
(payload) => payload as SubtitlePosition | null,
|
||||
);
|
||||
const onSecondarySubtitleSetEvent = createQueuedIpcListenerWithPayload<string>(
|
||||
const onSecondarySubtitleSetEvent = createLatestValueIpcListenerWithPayload<string>(
|
||||
IPC_CHANNELS.event.secondarySubtitleSet,
|
||||
(payload) => (typeof payload === 'string' ? payload : ''),
|
||||
);
|
||||
const onSecondarySubtitleModeEvent = createQueuedIpcListenerWithPayload<SecondarySubMode>(
|
||||
const onSecondarySubtitleModeEvent = createLatestValueIpcListenerWithPayload<SecondarySubMode>(
|
||||
IPC_CHANNELS.event.secondarySubtitleMode,
|
||||
(payload) => payload as SecondarySubMode,
|
||||
);
|
||||
|
||||
@@ -2,17 +2,17 @@ import test from 'node:test';
|
||||
import assert from 'node:assert/strict';
|
||||
import * as ankiControls from './settings-anki-controls';
|
||||
|
||||
test('note field model preference ignores configured sentence-card model before Kiku fallback', () => {
|
||||
test('note field model preference keeps configured sentence-card model before Kiku fallback', () => {
|
||||
assert.equal(
|
||||
ankiControls.selectPreferredNoteFieldModelName(['Lapis Morph', 'Kiku'], 'Lapis Morph'),
|
||||
'Kiku',
|
||||
'Lapis Morph',
|
||||
);
|
||||
});
|
||||
|
||||
test('note field model preference ignores configured sentence-card model case-insensitively', () => {
|
||||
test('note field model preference keeps configured sentence-card model case-insensitively', () => {
|
||||
assert.equal(
|
||||
ankiControls.selectPreferredNoteFieldModelName(['Lapis Morph', 'Kiku'], 'lapis morph'),
|
||||
'Kiku',
|
||||
'Lapis Morph',
|
||||
);
|
||||
});
|
||||
|
||||
@@ -29,15 +29,23 @@ test('note field model preference does not treat partial Kiku matches as Kiku',
|
||||
});
|
||||
|
||||
test('note field model preference does not treat partial Lapis matches as Lapis', () => {
|
||||
assert.equal(
|
||||
ankiControls.selectPreferredNoteFieldModelName(['Mining', 'Lapis Morph'], ''),
|
||||
'',
|
||||
);
|
||||
assert.equal(ankiControls.selectPreferredNoteFieldModelName(['Mining', 'Lapis Morph'], ''), '');
|
||||
});
|
||||
|
||||
test('note field model preference stays blank when no Kiku or Lapis note type exists', () => {
|
||||
test('note field model preference stays blank when no current Kiku or Lapis note type exists', () => {
|
||||
assert.equal(
|
||||
ankiControls.selectPreferredNoteFieldModelName(['Basic', 'Mining'], 'Lapis Morph'),
|
||||
'',
|
||||
);
|
||||
});
|
||||
|
||||
test('known word deck rename selection keeps current deck on collision', () => {
|
||||
assert.equal(
|
||||
ankiControls.chooseKnownWordsDeckRenameValue(
|
||||
{ Mining: ['Word'], Core: ['Expression'] },
|
||||
'Core',
|
||||
'Mining',
|
||||
),
|
||||
'Core',
|
||||
);
|
||||
});
|
||||
|
||||
@@ -55,7 +55,13 @@ export function selectPreferredNoteFieldModelName(
|
||||
modelNames: readonly string[],
|
||||
currentModelName = '',
|
||||
): string {
|
||||
void currentModelName;
|
||||
const normalizedCurrent = currentModelName.trim().toLowerCase();
|
||||
if (normalizedCurrent) {
|
||||
const current = modelNames.find((name) => name.trim().toLowerCase() === normalizedCurrent);
|
||||
if (current) {
|
||||
return current;
|
||||
}
|
||||
}
|
||||
|
||||
const exactKiku = modelNames.find((name) => name.trim().toLowerCase() === 'kiku');
|
||||
if (exactKiku) {
|
||||
@@ -70,6 +76,20 @@ export function selectPreferredNoteFieldModelName(
|
||||
return '';
|
||||
}
|
||||
|
||||
export function chooseKnownWordsDeckRenameValue(
|
||||
decks: Record<string, string[]>,
|
||||
currentDeckName: string,
|
||||
nextDeckName: string,
|
||||
): string {
|
||||
if (
|
||||
nextDeckName !== currentDeckName &&
|
||||
Object.prototype.hasOwnProperty.call(decks, nextDeckName)
|
||||
) {
|
||||
return currentDeckName;
|
||||
}
|
||||
return nextDeckName;
|
||||
}
|
||||
|
||||
function normalizeStringArray(value: unknown): string[] {
|
||||
return Array.isArray(value)
|
||||
? value.filter((entry): entry is string => typeof entry === 'string' && entry.length > 0)
|
||||
@@ -491,16 +511,23 @@ export function renderKnownWordsDecksInput(
|
||||
void loadAnkiDeckFieldNames(deckName, draftUrl);
|
||||
}
|
||||
const row = createElement('div', 'deck-field-row');
|
||||
const usedDeckNames = new Set(Object.keys(currentDecks));
|
||||
const deckSelect = createElement('select', 'config-input') as HTMLSelectElement;
|
||||
for (const candidateDeck of uniqueSorted([...deckNames, deckName])) {
|
||||
if (candidateDeck !== deckName && usedDeckNames.has(candidateDeck)) continue;
|
||||
addOption(deckSelect, candidateDeck);
|
||||
}
|
||||
deckSelect.value = deckName;
|
||||
deckSelect.addEventListener('change', () => {
|
||||
const nextDecks = normalizeKnownWordsDecks(context.valueForField(field));
|
||||
const nextDeckName = chooseKnownWordsDeckRenameValue(nextDecks, deckName, deckSelect.value);
|
||||
if (nextDeckName !== deckSelect.value) {
|
||||
deckSelect.value = nextDeckName;
|
||||
return;
|
||||
}
|
||||
const fields = nextDecks[deckName] ?? [];
|
||||
delete nextDecks[deckName];
|
||||
nextDecks[deckSelect.value] = fields;
|
||||
nextDecks[nextDeckName] = fields;
|
||||
setKnownWordsDecks(context, field.configPath, nextDecks);
|
||||
requestRender();
|
||||
});
|
||||
|
||||
Reference in New Issue
Block a user