mirror of
https://github.com/ksyasuda/SubMiner.git
synced 2026-05-27 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:
@@ -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;
|
||||
}
|
||||
Reference in New Issue
Block a user