mirror of
https://github.com/ksyasuda/SubMiner.git
synced 2026-05-27 00:55:16 -07:00
feat(config): unify mpv plugin options under main config and add CSS/Ani
- Replace subminer.conf plugin config with mpv.* fields in config.jsonc - Add socketPath, backend, autoStartSubMiner, pauseUntilOverlayReady, aniskipEnabled/buttonKey, subminerBinaryPath to mpv config - Add subtitleSidebar.css field; migrate legacy sidebar appearance fields - Add paintOrder and WebkitTextStroke to subtitle style options - Update default subtitle/sidebar fontFamily to CJK-first stack - Fix overlay visible state surviving mpv y-r restart - Fix live config saves applying subtitle CSS immediately to open overlays - Migrate legacy primary/secondary subtitle appearance into subtitleStyle.css on load - Switch AniSkip button key setting to click-to-learn key capture
This commit is contained in:
@@ -58,6 +58,23 @@ test('keyboardEventToConfigKey formats bare key-code fields without modifiers',
|
||||
);
|
||||
});
|
||||
|
||||
test('keyboardEventToConfigKey formats mpv key bindings from learned input', () => {
|
||||
assert.equal(
|
||||
keyboardEventToConfigKey(
|
||||
{ code: 'Tab', key: 'Tab', ctrlKey: false, altKey: false, shiftKey: false, metaKey: false },
|
||||
'mpv-key',
|
||||
),
|
||||
'TAB',
|
||||
);
|
||||
assert.equal(
|
||||
keyboardEventToConfigKey(
|
||||
{ code: 'KeyK', key: 'K', ctrlKey: true, altKey: false, shiftKey: true, metaKey: false },
|
||||
'mpv-key',
|
||||
),
|
||||
'Ctrl+Shift+K',
|
||||
);
|
||||
});
|
||||
|
||||
test('MPV keybinding rows save default key moves as a disable plus replacement', () => {
|
||||
const defaults: Keybinding[] = [{ key: 'Space', command: ['cycle', 'pause'] }];
|
||||
const rows = createMpvKeybindingRows(defaults, []);
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
import type { Keybinding } from '../types/runtime';
|
||||
|
||||
export type KeyInputMode = 'accelerator' | 'dom-code' | 'code';
|
||||
export type KeyInputMode = 'accelerator' | 'dom-code' | 'code' | 'mpv-key';
|
||||
|
||||
export interface KeyboardInputLike {
|
||||
code: string;
|
||||
@@ -54,6 +54,31 @@ const ELECTRON_KEY_BY_CODE: Record<string, string> = {
|
||||
Tab: 'Tab',
|
||||
};
|
||||
|
||||
const MPV_KEY_BY_CODE: Record<string, string> = {
|
||||
Backspace: 'BS',
|
||||
Backquote: '`',
|
||||
Backslash: '\\',
|
||||
BracketLeft: '[',
|
||||
BracketRight: ']',
|
||||
Comma: ',',
|
||||
Delete: 'DEL',
|
||||
End: 'END',
|
||||
Enter: 'ENTER',
|
||||
Equal: '=',
|
||||
Escape: 'ESC',
|
||||
Home: 'HOME',
|
||||
Insert: 'INS',
|
||||
Minus: '-',
|
||||
PageDown: 'PGDWN',
|
||||
PageUp: 'PGUP',
|
||||
Period: '.',
|
||||
Quote: "'",
|
||||
Semicolon: ';',
|
||||
Slash: '/',
|
||||
Space: 'SPACE',
|
||||
Tab: 'TAB',
|
||||
};
|
||||
|
||||
function commandEquals(a: Keybinding['command'], b: Keybinding['command']): boolean {
|
||||
return JSON.stringify(a) === JSON.stringify(b);
|
||||
}
|
||||
@@ -79,6 +104,17 @@ function electronKeyToken(input: KeyboardInputLike): string | null {
|
||||
return ELECTRON_KEY_BY_CODE[input.code] ?? null;
|
||||
}
|
||||
|
||||
function mpvKeyToken(input: KeyboardInputLike): string | null {
|
||||
if (/^Key[A-Z]$/.test(input.code)) {
|
||||
return input.key.length === 1 ? input.key : input.code.slice(3).toLowerCase();
|
||||
}
|
||||
if (/^Digit[0-9]$/.test(input.code)) return input.code.slice(5);
|
||||
if (/^Numpad[0-9]$/.test(input.code)) return `KP${input.code.slice(6)}`;
|
||||
if (/^F\d{1,2}$/.test(input.code)) return input.code;
|
||||
if (input.code.startsWith('Arrow')) return input.code.replace('Arrow', '').toUpperCase();
|
||||
return MPV_KEY_BY_CODE[input.code] ?? null;
|
||||
}
|
||||
|
||||
export function keyboardEventToConfigKey(
|
||||
input: KeyboardInputLike,
|
||||
mode: KeyInputMode,
|
||||
@@ -92,6 +128,15 @@ export function keyboardEventToConfigKey(
|
||||
}
|
||||
|
||||
const parts: string[] = [];
|
||||
if (mode === 'mpv-key') {
|
||||
if (input.ctrlKey) parts.push('Ctrl');
|
||||
if (input.altKey) parts.push('Alt');
|
||||
if (input.shiftKey) parts.push('Shift');
|
||||
if (input.metaKey) parts.push('Meta');
|
||||
const key = mpvKeyToken(input);
|
||||
return key ? [...parts, key].join('+') : null;
|
||||
}
|
||||
|
||||
if (mode === 'accelerator') {
|
||||
if (input.ctrlKey || input.metaKey) parts.push('CommandOrControl');
|
||||
if (input.altKey) parts.push('Alt');
|
||||
|
||||
@@ -2,24 +2,42 @@ import test from 'node:test';
|
||||
import assert from 'node:assert/strict';
|
||||
import * as ankiControls from './settings-anki-controls';
|
||||
|
||||
test('note field model preference chooses Kiku before configured Lapis default', () => {
|
||||
test('note field model preference prefers exact Kiku over configured model', () => {
|
||||
assert.equal(
|
||||
ankiControls.selectPreferredNoteFieldModelName(['Lapis Morph', 'Kiku'], 'Lapis Morph'),
|
||||
'Kiku',
|
||||
);
|
||||
});
|
||||
|
||||
test('note field model preference falls back to Lapis when Kiku is unavailable', () => {
|
||||
test('note field model preference ignores configured model case-insensitively', () => {
|
||||
assert.equal(
|
||||
ankiControls.selectPreferredNoteFieldModelName(['Mining', 'Lapis Morph'], 'Lapis Morph'),
|
||||
'Lapis Morph',
|
||||
ankiControls.selectPreferredNoteFieldModelName(['Lapis Morph', 'Kiku'], 'lapis morph'),
|
||||
'Kiku',
|
||||
);
|
||||
});
|
||||
|
||||
test('note field model preference prefers exact Lapis when Kiku is unavailable', () => {
|
||||
assert.equal(
|
||||
ankiControls.selectPreferredNoteFieldModelName(['Mining', 'Lapis'], ''),
|
||||
'Lapis',
|
||||
);
|
||||
});
|
||||
|
||||
test('note field model preference prefers exact Kiku over exact Lapis', () => {
|
||||
assert.equal(ankiControls.selectPreferredNoteFieldModelName(['Lapis', 'Kiku'], ''), 'Kiku');
|
||||
});
|
||||
|
||||
test('note field model preference does not treat partial Kiku matches as Kiku', () => {
|
||||
assert.equal(
|
||||
ankiControls.selectPreferredNoteFieldModelName(['Kikuchi', 'Lapis Morph'], 'Lapis Morph'),
|
||||
'Lapis Morph',
|
||||
'',
|
||||
);
|
||||
});
|
||||
|
||||
test('note field model preference does not treat partial Lapis matches as Lapis', () => {
|
||||
assert.equal(
|
||||
ankiControls.selectPreferredNoteFieldModelName(['Mining', 'Lapis Morph'], 'Lapis Morph'),
|
||||
'',
|
||||
);
|
||||
});
|
||||
|
||||
|
||||
@@ -62,9 +62,9 @@ export function selectPreferredNoteFieldModelName(
|
||||
return exactKiku;
|
||||
}
|
||||
|
||||
const lapis = modelNames.find((name) => name.toLowerCase().includes('lapis'));
|
||||
if (lapis) {
|
||||
return lapis;
|
||||
const exactLapis = modelNames.find((name) => name.toLowerCase() === 'lapis');
|
||||
if (exactLapis) {
|
||||
return exactLapis;
|
||||
}
|
||||
|
||||
return '';
|
||||
@@ -111,7 +111,20 @@ function syncAnkiConnectUrl(draftUrl: string | undefined): void {
|
||||
if (state.ankiConnectUrl === nextUrl) {
|
||||
return;
|
||||
}
|
||||
const hasAnkiMetadata =
|
||||
state.deckNames !== null ||
|
||||
state.deckNamesLoading ||
|
||||
state.deckFieldNames.size > 0 ||
|
||||
state.deckFieldNamesLoading.size > 0 ||
|
||||
state.modelNames !== null ||
|
||||
state.modelNamesLoading ||
|
||||
state.modelFieldNames.size > 0 ||
|
||||
state.modelFieldNamesLoading.size > 0;
|
||||
state.ankiConnectUrl = nextUrl;
|
||||
if (hasAnkiMetadata) {
|
||||
state.noteFieldModelName = '';
|
||||
state.noteFieldModelNameManuallySelected = false;
|
||||
}
|
||||
state.deckNames = null;
|
||||
state.deckNamesLoading = false;
|
||||
state.deckNamesError = null;
|
||||
@@ -129,9 +142,11 @@ function syncAnkiConnectUrl(draftUrl: string | undefined): void {
|
||||
async function loadAnkiDeckNames(draftUrl?: string): Promise<void> {
|
||||
syncAnkiConnectUrl(draftUrl);
|
||||
if (state.deckNames || state.deckNamesLoading) return;
|
||||
const requestUrl = state.ankiConnectUrl;
|
||||
state.deckNamesLoading = true;
|
||||
try {
|
||||
const result = await window.configSettingsAPI.getAnkiDeckNames(draftUrl);
|
||||
if (state.ankiConnectUrl !== requestUrl) return;
|
||||
if (result.ok) {
|
||||
state.deckNames = uniqueSorted(result.values);
|
||||
state.deckNamesError = null;
|
||||
@@ -140,11 +155,14 @@ async function loadAnkiDeckNames(draftUrl?: string): Promise<void> {
|
||||
state.deckNamesError = result.error ?? 'Failed to load Anki decks.';
|
||||
}
|
||||
} catch (error) {
|
||||
if (state.ankiConnectUrl !== requestUrl) return;
|
||||
state.deckNames = [];
|
||||
state.deckNamesError = error instanceof Error ? error.message : 'Failed to load Anki decks.';
|
||||
} finally {
|
||||
state.deckNamesLoading = false;
|
||||
requestRender();
|
||||
if (state.ankiConnectUrl === requestUrl) {
|
||||
state.deckNamesLoading = false;
|
||||
requestRender();
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -157,9 +175,11 @@ async function loadAnkiDeckFieldNames(deckName: string, draftUrl?: string): Prom
|
||||
) {
|
||||
return;
|
||||
}
|
||||
const requestUrl = state.ankiConnectUrl;
|
||||
state.deckFieldNamesLoading.add(deckName);
|
||||
try {
|
||||
const result = await window.configSettingsAPI.getAnkiDeckFieldNames(deckName, draftUrl);
|
||||
if (state.ankiConnectUrl !== requestUrl) return;
|
||||
if (result.ok) {
|
||||
state.deckFieldNames.set(deckName, uniqueSorted(result.values));
|
||||
state.deckFieldNamesErrors.delete(deckName);
|
||||
@@ -171,23 +191,28 @@ async function loadAnkiDeckFieldNames(deckName: string, draftUrl?: string): Prom
|
||||
);
|
||||
}
|
||||
} catch (error) {
|
||||
if (state.ankiConnectUrl !== requestUrl) return;
|
||||
state.deckFieldNames.set(deckName, []);
|
||||
state.deckFieldNamesErrors.set(
|
||||
deckName,
|
||||
error instanceof Error ? error.message : `Failed to load fields for ${deckName}.`,
|
||||
);
|
||||
} finally {
|
||||
state.deckFieldNamesLoading.delete(deckName);
|
||||
requestRender();
|
||||
if (state.ankiConnectUrl === requestUrl) {
|
||||
state.deckFieldNamesLoading.delete(deckName);
|
||||
requestRender();
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
async function loadAnkiModelNames(draftUrl?: string): Promise<void> {
|
||||
syncAnkiConnectUrl(draftUrl);
|
||||
if (state.modelNames || state.modelNamesLoading) return;
|
||||
const requestUrl = state.ankiConnectUrl;
|
||||
state.modelNamesLoading = true;
|
||||
try {
|
||||
const result = await window.configSettingsAPI.getAnkiModelNames(draftUrl);
|
||||
if (state.ankiConnectUrl !== requestUrl) return;
|
||||
if (result.ok) {
|
||||
state.modelNames = uniqueSorted(result.values);
|
||||
state.modelNamesError = null;
|
||||
@@ -202,12 +227,15 @@ async function loadAnkiModelNames(draftUrl?: string): Promise<void> {
|
||||
state.modelNamesError = result.error ?? 'Failed to load Anki note types.';
|
||||
}
|
||||
} catch (error) {
|
||||
if (state.ankiConnectUrl !== requestUrl) return;
|
||||
state.modelNames = [];
|
||||
state.modelNamesError =
|
||||
error instanceof Error ? error.message : 'Failed to load Anki note types.';
|
||||
} finally {
|
||||
state.modelNamesLoading = false;
|
||||
requestRender();
|
||||
if (state.ankiConnectUrl === requestUrl) {
|
||||
state.modelNamesLoading = false;
|
||||
requestRender();
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -220,9 +248,11 @@ async function loadAnkiModelFieldNames(modelName: string, draftUrl?: string): Pr
|
||||
) {
|
||||
return;
|
||||
}
|
||||
const requestUrl = state.ankiConnectUrl;
|
||||
state.modelFieldNamesLoading.add(modelName);
|
||||
try {
|
||||
const result = await window.configSettingsAPI.getAnkiModelFieldNames(modelName, draftUrl);
|
||||
if (state.ankiConnectUrl !== requestUrl) return;
|
||||
if (result.ok) {
|
||||
state.modelFieldNames.set(modelName, uniqueSorted(result.values));
|
||||
state.modelFieldNamesErrors.delete(modelName);
|
||||
@@ -234,14 +264,17 @@ async function loadAnkiModelFieldNames(modelName: string, draftUrl?: string): Pr
|
||||
);
|
||||
}
|
||||
} catch (error) {
|
||||
if (state.ankiConnectUrl !== requestUrl) return;
|
||||
state.modelFieldNames.set(modelName, []);
|
||||
state.modelFieldNamesErrors.set(
|
||||
modelName,
|
||||
error instanceof Error ? error.message : `Failed to load fields for ${modelName}.`,
|
||||
);
|
||||
} finally {
|
||||
state.modelFieldNamesLoading.delete(modelName);
|
||||
requestRender();
|
||||
if (state.ankiConnectUrl === requestUrl) {
|
||||
state.modelFieldNamesLoading.delete(modelName);
|
||||
requestRender();
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -153,6 +153,10 @@ export function renderControl(
|
||||
return renderKeyboardInput(context, field, 'code');
|
||||
}
|
||||
|
||||
if (field.control === 'mpv-key') {
|
||||
return renderKeyboardInput(context, field, 'mpv-key');
|
||||
}
|
||||
|
||||
if (field.control === 'known-words-decks') {
|
||||
return renderKnownWordsDecksInput(context, field);
|
||||
}
|
||||
|
||||
@@ -66,6 +66,27 @@ test('filterSettingsFields searches label, section, and config path', () => {
|
||||
assert.deepEqual(filterSettingsFields(fields, { category: 'appearance', query: '' }), []);
|
||||
});
|
||||
|
||||
test('filterSettingsFields normalizes punctuation in query terms', () => {
|
||||
const nPlusOneFields: ConfigSettingsField[] = [
|
||||
{
|
||||
id: 'ankiConnect.nPlusOne.enabled',
|
||||
label: 'Enable N+1',
|
||||
description: 'Highlight N+1 cards.',
|
||||
configPath: 'ankiConnect.nPlusOne.enabled',
|
||||
category: 'mining-anki',
|
||||
section: 'N+1',
|
||||
control: 'boolean',
|
||||
defaultValue: true,
|
||||
restartBehavior: 'hot-reload',
|
||||
},
|
||||
];
|
||||
|
||||
assert.deepEqual(
|
||||
filterSettingsFields(nPlusOneFields, { query: 'n+1' }).map((field) => field.configPath),
|
||||
['ankiConnect.nPlusOne.enabled'],
|
||||
);
|
||||
});
|
||||
|
||||
test('settings draft tracks dirty set and emits save operations', () => {
|
||||
const draft = createSettingsDraft({
|
||||
'subtitleStyle.autoPauseVideoOnHover': true,
|
||||
|
||||
@@ -38,7 +38,7 @@ export function filterSettingsFields(
|
||||
filter: SettingsFilter,
|
||||
): ConfigSettingsField[] {
|
||||
const query = normalizeQuery(filter.query);
|
||||
const terms = query.length > 0 ? query.split(/\s+/) : [];
|
||||
const terms = query.length > 0 ? searchableText([query]).split(/\s+/).filter(Boolean) : [];
|
||||
return fields.filter((field) => {
|
||||
if (field.legacyHidden || field.settingsHidden) {
|
||||
return false;
|
||||
@@ -46,7 +46,7 @@ export function filterSettingsFields(
|
||||
if (filter.category && field.category !== filter.category) {
|
||||
return false;
|
||||
}
|
||||
if (!query) {
|
||||
if (!query || terms.length === 0) {
|
||||
return true;
|
||||
}
|
||||
const haystack = searchableText([
|
||||
|
||||
@@ -32,7 +32,6 @@ const CATEGORY_LABELS: Record<ConfigSettingsCategory, string> = {
|
||||
appearance: 'Appearance',
|
||||
behavior: 'Behavior',
|
||||
'mining-anki': 'Mining & Anki',
|
||||
'playback-sources': 'Playback & Sources',
|
||||
input: 'Input',
|
||||
integrations: 'Integrations',
|
||||
'tracking-app': 'Tracking & App',
|
||||
@@ -43,7 +42,6 @@ const CATEGORY_ORDER: ConfigSettingsCategory[] = [
|
||||
'appearance',
|
||||
'behavior',
|
||||
'mining-anki',
|
||||
'playback-sources',
|
||||
'input',
|
||||
'integrations',
|
||||
'tracking-app',
|
||||
|
||||
@@ -7,12 +7,16 @@ import {
|
||||
serializeSubtitleCssDeclarations,
|
||||
} from './subtitle-style-css';
|
||||
|
||||
test('serializeSubtitleCssDeclarations builds primary CSS from config minus default colors', () => {
|
||||
test('serializeSubtitleCssDeclarations builds primary CSS from all managed appearance config', () => {
|
||||
const css = serializeSubtitleCssDeclarations('primary', {
|
||||
'subtitleStyle.fontFamily': 'M PLUS 1, sans-serif',
|
||||
'subtitleStyle.fontSize': 35,
|
||||
'subtitleStyle.fontColor': '#cad3f5',
|
||||
'subtitleStyle.backgroundColor': 'transparent',
|
||||
'subtitleStyle.hoverTokenColor': '#f4dbd6',
|
||||
'subtitleStyle.hoverTokenBackgroundColor': 'rgba(54, 58, 79, 0.84)',
|
||||
'subtitleStyle.paintOrder': 'stroke fill',
|
||||
'subtitleStyle.WebkitTextStroke': '1.5px #000',
|
||||
'subtitleStyle.textShadow': '0 2px 6px rgba(0,0,0,0.9)',
|
||||
'subtitleStyle.css': {
|
||||
filter: 'drop-shadow(0 0 8px #000)',
|
||||
@@ -22,11 +26,21 @@ test('serializeSubtitleCssDeclarations builds primary CSS from config minus defa
|
||||
|
||||
assert.match(css, /font-family: M PLUS 1, sans-serif;/);
|
||||
assert.match(css, /font-size: 35px;/);
|
||||
assert.match(css, /color: #cad3f5;/);
|
||||
assert.match(css, /background-color: transparent;/);
|
||||
assert.match(css, /--subtitle-hover-token-color: #f4dbd6;/);
|
||||
assert.match(css, /--subtitle-hover-token-background-color: rgba\(54, 58, 79, 0.84\);/);
|
||||
assert.match(css, /paint-order: stroke fill;/);
|
||||
assert.match(css, /-webkit-text-stroke: 1.5px #000;/);
|
||||
assert.doesNotMatch(css, /--subtitle-known-word-color:/);
|
||||
assert.doesNotMatch(css, /--subtitle-n-plus-one-color:/);
|
||||
assert.doesNotMatch(css, /--subtitle-name-match-color:/);
|
||||
assert.doesNotMatch(css, /--subtitle-jlpt-n1-color:/);
|
||||
assert.doesNotMatch(css, /--subtitle-frequency-single-color:/);
|
||||
assert.doesNotMatch(css, /--subtitle-frequency-band-1-color:/);
|
||||
assert.match(css, /text-shadow: 0 2px 6px rgba\(0,0,0,0.9\);/);
|
||||
assert.match(css, /filter: drop-shadow\(0 0 8px #000\);/);
|
||||
assert.match(css, /--subtitle-outline: 1px;/);
|
||||
assert.doesNotMatch(css, /^color:/m);
|
||||
assert.doesNotMatch(css, /^background-color:/m);
|
||||
});
|
||||
|
||||
test('serializeSubtitleCssDeclarations builds secondary CSS from secondary config paths', () => {
|
||||
@@ -42,9 +56,40 @@ test('serializeSubtitleCssDeclarations builds secondary CSS from secondary confi
|
||||
|
||||
assert.match(css, /font-family: Noto Sans, sans-serif;/);
|
||||
assert.match(css, /font-size: 24px;/);
|
||||
assert.match(css, /color: #cad3f5;/);
|
||||
assert.match(css, /background-color: transparent;/);
|
||||
assert.match(css, /text-transform: uppercase;/);
|
||||
assert.doesNotMatch(css, /^color:/m);
|
||||
assert.doesNotMatch(css, /^background-color:/m);
|
||||
});
|
||||
|
||||
test('serializeSubtitleCssDeclarations builds sidebar CSS from subtitle sidebar config paths', () => {
|
||||
const css = serializeSubtitleCssDeclarations('sidebar', {
|
||||
'subtitleSidebar.fontFamily': 'M PLUS 1, sans-serif',
|
||||
'subtitleSidebar.fontSize': 16,
|
||||
'subtitleSidebar.textColor': '#cad3f5',
|
||||
'subtitleSidebar.backgroundColor': 'rgba(73, 77, 100, 0.9)',
|
||||
'subtitleSidebar.opacity': 0.95,
|
||||
'subtitleSidebar.maxWidth': 420,
|
||||
'subtitleSidebar.timestampColor': '#a5adcb',
|
||||
'subtitleSidebar.activeLineColor': '#f5bde6',
|
||||
'subtitleSidebar.activeLineBackgroundColor': 'rgba(138, 173, 244, 0.22)',
|
||||
'subtitleSidebar.hoverLineBackgroundColor': 'rgba(54, 58, 79, 0.84)',
|
||||
'subtitleSidebar.css': {
|
||||
'font-size': '18px',
|
||||
'text-wrap': 'pretty',
|
||||
},
|
||||
});
|
||||
|
||||
assert.match(css, /font-family: M PLUS 1, sans-serif;/);
|
||||
assert.match(css, /font-size: 18px;/);
|
||||
assert.match(css, /color: #cad3f5;/);
|
||||
assert.match(css, /background-color: rgba\(73, 77, 100, 0.9\);/);
|
||||
assert.match(css, /opacity: 0.95;/);
|
||||
assert.match(css, /--subtitle-sidebar-max-width: 420px;/);
|
||||
assert.match(css, /--subtitle-sidebar-timestamp-color: #a5adcb;/);
|
||||
assert.match(css, /--subtitle-sidebar-active-line-color: #f5bde6;/);
|
||||
assert.match(css, /--subtitle-sidebar-active-background-color: rgba\(138, 173, 244, 0.22\);/);
|
||||
assert.match(css, /--subtitle-sidebar-hover-background-color: rgba\(54, 58, 79, 0.84\);/);
|
||||
assert.match(css, /text-wrap: pretty;/);
|
||||
});
|
||||
|
||||
test('parseSubtitleCssDeclarations accepts arbitrary declaration properties', () => {
|
||||
@@ -72,17 +117,77 @@ test('parseSubtitleCssDeclarations rejects selectors and malformed declarations'
|
||||
assert.equal(parseSubtitleCssDeclarations('font-size 40px;').ok, false);
|
||||
});
|
||||
|
||||
test('getSubtitleCssManagedConfigPaths excludes color controls', () => {
|
||||
test('getSubtitleCssManagedConfigPaths includes all CSS-editor-owned appearance controls', () => {
|
||||
assert.ok(!getSubtitleCssManagedConfigPaths('primary').includes(''));
|
||||
assert.ok(!getSubtitleCssManagedConfigPaths('secondary').includes(''));
|
||||
assert.ok(!getSubtitleCssManagedConfigPaths('sidebar').includes(''));
|
||||
assert.ok(getSubtitleCssManagedConfigPaths('primary').includes('subtitleStyle.fontSize'));
|
||||
assert.ok(
|
||||
getSubtitleCssManagedConfigPaths('secondary').includes('subtitleStyle.secondary.fontSize'),
|
||||
);
|
||||
assert.ok(getSubtitleCssManagedConfigPaths('sidebar').includes('subtitleSidebar.fontSize'));
|
||||
assert.ok(getSubtitleCssManagedConfigPaths('sidebar').includes('subtitleSidebar.textColor'));
|
||||
assert.ok(getSubtitleCssManagedConfigPaths('sidebar').includes('subtitleSidebar.maxWidth'));
|
||||
assert.equal(
|
||||
getSubtitleCssManagedConfigPaths('primary').includes('subtitleStyle.fontColor'),
|
||||
false,
|
||||
true,
|
||||
);
|
||||
assert.equal(
|
||||
getSubtitleCssManagedConfigPaths('secondary').includes('subtitleStyle.secondary.fontColor'),
|
||||
true,
|
||||
);
|
||||
assert.equal(
|
||||
getSubtitleCssManagedConfigPaths('primary').includes('subtitleStyle.backgroundColor'),
|
||||
true,
|
||||
);
|
||||
assert.equal(
|
||||
getSubtitleCssManagedConfigPaths('secondary').includes(
|
||||
'subtitleStyle.secondary.backgroundColor',
|
||||
),
|
||||
true,
|
||||
);
|
||||
assert.equal(
|
||||
getSubtitleCssManagedConfigPaths('primary').includes('subtitleStyle.hoverTokenColor'),
|
||||
true,
|
||||
);
|
||||
assert.equal(
|
||||
getSubtitleCssManagedConfigPaths('primary').includes('subtitleStyle.hoverTokenBackgroundColor'),
|
||||
true,
|
||||
);
|
||||
assert.equal(
|
||||
getSubtitleCssManagedConfigPaths('primary').includes('subtitleStyle.paintOrder'),
|
||||
true,
|
||||
);
|
||||
assert.equal(
|
||||
getSubtitleCssManagedConfigPaths('primary').includes('subtitleStyle.WebkitTextStroke'),
|
||||
true,
|
||||
);
|
||||
assert.equal(
|
||||
getSubtitleCssManagedConfigPaths('primary').includes('subtitleStyle.knownWordColor'),
|
||||
false,
|
||||
);
|
||||
assert.equal(
|
||||
getSubtitleCssManagedConfigPaths('primary').includes('subtitleStyle.nPlusOneColor'),
|
||||
false,
|
||||
);
|
||||
assert.equal(
|
||||
getSubtitleCssManagedConfigPaths('primary').includes('subtitleStyle.nameMatchColor'),
|
||||
false,
|
||||
);
|
||||
assert.equal(
|
||||
getSubtitleCssManagedConfigPaths('primary').includes('subtitleStyle.jlptColors.N1'),
|
||||
false,
|
||||
);
|
||||
assert.equal(
|
||||
getSubtitleCssManagedConfigPaths('primary').includes(
|
||||
'subtitleStyle.frequencyDictionary.singleColor',
|
||||
),
|
||||
false,
|
||||
);
|
||||
assert.equal(
|
||||
getSubtitleCssManagedConfigPaths('primary').includes(
|
||||
'subtitleStyle.frequencyDictionary.bandedColors',
|
||||
),
|
||||
false,
|
||||
);
|
||||
});
|
||||
|
||||
@@ -1,11 +1,10 @@
|
||||
import type { ConfigSettingsSnapshotValue } from '../types/settings';
|
||||
|
||||
export type SubtitleCssScope = 'primary' | 'secondary';
|
||||
export type SubtitleCssScope = 'primary' | 'secondary' | 'sidebar';
|
||||
|
||||
type LegacyCssDeclaration = {
|
||||
property: string;
|
||||
primaryPath: string;
|
||||
secondaryPath: string;
|
||||
paths: Partial<Record<SubtitleCssScope, string>>;
|
||||
format?: (value: unknown) => string | undefined;
|
||||
};
|
||||
|
||||
@@ -16,87 +15,187 @@ export type SubtitleCssParseResult =
|
||||
const LEGACY_CSS_DECLARATIONS: LegacyCssDeclaration[] = [
|
||||
{
|
||||
property: 'font-family',
|
||||
primaryPath: 'subtitleStyle.fontFamily',
|
||||
secondaryPath: 'subtitleStyle.secondary.fontFamily',
|
||||
paths: {
|
||||
primary: 'subtitleStyle.fontFamily',
|
||||
secondary: 'subtitleStyle.secondary.fontFamily',
|
||||
sidebar: 'subtitleSidebar.fontFamily',
|
||||
},
|
||||
},
|
||||
{
|
||||
property: 'color',
|
||||
paths: {
|
||||
primary: 'subtitleStyle.fontColor',
|
||||
secondary: 'subtitleStyle.secondary.fontColor',
|
||||
sidebar: 'subtitleSidebar.textColor',
|
||||
},
|
||||
},
|
||||
{
|
||||
property: 'background-color',
|
||||
paths: {
|
||||
primary: 'subtitleStyle.backgroundColor',
|
||||
secondary: 'subtitleStyle.secondary.backgroundColor',
|
||||
sidebar: 'subtitleSidebar.backgroundColor',
|
||||
},
|
||||
},
|
||||
{
|
||||
property: 'font-size',
|
||||
primaryPath: 'subtitleStyle.fontSize',
|
||||
secondaryPath: 'subtitleStyle.secondary.fontSize',
|
||||
paths: {
|
||||
primary: 'subtitleStyle.fontSize',
|
||||
secondary: 'subtitleStyle.secondary.fontSize',
|
||||
sidebar: 'subtitleSidebar.fontSize',
|
||||
},
|
||||
format: formatCssLengthLikeValue,
|
||||
},
|
||||
{
|
||||
property: 'font-weight',
|
||||
primaryPath: 'subtitleStyle.fontWeight',
|
||||
secondaryPath: 'subtitleStyle.secondary.fontWeight',
|
||||
paths: {
|
||||
primary: 'subtitleStyle.fontWeight',
|
||||
secondary: 'subtitleStyle.secondary.fontWeight',
|
||||
},
|
||||
},
|
||||
{
|
||||
property: 'font-style',
|
||||
primaryPath: 'subtitleStyle.fontStyle',
|
||||
secondaryPath: 'subtitleStyle.secondary.fontStyle',
|
||||
paths: {
|
||||
primary: 'subtitleStyle.fontStyle',
|
||||
secondary: 'subtitleStyle.secondary.fontStyle',
|
||||
},
|
||||
},
|
||||
{
|
||||
property: 'line-height',
|
||||
primaryPath: 'subtitleStyle.lineHeight',
|
||||
secondaryPath: 'subtitleStyle.secondary.lineHeight',
|
||||
paths: {
|
||||
primary: 'subtitleStyle.lineHeight',
|
||||
secondary: 'subtitleStyle.secondary.lineHeight',
|
||||
},
|
||||
},
|
||||
{
|
||||
property: 'letter-spacing',
|
||||
primaryPath: 'subtitleStyle.letterSpacing',
|
||||
secondaryPath: 'subtitleStyle.secondary.letterSpacing',
|
||||
paths: {
|
||||
primary: 'subtitleStyle.letterSpacing',
|
||||
secondary: 'subtitleStyle.secondary.letterSpacing',
|
||||
},
|
||||
},
|
||||
{
|
||||
property: 'word-spacing',
|
||||
primaryPath: 'subtitleStyle.wordSpacing',
|
||||
secondaryPath: 'subtitleStyle.secondary.wordSpacing',
|
||||
paths: {
|
||||
primary: 'subtitleStyle.wordSpacing',
|
||||
secondary: 'subtitleStyle.secondary.wordSpacing',
|
||||
},
|
||||
},
|
||||
{
|
||||
property: 'font-kerning',
|
||||
primaryPath: 'subtitleStyle.fontKerning',
|
||||
secondaryPath: 'subtitleStyle.secondary.fontKerning',
|
||||
paths: {
|
||||
primary: 'subtitleStyle.fontKerning',
|
||||
secondary: 'subtitleStyle.secondary.fontKerning',
|
||||
},
|
||||
},
|
||||
{
|
||||
property: 'text-rendering',
|
||||
primaryPath: 'subtitleStyle.textRendering',
|
||||
secondaryPath: 'subtitleStyle.secondary.textRendering',
|
||||
paths: {
|
||||
primary: 'subtitleStyle.textRendering',
|
||||
secondary: 'subtitleStyle.secondary.textRendering',
|
||||
},
|
||||
},
|
||||
{
|
||||
property: 'text-shadow',
|
||||
primaryPath: 'subtitleStyle.textShadow',
|
||||
secondaryPath: 'subtitleStyle.secondary.textShadow',
|
||||
paths: {
|
||||
primary: 'subtitleStyle.textShadow',
|
||||
secondary: 'subtitleStyle.secondary.textShadow',
|
||||
},
|
||||
},
|
||||
{
|
||||
property: 'paint-order',
|
||||
paths: {
|
||||
primary: 'subtitleStyle.paintOrder',
|
||||
secondary: 'subtitleStyle.secondary.paintOrder',
|
||||
},
|
||||
},
|
||||
{
|
||||
property: '-webkit-text-stroke',
|
||||
paths: {
|
||||
primary: 'subtitleStyle.WebkitTextStroke',
|
||||
secondary: 'subtitleStyle.secondary.WebkitTextStroke',
|
||||
},
|
||||
},
|
||||
{
|
||||
property: 'backdrop-filter',
|
||||
primaryPath: 'subtitleStyle.backdropFilter',
|
||||
secondaryPath: 'subtitleStyle.secondary.backdropFilter',
|
||||
paths: {
|
||||
primary: 'subtitleStyle.backdropFilter',
|
||||
secondary: 'subtitleStyle.secondary.backdropFilter',
|
||||
},
|
||||
},
|
||||
{
|
||||
property: 'color',
|
||||
primaryPath: 'subtitleStyle.fontColor',
|
||||
secondaryPath: 'subtitleStyle.secondary.fontColor',
|
||||
property: '--subtitle-hover-token-color',
|
||||
paths: {
|
||||
primary: 'subtitleStyle.hoverTokenColor',
|
||||
},
|
||||
},
|
||||
{
|
||||
property: 'background-color',
|
||||
primaryPath: 'subtitleStyle.backgroundColor',
|
||||
secondaryPath: 'subtitleStyle.secondary.backgroundColor',
|
||||
property: '--subtitle-hover-token-background-color',
|
||||
paths: {
|
||||
primary: 'subtitleStyle.hoverTokenBackgroundColor',
|
||||
},
|
||||
},
|
||||
{
|
||||
property: 'opacity',
|
||||
paths: {
|
||||
sidebar: 'subtitleSidebar.opacity',
|
||||
},
|
||||
},
|
||||
{
|
||||
property: '--subtitle-sidebar-max-width',
|
||||
paths: {
|
||||
sidebar: 'subtitleSidebar.maxWidth',
|
||||
},
|
||||
format: formatCssLengthLikeValue,
|
||||
},
|
||||
{
|
||||
property: '--subtitle-sidebar-timestamp-color',
|
||||
paths: {
|
||||
sidebar: 'subtitleSidebar.timestampColor',
|
||||
},
|
||||
},
|
||||
{
|
||||
property: '--subtitle-sidebar-active-line-color',
|
||||
paths: {
|
||||
sidebar: 'subtitleSidebar.activeLineColor',
|
||||
},
|
||||
},
|
||||
{
|
||||
property: '--subtitle-sidebar-active-background-color',
|
||||
paths: {
|
||||
sidebar: 'subtitleSidebar.activeLineBackgroundColor',
|
||||
},
|
||||
},
|
||||
{
|
||||
property: '--subtitle-sidebar-hover-background-color',
|
||||
paths: {
|
||||
sidebar: 'subtitleSidebar.hoverLineBackgroundColor',
|
||||
},
|
||||
},
|
||||
];
|
||||
|
||||
const CSS_PROPERTY_PATTERN = /^(?:--[A-Za-z0-9_-]+|-?[A-Za-z][A-Za-z0-9_-]*)$/;
|
||||
|
||||
export function getSubtitleCssPath(scope: SubtitleCssScope): string {
|
||||
return scope === 'primary' ? 'subtitleStyle.css' : 'subtitleStyle.secondary.css';
|
||||
if (scope === 'primary') return 'subtitleStyle.css';
|
||||
if (scope === 'secondary') return 'subtitleStyle.secondary.css';
|
||||
return 'subtitleSidebar.css';
|
||||
}
|
||||
|
||||
export function getSubtitleCssManagedConfigPaths(scope: SubtitleCssScope): string[] {
|
||||
return LEGACY_CSS_DECLARATIONS.map((declaration) =>
|
||||
scope === 'primary' ? declaration.primaryPath : declaration.secondaryPath,
|
||||
);
|
||||
return [
|
||||
...new Set(
|
||||
LEGACY_CSS_DECLARATIONS.map((declaration) => declaration.paths[scope]).filter(
|
||||
(path): path is string => typeof path === 'string' && path.length > 0,
|
||||
),
|
||||
),
|
||||
];
|
||||
}
|
||||
|
||||
export function getSubtitleCssScopeForPath(path: string): SubtitleCssScope | null {
|
||||
if (path === 'subtitleStyle.css') return 'primary';
|
||||
if (path === 'subtitleStyle.secondary.css') return 'secondary';
|
||||
if (path === 'subtitleSidebar.css') return 'sidebar';
|
||||
return null;
|
||||
}
|
||||
|
||||
@@ -104,10 +203,20 @@ export function serializeSubtitleCssDeclarations(
|
||||
scope: SubtitleCssScope,
|
||||
values: Record<string, ConfigSettingsSnapshotValue | undefined>,
|
||||
): string {
|
||||
return Object.entries(buildSubtitleCssDeclarationObject(scope, values))
|
||||
.map(([property, value]) => `${property}: ${value};`)
|
||||
.join('\n');
|
||||
}
|
||||
|
||||
export function buildSubtitleCssDeclarationObject(
|
||||
scope: SubtitleCssScope,
|
||||
values: Record<string, ConfigSettingsSnapshotValue | undefined>,
|
||||
): Record<string, string> {
|
||||
const declarations = new Map<string, string>();
|
||||
|
||||
for (const declaration of LEGACY_CSS_DECLARATIONS) {
|
||||
const path = scope === 'primary' ? declaration.primaryPath : declaration.secondaryPath;
|
||||
const path = declaration.paths[scope];
|
||||
if (typeof path !== 'string' || path.length === 0) continue;
|
||||
const formatted = (declaration.format ?? formatCssPrimitiveValue)(values[path]);
|
||||
if (formatted !== undefined) {
|
||||
declarations.set(declaration.property, formatted);
|
||||
@@ -119,9 +228,7 @@ export function serializeSubtitleCssDeclarations(
|
||||
declarations.set(normalizeCssPropertyName(property), value);
|
||||
}
|
||||
|
||||
return [...declarations.entries()]
|
||||
.map(([property, value]) => `${property}: ${value};`)
|
||||
.join('\n');
|
||||
return Object.fromEntries(declarations.entries());
|
||||
}
|
||||
|
||||
export function parseSubtitleCssDeclarations(text: string): SubtitleCssParseResult {
|
||||
|
||||
Reference in New Issue
Block a user