@sequent-org/moodboard 1.4.70 → 1.4.72

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.70",
3
+ "version": "1.4.72",
4
4
  "type": "module",
5
5
  "description": "Interactive moodboard",
6
6
  "main": "./src/index.js",
@@ -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, () => {
@@ -0,0 +1,92 @@
1
+ /**
2
+ * Text-safe area (вписанный прямоугольник) внутри фигуры.
3
+ *
4
+ * Текст фигуры не должен выходить за её видимый контур. Bounding box фигуры
5
+ * прямоугольный, но сама фигура (треугольник, ромб, круг, стрелка, параллелограмм)
6
+ * занимает лишь часть бокса. Здесь считаем максимальный прямоугольник, который
7
+ * гарантированно лежит внутри контура — по нему позиционируется и статический
8
+ * HTML-оверлей текста, и inline-редактор.
9
+ *
10
+ * Геометрия форм — из ShapeDrawer._drawFillShape (тот же контур).
11
+ *
12
+ * @param {string} kind — square|rounded|circle|triangle|diamond|parallelogram|arrow
13
+ * @param {number} w — ширина bounds (world px)
14
+ * @param {number} h — высота bounds (world px)
15
+ * @returns {{left:number, top:number, width:number, height:number}} область в локальных координатах бокса (0,0 — левый верх)
16
+ */
17
+ // Доля запаса внутрь контура для непрямоугольных фигур. Вписанный прямоугольник и
18
+ // клип-контур касаются краёв фигуры углами/сторонами, поэтому глифы верхней строки
19
+ // при переполнении «липнут» к наклонной стороне (для круга — углы вписанного квадрата
20
+ // лежат ровно на окружности). Ужимаем безопасную зону на этот множитель, чтобы текст
21
+ // не доходил до самого контура. 0.8 даёт видимый зазор (~20% радиуса) и служит буфером
22
+ // против небольшого смещения бокса относительно центра фигуры.
23
+ const SHAPE_INNER_MARGIN = 0.8;
24
+
25
+ export function getShapeTextSafeArea(kind, w, h) {
26
+ const W = Math.max(0, w || 0);
27
+ const H = Math.max(0, h || 0);
28
+
29
+ switch (kind) {
30
+ case 'circle': {
31
+ // Вписанный прямоугольник в эллипс (полуоси W/2, H/2): множитель 1/√2.
32
+ // Углы такого прямоугольника лежат точно на окружности, поэтому дополнительно
33
+ // ужимаем на SHAPE_INNER_MARGIN — иначе крайние строки касаются контура.
34
+ const f = (1 / Math.SQRT2) * SHAPE_INNER_MARGIN;
35
+ const iw = W * f;
36
+ const ih = H * f;
37
+ return { left: (W - iw) / 2, top: (H - ih) / 2, width: iw, height: ih };
38
+ }
39
+ case 'triangle': {
40
+ // Вершина сверху (W/2,0), основание снизу. Ширину берём по вписанному в
41
+ // основание прямоугольнику (W/2), но по вертикали центрируем на середине
42
+ // фигуры, а не прижимаем к низу — текст должен стоять по центру и расти
43
+ // вверх при переполнении. Верхние углы box'а выходят за наклонные стороны,
44
+ // но их прячет клип-контур (getShapeTextClipPath) с внутренним отступом.
45
+ return { left: W * 0.25, top: H * 0.25, width: W * 0.5, height: H * 0.5 };
46
+ }
47
+ case 'diamond': {
48
+ // Ромб с вершинами по серединам сторон: вписанный центрированный
49
+ // прямоугольник — половина ширины и высоты. Его углы лежат на сторонах
50
+ // ромба, поэтому ужимаем на SHAPE_INNER_MARGIN, чтобы текст не касался края.
51
+ const dw = W * 0.5 * SHAPE_INNER_MARGIN;
52
+ const dh = H * 0.5 * SHAPE_INNER_MARGIN;
53
+ return { left: (W - dw) / 2, top: (H - dh) / 2, width: dw, height: dh };
54
+ }
55
+ case 'parallelogram': {
56
+ // skew совпадает с ShapeDrawer. Безопасна средняя вертикальная полоса.
57
+ const skew = Math.min(W * 0.25, 20);
58
+ return { left: skew, top: 0, width: Math.max(0, W - 2 * skew), height: H };
59
+ }
60
+ case 'arrow': {
61
+ // Прямоугольное «древко» стрелки — единственная зона с полной высотой полосы.
62
+ const shaftH = Math.max(6, H * 0.3);
63
+ const shaftY = (H - shaftH) / 2;
64
+ return { left: 0, top: shaftY, width: W * 0.6, height: shaftH };
65
+ }
66
+ case 'square':
67
+ case 'rounded':
68
+ default:
69
+ return { left: 0, top: 0, width: W, height: H };
70
+ }
71
+ }
72
+
73
+ /**
74
+ * CSS clip-path для текстового слоя фигуры — чтобы текст, вышедший за наклонные
75
+ * стороны, обрезался контуром («прятался внутри фигуры»), а не торчал наружу.
76
+ *
77
+ * Координаты заданы в системе САМОГО слоя (он позиционируется по safe-area из
78
+ * getShapeTextSafeArea). Для большинства фигур safe-area целиком лежит внутри
79
+ * контура, поэтому клип не нужен (null). Исключение — треугольник: верх safe-area
80
+ * (центральный прямоугольник W/2×H/2) шире контура, поэтому клипуем трапецией.
81
+ * Трапеция задана с внутренним отступом от наклонных сторон (не 25%/0, а 29%/5%),
82
+ * чтобы верхняя строка при переполнении не касалась контура, а обрезалась чуть внутри.
83
+ *
84
+ * @param {string} kind
85
+ * @returns {string|null}
86
+ */
87
+ export function getShapeTextClipPath(kind) {
88
+ if (kind === 'triangle') {
89
+ return 'polygon(29% 5%, 71% 5%, 93% 95%, 7% 95%)';
90
+ }
91
+ return null;
92
+ }
@@ -262,7 +262,9 @@ export class PlacementInputRouter {
262
262
  id: objectData.id,
263
263
  type: 'shape',
264
264
  position: objectData.position,
265
- properties: { content: '' }
265
+ // kind обязателен: без него редактор берёт safe-area как для квадрата
266
+ // (полные границы), и текст в круге/треугольнике вылезает за контур.
267
+ properties: { content: '', kind: objectData.properties?.kind || 'square' }
266
268
  },
267
269
  create: true
268
270
  });
