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:
2026-05-17 04:13:02 -07:00
parent 3447103857
commit 81830b3372
39 changed files with 1147 additions and 86 deletions
-1
View File
@@ -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'),
'',
);
});
+30 -3
View File
@@ -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);
+1
View File
@@ -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;
+61 -2
View File
@@ -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;
+26 -2
View File
@@ -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;
}
+11
View File
@@ -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);
});
+45
View File
@@ -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',
},
]);
});
+18 -7
View File
@@ -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
View File
@@ -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');
+8 -2
View File
@@ -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;
}
+88
View File
@@ -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,
);
});
+302
View File
@@ -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;
}