mirror of
https://github.com/ksyasuda/SubMiner.git
synced 2026-05-26 00:55:16 -07:00
feat(config): add subtitle CSS editor, nPlusOne.enabled flag, and fix se
- subtitleStyle.css / subtitleStyle.secondary.css replace flat style fields in the settings window - ankiConnect.nPlusOne.enabled gates known-word cache independently of knownWords.highlightEnabled - Settings search now covers all categories, narrows on multi-word terms, and hides editor-owned fields - Default note-type picker to Kiku then Lapis; rename isLapis.sentenceCardModel default to "Lapis"
This commit is contained in:
@@ -277,7 +277,8 @@ export class KnownWordCacheManager {
|
||||
}
|
||||
|
||||
private isKnownWordCacheEnabled(): boolean {
|
||||
return this.deps.getConfig().knownWords?.highlightEnabled === true;
|
||||
const config = this.deps.getConfig();
|
||||
return config.knownWords?.highlightEnabled === true || config.nPlusOne?.enabled === true;
|
||||
}
|
||||
|
||||
private shouldAddMinedWordsImmediately(): boolean {
|
||||
|
||||
@@ -157,7 +157,8 @@ export class AnkiIntegrationRuntime {
|
||||
}
|
||||
|
||||
applyRuntimeConfigPatch(patch: Partial<AnkiConnectConfig>): void {
|
||||
const wasKnownWordCacheEnabled = this.config.knownWords?.highlightEnabled === true;
|
||||
const wasKnownWordCacheEnabled =
|
||||
this.config.knownWords?.highlightEnabled === true || this.config.nPlusOne?.enabled === true;
|
||||
const previousKnownWordCacheConfig = wasKnownWordCacheEnabled
|
||||
? this.getKnownWordCacheLifecycleConfig(this.config)
|
||||
: null;
|
||||
@@ -207,7 +208,8 @@ export class AnkiIntegrationRuntime {
|
||||
};
|
||||
this.config = normalizeAnkiIntegrationConfig(mergedConfig);
|
||||
this.deps.onConfigChanged?.(this.config);
|
||||
const nextKnownWordCacheEnabled = this.config.knownWords?.highlightEnabled === true;
|
||||
const nextKnownWordCacheEnabled =
|
||||
this.config.knownWords?.highlightEnabled === true || this.config.nPlusOne?.enabled === true;
|
||||
|
||||
if (wasKnownWordCacheEnabled && !nextKnownWordCacheEnabled) {
|
||||
if (this.started) {
|
||||
|
||||
@@ -69,6 +69,7 @@ export const INTEGRATIONS_DEFAULT_CONFIG: Pick<
|
||||
autoUpdateNewCards: true,
|
||||
},
|
||||
nPlusOne: {
|
||||
enabled: false,
|
||||
minSentenceWords: 3,
|
||||
},
|
||||
metadata: {
|
||||
@@ -76,7 +77,7 @@ export const INTEGRATIONS_DEFAULT_CONFIG: Pick<
|
||||
},
|
||||
isLapis: {
|
||||
enabled: false,
|
||||
sentenceCardModel: 'Japanese sentences',
|
||||
sentenceCardModel: 'Lapis',
|
||||
},
|
||||
isKiku: {
|
||||
enabled: false,
|
||||
|
||||
@@ -3,6 +3,7 @@ import { ResolvedConfig } from '../../types/config';
|
||||
export const SUBTITLE_DEFAULT_CONFIG: Pick<ResolvedConfig, 'subtitleStyle' | 'subtitleSidebar'> = {
|
||||
subtitleStyle: {
|
||||
primaryDefaultMode: 'visible',
|
||||
css: {},
|
||||
enableJlpt: false,
|
||||
preserveLineBreaks: false,
|
||||
autoPauseVideoOnHover: true,
|
||||
@@ -43,6 +44,7 @@ export const SUBTITLE_DEFAULT_CONFIG: Pick<ResolvedConfig, 'subtitleStyle' | 'su
|
||||
bandedColors: ['#ed8796', '#f5a97f', '#f9e2af', '#8bd5ca', '#8aadf4'],
|
||||
},
|
||||
secondary: {
|
||||
css: {},
|
||||
fontFamily: 'Inter, Noto Sans, Helvetica Neue, sans-serif',
|
||||
fontSize: 24,
|
||||
fontColor: '#cad3f5',
|
||||
|
||||
@@ -278,6 +278,12 @@ export function buildIntegrationConfigOptionRegistry(
|
||||
defaultValue: defaultConfig.ankiConnect.knownWords.addMinedWordsImmediately,
|
||||
description: 'Immediately append newly mined card words into the known-word cache.',
|
||||
},
|
||||
{
|
||||
path: 'ankiConnect.nPlusOne.enabled',
|
||||
kind: 'boolean',
|
||||
defaultValue: defaultConfig.ankiConnect.nPlusOne.enabled,
|
||||
description: 'Enable N+1 subtitle highlighting (highlights the one unknown word in a sentence). Requires known-word cache data.',
|
||||
},
|
||||
{
|
||||
path: 'ankiConnect.nPlusOne.minSentenceWords',
|
||||
kind: 'number',
|
||||
|
||||
@@ -13,6 +13,20 @@ export function buildSubtitleConfigOptionRegistry(
|
||||
description:
|
||||
'Default primary subtitle bar visibility mode. hidden hides it, visible shows it, hover reveals it on hover.',
|
||||
},
|
||||
{
|
||||
path: 'subtitleStyle.css',
|
||||
kind: 'object',
|
||||
defaultValue: defaultConfig.subtitleStyle.css,
|
||||
description:
|
||||
'CSS declaration object applied to primary subtitles after normal subtitle style defaults.',
|
||||
},
|
||||
{
|
||||
path: 'subtitleStyle.secondary.css',
|
||||
kind: 'object',
|
||||
defaultValue: defaultConfig.subtitleStyle.secondary.css,
|
||||
description:
|
||||
'CSS declaration object applied to secondary subtitles after normal subtitle style defaults.',
|
||||
},
|
||||
{
|
||||
path: 'subtitleStyle.enableJlpt',
|
||||
kind: 'boolean',
|
||||
|
||||
@@ -789,6 +789,21 @@ export function applyAnkiConnectResolution(context: ResolveContext): void {
|
||||
DEFAULT_CONFIG.ankiConnect.knownWords.addMinedWordsImmediately;
|
||||
}
|
||||
|
||||
const nPlusOneEnabled = asBoolean(nPlusOneConfig.enabled);
|
||||
if (nPlusOneEnabled !== undefined) {
|
||||
context.resolved.ankiConnect.nPlusOne.enabled = nPlusOneEnabled;
|
||||
} else if (nPlusOneConfig.enabled !== undefined) {
|
||||
context.warn(
|
||||
'ankiConnect.nPlusOne.enabled',
|
||||
nPlusOneConfig.enabled,
|
||||
context.resolved.ankiConnect.nPlusOne.enabled,
|
||||
'Expected boolean.',
|
||||
);
|
||||
context.resolved.ankiConnect.nPlusOne.enabled = DEFAULT_CONFIG.ankiConnect.nPlusOne.enabled;
|
||||
} else {
|
||||
context.resolved.ankiConnect.nPlusOne.enabled = DEFAULT_CONFIG.ankiConnect.nPlusOne.enabled;
|
||||
}
|
||||
|
||||
const nPlusOneMinSentenceWords = asNumber(nPlusOneConfig.minSentenceWords);
|
||||
const hasValidNPlusOneMinSentenceWords =
|
||||
nPlusOneMinSentenceWords !== undefined &&
|
||||
|
||||
@@ -10,6 +10,21 @@ import {
|
||||
isObject,
|
||||
} from './shared';
|
||||
|
||||
function asCssDeclarations(value: unknown): Record<string, string> | undefined {
|
||||
if (!isObject(value)) return undefined;
|
||||
|
||||
const declarations: Record<string, string> = {};
|
||||
for (const [property, declarationValue] of Object.entries(value)) {
|
||||
if (typeof declarationValue !== 'string') {
|
||||
return undefined;
|
||||
}
|
||||
if (declarationValue.trim().length > 0) {
|
||||
declarations[property] = declarationValue.trim();
|
||||
}
|
||||
}
|
||||
return declarations;
|
||||
}
|
||||
|
||||
export function applySubtitleDomainConfig(context: ResolveContext): void {
|
||||
const { src, resolved, warn } = context;
|
||||
|
||||
@@ -159,6 +174,8 @@ export function applySubtitleDomainConfig(context: ResolveContext): void {
|
||||
const fallbackSubtitleStyleNameMatchColor = resolved.subtitleStyle.nameMatchColor;
|
||||
const fallbackSubtitleStyleKnownWordColor = resolved.subtitleStyle.knownWordColor;
|
||||
const fallbackSubtitleStyleNPlusOneColor = resolved.subtitleStyle.nPlusOneColor;
|
||||
const fallbackSubtitleStyleCss = { ...resolved.subtitleStyle.css };
|
||||
const fallbackSubtitleStyleSecondaryCss = { ...resolved.subtitleStyle.secondary.css };
|
||||
const fallbackFrequencyDictionary = {
|
||||
...resolved.subtitleStyle.frequencyDictionary,
|
||||
};
|
||||
@@ -211,6 +228,35 @@ export function applySubtitleDomainConfig(context: ResolveContext): void {
|
||||
);
|
||||
}
|
||||
|
||||
const css = asCssDeclarations((src.subtitleStyle as { css?: unknown }).css);
|
||||
if (css !== undefined) {
|
||||
resolved.subtitleStyle.css = css;
|
||||
} else if ((src.subtitleStyle as { css?: unknown }).css !== undefined) {
|
||||
resolved.subtitleStyle.css = fallbackSubtitleStyleCss;
|
||||
warn(
|
||||
'subtitleStyle.css',
|
||||
(src.subtitleStyle as { css?: unknown }).css,
|
||||
resolved.subtitleStyle.css,
|
||||
'Expected an object whose values are CSS declaration strings.',
|
||||
);
|
||||
}
|
||||
|
||||
const rawSecondary = isObject(src.subtitleStyle.secondary)
|
||||
? (src.subtitleStyle.secondary as { css?: unknown })
|
||||
: undefined;
|
||||
const secondaryCss = asCssDeclarations(rawSecondary?.css);
|
||||
if (secondaryCss !== undefined) {
|
||||
resolved.subtitleStyle.secondary.css = secondaryCss;
|
||||
} else if (rawSecondary?.css !== undefined) {
|
||||
resolved.subtitleStyle.secondary.css = fallbackSubtitleStyleSecondaryCss;
|
||||
warn(
|
||||
'subtitleStyle.secondary.css',
|
||||
rawSecondary.css,
|
||||
resolved.subtitleStyle.secondary.css,
|
||||
'Expected an object whose values are CSS declaration strings.',
|
||||
);
|
||||
}
|
||||
|
||||
const preserveLineBreaks = asBoolean(
|
||||
(src.subtitleStyle as { preserveLineBreaks?: unknown }).preserveLineBreaks,
|
||||
);
|
||||
|
||||
@@ -28,6 +28,46 @@ test('subtitleStyle preserveLineBreaks falls back while merge is preserved', ()
|
||||
);
|
||||
});
|
||||
|
||||
test('subtitleStyle css declarations accept string declaration maps and warn on invalid values', () => {
|
||||
const valid = createResolveContext({
|
||||
subtitleStyle: {
|
||||
css: {
|
||||
'font-size': '42px',
|
||||
'text-wrap': 'balance',
|
||||
},
|
||||
secondary: {
|
||||
css: {
|
||||
'text-transform': 'uppercase',
|
||||
},
|
||||
},
|
||||
},
|
||||
});
|
||||
applySubtitleDomainConfig(valid.context);
|
||||
assert.deepEqual(valid.context.resolved.subtitleStyle.css, {
|
||||
'font-size': '42px',
|
||||
'text-wrap': 'balance',
|
||||
});
|
||||
assert.deepEqual(valid.context.resolved.subtitleStyle.secondary.css, {
|
||||
'text-transform': 'uppercase',
|
||||
});
|
||||
|
||||
const invalid = createResolveContext({
|
||||
subtitleStyle: {
|
||||
css: {
|
||||
'font-size': 42,
|
||||
} as never,
|
||||
secondary: {
|
||||
css: 'font-size: 28px;' as never,
|
||||
},
|
||||
},
|
||||
});
|
||||
applySubtitleDomainConfig(invalid.context);
|
||||
assert.deepEqual(invalid.context.resolved.subtitleStyle.css, {});
|
||||
assert.deepEqual(invalid.context.resolved.subtitleStyle.secondary.css, {});
|
||||
assert.ok(invalid.warnings.some((warning) => warning.path === 'subtitleStyle.css'));
|
||||
assert.ok(invalid.warnings.some((warning) => warning.path === 'subtitleStyle.secondary.css'));
|
||||
});
|
||||
|
||||
test('subtitleStyle autoPauseVideoOnHover falls back on invalid value', () => {
|
||||
const { context, warnings } = createResolveContext({
|
||||
subtitleStyle: {
|
||||
|
||||
@@ -188,7 +188,7 @@ export function buildConfigSettingsSnapshot(
|
||||
continue;
|
||||
}
|
||||
|
||||
values[field.configPath] = structuredClone(rawValue !== undefined ? rawValue : resolvedValue);
|
||||
values[field.configPath] = structuredClone(rawValue != null ? rawValue : resolvedValue);
|
||||
}
|
||||
|
||||
return {
|
||||
|
||||
@@ -28,17 +28,60 @@ test('settings registry groups annotation display fields by config group', () =>
|
||||
assert.equal(field('subtitleStyle.jlptColors.N1').control, 'color');
|
||||
});
|
||||
|
||||
test('settings registry routes known words sync, n+1, and frequency config to behavior', () => {
|
||||
assert.equal(field('ankiConnect.knownWords.addMinedWordsImmediately').category, 'behavior');
|
||||
assert.equal(field('ankiConnect.knownWords.addMinedWordsImmediately').section, 'Known Words');
|
||||
assert.equal(field('ankiConnect.knownWords.decks').category, 'behavior');
|
||||
assert.equal(field('ankiConnect.knownWords.decks').section, 'Known Words');
|
||||
assert.equal(field('ankiConnect.knownWords.matchMode').category, 'behavior');
|
||||
assert.equal(field('ankiConnect.knownWords.matchMode').section, 'Known Words');
|
||||
assert.equal(field('ankiConnect.knownWords.refreshMinutes').category, 'behavior');
|
||||
assert.equal(field('ankiConnect.knownWords.refreshMinutes').section, 'Known Words');
|
||||
assert.equal(field('ankiConnect.nPlusOne.minSentenceWords').category, 'behavior');
|
||||
assert.equal(field('ankiConnect.nPlusOne.minSentenceWords').section, 'N+1');
|
||||
assert.equal(field('subtitleStyle.frequencyDictionary.sourcePath').category, 'behavior');
|
||||
assert.equal(field('subtitleStyle.frequencyDictionary.sourcePath').section, 'Frequency Highlighting');
|
||||
assert.equal(field('subtitleStyle.frequencyDictionary.mode').category, 'behavior');
|
||||
assert.equal(field('subtitleStyle.frequencyDictionary.matchMode').category, 'behavior');
|
||||
assert.equal(field('subtitleStyle.frequencyDictionary.topX').category, 'behavior');
|
||||
});
|
||||
|
||||
test('settings registry exposes specialized controls for config-assisted inputs', () => {
|
||||
assert.equal(field('ankiConnect.knownWords.decks').control, 'known-words-decks');
|
||||
assert.equal(field('ankiConnect.isLapis.sentenceCardModel').control, 'anki-note-type');
|
||||
assert.equal(field('ankiConnect.fields.word').control, 'anki-field');
|
||||
assert.equal(field('keybindings').control, 'mpv-keybindings');
|
||||
assert.equal(field('subtitleStyle.css').control, 'css-declarations');
|
||||
assert.equal(field('subtitleStyle.secondary.css').control, 'css-declarations');
|
||||
assert.equal(field('shortcuts.copySubtitle').control, 'keyboard-shortcut');
|
||||
assert.equal(field('subtitleSidebar.toggleKey').control, 'key-code');
|
||||
assert.equal(field('stats.toggleKey').control, 'key-code');
|
||||
assert.equal(field('discordPresence.presenceStyle').control, 'select');
|
||||
});
|
||||
|
||||
test('settings registry exposes css declaration editor for primary and secondary subtitle appearance', () => {
|
||||
const primaryVisible = fields
|
||||
.filter(
|
||||
(candidate) =>
|
||||
candidate.section === 'Primary Subtitle Appearance' && !candidate.settingsHidden,
|
||||
)
|
||||
.map((candidate) => candidate.configPath);
|
||||
const secondaryVisible = fields
|
||||
.filter(
|
||||
(candidate) =>
|
||||
candidate.section === 'Secondary Subtitle Appearance' && !candidate.settingsHidden,
|
||||
)
|
||||
.map((candidate) => candidate.configPath);
|
||||
|
||||
assert.deepEqual(primaryVisible, ['subtitleStyle.css']);
|
||||
assert.deepEqual(secondaryVisible, ['subtitleStyle.secondary.css']);
|
||||
assert.equal(field('subtitleStyle.fontSize').settingsHidden, true);
|
||||
assert.equal(field('subtitleStyle.secondary.fontSize').settingsHidden, true);
|
||||
assert.equal(field('subtitleStyle.fontColor').settingsHidden, true);
|
||||
assert.equal(field('subtitleStyle.backgroundColor').settingsHidden, true);
|
||||
assert.equal(field('subtitleStyle.hoverTokenColor').settingsHidden, true);
|
||||
assert.equal(field('subtitleStyle.hoverTokenBackgroundColor').settingsHidden, true);
|
||||
});
|
||||
|
||||
test('settings registry puts feature toggles first, then other toggles alphabetically', () => {
|
||||
const ankiConnect = fields.filter((candidate) => candidate.section === 'AnkiConnect');
|
||||
assert.equal(ankiConnect[0]?.configPath, 'ankiConnect.enabled');
|
||||
@@ -48,7 +91,7 @@ test('settings registry puts feature toggles first, then other toggles alphabeti
|
||||
);
|
||||
|
||||
const kikuLapis = fields.filter(
|
||||
(candidate) => candidate.section === 'Kiku Features And Lapis Features',
|
||||
(candidate) => candidate.section === 'Kiku/Lapis Features',
|
||||
);
|
||||
assert.deepEqual(
|
||||
kikuLapis.slice(0, 2).map((candidate) => candidate.configPath),
|
||||
@@ -68,6 +111,8 @@ test('settings registry hides app-managed and inactive config surfaces', () => {
|
||||
'jellyfin.defaultLibraryId',
|
||||
'jellyfin.deviceId',
|
||||
'jellyfin.clientName',
|
||||
'subtitleSidebar.toggleKey',
|
||||
'jellyfin.recentServers',
|
||||
]) {
|
||||
assert.equal(paths.has(hiddenPath), false, `${hiddenPath} should be hidden`);
|
||||
}
|
||||
|
||||
+141
-13
@@ -6,6 +6,10 @@ import type {
|
||||
ConfigSettingsRestartBehavior,
|
||||
} from '../../types/settings';
|
||||
import { CONFIG_OPTION_REGISTRY, DEFAULT_CONFIG } from '../definitions';
|
||||
import {
|
||||
getSubtitleCssManagedConfigPaths,
|
||||
getSubtitleCssScopeForPath,
|
||||
} from '../../settings/subtitle-style-css';
|
||||
|
||||
type Leaf = {
|
||||
path: string;
|
||||
@@ -67,6 +71,8 @@ export const LEGACY_HIDDEN_CONFIG_PATHS = [
|
||||
'jellyfin.defaultLibraryId',
|
||||
'jellyfin.deviceId',
|
||||
'controller.buttonIndices',
|
||||
'subtitleSidebar.toggleKey',
|
||||
'jellyfin.recentServers',
|
||||
] as const;
|
||||
|
||||
const EXCLUDED_PREFIXES = ['controller.buttonIndices', 'youtubeSubgen'] as const;
|
||||
@@ -76,11 +82,19 @@ const JSON_OBJECT_FIELDS = new Set([
|
||||
'controller.bindings',
|
||||
'controller.profiles',
|
||||
'ankiConnect.knownWords.decks',
|
||||
'subtitleStyle.css',
|
||||
'subtitleStyle.secondary.css',
|
||||
]);
|
||||
|
||||
const SECRET_PATHS = new Set(['ai.apiKey', 'jimaku.apiKey', 'anilist.accessToken']);
|
||||
|
||||
const COLOR_SUFFIXES = new Set(['Color', 'color', 'backgroundColor', 'singleColor', 'nPlusOne']);
|
||||
const SUBTITLE_CSS_MANAGED_CONFIG_PATHS = new Set([
|
||||
...getSubtitleCssManagedConfigPaths('primary'),
|
||||
...getSubtitleCssManagedConfigPaths('secondary'),
|
||||
'subtitleStyle.hoverTokenColor',
|
||||
'subtitleStyle.hoverTokenBackgroundColor',
|
||||
]);
|
||||
|
||||
const OPTION_BY_PATH = new Map(CONFIG_OPTION_REGISTRY.map((entry) => [entry.path, entry]));
|
||||
|
||||
@@ -106,7 +120,7 @@ const SECTION_ORDER = new Map<string, number>(
|
||||
'Subtitle Sidebar Behavior',
|
||||
'Note Fields',
|
||||
'Media Capture',
|
||||
'Kiku Features And Lapis Features',
|
||||
'Kiku/Lapis Features',
|
||||
'Anki AI',
|
||||
'AnkiConnect Proxy',
|
||||
'AnkiConnect',
|
||||
@@ -123,20 +137,48 @@ const PATH_ORDER = new Map<string, number>(
|
||||
'ankiConnect.proxy.enabled',
|
||||
'ankiConnect.isLapis.enabled',
|
||||
'ankiConnect.isKiku.enabled',
|
||||
'subtitleStyle.fontColor',
|
||||
'subtitleStyle.backgroundColor',
|
||||
'subtitleStyle.hoverTokenColor',
|
||||
'subtitleStyle.hoverTokenBackgroundColor',
|
||||
'subtitleStyle.css',
|
||||
'subtitleStyle.secondary.fontColor',
|
||||
'subtitleStyle.secondary.backgroundColor',
|
||||
'subtitleStyle.secondary.css',
|
||||
'secondarySub.defaultMode',
|
||||
'secondarySub.secondarySubLanguages',
|
||||
].map((path, index) => [path, index]),
|
||||
);
|
||||
|
||||
const SUBSECTION_ORDER = new Map<string, number>(
|
||||
['Known Words', 'N+1', 'JLPT', 'Frequency Dictionary', 'Character Names'].map(
|
||||
(subsection, index) => [subsection, index],
|
||||
),
|
||||
[
|
||||
'Known Words',
|
||||
'N+1',
|
||||
'JLPT',
|
||||
'Frequency Highlighting',
|
||||
'Character Names',
|
||||
'Mining & Clipboard',
|
||||
'Toggle & Visibility',
|
||||
'Open Panels',
|
||||
'Playback',
|
||||
'Timing',
|
||||
'Default Fold State',
|
||||
].map((subsection, index) => [subsection, index]),
|
||||
);
|
||||
|
||||
const LABEL_OVERRIDES: Record<string, string> = {
|
||||
'ankiConnect.knownWords.highlightEnabled': 'Enabled',
|
||||
'ankiConnect.nPlusOne.enabled': 'Enabled',
|
||||
'ankiConnect.isLapis.enabled': 'Enable Lapis Features',
|
||||
'ankiConnect.isKiku.enabled': 'Enable Kiku Features',
|
||||
'stats.toggleKey': 'Toggle Stats Overlay',
|
||||
'shortcuts.openCharacterDictionary': 'Open AniList Override',
|
||||
'subtitleSidebar.pauseVideoOnHover': 'Pause Video On Hover - Sidebar',
|
||||
'subtitleStyle.autoPauseVideoOnHover': 'Pause Video On Hover - Subtitles',
|
||||
'subtitleStyle.autoPauseVideoOnYomitanPopup': 'Pause Video On Yomitan Popup',
|
||||
'subtitleStyle.primaryDefaultMode': 'Primary Subtitle Visibility Mode',
|
||||
'subtitleStyle.css': 'CSS Declarations',
|
||||
'subtitleStyle.secondary.css': 'CSS Declarations',
|
||||
'secondarySub.defaultMode': 'Secondary Subtitle Visibility Mode',
|
||||
'subtitlePosition.yPercent': 'Subtitle Position',
|
||||
};
|
||||
@@ -150,6 +192,10 @@ const DESCRIPTION_OVERRIDES: Record<string, string> = {
|
||||
'Enable Lapis-specific mining behavior and sentence-card model targeting. When Kiku is enabled, Lapis features still work and Kiku-specific features are added on top.',
|
||||
'ankiConnect.isLapis.sentenceCardModel':
|
||||
'Anki note type used for Lapis sentence cards. Select from note types reported by AnkiConnect.',
|
||||
'subtitleStyle.css':
|
||||
'CSS declarations applied to primary subtitles. Includes color, background-color, and all font properties.',
|
||||
'subtitleStyle.secondary.css':
|
||||
'CSS declarations applied to secondary subtitles. Includes color, background-color, and all font properties.',
|
||||
};
|
||||
|
||||
function isRecord(value: unknown): value is Record<string, unknown> {
|
||||
@@ -215,6 +261,28 @@ function categoryAndSection(path: string): { category: ConfigSettingsCategory; s
|
||||
) {
|
||||
return { category: 'behavior', section: 'Playback Pause Behavior' };
|
||||
}
|
||||
if (path === 'subtitleStyle.preserveLineBreaks') {
|
||||
return { category: 'behavior', section: 'Subtitle Behavior' };
|
||||
}
|
||||
if (
|
||||
path === 'ankiConnect.knownWords.addMinedWordsImmediately' ||
|
||||
path === 'ankiConnect.knownWords.decks' ||
|
||||
path === 'ankiConnect.knownWords.matchMode' ||
|
||||
path === 'ankiConnect.knownWords.refreshMinutes'
|
||||
) {
|
||||
return { category: 'behavior', section: 'Known Words' };
|
||||
}
|
||||
if (path === 'ankiConnect.nPlusOne.minSentenceWords') {
|
||||
return { category: 'behavior', section: 'N+1' };
|
||||
}
|
||||
if (
|
||||
path === 'subtitleStyle.frequencyDictionary.matchMode' ||
|
||||
path === 'subtitleStyle.frequencyDictionary.mode' ||
|
||||
path === 'subtitleStyle.frequencyDictionary.sourcePath' ||
|
||||
path === 'subtitleStyle.frequencyDictionary.topX'
|
||||
) {
|
||||
return { category: 'behavior', section: 'Frequency Highlighting' };
|
||||
}
|
||||
if (
|
||||
path.startsWith('ankiConnect.knownWords.') ||
|
||||
path.startsWith('ankiConnect.nPlusOne.') ||
|
||||
@@ -243,7 +311,6 @@ function categoryAndSection(path: string): { category: ConfigSettingsCategory; s
|
||||
'subtitleSidebar.autoOpen',
|
||||
'subtitleSidebar.autoScroll',
|
||||
'subtitleSidebar.layout',
|
||||
'subtitleSidebar.toggleKey',
|
||||
]);
|
||||
return sidebarBehaviorPaths.has(path)
|
||||
? { category: 'behavior', section: 'Subtitle Sidebar Behavior' }
|
||||
@@ -259,7 +326,7 @@ function categoryAndSection(path: string): { category: ConfigSettingsCategory; s
|
||||
return { category: 'mining-anki', section: 'Media Capture' };
|
||||
}
|
||||
if (path.startsWith('ankiConnect.isKiku.') || path.startsWith('ankiConnect.isLapis.')) {
|
||||
return { category: 'mining-anki', section: 'Kiku Features And Lapis Features' };
|
||||
return { category: 'mining-anki', section: 'Kiku/Lapis Features' };
|
||||
}
|
||||
if (path.startsWith('ankiConnect.ai.')) {
|
||||
return { category: 'mining-anki', section: 'Anki AI' };
|
||||
@@ -279,6 +346,9 @@ function categoryAndSection(path: string): { category: ConfigSettingsCategory; s
|
||||
) {
|
||||
return { category: 'playback-sources', section: topSection(path) };
|
||||
}
|
||||
if (path === 'stats.toggleKey' || path === 'stats.markWatchedKey') {
|
||||
return { category: 'input', section: 'Overlay Shortcuts' };
|
||||
}
|
||||
if (path.startsWith('shortcuts.')) {
|
||||
return { category: 'input', section: 'Overlay Shortcuts' };
|
||||
}
|
||||
@@ -346,6 +416,7 @@ function topSection(path: string): string {
|
||||
|
||||
function controlForPath(path: string, value: unknown): ConfigSettingsControl {
|
||||
if (SECRET_PATHS.has(path)) return 'secret';
|
||||
if (getSubtitleCssScopeForPath(path)) return 'css-declarations';
|
||||
if (path === 'keybindings') return 'mpv-keybindings';
|
||||
if (path === 'ankiConnect.knownWords.decks') return 'known-words-decks';
|
||||
if (path === 'ankiConnect.isLapis.sentenceCardModel') return 'anki-note-type';
|
||||
@@ -376,17 +447,71 @@ function controlForPath(path: string, value: unknown): ConfigSettingsControl {
|
||||
}
|
||||
|
||||
function subsectionForPath(path: string): string | undefined {
|
||||
if (path.startsWith('ankiConnect.knownWords.')) return 'Known Words';
|
||||
if (path.startsWith('ankiConnect.nPlusOne.')) return 'N+1';
|
||||
if (path === 'ankiConnect.knownWords.highlightEnabled') return 'Known Words';
|
||||
if (path === 'ankiConnect.nPlusOne.enabled') return 'N+1';
|
||||
if (path === 'subtitleStyle.knownWordColor') return 'Known Words';
|
||||
if (path === 'subtitleStyle.nPlusOneColor') return 'N+1';
|
||||
if (path === 'subtitleStyle.enableJlpt' || path.startsWith('subtitleStyle.jlptColors.')) {
|
||||
return 'JLPT';
|
||||
}
|
||||
if (path.startsWith('subtitleStyle.frequencyDictionary.')) return 'Frequency Dictionary';
|
||||
if (
|
||||
path === 'subtitleStyle.frequencyDictionary.enabled' ||
|
||||
path === 'subtitleStyle.frequencyDictionary.singleColor' ||
|
||||
path === 'subtitleStyle.frequencyDictionary.bandedColors'
|
||||
) {
|
||||
return 'Frequency Highlighting';
|
||||
}
|
||||
if (path === 'subtitleStyle.nameMatchEnabled' || path === 'subtitleStyle.nameMatchColor') {
|
||||
return 'Character Names';
|
||||
}
|
||||
if (path === 'anilist.characterDictionary.collapsibleSections.description') {
|
||||
return 'Default Fold State';
|
||||
}
|
||||
if (path === 'anilist.characterDictionary.collapsibleSections.characterInformation') {
|
||||
return 'Default Fold State';
|
||||
}
|
||||
if (path === 'anilist.characterDictionary.collapsibleSections.voicedBy') {
|
||||
return 'Default Fold State';
|
||||
}
|
||||
if (path === 'stats.toggleKey' || path === 'stats.markWatchedKey') {
|
||||
return 'Toggle & Visibility';
|
||||
}
|
||||
if (path.startsWith('shortcuts.')) {
|
||||
const leaf = path.split('.').at(-1) ?? '';
|
||||
if (leaf === 'multiCopyTimeoutMs') return 'Timing';
|
||||
if (
|
||||
leaf === 'copySubtitle' ||
|
||||
leaf === 'copySubtitleMultiple' ||
|
||||
leaf === 'mineSentence' ||
|
||||
leaf === 'mineSentenceMultiple' ||
|
||||
leaf === 'updateLastCardFromClipboard' ||
|
||||
leaf === 'triggerFieldGrouping' ||
|
||||
leaf === 'markAudioCard'
|
||||
) {
|
||||
return 'Mining & Clipboard';
|
||||
}
|
||||
if (
|
||||
leaf === 'toggleVisibleOverlayGlobal' ||
|
||||
leaf === 'toggleSubtitleSidebar' ||
|
||||
leaf === 'toggleSecondarySub' ||
|
||||
leaf === 'toggleStatsOverlay' ||
|
||||
leaf === 'markWatched'
|
||||
) {
|
||||
return 'Toggle & Visibility';
|
||||
}
|
||||
if (
|
||||
leaf === 'openCharacterDictionary' ||
|
||||
leaf === 'openRuntimeOptions' ||
|
||||
leaf === 'openJimaku' ||
|
||||
leaf === 'openSessionHelp' ||
|
||||
leaf === 'openControllerSelect' ||
|
||||
leaf === 'openControllerDebug'
|
||||
) {
|
||||
return 'Open Panels';
|
||||
}
|
||||
if (leaf === 'triggerSubsync') return 'Playback';
|
||||
return undefined;
|
||||
}
|
||||
return undefined;
|
||||
}
|
||||
|
||||
@@ -418,9 +543,9 @@ function compareFields(a: ConfigSettingsField, b: ConfigSettingsField): number {
|
||||
const sectionName = a.section.localeCompare(b.section);
|
||||
if (sectionName !== 0) return sectionName;
|
||||
|
||||
const subsection =
|
||||
(SUBSECTION_ORDER.get(a.subsection ?? '') ?? Number.MAX_SAFE_INTEGER) -
|
||||
(SUBSECTION_ORDER.get(b.subsection ?? '') ?? Number.MAX_SAFE_INTEGER);
|
||||
const aSubOrder = a.subsection === undefined ? -1 : (SUBSECTION_ORDER.get(a.subsection) ?? Number.MAX_SAFE_INTEGER);
|
||||
const bSubOrder = b.subsection === undefined ? -1 : (SUBSECTION_ORDER.get(b.subsection) ?? Number.MAX_SAFE_INTEGER);
|
||||
const subsection = aSubOrder - bSubOrder;
|
||||
if (subsection !== 0) return subsection;
|
||||
|
||||
const subsectionName = (a.subsection ?? '').localeCompare(b.subsection ?? '');
|
||||
@@ -446,7 +571,9 @@ function restartBehaviorForPath(path: string): ConfigSettingsRestartBehavior {
|
||||
pathStartsWith(path, 'subtitleStyle') ||
|
||||
pathStartsWith(path, 'subtitleSidebar') ||
|
||||
path === 'secondarySub.defaultMode' ||
|
||||
pathStartsWith(path, 'ankiConnect.ai')
|
||||
pathStartsWith(path, 'ankiConnect.ai') ||
|
||||
path === 'stats.toggleKey' ||
|
||||
path === 'stats.markWatchedKey'
|
||||
) {
|
||||
return 'hot-reload';
|
||||
}
|
||||
@@ -474,6 +601,7 @@ function fieldForLeaf(leaf: Leaf): ConfigSettingsField {
|
||||
leaf.path.startsWith('immersionTracking.retention.') ||
|
||||
leaf.path.startsWith('youtubeSubgen.'),
|
||||
secret: SECRET_PATHS.has(leaf.path),
|
||||
settingsHidden: SUBTITLE_CSS_MANAGED_CONFIG_PATHS.has(leaf.path),
|
||||
};
|
||||
}
|
||||
|
||||
|
||||
+2
-2
@@ -2583,7 +2583,7 @@ function shouldInitializeMecabForAnnotations(): boolean {
|
||||
const config = getResolvedConfig();
|
||||
const nPlusOneEnabled = getRuntimeBooleanOption(
|
||||
'subtitle.annotation.nPlusOne',
|
||||
config.ankiConnect.knownWords.highlightEnabled,
|
||||
config.ankiConnect.nPlusOne.enabled,
|
||||
);
|
||||
const jlptEnabled = getRuntimeBooleanOption(
|
||||
'subtitle.annotation.jlpt',
|
||||
@@ -4227,7 +4227,7 @@ const {
|
||||
getNPlusOneEnabled: () =>
|
||||
getRuntimeBooleanOption(
|
||||
'subtitle.annotation.nPlusOne',
|
||||
getResolvedConfig().ankiConnect.knownWords.highlightEnabled,
|
||||
getResolvedConfig().ankiConnect.nPlusOne.enabled,
|
||||
),
|
||||
getMinSentenceWordsForNPlusOne: () =>
|
||||
getResolvedConfig().ankiConnect.nPlusOne.minSentenceWords,
|
||||
|
||||
@@ -426,6 +426,55 @@ test('applySubtitleStyle stores secondary background styles in hover-aware css v
|
||||
}
|
||||
});
|
||||
|
||||
test('applySubtitleStyle applies primary and secondary css declaration objects', () => {
|
||||
const restoreDocument = installFakeDocument();
|
||||
try {
|
||||
const subtitleRoot = new FakeElement('div');
|
||||
const subtitleContainer = new FakeElement('div');
|
||||
const secondarySubRoot = new FakeElement('div');
|
||||
const secondarySubContainer = new FakeElement('div');
|
||||
const ctx = {
|
||||
state: createRendererState(),
|
||||
dom: {
|
||||
subtitleRoot,
|
||||
subtitleContainer,
|
||||
secondarySubRoot,
|
||||
secondarySubContainer,
|
||||
},
|
||||
} as never;
|
||||
|
||||
const renderer = createSubtitleRenderer(ctx);
|
||||
renderer.applySubtitleStyle({
|
||||
fontSize: 35,
|
||||
css: {
|
||||
'font-size': '42px',
|
||||
'text-wrap': 'balance',
|
||||
'--subtitle-outline': '1px',
|
||||
},
|
||||
secondary: {
|
||||
fontSize: 24,
|
||||
css: {
|
||||
'font-size': '28px',
|
||||
'text-transform': 'uppercase',
|
||||
},
|
||||
},
|
||||
} as never);
|
||||
|
||||
const primaryValues = (subtitleRoot.style as unknown as { values?: Map<string, string> })
|
||||
.values;
|
||||
const secondaryValues = (secondarySubRoot.style as unknown as { values?: Map<string, string> })
|
||||
.values;
|
||||
|
||||
assert.equal(primaryValues?.get('font-size'), '42px');
|
||||
assert.equal(primaryValues?.get('text-wrap'), 'balance');
|
||||
assert.equal(primaryValues?.get('--subtitle-outline'), '1px');
|
||||
assert.equal(secondaryValues?.get('font-size'), '28px');
|
||||
assert.equal(secondaryValues?.get('text-transform'), 'uppercase');
|
||||
} finally {
|
||||
restoreDocument();
|
||||
}
|
||||
});
|
||||
|
||||
test('annotated subtitle tokens inherit configured base subtitle typography', () => {
|
||||
const restoreDocument = installFakeDocument();
|
||||
try {
|
||||
|
||||
@@ -158,6 +158,32 @@ function applyInlineStyleDeclarations(
|
||||
}
|
||||
}
|
||||
|
||||
function normalizeCssDeclarationObject(value: unknown): Record<string, string> {
|
||||
if (!value || typeof value !== 'object' || Array.isArray(value)) {
|
||||
return {};
|
||||
}
|
||||
|
||||
const declarations: Record<string, string> = {};
|
||||
for (const [key, rawValue] of Object.entries(value)) {
|
||||
if (typeof rawValue !== 'string') continue;
|
||||
const cssValue = rawValue.trim();
|
||||
if (cssValue.length > 0) declarations[key] = cssValue;
|
||||
}
|
||||
return declarations;
|
||||
}
|
||||
|
||||
function applySubtitleCssDeclarations(
|
||||
root: HTMLElement,
|
||||
container: HTMLElement,
|
||||
declarations: Record<string, string>,
|
||||
): void {
|
||||
applyInlineStyleDeclarations(root, declarations, CONTAINER_STYLE_KEYS);
|
||||
applyInlineStyleDeclarations(
|
||||
container,
|
||||
pickInlineStyleDeclarations(declarations, CONTAINER_STYLE_KEYS),
|
||||
);
|
||||
}
|
||||
|
||||
function pickInlineStyleDeclarations(
|
||||
declarations: Record<string, unknown>,
|
||||
includedKeys: ReadonlySet<string>,
|
||||
@@ -172,7 +198,9 @@ function pickInlineStyleDeclarations(
|
||||
|
||||
const CONTAINER_STYLE_KEYS = new Set<string>([
|
||||
'background',
|
||||
'background-color',
|
||||
'backgroundColor',
|
||||
'backdrop-filter',
|
||||
'backdropFilter',
|
||||
'WebkitBackdropFilter',
|
||||
'webkitBackdropFilter',
|
||||
@@ -180,7 +208,7 @@ const CONTAINER_STYLE_KEYS = new Set<string>([
|
||||
]);
|
||||
|
||||
function resolveSecondaryBackgroundColor(declarations: Record<string, unknown>): string {
|
||||
for (const key of ['backgroundColor', 'background']) {
|
||||
for (const key of ['backgroundColor', 'background-color', 'background']) {
|
||||
const value = declarations[key];
|
||||
if (typeof value === 'string' && value.trim().length > 0) {
|
||||
return value.trim();
|
||||
@@ -193,6 +221,7 @@ function resolveSecondaryBackgroundColor(declarations: Record<string, unknown>):
|
||||
function resolveSecondaryBackdropFilter(declarations: Record<string, unknown>): string {
|
||||
for (const key of [
|
||||
'backdropFilter',
|
||||
'backdrop-filter',
|
||||
'WebkitBackdropFilter',
|
||||
'webkitBackdropFilter',
|
||||
'-webkit-backdrop-filter',
|
||||
@@ -762,20 +791,26 @@ export function createSubtitleRenderer(ctx: RendererContext) {
|
||||
'--subtitle-frequency-band-5-color',
|
||||
frequencyBandedColors[4],
|
||||
);
|
||||
applySubtitleCssDeclarations(
|
||||
ctx.dom.subtitleRoot,
|
||||
ctx.dom.subtitleContainer,
|
||||
normalizeCssDeclarationObject(style.css),
|
||||
);
|
||||
|
||||
const secondaryStyle = style.secondary;
|
||||
if (!secondaryStyle) return;
|
||||
|
||||
const secondaryStyleDeclarations = secondaryStyle as Record<string, unknown>;
|
||||
const secondaryCssDeclarations = normalizeCssDeclarationObject(secondaryStyle.css);
|
||||
applyInlineStyleDeclarations(
|
||||
ctx.dom.secondarySubRoot,
|
||||
secondaryStyleDeclarations,
|
||||
CONTAINER_STYLE_KEYS,
|
||||
);
|
||||
const secondaryContainerStyleDeclarations = pickInlineStyleDeclarations(
|
||||
secondaryStyleDeclarations,
|
||||
CONTAINER_STYLE_KEYS,
|
||||
);
|
||||
const secondaryContainerStyleDeclarations = {
|
||||
...pickInlineStyleDeclarations(secondaryStyleDeclarations, CONTAINER_STYLE_KEYS),
|
||||
...pickInlineStyleDeclarations(secondaryCssDeclarations, CONTAINER_STYLE_KEYS),
|
||||
};
|
||||
ctx.dom.secondarySubContainer.style.setProperty(
|
||||
'--secondary-sub-background-color',
|
||||
resolveSecondaryBackgroundColor(secondaryContainerStyleDeclarations),
|
||||
@@ -800,6 +835,11 @@ export function createSubtitleRenderer(ctx: RendererContext) {
|
||||
if (secondaryStyle.fontStyle) {
|
||||
ctx.dom.secondarySubRoot.style.fontStyle = secondaryStyle.fontStyle;
|
||||
}
|
||||
applySubtitleCssDeclarations(
|
||||
ctx.dom.secondarySubRoot,
|
||||
ctx.dom.secondarySubContainer,
|
||||
secondaryCssDeclarations,
|
||||
);
|
||||
}
|
||||
|
||||
return {
|
||||
|
||||
@@ -33,7 +33,6 @@
|
||||
placeholder="Search"
|
||||
aria-label="Search settings"
|
||||
/>
|
||||
<button id="openFileButton" class="secondary-button" type="button">Open File</button>
|
||||
<button id="saveButton" class="primary-button" type="button" disabled>Save</button>
|
||||
</div>
|
||||
</header>
|
||||
|
||||
@@ -0,0 +1,31 @@
|
||||
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', () => {
|
||||
assert.equal(
|
||||
ankiControls.selectPreferredNoteFieldModelName(['Lapis Morph', 'Kiku'], 'Lapis Morph'),
|
||||
'Kiku',
|
||||
);
|
||||
});
|
||||
|
||||
test('note field model preference falls back to Lapis when Kiku is unavailable', () => {
|
||||
assert.equal(
|
||||
ankiControls.selectPreferredNoteFieldModelName(['Mining', 'Lapis Morph'], 'Lapis Morph'),
|
||||
'Lapis Morph',
|
||||
);
|
||||
});
|
||||
|
||||
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 stays blank when no Kiku or Lapis note type exists', () => {
|
||||
assert.equal(
|
||||
ankiControls.selectPreferredNoteFieldModelName(['Basic', 'Mining'], 'Lapis Morph'),
|
||||
'',
|
||||
);
|
||||
});
|
||||
@@ -17,6 +17,7 @@ const state: {
|
||||
modelFieldNamesErrors: Map<string, string>;
|
||||
noteFieldModelName: string;
|
||||
ankiConnectUrl: string;
|
||||
noteFieldModelNameManuallySelected: boolean;
|
||||
} = {
|
||||
deckNames: null,
|
||||
deckNamesLoading: false,
|
||||
@@ -32,6 +33,7 @@ const state: {
|
||||
modelFieldNamesErrors: new Map(),
|
||||
noteFieldModelName: '',
|
||||
ankiConnectUrl: '',
|
||||
noteFieldModelNameManuallySelected: false,
|
||||
};
|
||||
|
||||
let requestRender = (): void => undefined;
|
||||
@@ -42,11 +44,32 @@ export function configureAnkiControls(options: { requestRender: () => void }): v
|
||||
|
||||
export function initializeAnkiControls(values: Record<string, ConfigSettingsSnapshotValue>): void {
|
||||
const configuredNoteType = values['ankiConnect.isLapis.sentenceCardModel'];
|
||||
if (!state.noteFieldModelName && typeof configuredNoteType === 'string') {
|
||||
if (
|
||||
!state.noteFieldModelName &&
|
||||
!state.noteFieldModelNameManuallySelected &&
|
||||
typeof configuredNoteType === 'string'
|
||||
) {
|
||||
state.noteFieldModelName = configuredNoteType;
|
||||
}
|
||||
}
|
||||
|
||||
export function selectPreferredNoteFieldModelName(
|
||||
modelNames: readonly string[],
|
||||
_currentModelName = '',
|
||||
): string {
|
||||
const exactKiku = modelNames.find((name) => name.toLowerCase() === 'kiku');
|
||||
if (exactKiku) {
|
||||
return exactKiku;
|
||||
}
|
||||
|
||||
const lapis = modelNames.find((name) => name.toLowerCase().includes('lapis'));
|
||||
if (lapis) {
|
||||
return lapis;
|
||||
}
|
||||
|
||||
return '';
|
||||
}
|
||||
|
||||
function normalizeStringArray(value: unknown): string[] {
|
||||
return Array.isArray(value)
|
||||
? value.filter((entry): entry is string => typeof entry === 'string' && entry.length > 0)
|
||||
@@ -168,8 +191,11 @@ async function loadAnkiModelNames(draftUrl?: string): Promise<void> {
|
||||
if (result.ok) {
|
||||
state.modelNames = uniqueSorted(result.values);
|
||||
state.modelNamesError = null;
|
||||
if (!state.noteFieldModelName && state.modelNames[0]) {
|
||||
state.noteFieldModelName = state.modelNames[0];
|
||||
if (!state.noteFieldModelNameManuallySelected) {
|
||||
state.noteFieldModelName = selectPreferredNoteFieldModelName(
|
||||
state.modelNames,
|
||||
state.noteFieldModelName,
|
||||
);
|
||||
}
|
||||
} else {
|
||||
state.modelNames = [];
|
||||
@@ -318,6 +344,7 @@ export function renderNoteFieldModelPicker(context: SettingsControlContext): HTM
|
||||
select.value = state.noteFieldModelName;
|
||||
select.addEventListener('change', () => {
|
||||
state.noteFieldModelName = select.value;
|
||||
state.noteFieldModelNameManuallySelected = true;
|
||||
requestRender();
|
||||
});
|
||||
control.append(select);
|
||||
|
||||
@@ -2,6 +2,7 @@ import type { ConfigSettingsField, ConfigSettingsSnapshotValue } from '../types/
|
||||
|
||||
export interface SettingsControlContext {
|
||||
setFieldError(path: string, message: string | null): void;
|
||||
resetDraftPath(path: string, defaultValue?: ConfigSettingsSnapshotValue): void;
|
||||
updateDraft(path: string, value: ConfigSettingsSnapshotValue): void;
|
||||
valueForField(field: ConfigSettingsField): ConfigSettingsSnapshotValue;
|
||||
valueForPath(path: string): ConfigSettingsSnapshotValue | undefined;
|
||||
|
||||
@@ -10,12 +10,23 @@ import {
|
||||
} from './settings-anki-controls';
|
||||
import type { SettingsControlContext } from './settings-control-context';
|
||||
import { createElement, isSecretSnapshotValue } from './settings-control-dom';
|
||||
import { renderKeyboardInput, renderMpvKeybindingsInput } from './settings-keybinding-controls';
|
||||
import {
|
||||
configureKeybindingControls,
|
||||
renderKeyboardInput,
|
||||
renderMpvKeybindingsInput,
|
||||
} from './settings-keybinding-controls';
|
||||
import {
|
||||
getSubtitleCssManagedConfigPaths,
|
||||
getSubtitleCssScopeForPath,
|
||||
parseSubtitleCssDeclarations,
|
||||
serializeSubtitleCssDeclarations,
|
||||
} from './subtitle-style-css';
|
||||
|
||||
export { renderNoteFieldModelPicker };
|
||||
|
||||
export function configureSettingsControls(options: { requestRender: () => void }): void {
|
||||
configureAnkiControls(options);
|
||||
configureKeybindingControls(options);
|
||||
}
|
||||
|
||||
export function initializeSettingsControls(
|
||||
@@ -90,6 +101,44 @@ function renderStringListInput(
|
||||
return textarea;
|
||||
}
|
||||
|
||||
function renderCssDeclarationsInput(
|
||||
context: SettingsControlContext,
|
||||
field: ConfigSettingsField,
|
||||
): HTMLElement {
|
||||
const scope = getSubtitleCssScopeForPath(field.configPath);
|
||||
const textarea = createElement(
|
||||
'textarea',
|
||||
'config-textarea css-declarations',
|
||||
) as HTMLTextAreaElement;
|
||||
textarea.spellcheck = false;
|
||||
if (!scope) return textarea;
|
||||
|
||||
const managedPaths = getSubtitleCssManagedConfigPaths(scope);
|
||||
const values: Record<string, ConfigSettingsSnapshotValue | undefined> = {
|
||||
[field.configPath]: context.valueForPath(field.configPath),
|
||||
};
|
||||
for (const path of managedPaths) {
|
||||
values[path] = context.valueForPath(path);
|
||||
}
|
||||
textarea.value = serializeSubtitleCssDeclarations(scope, values);
|
||||
textarea.addEventListener('input', () => {
|
||||
const parsed = parseSubtitleCssDeclarations(textarea.value);
|
||||
if (!parsed.ok) {
|
||||
textarea.classList.add('invalid');
|
||||
context.setFieldError(field.configPath, parsed.error);
|
||||
return;
|
||||
}
|
||||
|
||||
textarea.classList.remove('invalid');
|
||||
context.setFieldError(field.configPath, null);
|
||||
context.updateDraft(field.configPath, parsed.declarations);
|
||||
for (const path of managedPaths) {
|
||||
context.resetDraftPath(path, undefined);
|
||||
}
|
||||
});
|
||||
return textarea;
|
||||
}
|
||||
|
||||
export function renderControl(
|
||||
field: ConfigSettingsField,
|
||||
context: SettingsControlContext,
|
||||
@@ -134,7 +183,13 @@ export function renderControl(
|
||||
if (field.control === 'number') {
|
||||
const input = createElement('input', 'config-input') as HTMLInputElement;
|
||||
input.type = 'number';
|
||||
input.value = typeof value === 'number' ? String(value) : '';
|
||||
const numericValue =
|
||||
typeof value === 'number'
|
||||
? value
|
||||
: typeof field.defaultValue === 'number'
|
||||
? field.defaultValue
|
||||
: NaN;
|
||||
input.value = Number.isFinite(numericValue) ? String(numericValue) : '';
|
||||
input.addEventListener('input', () => {
|
||||
const next = parseOptionalNumberInputValue(input.value);
|
||||
if (next.ok) {
|
||||
@@ -174,6 +229,10 @@ export function renderControl(
|
||||
return renderJsonInput(context, field, value);
|
||||
}
|
||||
|
||||
if (field.control === 'css-declarations') {
|
||||
return renderCssDeclarationsInput(context, field);
|
||||
}
|
||||
|
||||
if (field.control === 'textarea') {
|
||||
const textarea = createElement('textarea', 'config-textarea compact') as HTMLTextAreaElement;
|
||||
textarea.spellcheck = false;
|
||||
|
||||
@@ -12,6 +12,11 @@ import type { SettingsControlContext } from './settings-control-context';
|
||||
import { createElement } from './settings-control-dom';
|
||||
|
||||
let activeKeyLearningStop: (() => void) | null = null;
|
||||
let requestRender = (): void => undefined;
|
||||
|
||||
export function configureKeybindingControls(options: { requestRender: () => void }): void {
|
||||
requestRender = options.requestRender;
|
||||
}
|
||||
|
||||
function startKeyLearning(
|
||||
button: HTMLButtonElement,
|
||||
@@ -107,7 +112,8 @@ export function renderMpvKeybindingsInput(
|
||||
const rows = createMpvKeybindingRows(DEFAULT_KEYBINDINGS, context.valueForField(field));
|
||||
const container = createElement('div', 'keybinding-editor');
|
||||
|
||||
for (const row of rows) {
|
||||
for (let i = 0; i < rows.length; i++) {
|
||||
const row = rows[i]!;
|
||||
const item = createElement('div', 'keybinding-row');
|
||||
const keyButton = renderKeyLearnButton(row.key, 'dom-code', (next) => {
|
||||
row.key = next;
|
||||
@@ -130,9 +136,27 @@ export function renderMpvKeybindingsInput(
|
||||
row.commandText = command.value;
|
||||
applyMpvRows(context, field, rows);
|
||||
});
|
||||
item.append(keyButton, command);
|
||||
const removeButton = createElement('button', 'reset-button icon-button') as HTMLButtonElement;
|
||||
removeButton.type = 'button';
|
||||
removeButton.textContent = 'Remove';
|
||||
removeButton.addEventListener('click', () => {
|
||||
rows.splice(i, 1);
|
||||
applyMpvRows(context, field, rows);
|
||||
requestRender();
|
||||
});
|
||||
item.append(keyButton, command, removeButton);
|
||||
container.append(item);
|
||||
}
|
||||
|
||||
const addButton = createElement('button', 'secondary-button compact-button') as HTMLButtonElement;
|
||||
addButton.type = 'button';
|
||||
addButton.textContent = 'Add Binding';
|
||||
addButton.addEventListener('click', () => {
|
||||
rows.push({ defaultKey: '', key: '', command: null, commandText: '', isDefault: false });
|
||||
applyMpvRows(context, field, rows);
|
||||
requestRender();
|
||||
});
|
||||
container.append(addButton);
|
||||
|
||||
return container;
|
||||
}
|
||||
|
||||
@@ -0,0 +1,11 @@
|
||||
import test from 'node:test';
|
||||
import assert from 'node:assert/strict';
|
||||
import fs from 'node:fs';
|
||||
import path from 'node:path';
|
||||
|
||||
test('settings toolbar does not expose an open file button', () => {
|
||||
const html = fs.readFileSync(path.join(process.cwd(), 'src/settings/index.html'), 'utf8');
|
||||
|
||||
assert.equal(html.includes('id="openFileButton"'), false);
|
||||
assert.equal(html.includes('Open File'), false);
|
||||
});
|
||||
@@ -4,6 +4,7 @@ import {
|
||||
createSettingsDraft,
|
||||
filterSettingsFields,
|
||||
setDraftValue,
|
||||
resetDraftPath,
|
||||
getDirtyOperations,
|
||||
} from './settings-model';
|
||||
import type { ConfigSettingsField } from '../types/settings';
|
||||
@@ -31,6 +32,18 @@ const fields: ConfigSettingsField[] = [
|
||||
defaultValue: true,
|
||||
restartBehavior: 'restart',
|
||||
},
|
||||
{
|
||||
id: 'subtitleStyle.fontSize',
|
||||
label: 'Font Size',
|
||||
description: 'Hidden behind CSS editor.',
|
||||
configPath: 'subtitleStyle.fontSize',
|
||||
category: 'appearance',
|
||||
section: 'Primary Subtitle Appearance',
|
||||
control: 'number',
|
||||
defaultValue: 35,
|
||||
restartBehavior: 'hot-reload',
|
||||
settingsHidden: true,
|
||||
},
|
||||
];
|
||||
|
||||
test('filterSettingsFields searches label, section, and config path', () => {
|
||||
@@ -41,6 +54,16 @@ test('filterSettingsFields searches label, section, and config path', () => {
|
||||
['subtitleStyle.autoPauseVideoOnHover'],
|
||||
);
|
||||
assert.deepEqual(filterSettingsFields(fields, { category: 'behavior', query: 'anki' }), []);
|
||||
assert.deepEqual(
|
||||
filterSettingsFields(fields, { query: 'anki' }).map((field) => field.configPath),
|
||||
['ankiConnect.enabled'],
|
||||
);
|
||||
assert.deepEqual(
|
||||
filterSettingsFields(fields, { query: 'pause hover' }).map((field) => field.configPath),
|
||||
['subtitleStyle.autoPauseVideoOnHover'],
|
||||
);
|
||||
assert.deepEqual(filterSettingsFields(fields, { query: 'pause anki' }), []);
|
||||
assert.deepEqual(filterSettingsFields(fields, { category: 'appearance', query: '' }), []);
|
||||
});
|
||||
|
||||
test('settings draft tracks dirty set and emits save operations', () => {
|
||||
@@ -60,3 +83,25 @@ test('settings draft tracks dirty set and emits save operations', () => {
|
||||
setDraftValue(draft, 'subtitleStyle.autoPauseVideoOnHover', true);
|
||||
assert.deepEqual(getDirtyOperations(draft), []);
|
||||
});
|
||||
|
||||
test('settings draft emits reset operations for css-editor-owned legacy style paths', () => {
|
||||
const draft = createSettingsDraft({
|
||||
'subtitleStyle.css': {},
|
||||
'subtitleStyle.fontSize': 35,
|
||||
});
|
||||
|
||||
setDraftValue(draft, 'subtitleStyle.css', { 'font-size': '42px' });
|
||||
resetDraftPath(draft, 'subtitleStyle.fontSize', undefined);
|
||||
|
||||
assert.deepEqual(getDirtyOperations(draft), [
|
||||
{
|
||||
op: 'set',
|
||||
path: 'subtitleStyle.css',
|
||||
value: { 'font-size': '42px' },
|
||||
},
|
||||
{
|
||||
op: 'reset',
|
||||
path: 'subtitleStyle.fontSize',
|
||||
},
|
||||
]);
|
||||
});
|
||||
|
||||
@@ -6,7 +6,7 @@ import type {
|
||||
} from '../types/settings';
|
||||
|
||||
export interface SettingsFilter {
|
||||
category: ConfigSettingsCategory;
|
||||
category?: ConfigSettingsCategory;
|
||||
query?: string;
|
||||
}
|
||||
|
||||
@@ -20,6 +20,15 @@ function normalizeQuery(query: string | undefined): string {
|
||||
return (query ?? '').trim().toLowerCase();
|
||||
}
|
||||
|
||||
function searchableText(parts: Array<string | undefined>): string {
|
||||
return parts
|
||||
.filter(Boolean)
|
||||
.join(' ')
|
||||
.replace(/([a-z0-9])([A-Z])/g, '$1 $2')
|
||||
.replace(/[^a-zA-Z0-9]+/g, ' ')
|
||||
.toLowerCase();
|
||||
}
|
||||
|
||||
function valuesEqual(a: unknown, b: unknown): boolean {
|
||||
return JSON.stringify(a) === JSON.stringify(b);
|
||||
}
|
||||
@@ -29,24 +38,26 @@ export function filterSettingsFields(
|
||||
filter: SettingsFilter,
|
||||
): ConfigSettingsField[] {
|
||||
const query = normalizeQuery(filter.query);
|
||||
const terms = query.length > 0 ? query.split(/\s+/) : [];
|
||||
return fields.filter((field) => {
|
||||
if (field.category !== filter.category || field.legacyHidden) {
|
||||
if (field.legacyHidden || field.settingsHidden) {
|
||||
return false;
|
||||
}
|
||||
if (filter.category && field.category !== filter.category) {
|
||||
return false;
|
||||
}
|
||||
if (!query) {
|
||||
return true;
|
||||
}
|
||||
const haystack = [
|
||||
const haystack = searchableText([
|
||||
field.label,
|
||||
field.description,
|
||||
field.configPath,
|
||||
field.section,
|
||||
field.subsection ?? '',
|
||||
field.enumValues?.join(' ') ?? '',
|
||||
]
|
||||
.join(' ')
|
||||
.toLowerCase();
|
||||
return haystack.includes(query);
|
||||
]);
|
||||
return terms.every((term) => haystack.includes(term));
|
||||
});
|
||||
}
|
||||
|
||||
|
||||
+57
-21
@@ -20,6 +20,7 @@ import {
|
||||
setDraftValue,
|
||||
type SettingsDraft,
|
||||
} from './settings-model';
|
||||
import { getSubtitleCssManagedConfigPaths, getSubtitleCssScopeForPath } from './subtitle-style-css';
|
||||
|
||||
declare global {
|
||||
interface Window {
|
||||
@@ -76,7 +77,6 @@ const dom = {
|
||||
categoryTitle: getElement<HTMLHeadingElement>('categoryTitle'),
|
||||
categoryMeta: getElement<HTMLElement>('categoryMeta'),
|
||||
searchInput: getElement<HTMLInputElement>('searchInput'),
|
||||
openFileButton: getElement<HTMLButtonElement>('openFileButton'),
|
||||
saveButton: getElement<HTMLButtonElement>('saveButton'),
|
||||
statusBanner: getElement<HTMLElement>('statusBanner'),
|
||||
warningsPanel: getElement<HTMLElement>('warningsPanel'),
|
||||
@@ -163,6 +163,13 @@ function updateDraft(path: string, value: ConfigSettingsSnapshotValue): void {
|
||||
syncSaveButton();
|
||||
}
|
||||
|
||||
function resetDraftPathContext(path: string, defaultValue?: ConfigSettingsSnapshotValue): void {
|
||||
if (!state.draft) return;
|
||||
resetDraftPath(state.draft, path, defaultValue);
|
||||
state.inputErrors.delete(path);
|
||||
syncSaveButton();
|
||||
}
|
||||
|
||||
function renderWarnings(snapshot: ConfigSettingsSnapshot): void {
|
||||
dom.warningsPanel.replaceChildren();
|
||||
if (snapshot.warnings.length === 0) {
|
||||
@@ -192,7 +199,7 @@ function renderCategoryNav(snapshot: ConfigSettingsSnapshot): void {
|
||||
dom.categoryNav.replaceChildren();
|
||||
for (const category of CATEGORY_ORDER) {
|
||||
const count = snapshot.fields.filter(
|
||||
(field) => field.category === category && !field.legacyHidden,
|
||||
(field) => field.category === category && !field.legacyHidden && !field.settingsHidden,
|
||||
).length;
|
||||
if (count === 0) continue;
|
||||
const button = createElement('button', 'category-button') as HTMLButtonElement;
|
||||
@@ -206,6 +213,7 @@ function renderCategoryNav(snapshot: ConfigSettingsSnapshot): void {
|
||||
button.addEventListener('click', () => {
|
||||
state.category = category;
|
||||
render();
|
||||
dom.settingsContent.scrollTop = 0;
|
||||
});
|
||||
dom.categoryNav.append(button);
|
||||
}
|
||||
@@ -222,7 +230,13 @@ function renderField(field: ConfigSettingsField): HTMLElement {
|
||||
|
||||
const controlWrap = createElement('div', 'field-control');
|
||||
controlWrap.append(
|
||||
renderControl(field, { setFieldError, updateDraft, valueForField, valueForPath }),
|
||||
renderControl(field, {
|
||||
setFieldError,
|
||||
resetDraftPath: resetDraftPathContext,
|
||||
updateDraft,
|
||||
valueForField,
|
||||
valueForPath,
|
||||
}),
|
||||
);
|
||||
const resetButton = createElement('button', 'reset-button') as HTMLButtonElement;
|
||||
resetButton.type = 'button';
|
||||
@@ -230,6 +244,12 @@ function renderField(field: ConfigSettingsField): HTMLElement {
|
||||
resetButton.addEventListener('click', () => {
|
||||
if (!state.draft) return;
|
||||
resetDraftPath(state.draft, field.configPath, field.defaultValue);
|
||||
const cssScope = getSubtitleCssScopeForPath(field.configPath);
|
||||
if (cssScope) {
|
||||
for (const path of getSubtitleCssManagedConfigPaths(cssScope)) {
|
||||
resetDraftPath(state.draft, path, undefined);
|
||||
}
|
||||
}
|
||||
state.inputErrors.delete(field.configPath);
|
||||
render();
|
||||
});
|
||||
@@ -240,13 +260,24 @@ function renderField(field: ConfigSettingsField): HTMLElement {
|
||||
|
||||
function renderSettingsContent(snapshot: ConfigSettingsSnapshot): void {
|
||||
dom.settingsContent.replaceChildren();
|
||||
const query = state.query.trim();
|
||||
const fields = filterSettingsFields(snapshot.fields, {
|
||||
category: state.category,
|
||||
query: state.query,
|
||||
category: query ? undefined : state.category,
|
||||
query,
|
||||
});
|
||||
|
||||
dom.categoryTitle.textContent = CATEGORY_LABELS[state.category];
|
||||
dom.categoryMeta.textContent = `${fields.length} setting${fields.length === 1 ? '' : 's'}`;
|
||||
if (query) {
|
||||
const categoryCount = new Set(fields.map((field) => field.category)).size;
|
||||
dom.categoryTitle.textContent = 'Search results';
|
||||
dom.categoryMeta.textContent = `${fields.length} setting${fields.length === 1 ? '' : 's'}${
|
||||
categoryCount > 0
|
||||
? ` across ${categoryCount} categor${categoryCount === 1 ? 'y' : 'ies'}`
|
||||
: ''
|
||||
}`;
|
||||
} else {
|
||||
dom.categoryTitle.textContent = CATEGORY_LABELS[state.category];
|
||||
dom.categoryMeta.textContent = `${fields.length} setting${fields.length === 1 ? '' : 's'}`;
|
||||
}
|
||||
|
||||
if (fields.length === 0) {
|
||||
const empty = createElement('div', 'empty-state');
|
||||
@@ -255,25 +286,35 @@ function renderSettingsContent(snapshot: ConfigSettingsSnapshot): void {
|
||||
return;
|
||||
}
|
||||
|
||||
const sections = new Map<string, ConfigSettingsField[]>();
|
||||
const sections = new Map<
|
||||
string,
|
||||
{ title: string; rawSection: string; fields: ConfigSettingsField[] }
|
||||
>();
|
||||
for (const field of fields) {
|
||||
const sectionFields = sections.get(field.section) ?? [];
|
||||
sectionFields.push(field);
|
||||
sections.set(field.section, sectionFields);
|
||||
const title = query ? `${CATEGORY_LABELS[field.category]} / ${field.section}` : field.section;
|
||||
const section = sections.get(title) ?? { title, rawSection: field.section, fields: [] };
|
||||
section.fields.push(field);
|
||||
sections.set(title, section);
|
||||
}
|
||||
|
||||
for (const [section, sectionFields] of sections) {
|
||||
for (const section of sections.values()) {
|
||||
const sectionEl = createElement('section', 'settings-section');
|
||||
const title = createElement('h2');
|
||||
title.textContent = section;
|
||||
title.textContent = section.title;
|
||||
sectionEl.append(title);
|
||||
if (section === 'Note Fields') {
|
||||
if (section.rawSection === 'Note Fields') {
|
||||
sectionEl.append(
|
||||
renderNoteFieldModelPicker({ setFieldError, updateDraft, valueForField, valueForPath }),
|
||||
renderNoteFieldModelPicker({
|
||||
setFieldError,
|
||||
resetDraftPath: resetDraftPathContext,
|
||||
updateDraft,
|
||||
valueForField,
|
||||
valueForPath,
|
||||
}),
|
||||
);
|
||||
}
|
||||
let currentSubsection = '';
|
||||
for (const field of sectionFields) {
|
||||
for (const field of section.fields) {
|
||||
if (field.subsection && field.subsection !== currentSubsection) {
|
||||
currentSubsection = field.subsection;
|
||||
const subsectionTitle = createElement('h3', 'settings-subsection-title');
|
||||
@@ -353,11 +394,6 @@ dom.searchInput.addEventListener('input', () => {
|
||||
dom.saveButton.addEventListener('click', () => {
|
||||
void save();
|
||||
});
|
||||
dom.openFileButton.addEventListener('click', () => {
|
||||
void window.configSettingsAPI.openSettingsFile().catch((error) => {
|
||||
setStatus(error instanceof Error ? error.message : 'Failed to open settings file', 'error');
|
||||
});
|
||||
});
|
||||
|
||||
void loadSnapshot().catch((error) => {
|
||||
setStatus(error instanceof Error ? error.message : 'Failed to load settings', 'error');
|
||||
|
||||
@@ -262,7 +262,7 @@ h1 {
|
||||
}
|
||||
|
||||
.search-input {
|
||||
width: 210px;
|
||||
width: min(360px, 34vw);
|
||||
height: 36px;
|
||||
padding: 0 12px;
|
||||
}
|
||||
@@ -296,6 +296,12 @@ h1 {
|
||||
min-height: 86px;
|
||||
}
|
||||
|
||||
.config-textarea.css-declarations {
|
||||
width: min(560px, 100%);
|
||||
min-height: 188px;
|
||||
tab-size: 2;
|
||||
}
|
||||
|
||||
.search-input:hover,
|
||||
.config-input:hover,
|
||||
.config-textarea:hover {
|
||||
@@ -675,7 +681,7 @@ code {
|
||||
|
||||
.keybinding-row {
|
||||
display: grid;
|
||||
grid-template-columns: minmax(146px, 0.78fr) minmax(220px, 1.22fr);
|
||||
grid-template-columns: minmax(146px, 0.78fr) minmax(180px, 1.22fr) auto;
|
||||
gap: 8px;
|
||||
align-items: start;
|
||||
}
|
||||
|
||||
@@ -0,0 +1,88 @@
|
||||
import test from 'node:test';
|
||||
import assert from 'node:assert/strict';
|
||||
|
||||
import {
|
||||
getSubtitleCssManagedConfigPaths,
|
||||
parseSubtitleCssDeclarations,
|
||||
serializeSubtitleCssDeclarations,
|
||||
} from './subtitle-style-css';
|
||||
|
||||
test('serializeSubtitleCssDeclarations builds primary CSS from config minus default colors', () => {
|
||||
const css = serializeSubtitleCssDeclarations('primary', {
|
||||
'subtitleStyle.fontFamily': 'M PLUS 1, sans-serif',
|
||||
'subtitleStyle.fontSize': 35,
|
||||
'subtitleStyle.fontColor': '#cad3f5',
|
||||
'subtitleStyle.backgroundColor': 'transparent',
|
||||
'subtitleStyle.textShadow': '0 2px 6px rgba(0,0,0,0.9)',
|
||||
'subtitleStyle.css': {
|
||||
filter: 'drop-shadow(0 0 8px #000)',
|
||||
'--subtitle-outline': '1px',
|
||||
},
|
||||
});
|
||||
|
||||
assert.match(css, /font-family: M PLUS 1, sans-serif;/);
|
||||
assert.match(css, /font-size: 35px;/);
|
||||
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', () => {
|
||||
const css = serializeSubtitleCssDeclarations('secondary', {
|
||||
'subtitleStyle.secondary.fontFamily': 'Noto Sans, sans-serif',
|
||||
'subtitleStyle.secondary.fontSize': 24,
|
||||
'subtitleStyle.secondary.fontColor': '#cad3f5',
|
||||
'subtitleStyle.secondary.backgroundColor': 'transparent',
|
||||
'subtitleStyle.secondary.css': {
|
||||
'text-transform': 'uppercase',
|
||||
},
|
||||
});
|
||||
|
||||
assert.match(css, /font-family: Noto Sans, sans-serif;/);
|
||||
assert.match(css, /font-size: 24px;/);
|
||||
assert.match(css, /text-transform: uppercase;/);
|
||||
assert.doesNotMatch(css, /^color:/m);
|
||||
assert.doesNotMatch(css, /^background-color:/m);
|
||||
});
|
||||
|
||||
test('parseSubtitleCssDeclarations accepts arbitrary declaration properties', () => {
|
||||
assert.deepEqual(
|
||||
parseSubtitleCssDeclarations(`
|
||||
font-size: 40px;
|
||||
text-wrap: balance;
|
||||
-webkit-text-stroke: 1px black;
|
||||
--subtitle-outline: 1px;
|
||||
`),
|
||||
{
|
||||
ok: true,
|
||||
declarations: {
|
||||
'font-size': '40px',
|
||||
'text-wrap': 'balance',
|
||||
'-webkit-text-stroke': '1px black',
|
||||
'--subtitle-outline': '1px',
|
||||
},
|
||||
},
|
||||
);
|
||||
});
|
||||
|
||||
test('parseSubtitleCssDeclarations rejects selectors and malformed declarations', () => {
|
||||
assert.equal(parseSubtitleCssDeclarations('#subtitleRoot { font-size: 40px; }').ok, false);
|
||||
assert.equal(parseSubtitleCssDeclarations('font-size 40px;').ok, false);
|
||||
});
|
||||
|
||||
test('getSubtitleCssManagedConfigPaths excludes color controls', () => {
|
||||
assert.ok(getSubtitleCssManagedConfigPaths('primary').includes('subtitleStyle.fontSize'));
|
||||
assert.ok(
|
||||
getSubtitleCssManagedConfigPaths('secondary').includes('subtitleStyle.secondary.fontSize'),
|
||||
);
|
||||
assert.equal(
|
||||
getSubtitleCssManagedConfigPaths('primary').includes('subtitleStyle.fontColor'),
|
||||
false,
|
||||
);
|
||||
assert.equal(
|
||||
getSubtitleCssManagedConfigPaths('secondary').includes('subtitleStyle.secondary.fontColor'),
|
||||
false,
|
||||
);
|
||||
});
|
||||
@@ -0,0 +1,302 @@
|
||||
import type { ConfigSettingsSnapshotValue } from '../types/settings';
|
||||
|
||||
export type SubtitleCssScope = 'primary' | 'secondary';
|
||||
|
||||
type LegacyCssDeclaration = {
|
||||
property: string;
|
||||
primaryPath: string;
|
||||
secondaryPath: string;
|
||||
format?: (value: unknown) => string | undefined;
|
||||
};
|
||||
|
||||
export type SubtitleCssParseResult =
|
||||
| { ok: true; declarations: Record<string, string> }
|
||||
| { ok: false; error: string };
|
||||
|
||||
const LEGACY_CSS_DECLARATIONS: LegacyCssDeclaration[] = [
|
||||
{
|
||||
property: 'font-family',
|
||||
primaryPath: 'subtitleStyle.fontFamily',
|
||||
secondaryPath: 'subtitleStyle.secondary.fontFamily',
|
||||
},
|
||||
{
|
||||
property: 'font-size',
|
||||
primaryPath: 'subtitleStyle.fontSize',
|
||||
secondaryPath: 'subtitleStyle.secondary.fontSize',
|
||||
format: formatCssLengthLikeValue,
|
||||
},
|
||||
{
|
||||
property: 'font-weight',
|
||||
primaryPath: 'subtitleStyle.fontWeight',
|
||||
secondaryPath: 'subtitleStyle.secondary.fontWeight',
|
||||
},
|
||||
{
|
||||
property: 'font-style',
|
||||
primaryPath: 'subtitleStyle.fontStyle',
|
||||
secondaryPath: 'subtitleStyle.secondary.fontStyle',
|
||||
},
|
||||
{
|
||||
property: 'line-height',
|
||||
primaryPath: 'subtitleStyle.lineHeight',
|
||||
secondaryPath: 'subtitleStyle.secondary.lineHeight',
|
||||
},
|
||||
{
|
||||
property: 'letter-spacing',
|
||||
primaryPath: 'subtitleStyle.letterSpacing',
|
||||
secondaryPath: 'subtitleStyle.secondary.letterSpacing',
|
||||
},
|
||||
{
|
||||
property: 'word-spacing',
|
||||
primaryPath: 'subtitleStyle.wordSpacing',
|
||||
secondaryPath: 'subtitleStyle.secondary.wordSpacing',
|
||||
},
|
||||
{
|
||||
property: 'font-kerning',
|
||||
primaryPath: 'subtitleStyle.fontKerning',
|
||||
secondaryPath: 'subtitleStyle.secondary.fontKerning',
|
||||
},
|
||||
{
|
||||
property: 'text-rendering',
|
||||
primaryPath: 'subtitleStyle.textRendering',
|
||||
secondaryPath: 'subtitleStyle.secondary.textRendering',
|
||||
},
|
||||
{
|
||||
property: 'text-shadow',
|
||||
primaryPath: 'subtitleStyle.textShadow',
|
||||
secondaryPath: 'subtitleStyle.secondary.textShadow',
|
||||
},
|
||||
{
|
||||
property: 'backdrop-filter',
|
||||
primaryPath: 'subtitleStyle.backdropFilter',
|
||||
secondaryPath: 'subtitleStyle.secondary.backdropFilter',
|
||||
},
|
||||
{
|
||||
property: 'color',
|
||||
primaryPath: 'subtitleStyle.fontColor',
|
||||
secondaryPath: 'subtitleStyle.secondary.fontColor',
|
||||
},
|
||||
{
|
||||
property: 'background-color',
|
||||
primaryPath: 'subtitleStyle.backgroundColor',
|
||||
secondaryPath: 'subtitleStyle.secondary.backgroundColor',
|
||||
},
|
||||
];
|
||||
|
||||
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';
|
||||
}
|
||||
|
||||
export function getSubtitleCssManagedConfigPaths(scope: SubtitleCssScope): string[] {
|
||||
return LEGACY_CSS_DECLARATIONS.map((declaration) =>
|
||||
scope === 'primary' ? declaration.primaryPath : declaration.secondaryPath,
|
||||
);
|
||||
}
|
||||
|
||||
export function getSubtitleCssScopeForPath(path: string): SubtitleCssScope | null {
|
||||
if (path === 'subtitleStyle.css') return 'primary';
|
||||
if (path === 'subtitleStyle.secondary.css') return 'secondary';
|
||||
return null;
|
||||
}
|
||||
|
||||
export function serializeSubtitleCssDeclarations(
|
||||
scope: SubtitleCssScope,
|
||||
values: Record<string, ConfigSettingsSnapshotValue | undefined>,
|
||||
): string {
|
||||
const declarations = new Map<string, string>();
|
||||
|
||||
for (const declaration of LEGACY_CSS_DECLARATIONS) {
|
||||
const path = scope === 'primary' ? declaration.primaryPath : declaration.secondaryPath;
|
||||
const formatted = (declaration.format ?? formatCssPrimitiveValue)(values[path]);
|
||||
if (formatted !== undefined) {
|
||||
declarations.set(declaration.property, formatted);
|
||||
}
|
||||
}
|
||||
|
||||
const cssObject = normalizeCssDeclarationRecord(values[getSubtitleCssPath(scope)]);
|
||||
for (const [property, value] of Object.entries(cssObject)) {
|
||||
declarations.set(normalizeCssPropertyName(property), value);
|
||||
}
|
||||
|
||||
return [...declarations.entries()]
|
||||
.map(([property, value]) => `${property}: ${value};`)
|
||||
.join('\n');
|
||||
}
|
||||
|
||||
export function parseSubtitleCssDeclarations(text: string): SubtitleCssParseResult {
|
||||
const trimmed = text.trim();
|
||||
if (trimmed.length === 0) {
|
||||
return { ok: true, declarations: {} };
|
||||
}
|
||||
|
||||
if (/[{}]/.test(trimmed)) {
|
||||
return {
|
||||
ok: false,
|
||||
error: 'Enter CSS declarations only, without selectors or braces.',
|
||||
};
|
||||
}
|
||||
|
||||
const declarations: Record<string, string> = {};
|
||||
for (const rawDeclaration of splitCssDeclarations(trimmed)) {
|
||||
const declaration = rawDeclaration.trim();
|
||||
if (declaration.length === 0) continue;
|
||||
|
||||
const colonIndex = findTopLevelColon(declaration);
|
||||
if (colonIndex <= 0) {
|
||||
return { ok: false, error: `Invalid CSS declaration: ${declaration}` };
|
||||
}
|
||||
|
||||
const property = normalizeCssPropertyName(declaration.slice(0, colonIndex).trim());
|
||||
const value = declaration.slice(colonIndex + 1).trim();
|
||||
if (!CSS_PROPERTY_PATTERN.test(property)) {
|
||||
return { ok: false, error: `Invalid CSS property: ${property}` };
|
||||
}
|
||||
if (value.length === 0) {
|
||||
return { ok: false, error: `Missing CSS value for ${property}.` };
|
||||
}
|
||||
|
||||
declarations[property] = value;
|
||||
}
|
||||
|
||||
return { ok: true, declarations };
|
||||
}
|
||||
|
||||
function normalizeCssDeclarationRecord(value: unknown): Record<string, string> {
|
||||
if (!value || typeof value !== 'object' || Array.isArray(value)) {
|
||||
return {};
|
||||
}
|
||||
|
||||
const declarations: Record<string, string> = {};
|
||||
for (const [property, rawValue] of Object.entries(value)) {
|
||||
if (typeof rawValue !== 'string') continue;
|
||||
const trimmed = rawValue.trim();
|
||||
if (trimmed.length === 0) continue;
|
||||
declarations[property] = trimmed;
|
||||
}
|
||||
return declarations;
|
||||
}
|
||||
|
||||
function normalizeCssPropertyName(property: string): string {
|
||||
const trimmed = property.trim();
|
||||
if (trimmed.startsWith('--')) return trimmed;
|
||||
if (trimmed.includes('-')) return trimmed.toLowerCase();
|
||||
|
||||
const kebab = trimmed
|
||||
.replace(/([a-z0-9])([A-Z])/g, '$1-$2')
|
||||
.replace(/^Webkit-/, '-webkit-')
|
||||
.toLowerCase();
|
||||
return kebab.startsWith('webkit-') ? `-${kebab}` : kebab;
|
||||
}
|
||||
|
||||
function formatCssLengthLikeValue(value: unknown): string | undefined {
|
||||
if (typeof value === 'number' && Number.isFinite(value)) {
|
||||
return `${value}px`;
|
||||
}
|
||||
return formatCssPrimitiveValue(value);
|
||||
}
|
||||
|
||||
function formatCssPrimitiveValue(value: unknown): string | undefined {
|
||||
if (value === null || value === undefined || typeof value === 'object') {
|
||||
return undefined;
|
||||
}
|
||||
|
||||
const text = String(value).trim();
|
||||
return text.length > 0 ? text : undefined;
|
||||
}
|
||||
|
||||
function splitCssDeclarations(text: string): string[] {
|
||||
const declarations: string[] = [];
|
||||
let current = '';
|
||||
let quote: '"' | "'" | null = null;
|
||||
let parenDepth = 0;
|
||||
let escaping = false;
|
||||
|
||||
for (const char of text) {
|
||||
if (escaping) {
|
||||
current += char;
|
||||
escaping = false;
|
||||
continue;
|
||||
}
|
||||
|
||||
if (char === '\\') {
|
||||
current += char;
|
||||
escaping = true;
|
||||
continue;
|
||||
}
|
||||
|
||||
if (quote) {
|
||||
current += char;
|
||||
if (char === quote) quote = null;
|
||||
continue;
|
||||
}
|
||||
|
||||
if (char === '"' || char === "'") {
|
||||
current += char;
|
||||
quote = char;
|
||||
continue;
|
||||
}
|
||||
|
||||
if (char === '(') {
|
||||
parenDepth += 1;
|
||||
current += char;
|
||||
continue;
|
||||
}
|
||||
|
||||
if (char === ')') {
|
||||
parenDepth = Math.max(0, parenDepth - 1);
|
||||
current += char;
|
||||
continue;
|
||||
}
|
||||
|
||||
if (char === ';' && parenDepth === 0) {
|
||||
declarations.push(current);
|
||||
current = '';
|
||||
continue;
|
||||
}
|
||||
|
||||
current += char;
|
||||
}
|
||||
|
||||
declarations.push(current);
|
||||
return declarations;
|
||||
}
|
||||
|
||||
function findTopLevelColon(text: string): number {
|
||||
let quote: '"' | "'" | null = null;
|
||||
let parenDepth = 0;
|
||||
let escaping = false;
|
||||
|
||||
for (let i = 0; i < text.length; i += 1) {
|
||||
const char = text[i];
|
||||
if (escaping) {
|
||||
escaping = false;
|
||||
continue;
|
||||
}
|
||||
if (char === '\\') {
|
||||
escaping = true;
|
||||
continue;
|
||||
}
|
||||
if (quote) {
|
||||
if (char === quote) quote = null;
|
||||
continue;
|
||||
}
|
||||
if (char === '"' || char === "'") {
|
||||
quote = char;
|
||||
continue;
|
||||
}
|
||||
if (char === '(') {
|
||||
parenDepth += 1;
|
||||
continue;
|
||||
}
|
||||
if (char === ')') {
|
||||
parenDepth = Math.max(0, parenDepth - 1);
|
||||
continue;
|
||||
}
|
||||
if (char === ':' && parenDepth === 0) {
|
||||
return i;
|
||||
}
|
||||
}
|
||||
|
||||
return -1;
|
||||
}
|
||||
@@ -86,6 +86,7 @@ export interface AnkiConnectConfig {
|
||||
color?: string;
|
||||
};
|
||||
nPlusOne?: {
|
||||
enabled?: boolean;
|
||||
nPlusOne?: string;
|
||||
minSentenceWords?: number;
|
||||
};
|
||||
|
||||
@@ -214,6 +214,7 @@ export interface ResolvedConfig {
|
||||
decks: Record<string, string[]>;
|
||||
};
|
||||
nPlusOne: {
|
||||
enabled: boolean;
|
||||
minSentenceWords: number;
|
||||
};
|
||||
behavior: {
|
||||
|
||||
@@ -26,7 +26,8 @@ export type ConfigSettingsControl =
|
||||
| 'anki-note-type'
|
||||
| 'anki-field'
|
||||
| 'mpv-keybindings'
|
||||
| 'color-list';
|
||||
| 'color-list'
|
||||
| 'css-declarations';
|
||||
|
||||
export type ConfigSettingsRestartBehavior = 'hot-reload' | 'restart';
|
||||
|
||||
@@ -45,6 +46,7 @@ export interface ConfigSettingsField {
|
||||
advanced?: boolean;
|
||||
secret?: boolean;
|
||||
legacyHidden?: boolean;
|
||||
settingsHidden?: boolean;
|
||||
}
|
||||
|
||||
export type ConfigSettingsSnapshotValue = unknown;
|
||||
|
||||
@@ -70,6 +70,7 @@ export type FrequencyDictionaryMatchMode = 'headword' | 'surface';
|
||||
|
||||
export interface SubtitleStyleConfig {
|
||||
primaryDefaultMode?: PrimarySubMode;
|
||||
css?: Record<string, string>;
|
||||
enableJlpt?: boolean;
|
||||
preserveLineBreaks?: boolean;
|
||||
autoPauseVideoOnHover?: boolean;
|
||||
@@ -110,6 +111,7 @@ export interface SubtitleStyleConfig {
|
||||
bandedColors?: [string, string, string, string, string];
|
||||
};
|
||||
secondary?: {
|
||||
css?: Record<string, string>;
|
||||
fontFamily?: string;
|
||||
fontSize?: number;
|
||||
fontColor?: string;
|
||||
|
||||
Reference in New Issue
Block a user