@@ -105,7 +105,7 @@ export function openFileNameEditor(object, create = false) {
105
105
  };
106
106
 
107
107
  // Фокусируем и выделяем весь текст
108
- input.focus();
108
+ input.focus({ preventScroll: true });
109
109
  input.select();
110
110
 
111
111
  // Функция завершения редактирования
@@ -89,7 +89,7 @@ export function openFrameTitleEditor(object, _create = false) {
89
89
  _frameInstance: frameInstance,
90
90
  };
91
91
 
92
- input.focus();
92
+ input.focus({ preventScroll: true });
93
93
  input.select();
94
94
 
95
95
  const finalize = (commit) => this._closeFrameTitleEditor(commit);
@@ -837,7 +837,7 @@ export function openMindmapEditor(object, create = false) {
837
837
  _removeDomListeners: removeDomListeners,
838
838
  };
839
839
 
840
- textarea.focus();
840
+ textarea.focus({ preventScroll: true });
841
841
  applyMindmapCaretFromClick({
842
842
  create,
843
843
  objectId,
@@ -322,7 +322,7 @@ export function onDoubleClick(event) {
322
322
  id: hitResult.object,
323
323
  type: 'shape',
324
324
  position: posData.position,
325
- properties: { content: shapeContent },
325
+ properties: { content: shapeContent, kind: pix._mb?.properties?.kind || 'square' },
326
326
  caretClick: {
327
327
  clientX: event?.originalEvent?.clientX,
328
328
  clientY: event?.originalEvent?.clientY,
@@ -232,7 +232,7 @@ export function bindTextEditorInteractions(controller, {
232
232
  // который тут же снимает фокус с только что созданного пустого поля.
233
233
  // В течение короткого окна после создания не финализируем, а возвращаем фокус.
234
234
  if (isNewCreation && (Date.now() - editorCreatedAt) < 400) {
235
- try { textarea.focus(); } catch (_) {}
235
+ try { textarea.focus({ preventScroll: true }); } catch (_) {}
236
236
  return;
237
237
  }
238
238
 
@@ -291,7 +291,7 @@ export function bindTextEditorInteractions(controller, {
291
291
  // Страховочный возврат фокуса на случай, если браузер всё-таки убрал фокус.
292
292
  setTimeout(() => {
293
293
  if (controller?.textEditor?.active && textarea) {
294
- try { textarea.focus(); } catch (_) {}
294
+ try { textarea.focus({ preventScroll: true }); } catch (_) {}
295
295
  }
296
296
  }, 0);
297
297
  };
@@ -32,6 +32,7 @@ import {
32
32
  import { updateCustomCaret } from './TextEditorCaretService.js';
33
33
  import { TEXT_BOX_BOTTOM_PAD_PX } from '../../../services/text/TextBoxMetrics.js';
34
34
  import { buildHtmlWithRanges } from '../../../ui/HtmlTextLayer.js';
35
+ import { getShapeTextSafeArea, getShapeTextClipPath } from '../../../objects/shape/shapeTextArea.js';
35
36
 
36
37
  export function openTextEditor(object, create = false) {
37
38
 
@@ -77,7 +78,7 @@ export function openTextEditor(object, create = false) {
77
78
  this.textEditor.objectType === objectType
78
79
  );
79
80
  if (sameEditorObject && this.textEditor.textarea) {
80
- try { this.textEditor.textarea.focus(); } catch (_) {}
81
+ try { this.textEditor.textarea.focus({ preventScroll: true }); } catch (_) {}
81
82
  return;
82
83
  }
83
84
  this._closeTextEditor(true);
@@ -99,6 +100,9 @@ export function openTextEditor(object, create = false) {
99
100
  const meta = pixiReq.pixiObject && pixiReq.pixiObject._mb ? pixiReq.pixiObject._mb.properties || {} : {};
100
101
  if (meta.content) properties.content = meta.content;
101
102
  if (meta.fontSize) properties.fontSize = meta.fontSize;
103
+ // kind берём из самого объекта, если его не передали в payload — иначе safe-area
104
+ // фигуры считается как для квадрата и текст вылезает за контур круга/треугольника.
105
+ if (meta.kind && !properties.kind) properties.kind = meta.kind;
102
106
  }
103
107
 
104
108
  // Уведомляем о начале редактирования (для разных типов отдельно)
@@ -355,27 +359,67 @@ export function openTextEditor(object, create = false) {
355
359
  if (sizeData.size) { shapeW = sizeData.size.width; shapeH = sizeData.size.height; }
356
360
  }
357
361
 
358
- const shapeCssW = Math.max(1, Math.round(shapeW * sCssShape));
359
- const shapeCssH = Math.max(1, Math.round(shapeH * sCssShape));
360
- // Центрируем каретку по вертикали: padding-top = (высота фигуры - высота одной строки) / 2
361
- const oneLinePx = Math.max(1, Math.round(effectiveFontPx * 1.4));
362
- const paddingTopShape = Math.max(0, Math.round((shapeCssH - oneLinePx) / 2));
362
+ // Text-safe area: редактор занимает не весь bounds, а вписанный в контур
363
+ // фигуры прямоугольник иначе строки при вводе вылезают за наклонные стороны.
364
+ const kindShape = properties?.kind || 'square';
365
+ const safeShape = getShapeTextSafeArea(kindShape, shapeW, shapeH);
366
+ const safeLeftCss = Math.round(safeShape.left * sCssShape);
367
+ const safeTopCss = Math.round(safeShape.top * sCssShape);
368
+ const shapeCssW = Math.max(1, Math.round(safeShape.width * sCssShape));
369
+ const shapeCssH = Math.max(1, Math.round(safeShape.height * sCssShape));
370
+
371
+ // Фактическая line-height textarea в px: читаем computed style, т.к. textarea
372
+ // уже вставлена в DOM (view.parentElement.appendChild выше). Это надёжнее, чем
373
+ // hardcode * 1.4 — реальный коэффициент зависит от baseFontSizePx через resolveLineHeightRatio.
374
+ let shapeLineHeightPx = Math.max(1, Math.round(effectiveFontPx * 1.4));
375
+ try {
376
+ if (typeof window !== 'undefined' && window.getComputedStyle) {
377
+ const lhComputed = parseFloat(window.getComputedStyle(textarea).lineHeight);
378
+ if (isFinite(lhComputed) && lhComputed > 0) shapeLineHeightPx = Math.round(lhComputed);
379
+ }
380
+ } catch (_) {}
381
+
382
+ // backdrop: text center = shape center → paddingTop = (H - lineH) / 2
383
+ const backdropPadTop = Math.max(0, Math.round((shapeCssH - shapeLineHeightPx) / 2));
384
+ // textarea: компенсируем -2px смещение в updateCustomCaret (caretEl.style.top = top - 2),
385
+ // чтобы визуальная каретка тоже встала строго по центру фигуры
386
+ const textareaPadTop = backdropPadTop + 2;
363
387
 
364
- wrapper.style.left = `${Math.round(screenPos.x)}px`;
365
- wrapper.style.top = `${Math.round(screenPos.y)}px`;
388
+ wrapper.style.left = `${Math.round(screenPos.x) + safeLeftCss}px`;
389
+ wrapper.style.top = `${Math.round(screenPos.y) + safeTopCss}px`;
366
390
  wrapper.style.width = `${shapeCssW}px`;
367
391
  wrapper.style.height = `${shapeCssH}px`;
392
+ // Клип по контуру: при вводе текст, вышедший за наклонные стороны, прячется.
393
+ wrapper.style.clipPath = getShapeTextClipPath(kindShape) || 'none';
368
394
 
369
395
  textarea.style.width = `${shapeCssW}px`;
370
396
  textarea.style.height = `${shapeCssH}px`;
371
397
  textarea.style.boxSizing = 'border-box';
398
+ // Перенос строк внутри фигуры (applyEditorSizing ставит white-space:pre — без
399
+ // переноса текст рос бы вбок и вылезал за границы). Совпадает со статическим
400
+ // слоем (mb-shape-text), поэтому строки в редакторе и после совпадают.
401
+ textarea.style.whiteSpace = 'pre-wrap';
402
+ textarea.style.wordBreak = 'break-word';
403
+ textarea.style.overflowWrap = 'break-word';
404
+ if (backdrop) {
405
+ backdrop.style.whiteSpace = 'pre-wrap';
406
+ backdrop.style.wordBreak = 'break-word';
407
+ backdrop.style.overflowWrap = 'break-word';
408
+ }
409
+ // CSS .moodboard-text-input задаёт transition: all 0.2s — он анимирует padding-top
410
+ // от старого значения к вычисленному. Начальный расчёт каретки (setTimeout 0 в
411
+ // bindTextEditorInteractions) читает промежуточный padding и ставит каретку у верха
412
+ // фигуры; центр появляется только после ввода. Отключаем анимацию, чтобы padding
413
+ // применился мгновенно и каретка сразу встала по центру.
414
+ textarea.style.transition = 'none';
372
415
 
373
416
  if (backdrop) {
374
417
  backdrop.style.width = `${shapeCssW}px`;
375
418
  backdrop.style.height = `${shapeCssH}px`;
376
419
  backdrop.style.boxSizing = 'border-box';
377
420
  backdrop.style.display = 'block';
378
- backdrop.style.paddingTop = `${paddingTopShape}px`;
421
+ backdrop.style.transition = 'none';
422
+ backdrop.style.paddingTop = `${backdropPadTop}px`;
379
423
  backdrop.style.paddingBottom = '0px';
380
424
  }
381
425
 
@@ -385,9 +429,48 @@ export function openTextEditor(object, create = false) {
385
429
  // padding-bottom:0 перебивает CSS .moodboard-text-input (5px), чтобы не раздувать
386
430
  // высоту поля относительно крошечной фигуры.
387
431
  textarea.style.display = 'block';
388
- textarea.style.paddingTop = `${paddingTopShape}px`;
432
+ textarea.style.paddingTop = `${textareaPadTop}px`;
389
433
  textarea.style.paddingBottom = '0px';
390
434
 
435
+ // Вертикаль: пока текст помещается — центр по середине фигуры; при
436
+ // переполнении — рост вверх (низ/каретка видны, старые строки уходят выше).
437
+ // Пересчитываем paddingTop по фактической высоте контента, т.к. фиксированный
438
+ // padding центрирует только одну строку. Замер — по backdrop (видимый слой).
439
+ const applyShapeVerticalAlign = () => {
440
+ let contentH = shapeLineHeightPx;
441
+ if (backdrop) {
442
+ // Замер реальной высоты контента: у backdrop фиксированная height,
443
+ // поэтому scrollHeight не опускается ниже неё — временно снимаем height
444
+ // и padding, иначе одна строка «измеряется» как полный box и padding=0.
445
+ const prevPad = backdrop.style.paddingTop;
446
+ const prevH = backdrop.style.height;
447
+ backdrop.style.paddingTop = '0px';
448
+ backdrop.style.height = 'auto';
449
+ contentH = backdrop.scrollHeight;
450
+ backdrop.style.height = prevH;
451
+ backdrop.style.paddingTop = prevPad;
452
+ }
453
+ const overflow = contentH > shapeCssH + 1;
454
+ const bdPad = overflow ? 0 : Math.max(0, Math.round((shapeCssH - contentH) / 2));
455
+ if (backdrop) {
456
+ backdrop.style.paddingTop = `${bdPad}px`;
457
+ backdrop.scrollTop = overflow ? backdrop.scrollHeight : 0;
458
+ }
459
+ textarea.style.paddingTop = `${bdPad + 2}px`;
460
+ textarea.scrollTop = overflow ? textarea.scrollHeight : 0;
461
+ };
462
+ const scheduleShapeVerticalAlign = () => {
463
+ // rAF: даём syncBackdrop (input-листенер) обновить содержимое до замера.
464
+ requestAnimationFrame(() => {
465
+ applyShapeVerticalAlign();
466
+ if (this.textEditor && this.textEditor.caret) {
467
+ updateCustomCaret(textarea, this.textEditor.caret);
468
+ }
469
+ });
470
+ };
471
+ textarea.addEventListener('input', scheduleShapeVerticalAlign);
472
+ scheduleShapeVerticalAlign();
473
+
391
474
  this.textEditor._cssLeftPx = Math.round(screenPos.x);
392
475
  this.textEditor._cssTopPx = Math.round(screenPos.y);
393
476
  } else {
@@ -540,7 +623,11 @@ export function openTextEditor(object, create = false) {
540
623
  if (!isNote && !isShape && create) {
541
624
  autoSize();
542
625
  }
543
- textarea.focus();
626
+ // preventScroll: на планшете экранная клавиатура иначе триггерит нативный
627
+ // scroll-into-view, который прокручивает контейнер холста — фигура и поле
628
+ // ввода уезжают вниз от точки тапа. Позиция редактора уже привязана к
629
+ // координатам канваса, нативная прокрутка здесь только мешает.
630
+ textarea.focus({ preventScroll: true });
544
631
  // Записка: после фокуса повторно подгоняем блок (focus может изменить layout),
545
632
  // чтобы стартовая каретка считалась от уже подобранного размера шрифта, а не от 32px.
546
633
  if (isNote && updateNoteEditor) {
@@ -4,6 +4,7 @@ import { Events } from '../core/events/Events.js';
4
4
  import * as PIXI from 'pixi.js';
5
5
  import { renderRichText, hasMath } from '../utils/richText.js';
6
6
  import { renderTextList } from './text-properties/TextListRenderer.js';
7
+ import { getShapeTextSafeArea, getShapeTextClipPath } from '../objects/shape/shapeTextArea.js';
7
8
  import {
8
9
  applyTextStyles,
9
10
  resolveLineHeightRatio,
@@ -714,12 +715,46 @@ export class HtmlTextLayer {
714
715
  if (el.dataset.baseFontSize !== String(newShapeFS)) {
715
716
  el.dataset.baseFontSize = String(newShapeFS);
716
717
  }
718
+ // Реальный размер применяется здесь: при смене шрифта в панели фигуры updateOne
719
+ // раньше обновлял только dataset, а style.fontSize оставался с момента создания.
720
+ el.style.fontSize = `${newShapeFS}px`;
717
721
  el.style.fontFamily = textProps.fontFamily || 'Inter, sans-serif';
718
722
  el.style.color = textProps.color || '#111111';
719
723
  el.style.fontWeight = textProps.bold ? 'bold' : '';
720
724
  el.style.fontStyle = textProps.italic ? 'italic' : '';
721
725
  const shapeContent = obj.properties?.content || '';
722
726
  if (el.textContent !== shapeContent) el.textContent = shapeContent;
727
+ // Text-safe area: оверлей занимает не весь bounds, а вписанный в контур
728
+ // фигуры прямоугольник (overflow: hidden клипует по нему). Иначе текст
729
+ // выходит за наклонные стороны треугольника/ромба/круга/стрелки.
730
+ if (w > 0 && h > 0) {
731
+ const kind = obj.properties?.kind || 'square';
732
+ const safe = getShapeTextSafeArea(kind, w, h);
733
+ const sx = logWidth / w;
734
+ const sy = logHeight / h;
735
+ const safeLeft = Math.round(logLeft + safe.left * sx);
736
+ const safeTop = Math.round(logTop + safe.top * sy);
737
+ const safeW = Math.max(1, Math.round(safe.width * sx));
738
+ const safeH = Math.max(1, Math.round(safe.height * sy));
739
+ el.style.left = `${safeLeft}px`;
740
+ el.style.top = `${safeTop}px`;
741
+ el.style.width = `${safeW}px`;
742
+ el.style.height = `${safeH}px`;
743
+ // Поворот должен идти вокруг центра фигуры, а не центра safe-area.
744
+ const originX = (logLeft + logWidth / 2) - safeLeft;
745
+ const originY = (logTop + logHeight / 2) - safeTop;
746
+ el.style.transformOrigin = `${originX}px ${originY}px`;
747
+ // Клип по контуру: текст, вышедший за наклонные стороны, прячется внутри.
748
+ el.style.clipPath = getShapeTextClipPath(kind) || 'none';
749
+ }
750
+ // Вертикальное выравнивание: пока текст помещается — центр по середине
751
+ // фигуры; при переполнении — прижимаем к низу safe-area, чтобы текст рос
752
+ // вверх (низ зафиксирован, верхние строки уходят выше и обрезаются
753
+ // overflow: hidden). Замер overflow делаем при flex-start — при
754
+ // align-items:center контент вылезает вверх/вниз и scrollHeight врёт.
755
+ el.style.alignItems = 'flex-start';
756
+ const overflowsBox = el.scrollHeight > el.clientHeight + 1;
757
+ el.style.alignItems = overflowsBox ? 'flex-end' : 'center';
723
758
  return;
724
759
  }
725
760
 
@@ -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();
@@ -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);