mirror of
https://github.com/ksyasuda/SubMiner.git
synced 2026-05-26 00:55:16 -07:00
166015897d
- Replace `--config`/`subminer config` (no action) with `--settings`/`subminer settings` - `subminer config` now requires an explicit action (`path` or `show`) - `--settings` previously opened Yomitan; replaced by `--yomitan` - Linux tray update installs AppImage via electron-updater instead of manual flow - macOS update dialog activation and curl-fetch routing fixes - Delete stale compiled artifacts (main.js, app-updater.js)
267 lines
8.7 KiB
TypeScript
267 lines
8.7 KiB
TypeScript
import type { ConfigSettingsField, ConfigSettingsSnapshotValue } from '../types/settings';
|
|
import { toConfigDraftValue, toSettingsDisplayValue } from './settings-model';
|
|
import { parseOptionalNumberInputValue } from './input-values';
|
|
import {
|
|
configureAnkiControls,
|
|
initializeAnkiControls,
|
|
renderAnkiFieldInput,
|
|
renderAnkiNoteTypeInput,
|
|
renderKnownWordsDecksInput,
|
|
renderNoteFieldModelPicker,
|
|
} from './settings-anki-controls';
|
|
import type { SettingsControlContext } from './settings-control-context';
|
|
import { createElement, isSecretSnapshotValue } from './settings-control-dom';
|
|
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(
|
|
values: Record<string, ConfigSettingsSnapshotValue>,
|
|
): void {
|
|
initializeAnkiControls(values);
|
|
}
|
|
|
|
function renderColorListInput(
|
|
context: SettingsControlContext,
|
|
field: ConfigSettingsField,
|
|
value: ConfigSettingsSnapshotValue,
|
|
): HTMLElement {
|
|
const colors = Array.isArray(value) ? (value as string[]) : [];
|
|
const container = createElement('div', 'color-list');
|
|
for (let i = 0; i < colors.length; i++) {
|
|
const row = createElement('div', 'color-list-row');
|
|
const label = createElement('span', 'color-list-label');
|
|
label.textContent = `Band ${i + 1}`;
|
|
const input = createElement('input', 'config-input') as HTMLInputElement;
|
|
input.type = 'color';
|
|
input.value = colors[i] ?? '#000000';
|
|
input.addEventListener('input', () => {
|
|
const updated = [...colors];
|
|
updated[i] = input.value;
|
|
context.updateDraft(field.configPath, updated);
|
|
});
|
|
row.append(label, input);
|
|
container.append(row);
|
|
}
|
|
return container;
|
|
}
|
|
|
|
function renderJsonInput(
|
|
context: SettingsControlContext,
|
|
field: ConfigSettingsField,
|
|
value: ConfigSettingsSnapshotValue,
|
|
): HTMLElement {
|
|
const textarea = createElement('textarea', 'config-textarea') as HTMLTextAreaElement;
|
|
textarea.spellcheck = false;
|
|
textarea.value = JSON.stringify(value ?? {}, null, 2);
|
|
textarea.addEventListener('input', () => {
|
|
try {
|
|
context.updateDraft(field.configPath, JSON.parse(textarea.value));
|
|
textarea.classList.remove('invalid');
|
|
context.setFieldError(field.configPath, null);
|
|
} catch {
|
|
textarea.classList.add('invalid');
|
|
context.setFieldError(field.configPath, 'Invalid JSON');
|
|
}
|
|
});
|
|
return textarea;
|
|
}
|
|
|
|
function renderStringListInput(
|
|
context: SettingsControlContext,
|
|
field: ConfigSettingsField,
|
|
value: ConfigSettingsSnapshotValue,
|
|
): HTMLElement {
|
|
const textarea = createElement('textarea', 'config-textarea compact') as HTMLTextAreaElement;
|
|
textarea.spellcheck = false;
|
|
textarea.value = Array.isArray(value) ? value.join('\n') : '';
|
|
textarea.addEventListener('input', () => {
|
|
context.updateDraft(
|
|
field.configPath,
|
|
textarea.value
|
|
.split('\n')
|
|
.map((entry) => entry.trim())
|
|
.filter(Boolean),
|
|
);
|
|
});
|
|
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,
|
|
): HTMLElement {
|
|
const value = toSettingsDisplayValue(field.configPath, context.valueForField(field));
|
|
|
|
if (field.control === 'keyboard-shortcut') {
|
|
return renderKeyboardInput(context, field, 'accelerator');
|
|
}
|
|
|
|
if (field.control === 'key-code') {
|
|
return renderKeyboardInput(context, field, 'code');
|
|
}
|
|
|
|
if (field.control === 'mpv-key') {
|
|
return renderKeyboardInput(context, field, 'mpv-key');
|
|
}
|
|
|
|
if (field.control === 'known-words-decks') {
|
|
return renderKnownWordsDecksInput(context, field);
|
|
}
|
|
|
|
if (field.control === 'anki-note-type') {
|
|
return renderAnkiNoteTypeInput(context, field);
|
|
}
|
|
|
|
if (field.control === 'anki-field') {
|
|
return renderAnkiFieldInput(context, field);
|
|
}
|
|
|
|
if (field.control === 'mpv-keybindings') {
|
|
return renderMpvKeybindingsInput(context, field);
|
|
}
|
|
|
|
if (field.control === 'boolean') {
|
|
const label = createElement('label', 'switch-control');
|
|
const input = createElement('input') as HTMLInputElement;
|
|
input.type = 'checkbox';
|
|
input.checked = Boolean(value);
|
|
input.addEventListener('change', () => context.updateDraft(field.configPath, input.checked));
|
|
const track = createElement('span', 'switch-track');
|
|
label.append(input, track);
|
|
return label;
|
|
}
|
|
|
|
if (field.control === 'number') {
|
|
const input = createElement('input', 'config-input') as HTMLInputElement;
|
|
input.type = 'number';
|
|
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) {
|
|
input.classList.remove('invalid');
|
|
context.setFieldError(field.configPath, null);
|
|
context.updateDraft(field.configPath, toConfigDraftValue(field.configPath, next.value));
|
|
} else {
|
|
input.classList.add('invalid');
|
|
context.setFieldError(field.configPath, 'Invalid number');
|
|
}
|
|
});
|
|
return input;
|
|
}
|
|
|
|
if (field.control === 'select') {
|
|
const select = createElement('select', 'config-input') as HTMLSelectElement;
|
|
for (const enumValue of field.enumValues ?? []) {
|
|
const option = createElement('option') as HTMLOptionElement;
|
|
option.value = enumValue;
|
|
option.textContent = enumValue;
|
|
option.selected = enumValue === value;
|
|
select.append(option);
|
|
}
|
|
select.addEventListener('change', () => context.updateDraft(field.configPath, select.value));
|
|
return select;
|
|
}
|
|
|
|
if (field.control === 'color-list') {
|
|
return renderColorListInput(context, field, value);
|
|
}
|
|
|
|
if (field.control === 'string-list') {
|
|
return renderStringListInput(context, field, value);
|
|
}
|
|
|
|
if (field.control === 'json') {
|
|
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;
|
|
textarea.value = typeof value === 'string' ? value : '';
|
|
textarea.addEventListener('input', () => context.updateDraft(field.configPath, textarea.value));
|
|
return textarea;
|
|
}
|
|
|
|
const input = createElement('input', 'config-input') as HTMLInputElement;
|
|
input.type = field.control === 'secret' ? 'password' : field.control;
|
|
if (field.control === 'secret') {
|
|
input.placeholder =
|
|
isSecretSnapshotValue(value) && value.configured ? 'Configured' : 'Not configured';
|
|
input.addEventListener('input', () => {
|
|
if (input.value.trim().length === 0) {
|
|
context.updateDraft(field.configPath, value);
|
|
return;
|
|
}
|
|
context.updateDraft(field.configPath, input.value);
|
|
});
|
|
} else {
|
|
input.value = typeof value === 'string' ? value : '';
|
|
input.addEventListener('input', () => context.updateDraft(field.configPath, input.value));
|
|
}
|
|
return input;
|
|
}
|