@sequent-org/moodboard 1.4.71 → 1.4.73

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.71",
3
+ "version": "1.4.73",
4
4
  "type": "module",
5
5
  "description": "Interactive moodboard",
6
6
  "main": "./src/index.js",
@@ -296,7 +296,7 @@ export function setupClipboardFlow(core) {
296
296
  }
297
297
  });
298
298
 
299
- core.eventBus.on(Events.UI.PasteImageAt, async ({ x, y, src, name, skipUpload, aiMessageId, objectType, modelUrl, format }) => {
299
+ core.eventBus.on(Events.UI.PasteImageAt, async ({ x, y, src, name, skipUpload, aiMessageId, objectType, modelUrl, format, placeholderId }) => {
300
300
  if (!src) return;
301
301
 
302
302
  // Отдельный путь для 3D-скрина: без resolveRevitImagePayload и без загрузки на сервер
@@ -327,7 +327,10 @@ export function setupClipboardFlow(core) {
327
327
  }
328
328
 
329
329
  const uploaded = await ensureServerImage({ src, name, skipUpload });
330
- if (!uploaded?.src) return;
330
+ if (!uploaded?.src) {
331
+ if (placeholderId) core.removeDropPlaceholder?.(placeholderId);
332
+ return;
333
+ }
331
334
  const world = core.pixi.worldLayer || core.pixi.app.stage;
332
335
  const s = world?.scale?.x || 1;
333
336
  const worldX = (x - (world?.x || 0)) / s;
@@ -358,6 +361,10 @@ export function setupClipboardFlow(core) {
358
361
  { x: Math.round(worldX - Math.round(w / 2)), y: Math.round(worldY - Math.round(h / 2)) },
359
362
  properties
360
363
  );
364
+ // Связываем drop-заглушку с объектом: снимется, когда картинка станет видимой.
365
+ if (placeholderId && createdData?.id) {
366
+ core.linkDropPlaceholderToObject?.(createdData.id, placeholderId);
367
+ }
361
368
  // data:-URL изображения (AI-генерация) не проходят через SaveManager,
362
369
  // поэтому Events.Save.Success никогда не придёт и объект останется скрытым.
363
370
  // Раскрываем сразу, минуя ожидание подтверждения сохранения.
@@ -372,10 +379,12 @@ export function setupClipboardFlow(core) {
372
379
  img.decoding = 'async';
373
380
  img.onload = () => { void placeWithAspect(img.naturalWidth || 0, img.naturalHeight || 0); };
374
381
  img.onerror = () => {
382
+ if (placeholderId) core.removeDropPlaceholder?.(placeholderId);
375
383
  alert('Не удалось загрузить изображение с сервера. Изображение не добавлено.');
376
384
  };
377
385
  img.src = uploaded.src;
378
386
  } catch (_) {
387
+ if (placeholderId) core.removeDropPlaceholder?.(placeholderId);
379
388
  alert('Не удалось загрузить изображение с сервера. Изображение не добавлено.');
380
389
  }
381
390
  });
@@ -185,7 +185,11 @@ export function setupLayerAndViewportFlow(core) {
185
185
  settings: { pan: { x: world.x || 0, y: world.y || 0 } }
186
186
  });
187
187
  } catch (_) {}
188
- core.eventBus.emit(Events.Viewport.Changed);
188
+ // Во время pan не эмитим Viewport.Changed: все viewport-реактивные компоненты
189
+ // уже репозиционируются по Tool.PanUpdate, а повторный Viewport.Changed заставлял
190
+ // их пересчитываться дважды за одно движение. Сетка — единственный получатель
191
+ // Viewport.Changed без подписки на PanUpdate — обновляется прямым вызовом.
192
+ core.boardService?.refreshGridViewport?.();
189
193
  });
190
194
 
