Split SwarmUI portraits into a model catalog and layered prompts.
Picker is Swarm intersect config, preset style/shot inherit model defaults, and living saves lift the old preset shape.
This commit is contained in:
@@ -44,8 +44,13 @@ const ru = {
|
||||
settingsSampler: 'Sampler',
|
||||
settingsScheduler: 'Scheduler',
|
||||
settingsSeed: 'Seed',
|
||||
settingsPositive: 'Базовый positive',
|
||||
settingsNegative: 'Negative',
|
||||
settingsStyle: 'Стиль',
|
||||
settingsPresetNegative: 'Negative пресета',
|
||||
settingsModelDefaults: 'Дефолты модели',
|
||||
settingsModelMissing: 'Этой модели нет в каталоге — выберите другую.',
|
||||
settingsModelPositive: 'Базовый positive модели',
|
||||
settingsModelNegative: 'Базовый negative модели',
|
||||
settingsOverrides: 'Настройки генерации пресета',
|
||||
settingsPositiveLoras: 'Positive LoRA',
|
||||
settingsNegativeLoras: 'Negative LoRA',
|
||||
settingsAddLora: 'Добавить LoRA',
|
||||
@@ -54,7 +59,7 @@ const ru = {
|
||||
settingsFullBodyPreset: 'В полный рост',
|
||||
settingsWidth: 'Ширина',
|
||||
settingsHeight: 'Высота',
|
||||
settingsKindPositive: 'Positive для вида',
|
||||
settingsShotType: 'Тип кадра',
|
||||
settingsAgeRules: 'Правила по возрасту',
|
||||
settingsAgeMin: 'От',
|
||||
settingsAgeMax: 'До',
|
||||
@@ -417,8 +422,13 @@ const en: Messages = {
|
||||
settingsSampler: 'Sampler',
|
||||
settingsScheduler: 'Scheduler',
|
||||
settingsSeed: 'Seed',
|
||||
settingsPositive: 'Base positive',
|
||||
settingsNegative: 'Negative',
|
||||
settingsStyle: 'Style',
|
||||
settingsPresetNegative: 'Preset negative',
|
||||
settingsModelDefaults: 'Model defaults',
|
||||
settingsModelMissing: 'This model is not in the catalog — pick another.',
|
||||
settingsModelPositive: 'Model base positive',
|
||||
settingsModelNegative: 'Model base negative',
|
||||
settingsOverrides: 'Preset generation overrides',
|
||||
settingsPositiveLoras: 'Positive LoRA',
|
||||
settingsNegativeLoras: 'Negative LoRA',
|
||||
settingsAddLora: 'Add LoRA',
|
||||
@@ -427,7 +437,7 @@ const en: Messages = {
|
||||
settingsFullBodyPreset: 'Full body',
|
||||
settingsWidth: 'Width',
|
||||
settingsHeight: 'Height',
|
||||
settingsKindPositive: 'Kind positive',
|
||||
settingsShotType: 'Shot type',
|
||||
settingsAgeRules: 'Age rules',
|
||||
settingsAgeMin: 'From',
|
||||
settingsAgeMax: 'To',
|
||||
|
||||
@@ -497,7 +497,7 @@ export async function fetchChangelog(since?: string | null): Promise<ChangelogRe
|
||||
export interface SwarmUiKindPreset {
|
||||
width: number;
|
||||
height: number;
|
||||
positive: string;
|
||||
shotType: string;
|
||||
}
|
||||
|
||||
export interface SwarmUiLoraEntry {
|
||||
@@ -505,10 +505,9 @@ export interface SwarmUiLoraEntry {
|
||||
weight: number;
|
||||
}
|
||||
|
||||
export interface SwarmUiPresetDefinition {
|
||||
export interface SwarmUiModelDefinition {
|
||||
id: string;
|
||||
label: string;
|
||||
model: string;
|
||||
steps: number;
|
||||
cfgScale: number;
|
||||
clipSkip: number;
|
||||
@@ -519,6 +518,22 @@ export interface SwarmUiPresetDefinition {
|
||||
negative: string;
|
||||
positiveLoras: SwarmUiLoraEntry[];
|
||||
negativeLoras: SwarmUiLoraEntry[];
|
||||
}
|
||||
|
||||
export interface SwarmUiPresetDefinition {
|
||||
id: string;
|
||||
label: string;
|
||||
model: string;
|
||||
style: string;
|
||||
negative: string;
|
||||
steps?: number | null;
|
||||
cfgScale?: number | null;
|
||||
clipSkip?: number | null;
|
||||
sampler?: string | null;
|
||||
scheduler?: string | null;
|
||||
seed?: number | null;
|
||||
positiveLoras?: SwarmUiLoraEntry[] | null;
|
||||
negativeLoras?: SwarmUiLoraEntry[] | null;
|
||||
avatar: SwarmUiKindPreset;
|
||||
custom: SwarmUiKindPreset;
|
||||
fullBody: SwarmUiKindPreset;
|
||||
@@ -532,6 +547,7 @@ export interface SwarmUiAgeRule {
|
||||
|
||||
export interface SwarmUiSettingsFile {
|
||||
activePresetId: string;
|
||||
models: SwarmUiModelDefinition[];
|
||||
presets: SwarmUiPresetDefinition[];
|
||||
ageRules: SwarmUiAgeRule[];
|
||||
}
|
||||
@@ -544,6 +560,15 @@ export interface SwarmUiDiscovery {
|
||||
schedulers: readonly string[];
|
||||
}
|
||||
|
||||
export function allowedSwarmModels(settings: SwarmUiSettingsFile, discovery: SwarmUiDiscovery): string[] {
|
||||
const catalog = settings.models.map((model) => model.id);
|
||||
if (!discovery.connected || discovery.models.length === 0) {
|
||||
return catalog;
|
||||
}
|
||||
|
||||
return discovery.models.filter((name) => catalog.includes(name));
|
||||
}
|
||||
|
||||
export async function fetchSwarmUiSettings(): Promise<SwarmUiSettingsFile> {
|
||||
return request<SwarmUiSettingsFile>('/api/settings/swarmui');
|
||||
}
|
||||
|
||||
@@ -717,6 +717,30 @@ body {
|
||||
overflow: auto;
|
||||
}
|
||||
|
||||
.settings-model {
|
||||
margin-top: 16px;
|
||||
padding-top: 8px;
|
||||
border-top: 1px solid var(--border, #ddd);
|
||||
}
|
||||
|
||||
.settings-overrides {
|
||||
margin: 16px 0 8px;
|
||||
padding: 8px 12px 12px;
|
||||
border: 1px solid var(--border, #ddd);
|
||||
border-radius: 8px;
|
||||
background: var(--panel-muted, #f7f7f7);
|
||||
}
|
||||
|
||||
.settings-overrides > summary {
|
||||
cursor: pointer;
|
||||
font-weight: 600;
|
||||
font-size: 14px;
|
||||
}
|
||||
|
||||
.settings-overrides__body {
|
||||
margin-top: 12px;
|
||||
}
|
||||
|
||||
.settings-section-title {
|
||||
margin: 20px 0 8px;
|
||||
font-size: 15px;
|
||||
|
||||
@@ -114,24 +114,32 @@ describe('createSchoolDialog', () => {
|
||||
vi.mocked(fetchSwarmUiSettings).mockReset();
|
||||
vi.mocked(fetchSwarmUiSettings).mockResolvedValue({
|
||||
activePresetId: 'default',
|
||||
presets: [
|
||||
models: [
|
||||
{
|
||||
id: 'default',
|
||||
label: 'Default',
|
||||
model: 'template.safetensors',
|
||||
id: 'template.safetensors',
|
||||
label: 'Template',
|
||||
steps: 4,
|
||||
cfgScale: 1,
|
||||
clipSkip: 1,
|
||||
sampler: 'euler',
|
||||
scheduler: 'normal',
|
||||
seed: -1,
|
||||
positive: 'base',
|
||||
negative: 'neg',
|
||||
positive: '',
|
||||
negative: '',
|
||||
positiveLoras: [],
|
||||
negativeLoras: [],
|
||||
avatar: { width: 512, height: 512, positive: '' },
|
||||
custom: { width: 512, height: 512, positive: '' },
|
||||
fullBody: { width: 512, height: 512, positive: '' },
|
||||
},
|
||||
],
|
||||
presets: [
|
||||
{
|
||||
id: 'default',
|
||||
label: 'Default',
|
||||
model: 'template.safetensors',
|
||||
style: 'base',
|
||||
negative: 'neg',
|
||||
avatar: { width: 512, height: 512, shotType: '' },
|
||||
custom: { width: 512, height: 512, shotType: '' },
|
||||
fullBody: { width: 512, height: 512, shotType: '' },
|
||||
},
|
||||
],
|
||||
ageRules: [],
|
||||
|
||||
@@ -0,0 +1,175 @@
|
||||
/**
|
||||
* @vitest-environment happy-dom
|
||||
*/
|
||||
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest';
|
||||
import {
|
||||
allowedSwarmModels,
|
||||
fetchGameStatus,
|
||||
fetchSwarmUiDiscovery,
|
||||
fetchSwarmUiSettings,
|
||||
type SwarmUiDiscovery,
|
||||
type SwarmUiSettingsFile,
|
||||
} from '../net/api.ts';
|
||||
import { getLocale, setLocale } from '../i18n/locale.ts';
|
||||
import { t } from '../i18n/strings.ts';
|
||||
import { swarmUiSettingsDialog } from './swarmUiSettingsDialog.ts';
|
||||
|
||||
vi.mock('../net/api.ts', async (importOriginal) => {
|
||||
const actual = await importOriginal<typeof import('../net/api.ts')>();
|
||||
return {
|
||||
...actual,
|
||||
fetchGameStatus: vi.fn(),
|
||||
fetchSwarmUiDiscovery: vi.fn(),
|
||||
fetchSwarmUiSettings: vi.fn(),
|
||||
};
|
||||
});
|
||||
|
||||
const initialLocale = getLocale();
|
||||
|
||||
function settings(): SwarmUiSettingsFile {
|
||||
return {
|
||||
activePresetId: 'default',
|
||||
models: [
|
||||
{
|
||||
id: 'template.safetensors',
|
||||
label: 'Template',
|
||||
steps: 4,
|
||||
cfgScale: 1,
|
||||
clipSkip: 1,
|
||||
sampler: 'euler',
|
||||
scheduler: 'normal',
|
||||
seed: -1,
|
||||
positive: '',
|
||||
negative: '',
|
||||
positiveLoras: [],
|
||||
negativeLoras: [],
|
||||
},
|
||||
{
|
||||
id: 'other.safetensors',
|
||||
label: 'Other',
|
||||
steps: 8,
|
||||
cfgScale: 2,
|
||||
clipSkip: 0,
|
||||
sampler: 'euler',
|
||||
scheduler: 'normal',
|
||||
seed: 0,
|
||||
positive: '',
|
||||
negative: '',
|
||||
positiveLoras: [],
|
||||
negativeLoras: [],
|
||||
},
|
||||
],
|
||||
presets: [
|
||||
{
|
||||
id: 'default',
|
||||
label: 'Default',
|
||||
model: 'template.safetensors',
|
||||
style: 'cinematic',
|
||||
negative: 'neg',
|
||||
avatar: { width: 512, height: 512, shotType: 'close up' },
|
||||
custom: { width: 512, height: 512, shotType: '' },
|
||||
fullBody: { width: 512, height: 512, shotType: 'full body' },
|
||||
},
|
||||
],
|
||||
ageRules: [],
|
||||
};
|
||||
}
|
||||
|
||||
function discovery(overrides: Partial<SwarmUiDiscovery> = {}): SwarmUiDiscovery {
|
||||
return {
|
||||
connected: true,
|
||||
models: ['template.safetensors', 'not-in-config.safetensors'],
|
||||
loras: [],
|
||||
samplers: ['euler'],
|
||||
schedulers: ['normal'],
|
||||
...overrides,
|
||||
};
|
||||
}
|
||||
|
||||
describe('allowedSwarmModels', () => {
|
||||
it('returns the Swarm ∩ catalog intersection when connected', () => {
|
||||
expect(allowedSwarmModels(settings(), discovery())).toEqual(['template.safetensors']);
|
||||
});
|
||||
|
||||
it('returns the catalog when Swarm is offline', () => {
|
||||
expect(allowedSwarmModels(settings(), discovery({ connected: false, models: [] }))).toEqual([
|
||||
'template.safetensors',
|
||||
'other.safetensors',
|
||||
]);
|
||||
});
|
||||
});
|
||||
|
||||
describe('swarmUiSettingsDialog', () => {
|
||||
beforeEach(() => {
|
||||
setLocale('en');
|
||||
vi.mocked(fetchGameStatus).mockReset();
|
||||
vi.mocked(fetchSwarmUiDiscovery).mockReset();
|
||||
vi.mocked(fetchSwarmUiSettings).mockReset();
|
||||
vi.mocked(fetchGameStatus).mockResolvedValue({
|
||||
tick: 0,
|
||||
tickRate: 20,
|
||||
schools: 0,
|
||||
maxSchools: 2,
|
||||
connections: 1,
|
||||
swarmUiConfigured: true,
|
||||
swarmUiConnected: true,
|
||||
});
|
||||
vi.mocked(fetchSwarmUiDiscovery).mockResolvedValue(discovery());
|
||||
});
|
||||
|
||||
afterEach(() => {
|
||||
document.body.replaceChildren();
|
||||
setLocale(initialLocale);
|
||||
});
|
||||
|
||||
it('shows style, shot type, model defaults and a collapsed overrides spoiler', async () => {
|
||||
const opened = swarmUiSettingsDialog(settings());
|
||||
const dialog = await vi.waitFor(() => {
|
||||
const node = document.querySelector('dialog');
|
||||
if (node === null || ![...node.querySelectorAll('.field__label')].some((entry) => entry.textContent === t('settingsStyle'))) {
|
||||
throw new Error('settings form is not painted');
|
||||
}
|
||||
|
||||
return node;
|
||||
});
|
||||
|
||||
expect([...dialog.querySelectorAll('.field__label')].some((node) => node.textContent === t('settingsStyle'))).toBe(true);
|
||||
expect([...dialog.querySelectorAll('.field__label')].some((node) => node.textContent === t('settingsShotType'))).toBe(true);
|
||||
expect(dialog.querySelector('.settings-model')?.textContent).toContain(t('settingsModelDefaults'));
|
||||
const spoiler = dialog.querySelector('details.settings-overrides');
|
||||
if (!(spoiler instanceof HTMLDetailsElement)) {
|
||||
throw new Error('overrides spoiler is missing');
|
||||
}
|
||||
|
||||
expect(spoiler.open).toBe(false);
|
||||
expect(spoiler.querySelector('summary')?.textContent).toBe(t('settingsOverrides'));
|
||||
void opened;
|
||||
});
|
||||
|
||||
it('omits Swarm models that are not in the catalog', async () => {
|
||||
const opened = swarmUiSettingsDialog(settings());
|
||||
const dialog = await vi.waitFor(() => {
|
||||
const node = document.querySelector('dialog');
|
||||
const label = node === null
|
||||
? undefined
|
||||
: [...node.querySelectorAll('.field__label')].find((entry) => entry.textContent === t('settingsModel'));
|
||||
if (node === null || label === undefined) {
|
||||
throw new Error('model field is not painted');
|
||||
}
|
||||
|
||||
return node;
|
||||
});
|
||||
|
||||
const label = [...dialog.querySelectorAll('.field__label')].find((node) => node.textContent === t('settingsModel'));
|
||||
const select = label?.parentElement?.querySelector('select');
|
||||
if (!(select instanceof HTMLSelectElement)) {
|
||||
throw new Error('model select is missing');
|
||||
}
|
||||
|
||||
const values = [...select.options].map((option) => option.value);
|
||||
expect(values).toContain('template.safetensors');
|
||||
expect(values).not.toContain('not-in-config.safetensors');
|
||||
expect(values).not.toContain('other.safetensors');
|
||||
void opened;
|
||||
});
|
||||
});
|
||||
@@ -1,9 +1,11 @@
|
||||
import {
|
||||
allowedSwarmModels,
|
||||
fetchGameStatus,
|
||||
fetchSwarmUiDiscovery,
|
||||
fetchSwarmUiSettings,
|
||||
type SwarmUiDiscovery,
|
||||
type SwarmUiLoraEntry,
|
||||
type SwarmUiModelDefinition,
|
||||
type SwarmUiPresetDefinition,
|
||||
type SwarmUiSettingsFile,
|
||||
} from '../net/api.ts';
|
||||
@@ -196,50 +198,117 @@ export function swarmUiSettingsDialog(initial?: SwarmUiSettingsFile): Promise<Sw
|
||||
function paintPresetForm(): void {
|
||||
formHost.replaceChildren();
|
||||
const preset = currentPreset();
|
||||
if (preset === null || preset === undefined) {
|
||||
if (config === null || preset === undefined) {
|
||||
return;
|
||||
}
|
||||
|
||||
const model = currentModel(preset);
|
||||
const modelIds = allowedSwarmModels(config, discovery);
|
||||
formHost.append(
|
||||
textField(t('settingsPresetLabel'), preset.label, (value) => {
|
||||
preset.label = value;
|
||||
paintPresetSelectors();
|
||||
}),
|
||||
choiceField(t('settingsModel'), preset.model, discovery.models, (value) => {
|
||||
choiceField(t('settingsModel'), preset.model, modelIds, (value) => {
|
||||
preset.model = value;
|
||||
paintPresetForm();
|
||||
}),
|
||||
numberField(t('settingsSteps'), preset.steps, (value) => {
|
||||
preset.steps = value;
|
||||
textareaField(t('settingsStyle'), preset.style, (value) => {
|
||||
preset.style = value;
|
||||
}),
|
||||
numberField(t('settingsCfg'), preset.cfgScale, (value) => {
|
||||
preset.cfgScale = value;
|
||||
}, 0.1),
|
||||
numberField(t('settingsClipSkip'), preset.clipSkip, (value) => {
|
||||
preset.clipSkip = value;
|
||||
}),
|
||||
choiceField(t('settingsSampler'), preset.sampler, discovery.samplers, (value) => {
|
||||
preset.sampler = value;
|
||||
}),
|
||||
choiceField(t('settingsScheduler'), preset.scheduler, discovery.schedulers, (value) => {
|
||||
preset.scheduler = value;
|
||||
}),
|
||||
numberField(t('settingsSeed'), preset.seed, (value) => {
|
||||
preset.seed = value;
|
||||
}),
|
||||
textareaField(t('settingsPositive'), preset.positive, (value) => {
|
||||
preset.positive = value;
|
||||
}),
|
||||
textareaField(t('settingsNegative'), preset.negative, (value) => {
|
||||
textareaField(t('settingsPresetNegative'), preset.negative, (value) => {
|
||||
preset.negative = value;
|
||||
}),
|
||||
loraSection(t('settingsPositiveLoras'), preset.positiveLoras),
|
||||
loraSection(t('settingsNegativeLoras'), preset.negativeLoras),
|
||||
kindSection(t('settingsAvatarPreset'), preset.avatar),
|
||||
kindSection(t('settingsCustomPreset'), preset.custom),
|
||||
kindSection(t('settingsFullBodyPreset'), preset.fullBody),
|
||||
modelDefaultsSection(model),
|
||||
overrideSection(preset, model),
|
||||
);
|
||||
}
|
||||
|
||||
function currentModel(preset: SwarmUiPresetDefinition): SwarmUiModelDefinition | undefined {
|
||||
return config?.models.find((entry) => entry.id === preset.model);
|
||||
}
|
||||
|
||||
function modelDefaultsSection(model: SwarmUiModelDefinition | undefined): HTMLElement {
|
||||
const host = el('section', { class: 'settings-model' });
|
||||
host.append(el('h4', { class: 'settings-subtitle', text: t('settingsModelDefaults') }));
|
||||
if (model === undefined) {
|
||||
host.append(el('p', { class: 'hint', text: t('settingsModelMissing') }));
|
||||
return host;
|
||||
}
|
||||
|
||||
host.append(
|
||||
textareaField(t('settingsModelPositive'), model.positive, (value) => {
|
||||
model.positive = value;
|
||||
}),
|
||||
textareaField(t('settingsModelNegative'), model.negative, (value) => {
|
||||
model.negative = value;
|
||||
}),
|
||||
numberField(t('settingsSteps'), model.steps, (value) => {
|
||||
model.steps = value;
|
||||
}),
|
||||
numberField(t('settingsCfg'), model.cfgScale, (value) => {
|
||||
model.cfgScale = value;
|
||||
}, 0.1),
|
||||
numberField(t('settingsClipSkip'), model.clipSkip, (value) => {
|
||||
model.clipSkip = value;
|
||||
}),
|
||||
choiceField(t('settingsSampler'), model.sampler, discovery.samplers, (value) => {
|
||||
model.sampler = value;
|
||||
}),
|
||||
choiceField(t('settingsScheduler'), model.scheduler, discovery.schedulers, (value) => {
|
||||
model.scheduler = value;
|
||||
}),
|
||||
numberField(t('settingsSeed'), model.seed, (value) => {
|
||||
model.seed = value;
|
||||
}),
|
||||
loraSection(t('settingsPositiveLoras'), model.positiveLoras),
|
||||
loraSection(t('settingsNegativeLoras'), model.negativeLoras),
|
||||
);
|
||||
return host;
|
||||
}
|
||||
|
||||
function overrideSection(preset: SwarmUiPresetDefinition, model: SwarmUiModelDefinition | undefined): HTMLElement {
|
||||
const details = el('details', { class: 'settings-overrides' });
|
||||
details.append(el('summary', { text: t('settingsOverrides') }));
|
||||
const body = el('div', { class: 'settings-overrides__body' });
|
||||
body.append(
|
||||
nullableNumberField(t('settingsSteps'), preset.steps, model?.steps, (value) => {
|
||||
preset.steps = value;
|
||||
}),
|
||||
nullableNumberField(t('settingsCfg'), preset.cfgScale, model?.cfgScale, (value) => {
|
||||
preset.cfgScale = value;
|
||||
}, 0.1),
|
||||
nullableNumberField(t('settingsClipSkip'), preset.clipSkip, model?.clipSkip, (value) => {
|
||||
preset.clipSkip = value;
|
||||
}),
|
||||
nullableChoiceField(t('settingsSampler'), preset.sampler, model?.sampler ?? '', discovery.samplers, (value) => {
|
||||
preset.sampler = value;
|
||||
}),
|
||||
nullableChoiceField(t('settingsScheduler'), preset.scheduler, model?.scheduler ?? '', discovery.schedulers, (value) => {
|
||||
preset.scheduler = value;
|
||||
}),
|
||||
nullableNumberField(t('settingsSeed'), preset.seed, model?.seed, (value) => {
|
||||
preset.seed = value;
|
||||
}),
|
||||
);
|
||||
|
||||
const positiveLoras = preset.positiveLoras ?? [];
|
||||
const negativeLoras = preset.negativeLoras ?? [];
|
||||
preset.positiveLoras = preset.positiveLoras ?? null;
|
||||
const positiveHost = loraSection(t('settingsPositiveLoras'), positiveLoras, () => {
|
||||
preset.positiveLoras = positiveLoras;
|
||||
});
|
||||
const negativeHost = loraSection(t('settingsNegativeLoras'), negativeLoras, () => {
|
||||
preset.negativeLoras = negativeLoras;
|
||||
});
|
||||
body.append(positiveHost, negativeHost);
|
||||
details.append(body);
|
||||
return details;
|
||||
}
|
||||
|
||||
function syncActivePresetFromForm(): void {
|
||||
// Values are bound live through closures; nothing extra to scrape from DOM.
|
||||
}
|
||||
@@ -298,7 +367,7 @@ export function swarmUiSettingsDialog(initial?: SwarmUiSettingsFile): Promise<Sw
|
||||
}
|
||||
}
|
||||
|
||||
function loraSection(title: string, loras: SwarmUiLoraEntry[]): HTMLElement {
|
||||
function loraSection(title: string, loras: SwarmUiLoraEntry[], onMutate?: () => void): HTMLElement {
|
||||
const host = el('div', { class: 'settings-loras' });
|
||||
const heading = el('h4', { class: 'settings-subtitle', text: title });
|
||||
const rows = el('div', { class: 'settings-lora-rows' });
|
||||
@@ -340,6 +409,7 @@ export function swarmUiSettingsDialog(initial?: SwarmUiSettingsFile): Promise<Sw
|
||||
text: t('settingsAddLora'),
|
||||
onClick: () => {
|
||||
loras.push({ name: discovery.loras[0] ?? '', weight: 1 });
|
||||
onMutate?.();
|
||||
paintRows();
|
||||
},
|
||||
});
|
||||
@@ -359,8 +429,8 @@ export function swarmUiSettingsDialog(initial?: SwarmUiSettingsFile): Promise<Sw
|
||||
numberField(t('settingsHeight'), preset.height, (value) => {
|
||||
preset.height = value;
|
||||
}),
|
||||
textareaField(t('settingsKindPositive'), preset.positive, (value) => {
|
||||
preset.positive = value;
|
||||
textareaField(t('settingsShotType'), preset.shotType, (value) => {
|
||||
preset.shotType = value;
|
||||
}),
|
||||
);
|
||||
}
|
||||
@@ -424,3 +494,52 @@ function choiceField(
|
||||
select.addEventListener('change', () => onChange(select.value));
|
||||
return el('label', { class: 'field' }, el('span', { class: 'field__label', text: label }), select);
|
||||
}
|
||||
|
||||
function nullableNumberField(
|
||||
label: string,
|
||||
value: number | null | undefined,
|
||||
inherited: number | undefined,
|
||||
onChange: (value: number | null) => void,
|
||||
step = 1,
|
||||
): HTMLElement {
|
||||
const input = el('input', { class: 'input', type: 'number' });
|
||||
input.step = String(step);
|
||||
if (value !== null && value !== undefined) {
|
||||
input.value = String(value);
|
||||
}
|
||||
|
||||
if (inherited !== undefined) {
|
||||
input.placeholder = String(inherited);
|
||||
}
|
||||
|
||||
input.addEventListener('input', () => {
|
||||
onChange(input.value.trim() === '' ? null : Number(input.value));
|
||||
});
|
||||
return el('label', { class: 'field' }, el('span', { class: 'field__label', text: label }), input);
|
||||
}
|
||||
|
||||
function nullableChoiceField(
|
||||
label: string,
|
||||
value: string | null | undefined,
|
||||
inherited: string,
|
||||
options: readonly string[],
|
||||
onChange: (value: string | null) => void,
|
||||
): HTMLElement {
|
||||
const inherit = inherited.length > 0 ? inherited : '—';
|
||||
const values = options.filter((entry) => entry !== inherited);
|
||||
const select = el(
|
||||
'select',
|
||||
{ class: 'input' },
|
||||
el('option', { value: '', text: inherit }),
|
||||
...values.map((entry) => el('option', { value: entry, text: entry })),
|
||||
);
|
||||
if (value !== null && value !== undefined && value.length > 0 && !options.includes(value) && value !== inherited) {
|
||||
select.append(el('option', { value, text: value }));
|
||||
}
|
||||
|
||||
select.value = value !== null && value !== undefined && value.length > 0 ? value : '';
|
||||
select.addEventListener('change', () => {
|
||||
onChange(select.value === '' ? null : select.value);
|
||||
});
|
||||
return el('label', { class: 'field' }, el('span', { class: 'field__label', text: label }), select);
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user