@sequent-org/moodboard 1.4.45 → 1.4.47

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.
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@sequent-org/moodboard",
3
- "version": "1.4.45",
3
+ "version": "1.4.47",
4
4
  "type": "module",
5
5
  "description": "Interactive moodboard",
6
6
  "main": "./src/index.js",
@@ -70,6 +70,7 @@ export const Events = {
70
70
  ZoomSelection: 'ui:zoom:selection',
71
71
  ZoomPercent: 'ui:zoom:percent',
72
72
  RevitShowInModel: 'ui:revit:show-in-model',
73
+ Model3dShowInViewer: 'ui:model3d:show-in-viewer',
73
74
  MinimapGetData: 'ui:minimap:get-data',
74
75
  MinimapCenterOn: 'ui:minimap:center-on',
75
76
  TextEditStart: 'ui:text:edit:start',
@@ -286,8 +286,36 @@ export function setupClipboardFlow(core) {
286
286
  }
287
287
  });
288
288
 
289
- core.eventBus.on(Events.UI.PasteImageAt, async ({ x, y, src, name, skipUpload, aiMessageId }) => {
289
+ core.eventBus.on(Events.UI.PasteImageAt, async ({ x, y, src, name, skipUpload, aiMessageId, objectType, modelUrl, format }) => {
290
290
  if (!src) return;
291
+
292
+ // Отдельный путь для 3D-скрина: без resolveRevitImagePayload и без загрузки на сервер
293
+ if (objectType === 'model3d-screenshot-img') {
294
+ const world = core.pixi.worldLayer || core.pixi.app.stage;
295
+ const s = world?.scale?.x || 1;
296
+ const worldX = (x - (world?.x || 0)) / s;
297
+ const worldY = (y - (world?.y || 0)) / s;
298
+ const placeModel3d = (natW, natH) => {
299
+ const ar = (natW > 0 && natH > 0) ? natW / natH : 1;
300
+ const w = 300;
301
+ const h = Math.max(1, Math.round(w / ar));
302
+ const created = core.createObject(
303
+ 'model3d-screenshot-img',
304
+ { x: Math.round(worldX - Math.round(w / 2)), y: Math.round(worldY - Math.round(h / 2)) },
305
+ { src, name: name || 'ai-3d-model', width: w, height: h, modelUrl, format }
306
+ );
307
+ if (skipUpload && created?.id) {
308
+ core._pendingPersistAckVisibilityIds?.delete(created.id);
309
+ core._setObjectVisibility?.(created.id, true);
310
+ }
311
+ };
312
+ const img = new Image();
313
+ img.onload = () => placeModel3d(img.naturalWidth, img.naturalHeight);
314
+ img.onerror = () => placeModel3d(0, 0);
315
+ img.src = src;
316
+ return;
317
+ }
318
+
291
319
  const uploaded = await ensureServerImage({ src, name, skipUpload });
292
320
  if (!uploaded?.src) return;
293
321
  const world = core.pixi.worldLayer || core.pixi.app.stage;
@@ -0,0 +1,15 @@
1
+ import { Events } from '../events/Events.js';
2
+
3
+ export function setupModel3dFlow(core) {
4
+ core.eventBus.on(Events.UI.Model3dShowInViewer, ({ objectId, modelUrl, format }) => {
5
+ if (!modelUrl || typeof modelUrl !== 'string') {
6
+ return;
7
+ }
8
+
9
+ if (typeof core.options?.onShowModel3dInViewer === 'function') {
10
+ core.options.onShowModel3dInViewer({ objectId, modelUrl, format });
11
+ } else {
12
+ console.warn('[Model3dFlow] onShowModel3dInViewer не задан в options. objectId:', objectId, 'modelUrl:', modelUrl);
13
+ }
14
+ });
15
+ }
package/src/core/index.js CHANGED
@@ -17,6 +17,7 @@ import { setupClipboardFlow, setupClipboardKeyboardFlow } from './flows/Clipboar
17
17
  import { setupObjectLifecycleFlow } from './flows/ObjectLifecycleFlow.js';
18
18
  import { setupLayerAndViewportFlow } from './flows/LayerAndViewportFlow.js';
19
19
  import { setupRevitFlow } from './flows/RevitFlow.js';
20
+ import { setupModel3dFlow } from './flows/Model3dFlow.js';
20
21
  import { setupSaveFlow } from './flows/SaveFlow.js';
21
22
  import {
22
23
  logMindmapCompoundDebug,
@@ -133,6 +134,7 @@ export class CoreMoodBoard {
133
134
  setupTransformFlow(this);
134
135
  setupObjectLifecycleFlow(this);
135
136
  setupRevitFlow(this);
137
+ setupModel3dFlow(this);
136
138
  }
137
139
 
138
140
  /**
@@ -432,7 +434,7 @@ export class CoreMoodBoard {
432
434
  },
433
435
  ...extraData
434
436
  };
435
- if (type === 'image' || type === 'revit-screenshot-img' || type === 'file') {
437
+ if (type === 'image' || type === 'revit-screenshot-img' || type === 'model3d-screenshot-img' || type === 'file') {
436
438
  const propSrc = typeof properties?.src === 'string' ? properties.src.trim() : '';
437
439
  const propUrl = typeof properties?.url === 'string' ? properties.url.trim() : '';
438
440
  const normalizedSrc = propSrc || propUrl;
@@ -557,7 +559,7 @@ export class CoreMoodBoard {
557
559
  properties: objectData.properties || {},
558
560
  existingObjects: this.state?.state?.objects || [],
559
561
  });
560
- if (objectData.type === 'image' || objectData.type === 'revit-screenshot-img' || objectData.type === 'file') {
562
+ if (objectData.type === 'image' || objectData.type === 'revit-screenshot-img' || objectData.type === 'model3d-screenshot-img' || objectData.type === 'file') {
561
563
  const topSrc = typeof objectData.src === 'string' ? objectData.src.trim() : '';
562
564
  const propSrc = typeof objectData.properties?.src === 'string' ? objectData.properties.src.trim() : '';
563
565
  const topUrl = typeof objectData.url === 'string' ? objectData.url.trim() : '';
@@ -18,6 +18,7 @@ import { TextPropertiesPanel } from '../../ui/TextPropertiesPanel.js';
18
18
  import { FramePropertiesPanel } from '../../ui/FramePropertiesPanel.js';
19
19
  import { NotePropertiesPanel } from '../../ui/NotePropertiesPanel.js';
20
20
  import { FilePropertiesPanel } from '../../ui/FilePropertiesPanel.js';
21
+ import { ImagePropertiesPanel } from '../../ui/ImagePropertiesPanel.js';
21
22
  import { ConnectorPropertiesPanel } from '../../ui/ConnectorPropertiesPanel.js';
22
23
  import { ShapePropertiesPanel } from '../../ui/ShapePropertiesPanel.js';
23
24
  import { DrawingPropertiesPanel } from '../../ui/DrawingPropertiesPanel.js';
@@ -151,6 +152,7 @@ function initHtmlLayersAndPanels(board) {
151
152
  board.framePropertiesPanel = new FramePropertiesPanel(board.coreMoodboard.eventBus, board.canvasContainer, board.coreMoodboard);
152
153
  board.notePropertiesPanel = new NotePropertiesPanel(board.coreMoodboard.eventBus, board.canvasContainer, board.coreMoodboard);
153
154
  board.filePropertiesPanel = new FilePropertiesPanel(board.coreMoodboard.eventBus, board.canvasContainer, board.coreMoodboard);
155
+ board.imagePropertiesPanel = new ImagePropertiesPanel(board.coreMoodboard.eventBus, board.canvasContainer, board.coreMoodboard);
154
156
  board.connectorPropertiesPanel = new ConnectorPropertiesPanel(board.coreMoodboard.eventBus, board.canvasContainer, board.coreMoodboard);
155
157
  board.shapePropertiesPanel = new ShapePropertiesPanel(board.coreMoodboard.eventBus, board.canvasContainer, board.coreMoodboard);
156
158
  board.drawingPropertiesPanel = new DrawingPropertiesPanel(board.coreMoodboard.eventBus, board.canvasContainer, board.coreMoodboard);
@@ -0,0 +1,8 @@
1
+ import { ImageObject } from './ImageObject.js';
2
+
3
+ /**
4
+ * Model3dScreenshotImageObject
5
+ * Специализированный тип для превью 3D-моделей.
6
+ * Визуально и по трансформациям полностью повторяет ImageObject.
7
+ */
8
+ export class Model3dScreenshotImageObject extends ImageObject {}
@@ -5,6 +5,7 @@ import { TextObject } from './TextObject.js';
5
5
  import { EmojiObject } from './EmojiObject.js';
6
6
  import { ImageObject } from './ImageObject.js';
7
7
  import { RevitScreenshotImageObject } from './RevitScreenshotImageObject.js';
8
+ import { Model3dScreenshotImageObject } from './Model3dScreenshotImageObject.js';
8
9
  import { CommentObject } from './CommentObject.js';
9
10
  import { NoteObject } from './NoteObject.js';
10
11
  import { FileObject } from './FileObject.js';
@@ -25,6 +26,7 @@ export class ObjectFactory {
25
26
  ['emoji', EmojiObject],
26
27
  ['image', ImageObject],
27
28
  ['revit-screenshot-img', RevitScreenshotImageObject],
29
+ ['model3d-screenshot-img', Model3dScreenshotImageObject],
28
30
  ['comment', CommentObject],
29
31
  ['note', NoteObject],
30
32
  ['file', FileObject],
@@ -141,6 +141,124 @@ export class AiClient {
141
141
  }
142
142
  return res.json();
143
143
  }
144
+
145
+ /**
146
+ * Отправляет джоб генерации 3D-модели.
147
+ * @param {object} args
148
+ * @param {string} [args.provider='hunyuan-3d']
149
+ * @param {string} [args.mode='image'] 'text'|'image'|'multi'
150
+ * @param {string} [args.prompt]
151
+ * @param {File} [args.image]
152
+ * @param {Array<{file: File, viewType: string}>} [args.multiViewImages]
153
+ * @param {string} [args.model='3.1']
154
+ * @param {string} [args.generateType]
155
+ * @param {number} [args.faceCount]
156
+ * @param {boolean} [args.pbr]
157
+ * @param {string} [args.downloadFormat]
158
+ * @param {AbortSignal} [args.signal]
159
+ * @returns {Promise<{jobId: string}>}
160
+ */
161
+ async submit3dModel({ provider = 'hunyuan-3d', mode = 'image', prompt, image, multiViewImages, model = '3.1', generateType, faceCount, pbr, downloadFormat, signal }) {
162
+ const body = { mode, model, downloadFormat };
163
+ if (generateType !== undefined) body.generateType = generateType;
164
+ if (faceCount !== undefined) body.faceCount = faceCount;
165
+ if (pbr !== undefined) body.pbr = pbr;
166
+
167
+ if (mode === 'text') {
168
+ body.prompt = prompt;
169
+ } else if (mode === 'multi') {
170
+ if (Array.isArray(multiViewImages) && multiViewImages.length) {
171
+ body.multiViewImages = await Promise.all(
172
+ multiViewImages.map(async ({ file, viewType }) => {
173
+ const [encoded] = await filesToBase64([file]);
174
+ return { ...encoded, viewType };
175
+ })
176
+ );
177
+ }
178
+ } else {
179
+ if (image) {
180
+ const [encoded] = await filesToBase64([image]);
181
+ body.image = encoded;
182
+ }
183
+ }
184
+
185
+ const res = await this._fetch(`${this._baseUrl}/${provider}/model3d`, {
186
+ method: 'POST',
187
+ headers: { 'Content-Type': 'application/json', 'Accept': 'application/json' },
188
+ body: JSON.stringify(body),
189
+ signal
190
+ });
191
+ if (!res.ok) {
192
+ const detail = await safeReadError(res);
193
+ throw new Error(`AiClient.submit3dModel (${res.status}): ${detail}`);
194
+ }
195
+ return res.json();
196
+ }
197
+
198
+ /**
199
+ * Опрашивает статус джоба 3D-модели.
200
+ * @param {string} jobId
201
+ * @param {AbortSignal} [signal]
202
+ * @param {string} [provider='hunyuan-3d']
203
+ * @param {string} [format]
204
+ * @returns {Promise<object>}
205
+ */
206
+ async poll3dModel(jobId, signal, provider = 'hunyuan-3d', format) {
207
+ const url = `${this._baseUrl}/${provider}/model3d/${jobId}${format ? `?format=${format}` : ''}`;
208
+ const res = await this._fetch(url, {
209
+ method: 'GET',
210
+ headers: { 'Accept': 'application/json' },
211
+ signal
212
+ });
213
+ if (!res.ok) {
214
+ const detail = await safeReadError(res);
215
+ throw new Error(`AiClient.poll3dModel (${res.status}): ${detail}`);
216
+ }
217
+ return res.json();
218
+ }
219
+
220
+ /**
221
+ * Отправляет джоб конвертации GLB -> FBX/STL.
222
+ * @param {object} args
223
+ * @param {string} args.glbUrl
224
+ * @param {string} args.format 'fbx'|'stl'
225
+ * @param {AbortSignal} [args.signal]
226
+ * @returns {Promise<{jobId: string}>}
227
+ */
228
+ async submitConvert3d({ glbUrl, format, signal }) {
229
+ const res = await this._fetch(`${this._baseUrl}/convert3d`, {
230
+ method: 'POST',
231
+ headers: { 'Content-Type': 'application/json', 'Accept': 'application/json' },
232
+ body: JSON.stringify({ glbUrl, format }),
233
+ signal
234
+ });
235
+ if (!res.ok) {
236
+ const detail = await safeReadError(res);
237
+ throw new Error(`AiClient.submitConvert3d (${res.status}): ${detail}`);
238
+ }
239
+ return res.json();
240
+ }
241
+
242
+ /**
243
+ * Опрашивает статус джоба конвертации.
244
+ * @param {string} jobId
245
+ * @param {AbortSignal} [signal]
246
+ * @param {string} [format]
247
+ * @returns {Promise<object>}
248
+ */
249
+ async pollConvert3d(jobId, signal, format) {
250
+ const url = `${this._baseUrl}/convert3d/${jobId}${format ? `?format=${format}` : ''}`;
251
+ const res = await this._fetch(url, {
252
+ method: 'GET',
253
+ headers: { 'Accept': 'application/json' },
254
+ signal
255
+ });
256
+ if (!res.ok) {
257
+ const detail = await safeReadError(res);
258
+ throw new Error(`AiClient.pollConvert3d (${res.status}): ${detail}`);
259
+ }
260
+ return res.json();
261
+ }
144
262
  }
145
263
 
146
264
  /**
@@ -221,12 +339,22 @@ function safeJson(text) {
221
339
  }
222
340
 
223
341
  async function safeReadError(res) {
342
+ const fallback = res.statusText || `HTTP ${res.status}`;
224
343
  try {
225
344
  const text = await res.text();
226
345
  const json = safeJson(text);
227
- return json?.error ? json.error : (text || res.statusText);
346
+ if (json?.error) {
347
+ return typeof json.error === 'string' ? json.error : fallback;
348
+ }
349
+ // Не отдаём в UI сырой HTML/стектрейс (Laravel debug-страница) или
350
+ // слишком длинное тело — показываем лаконичный статус вместо «простыни».
351
+ const trimmed = (text || '').trim();
352
+ if (!trimmed || trimmed.startsWith('<') || trimmed.length > 200) {
353
+ return fallback;
354
+ }
355
+ return trimmed;
228
356
  } catch {
229
- return res.statusText;
357
+ return fallback;
230
358
  }
231
359
  }
232
360
 
@@ -0,0 +1,238 @@
1
+ /**
2
+ * Контроллер сессии генерации 3D-модели (Hunyuan 3D).
3
+ *
4
+ * Одна ответственность: оркестрирует submit -> poll-цикл через AiClient,
5
+ * держит состояние джоба и уведомляет подписчиков об изменениях.
6
+ * Не знает про DOM, доску и UI.
7
+ *
8
+ * Состояние:
9
+ * - status: 'idle' | 'submitting' | 'polling' | 'done' | 'error'
10
+ * - progress: number (0–100)
11
+ * - stage: 'geometry' | 'texture' | 'convert' | null
12
+ * - error: string | null
13
+ * - jobId: string | null
14
+ * - result: { previewBase64, mimeType, modelUrl, format } | null
15
+ */
16
+
17
+ const POLL_INTERVAL_MS = 2500;
18
+ const JOB_TIMEOUT_MS = 600_000; // 600с: покрывает оба джоба (генерация + конвертация)
19
+
20
+ export class Model3dSessionController {
21
+ /**
22
+ * @param {object} deps
23
+ * @param {import('./AiClient.js').AiClient} deps.aiClient
24
+ */
25
+ constructor({ aiClient }) {
26
+ this._client = aiClient;
27
+ this._listeners = new Set();
28
+ this._abort = null;
29
+
30
+ this._state = {
31
+ status: 'idle',
32
+ progress: 0,
33
+ stage: null,
34
+ error: null,
35
+ jobId: null,
36
+ result: null
37
+ };
38
+ }
39
+
40
+ getState() {
41
+ return this._state;
42
+ }
43
+
44
+ /**
45
+ * @param {function} listener
46
+ * @returns {function} unsubscribe
47
+ */
48
+ subscribe(listener) {
49
+ this._listeners.add(listener);
50
+ return () => this._listeners.delete(listener);
51
+ }
52
+
53
+ /**
54
+ * Запускает двухфазный джоб: генерация -> (опционально) конвертация.
55
+ * @param {object} args
56
+ * @param {string} [args.mode='image'] 'text'|'image'|'multi'
57
+ * @param {string} [args.prompt]
58
+ * @param {File} [args.image]
59
+ * @param {Array<{file: File, viewType: string}>} [args.multiViewImages]
60
+ * @param {string} [args.model='3.1']
61
+ * @param {string} [args.generateType]
62
+ * @param {number} [args.faceCount]
63
+ * @param {boolean} [args.pbr]
64
+ * @param {string} [args.downloadFormat='glb']
65
+ * @param {AbortSignal} [args.signal]
66
+ * @returns {Promise<void>}
67
+ */
68
+ async start({ mode = 'image', prompt, image, multiViewImages, model = '3.1', generateType, faceCount, pbr, downloadFormat = 'glb', signal: externalSignal } = {}) {
69
+ if (this._abort) this.abort();
70
+
71
+ const abort = new AbortController();
72
+ this._abort = abort;
73
+ const { signal } = abort;
74
+
75
+ if (externalSignal) {
76
+ if (externalSignal.aborted) { abort.abort(); return; }
77
+ externalSignal.addEventListener('abort', () => abort.abort(), { once: true });
78
+ }
79
+
80
+ this._setState({ status: 'submitting', progress: 0, stage: null, error: null, jobId: null, result: null });
81
+
82
+ const timeoutId = setTimeout(() => {
83
+ if (this._abort === abort) {
84
+ abort.abort();
85
+ this._setState({ status: 'error', error: 'Таймаут: джоб превысил 600 секунд' });
86
+ }
87
+ }, JOB_TIMEOUT_MS);
88
+
89
+ try {
90
+ const { jobId } = await this._client.submit3dModel({
91
+ mode, prompt, image, multiViewImages, model, generateType, faceCount, pbr, downloadFormat, signal
92
+ });
93
+ if (signal.aborted) return;
94
+
95
+ this._setState({ status: 'polling', jobId });
96
+
97
+ const genResult = await this._pollLoop(jobId, signal, downloadFormat);
98
+ if (!genResult || signal.aborted) return;
99
+
100
+ if (!genResult.needsConvert) {
101
+ const { previewBase64, mimeType, modelUrl, format } = genResult;
102
+ this._setState({ status: 'done', progress: 100, stage: null, result: { previewBase64, mimeType, modelUrl, format } });
103
+ return;
104
+ }
105
+
106
+ // Фаза 2: конвертация GLB -> FBX/STL
107
+ const { previewBase64: genPreview, mimeType: genMime, sourceGlbUrl } = genResult;
108
+ this._setState({ stage: 'convert', progress: 0 });
109
+
110
+ const { jobId: convertJobId } = await this._client.submitConvert3d({
111
+ glbUrl: sourceGlbUrl, format: downloadFormat, signal
112
+ });
113
+ if (signal.aborted) return;
114
+
115
+ this._setState({ jobId: convertJobId });
116
+
117
+ const convResult = await this._pollConvertLoop(convertJobId, signal, downloadFormat);
118
+ if (!convResult || signal.aborted) return;
119
+
120
+ this._setState({
121
+ status: 'done',
122
+ progress: 100,
123
+ stage: null,
124
+ result: { previewBase64: genPreview, mimeType: genMime, modelUrl: convResult.modelUrl, format: convResult.format }
125
+ });
126
+ } catch (err) {
127
+ if (err?.name === 'AbortError' || signal.aborted) return;
128
+ this._setState({ status: 'error', error: err?.message || 'Ошибка запроса' });
129
+ } finally {
130
+ clearTimeout(timeoutId);
131
+ if (this._abort === abort) this._abort = null;
132
+ }
133
+ }
134
+
135
+ /** Прерывает submit или поллинг. */
136
+ abort() {
137
+ if (this._abort) {
138
+ try { this._abort.abort(); } catch { /* noop */ }
139
+ this._abort = null;
140
+ }
141
+ }
142
+
143
+ async _pollLoop(jobId, signal, format) {
144
+ while (!signal.aborted) {
145
+ await sleep(POLL_INTERVAL_MS, signal);
146
+ if (signal.aborted) return null;
147
+
148
+ let data;
149
+ try {
150
+ data = await this._client.poll3dModel(jobId, signal, undefined, format);
151
+ } catch (err) {
152
+ if (err?.name === 'AbortError' || signal.aborted) return null;
153
+ this._setState({ status: 'error', error: err?.message || 'Ошибка поллинга' });
154
+ return null;
155
+ }
156
+
157
+ const { status, progress, stage, error } = data;
158
+
159
+ if (status === 'done') {
160
+ return data;
161
+ }
162
+
163
+ if (status === 'error') {
164
+ this._setState({ status: 'error', error: error || 'Ошибка джоба' });
165
+ return null;
166
+ }
167
+
168
+ // 'pending' | 'running' — обновляем прогресс
169
+ this._setState({
170
+ progress: progress ?? this._state.progress,
171
+ stage: stage ?? this._state.stage
172
+ });
173
+ }
174
+ return null;
175
+ }
176
+
177
+ async _pollConvertLoop(jobId, signal, format) {
178
+ while (!signal.aborted) {
179
+ await sleep(POLL_INTERVAL_MS, signal);
180
+ if (signal.aborted) return null;
181
+
182
+ let data;
183
+ try {
184
+ data = await this._client.pollConvert3d(jobId, signal, format);
185
+ } catch (err) {
186
+ if (err?.name === 'AbortError' || signal.aborted) return null;
187
+ this._setState({ status: 'error', error: err?.message || 'Ошибка конвертации' });
188
+ return null;
189
+ }
190
+
191
+ const { status, progress, error } = data;
192
+
193
+ if (status === 'done') {
194
+ return data;
195
+ }
196
+
197
+ if (status === 'error') {
198
+ this._setState({ status: 'error', error: error || 'Ошибка конвертации' });
199
+ return null;
200
+ }
201
+
202
+ this._setState({ progress: progress ?? this._state.progress });
203
+ }
204
+ return null;
205
+ }
206
+
207
+ _setState(patch) {
208
+ this._state = { ...this._state, ...patch };
209
+ this._emit();
210
+ }
211
+
212
+ _emit() {
213
+ for (const listener of this._listeners) {
214
+ try { listener(this._state); } catch (err) {
215
+ console.error('[Model3dSession] listener error:', err);
216
+ }
217
+ }
218
+ }
219
+ }
220
+
221
+ /**
222
+ * Promise, который разрешается через ms миллисекунд или отклоняется по AbortSignal.
223
+ * @param {number} ms
224
+ * @param {AbortSignal} [signal]
225
+ */
226
+ function sleep(ms, signal) {
227
+ return new Promise((resolve, reject) => {
228
+ if (signal?.aborted) {
229
+ reject(new DOMException('Aborted', 'AbortError'));
230
+ return;
231
+ }
232
+ const id = setTimeout(resolve, ms);
233
+ signal?.addEventListener('abort', () => {
234
+ clearTimeout(id);
235
+ reject(new DOMException('Aborted', 'AbortError'));
236
+ }, { once: true });
237
+ });
238
+ }
@@ -0,0 +1,8 @@
1
+ /**
2
+ * Панель свойств изображения.
3
+ * Stub — заглушка, реализация будет добавлена отдельной задачей.
4
+ */
5
+ export class ImagePropertiesPanel {
6
+ constructor(_eventBus, _container, _core = null) {}
7
+ destroy() {}
8
+ }
@@ -32,6 +32,14 @@ export class ChatComposer {
32
32
  * @type {{ file: File, sourceObjectId: string|null }[]}
33
33
  */
34
34
  this._attachments = [];
35
+ this._attachmentLabelProvider = null;
36
+ /**
37
+ * Жёстко зафиксированный placeholder. Когда задан, логика вложений
38
+ * (`_renderAttachmentsPreview`) не перетирает текст. Нужно для 3D-режима
39
+ * «Изображение», где описание не требуется.
40
+ * @type {string|null}
41
+ */
42
+ this._placeholderOverride = null;
35
43
  }
36
44
 
37
45
  attach() {
@@ -135,6 +143,39 @@ export class ChatComposer {
135
143
  this._attachments = [];
136
144
  }
137
145
 
146
+ /**
147
+ * Включает/выключает поле ввода текста.
148
+ * В 3D-режимах image/multi textarea должна быть заблокирована.
149
+ * @param {boolean} enabled
150
+ */
151
+ setInputEnabled(enabled) {
152
+ this._textarea.disabled = !enabled;
153
+ this._textarea.style.opacity = enabled ? '' : '0.4';
154
+ this._textarea.style.cursor = enabled ? '' : 'not-allowed';
155
+ }
156
+
157
+ /**
158
+ * Фиксирует placeholder, который не перетирается логикой вложений.
159
+ * `null` — вернуть обычное поведение (текст зависит от наличия вложений).
160
+ * @param {string|null} text
161
+ */
162
+ setPlaceholderOverride(text) {
163
+ this._placeholderOverride = text ?? null;
164
+ if (this._placeholderOverride != null) {
165
+ this._textarea.placeholder = this._placeholderOverride;
166
+ }
167
+ }
168
+
169
+ /**
170
+ * Устанавливает провайдер пользовательских лейблов для превью вложений.
171
+ * Используется в мультивью: i===0 → 'Фронт', i>0 → ракурс по порядку.
172
+ * @param {((index: number) => string) | null} fn
173
+ */
174
+ setAttachmentLabelProvider(fn) {
175
+ this._attachmentLabelProvider = fn ?? null;
176
+ this._renderAttachmentsPreview();
177
+ }
178
+
138
179
  _submit() {
139
180
  const text = this._textarea.value;
140
181
  const trimmed = text.trim();
@@ -180,13 +221,17 @@ export class ChatComposer {
180
221
  if (this._attachments.length === 0) {
181
222
  container.classList.remove('is-visible');
182
223
  inputRow?.classList.remove('has-attachments');
183
- this._textarea.placeholder = 'Опишите то, что хотите сгенерировать';
224
+ if (this._placeholderOverride == null) {
225
+ this._textarea.placeholder = 'Опишите то, что хотите сгенерировать';
226
+ }
184
227
  return;
185
228
  }
186
229
 
187
230
  container.classList.add('is-visible');
188
231
  inputRow?.classList.add('has-attachments');
189
- this._textarea.placeholder = 'Опишите правку, изменение или стилевое направление эталонного изображения';
232
+ if (this._placeholderOverride == null) {
233
+ this._textarea.placeholder = 'Опишите правку, изменение или стилевое направление эталонного изображения';
234
+ }
190
235
  for (let i = 0; i < this._attachments.length; i++) {
191
236
  const entry = this._attachments[i];
192
237
  const item = this._buildAttachmentItem(entry.file, i);
@@ -219,7 +264,9 @@ export class ChatComposer {
219
264
 
220
265
  const badge = document.createElement('div');
221
266
  badge.className = 'moodboard-chat__attachment-badge';
222
- badge.textContent = String(index + 1);
267
+ badge.textContent = this._attachmentLabelProvider
268
+ ? (this._attachmentLabelProvider(index) ?? String(index + 1))
269
+ : String(index + 1);
223
270
  item.appendChild(badge);
224
271
 
225
272
  const remove = document.createElement('button');