@sequent-org/moodboard 1.4.26 → 1.4.27

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.
Files changed (30) hide show
  1. package/package.json +2 -1
  2. package/src/assets/fonts/geist/geist-sans-latin-100-normal.ttf +0 -0
  3. package/src/assets/fonts/geist/geist-sans-latin-200-normal.ttf +0 -0
  4. package/src/assets/fonts/geist/geist-sans-latin-300-normal.ttf +0 -0
  5. package/src/assets/fonts/geist/geist-sans-latin-400-normal.ttf +0 -0
  6. package/src/assets/fonts/geist/geist-sans-latin-500-normal.ttf +0 -0
  7. package/src/assets/fonts/geist/geist-sans-latin-600-normal.ttf +0 -0
  8. package/src/assets/fonts/geist/geist-sans-latin-700-normal.ttf +0 -0
  9. package/src/assets/fonts/geist/geist-sans-latin-800-normal.ttf +0 -0
  10. package/src/assets/fonts/geist/geist-sans-latin-900-normal.ttf +0 -0
  11. package/src/core/SaveManager.js +6 -0
  12. package/src/core/flows/ClipboardFlow.js +16 -6
  13. package/src/moodboard/bootstrap/MoodBoardUiFactory.js +10 -0
  14. package/src/moodboard/lifecycle/MoodBoardDestroyer.js +3 -0
  15. package/src/services/ai/AiClient.js +220 -0
  16. package/src/services/ai/ChatHistoryStore.js +55 -0
  17. package/src/services/ai/ChatPresets.js +40 -0
  18. package/src/services/ai/ChatSessionController.js +220 -0
  19. package/src/ui/chat/ChatComposer.js +198 -0
  20. package/src/ui/chat/ChatExtendedPromptModal.js +131 -0
  21. package/src/ui/chat/ChatMessageList.js +92 -0
  22. package/src/ui/chat/ChatPillMenu.js +141 -0
  23. package/src/ui/chat/ChatSettingsPopup.js +171 -0
  24. package/src/ui/chat/ChatWindow.js +407 -0
  25. package/src/ui/chat/ChatWindowRenderer.js +214 -0
  26. package/src/ui/chat/icons.js +113 -0
  27. package/src/ui/styles/chat.css +920 -0
  28. package/src/ui/styles/index.css +1 -0
  29. package/src/ui/styles/workspace.css +73 -1
  30. package/src/utils/styleLoader.js +2 -1
