@sequent-org/moodboard 1.4.59 → 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.59",
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>
@@ -249,13 +249,20 @@ export class PixiEngine {
249
249
  if (pixiObject) {
250
250
  console.log('🗑️ PixiEngine: удаляем объект из сцены:', objectId);
251
251
 
252
- // Видео: останавливаем HTMLVideoElement и снимаем listeners до уничтожения
253
- // спрайта. Без этого видео продолжает играть и тикать текстуру — утечка.
254
- // Хук сужен до 'video', чтобы не задеть destroy() фреймов/коннекторов,
255
- // у которых контейнер уничтожается общим путём ниже (иначе двойной destroy).
256
252
  const mbMeta = pixiObject._mb;
257
- if (mbMeta?.type === 'video' && typeof mbMeta.instance?.destroy === 'function') {
258
- try { mbMeta.instance.destroy(); } catch (_) {}
253
+ const mbInstance = mbMeta?.instance;
254
+
255
+ // Видео: останавливаем HTMLVideoElement и снимаем listeners до уничтожения спрайта.
256
+ // Фрейм и другие объекты с instance.destroy(): снимаем подписки с EventBus ДО того,
257
+ // как PIXI-контейнер будет уничтожен. FrameObject.destroy() сам уничтожает контейнер,
258
+ // поэтому после него ручной pixiObject.destroy() не нужен — флаг handledByInstance.
259
+ let handledByInstance = false;
260
+ if (typeof mbInstance?.destroy === 'function') {
261
+ try { mbInstance.destroy(); } catch (_) {}
262
+ // Типы, чей instance.destroy() полностью берёт на себя PIXI-cleanup
263
+ if (mbMeta.type === 'frame') {
264
+ handledByInstance = true;
265
+ }
259
266
  }
260
267
 
261
268
  // Удаляем из родительского контейнера
@@ -266,7 +273,12 @@ export class PixiEngine {
266
273
  }
267
274
 
268
275
  // ИСПРАВЛЕНИЕ: Полная очистка для изображений/эмоджи
269
- if (pixiObject instanceof PIXI.Sprite) {
276
+ if (handledByInstance) {
277
+ // instance.destroy() уже уничтожил PIXI-контейнер и снял все подписки
278
+ if (this.hoverLift) {
279
+ try { this.hoverLift.detach(pixiObject); } catch (_) {}
280
+ }
281
+ } else if (pixiObject instanceof PIXI.Sprite) {
270
282
  console.log('🗑️ PixiEngine: очищаем ресурсы изображения/эмоджи');
271
283
 
272
284
  // Очищаем текстуру (data URL, blob URL — освобождаем память)
@@ -138,6 +138,7 @@ export class FrameObject {
138
138
 
139
139
  /** Скрыть/показать серую рамку (при выделении скрываем, чтобы не накладывалась на синюю) */
140
140
  setBorderVisible(visible) {
141
+ if (this._destroyed || !this.container) return;
141
142
  if (this._borderVisible === visible) return;
142
143
  this._borderVisible = visible;
143
144
  this._redrawPreserveTransform(this.width, this.height, this.fillColor);
@@ -332,6 +333,7 @@ export class FrameObject {
332
333
  */
333
334
  _onZoomChange(data) {
334
335
  if (!data || typeof data.percentage !== 'number') return;
336
+ if (!this.titleLayer || this.container?.destroyed) return;
335
337
 
336
338
  const worldScale = data.percentage / 100;
337
339
  this.currentWorldScale = worldScale;
@@ -342,7 +344,7 @@ export class FrameObject {
342
344
  * Масштаб и позиция слоя заголовка — компенсируем зум, держим постоянный экранный размер
343
345
  */
344
346
  _updateTitleScale() {
345
- if (!this.titleLayer) return;
347
+ if (!this.titleLayer || this.titleLayer.destroyed || this.container?.destroyed) return;
346
348
 
347
349
  const compensationScale = 1 / this.currentWorldScale;
348
350
 
@@ -590,9 +592,13 @@ export class FrameObject {
590
592
  }
591
593
 
592
594
  /**
593
- * Метод для отписки от событий при уничтожении объекта
595
+ * Метод для отписки от событий при уничтожении объекта.
596
+ * Идемпотентен: повторный вызов безопасен.
594
597
  */
595
598
  destroy() {
599
+ if (this._destroyed) return;
600
+ this._destroyed = true;
601
+
596
602
  if (this.eventBus) {
597
603
  if (this._boundOnZoomChange) {
598
604
  this.eventBus.off(Events.UI.ZoomPercent, this._boundOnZoomChange);
@@ -605,9 +611,16 @@ export class FrameObject {
605
611
  this._boundOnSelectionAdd = this._boundOnSelectionRemove = this._boundOnSelectionClear = null;
606
612
  }
607
613
  }
608
- if (this.container) {
614
+
615
+ if (this.container && !this.container.destroyed) {
609
616
  this.container.destroy({ children: true });
610
617
  }
618
+
619
+ this.container = null;
620
+ this.titleLayer = null;
621
+ this.titleText = null;
622
+ this.titleBg = null;
623
+ this.graphics = null;
611
624
  }
612
625
  }
613
626
 
@@ -98,6 +98,92 @@ export function applyTextStyles(el, { fontSizePx, baseFontSizePx, fontFamily, pr
98
98
  el.style.textRendering = 'optimizeLegibility';
99
99
  }
100
100
 
101
+ // Канвас и кеш для измерения видимых границ глифов (см. computeSingleLineCenterDelta).
102
+ // getContext оборачиваем в try/catch: в jsdom (тесты) он не реализован и иначе шумит в stderr.
103
+ let _vCenterCtx = null;
104
+ try {
105
+ if (typeof document !== 'undefined') {
106
+ _vCenterCtx = document.createElement('canvas').getContext('2d');
107
+ }
108
+ } catch (_) {
109
+ _vCenterCtx = null;
110
+ }
111
+ const _vCenterCache = new Map();
112
+ const _VCENTER_CACHE_LIMIT = 512;
113
+
114
+ /**
115
+ * Дельта высоты (px) для вертикального центрирования видимых глифов в однострочном
116
+ * текстовом боксе.
117
+ *
118
+ * Проблема: line-box шрифта резервирует сверху место под прописные и верхние выносные,
119
+ * которого строчный текст не занимает. Из-за этого буквы визуально прижаты к низу рамки
120
+ * (сверху пустого места больше, снизу меньше), и на отдельных стадиях зума перекос
121
+ * усиливается округлением. Возвращаем «верхний резерв − нижний резерв»: добавив это к
122
+ * высоте бокса, уравниваем зазоры сверху и снизу вокруг реальных букв.
123
+ *
124
+ * Измеряем по фактически отрисованному элементу (его computed-стили, baseline и
125
+ * округление на текущем зуме), поэтому остаточная асимметрия не превышает 1px на любой
126
+ * стадии. Результат кешируется по сигнатуре «шрифт+размер+контент», чтобы не запускать
127
+ * измерение на каждый кадр пана/зума.
128
+ *
129
+ * @param {HTMLElement} el — элемент .mb-text с уже выставленной height:auto
130
+ * @returns {number|null} целочисленная дельта (может быть отрицательной), либо null,
131
+ * если центрирование неприменимо (пустой/многострочный текст, окружение без layout/canvas)
132
+ */
133
+ export function computeSingleLineCenterDelta(el) {
134
+ if (!el || !_vCenterCtx || typeof el.getBoundingClientRect !== 'function' || typeof window === 'undefined') {
135
+ return null;
136
+ }
137
+ const text = el.textContent || '';
138
+ if (!text.trim() || text.includes('\n')) {
139
+ return null;
140
+ }
141
+ let cs;
142
+ try {
143
+ cs = window.getComputedStyle(el);
144
+ } catch (_) {
145
+ return null;
146
+ }
147
+ const key = `${cs.fontFamily}|${cs.fontWeight}|${cs.fontStyle}|${cs.lineHeight}|${cs.fontSize}|${text}`;
148
+ const cached = _vCenterCache.get(key);
149
+ if (cached !== undefined) {
150
+ return cached;
151
+ }
152
+
153
+ let delta = null;
154
+ try {
155
+ const elRect = el.getBoundingClientRect();
156
+ const lineBoxHeight = el.scrollHeight;
157
+ // Базовая линия строки: inline-block нулевой высоты, выровненный по baseline,
158
+ // верхней гранью садится ровно на базовую линию текста.
159
+ const marker = document.createElement('span');
160
+ marker.style.cssText = 'display:inline-block;width:0;height:0;vertical-align:baseline;';
161
+ el.appendChild(marker);
162
+ const baselineRel = marker.getBoundingClientRect().top - elRect.top;
163
+ el.removeChild(marker);
164
+
165
+ _vCenterCtx.font = `${cs.fontStyle} ${cs.fontWeight} ${cs.fontSize} ${cs.fontFamily}`;
166
+ const m = _vCenterCtx.measureText(text);
167
+ const ascent = m.actualBoundingBoxAscent;
168
+ const descent = m.actualBoundingBoxDescent;
169
+ if (Number.isFinite(ascent) && Number.isFinite(descent) && lineBoxHeight > 0) {
170
+ const inkTop = baselineRel - ascent;
171
+ const inkBottom = baselineRel + descent;
172
+ const topReserve = inkTop;
173
+ const bottomReserve = lineBoxHeight - inkBottom;
174
+ delta = Math.round(topReserve - bottomReserve);
175
+ }
176
+ } catch (_) {
177
+ delta = null;
178
+ }
179
+
180
+ if (_vCenterCache.size >= _VCENTER_CACHE_LIMIT) {
181
+ _vCenterCache.clear();
182
+ }
183
+ _vCenterCache.set(key, delta);
184
+ return delta;
185
+ }
186
+
101
187
  /**
102
188
  * Применяет параметры размера поля для inline-редактора (textarea/backdrop).
103
189
  * Отдельно от applyTextStyles, чтобы не дублировать логику в статическом слое.
@@ -1,3 +1,5 @@
1
+ import { Events } from '../../core/events/Events.js';
2
+
1
3
  const PASSIVE_FALSE = { passive: false };
2
4
 
3
5
  export class ToolManagerLifecycle {
@@ -70,12 +72,49 @@ export class ToolManagerLifecycle {
70
72
 
71
73
  manager._onWindowWheel = (event) => {
72
74
  try {
73
- if (event && event.ctrlKey && manager.isMouseOverContainer) {
74
- event.preventDefault();
75
+ if (!event || !(event.ctrlKey || event.metaKey)) return;
76
+ // Гасим браузерный зум страницы при Ctrl/Cmd+колесо в пределах мудборда,
77
+ // независимо от того, над холстом курсор или над HTML-панелью
78
+ event.preventDefault();
79
+
80
+ // _onWheel (на canvas) ловит зум, когда цель события — сам canvas.
81
+ // HTML-оверлеи (textarea редактора текста, ручки и т.п.) — соседи canvas,
82
+ // а не его потомки, поэтому их wheel-события не всплывают через canvas.
83
+ // Ловим их здесь: эмитируем WheelZoom, если событие пришло изнутри
84
+ // контейнера мудборда, но НЕ с самого canvas (чтобы не дублировать).
85
+ if (event.target !== manager.container) {
86
+ const parent = manager.container && manager.container.parentElement;
87
+ if (parent && parent.contains(event.target)) {
88
+ const canvasRect = manager.container.getBoundingClientRect();
89
+ const x = event.clientX - canvasRect.left;
90
+ const y = event.clientY - canvasRect.top;
91
+ manager.eventBus.emit(Events.Tool.WheelZoom, { x, y, delta: event.deltaY });
92
+ }
75
93
  }
76
94
  } catch (_) {}
77
95
  };
78
96
  window.addEventListener('wheel', manager._onWindowWheel, PASSIVE_FALSE);
97
+
98
+ // Перехватываем браузерный зум страницы (Ctrl +/-/0) и масштабируем только холст
99
+ manager._onWindowZoomKeys = (event) => {
100
+ try {
101
+ if (!event || !(event.ctrlKey || event.metaKey) || event.altKey) return;
102
+ const key = event.key;
103
+ let zoomEvent = null;
104
+ if (key === '=' || key === '+') {
105
+ zoomEvent = Events.UI.ZoomIn;
106
+ } else if (key === '-' || key === '_') {
107
+ zoomEvent = Events.UI.ZoomOut;
108
+ } else if (key === '0') {
109
+ zoomEvent = Events.UI.ZoomReset;
110
+ }
111
+ if (!zoomEvent) return;
112
+ event.preventDefault();
113
+ event.stopPropagation();
114
+ manager.eventBus.emit(zoomEvent);
115
+ } catch (_) {}
116
+ };
117
+ window.addEventListener('keydown', manager._onWindowZoomKeys, true);
79
118
  }
80
119
 
81
120
  static destroy(manager) {
@@ -112,6 +151,13 @@ export class ToolManagerLifecycle {
112
151
  manager._onWindowWheel = null;
113
152
  }
114
153
 
154
+ if (manager._onWindowZoomKeys) {
155
+ try {
156
+ window.removeEventListener('keydown', manager._onWindowZoomKeys, true);
157
+ } catch (_) {}
158
+ manager._onWindowZoomKeys = null;
159
+ }
160
+
115
161
  if (manager.gestures) {
116
162
  manager.gestures.destroy();
117
163
  }
@@ -172,6 +172,9 @@ export class ConnectorDragController {
172
172
  if (Math.abs(e.clientX - this._startX) < DRAG_THRESHOLD
173
173
  && Math.abs(e.clientY - this._startY) < DRAG_THRESHOLD) return;
174
174
  this._dragging = true;
175
+ // Гасим hover-lift на время протягивания: цель не должна масштабироваться
176
+ // и подниматься, иначе её границы вылезают за рамку подсветки.
177
+ this.core?.pixi?.hoverLift?.setConnecting(true);
175
178
  const world = this._world();
176
179
  if (world) {
177
180
  this._previewGraphics = new PIXI.Graphics();
@@ -192,6 +195,10 @@ export class ConnectorDragController {
192
195
  if (objectId && objectId !== this._sourceTerminal?.boundId) {
193
196
  const bounds = this._objectBounds(objectId);
194
197
  if (bounds) {
198
+ // Рамку строим строго по логическим bounds — коннектор привязывается
199
+ // к ним же. Hover-трансформацию (scale/lift) не учитываем: иначе рамка
200
+ // выпирает за периметр объекта (раздув ×scale + подъём центра вверх) и
201
+ // отстаёт от анимации, т.к. перерисовывается только на pointermove.
195
202
  this._highlightGraphics.lineStyle({ width: 2, color: 0x2563EB, alpha: 0.85 });
196
203
  this._highlightGraphics.drawRect(bounds.x, bounds.y, bounds.width, bounds.height);
197
204
  // Подводим превью к коннектору, если курсор в зоне магнита
@@ -220,6 +227,7 @@ export class ConnectorDragController {
220
227
  const source = this._sourceTerminal;
221
228
  this._dragging = false;
222
229
  this._sourceTerminal = null;
230
+ this.core?.pixi?.hoverLift?.setConnecting(false);
223
231
  this._clearGraphics();
224
232
 
225
233
  if (!source) return;
@@ -331,6 +339,7 @@ export class ConnectorDragController {
331
339
  destroy() {
332
340
  document.removeEventListener('pointermove', this._boundMove);
333
341
  document.removeEventListener('pointerup', this._boundUp);
342
+ this.core?.pixi?.hoverLift?.setConnecting(false);
334
343
  if (this._pendingDupListener) {
335
344
  this.eventBus?.off(Events.Tool.DuplicateReady, this._pendingDupListener);
336
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,63 +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
- textarea.style.textAlign = 'center';
63
- const maxEditorWidthPx = Math.max(1, Math.round((noteWidth - (horizontalPadding * 2)) * sCssLocal));
64
- const maxEditorHeightPx = Math.max(1, Math.round((noteHeight - (horizontalPadding * 2)) * sCssLocal));
65
- const MIN_NOTE_EDITOR_W = 20;
66
- const MIN_NOTE_EDITOR_H = Math.max(1, computeLineHeightPx(effectiveFontPx));
73
+ const backdrop = wrapper.querySelector('.moodboard-text-backdrop');
67
74
 
68
- const autoSizeNote = () => {
69
- // Сначала сбрасываем размеры, чтобы измерить естественные
70
- textarea.style.width = 'auto';
71
- textarea.style.height = 'auto';
75
+ // Оверлей запрета ввода: показывается по центру записки, когда достигнут лимит
76
+ // строк (NOTE_MAX_LINES). Иконка assets/icons/ban.svg (stroke=currentColor).
77
+ const setBanVisible = ensureNoteLimitBan(wrapper);
72
78
 
73
- // Ширина по содержимому, но не шире границ записки CSS-пикселях)
74
- const naturalW = Math.ceil(textarea.scrollWidth + 1);
75
- const targetW = Math.min(maxEditorWidthPx, Math.max(MIN_NOTE_EDITOR_W, naturalW));
76
- textarea.style.width = `${targetW}px`;
77
- 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));
78
86
 
79
- // Высота по содержимому, c нижним пределом = одна строка
80
- const computed = (typeof window !== 'undefined') ? window.getComputedStyle(textarea) : null;
81
- const lineH = (computed ? parseFloat(computed.lineHeight) : computeLineHeightPx(effectiveFontPx));
82
- const naturalH = Math.ceil(textarea.scrollHeight);
83
- const targetH = Math.min(maxEditorHeightPx, Math.max(MIN_NOTE_EDITOR_H, naturalH));
84
- textarea.style.height = `${targetH}px`;
85
- 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`;
86
95
 
87
- // Центрируем wrapper внутри записки после смены размеров (в CSS-пикселях)
88
- const left = Math.round(screenPos.x + (noteWidth * sCssLocal) / 2 - (targetW / 2));
89
- 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));
90
99
  wrapper.style.left = `${left}px`;
91
100
  wrapper.style.top = `${top}px`;
101
+ return result;
92
102
  };
93
103
  // Первый вызов — синхронизировать с текущим содержимым
94
- autoSizeNote();
104
+ const initial = autoSizeNote();
105
+ setBanVisible(!!(initial && initial.full));
95
106
 
96
107
  // Динамическое обновление позиции/размера редактора при зуме/панорамировании/трансформациях
97
108
  const updateNoteEditor = createNoteEditorUpdater(controller, {
@@ -109,5 +120,5 @@ export function setupNoteInlineEditor(controller, params) {
109
120
  });
110
121
  registerNoteEditorSync(controller, { objectId, updateNoteEditor });
111
122
 
112
- return { updateNoteEditor };
123
+ return { updateNoteEditor, setBanVisible };
113
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,8 +123,21 @@ 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;
87
- // Caveat has thicker strokes, Arial is standard. A good heuristic is ~0.06 to 0.08 of font size, min 2px.
88
- const caretWidth = Math.max(2, Math.round(fontSize * 0.07));
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;
137
+ // Толщина каретки пропорциональна шрифту (≈7% размера). Минимум 1px — при сильном
138
+ // отдалении каретка остаётся видимой и не нарушает пропорцию. Жёсткий пол 2px убран,
139
+ // чтобы при уменьшении масштаба каретка масштабировалась вместе с текстом.
140
+ const caretWidth = Math.max(1, Math.round(fontSize * 0.07));
89
141
 
90
142
  // Цвет текста в textarea теперь прозрачный (видимый текст рисует backdrop-слой),
91
143
  // поэтому цвет каретки берём из backdrop, иначе она станет невидимой.
@@ -101,7 +153,7 @@ export function updateCustomCaret(textarea, caretEl) {
101
153
  }
102
154
 
103
155
  caretEl.style.top = `${top - 2}px`;
104
- caretEl.style.left = `${left - Math.floor(caretWidth / 2)}px`;
156
+ caretEl.style.left = `${left - Math.floor(caretWidth / 2) + caretGap}px`;
105
157
  caretEl.style.height = `${coords.height}px`;
106
158
  caretEl.style.width = `${caretWidth}px`;
107
159
  caretEl.style.backgroundColor = caretColor;
@@ -8,6 +8,7 @@ import {
8
8
  showStaticTextAfterEditing,
9
9
  updateGlobalTextEditorHandlesLayer,
10
10
  } from './TextEditorLifecycleRegistry.js';
11
+ import { unregisterEditorListeners } from './InlineEditorListenersRegistry.js';
11
12
 
12
13
  export function applyTextEditorCaretFromClick({ create, objectId, object, textarea }) {
13
14
  try {
@@ -213,6 +214,7 @@ export function bindTextEditorInteractions(controller, {
213
214
  isShape,
214
215
  autoSize,
215
216
  updateNoteEditor,
217
+ setNoteBanVisible,
216
218
  finalize,
217
219
  listType,
218
220
  }) {
@@ -286,6 +288,12 @@ export function bindTextEditorInteractions(controller, {
286
288
  const keydownHandler = (e) => {
287
289
  const isList = listType && listType !== 'none';
288
290
  if (e.key === 'Enter') {
291
+ // В записке Enter переносит каретку на новую строку (нативное поведение
292
+ // textarea), а не завершает ввод. Завершение — по клику вне записки (blur)
293
+ // или по Escape. Высота поля пересчитывается через 'input' → updateNoteEditor.
294
+ if (isNote) {
295
+ return;
296
+ }
289
297
  if (isList && !e.shiftKey) {
290
298
  e.preventDefault();
291
299
  const start = textarea.selectionStart;
@@ -320,7 +328,31 @@ export function bindTextEditorInteractions(controller, {
320
328
  // уносил текст вправо от фигуры. Поэтому ввод в фигуре границы не меняет.
321
329
  let inputHandler;
322
330
  if (isNote) {
323
- 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
+ };
324
356
  } else if (isShape) {
325
357
  inputHandler = () => {};
326
358
  } else {
@@ -359,6 +391,10 @@ export function bindTextEditorInteractions(controller, {
359
391
  };
360
392
 
361
393
  const caretUpdateHandler = () => {
394
+ // _caretSuppressed выставляется registerRegularTextEditorSync на время зума:
395
+ // не перерисовываем каретку, пока она должна быть скрыта (избегаем мигания
396
+ // каретки старого размера в промежуточных кадрах при Ctrl±).
397
+ if (controller.textEditor && controller.textEditor._caretSuppressed) return;
362
398
  if (controller.textEditor && controller.textEditor.caret) {
363
399
  updateCustomCaret(textarea, controller.textEditor.caret);
364
400
  }
@@ -444,6 +480,9 @@ export function closeTextEditorFromState(controller, commit) {
444
480
  }
445
481
  }
446
482
 
483
+ if (Array.isArray(controller.textEditor._listeners)) {
484
+ try { unregisterEditorListeners(controller.eventBus, controller.textEditor._listeners); } catch (_) {}
485
+ }
447
486
  if (typeof removeDomListeners === 'function') {
448
487
  try { removeDomListeners(); } catch (_) {}
449
488
  }
@@ -71,6 +71,13 @@ export function cleanupActiveTextEditor(controller, wrapper) {
71
71
  unregisterEditorListeners(controller.eventBus, controller.textEditor._listeners);
72
72
  }
73
73
  } catch (_) {}
74
+ // Снимаем таймер дебаунса и флаг подавления каретки (редактор мог закрыться в процессе зума)
75
+ try {
76
+ if (typeof controller.textEditor?._caretHideTimer === 'function') {
77
+ controller.textEditor._caretHideTimer();
78
+ }
79
+ if (controller.textEditor) controller.textEditor._caretSuppressed = false;
80
+ } catch (_) {}
74
81
 
75
82
  wrapper.remove();
76
83
  controller.textEditor = {
@@ -71,9 +71,10 @@ export function positionRegularTextEditor({
71
71
  } catch (_) {}
72
72
 
73
73
  const leftPx = Math.round(baseLeftPx - padLeft);
74
- const topPx = create
75
- ? Math.round(baseTopPx - padTop - (lineHeightPx / 2))
76
- : Math.round(baseTopPx + staticPadTop - padTop + 1);
74
+ // Верх текста редактора привязываем к точке клика (= верх рамки ручек и будущего
75
+ // статического текста). Прежний create-якорь `- lineHeightPx/2` (центрирование каретки
76
+ // по клику) поднимал текст на пол-строки выше рамки mb-handles-box-obj_.
77
+ const topPx = Math.round(baseTopPx + staticPadTop - padTop + 1);
77
78
 
78
79
  wrapper.style.left = `${leftPx}px`;
79
80
  wrapper.style.top = `${topPx}px`;