@sequent-org/moodboard 1.4.47 → 1.4.48

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -1,5 +1,13 @@
1
1
  /**
2
- * Попап настроек чата (системный промпт, temperature, maxTokens, очистить историю).
2
+ * Попап доп-настроек генерации изображения + «Очистить историю».
3
+ *
4
+ * Секция изображения (seed, негативный промпт, фон, формат файла,
5
+ * авто-расширение, водяной знак) — поля отображаются только когда текущая
6
+ * модель изображения поддерживает соответствующую настройку.
7
+ *
8
+ * Видео-настройки в этой панели НЕ живут — они вынесены в тулбар
9
+ * (см. ChatVideoToolbarPills). Поэтому кнопка-триггер скрывается целиком,
10
+ * когда у текущей модели изображения нет ни одной поддерживаемой настройки.
3
11
  *
4
12
  * Одна ответственность: построить контент попапа, прокинуть значения
5
13
  * наружу при изменении, открывать/закрывать по триггеру.
@@ -9,7 +17,13 @@ export class ChatSettingsPopup {
9
17
  /**
10
18
  * @param {{ trigger: HTMLElement, popup: HTMLElement }} refs
11
19
  * @param {{
12
- * getSettings: () => { systemPrompt: string, temperature: number, maxTokens: number },
20
+ * getSettings: () => {
21
+ * systemPrompt: string, temperature: number, maxTokens: number,
22
+ * seed: number|null, negativePrompt: string,
23
+ * background: string|null, outputFormat: string|null,
24
+ * promptExtend: boolean, watermark: boolean
25
+ * },
26
+ * getCapability?: () => import('../../services/ai/imageModelCapabilities.js').ImageModelCapability|null,
13
27
  * onChange: (patch: object) => void,
14
28
  * onClearHistory: () => void
15
29
  * }} handlers
@@ -22,6 +36,8 @@ export class ChatSettingsPopup {
22
36
  this._docClickHandler = null;
23
37
  this._isOpen = false;
24
38
  this._fields = null;
39
+ // Обёртка секции доп-настроек — скрывается целиком, когда модель ничего не поддерживает
40
+ this._imageGenSection = null;
25
41
  }
26
42
 
27
43
  attach() {
@@ -35,15 +51,19 @@ export class ChatSettingsPopup {
35
51
  refresh() {
36
52
  if (!this._fields) return;
37
53
  const s = this._handlers.getSettings?.() || {};
38
- if (document.activeElement !== this._fields.system) {
39
- this._fields.system.value = s.systemPrompt || '';
40
- }
41
- if (document.activeElement !== this._fields.temperature) {
42
- this._fields.temperature.value = formatNumber(s.temperature);
43
- }
44
- if (document.activeElement !== this._fields.maxTokens) {
45
- this._fields.maxTokens.value = String(s.maxTokens ?? '');
46
- }
54
+
55
+ const imageVisible = this._refreshImageGenSection(s);
56
+
57
+ // Кнопка-триггер видна только когда для текущей модели есть хотя бы
58
+ // одна поддерживаемая настройка генерации изображения. Видео-настройки
59
+ // живут в тулбаре (ChatVideoToolbarPills), не в этой панели.
60
+ this._setTriggerVisible(imageVisible);
61
+ }
62
+
63
+ _setTriggerVisible(visible) {
64
+ const host = this._trigger?.parentNode || this._trigger;
65
+ if (host) host.style.display = visible ? 'inline-flex' : 'none';
66
+ if (!visible && this._isOpen) this.close();
47
67
  }
48
68
 
49
69
  toggle() {
@@ -78,58 +98,144 @@ export class ChatSettingsPopup {
78
98
  for (const off of this._listeners) off();
79
99
  this._listeners = [];
80
100
  this._fields = null;
101
+ this._imageGenSection = null;
81
102
  }
82
103
 
83
104
  _buildContent() {
84
105
  const fields = {};
85
106
  const fragment = document.createDocumentFragment();
86
107
 
87
- fragment.appendChild(this._buildField({
88
- label: 'Системный промпт',
89
- input: () => {
90
- const ta = document.createElement('textarea');
91
- ta.className = 'moodboard-chat__settings-textarea';
92
- ta.rows = 3;
93
- fields.system = ta;
94
- this._on(ta, 'change', () => this._handlers.onChange?.({ systemPrompt: ta.value }));
95
- return ta;
96
- }
97
- }));
108
+ // Секция доп-настроек генерации изображения
109
+ const imageGenSection = document.createElement('div');
110
+ imageGenSection.className = 'moodboard-chat__settings-image-section';
111
+ imageGenSection.style.display = 'none';
112
+
113
+ const sectionDivider = document.createElement('div');
114
+ sectionDivider.className = 'moodboard-chat__settings-divider';
115
+ sectionDivider.style.cssText = 'border-top:1px solid #E5E7EB;margin:2px 0;';
116
+ imageGenSection.appendChild(sectionDivider);
98
117
 
99
- fragment.appendChild(this._buildField({
100
- label: 'Temperature (0–1)',
118
+ const sectionTitle = document.createElement('div');
119
+ sectionTitle.className = 'moodboard-chat__settings-label';
120
+ sectionTitle.textContent = 'Параметры генерации';
121
+ sectionTitle.style.marginTop = '4px';
122
+ imageGenSection.appendChild(sectionTitle);
123
+
124
+ // Seed
125
+ const seedField = this._buildField({
126
+ label: 'Seed (число)',
101
127
  input: () => {
102
128
  const inp = document.createElement('input');
103
129
  inp.type = 'number';
104
- inp.step = '0.1';
105
130
  inp.min = '0';
106
- inp.max = '2';
131
+ inp.step = '1';
132
+ inp.placeholder = 'Случайный';
107
133
  inp.className = 'moodboard-chat__settings-input';
108
- fields.temperature = inp;
134
+ fields.seed = inp;
109
135
  this._on(inp, 'change', () => {
110
- const v = Number(inp.value);
111
- if (!Number.isNaN(v)) this._handlers.onChange?.({ temperature: v });
136
+ const raw = inp.value.trim();
137
+ const v = raw === '' ? null : Number.parseInt(raw, 10);
138
+ this._handlers.onChange?.({ seed: (v !== null && !Number.isNaN(v)) ? v : null });
112
139
  });
113
140
  return inp;
114
141
  }
115
- }));
142
+ });
143
+ seedField.dataset.imageGenField = 'seed';
144
+ imageGenSection.appendChild(seedField);
116
145
 
117
- fragment.appendChild(this._buildField({
118
- label: 'Max tokens',
146
+ // Негативный промпт
147
+ const negField = this._buildField({
148
+ label: 'Негативный промпт',
119
149
  input: () => {
120
150
  const inp = document.createElement('input');
121
- inp.type = 'number';
122
- inp.step = '100';
123
- inp.min = '1';
151
+ inp.type = 'text';
152
+ inp.placeholder = 'Что исключить из изображения';
124
153
  inp.className = 'moodboard-chat__settings-input';
125
- fields.maxTokens = inp;
154
+ fields.negativePrompt = inp;
126
155
  this._on(inp, 'change', () => {
127
- const v = Number.parseInt(inp.value, 10);
128
- if (!Number.isNaN(v) && v > 0) this._handlers.onChange?.({ maxTokens: v });
156
+ this._handlers.onChange?.({ negativePrompt: inp.value });
129
157
  });
130
158
  return inp;
131
159
  }
132
- }));
160
+ });
161
+ negField.dataset.imageGenField = 'negativePrompt';
162
+ imageGenSection.appendChild(negField);
163
+
164
+ // Фон (background)
165
+ const bgField = this._buildField({
166
+ label: 'Фон',
167
+ input: () => {
168
+ const sel = document.createElement('select');
169
+ sel.className = 'moodboard-chat__settings-input';
170
+ [
171
+ { value: '', label: 'По умолчанию' },
172
+ { value: 'opaque', label: 'Непрозрачный' },
173
+ { value: 'auto', label: 'Авто' }
174
+ ].forEach(({ value, label }) => {
175
+ const opt = document.createElement('option');
176
+ opt.value = value;
177
+ opt.textContent = label;
178
+ sel.appendChild(opt);
179
+ });
180
+ fields.background = sel;
181
+ this._on(sel, 'change', () => {
182
+ this._handlers.onChange?.({ background: sel.value || null });
183
+ });
184
+ return sel;
185
+ }
186
+ });
187
+ bgField.dataset.imageGenField = 'background';
188
+ imageGenSection.appendChild(bgField);
189
+
190
+ // Формат файла (outputFormat)
191
+ const fmtField = this._buildField({
192
+ label: 'Формат файла',
193
+ input: () => {
194
+ const sel = document.createElement('select');
195
+ sel.className = 'moodboard-chat__settings-input';
196
+ [
197
+ { value: '', label: 'По умолчанию' },
198
+ { value: 'png', label: 'PNG' },
199
+ { value: 'jpeg', label: 'JPEG' },
200
+ { value: 'webp', label: 'WebP' }
201
+ ].forEach(({ value, label }) => {
202
+ const opt = document.createElement('option');
203
+ opt.value = value;
204
+ opt.textContent = label;
205
+ sel.appendChild(opt);
206
+ });
207
+ fields.outputFormat = sel;
208
+ this._on(sel, 'change', () => {
209
+ this._handlers.onChange?.({ outputFormat: sel.value || null });
210
+ });
211
+ return sel;
212
+ }
213
+ });
214
+ fmtField.dataset.imageGenField = 'outputFormat';
215
+ imageGenSection.appendChild(fmtField);
216
+
217
+ // Авто-расширение промпта
218
+ const extField = this._buildCheckboxField({
219
+ label: 'Авто-расширение промпта',
220
+ key: 'promptExtend',
221
+ fields,
222
+ onChange: (v) => this._handlers.onChange?.({ promptExtend: v })
223
+ });
224
+ extField.dataset.imageGenField = 'promptExtend';
225
+ imageGenSection.appendChild(extField);
226
+
227
+ // Водяной знак
228
+ const wmField = this._buildCheckboxField({
229
+ label: 'Водяной знак',
230
+ key: 'watermark',
231
+ fields,
232
+ onChange: (v) => this._handlers.onChange?.({ watermark: v })
233
+ });
234
+ wmField.dataset.imageGenField = 'watermark';
235
+ imageGenSection.appendChild(wmField);
236
+
237
+ fragment.appendChild(imageGenSection);
238
+ this._imageGenSection = imageGenSection;
133
239
 
134
240
  const row = document.createElement('div');
135
241
  row.className = 'moodboard-chat__settings-popup-row';
@@ -148,6 +254,47 @@ export class ChatSettingsPopup {
148
254
  this._fields = fields;
149
255
  }
150
256
 
257
+ /**
258
+ * Показывает/скрывает поля секции генерации и синхронизирует значения
259
+ * в зависимости от текущих возможностей модели.
260
+ */
261
+ _refreshImageGenSection(s) {
262
+ if (!this._imageGenSection || !this._fields) return false;
263
+ const isImage = this._handlers.getContentType?.() === 'image';
264
+ const cap = isImage ? (this._handlers.getCapability?.() ?? null) : null;
265
+ const sup = cap?.supports ?? {};
266
+
267
+ const anyVisible = !!(sup.seed || sup.negativePrompt || sup.background || sup.outputFormat || sup.promptExtend || sup.watermark);
268
+ this._imageGenSection.style.display = anyVisible ? '' : 'none';
269
+
270
+ // Показываем/скрываем отдельные поля по флагам
271
+ for (const el of this._imageGenSection.querySelectorAll('[data-image-gen-field]')) {
272
+ const flag = el.dataset.imageGenField;
273
+ el.style.display = sup[flag] ? '' : 'none';
274
+ }
275
+
276
+ // Синхронизируем значения только если поле не активно
277
+ if (sup.seed && document.activeElement !== this._fields.seed) {
278
+ this._fields.seed.value = s.seed != null ? String(s.seed) : '';
279
+ }
280
+ if (sup.negativePrompt && document.activeElement !== this._fields.negativePrompt) {
281
+ this._fields.negativePrompt.value = s.negativePrompt || '';
282
+ }
283
+ if (sup.background) {
284
+ this._fields.background.value = s.background || '';
285
+ }
286
+ if (sup.outputFormat) {
287
+ this._fields.outputFormat.value = s.outputFormat || '';
288
+ }
289
+ if (sup.promptExtend && this._fields.promptExtend) {
290
+ this._fields.promptExtend.checked = Boolean(s.promptExtend);
291
+ }
292
+ if (sup.watermark && this._fields.watermark) {
293
+ this._fields.watermark.checked = Boolean(s.watermark);
294
+ }
295
+ return anyVisible;
296
+ }
297
+
151
298
  _buildField({ label, input }) {
152
299
  const wrap = document.createElement('div');
153
300
  wrap.className = 'moodboard-chat__settings-field';
@@ -159,13 +306,32 @@ export class ChatSettingsPopup {
159
306
  return wrap;
160
307
  }
161
308
 
309
+ _buildCheckboxField({ label, key, fields, onChange }) {
310
+ const wrap = document.createElement('div');
311
+ wrap.className = 'moodboard-chat__settings-field';
312
+ wrap.style.flexDirection = 'row';
313
+ wrap.style.alignItems = 'center';
314
+ wrap.style.gap = '8px';
315
+
316
+ const cb = document.createElement('input');
317
+ cb.type = 'checkbox';
318
+ cb.className = 'moodboard-chat__settings-checkbox';
319
+ fields[key] = cb;
320
+ this._on(cb, 'change', () => onChange(cb.checked));
321
+
322
+ const labelEl = document.createElement('label');
323
+ labelEl.className = 'moodboard-chat__settings-label';
324
+ labelEl.style.textTransform = 'none';
325
+ labelEl.style.cursor = 'pointer';
326
+ labelEl.textContent = label;
327
+
328
+ wrap.appendChild(cb);
329
+ wrap.appendChild(labelEl);
330
+ return wrap;
331
+ }
332
+
162
333
  _on(el, type, handler) {
163
334
  el.addEventListener(type, handler);
164
335
  this._listeners.push(() => el.removeEventListener(type, handler));
165
336
  }
166
337
  }