191
195
  core.eventBus.on(Events.UI.ZoomSelection, () => {
package/src/core/index.js CHANGED
@@ -69,6 +69,11 @@ export class CoreMoodBoard {
69
69
  this.gridSnapResolver = new GridSnapResolver(this);
70
70
  // Объекты, требующие подтверждения сохранения (image/file), показываем только после save:success.
71
71
  this._pendingPersistAckVisibilityIds = new Set();
72
+
73
+ // Градиентные заглушки на месте перетащенных картинок (пока идёт аплоад/сохранение).
74
+ this._dropPlaceholders = new Map(); // placeholderId -> { destroy, timeoutId, objectId }
75
+ this._dropPlaceholderByObject = new Map(); // objectId -> placeholderId
76
+ this._dropPlaceholderSeq = 0;
72
77
 
73
78
  // Связываем SaveManager с ApiClient для правильной обработки изображений
74
79
  this.saveManager.setApiClient(this.apiClient);
@@ -490,6 +495,10 @@ export class CoreMoodBoard {
490
495
  if (pixiObject) {
491
496
  pixiObject.visible = !!visible;
492
497
  }
498
+ // Картинка реально появилась на доске → снимаем её drop-заглушку.
499
+ if (visible) {
500
+ this._removeDropPlaceholderForObject(objectId);
501
+ }
493
502
  }
494
503
 
495
504
  revealPendingObjectsAfterSave() {
@@ -500,6 +509,66 @@ export class CoreMoodBoard {
500
509
  this._pendingPersistAckVisibilityIds.clear();
501
510
  }
502
511
 
512
+ /**
513
+ * Регистрирует градиентную заглушку drag-and-drop. Заглушку создаёт вызывающий
514
+ * код (ToolEventRouter) и добавляет в worldLayer; здесь храним только её lifecycle.
515
+ * @param {{ destroy: () => void }} entry
516
+ * @returns {string} placeholderId
517
+ */
518
+ registerDropPlaceholder(entry) {
519
+ if (!entry || typeof entry.destroy !== 'function') return null;
520
+ const placeholderId = `dph_${++this._dropPlaceholderSeq}`;
521
+ // Страховка от утечки: если объект так и не появится (ошибка сохранения и т.п.).
522
+ const timeoutId = setTimeout(() => this.removeDropPlaceholder(placeholderId), 30000);
523
+ this._dropPlaceholders.set(placeholderId, {
524
+ destroy: entry.destroy,
525
+ timeoutId,
526
+ objectId: null,
527
+ });
528
+ return placeholderId;
529
+ }
530
+
531
+ /** Связывает заглушку с созданным объектом; если объект уже видим — снимает сразу. */
532
+ linkDropPlaceholderToObject(objectId, placeholderId) {
533
+ if (!placeholderId) return;
534
+ const entry = this._dropPlaceholders.get(placeholderId);
535
+ if (!entry) return;
536
+ entry.objectId = objectId;
537
+ this._dropPlaceholderByObject.set(objectId, placeholderId);
538
+ const pixiObject = this.pixi?.objects?.get?.(objectId);
539
+ if (pixiObject && pixiObject.visible) {
540
+ this.removeDropPlaceholder(placeholderId);
541
+ }
542
+ }
543
+
544
+ removeDropPlaceholder(placeholderId) {
545
+ const entry = this._dropPlaceholders.get(placeholderId);
546
+ if (!entry) return;
547
+ this._dropPlaceholders.delete(placeholderId);
548
+ if (entry.objectId) {
549
+ this._dropPlaceholderByObject.delete(entry.objectId);
550
+ }
551
+ try {
552
+ if (entry.timeoutId) clearTimeout(entry.timeoutId);
553
+ } catch (_) { /* no-op */ }
554
+ try {
555
+ entry.destroy();
556
+ } catch (_) { /* no-op */ }
557
+ }
558
+
559
+ _removeDropPlaceholderForObject(objectId) {
560
+ const placeholderId = this._dropPlaceholderByObject.get(objectId);
561
+ if (placeholderId) {
562
+ this.removeDropPlaceholder(placeholderId);
563
+ }
564
+ }
565
+
566
+ clearDropPlaceholders() {
567
+ for (const placeholderId of Array.from(this._dropPlaceholders.keys())) {
568
+ this.removeDropPlaceholder(placeholderId);
569
+ }
570
+ }
571
+
503
572
  // === Прикрепления к фреймам ===
504
573
  // Логика фреймов перенесена в FrameService
505
574
 
@@ -701,7 +770,12 @@ export class CoreMoodBoard {
701
770
 
702
771
  // Устанавливаем флаг уничтожения
703
772
  this.destroyed = true;
704
-
773
+
774
+ // Снимаем незакрытые drop-заглушки (тикеры/текстуры/ноды)
775
+ try {
776
+ this.clearDropPlaceholders();
777
+ } catch (_) { /* no-op */ }
778
+
705
779
  // Останавливаем ResizeObserver
706
780
  if (this.resizeObserver) {
707
781
  this.resizeObserver.disconnect();
@@ -18,6 +18,14 @@ function shouldOpenEditorForObject(objectType) {
18
18
  || objectType === 'file';
19
19
  }
20
20
 
21
+ // Картинки при размещении не выделяем: выделение с попаданием в AI-композер —
22
+ // только осознанный клик пользователя по уже лежащему на мудборде изображению.
23
+ function shouldSelectOnCreate(objectType) {
24
+ return objectType !== 'image'
25
+ && objectType !== 'revit-screenshot-img'
26
+ && objectType !== 'model3d-screenshot-img';
27
+ }
28
+
21
29
  function focusCreatedObject(board, createdObject) {
22
30
  if (!board?.coreMoodboard?.eventBus || !createdObject?.id) return;
23
31
  const objectType = createdObject?.type || null;
@@ -32,7 +40,7 @@ function focusCreatedObject(board, createdObject) {
32
40
  setTimeout(() => {
33
41
  board.coreMoodboard.eventBus.emit(Events.Keyboard.ToolSelect, { tool: 'select' });
34
42
  const selectTool = getSelectTool(board);
35
- if (typeof selectTool?.setSelection === 'function') {
43
+ if (shouldSelectOnCreate(objectType) && typeof selectTool?.setSelection === 'function') {
36
44
  selectTool.setSelection([createdObject.id]);
37
45
  }
38
46
  if (shouldOpenEditorForObject(objectType)) {
@@ -56,9 +56,14 @@ export class MindmapObject {
56
56
  const rot = g.rotation || 0;
57
57
 
58
58
  this._draw();
59
- g.pivot.set(Math.floor(this.width / 2), Math.floor(this.height / 2));
60
- g.x = Math.round(centerX);
61
- g.y = Math.round(centerY);
59
+ // Точный pivot (size/2, без floor) и точный центр: при нечётной ширине/высоте
60
+ // floor терял 0.5px, из-за чего капсула смещалась вниз-вправо относительно
61
+ // бокса объекта (position — целые числа). Рамка выделения строится по боксу
62
+ // объекта, поэтому во время резайза/редактирования её верх уходил выше капсулы.
63
+ // top-left = center - pivot = position (целое) → грани попадают в целый пиксель.
64
+ g.pivot.set(this.width / 2, this.height / 2);
65
+ g.x = centerX;
66
+ g.y = centerY;
62
67
  g.rotation = rot;
63
68
  }
64
69
 
@@ -1,5 +1,68 @@
1
1
  import { Events } from '../../core/events/Events.js';
2
2
  import { ToolManagerGuards } from './ToolManagerGuards.js';
3
+ import { createDropPlaceholder } from '../../utils/dropPlaceholder.js';
4
+
5
+ /**
6
+ * Считывает натуральные пропорции файла-картинки локально (без сети), чтобы
7
+ * заглушка сразу получила тот же размер, что и будущее изображение.
8
+ * @returns {Promise<number>} aspectRatio (w/h), либо дефолт 3/2 при сбое.
9
+ */
10
+ function readImageAspectRatio(file) {
11
+ return new Promise((resolve) => {
12
+ let url = null;
13
+ const done = (ar) => {
14
+ try { if (url) URL.revokeObjectURL(url); } catch (_) { /* no-op */ }
15
+ resolve(ar);
16
+ };
17
+ try {
18
+ url = URL.createObjectURL(file);
19
+ const img = new Image();
20
+ img.onload = () => {
21
+ const w = img.naturalWidth || 0;
22
+ const h = img.naturalHeight || 0;
23
+ done(w > 0 && h > 0 ? w / h : 1.5);
24
+ };
25
+ img.onerror = () => done(1.5);
26
+ img.src = url;
27
+ } catch (_) {
28
+ done(1.5);
29
+ }
30
+ });
31
+ }
32
+
33
+ /**
34
+ * Создаёт градиентную заглушку в worldLayer точно там и того размера,
35
+ * какими окажется приземлённое изображение (см. ClipboardFlow PasteImageAt:
36
+ * ширина 300 мировых ед., высота 300/AR, центр в точке дропа + веерный сдвиг).
37
+ * @returns {Promise<string|null>} placeholderId
38
+ */
39
+ async function createImageDropPlaceholder(manager, file, baseScreenX, baseScreenY, index) {
40
+ const core = manager.core;
41
+ const app = core?.pixi?.app;
42
+ const world = core?.pixi?.worldLayer || app?.stage;
43
+ if (!core || !app || !world || typeof core.registerDropPlaceholder !== 'function') {
44
+ return null;
45
+ }
46
+
47
+ const ar = await readImageAspectRatio(file);
48
+ const w = 300;
49
+ const h = Math.max(1, Math.round(w / ar));
50
+
51
+ // Тот же экранный сдвиг веером, что и в emitAt (25 px на каждую следующую картинку).
52
+ const offset = 25 * index;
53
+ const screenX = baseScreenX + offset;
54
+ const screenY = baseScreenY + offset;
55
+ const scale = world?.scale?.x || 1;
56
+ const worldX = (screenX - (world?.x || 0)) / scale;
57
+ const worldY = (screenY - (world?.y || 0)) / scale;
58
+
59
+ const placeholder = createDropPlaceholder(app, w, h);
60
+ placeholder.node.x = Math.round(worldX - Math.round(w / 2));
61
+ placeholder.node.y = Math.round(worldY - Math.round(h / 2));
62
+ world.addChild(placeholder.node);
63
+
64
+ return core.registerDropPlaceholder({ destroy: placeholder.destroy });
65
+ }
3
66
 
4
67
  function createPointerEvent(manager, event, extras = {}) {
5
68
  const rect = manager.container.getBoundingClientRect();
@@ -275,9 +338,10 @@ export class ToolEventRouter {
275
338
  return { dx: direction.x * step * ring, dy: direction.y * step * ring };
276
339
  };
277
340
 
278
- const emitAt = (src, name, offsetIndex = 0) => {
341
+ const emitAt = (src, name, offsetIndex = 0, placeholderId = null) => {
279
342
  if (!isCurrentDrop()) {
280
343
  logDropDebug(diagnostics, 'emit_skipped_stale_drop', { route: 'image', offsetIndex });
344
+ if (placeholderId) manager.core?.removeDropPlaceholder?.(placeholderId);
281
345
  return;
282
346
  }
283
347
  const offset = 25 * offsetIndex;
@@ -285,7 +349,8 @@ export class ToolEventRouter {
285
349
  x: x + offset,
286
350
  y: y + offset,
287
351
  src,
288
- name
352
+ name,
353
+ placeholderId
289
354
  });
290
355
  };
291
356
 
@@ -314,6 +379,23 @@ export class ToolEventRouter {
314
379
  const imageFiles = limitedFiles.filter((file) => file.type && file.type.startsWith('image/'));
315
380
  if (imageFiles.length > 0) {
316
381
  logDropDebug(diagnostics, 'route_image_files', { count: imageFiles.length });
382
+
383
+ // Мгновенно рисуем градиентные заглушки на местах будущих картинок,
384
+ // чтобы перекрыть лаг аплоада и подтверждения сохранения.
385
+ const placeholderIds = await Promise.all(
386
+ imageFiles.map((file, index) => {
387
+ if (!isCurrentDrop()) return null;
388
+ return createImageDropPlaceholder(manager, file, x, y, index)
389
+ .catch(() => null);
390
+ })
391
+ );
392
+ // Если дроп устарел, пока считали пропорции — снимаем созданные заглушки.
393
+ if (!isCurrentDrop()) {
394
+ for (const pid of placeholderIds) {
395
+ if (pid) manager.core?.removeDropPlaceholder?.(pid);
396
+ }
397
+ }
398
+
317
399
  const imagePlacements = await mapWithConcurrency(imageFiles, 2, async (file, index) => {
318
400
  logDropDebug(diagnostics, 'image_upload_start', {
319
401
  fileName: file.name || 'image',
@@ -327,6 +409,7 @@ export class ToolEventRouter {
327
409
  logDropDebug(diagnostics, 'image_upload_stale_drop_ignored', {
328
410
  fileName: uploadResult?.name || file.name || 'image'
329
411
  });
412
+ if (placeholderIds[index]) manager.core?.removeDropPlaceholder?.(placeholderIds[index]);
330
413
  return null;
331
414
  }
332
415
  logDropDebug(diagnostics, 'image_upload_success', {
@@ -344,6 +427,7 @@ export class ToolEventRouter {
344
427
  diagnostics,
345
428
  { fileName: file.name || 'image' }
346
429
  );
430
+ if (placeholderIds[index]) manager.core?.removeDropPlaceholder?.(placeholderIds[index]);
347
431
  return null;
348
432
  }
349
433
  } catch (error) {
@@ -361,12 +445,13 @@ export class ToolEventRouter {
361
445
  message: error?.message || String(error)
362
446
  }
363
447
  );
448
+ if (placeholderIds[index]) manager.core?.removeDropPlaceholder?.(placeholderIds[index]);
364
449
  return null;
365
450
  }
366
451
  });
367
452
  for (const placement of imagePlacements) {
368
453
  if (!placement) continue;
369
- emitAt(placement.src, placement.name, placement.index);
454
+ emitAt(placement.src, placement.name, placement.index, placeholderIds[placement.index]);
370
455
  }
371
456
  logDropDebug(diagnostics, 'drop_done', { route: 'image_files', itemsProcessed: imageFiles.length });
372
457
  return;
@@ -315,7 +315,10 @@ export function openMindmapEditor(object, create = false) {
315
315
  backdrop.style.height = '100%';
316
316
  backdrop.style.display = 'flex';
317
317
  backdrop.style.alignItems = 'center';
318
- backdrop.style.justifyContent = 'center';
318
+ // flex-start, а не center: textarea (источник координат каретки) left-aligned,
319
+ // backdrop должен совпадать с ней по горизонтали, иначе видимый текст уезжает
320
+ // относительно каретки на короткой строке/placeholder.
321
+ backdrop.style.justifyContent = 'flex-start';
319
322
  }
320
323
 
321
324
  const initialCssWidth = targetWidth;
@@ -332,16 +335,6 @@ export function openMindmapEditor(object, create = false) {
332
335
  : ((typeof properties.width === 'number' && properties.width > 0) ? properties.width : MINDMAP_LAYOUT.width))
333
336
  )
334
337
  );
335
- const stableBaseWorldHeight = Math.max(
336
- 1,
337
- Math.round(
338
- (typeof properties.capsuleBaseHeight === 'number' && properties.capsuleBaseHeight > 0)
339
- ? properties.capsuleBaseHeight
340
- : ((typeof objectHeight === 'number' && objectHeight > 0)
341
- ? objectHeight
342
- : ((typeof properties.height === 'number' && properties.height > 0) ? properties.height : MINDMAP_LAYOUT.height))
343
- )
344
- );
345
338
  const resizeSession = {
346
339
  started: false,
347
340
  oldSize: null,
@@ -449,9 +442,12 @@ export function openMindmapEditor(object, create = false) {
449
442
  const placeholderWidth = measureMindmapTextWidthPx(textarea, measureEl, textarea.placeholder || '');
450
443
  const baseCssWidth = Math.max(1, Math.round(stableBaseWorldWidth * getWorldToCssScale()));
451
444
  const placeholderCssWidth = Math.max(1, Math.ceil(placeholderWidth + padding.left + padding.right));
445
+ // Ширина пустого состояния (плейсхолдер/база) — нижняя граница и при вводе,
446
+ // чтобы капсула не схлопывалась на первой букве, а росла только когда текст длиннее.
447
+ const emptyStateCssWidth = Math.max(baseCssWidth, placeholderCssWidth);
452
448
  const rawNextCssWidth = hasText
453
- ? Math.max(1, Math.ceil(textWidth + padding.left + padding.right))
454
- : Math.max(baseCssWidth, placeholderCssWidth);
449
+ ? Math.max(emptyStateCssWidth, Math.ceil(textWidth + padding.left + padding.right))
450
+ : emptyStateCssWidth;
455
451
  const level = properties?.mindmap?.level ?? 0;
456
452
  const isRoot = level === 0;
457
453
  const minCssW = Math.max(1, Math.round(
@@ -463,7 +459,13 @@ export function openMindmapEditor(object, create = false) {
463
459
  const nextCssWidth = Math.max(minCssW, Math.min(maxCssW, rawNextCssWidth));
464
460
  const lineCount = getEditorLineCount();
465
461
  const lineHeightPx = getEditorLineHeightPx();
466
- const baseCssHeight = Math.max(1, Math.round(stableBaseWorldHeight * getWorldToCssScale()));
462
+ // Высота одной строки капсулы = вертикальный паддинг + line-height — та же формула,
463
+ // что в статическом слое (MindmapHtmlTextLayer._autoFitNodeWidth: scrollHeight).
464
+ // Раньше базой служил capsuleBaseHeight (MINDMAP_LAYOUT.height=40), который меньше
465
+ // паддинга+строки, из-за чего поле сжималось при вводе и «прыгало» обратно на blur.
466
+ const basePaddingYWorld = Math.max(0, Math.round(properties.paddingY ?? MINDMAP_LAYOUT.paddingY));
467
+ const paddingYCss = Math.max(0, Math.round(basePaddingYWorld * getWorldToCssScale()));
468
+ const baseCssHeight = Math.max(1, 2 * paddingYCss + lineHeightPx);
467
469
  const nextCssHeight = Math.max(1, Math.ceil(baseCssHeight + (Math.max(1, lineCount) - 1) * lineHeightPx));
468
470
 
469
471
  const currentCssWidth = Math.max(1, Math.round(parseFloat(wrapper.style.width || `${initialCssWidth}`)));
@@ -115,10 +115,24 @@ export function updateCustomCaret(textarea, caretEl) {
115
115
 
116
116
  const pos = textarea.selectionStart;
117
117
  const coords = getCaretCoordinates(textarea, pos);
118
-
118
+
119
+ // Смещение textarea относительно родителя каретки (wrapper). В обычном редакторе
120
+ // textarea прижата к top:0/left:0 wrapper — смещение нулевое. В mindmap-редакторе
121
+ // textarea центрируется flex-ом и дополнительно сдвигается через transform: translateY,
122
+ // поэтому без этой компенсации каретка "прилипает" к верхнему левому углу капсулы.
123
+ let offsetX = 0;
124
+ let offsetY = 0;
125
+ const caretParent = caretEl.parentElement;
126
+ if (caretParent) {
127
+ const parentRect = caretParent.getBoundingClientRect();
128
+ const textareaRect = textarea.getBoundingClientRect();
129
+ offsetX = textareaRect.left - parentRect.left;
130
+ offsetY = textareaRect.top - parentRect.top;
131
+ }
132
+
119
133
  // Adjust for scroll
120
- const top = coords.top - textarea.scrollTop;
121
- const left = coords.left - textarea.scrollLeft;
134
+ const top = offsetY + coords.top - textarea.scrollTop;
135
+ const left = offsetX + coords.left - textarea.scrollLeft;
122
136
 
123
137
  // Calculate width based on font size to match stroke thickness
124
138
  const computed = window.getComputedStyle(textarea);
@@ -2090,7 +2090,38 @@ export class ChatWindow {
2090
2090
  return y;
2091
2091
  }
2092
2092
 
2093
+ _getViewportCenter() {
2094
+ const rect = this._container?.getBoundingClientRect?.();
2095
+ if (rect && rect.width > 0 && rect.height > 0) {
2096
+ return {
2097
+ x: Math.round(rect.left + rect.width / 2),
2098
+ y: Math.round(rect.top + rect.height / 2)
2099
+ };
2100
+ }
2101
+ return null;
2102
+ }
2103
+
2093
2104
  _getImageGroupAnchor() {
2105
+ // Базовый якорь — центр видимой области холста: центр заглушки (и итогового
2106
+ // изображения) должен совпадать с серединой экрана. Ряд из нескольких изображений
2107
+ // держим на общей горизонтальной оси через existingCenterY.
2108
+ const viewportCenter = this._getViewportCenter();
2109
+ if (viewportCenter) {
2110
+ const existingCenterY = this._getAiImageLaneCenterScreenY();
2111
+ const [wr, hr] = parseFormatRatio(this._formatId);
2112
+ const actualHeight = Math.round(BOARD_IMAGE_WIDTH / (wr / hr));
2113
+
2114
+ let y = existingCenterY ?? viewportCenter.y;
2115
+ if (existingCenterY == null) {
2116
+ y = this._clampImageGroupAnchorY(y, actualHeight, 0);
2117
+ }
2118
+
2119
+ return {
2120
+ x: viewportCenter.x,
2121
+ y: Math.round(y)
2122
+ };
2123
+ }
2124
+
2094
2125
  const composerRect = this._refs?.composer?.getBoundingClientRect?.();
2095
2126
  if (composerRect) {
2096
2127
  const existingCenterY = this._getAiImageLaneCenterScreenY();
@@ -464,8 +464,8 @@
464
464
  .mb-text--mindmap {
465
465
  display: flex;
466
466
  align-items: center;
467
- justify-content: center;
468
- text-align: center;
467
+ justify-content: flex-start;
468
+ text-align: left;
469
469
  pointer-events: none;
470
470
  cursor: default;
471
471
  color: #212121;
@@ -260,27 +260,6 @@ function createFontControls(panelInstance, panel) {
260
260
 
261
261
  createCompactBackgroundSelector(panelInstance, panel);
262
262
 
263
- const mdSeparator = document.createElement('div');
264
- mdSeparator.style.cssText = 'width:1px;height:18px;background:#e0e0e0;margin:0 6px;flex-shrink:0;';
265
- panel.appendChild(mdSeparator);
266
-
267
- const mdId = `tpp-md-${Date.now()}`;
268
- panelInstance.markdownToggle = document.createElement('input');
269
- panelInstance.markdownToggle.type = 'checkbox';
270
- panelInstance.markdownToggle.id = mdId;
271
- panelInstance.markdownToggle.className = 'tpp-md-toggle';
272
- panelInstance.markdownToggle.style.cssText = 'width:14px;height:14px;cursor:pointer;flex-shrink:0;';
273
-
274
- const mdLabel = document.createElement('label');
275
- mdLabel.htmlFor = mdId;
276
- mdLabel.textContent = 'MD';
277
- mdLabel.title = 'Отображать как Markdown';
278
- mdLabel.className = 'tpp-label';
279
- mdLabel.style.cssText = 'cursor:pointer;user-select:none;';
280
-
281
- panel.appendChild(panelInstance.markdownToggle);
282
- panel.appendChild(mdLabel);
283
-
284
263
  createTextFormatControls(panelInstance, panel);
285
264
 
286
265
  createTextLockMoreControls(panelInstance, panel);
@@ -24,6 +24,28 @@ export class ToolbarDialogsController {
24
24
  return;
25
25
  }
26
26
 
27
+ const isImage = !!(file.type && file.type.startsWith('image/'));
28
+
29
+ if (isImage) {
30
+ // Изображение, выбранное через диалог вложений, размещаем как картинку
31
+ // (тот же путь, что и OS drag-and-drop), а не как файл-карточку.
32
+ this.toolbar.eventBus.emit(Events.Place.ImageSelected, {
33
+ file: file,
34
+ fileName: file.name,
35
+ fileSize: file.size,
36
+ mimeType: file.type,
37
+ properties: {
38
+ width: 300,
39
+ height: 200
40
+ }
41
+ });
42
+
43
+ this.toolbar.eventBus.emit(Events.Keyboard.ToolSelect, { tool: 'place' });
44
+ this.toolbar.placeSelectedButtonId = 'image';
45
+ this.toolbar.setActiveToolbarButton('place');
46
+ return;
47
+ }
48
+
27
49
  // Файл выбран - запускаем режим "призрака"
28
50
  this.toolbar.eventBus.emit(Events.Place.FileSelected, {
29
51
  file: file,
@@ -0,0 +1,141 @@
1
+ import * as PIXI from 'pixi.js';
2
+
3
+ /**
4
+ * Градиентная заглушка, которую показываем в мировом слое на месте будущего
5
+ * изображения, пока идёт загрузка на сервер и подтверждение сохранения
6
+ * (OS drag-and-drop). Заглушка живёт в worldLayer, поэтому панится/зумится
7
+ * вместе с доской и точно совпадает по месту и размеру с приземляемой картинкой.
8
+ */
9
+
10
+ function roundRectPath(ctx, x, y, w, h, r) {
11
+ const radius = Math.max(0, Math.min(r, w / 2, h / 2));
12
+ ctx.beginPath();
13
+ ctx.moveTo(x + radius, y);
14
+ ctx.arcTo(x + w, y, x + w, y + h, radius);
15
+ ctx.arcTo(x + w, y + h, x, y + h, radius);
16
+ ctx.arcTo(x, y + h, x, y, radius);
17
+ ctx.arcTo(x, y, x + w, y, radius);
18
+ ctx.closePath();
19
+ }
20
+
21
+ function buildGradientCanvas(w, h, radius) {
22
+ const canvas = document.createElement('canvas');
23
+ // Ограничиваем разрешение текстуры: заглушка временная, огромные битмапы не нужны.
24
+ const maxDim = 1024;
25
+ const scale = Math.min(1, maxDim / Math.max(w, h));
26
+ const cw = Math.max(1, Math.round(w * scale));
27
+ const ch = Math.max(1, Math.round(h * scale));
28
+ canvas.width = cw;
29
+ canvas.height = ch;
30
+
31
+ const ctx = canvas.getContext('2d');
32
+ roundRectPath(ctx, 0, 0, cw, ch, radius * scale);
33
+ ctx.clip();
34
+
35
+ const grad = ctx.createLinearGradient(0, 0, cw, ch);
36
+ grad.addColorStop(0, '#e9edf2');
37
+ grad.addColorStop(0.5, '#f5f7f9');
38
+ grad.addColorStop(1, '#dde3ea');
39
+ ctx.fillStyle = grad;
40
+ ctx.fillRect(0, 0, cw, ch);
41
+
42
+ return canvas;
43
+ }
44
+
45
+ function buildShimmerCanvas(bandW, h) {
46
+ const canvas = document.createElement('canvas');
47
+ const cw = Math.max(1, Math.round(bandW));
48
+ const ch = Math.max(1, Math.round(Math.min(h, 512)));
49
+ canvas.width = cw;
50
+ canvas.height = ch;
51
+
52
+ const ctx = canvas.getContext('2d');
53
+ const grad = ctx.createLinearGradient(0, 0, cw, 0);
54
+ grad.addColorStop(0, 'rgba(255,255,255,0)');
55
+ grad.addColorStop(0.5, 'rgba(255,255,255,0.55)');
56
+ grad.addColorStop(1, 'rgba(255,255,255,0)');
57
+ ctx.fillStyle = grad;
58
+ ctx.fillRect(0, 0, cw, ch);
59
+
60
+ return canvas;
61
+ }
62
+
63
+ /**
64
+ * @param {PIXI.Application} app - для тикера shimmer-анимации
65
+ * @param {number} width - мировая ширина будущего изображения
66
+ * @param {number} height - мировая высота будущего изображения
67
+ * @returns {{ node: PIXI.Container, destroy: () => void }}
68
+ */
69
+ export function createDropPlaceholder(app, width, height) {
70
+ const w = Math.max(1, Math.round(width));
71
+ const h = Math.max(1, Math.round(height));
72
+ const radius = Math.max(4, Math.min(12, Math.min(w, h) * 0.08));
73
+
74
+ const container = new PIXI.Container();
75
+ container.name = 'futurello-drop-placeholder';
76
+ container.eventMode = 'none';
77
+ container.interactiveChildren = false;
78
+
79
+ const textures = [];
80
+ let tickerFn = null;
81
+
82
+ const baseTexture = PIXI.Texture.from(buildGradientCanvas(w, h, radius));
83
+ textures.push(baseTexture);
84
+ const base = new PIXI.Sprite(baseTexture);
85
+ base.width = w;
86
+ base.height = h;
87
+ container.addChild(base);
88
+
89
+ try {
90
+ const bandW = Math.max(48, Math.round(w * 0.35));
91
+ const shimmerTexture = PIXI.Texture.from(buildShimmerCanvas(bandW, h));
92
+ textures.push(shimmerTexture);
93
+ const shimmer = new PIXI.Sprite(shimmerTexture);
94
+ shimmer.width = bandW;
95
+ shimmer.height = h;
96
+ shimmer.x = -bandW;
97
+ container.addChild(shimmer);
98
+
99
+ const mask = new PIXI.Graphics();
100
+ mask.beginFill(0xffffff, 1);
101
+ mask.drawRoundedRect(0, 0, w, h, radius);
102
+ mask.endFill();
103
+ container.addChild(mask);
104
+ shimmer.mask = mask;
105
+
106
+ const travel = w + bandW;
107
+ const speed = travel / 72; // ~1.2 c при 60 fps
108
+ tickerFn = () => {
109
+ shimmer.x += speed;
110
+ if (shimmer.x > w) {
111
+ shimmer.x = -bandW;
112
+ }
113
+ };
114
+ app?.ticker?.add(tickerFn);
115
+ } catch (_) {
116
+ // shimmer необязателен — при сбое остаётся статичный градиент
117
+ }
118
+
119
+ const destroy = () => {
120
+ try {
121
+ if (tickerFn) {
122
+ app?.ticker?.remove(tickerFn);
123
+ }
124
+ } catch (_) { /* no-op */ }
125
+ try {
126
+ if (container.parent) {
127
+ container.parent.removeChild(container);
128
+ }
129
+ } catch (_) { /* no-op */ }
130
+ try {
131
+ container.destroy({ children: true });
132
+ } catch (_) { /* no-op */ }
133
+ for (const texture of textures) {
134
+ try {
135
+ texture.destroy(true);
136
+ } catch (_) { /* no-op */ }
137
+ }
138
+ };
139
+
140
+ return { node: container, destroy };
141
+ }