@sequent-org/moodboard 1.4.60 → 1.4.61

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.60",
3
+ "version": "1.4.61",
4
4
  "type": "module",
5
5
  "description": "Interactive moodboard",
6
6
  "main": "./src/index.js",
@@ -0,0 +1 @@
1
+ <svg xmlns="http://www.w3.org/2000/svg" width="24" height="24" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round" class="lucide lucide-ban-icon lucide-ban"><circle cx="12" cy="12" r="10"/><path d="M4.929 4.929 19.07 19.071"/></svg>
@@ -101,34 +101,6 @@ export class ConnectorDragController {
101
101
  return { x: posData.position.x, y: posData.position.y, ...sizeData.size };
102
102
  }
103
103
 
104
- /**
105
- * Визуальные габариты объекта с учётом hover-трансформации (scale + подъём).
106
- * Подсветка коннектора должна совпадать с тем, что видит пользователь, а не
107
- * с логическими bounds из состояния. Привязка коннектора при этом остаётся
108
- * на логических bounds — трансформируется только прямоугольник подсветки.
109
- */
110
- _visualBounds(objectId, bounds) {
111
- const hoverLift = this.core?.pixi?.hoverLift;
112
- if (!hoverLift) return bounds;
113
-
114
- const pixiReq = { objectId, pixiObject: null };
115
- this.eventBus.emit(Events.Tool.GetObjectPixi, pixiReq);
116
- const pixiObject = pixiReq.pixiObject;
117
- if (!pixiObject) return bounds;
118
-
119
- const t = hoverLift.getVisualTransform(pixiObject);
120
- if (!t) return bounds;
121
-
122
- const width = bounds.width * t.scaleMulX;
123
- const height = bounds.height * t.scaleMulY;
124
- return {
125
- x: t.centerX - width / 2,
126
- y: t.centerY - height / 2,
127
- width,
128
- height,
129
- };
130
- }
131
-
132
104
  /**
133
105
  * Возвращает ближайший якорь из TARGET_ANCHORS в пределах ANCHOR_SNAP_CSS от worldPt,
134
106
  * иначе null. Приоритет выше грани — вызывать в _resolveEnd первым.
@@ -200,6 +172,9 @@ export class ConnectorDragController {
200
172
  if (Math.abs(e.clientX - this._startX) < DRAG_THRESHOLD
201
173
  && Math.abs(e.clientY - this._startY) < DRAG_THRESHOLD) return;
202
174
  this._dragging = true;
175
+ // Гасим hover-lift на время протягивания: цель не должна масштабироваться
176
+ // и подниматься, иначе её границы вылезают за рамку подсветки.
177
+ this.core?.pixi?.hoverLift?.setConnecting(true);
203
178
  const world = this._world();
204
179
  if (world) {
205
180
  this._previewGraphics = new PIXI.Graphics();
@@ -220,9 +195,12 @@ export class ConnectorDragController {
220
195
  if (objectId && objectId !== this._sourceTerminal?.boundId) {
221
196
  const bounds = this._objectBounds(objectId);
222
197
  if (bounds) {
223
- const visual = this._visualBounds(objectId, bounds);
198
+ // Рамку строим строго по логическим bounds — коннектор привязывается
199
+ // к ним же. Hover-трансформацию (scale/lift) не учитываем: иначе рамка
200
+ // выпирает за периметр объекта (раздув ×scale + подъём центра вверх) и
201
+ // отстаёт от анимации, т.к. перерисовывается только на pointermove.
224
202
  this._highlightGraphics.lineStyle({ width: 2, color: 0x2563EB, alpha: 0.85 });
225
- this._highlightGraphics.drawRect(visual.x, visual.y, visual.width, visual.height);
203
+ this._highlightGraphics.drawRect(bounds.x, bounds.y, bounds.width, bounds.height);
226
204
  // Подводим превью к коннектору, если курсор в зоне магнита
227
205
  const snapAnchor = this._snapToAnchor(bounds, worldPt);
228
206
  if (snapAnchor) {
@@ -249,6 +227,7 @@ export class ConnectorDragController {
249
227
  const source = this._sourceTerminal;
250
228
  this._dragging = false;
251
229
  this._sourceTerminal = null;
230
+ this.core?.pixi?.hoverLift?.setConnecting(false);
252
231
  this._clearGraphics();
253
232
 
254
233
  if (!source) return;
@@ -360,6 +339,7 @@ export class ConnectorDragController {
360
339
  destroy() {
361
340
  document.removeEventListener('pointermove', this._boundMove);
362
341
  document.removeEventListener('pointerup', this._boundUp);
342
+ this.core?.pixi?.hoverLift?.setConnecting(false);
363
343
  if (this._pendingDupListener) {
364
344
  this.eventBus?.off(Events.Tool.DuplicateReady, this._pendingDupListener);
365
345
  this._pendingDupListener = null;
@@ -1,9 +1,33 @@
1
1
  import { Events } from '../../../core/events/Events.js';
2
2
  import {
3
+ applyNoteEditorBox,
3
4
  createNoteEditorUpdater,
4
5
  registerNoteEditorSync,
5
6
  } from './TextEditorSyncService.js';
6
7
 
8
+ // Разметка иконки запрета (assets/icons/ban.svg). stroke=currentColor — цвет задаётся CSS.
9
+ const NOTE_BAN_SVG = '<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><circle cx="12" cy="12" r="10"/><path d="M4.929 4.929 19.07 19.071"/></svg>';
10
+
11
+ /**
12
+ * Создаёт (или переиспользует) оверлей запрета ввода внутри wrapper редактора записки
13
+ * и возвращает функцию переключения его видимости.
14
+ * @param {HTMLElement} wrapper
15
+ * @returns {(visible: boolean) => void}
16
+ */
17
+ function ensureNoteLimitBan(wrapper) {
18
+ let banEl = wrapper.querySelector('.moodboard-note-limit-ban');
19
+ if (!banEl) {
20
+ banEl = document.createElement('div');
21
+ banEl.className = 'moodboard-note-limit-ban';
22
+ banEl.innerHTML = NOTE_BAN_SVG;
23
+ banEl.style.display = 'none';
24
+ wrapper.appendChild(banEl);
25
+ }
26
+ return (visible) => {
27
+ banEl.style.display = visible ? 'flex' : 'none';
28
+ };
29
+ }
30
+
7
31
  export function setupNoteInlineEditor(controller, params) {
8
32
  const {
9
33
  objectId,
@@ -35,69 +59,50 @@ export function setupNoteInlineEditor(controller, params) {
35
59
  }
36
60
  }
37
61
 
38
- // Текст у записки центрирован по обеим осям; textarea тоже центрируем
39
- const horizontalPadding = 16; // немного больше, чем раньше
40
- // Преобразуем мировые размеры/отступы в CSS-пиксели с учётом текущего зума
41
- const viewResLocal = (controller.app?.renderer?.resolution) || (view.width && view.clientWidth ? (view.width / view.clientWidth) : 1);
62
+ // Текст у записки центрирован по обеим осям; редактор повторяет блок текста.
63
+ const horizontalPadding = 16;
64
+ // Мировые размеры/отступы CSS-пиксели. Множитель = worldScale, БЕЗ деления на
65
+ // renderer.resolution: screenPos берётся из toGlobal (см. InlineEditorPositioningService),
66
+ // который уже возвращает CSS-px независимо от res, а сама записка рисуется в PIXI, где
67
+ // 1 мировая единица = worldScale CSS-px. Лишнее /res занижало ширину блока и слагаемое
68
+ // центрирования (noteWidth*scale/2) → при зуме браузера ≠100% (res≠1) редактор уезжал влево.
42
69
  const worldLayerRefForCss = controller.textEditor.world || (controller.app?.stage);
43
70
  const sForCss = worldLayerRefForCss?.scale?.x || 1;
44
- const sCssLocal = sForCss / viewResLocal;
45
- const editorWidthWorld = Math.min(360, Math.max(1, noteWidth - (horizontalPadding * 2)));
46
- const editorHeightWorld = Math.min(180, Math.max(1, noteHeight - (horizontalPadding * 2)));
47
- const editorWidthPx = Math.max(1, Math.round(editorWidthWorld * sCssLocal));
48
- const editorHeightPx = Math.max(1, Math.round(editorHeightWorld * sCssLocal));
49
- const textCenterXWorld = noteWidth / 2;
50
- const textCenterYWorld = noteHeight / 2;
51
- const editorLeftWorld = textCenterXWorld - (editorWidthWorld / 2);
52
- const editorTopWorld = textCenterYWorld - (editorHeightWorld / 2);
53
- wrapper.style.left = `${Math.round(screenPos.x + editorLeftWorld * sCssLocal)}px`;
54
- wrapper.style.top = `${Math.round(screenPos.y + editorTopWorld * sCssLocal)}px`;
55
- // Устанавливаем размеры редактора (центрируем по контенту) в CSS-пикселях
56
- textarea.style.width = `${editorWidthPx}px`;
57
- textarea.style.height = `${editorHeightPx}px`;
58
- wrapper.style.width = `${editorWidthPx}px`;
59
- wrapper.style.height = `${editorHeightPx}px`;
71
+ const sCssLocal = sForCss;
60
72
 
61
- // Для записок: авто-ресайз редактора под содержимое с сохранением центрирования.
62
- // backdrop (видимые глифы) обязан повторять выравнивание textarea, иначе каретка,
63
- // позиционируемая по center-align textarea, уходит вправо от прижатого влево текста.
64
- textarea.style.textAlign = 'center';
65
73
  const backdrop = wrapper.querySelector('.moodboard-text-backdrop');
66
- if (backdrop) {
67
- backdrop.style.textAlign = 'center';
68
- }
69
- const maxEditorWidthPx = Math.max(1, Math.round((noteWidth - (horizontalPadding * 2)) * sCssLocal));
70
- const maxEditorHeightPx = Math.max(1, Math.round((noteHeight - (horizontalPadding * 2)) * sCssLocal));
71
- const MIN_NOTE_EDITOR_W = 20;
72
- const MIN_NOTE_EDITOR_H = Math.max(1, computeLineHeightPx(effectiveFontPx));
73
74
 
74
- const autoSizeNote = () => {
75
- // Сначала сбрасываем размеры, чтобы измерить естественные
76
- textarea.style.width = 'auto';
77
- textarea.style.height = 'auto';
75
+ // Оверлей запрета ввода: показывается по центру записки, когда достигнут лимит
76
+ // строк (NOTE_MAX_LINES). Иконка assets/icons/ban.svg (stroke=currentColor).
77
+ const setBanVisible = ensureNoteLimitBan(wrapper);
78
78
 
79
- // Ширина по содержимому, но не шире границ записки CSS-пикселях)
80
- const naturalW = Math.ceil(textarea.scrollWidth + 1);
81
- const targetW = Math.min(maxEditorWidthPx, Math.max(MIN_NOTE_EDITOR_W, naturalW));
82
- textarea.style.width = `${targetW}px`;
83
- wrapper.style.width = `${targetW}px`;
79
+ // Внутренний блок текста = границы записки минус отступы, ограничение ширины 360 —
80
+ // как в NoteObject._getVisibleTextWidth.
81
+ const MIN_NOTE_EDITOR_H = Math.max(1, computeLineHeightPx(effectiveFontPx));
82
+ const innerWorldW = Math.max(1, Math.min(360, noteWidth - (horizontalPadding * 2)));
83
+ const innerWorldH = Math.max(1, noteHeight - (horizontalPadding * 2));
84
+ const boxW = Math.max(1, Math.round(innerWorldW * sCssLocal));
85
+ const innerH = Math.max(MIN_NOTE_EDITOR_H, Math.round(innerWorldH * sCssLocal));
84
86
 
85
- // Высота по содержимому, c нижним пределом = одна строка
86
- const computed = (typeof window !== 'undefined') ? window.getComputedStyle(textarea) : null;
87
- const lineH = (computed ? parseFloat(computed.lineHeight) : computeLineHeightPx(effectiveFontPx));
88
- const naturalH = Math.ceil(textarea.scrollHeight);
89
- const targetH = Math.min(maxEditorHeightPx, Math.max(MIN_NOTE_EDITOR_H, naturalH));
90
- textarea.style.height = `${targetH}px`;
91
- wrapper.style.height = `${targetH}px`;
87
+ const autoSizeNote = () => {
88
+ const result = applyNoteEditorBox(textarea, backdrop, {
89
+ boxW,
90
+ innerH,
91
+ effectiveFontPx,
92
+ });
93
+ wrapper.style.width = `${boxW}px`;
94
+ wrapper.style.height = `${result.contentH}px`;
92
95
 
93
- // Центрируем wrapper внутри записки после смены размеров (в CSS-пикселях)
94
- const left = Math.round(screenPos.x + (noteWidth * sCssLocal) / 2 - (targetW / 2));
95
- const top = Math.round(screenPos.y + (noteHeight * sCssLocal) / 2 - (targetH / 2));
96
+ // Центрируем блок контента внутри записки по обеим осям (как PIXI-текст).
97
+ const left = Math.round(screenPos.x + (noteWidth * sCssLocal) / 2 - (boxW / 2));
98
+ const top = Math.round(screenPos.y + (noteHeight * sCssLocal) / 2 - (result.contentH / 2));
96
99
  wrapper.style.left = `${left}px`;
97
100
  wrapper.style.top = `${top}px`;
101
+ return result;
98
102
  };
99
103
  // Первый вызов — синхронизировать с текущим содержимым
100
- autoSizeNote();
104
+ const initial = autoSizeNote();
105
+ setBanVisible(!!(initial && initial.full));
101
106
 
102
107
  // Динамическое обновление позиции/размера редактора при зуме/панорамировании/трансформациях
103
108
  const updateNoteEditor = createNoteEditorUpdater(controller, {
@@ -115,5 +120,5 @@ export function setupNoteInlineEditor(controller, params) {
115
120
  });
116
121
  registerNoteEditorSync(controller, { objectId, updateNoteEditor });
117
122
 
118
- return { updateNoteEditor };
123
+ return { updateNoteEditor, setBanVisible };
119
124
  }
@@ -8,6 +8,45 @@ const propertiesToCopy = [
8
8
  ];
9
9
 
10
10
  let mirrorDiv = null;
11
+ let glyphMeasureCanvas = null;
12
+
13
+ // Возвращает выступ чернил (ink) глифа за его advance width в CSS-px. У рукописных и
14
+ // наклонных шрифтов (например Caveat) правый край буквы выходит за ширину продвижения,
15
+ // по которой считается позиция каретки, поэтому она визуально наезжает на символ.
16
+ // actualBoundingBoxRight — расстояние от точки отрисовки до правого края чернил;
17
+ // разница с width даёт величину выступа. <=0 для обычных прямых шрифтов.
18
+ function measureGlyphRightOverflow(computed, ch) {
19
+ if (!ch || ch === '\n') {
20
+ return 0;
21
+ }
22
+ try {
23
+ if (typeof document === 'undefined') {
24
+ return 0;
25
+ }
26
+ if (!glyphMeasureCanvas) {
27
+ glyphMeasureCanvas = document.createElement('canvas');
28
+ }
29
+ const ctx = glyphMeasureCanvas.getContext('2d');
30
+ if (!ctx) {
31
+ return 0;
32
+ }
33
+ const fontStyle = computed.fontStyle || 'normal';
34
+ const fontWeight = computed.fontWeight || 'normal';
35
+ const fontSize = computed.fontSize || '16px';
36
+ const fontFamily = computed.fontFamily || 'sans-serif';
37
+ ctx.font = `${fontStyle} ${fontWeight} ${fontSize} ${fontFamily}`;
38
+ const metrics = ctx.measureText(ch);
39
+ const advance = metrics.width || 0;
40
+ const inkRight = metrics.actualBoundingBoxRight;
41
+ if (!Number.isFinite(inkRight)) {
42
+ return 0;
43
+ }
44
+ const overflow = inkRight - advance;
45
+ return overflow > 0 ? overflow : 0;
46
+ } catch (_) {
47
+ return 0;
48
+ }
49
+ }
11
50
 
12
51
  export function getCaretCoordinates(element, position) {
13
52
  if (!mirrorDiv) {
@@ -84,6 +123,17 @@ export function updateCustomCaret(textarea, caretEl) {
84
123
  // Calculate width based on font size to match stroke thickness
85
124
  const computed = window.getComputedStyle(textarea);
86
125
  const fontSize = parseFloat(computed.fontSize) || 16;
126
+
127
+ // Сдвиг каретки вправо складывается из выступа чернил последней буквы (у рукописных/
128
+ // наклонных шрифтов глиф вылазит за advance width) и межбуквенного интервала текста.
129
+ // Применяем только в конце строки — в середине следующий символ и так перекрывает выступ.
130
+ const value = textarea.value || '';
131
+ const atLineEnd = pos >= value.length || value[pos] === '\n';
132
+ const lastChar = pos > 0 ? value[pos - 1] : '';
133
+ const inkOverflow = atLineEnd ? measureGlyphRightOverflow(computed, lastChar) : 0;
134
+ const letterSpacing = parseFloat(computed.letterSpacing);
135
+ const spacingGap = Number.isFinite(letterSpacing) ? Math.max(0, letterSpacing) : 0;
136
+ const caretGap = inkOverflow + spacingGap;
87
137
  // Толщина каретки пропорциональна шрифту (≈7% размера). Минимум 1px — при сильном
88
138
  // отдалении каретка остаётся видимой и не нарушает пропорцию. Жёсткий пол 2px убран,
89
139
  // чтобы при уменьшении масштаба каретка масштабировалась вместе с текстом.
@@ -103,7 +153,7 @@ export function updateCustomCaret(textarea, caretEl) {
103
153
  }
104
154
 
105
155
  caretEl.style.top = `${top - 2}px`;
106
- caretEl.style.left = `${left - Math.floor(caretWidth / 2)}px`;
156
+ caretEl.style.left = `${left - Math.floor(caretWidth / 2) + caretGap}px`;
107
157
  caretEl.style.height = `${coords.height}px`;
108
158
  caretEl.style.width = `${caretWidth}px`;
109
159
  caretEl.style.backgroundColor = caretColor;
@@ -214,6 +214,7 @@ export function bindTextEditorInteractions(controller, {
214
214
  isShape,
215
215
  autoSize,
216
216
  updateNoteEditor,
217
+ setNoteBanVisible,
217
218
  finalize,
218
219
  listType,
219
220
  }) {
@@ -327,7 +328,31 @@ export function bindTextEditorInteractions(controller, {
327
328
  // уносил текст вправо от фигуры. Поэтому ввод в фигуре границы не меняет.
328
329
  let inputHandler;
329
330
  if (isNote) {
330
- inputHandler = () => { try { if (updateNoteEditor) updateNoteEditor(); } catch (_) {} };
331
+ // Лимит строк записки: если очередной ввод превышает вместимость (>25 строк или
332
+ // не влезает по высоте при минимальном шрифте), откатываем значение и показываем
333
+ // иконку запрета. Иначе пересчитываем подгонку и держим иконку, пока записка полна.
334
+ let lastGoodValue = textarea.value;
335
+ let lastGoodSel = textarea.selectionStart || 0;
336
+ inputHandler = () => {
337
+ try {
338
+ const res = updateNoteEditor ? updateNoteEditor() : null;
339
+ // Откатываем только рост текста, превысивший вместимость. Удаление/замену
340
+ // на более короткое значение всегда принимаем, иначе из переполненного
341
+ // состояния нельзя выйти.
342
+ const growing = textarea.value.length > lastGoodValue.length;
343
+ if (res && res.fits === false && growing) {
344
+ const restorePos = Math.min(lastGoodSel, lastGoodValue.length);
345
+ textarea.value = lastGoodValue;
346
+ textarea.selectionStart = textarea.selectionEnd = restorePos;
347
+ if (updateNoteEditor) updateNoteEditor();
348
+ if (setNoteBanVisible) setNoteBanVisible(true);
349
+ } else {
350
+ lastGoodValue = textarea.value;
351
+ lastGoodSel = textarea.selectionStart || 0;
352
+ if (setNoteBanVisible) setNoteBanVisible(!!(res && res.full));
353
+ }
354
+ } catch (_) {}
355
+ };
331
356
  } else if (isShape) {
332
357
  inputHandler = () => {};
333
358
  } else {
@@ -1,7 +1,18 @@
1
1
  import * as PIXI from 'pixi.js';
2
2
  import { Events } from '../../../core/events/Events.js';
3
3
  import { registerEditorListeners } from './InlineEditorListenersRegistry.js';
4
- import { computeTextRightPadPx } from '../../../services/text/TextBoxMetrics.js';
4
+ import {
5
+ computeTextRightPadPx,
6
+ resolveLineHeightRatio,
7
+ } from '../../../services/text/TextBoxMetrics.js';
8
+
9
+ // Максимальное число визуальных строк в записке. По достижении лимита ввод
10
+ // блокируется (см. note inputHandler), шрифт до этого сжимается без нижнего предела,
11
+ // чтобы строки помещались по высоте записки.
12
+ export const NOTE_MAX_LINES = 25;
13
+ // Жёсткий пол только чтобы шрифт не стал 0/отрицательным. Ограничения по размеру нет —
14
+ // единственный лимит ввода это NOTE_MAX_LINES.
15
+ const NOTE_MIN_FONT_PX = 1;
5
16
 
6
17
  // Измеряет ширину текста textarea по реальным глифам через скрытый span.
7
18
  // Возвращает ширину самой длинной строки в CSS-px (white-space: pre — без переносов),
@@ -28,6 +39,106 @@ function measureTextareaContentWidth(textarea, value) {
28
39
  }
29
40
  }
30
41
 
42
+ // Высота блока текста (px) при заданном шрифте и ширине, измеренная скрытым div c
43
+ // тем же переносом, что и редактор. Возвращает 0, если layout недоступен (jsdom).
44
+ function measureNoteBlockHeight(text, boxW, fontPx, fontFamily, ratio) {
45
+ try {
46
+ if (typeof document === 'undefined' || !document.body) return 0;
47
+ const m = document.createElement('div');
48
+ m.style.cssText = 'position:absolute;visibility:hidden;top:-9999px;left:-9999px;padding:0;margin:0;white-space:pre-wrap;word-break:break-word;overflow-wrap:anywhere;';
49
+ m.style.width = `${boxW}px`;
50
+ m.style.fontFamily = fontFamily || '';
51
+ m.style.fontSize = `${fontPx}px`;
52
+ m.style.lineHeight = `${ratio}`;
53
+ // \u200b в конце сохраняет высоту хвостового перевода строки при измерении.
54
+ m.textContent = text && text.length ? `${text}\u200b` : 'W';
55
+ document.body.appendChild(m);
56
+ const h = m.getBoundingClientRect().height;
57
+ m.remove();
58
+ return Number.isFinite(h) ? h : 0;
59
+ } catch (_) {
60
+ return 0;
61
+ }
62
+ }
63
+
64
+ // Считает число визуальных строк текста при ЕСТЕСТВЕННОМ размере шрифта (effectiveFontPx),
65
+ // не зависящем от сжатия для отображения. Сжатие меняет fitFont, но не количество строк —
66
+ // поэтому лимит строк должен меряться по неизменному шрифту, иначе блокировка срабатывает
67
+ // преждевременно (при просевшем fitFont деление давало раздутый счёт).
68
+ function countNoteVisualLines(text, boxW, fontPx, fontFamily) {
69
+ const ratio = resolveLineHeightRatio(fontPx);
70
+ const oneLine = measureNoteBlockHeight('W', boxW, fontPx, fontFamily, ratio);
71
+ if (!(oneLine > 0)) return 1;
72
+ const fullH = measureNoteBlockHeight(text, boxW, fontPx, fontFamily, ratio);
73
+ return Math.max(1, Math.round(fullH / oneLine));
74
+ }
75
+
76
+ // Подгоняет inline-редактор записки под её внутренние границы так же, как
77
+ // NoteObject._fitTextToBounds: текст переносится по фиксированной ширине блока (boxW),
78
+ // размер шрифта уменьшается, пока высота контента не влезет в innerH (без нижнего предела).
79
+ // Число строк (для лимита) меряется отдельно по естественному шрифту effectiveFontPx.
80
+ export function applyNoteEditorBox(textarea, backdrop, { boxW, innerH, effectiveFontPx }) {
81
+ // line-height задаём коэффициентом по текущему размеру (как NoteObject), чтобы
82
+ // высота строки совпадала с отрисованной запиской.
83
+ const setWrapStyles = (el, fontPx) => {
84
+ if (!el) return;
85
+ el.style.whiteSpace = 'pre-wrap';
86
+ el.style.wordBreak = 'break-word';
87
+ el.style.overflowWrap = 'anywhere';
88
+ el.style.textAlign = 'center';
89
+ el.style.boxSizing = 'content-box';
90
+ el.style.padding = '0';
91
+ el.style.overflow = 'hidden';
92
+ el.style.fontSize = `${fontPx}px`;
93
+ el.style.lineHeight = `${resolveLineHeightRatio(fontPx)}`;
94
+ el.style.width = `${boxW}px`;
95
+ };
96
+
97
+ const fontFamily = (typeof window !== 'undefined' && window.getComputedStyle)
98
+ ? window.getComputedStyle(textarea).fontFamily
99
+ : (textarea.style.fontFamily || '');
100
+ const naturalFont = Math.max(NOTE_MIN_FONT_PX, Math.round(effectiveFontPx));
101
+ const value = textarea.value || '';
102
+ const lineCount = countNoteVisualLines(value, boxW, naturalFont, fontFamily);
103
+
104
+ // Подбор шрифта детерминированный — высоту блока меряем скрытым div
105
+ // (measureNoteBlockHeight), а НЕ textarea.scrollHeight. Chromium при быстрых
106
+ // правках возвращает устаревший (завышенный) scrollHeight даже после смены
107
+ // font-size, из-за чего цикл уводил шрифт в минимум (1px), а скачок contentH
108
+ // дёргал каретку. Скрытый div пересоздаётся на каждом замере и стейл-значения
109
+ // не накапливает. Тот же подход уже применён в createRegularTextAutoSize.
110
+ let fitFont = naturalFont;
111
+ let measuredH = measureNoteBlockHeight(value, boxW, fitFont, fontFamily, resolveLineHeightRatio(fitFont));
112
+ // measuredH === 0 → layout недоступен (jsdom): сжимать нечем, оставляем естественный шрифт.
113
+ if (measuredH > 0) {
114
+ for (let safety = 0; safety < 256; safety++) {
115
+ if (measuredH <= innerH || fitFont <= NOTE_MIN_FONT_PX) break;
116
+ fitFont = Math.max(NOTE_MIN_FONT_PX, fitFont - 1);
117
+ measuredH = measureNoteBlockHeight(value, boxW, fitFont, fontFamily, resolveLineHeightRatio(fitFont));
118
+ }
119
+ }
120
+
121
+ setWrapStyles(textarea, fitFont);
122
+ textarea.style.minHeight = '0px';
123
+
124
+ const naturalH = measuredH > 0
125
+ ? Math.max(1, Math.ceil(measuredH))
126
+ : Math.max(1, Math.ceil(textarea.scrollHeight));
127
+ const contentH = Math.max(1, Math.min(innerH, naturalH));
128
+
129
+ textarea.style.height = `${contentH}px`;
130
+ setWrapStyles(backdrop, fitFont);
131
+ if (backdrop) {
132
+ backdrop.style.height = `${contentH}px`;
133
+ }
134
+
135
+ // Единственный лимит — число строк, посчитанное по естественному шрифту.
136
+ const fits = lineCount <= NOTE_MAX_LINES;
137
+ const full = lineCount >= NOTE_MAX_LINES;
138
+
139
+ return { fitFont, contentH, naturalH, lineCount, fits, full };
140
+ }
141
+
31
142
  export function createRegularTextAutoSize({
32
143
  textarea,
33
144
  wrapper,
@@ -119,10 +230,10 @@ export function createNoteEditorUpdater(controller, {
119
230
  effectiveFontPx,
120
231
  toScreen,
121
232
  }) {
122
- const minNoteEditorWidthPx = 20;
123
233
  const minNoteEditorHeightPx = Math.max(1, computeLineHeightPx(effectiveFontPx));
124
234
 
125
235
  return () => {
236
+ let result = null;
126
237
  try {
127
238
  const posDataNow = { objectId, position: null };
128
239
  const sizeDataNow = { objectId, size: null };
@@ -131,41 +242,38 @@ export function createNoteEditorUpdater(controller, {
131
242
  const posNow = posDataNow.position || position;
132
243
  const sizeNow = sizeDataNow.size || { width: noteWidth, height: noteHeight };
133
244
  const screenNow = toScreen(posNow.x, posNow.y);
134
- const viewRes = (controller.app?.renderer?.resolution) || (view.width && view.clientWidth ? (view.width / view.clientWidth) : 1);
135
245
  const worldLayerRef = controller.textEditor.world || (controller.app?.stage);
136
246
  const scaleX = worldLayerRef?.scale?.x || 1;
137
- const scaleCss = scaleX / viewRes;
138
- const maxWpx = Math.max(1, Math.round((sizeNow.width - (horizontalPadding * 2)) * scaleCss));
139
- const maxHpx = Math.max(1, Math.round((sizeNow.height - (horizontalPadding * 2)) * scaleCss));
140
-
141
- textarea.style.width = 'auto';
142
- textarea.style.height = 'auto';
143
- const naturalW = Math.ceil(textarea.scrollWidth + 1);
144
- const naturalH = Math.ceil(textarea.scrollHeight);
145
- const widthPx = Math.min(maxWpx, Math.max(minNoteEditorWidthPx, naturalW));
146
- const heightPx = Math.min(maxHpx, Math.max(minNoteEditorHeightPx, naturalH));
147
-
148
- textarea.style.width = `${widthPx}px`;
149
- wrapper.style.width = `${widthPx}px`;
150
- textarea.style.height = `${heightPx}px`;
151
- wrapper.style.height = `${heightPx}px`;
152
-
153
- // backdrop рисует видимые глифы и имеет фиксированную высоту в px с момента
154
- // открытия (applyEditorSizing — одна строка). Без синхронизации с textarea
155
- // многострочный ввод (Enter) обрезается снизу (overflow: hidden).
247
+ // worldScale без /res: screenNow приходит из toScreen→toGlobal (CSS-px, res-независим),
248
+ // а записка — PIXI (1 мир. ед. = worldScale CSS-px). Деление на res уводило бы редактор
249
+ // влево и сжимало блок при зуме браузера ≠100% (res≠1). См. NoteInlineEditorController.
250
+ const scaleCss = scaleX;
251
+ // Внутренний блок текста = границы записки минус отступы, ограничение
252
+ // ширины 360 — как в NoteObject._getVisibleTextWidth.
253
+ const innerWorldW = Math.max(1, Math.min(360, sizeNow.width - (horizontalPadding * 2)));
254
+ const innerWorldH = Math.max(1, sizeNow.height - (horizontalPadding * 2));
255
+ const boxW = Math.max(1, Math.round(innerWorldW * scaleCss));
256
+ const innerH = Math.max(minNoteEditorHeightPx, Math.round(innerWorldH * scaleCss));
257
+
156
258
  const backdrop = wrapper.querySelector('.moodboard-text-backdrop');
157
- if (backdrop) {
158
- backdrop.style.width = `${widthPx}px`;
159
- backdrop.style.height = `${heightPx}px`;
160
- }
161
259
 
162
- const left = Math.round(screenNow.x + (sizeNow.width * scaleCss) / 2 - (widthPx / 2));
163
- const top = Math.round(screenNow.y + (sizeNow.height * scaleCss) / 2 - (heightPx / 2));
260
+ result = applyNoteEditorBox(textarea, backdrop, {
261
+ boxW,
262
+ innerH,
263
+ effectiveFontPx,
264
+ });
265
+ const contentH = result.contentH;
266
+
267
+ wrapper.style.width = `${boxW}px`;
268
+ wrapper.style.height = `${contentH}px`;
269
+
270
+ // Центрируем блок контента внутри записки по обеим осям (как PIXI-текст).
271
+ const left = Math.round(screenNow.x + (sizeNow.width * scaleCss) / 2 - (boxW / 2));
272
+ const top = Math.round(screenNow.y + (sizeNow.height * scaleCss) / 2 - (contentH / 2));
164
273
  wrapper.style.left = `${left}px`;
165
274
  wrapper.style.top = `${top}px`;
166
- textarea.style.width = `${widthPx}px`;
167
- textarea.style.height = `${heightPx}px`;
168
275
  } catch (_) {}
276
+ return result;
169
277
  };
170
278
  }
171
279
 
@@ -140,7 +140,10 @@ export function openTextEditor(object, create = false) {
140
140
  const wt = inst.textField.worldTransform;
141
141
  const scaleY = Math.max(0.0001, Math.hypot(wt.c || 0, wt.d || 0));
142
142
  const baseFS = parseFloat(inst.textField.style?.fontSize || fontSize || 14) || (fontSize || 14);
143
- effectiveFontPx = Math.max(1, Math.round(baseFS * (scaleY / viewResEarly)));
143
+ // worldTransform.scaleY = worldScale; CSS-размер глифа PIXI-записки = baseFS*worldScale
144
+ // (res в трансформ сцены не входит). Деление на viewResEarly делало шрифт редактора
145
+ // мельче статического при res≠1 и рассогласовывало перенос строк с записанным текстом.
146
+ effectiveFontPx = Math.max(1, Math.round(baseFS * scaleY));
144
147
  }
145
148
  } catch (_) {}
146
149
  } else if (typeof window !== 'undefined' && window.moodboardHtmlTextLayer) {
@@ -317,6 +320,7 @@ export function openTextEditor(object, create = false) {
317
320
 
318
321
  // Для записок позиционируем редактор внутри записки
319
322
  let updateNoteEditor = null;
323
+ let setNoteBanVisible = null;
320
324
  let regularEditorVisualBox = null;
321
325
  if (objectType === 'note') {
322
326
  const noteSetup = setupNoteInlineEditor(this, {
@@ -332,6 +336,7 @@ export function openTextEditor(object, create = false) {
332
336
  toScreen,
333
337
  });
334
338
  updateNoteEditor = noteSetup.updateNoteEditor;
339
+ setNoteBanVisible = noteSetup.setBanVisible;
335
340
  } else if (isShape) {
336
341
  // Shape: редактор занимает весь bounds фигуры, текст вертикально центрирован
337
342
  const viewResShape = (this.app?.renderer?.resolution) ||
@@ -444,8 +449,12 @@ export function openTextEditor(object, create = false) {
444
449
  const s = worldLayerRef?.scale?.x || 1;
445
450
  const viewRes = (this.app?.renderer?.resolution) || (view.width && view.clientWidth ? (view.width / view.clientWidth) : 1);
446
451
  // Синхронизируем стартовый размер шрифта textarea с текущим зумом (как HtmlTextLayer)
447
- // Используем ранее вычисленный effectiveFontPx (до вставки в DOM), если он есть в замыкании
448
- textarea.style.fontSize = `${effectiveFontPx}px`;
452
+ // Используем ранее вычисленный effectiveFontPx (до вставки в DOM), если он есть в замыкании.
453
+ // Для записки шрифт уже подобран autoSizeNote (fit-to-bounds): сброс к effectiveFontPx
454
+ // рассинхронизировал бы textarea (по нему считается каретка) с backdrop.
455
+ if (!isNote) {
456
+ textarea.style.fontSize = `${effectiveFontPx}px`;
457
+ }
449
458
  // Высоту/ширину поля при входе в редактор берём как максимум из видимого DOM-бокса
450
459
  // и размера в состоянии объекта. DOM-бокс .mb-text к этому моменту может быть схлопнут
451
460
  // до высоты контента (auto), тогда как в состоянии хранится вручную заданная высота рамки —
@@ -528,6 +537,11 @@ export function openTextEditor(object, create = false) {
528
537
  autoSize();
529
538
  }
530
539
  textarea.focus();
540
+ // Записка: после фокуса повторно подгоняем блок (focus может изменить layout),
541
+ // чтобы стартовая каретка считалась от уже подобранного размера шрифта, а не от 32px.
542
+ if (isNote && updateNoteEditor) {
543
+ try { updateNoteEditor(); } catch (_) {}
544
+ }
531
545
  // Ручки скрыты в режиме input
532
546
  // Локальная CSS-настройка placeholder (меньше базового шрифта)
533
547
  const styleEl = attachTextEditorPlaceholderStyle(textarea, {
@@ -588,6 +602,7 @@ export function openTextEditor(object, create = false) {
588
602
  isShape,
589
603
  autoSize,
590
604
  updateNoteEditor,
605
+ setNoteBanVisible,
591
606
  finalize,
592
607
  listType: properties.listType || 'none',
593
608
  });
@@ -83,6 +83,7 @@ export class HoverLiftController {
83
83
  this._isDragging = false;
84
84
  this._isResizing = false;
85
85
  this._isRotating = false;
86
+ this._isConnecting = false;
86
87
  /** Set<id> — выделенные объекты (по _mb.objectId) */
87
88
  this._selectedIds = new Set();
88
89
 
@@ -217,9 +218,20 @@ export class HoverLiftController {
217
218
  return world?.scale?.x ?? 1;
218
219
  }
219
220
 
221
+ /**
222
+ * Внешняя блокировка hover на время протягивания коннектора.
223
+ * Цель коннектора не должна играть scale/lift: иначе её визуальные
224
+ * границы выходят за рамку подсветки, которая строится по логическим bounds.
225
+ * @param {boolean} active
226
+ */
227
+ setConnecting(active) {
228
+ this._isConnecting = !!active;
229
+ if (this._isConnecting) this._snapBackAll();
230
+ }
231
+
220
232
  /** Можно ли сейчас показывать hover */
221
233
  _isBlocked(pixiObject) {
222
- if (this._isDragging || this._isResizing || this._isRotating) return true;
234
+ if (this._isDragging || this._isResizing || this._isRotating || this._isConnecting) return true;
223
235
  const id = pixiObject._mb?.objectId;
224
236
  if (id && this._selectedIds.has(id)) return true;
225
237
  return false;
@@ -676,6 +676,28 @@
676
676
  border-radius: 1px;
677
677
  }
678
678
 
679
+ /* Иконка запрета ввода при достижении лимита строк записки */
680
+ .moodboard-note-limit-ban {
681
+ position: absolute;
682
+ left: 50%;
683
+ top: 50%;
684
+ transform: translate(-50%, -50%);
685
+ width: 48px;
686
+ height: 48px;
687
+ align-items: center;
688
+ justify-content: center;
689
+ color: #b0b0b0;
690
+ opacity: 0.9;
691
+ pointer-events: none;
692
+ z-index: 10002;
693
+ }
694
+
695
+ .moodboard-note-limit-ban svg {
696
+ width: 100%;
697
+ height: 100%;
698
+ display: block;
699
+ }
700
+
679
701
  @keyframes mb-caret-blink {
680
702
  0%, 100% { opacity: 1; }
681
703
  50% { opacity: 0; }