167
-
168
- function formatNumber(n) {
169
- if (typeof n !== 'number' || Number.isNaN(n)) return '';
170
- return String(Math.round(n * 100) / 100);
171
- }
@@ -0,0 +1,284 @@
1
+ import { ICONS } from './icons.js';
2
+ import { ChatPillMenu } from './ChatPillMenu.js';
3
+ import { getVideoModelCapability } from '../../services/ai/videoModelCapabilities.js';
4
+
5
+ /**
6
+ * Видео-настройки как пилюли тулбара (вместо отдельной всплывающей панели).
7
+ *
8
+ * Каждая пилюля видна только когда выбран тип «видео» и текущая модель
9
+ * поддерживает соответствующий параметр (по capability.supports).
10
+ *
11
+ * Типы контролов:
12
+ * - тумблеры (Звук, Водяной знак) — клик переключает значение;
13
+ * - выбор (Люди) — пилюля-меню как формат/модель;
14
+ * - свободный ввод (Seed, CFG, Негатив) — пилюля с мини-поповером,
15
+ * привязанным к ней (тот же механизм, что у меню формата).
16
+ *
17
+ * Одна ответственность: построить/обновлять пилюли видео-настроек и
18
+ * прокидывать изменения наружу через onChange.
19
+ */
20
+ export class ChatVideoToolbarPills {
21
+ /**
22
+ * @param {HTMLElement} pillsContainer
23
+ * @param {{
24
+ * getActive: () => boolean,
25
+ * getModelId: () => string,
26
+ * getState: () => { seed:number|null, negativePrompt:string, audio:boolean, watermark:boolean, cfgScale:number|null, personGeneration:string },
27
+ * onChange: (patch: object) => void
28
+ * }} handlers
29
+ */
30
+ constructor(pillsContainer, handlers) {
31
+ this._container = pillsContainer;
32
+ this._h = handlers;
33
+ this._listeners = [];
34
+ this._entries = []; // { flag, wrapper }
35
+ this._pills = {}; // flag -> pill button
36
+ this._toggleIcons = {}; // flag -> { span, on, off } для смены иконки тумблера
37
+ this._labels = {}; // flag -> label span (для value-пилюль)
38
+ this._inputs = {}; // flag -> input element
39
+ this._personMenu = null;
40
+ this._popover = null; // { menu, pill }
41
+ this._docHandler = null;
42
+ }
43
+
44
+ attach() {
45
+ this._buildToggle('audio', 'Звук', ICONS.audio, {
46
+ iconOff: ICONS.audioOff,
47
+ title: 'Звук в видео: вкл / выкл',
48
+ });
49
+ this._buildToggle('watermark', 'Водяной знак', ICONS.watermark, {
50
+ title: 'Водяной знак на видео: вкл / выкл',
51
+ });
52
+ this._buildPersonPill();
53
+ this._buildSeedPill();
54
+ this._buildCfgPill();
55
+ this._buildNegativePill();
56
+ }
57
+
58
+ refresh() {
59
+ const active = !!this._h.getActive?.();
60
+ const cap = active ? getVideoModelCapability(this._h.getModelId?.()) : null;
61
+ const sup = cap?.supports ?? {};
62
+ const st = this._h.getState?.() ?? {};
63
+
64
+ for (const { flag, wrapper } of this._entries) {
65
+ wrapper.style.display = (active && sup[flag]) ? '' : 'none';
66
+ }
67
+ if (!active) {
68
+ this._closePopover();
69
+ return;
70
+ }
71
+
72
+ this._applyToggleState('audio', !!st.audio);
73
+ this._applyToggleState('watermark', !!st.watermark);
74
+
75
+ this._personMenu?.refresh();
76
+
77
+ if (this._labels.seed) this._labels.seed.textContent = st.seed != null ? `Seed · ${st.seed}` : 'Seed';
78
+ if (this._inputs.seed && document.activeElement !== this._inputs.seed) {
79
+ this._inputs.seed.value = st.seed != null ? String(st.seed) : '';
80
+ }
81
+
82
+ if (this._labels.cfgScale) {
83
+ this._labels.cfgScale.textContent = st.cfgScale != null ? `CFG · ${Number(st.cfgScale).toFixed(2)}` : 'CFG';
84
+ }
85
+ if (this._inputs.cfgScale) {
86
+ this._inputs.cfgScale.value = st.cfgScale != null ? String(st.cfgScale) : '0.5';
87
+ }
88
+
89
+ const hasNeg = !!(st.negativePrompt && st.negativePrompt.trim());
90
+ if (this._pills.negativePrompt) this._pills.negativePrompt.dataset.active = hasNeg ? 'true' : 'false';
91
+ if (this._inputs.negativePrompt && document.activeElement !== this._inputs.negativePrompt) {
92
+ this._inputs.negativePrompt.value = st.negativePrompt || '';
93
+ }
94
+ }
95
+
96
+ destroy() {
97
+ this._closePopover();
98
+ for (const off of this._listeners) off();
99
+ this._listeners = [];
100
+ this._personMenu?.destroy();
101
+ this._personMenu = null;
102
+ for (const { wrapper } of this._entries) wrapper.remove();
103
+ this._entries = [];
104
+ this._pills = {};
105
+ this._toggleIcons = {};
106
+ this._labels = {};
107
+ this._inputs = {};
108
+ }
109
+
110
+ _registerWrapper(flag, wrapper) {
111
+ wrapper.style.display = 'none';
112
+ this._container.appendChild(wrapper);
113
+ this._entries.push({ flag, wrapper });
114
+ }
115
+
116
+ _makePill(icon, labelText) {
117
+ const wrapper = document.createElement('div');
118
+ wrapper.className = 'moodboard-chat__pill-wrapper';
119
+ const pill = document.createElement('button');
120
+ pill.type = 'button';
121
+ pill.className = 'moodboard-chat__pill';
122
+ const iconSpan = document.createElement('span');
123
+ iconSpan.className = 'moodboard-chat__pill-icon-wrap';
124
+ iconSpan.innerHTML = icon;
125
+ const labelEl = document.createElement('span');
126
+ labelEl.className = 'moodboard-chat__pill-label';
127
+ labelEl.textContent = labelText;
128
+ pill.appendChild(iconSpan);
129
+ pill.appendChild(labelEl);
130
+ wrapper.appendChild(pill);
131
+ return { wrapper, pill, labelEl, iconSpan };
132
+ }
133
+
134
+ _buildToggle(flag, labelText, icon, opts = {}) {
135
+ const { wrapper, pill, iconSpan } = this._makePill(icon, labelText);
136
+ pill.classList.add('moodboard-chat__pill--toggle');
137
+ pill.setAttribute('aria-pressed', 'false');
138
+ if (opts.title) pill.title = opts.title;
139
+ this._pills[flag] = pill;
140
+ this._toggleIcons[flag] = { span: iconSpan, on: icon, off: opts.iconOff ?? icon };
141
+ this._on(pill, 'click', () => {
142
+ const st = this._h.getState?.() ?? {};
143
+ this._h.onChange?.({ [flag]: !st[flag] });
144
+ });
145
+ this._registerWrapper(flag, wrapper);
146
+ }
147
+
148
+ _applyToggleState(flag, on) {
149
+ const pill = this._pills[flag];
150
+ if (!pill) return;
151
+ pill.dataset.active = on ? 'true' : 'false';
152
+ pill.setAttribute('aria-pressed', on ? 'true' : 'false');
153
+ const ic = this._toggleIcons[flag];
154
+ if (ic) ic.span.innerHTML = on ? ic.on : ic.off;
155
+ }
156
+
157
+ _buildPersonPill() {
158
+ const { wrapper, pill, labelEl } = this._makePill(ICONS.users, 'Разрешить');
159
+ pill.setAttribute('aria-haspopup', 'menu');
160
+ pill.setAttribute('aria-expanded', 'false');
161
+ const menu = document.createElement('div');
162
+ menu.className = 'moodboard-chat__menu';
163
+ menu.setAttribute('role', 'menu');
164
+ wrapper.appendChild(menu);
165
+ this._pills.personGeneration = pill;
166
+ this._personMenu = new ChatPillMenu(
167
+ { trigger: pill, menu, label: labelEl },
168
+ {
169
+ getOptions: () => [
170
+ { id: 'allow_all', label: 'Разрешить' },
171
+ { id: 'disallow', label: 'Запретить' },
172
+ ],
173
+ getActiveId: () => this._h.getState?.().personGeneration ?? 'allow_all',
174
+ onSelect: (id) => this._h.onChange?.({ personGeneration: id }),
175
+ }
176
+ );
177
+ this._personMenu.attach();
178
+ this._registerWrapper('personGeneration', wrapper);
179
+ }
180
+
181
+ _buildSeedPill() {
182
+ this._buildPopoverPill('seed', 'Seed', ICONS.seed, 'Зерно генерации', (body) => {
183
+ const input = document.createElement('input');
184
+ input.type = 'number';
185
+ input.min = '0';
186
+ input.step = '1';
187
+ input.placeholder = 'Случайно';
188
+ input.className = 'moodboard-chat__settings-input';
189
+ this._inputs.seed = input;
190
+ this._on(input, 'input', () => {
191
+ const raw = input.value.trim();
192
+ const v = raw === '' ? null : Number.parseInt(raw, 10);
193
+ this._h.onChange?.({ seed: (v != null && !Number.isNaN(v)) ? v : null });
194
+ });
195
+ body.appendChild(input);
196
+ const hint = document.createElement('div');
197
+ hint.className = 'moodboard-chat__settings-hint';
198
+ hint.textContent = 'Число, фиксирующее случайность. Одно и то же число — похожий результат. Пусто — каждый раз по-новому.';
199
+ body.appendChild(hint);
200
+ }, { title: 'Seed — число для повторяемости результата' });
201
+ }
202
+
203
+ _buildCfgPill() {
204
+ this._buildPopoverPill('cfgScale', 'CFG', ICONS.gauge, 'CFG Scale (0–1)', (body) => {
205
+ const input = document.createElement('input');
206
+ input.type = 'range';
207
+ input.min = '0';
208
+ input.max = '1';
209
+ input.step = '0.05';
210
+ input.className = 'moodboard-chat__settings-input';
211
+ this._inputs.cfgScale = input;
212
+ this._on(input, 'input', () => this._h.onChange?.({ cfgScale: Number(input.value) }));
213
+ body.appendChild(input);
214
+ });
215
+ }
216
+
217
+ _buildNegativePill() {
218
+ this._buildPopoverPill('negativePrompt', 'Негатив', ICONS.negative, 'Негативный промпт', (body) => {
219
+ const input = document.createElement('input');
220
+ input.type = 'text';
221
+ input.placeholder = 'Что исключить';
222
+ input.className = 'moodboard-chat__settings-input';
223
+ this._inputs.negativePrompt = input;
224
+ this._on(input, 'input', () => this._h.onChange?.({ negativePrompt: input.value }));
225
+ body.appendChild(input);
226
+ });
227
+ }
228
+
229
+ _buildPopoverPill(flag, labelText, icon, fieldLabel, buildBody, opts = {}) {
230
+ const { wrapper, pill, labelEl } = this._makePill(icon, labelText);
231
+ pill.setAttribute('aria-haspopup', 'dialog');
232
+ pill.setAttribute('aria-expanded', 'false');
233
+ if (opts.title) pill.title = opts.title;
234
+
235
+ const menu = document.createElement('div');
236
+ menu.className = 'moodboard-chat__menu moodboard-chat__pill-popover';
237
+
238
+ const body = document.createElement('div');
239
+ body.className = 'moodboard-chat__pill-popover-body';
240
+ const label = document.createElement('div');
241
+ label.className = 'moodboard-chat__settings-label';
242
+ label.textContent = fieldLabel;
243
+ body.appendChild(label);
244
+ buildBody(body);
245
+ menu.appendChild(body);
246
+ wrapper.appendChild(menu);
247
+
248
+ this._pills[flag] = pill;
249
+ this._labels[flag] = labelEl;
250
+ this._on(pill, 'click', (e) => {
251
+ e.stopPropagation();
252
+ if (this._popover?.menu === menu) this._closePopover();
253
+ else this._openPopover(menu, pill);
254
+ });
255
+ this._registerWrapper(flag, wrapper);
256
+ }
257
+
258
+ _openPopover(menu, pill) {
259
+ this._closePopover();
260
+ menu.classList.add('is-open');
261
+ pill.setAttribute('aria-expanded', 'true');
262
+ this._popover = { menu, pill };
263
+ this._docHandler = (e) => {
264
+ if (!menu.contains(e.target) && !pill.contains(e.target)) this._closePopover();
265
+ };
266
+ document.addEventListener('mousedown', this._docHandler);
267
+ }
268
+
269
+ _closePopover() {
270
+ if (!this._popover) return;
271
+ this._popover.menu.classList.remove('is-open');
272
+ this._popover.pill.setAttribute('aria-expanded', 'false');
273
+ this._popover = null;
274
+ if (this._docHandler) {
275
+ document.removeEventListener('mousedown', this._docHandler);
276
+ this._docHandler = null;
277
+ }
278
+ }
279
+
280
+ _on(el, type, handler) {
281
+ el.addEventListener(type, handler);
282
+ this._listeners.push(() => el.removeEventListener(type, handler));
283
+ }
284
+ }