@sequent-org/moodboard 1.4.72 → 1.4.74

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.72",
3
+ "version": "1.4.74",
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
  });
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,
@@ -392,6 +385,13 @@ export function openMindmapEditor(object, create = false) {
392
385
  if (typeof window !== 'undefined' && typeof window.requestAnimationFrame === 'function') {
393
386
  window.requestAnimationFrame(() => {
394
387
  alignTextareaLineTop();
388
+ // Каретка позиционируется по textarea.getBoundingClientRect(). После
389
+ // выравнивания раскладка textarea окончательна — пересчитываем каретку,
390
+ // иначе в медленном окружении она остаётся в позиции раннего кадра
391
+ // (когда textarea ещё не сжат/не отцентрован) и «прилипает» к верху капсулы.
392
+ if (this.textEditor && this.textEditor.caret && this.textEditor.textarea === textarea) {
393
+ updateCustomCaret(textarea, this.textEditor.caret);
394
+ }
395
395
  });
396
396
  }
397
397
  };
@@ -449,9 +449,12 @@ export function openMindmapEditor(object, create = false) {
449
449
  const placeholderWidth = measureMindmapTextWidthPx(textarea, measureEl, textarea.placeholder || '');
450
450
  const baseCssWidth = Math.max(1, Math.round(stableBaseWorldWidth * getWorldToCssScale()));
451
451
  const placeholderCssWidth = Math.max(1, Math.ceil(placeholderWidth + padding.left + padding.right));
452
+ // Ширина пустого состояния (плейсхолдер/база) — нижняя граница и при вводе,
453
+ // чтобы капсула не схлопывалась на первой букве, а росла только когда текст длиннее.
454
+ const emptyStateCssWidth = Math.max(baseCssWidth, placeholderCssWidth);
452
455
  const rawNextCssWidth = hasText
453
- ? Math.max(1, Math.ceil(textWidth + padding.left + padding.right))
454
- : Math.max(baseCssWidth, placeholderCssWidth);
456
+ ? Math.max(emptyStateCssWidth, Math.ceil(textWidth + padding.left + padding.right))
457
+ : emptyStateCssWidth;
455
458
  const level = properties?.mindmap?.level ?? 0;
456
459
  const isRoot = level === 0;
457
460
  const minCssW = Math.max(1, Math.round(
@@ -463,7 +466,13 @@ export function openMindmapEditor(object, create = false) {
463
466
  const nextCssWidth = Math.max(minCssW, Math.min(maxCssW, rawNextCssWidth));
464
467
  const lineCount = getEditorLineCount();
465
468
  const lineHeightPx = getEditorLineHeightPx();
466
- const baseCssHeight = Math.max(1, Math.round(stableBaseWorldHeight * getWorldToCssScale()));
469
+ // Высота одной строки капсулы = вертикальный паддинг + line-height — та же формула,
470
+ // что в статическом слое (MindmapHtmlTextLayer._autoFitNodeWidth: scrollHeight).
471
+ // Раньше базой служил capsuleBaseHeight (MINDMAP_LAYOUT.height=40), который меньше
472
+ // паддинга+строки, из-за чего поле сжималось при вводе и «прыгало» обратно на blur.
473
+ const basePaddingYWorld = Math.max(0, Math.round(properties.paddingY ?? MINDMAP_LAYOUT.paddingY));
474
+ const paddingYCss = Math.max(0, Math.round(basePaddingYWorld * getWorldToCssScale()));
475
+ const baseCssHeight = Math.max(1, 2 * paddingYCss + lineHeightPx);
467
476
  const nextCssHeight = Math.max(1, Math.ceil(baseCssHeight + (Math.max(1, lineCount) - 1) * lineHeightPx));
468
477
 
469
478
  const currentCssWidth = Math.max(1, Math.round(parseFloat(wrapper.style.width || `${initialCssWidth}`)));
@@ -844,6 +853,20 @@ export function openMindmapEditor(object, create = false) {
844
853
  object,
845
854
  textarea,
846
855
  });
856
+
857
+ // При открытии редактора textarea сжимается до одной строки и центрируется flex-ом
858
+ // асинхронно; первый расчёт каретки может произойти до устаканивания раскладки
859
+ // (offsetY ≈ 0 → каретка у верха капсулы). Пересчитываем после двух кадров, когда
860
+ // геометрия textarea окончательна — независимо от скорости окружения.
861
+ if (typeof window !== 'undefined' && typeof window.requestAnimationFrame === 'function') {
862
+ window.requestAnimationFrame(() => {
863
+ window.requestAnimationFrame(() => {
864
+ if (this.textEditor && this.textEditor.caret && this.textEditor.textarea === textarea) {
865
+ updateCustomCaret(textarea, this.textEditor.caret);
866
+ }
867
+ });
868
+ });
869
+ }
847
870
  }
848
871
 
849
872
  export function closeMindmapEditor(commit) {
@@ -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);
@@ -227,6 +227,10 @@ export class NotePropertiesPanel {
227
227
  backgroundColor: '#fff',
228
228
  cursor: 'pointer',
229
229
  minWidth: '140px',
230
+ // Хост может форсить color-scheme: dark (как futurello), из-за чего
231
+ // нативный select и его опции рендерятся белым текстом на белом фоне.
232
+ colorScheme: 'light',
233
+ color: '#111827',
230
234
  });
231
235
  this.fontSelect = fontSelect;
232
236
 
@@ -249,6 +253,8 @@ export class NotePropertiesPanel {
249
253
  option.value = font.value;
250
254
  option.textContent = font.name;
251
255
  option.style.fontFamily = font.value;
256
+ option.style.color = '#111827';
257
+ option.style.backgroundColor = '#fff';
252
258
  fontSelect.appendChild(option);
253
259
  });
254
260
 
@@ -322,7 +328,12 @@ export class NotePropertiesPanel {
322
328
  border: '1px solid #ccc',
323
329
  borderRadius: '4px',
324
330
  fontSize: '11px',
325
- textAlign: 'center'
331
+ textAlign: 'center',
332
+ // Хост может форсить color-scheme: dark (как futurello) — фиксируем
333
+ // светлую схему и явные цвета, иначе число становится белым на чёрном.
334
+ colorScheme: 'light',
335
+ color: '#111827',
336
+ backgroundColor: '#fff'
326
337
  });
327
338
 
328
339
  fontSizeInput.addEventListener('change', () => {
@@ -11,7 +11,7 @@
11
11
  .moodboard-chat {
12
12
  position: absolute;
13
13
  left: 50%;
14
- bottom: 32px;
14
+ bottom: 16px;
15
15
  transform: translateX(-50%);
16
16
  width: min(720px, 96%);
17
17
  z-index: 2500;
@@ -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;
@@ -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
+ }