@sequent-org/moodboard 1.4.59 → 1.4.60

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.60",
4
4
  "type": "module",
5
5
  "description": "Interactive moodboard",
6
6
  "main": "./src/index.js",
@@ -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
  }
@@ -101,6 +101,34 @@ 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
+
104
132
  /**
105
133
  * Возвращает ближайший якорь из TARGET_ANCHORS в пределах ANCHOR_SNAP_CSS от worldPt,
106
134
  * иначе null. Приоритет выше грани — вызывать в _resolveEnd первым.
@@ -192,8 +220,9 @@ export class ConnectorDragController {
192
220
  if (objectId && objectId !== this._sourceTerminal?.boundId) {
193
221
  const bounds = this._objectBounds(objectId);
194
222
  if (bounds) {
223
+ const visual = this._visualBounds(objectId, bounds);
195
224
  this._highlightGraphics.lineStyle({ width: 2, color: 0x2563EB, alpha: 0.85 });
196
- this._highlightGraphics.drawRect(bounds.x, bounds.y, bounds.width, bounds.height);
225
+ this._highlightGraphics.drawRect(visual.x, visual.y, visual.width, visual.height);
197
226
  // Подводим превью к коннектору, если курсор в зоне магнита
198
227
  const snapAnchor = this._snapToAnchor(bounds, worldPt);
199
228
  if (snapAnchor) {
@@ -58,8 +58,14 @@ export function setupNoteInlineEditor(controller, params) {
58
58
  wrapper.style.width = `${editorWidthPx}px`;
59
59
  wrapper.style.height = `${editorHeightPx}px`;
60
60
 
61
- // Для записок: авто-ресайз редактора под содержимое с сохранением центрирования
61
+ // Для записок: авто-ресайз редактора под содержимое с сохранением центрирования.
62
+ // backdrop (видимые глифы) обязан повторять выравнивание textarea, иначе каретка,
63
+ // позиционируемая по center-align textarea, уходит вправо от прижатого влево текста.
62
64
  textarea.style.textAlign = 'center';
65
+ const backdrop = wrapper.querySelector('.moodboard-text-backdrop');
66
+ if (backdrop) {
67
+ backdrop.style.textAlign = 'center';
68
+ }
63
69
  const maxEditorWidthPx = Math.max(1, Math.round((noteWidth - (horizontalPadding * 2)) * sCssLocal));
64
70
  const maxEditorHeightPx = Math.max(1, Math.round((noteHeight - (horizontalPadding * 2)) * sCssLocal));
65
71
  const MIN_NOTE_EDITOR_W = 20;
@@ -84,8 +84,10 @@ export function updateCustomCaret(textarea, caretEl) {
84
84
  // Calculate width based on font size to match stroke thickness
85
85
  const computed = window.getComputedStyle(textarea);
86
86
  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));
87
+ // Толщина каретки пропорциональна шрифту (≈7% размера). Минимум 1px при сильном
88
+ // отдалении каретка остаётся видимой и не нарушает пропорцию. Жёсткий пол 2px убран,
89
+ // чтобы при уменьшении масштаба каретка масштабировалась вместе с текстом.
90
+ const caretWidth = Math.max(1, Math.round(fontSize * 0.07));
89
91
 
90
92
  // Цвет текста в textarea теперь прозрачный (видимый текст рисует backdrop-слой),
91
93
  // поэтому цвет каретки берём из backdrop, иначе она станет невидимой.
@@ -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 {
@@ -286,6 +287,12 @@ export function bindTextEditorInteractions(controller, {
286
287
  const keydownHandler = (e) => {
287
288
  const isList = listType && listType !== 'none';
288
289
  if (e.key === 'Enter') {
290
+ // В записке Enter переносит каретку на новую строку (нативное поведение
291
+ // textarea), а не завершает ввод. Завершение — по клику вне записки (blur)
292
+ // или по Escape. Высота поля пересчитывается через 'input' → updateNoteEditor.
293
+ if (isNote) {
294
+ return;
295
+ }
289
296
  if (isList && !e.shiftKey) {
290
297
  e.preventDefault();
291
298
  const start = textarea.selectionStart;
@@ -359,6 +366,10 @@ export function bindTextEditorInteractions(controller, {
359
366
  };
360
367
 
361
368
  const caretUpdateHandler = () => {
369
+ // _caretSuppressed выставляется registerRegularTextEditorSync на время зума:
370
+ // не перерисовываем каретку, пока она должна быть скрыта (избегаем мигания
371
+ // каретки старого размера в промежуточных кадрах при Ctrl±).
372
+ if (controller.textEditor && controller.textEditor._caretSuppressed) return;
362
373
  if (controller.textEditor && controller.textEditor.caret) {
363
374
  updateCustomCaret(textarea, controller.textEditor.caret);
364
375
  }
@@ -444,6 +455,9 @@ export function closeTextEditorFromState(controller, commit) {
444
455
  }
445
456
  }
446
457
 
458
+ if (Array.isArray(controller.textEditor._listeners)) {
459
+ try { unregisterEditorListeners(controller.eventBus, controller.textEditor._listeners); } catch (_) {}
460
+ }
447
461
  if (typeof removeDomListeners === 'function') {
448
462
  try { removeDomListeners(); } catch (_) {}
449
463
  }
@@ -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`;