@@ -0,0 +1,407 @@
1
+ import { AiClient } from '../../services/ai/AiClient.js';
2
+ import { ChatHistoryStore } from '../../services/ai/ChatHistoryStore.js';
3
+ import { ChatSessionController } from '../../services/ai/ChatSessionController.js';
4
+ import { Events } from '../../core/events/Events.js';
5
+
6
+ import { buildChatDom } from './ChatWindowRenderer.js';
7
+ import { ChatMessageList } from './ChatMessageList.js';
8
+ import { ChatComposer } from './ChatComposer.js';
9
+ import { ChatPillMenu } from './ChatPillMenu.js';
10
+ import { ChatExtendedPromptModal } from './ChatExtendedPromptModal.js';
11
+ import { ICONS, RATIO_ICONS, COUNT_ICONS } from './icons.js';
12
+
13
+ const CONTENT_TYPE_OPTIONS = [
14
+ {
15
+ id: 'image',
16
+ label: 'Изображение',
17
+ icon: ICONS.image,
18
+ description: 'Создать по тексту или приложите ссылку'
19
+ },
20
+ {
21
+ id: 'video',
22
+ label: 'Видео',
23
+ icon: ICONS.video,
24
+ description: 'Создать по тексту или руководствуясь первым и последним кадром'
25
+ }
26
+ ];
27
+
28
+ /** Порядок: портрет (строка 1), авто + альбом (строка 2) — соответствует грид-меню */
29
+ const FORMAT_OPTIONS = [
30
+ { id: '1:1', label: '1:1', icon: RATIO_ICONS['1:1'] },
31
+ { id: '4:5', label: '4:5', icon: RATIO_ICONS['4:5'] },
32
+ { id: '3:4', label: '3:4', icon: RATIO_ICONS['3:4'] },
33
+ { id: '10:14', label: '10:14', icon: RATIO_ICONS['10:14'] },
34
+ { id: '2:3', label: '2:3', icon: RATIO_ICONS['2:3'] },
35
+ { id: '9:16', label: '9:16', icon: RATIO_ICONS['9:16'] },
36
+ { id: '1:2', label: '1:2', icon: RATIO_ICONS['1:2'] },
37
+ { id: 'auto', label: 'Auto', icon: RATIO_ICONS['auto'] },
38
+ { id: '5:4', label: '5:4', icon: RATIO_ICONS['5:4'] },
39
+ { id: '4:3', label: '4:3', icon: RATIO_ICONS['4:3'] },
40
+ { id: '14:10', label: '14:10', icon: RATIO_ICONS['14:10'] },
41
+ { id: '3:2', label: '3:2', icon: RATIO_ICONS['3:2'] },
42
+ { id: '16:9', label: '16:9', icon: RATIO_ICONS['16:9'] },
43
+ { id: '2:1', label: '2:1', icon: RATIO_ICONS['2:1'] },
44
+ ];
45
+
46
+ const COUNT_OPTIONS = [
47
+ { id: 'auto', label: 'Авто', icon: COUNT_ICONS.auto },
48
+ { id: '1', label: '1 Изображение', icon: COUNT_ICONS[1] },
49
+ { id: '2', label: '2 Изображения', icon: COUNT_ICONS[2] },
50
+ { id: '3', label: '3 Изображения', icon: COUNT_ICONS[3] },
51
+ { id: '4', label: '4 Изображения', icon: COUNT_ICONS[4] },
52
+ ];
53
+
54
+ const MODEL_OPTIONS = [
55
+ {
56
+ id: 'auto',
57
+ label: 'Автоматический режим',
58
+ icon: ICONS.sparkles,
59
+ description: 'Мы подберем модель для ваших задач.'
60
+ },
61
+ {
62
+ id: 'yandex',
63
+ label: 'Алиса',
64
+ icon: '<img src="/icons/alice.png" width="36" height="36" alt="Алиса" style="object-fit: contain;" />',
65
+ description: 'YandexGPT'
66
+ },
67
+ {
68
+ id: 'gpt',
69
+ label: 'GPT',
70
+ icon: '<img src="/icons/gpt.svg" width="36" height="36" alt="GPT" style="object-fit: contain;" />',
71
+ description: 'OpenAI'
72
+ },
73
+ {
74
+ id: 'google',
75
+ label: 'Google',
76
+ icon: '<img src="/icons/google.svg" width="36" height="36" alt="Google" style="object-fit: contain;" />',
77
+ description: 'Gemini'
78
+ },
79
+ {
80
+ id: 'qwen',
81
+ label: 'Qwen',
82
+ icon: '<img src="/icons/qwen.svg" width="36" height="36" alt="Qwen" style="object-fit: contain;" />',
83
+ description: 'Alibaba'
84
+ }
85
+ ];
86
+
87
+ /**
88
+ * Корневой контейнер чата ИИ-ассистента.
89
+ *
90
+ * Одна ответственность: lifecycle (`attach` → `detach` → `destroy`)
91
+ * и wiring вспомогательных модулей. Никакой DOM-разметки и бизнес-логики
92
+ * здесь нет — они в Renderer / Controller / Composer / MessageList /
93
+ * PillMenu / SettingsPopup.
94
+ *
95
+ * Эквивалент по стилю — другие UI-классы в `src/ui/` (Topbar, Toolbar и т.п.).
96
+ */
97
+ export class ChatWindow {
98
+ /**
99
+ * @param {HTMLElement} container - workspace-контейнер мудборда
100
+ * @param {object} [options]
101
+ * @param {AiClient} [options.aiClient]
102
+ * @param {ChatHistoryStore} [options.historyStore]
103
+ * @param {ChatSessionController} [options.sessionController]
104
+ */
105
+ constructor(container, options = {}) {
106
+ this._container = container;
107
+ this._options = options;
108
+
109
+ this._aiClient = options.aiClient || new AiClient();
110
+ this._historyStore = options.historyStore || new ChatHistoryStore();
111
+ this._session = options.sessionController || new ChatSessionController({
112
+ aiClient: this._aiClient,
113
+ historyStore: this._historyStore
114
+ });
115
+ this._boardCore = options.boardCore || null;
116
+
117
+ this._refs = null;
118
+ this._messageList = null;
119
+ this._composer = null;
120
+ this._extendedPromptModal = null;
121
+ this._contentTypeId = 'image';
122
+ this._contentTypeMenu = null;
123
+ this._modelId = 'auto';
124
+ this._modelMenu = null;
125
+ this._formatId = 'auto';
126
+ this._formatMenu = null;
127
+ this._countId = 'auto';
128
+ this._countMenu = null;
129
+ this._unsubscribe = null;
130
+ this._attached = false;
131
+ this._boardImageMessageIds = new Set();
132
+ // Упорядоченный список ID объектов на доске, размещённых через AI-генерацию.
133
+ // Используется для сдвига предыдущих изображений влево при новой генерации.
134
+ this._boardAiImageIds = [];
135
+ this._onBoardObjectCreated = (data) => {
136
+ if (data?.objectData?.properties?.name === 'ai-generated.jpg') {
137
+ this._boardAiImageIds.push(data.objectId);
138
+ }
139
+ };
140
+ }
141
+
142
+ attach() {
143
+ if (this._attached) return;
144
+ this._refs = buildChatDom();
145
+ this._container.appendChild(this._refs.root);
146
+
147
+ this._messageList = new ChatMessageList(this._refs.history);
148
+
149
+ this._composer = new ChatComposer(
150
+ {
151
+ textarea: this._refs.textarea,
152
+ send: this._refs.send,
153
+ attach: this._refs.attach,
154
+ fileInput: this._refs.fileInput,
155
+ attachmentsPreview: this._refs.attachmentsPreview,
156
+ enhancePrompt: this._refs.enhancePrompt,
157
+ statusBar: this._refs.statusBar
158
+ },
159
+ {
160
+ onSubmit: (text, attachments) => this._session.send(text, this._getImageRequestOptions()),
161
+ onAbort: () => this._session.abort()
162
+ }
163
+ );
164
+ this._composer.attach();
165
+
166
+ this._extendedPromptModal = new ChatExtendedPromptModal(
167
+ this._container,
168
+ this._refs.textarea,
169
+ this._refs.extendPromptField
170
+ );
171
+ this._extendedPromptModal.attach();
172
+
173
+ this._contentTypeMenu = new ChatPillMenu(
174
+ {
175
+ trigger: this._refs.contentTypePill,
176
+ menu: this._refs.contentTypeMenu,
177
+ label: this._refs.contentTypeLabel,
178
+ icon: this._refs.contentTypeIcon
179
+ },
180
+ {
181
+ getOptions: () => CONTENT_TYPE_OPTIONS,
182
+ getActiveId: () => this._contentTypeId,
183
+ onSelect: (id) => {
184
+ this._contentTypeId = id;
185
+ this._contentTypeMenu.refresh();
186
+ }
187
+ }
188
+ );
189
+ this._contentTypeMenu.attach();
190
+
191
+ this._modelMenu = new ChatPillMenu(
192
+ { trigger: this._refs.modelPill, menu: this._refs.modelMenu, label: this._refs.modelLabel, icon: this._refs.modelIcon },
193
+ {
194
+ getOptions: () => MODEL_OPTIONS,
195
+ getActiveId: () => this._modelId,
196
+ onSelect: (id) => {
197
+ this._modelId = id;
198
+ this._modelMenu.refresh();
199
+ }
200
+ }
201
+ );
202
+ this._modelMenu.attach();
203
+
204
+ this._formatMenu = new ChatPillMenu(
205
+ { trigger: this._refs.formatPill, menu: this._refs.formatMenu, label: this._refs.formatLabel },
206
+ {
207
+ getOptions: () => FORMAT_OPTIONS,
208
+ getActiveId: () => this._formatId,
209
+ onSelect: (id) => {
210
+ this._formatId = id;
211
+ this._formatMenu.refresh();
212
+ this._updateFormatPillIcon();
213
+ this._updateFormatPillLabel();
214
+ }
215
+ }
216
+ );
217
+ this._formatMenu.attach();
218
+
219
+ this._countMenu = new ChatPillMenu(
220
+ { trigger: this._refs.countPill, menu: this._refs.countMenu, label: this._refs.countLabel, icon: this._refs.countIcon },
221
+ {
222
+ getOptions: () => COUNT_OPTIONS,
223
+ getActiveId: () => this._countId,
224
+ onSelect: (id) => {
225
+ this._countId = id;
226
+ this._countMenu.refresh();
227
+ this._updateCountPillIcon();
228
+ }
229
+ }
230
+ );
231
+ this._countMenu.attach();
232
+
233
+ this._boardCore?.eventBus?.on?.(Events.Object.Created, this._onBoardObjectCreated);
234
+
235
+ const initialState = this._session.getState();
236
+ this._markExistingBoardImages(initialState.messages);
237
+ this._unsubscribe = this._session.subscribe((state) => this._render(state));
238
+ this._render(initialState);
239
+
240
+ this._loadProviders();
241
+
242
+ this._attached = true;
243
+ }
244
+
245
+ detach() {
246
+ if (!this._attached) return;
247
+ if (this._unsubscribe) { this._unsubscribe(); this._unsubscribe = null; }
248
+ this._boardCore?.eventBus?.off?.(Events.Object.Created, this._onBoardObjectCreated);
249
+ this._boardAiImageIds = [];
250
+ this._composer?.destroy();
251
+ this._extendedPromptModal?.destroy();
252
+ this._contentTypeMenu?.destroy();
253
+ this._modelMenu?.destroy();
254
+ this._formatMenu?.destroy();
255
+ this._countMenu?.destroy();
256
+ if (this._refs?.root && this._refs.root.parentNode === this._container) {
257
+ this._container.removeChild(this._refs.root);
258
+ }
259
+ this._refs = null;
260
+ this._messageList = null;
261
+ this._composer = null;
262
+ this._extendedPromptModal = null;
263
+ this._contentTypeMenu = null;
264
+ this._modelMenu = null;
265
+ this._formatMenu = null;
266
+ this._countMenu = null;
267
+ this._attached = false;
268
+ }
269
+
270
+ destroy() {
271
+ this.detach();
272
+ }
273
+
274
+ _updateCountPillIcon() {
275
+ const active = COUNT_OPTIONS.find((o) => o.id === this._countId);
276
+ if (!active) return;
277
+ const iconWrap = this._refs?.countPill?.querySelector('.moodboard-chat__pill-icon-wrap');
278
+ if (iconWrap) iconWrap.innerHTML = active.icon;
279
+ }
280
+
281
+ _updateFormatPillIcon() {
282
+ const iconWrap = this._refs?.formatPill?.querySelector('.moodboard-chat__pill-icon-wrap');
283
+ if (!iconWrap) return;
284
+ iconWrap.innerHTML = RATIO_ICONS[this._formatId] ?? ICONS.ratio;
285
+ }
286
+
287
+ _updateFormatPillLabel() {
288
+ const labelEl = this._refs?.formatLabel;
289
+ if (!labelEl) return;
290
+ labelEl.textContent = this._formatId === 'auto' ? 'Соотношение сторон' : this._formatId;
291
+ }
292
+
293
+ async _loadProviders() {
294
+ try {
295
+ const list = await this._aiClient.listProviders();
296
+ this._session.setAvailableProviders(list);
297
+ } catch (err) {
298
+ console.warn('[ChatWindow] cannot load providers:', err.message);
299
+ this._session.setAvailableProviders([]);
300
+ }
301
+ }
302
+
303
+ _render(state) {
304
+ if (!this._attached && !this._refs) return;
305
+ this._syncGeneratedImagesToBoard(state.messages);
306
+ this._messageList.render(state.messages);
307
+ this._contentTypeMenu.refresh();
308
+ this._modelMenu.refresh();
309
+ this._formatMenu.refresh();
310
+ this._updateFormatPillIcon();
311
+ this._updateFormatPillLabel();
312
+ this._countMenu.refresh();
313
+ this._updateCountPillIcon();
314
+ this._composer.setStreaming(state.status === 'streaming');
315
+ }
316
+
317
+ _getImageRequestOptions() {
318
+ const [widthRatio, heightRatio] = parseFormatRatio(this._formatId);
319
+ return {
320
+ widthRatio,
321
+ heightRatio,
322
+ model: this._modelId === 'yandex' ? 'yandex-art' : undefined
323
+ };
324
+ }
325
+
326
+ _addImageToBoard(msg) {
327
+ if (!this._boardCore?.eventBus) return;
328
+ const dataUrl = `data:${msg.mimeType || 'image/jpeg'};base64,${msg.imageBase64}`;
329
+ const view = this._boardCore.pixi?.app?.view;
330
+ const world = this._boardCore.pixi?.worldLayer || this._boardCore.pixi?.app?.stage;
331
+ const s = world?.scale?.x || 1;
332
+
333
+ // Сдвигаем все ранее размещённые AI-изображения влево на 320 экранных пикселей
334
+ // (в мировых единицах: 320 / масштаб), чтобы новое изображение всегда появлялось
335
+ // на фиксированной базовой позиции, не перекрывая предыдущие.
336
+ if (this._boardAiImageIds.length > 0) {
337
+ const worldShift = Math.round(320 / s);
338
+ const objects = this._boardCore.state?.state?.objects;
339
+ for (const id of this._boardAiImageIds) {
340
+ const obj = objects?.find((o) => o.id === id);
341
+ if (obj?.position) {
342
+ this._boardCore.updateObjectPositionDirect?.(
343
+ id,
344
+ { x: Math.round(obj.position.x - worldShift), y: obj.position.y },
345
+ { snap: false }
346
+ );
347
+ }
348
+ }
349
+ }
350
+
351
+ // Новое изображение центрируется по горизонтали над панелью чата.
352
+ // Якорь по вертикали — верхний край composer (всегда виден).
353
+ // Объект создаётся с центром в (x,y), поэтому нижний край = y + h*s/2.
354
+ // При w=300 мировых ед. и масштабе s≈1 нижний край ≈ y+150.
355
+ // Чтобы нижний край изображения был на 100 px выше composer: y = composerTop - 250.
356
+ const chatRect = this._refs?.root?.getBoundingClientRect?.();
357
+ const composerRect = this._refs?.composer?.getBoundingClientRect?.();
358
+ const x = chatRect
359
+ ? Math.round(chatRect.left + chatRect.width / 2)
360
+ : (view ? Math.round(view.clientWidth / 2) : 400);
361
+ const y = composerRect
362
+ ? Math.round(composerRect.top - 250)
363
+ : (chatRect
364
+ ? Math.round(chatRect.top - 150)
365
+ : (view ? Math.round(view.clientHeight * 0.3) : 200));
366
+ this._boardCore.eventBus.emit(Events.UI.PasteImageAt, {
367
+ x, y,
368
+ src: dataUrl,
369
+ name: 'ai-generated.jpg',
370
+ skipUpload: true
371
+ });
372
+ }
373
+
374
+ _markExistingBoardImages(messages) {
375
+ for (const msg of messages || []) {
376
+ if (msg?.imageBase64) {
377
+ this._boardImageMessageIds.add(msg.id);
378
+ }
379
+ }
380
+ }
381
+
382
+ _syncGeneratedImagesToBoard(messages) {
383
+ if (!this._boardCore?.eventBus) return;
384
+
385
+ for (const msg of messages || []) {
386
+ if (!msg?.imageBase64 || msg.pending || this._boardImageMessageIds.has(msg.id)) {
387
+ continue;
388
+ }
389
+
390
+ this._boardImageMessageIds.add(msg.id);
391
+ this._addImageToBoard(msg);
392
+ }
393
+ }
394
+ }
395
+
396
+ function parseFormatRatio(formatId) {
397
+ if (!formatId || formatId === 'auto') {
398
+ return [1, 1];
399
+ }
400
+
401
+ const [width, height] = formatId.split(':').map((part) => Number.parseInt(part, 10));
402
+ if (!Number.isFinite(width) || !Number.isFinite(height) || width <= 0 || height <= 0) {
403
+ return [1, 1];
404
+ }
405
+
406
+ return [width, height];
407
+ }
@@ -0,0 +1,214 @@
1
+ import { ICONS } from './icons.js';
2
+
3
+ /**
4
+ * Чистый билд DOM-разметки чата.
5
+ *
6
+ * Одна ответственность: построить дерево узлов и вернуть ссылки на
7
+ * ключевые элементы. Никаких listeners, никакого состояния, никаких
8
+ * бизнес-правил — этим занимается ChatWindowController.
9
+ *
10
+ * Структура соответствует макету:
11
+ * .moodboard-chat
12
+ * ├ .moodboard-chat__history
13
+ * └ .moodboard-chat__composer
14
+ * ├ .moodboard-chat__input-row (textarea + prompt actions)
15
+ * └ .moodboard-chat__actions-row (content type + pills + attach + send)
16
+ */
17
+ export function buildChatDom() {
18
+ const root = createDiv('moodboard-chat');
19
+
20
+ const history = createDiv('moodboard-chat__history');
21
+ history.setAttribute('role', 'log');
22
+ history.setAttribute('aria-live', 'polite');
23
+
24
+ const composer = createDiv('moodboard-chat__composer');
25
+
26
+ const statusBar = createDiv('moodboard-chat__status-bar');
27
+ statusBar.setAttribute('aria-live', 'polite');
28
+ statusBar.setAttribute('aria-atomic', 'true');
29
+ statusBar.innerHTML = '<span class="moodboard-chat__status-bar-text">Идёт процесс генерации изображения…</span>';
30
+
31
+ const rendererRefs = {
32
+ root,
33
+ history,
34
+ composer,
35
+ statusBar
36
+ };
37
+
38
+ composer.appendChild(buildInputRow(refs => Object.assign(rendererRefs, refs)));
39
+ composer.appendChild(buildActionsRow(refs => Object.assign(rendererRefs, refs)));
40
+
41
+ root.appendChild(history);
42
+ root.appendChild(statusBar);
43
+ root.appendChild(composer);
44
+
45
+ // input/actions добавили свои ссылки в rendererRefs через collect-callback'и выше.
46
+ // На этом этапе rendererRefs уже содержит все ключи.
47
+ return rendererRefs;
48
+ }
49
+
50
+ function buildInputRow(collect) {
51
+ const row = createDiv('moodboard-chat__input-row');
52
+
53
+ const attachmentsPreview = createDiv('moodboard-chat__attachments');
54
+
55
+ const textareaRow = createDiv('moodboard-chat__textarea-row');
56
+
57
+ const textarea = document.createElement('textarea');
58
+ textarea.className = 'moodboard-chat__textarea';
59
+ textarea.rows = 1;
60
+ textarea.placeholder = 'Опишите то, что хотите сгенерировать';
61
+ textarea.setAttribute('aria-label', 'Сообщение');
62
+
63
+ const promptActionsWrapper = document.createElement('div');
64
+ promptActionsWrapper.className = 'moodboard-chat__pill-wrapper';
65
+
66
+ const enhancePrompt = createInputIconButton(
67
+ 'enhance-prompt',
68
+ 'Улучшить промпт',
69
+ ICONS.enhancePrompt
70
+ );
71
+ enhancePrompt.dataset.empty = 'true';
72
+ const extendPromptField = createInputIconButton(
73
+ 'extend-promt-field',
74
+ 'Развернуть поле ввода',
75
+ ICONS.extendPromptField
76
+ );
77
+
78
+ promptActionsWrapper.appendChild(enhancePrompt);
79
+ promptActionsWrapper.appendChild(extendPromptField);
80
+ textareaRow.appendChild(textarea);
81
+ textareaRow.appendChild(promptActionsWrapper);
82
+ row.appendChild(attachmentsPreview);
83
+ row.appendChild(textareaRow);
84
+
85
+ collect({ textarea, enhancePrompt, extendPromptField, attachmentsPreview });
86
+ return row;
87
+ }
88
+
89
+ function buildActionsRow(collect) {
90
+ const row = createDiv('moodboard-chat__actions-row');
91
+
92
+ const pills = createDiv('moodboard-chat__pills');
93
+
94
+ const contentTypeWrapper = pillWithMenu('Изображение', ICONS.image, 'chat-menu-content-type');
95
+ contentTypeWrapper.pill.title = 'Тип генерируемого контента';
96
+ contentTypeWrapper.pill.setAttribute('aria-label', 'Тип генерируемого контента');
97
+
98
+ const modelWrapper = pillWithMenu('Алиса', ICONS.model, 'chat-menu-model');
99
+ modelWrapper.pill.title = 'Модель ИИ';
100
+ modelWrapper.pill.setAttribute('aria-label', 'Модель ИИ');
101
+
102
+ const formatWrapper = pillWithMenu('Auto', ICONS.ratio, 'chat-menu-format');
103
+ formatWrapper.pill.title = 'Формат изображения';
104
+ formatWrapper.pill.setAttribute('aria-label', 'Формат изображения');
105
+ formatWrapper.menu.classList.add('moodboard-chat__menu--grid');
106
+
107
+ const countWrapper = pillWithMenu('Авто', ICONS.count, 'chat-menu-count');
108
+ countWrapper.pill.title = 'Количество изображений';
109
+ countWrapper.pill.setAttribute('aria-label', 'Количество изображений');
110
+
111
+ pills.appendChild(contentTypeWrapper.wrapper);
112
+ pills.appendChild(modelWrapper.wrapper);
113
+ pills.appendChild(formatWrapper.wrapper);
114
+ pills.appendChild(countWrapper.wrapper);
115
+
116
+ const sendRow = createDiv('moodboard-chat__send-row');
117
+
118
+ const attach = document.createElement('button');
119
+ attach.type = 'button';
120
+ attach.className = 'moodboard-chat__attach';
121
+ attach.title = 'Прикрепить файл';
122
+ attach.setAttribute('aria-label', 'Прикрепить файл');
123
+ attach.innerHTML = ICONS.attach;
124
+
125
+ const fileInput = document.createElement('input');
126
+ fileInput.type = 'file';
127
+ fileInput.multiple = true;
128
+ fileInput.accept = 'image/*,.pdf,.txt,.doc,.docx';
129
+ fileInput.className = 'moodboard-chat__file-input';
130
+ fileInput.setAttribute('aria-hidden', 'true');
131
+ fileInput.setAttribute('tabindex', '-1');
132
+
133
+ const send = document.createElement('button');
134
+ send.type = 'button';
135
+ send.className = 'moodboard-chat__send';
136
+ send.dataset.state = 'idle';
137
+ send.setAttribute('aria-label', 'Отправить');
138
+ send.innerHTML = ICONS.send;
139
+
140
+ sendRow.appendChild(attach);
141
+ sendRow.appendChild(fileInput);
142
+ sendRow.appendChild(send);
143
+
144
+ row.appendChild(pills);
145
+ row.appendChild(sendRow);
146
+
147
+ collect({
148
+ contentTypePill: contentTypeWrapper.pill,
149
+ contentTypeMenu: contentTypeWrapper.menu,
150
+ contentTypeLabel: contentTypeWrapper.labelEl,
151
+ contentTypeIcon: contentTypeWrapper.iconEl,
152
+ modelPill: modelWrapper.pill,
153
+ modelMenu: modelWrapper.menu,
154
+ modelLabel: modelWrapper.labelEl,
155
+ modelIcon: modelWrapper.iconEl,
156
+ formatPill: formatWrapper.pill,
157
+ formatMenu: formatWrapper.menu,
158
+ formatLabel: formatWrapper.labelEl,
159
+ countPill: countWrapper.pill,
160
+ countMenu: countWrapper.menu,
161
+ countLabel: countWrapper.labelEl,
162
+ countIcon: countWrapper.iconEl,
163
+ attach,
164
+ fileInput,
165
+ send
166
+ });
167
+ return row;
168
+ }
169
+
170
+ function pillWithMenu(label, iconSvg, menuId) {
171
+ const wrapper = document.createElement('div');
172
+ wrapper.className = 'moodboard-chat__pill-wrapper';
173
+
174
+ const pill = document.createElement('button');
175
+ pill.type = 'button';
176
+ pill.className = 'moodboard-chat__pill';
177
+ pill.setAttribute('aria-haspopup', 'menu');
178
+ pill.setAttribute('aria-expanded', 'false');
179
+ if (menuId) pill.setAttribute('aria-controls', menuId);
180
+
181
+ const iconSpan = document.createElement('span');
182
+ iconSpan.className = 'moodboard-chat__pill-icon-wrap';
183
+ iconSpan.innerHTML = iconSvg;
184
+
185
+ const labelEl = document.createElement('span');
186
+ labelEl.className = 'moodboard-chat__pill-label';
187
+ labelEl.textContent = label;
188
+
189
+ pill.appendChild(iconSpan);
190
+ pill.appendChild(labelEl);
191
+
192
+ const menu = createDiv('moodboard-chat__menu');
193
+ menu.setAttribute('role', 'menu');
194
+ if (menuId) menu.id = menuId;
195
+
196
+ wrapper.appendChild(pill);
197
+ wrapper.appendChild(menu);
198
+ return { wrapper, pill, menu, labelEl, iconEl: iconSpan };
199
+ }
200
+
201
+ function createInputIconButton(name, ariaLabel, iconSvg) {
202
+ const button = document.createElement('button');
203
+ button.type = 'button';
204
+ button.className = `moodboard-chat__input-icon-btn moodboard-chat__input-icon-btn--${name}`;
205
+ button.setAttribute('aria-label', ariaLabel);
206
+ button.innerHTML = iconSvg;
207
+ return button;
208
+ }
209
+
210
+ function createDiv(className) {
211
+ const el = document.createElement('div');
212
+ el.className = className;
213
+ return el;
214
+ }