@sequent-org/moodboard 1.2.15 → 1.2.17

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.2.15",
3
+ "version": "1.2.17",
4
4
  "type": "module",
5
5
  "description": "Interactive moodboard",
6
6
  "main": "./src/index.js",
@@ -1,6 +1,7 @@
1
1
  import * as PIXI from 'pixi.js';
2
2
  import { ObjectFactory } from '../objects/ObjectFactory.js';
3
3
  import { ObjectRenderer } from './rendering/ObjectRenderer.js';
4
+ import { Events } from './events/Events.js';
4
5
 
5
6
  export class PixiEngine {
6
7
  constructor(container, eventBus, options) {
@@ -38,13 +39,33 @@ export class PixiEngine {
38
39
  this.app.stage.addChild(this.worldLayer);
39
40
 
40
41
  // Инициализируем ObjectRenderer
41
- this.renderer = new ObjectRenderer(this.objects);
42
+ this.renderer = new ObjectRenderer(this.objects, this.eventBus);
43
+
44
+ // Поддержка чёткости текстов записок при зуме: подписка на событие зума
45
+ if (this.eventBus) {
46
+ const onZoom = ({ percentage }) => {
47
+ try {
48
+ const world = this.worldLayer || this.app.stage;
49
+ const s = world?.scale?.x || (percentage ? percentage / 100 : 1);
50
+ const res = this.app?.renderer?.resolution || 1;
51
+ for (const [, pixiObject] of this.objects) {
52
+ const mb = pixiObject && pixiObject._mb;
53
+ if (mb && mb.type === 'note' && mb.instance && typeof mb.instance.updateCrispnessForZoom === 'function') {
54
+ mb.instance.updateCrispnessForZoom(s, res);
55
+ }
56
+ }
57
+ } catch (e) {
58
+ console.warn('PixiEngine: zoom crispness update failed', e);
59
+ }
60
+ };
61
+ this.eventBus.on(Events.UI.ZoomPercent, onZoom);
62
+ }
42
63
  }
43
64
 
44
65
  createObject(objectData) {
45
66
  let pixiObject;
46
67
 
47
- const instance = ObjectFactory.create(objectData.type, objectData);
68
+ const instance = ObjectFactory.create(objectData.type, objectData, this.eventBus);
48
69
  if (instance) {
49
70
  pixiObject = instance.getPixi();
50
71
  const prevMb = pixiObject._mb || {};
@@ -56,6 +77,15 @@ export class PixiEngine {
56
77
  };
57
78
  this.objects.set(objectData.id, pixiObject);
58
79
  this.worldLayer.addChild(pixiObject);
80
+ // Первичная установка чёткости для записок по текущему масштабу/резолюции
81
+ try {
82
+ if (pixiObject && pixiObject._mb && pixiObject._mb.type === 'note' && pixiObject._mb.instance && typeof pixiObject._mb.instance.updateCrispnessForZoom === 'function') {
83
+ const world = this.worldLayer || this.app.stage;
84
+ const s = world?.scale?.x || 1;
85
+ const res = this.app?.renderer?.resolution || 1;
86
+ pixiObject._mb.instance.updateCrispnessForZoom(s, res);
87
+ }
88
+ } catch (_) {}
59
89
  } else {
60
90
  console.warn(`Unknown object type: ${objectData.type}`);
61
91
  pixiObject = this.createDefaultObject(objectData);
@@ -7,8 +7,9 @@ import { GeometryUtils } from './GeometryUtils.js';
7
7
  * Отвечает за создание, обновление и удаление PIXI объектов
8
8
  */
9
9
  export class ObjectRenderer {
10
- constructor(objectsMap) {
10
+ constructor(objectsMap, eventBus = null) {
11
11
  this.objects = objectsMap; // Map<id, pixiObject> из PixiEngine
12
+ this.eventBus = eventBus;
12
13
  }
13
14
 
14
15
  /**
@@ -20,7 +21,7 @@ export class ObjectRenderer {
20
21
  let pixiObject;
21
22
 
22
23
  // Создаем объект через фабрику
23
- const instance = ObjectFactory.create(objectData.type, objectData);
24
+ const instance = ObjectFactory.create(objectData.type, objectData, this.eventBus);
24
25
  if (instance) {
25
26
  pixiObject = instance.getPixi();
26
27
  this._setupObjectMetadata(pixiObject, objectData, instance);
@@ -1,4 +1,5 @@
1
1
  import * as PIXI from 'pixi.js';
2
+ import { Events } from '../core/events/Events.js';
2
3
 
3
4
  /**
4
5
  * Класс объекта «Фрейм» (контейнерная прямоугольная область)
@@ -7,9 +8,11 @@ import * as PIXI from 'pixi.js';
7
8
  export class FrameObject {
8
9
  /**
9
10
  * @param {Object} objectData Полные данные объекта из состояния
11
+ * @param {Object} eventBus EventBus для подписки на события зума
10
12
  */
11
- constructor(objectData) {
13
+ constructor(objectData, eventBus = null) {
12
14
  this.objectData = objectData || {};
15
+ this.eventBus = eventBus;
13
16
  this.width = this.objectData.width || 100;
14
17
  this.height = this.objectData.height || 100;
15
18
  // Берем стили рамки из CSS-переменных, с дефолтом
@@ -37,19 +40,30 @@ export class FrameObject {
37
40
  this.container.addChild(this.graphics);
38
41
 
39
42
  // Текст заголовка
43
+ this.baseFontSize = 14; // Сохраняем оригинальный размер шрифта
44
+ this.currentWorldScale = 1.0; // Текущий масштаб мира
45
+ this.originalTitle = this.title; // Сохраняем оригинальный заголовок
40
46
  this.titleText = new PIXI.Text(this.title, {
41
47
  fontFamily: 'Arial, sans-serif',
42
- fontSize: 14,
48
+ fontSize: this.baseFontSize,
43
49
  fill: 0x333333,
44
50
  fontWeight: 'bold'
45
51
  });
46
52
  // Размещаем заголовок внутри верхней части фрейма, чтобы не влиять на внешние границы
47
53
  this.titleText.anchor.set(0, 0);
54
+ this.titleText.scale.set(1); // Инициализируем базовый масштаб
48
55
  this.titleText.x = 8;
49
56
  this.titleText.y = 4;
50
57
  this.container.addChild(this.titleText);
51
58
 
59
+ // Подписываемся на события зума для компенсации масштабирования заголовка
60
+ if (this.eventBus) {
61
+ this.eventBus.on(Events.UI.ZoomPercent, this._onZoomChange.bind(this));
62
+ }
63
+
52
64
  this._draw(this.width, this.height, this.fillColor);
65
+ // Применяем начальный масштаб и обрезку заголовка
66
+ this._updateTitleScale();
53
67
  // Центрируем pivot контейнера, чтобы совпадали рамка и ручки
54
68
  // pivot по центру, чтобы позиция (x,y) контейнера соответствовала центру видимой области фрейма
55
69
  this.container.pivot.set(this.width / 2, this.height / 2);
@@ -79,9 +93,8 @@ export class FrameObject {
79
93
  */
80
94
  setTitle(title) {
81
95
  this.title = title || 'Новый';
82
- if (this.titleText) {
83
- this.titleText.text = this.title;
84
- }
96
+ this.originalTitle = this.title;
97
+ this._updateTitleText();
85
98
  }
86
99
 
87
100
  /**
@@ -125,6 +138,9 @@ export class FrameObject {
125
138
  container.x = x;
126
139
  container.y = y;
127
140
  container.rotation = rot;
141
+
142
+ // Обновляем заголовок после перерисовки
143
+ this._updateTitleText();
128
144
  }
129
145
 
130
146
  /**
@@ -152,5 +168,125 @@ export class FrameObject {
152
168
  point.y <= bounds.y + bounds.height;
153
169
  };
154
170
  }
171
+
172
+ /**
173
+ * Обработчик изменения зума
174
+ * @param {Object} data Данные события с процентом зума
175
+ */
176
+ _onZoomChange(data) {
177
+ if (!data || typeof data.percentage !== 'number') return;
178
+
179
+ const worldScale = data.percentage / 100;
180
+ this.currentWorldScale = worldScale;
181
+ this._updateTitleScale();
182
+ }
183
+
184
+ /**
185
+ * Обновить масштаб заголовка для компенсации зума
186
+ */
187
+ _updateTitleScale() {
188
+ if (!this.titleText) return;
189
+
190
+ // Компенсируем зум мира обратным масштабированием заголовка
191
+ const compensationScale = 1 / this.currentWorldScale;
192
+
193
+ // Используем scale вместо fontSize для избежания размытия
194
+ this.titleText.scale.set(compensationScale);
195
+
196
+ // Корректируем позицию заголовка с учетом изменения масштаба
197
+ this.titleText.x = 8 * compensationScale;
198
+ this.titleText.y = 4 * compensationScale;
199
+
200
+ // Обновляем текст с учетом нового масштаба
201
+ this._updateTitleText();
202
+ }
203
+
204
+ /**
205
+ * Обновить текст заголовка с учетом доступной ширины
206
+ */
207
+ _updateTitleText() {
208
+ if (!this.titleText) return;
209
+
210
+ const truncatedText = this._truncateTextToFit(this.originalTitle);
211
+ this.titleText.text = truncatedText;
212
+ }
213
+
214
+ /**
215
+ * Обрезать текст до доступной ширины с добавлением многоточия
216
+ * @param {string} text Исходный текст
217
+ * @returns {string} Обрезанный текст с многоточием или оригинальный текст
218
+ */
219
+ _truncateTextToFit(text) {
220
+ if (!text || !this.titleText) return text;
221
+
222
+ // Компенсация масштаба для правильного расчета размеров
223
+ const compensationScale = 1 / this.currentWorldScale;
224
+
225
+ // Доступная ширина = ширина фрейма - отступы слева и справа (с учетом масштаба)
226
+ const leftPadding = 8 * compensationScale;
227
+ const rightPadding = 8 * compensationScale;
228
+ const availableWidth = this.width - leftPadding - rightPadding;
229
+
230
+ // Создаем временный стиль для измерения текста
231
+ // Используем базовый размер шрифта, а масштаб учтем отдельно
232
+ const style = new PIXI.TextStyle({
233
+ fontFamily: this.titleText.style.fontFamily,
234
+ fontSize: this.baseFontSize,
235
+ fontWeight: this.titleText.style.fontWeight
236
+ });
237
+
238
+ // Измеряем ширину оригинального текста с учетом масштаба
239
+ const textMetrics = PIXI.TextMetrics.measureText(text, style);
240
+ const scaledTextWidth = textMetrics.width * compensationScale;
241
+
242
+ // Если текст помещается, возвращаем его как есть
243
+ if (scaledTextWidth <= availableWidth) {
244
+ return text;
245
+ }
246
+
247
+ // Измеряем ширину многоточия с учетом масштаба
248
+ const ellipsisMetrics = PIXI.TextMetrics.measureText('...', style);
249
+ const ellipsisWidth = ellipsisMetrics.width * compensationScale;
250
+
251
+ // Доступная ширина для текста без многоточия
252
+ const textAvailableWidth = availableWidth - ellipsisWidth;
253
+
254
+ if (textAvailableWidth <= 0) {
255
+ return '...';
256
+ }
257
+
258
+ // Бинарный поиск оптимальной длины текста
259
+ let left = 0;
260
+ let right = text.length;
261
+ let result = '';
262
+
263
+ while (left <= right) {
264
+ const mid = Math.floor((left + right) / 2);
265
+ const subText = text.substring(0, mid);
266
+ const subTextMetrics = PIXI.TextMetrics.measureText(subText, style);
267
+ const scaledSubTextWidth = subTextMetrics.width * compensationScale;
268
+
269
+ if (scaledSubTextWidth <= textAvailableWidth) {
270
+ result = subText;
271
+ left = mid + 1;
272
+ } else {
273
+ right = mid - 1;
274
+ }
275
+ }
276
+
277
+ return result + '...';
278
+ }
279
+
280
+ /**
281
+ * Метод для отписки от событий при уничтожении объекта
282
+ */
283
+ destroy() {
284
+ if (this.eventBus) {
285
+ this.eventBus.off(Events.UI.ZoomPercent, this._onZoomChange.bind(this));
286
+ }
287
+ if (this.container) {
288
+ this.container.destroy({ children: true });
289
+ }
290
+ }
155
291
  }
156
292
 
@@ -412,4 +412,27 @@ export class NoteObject {
412
412
  this.textField.x = centerX;
413
413
  this.textField.y = centerY;
414
414
  }
415
+
416
+ /**
417
+ * Корректирует чёткость текста при изменении масштаба мира.
418
+ * Увеличиваем resolution текстовой текстуры пропорционально зуму,
419
+ * затем перерисовываем текст без изменения визуального размера.
420
+ * @param {number} worldScale текущий масштаб worldLayer (например, 1.2)
421
+ * @param {number} deviceResolution текущее renderer.resolution (обычно devicePixelRatio)
422
+ */
423
+ updateCrispnessForZoom(worldScale, deviceResolution) {
424
+ try {
425
+ if (!this.textField) return;
426
+ const dpr = Math.max(1, Number(deviceResolution) || 1);
427
+ const s = Math.max(0.1, Number(worldScale) || 1);
428
+ const targetRes = Math.max(1, dpr * s);
429
+ if (this.textField.resolution !== targetRes) {
430
+ this.textField.resolution = targetRes;
431
+ // Для стабильной отрисовки
432
+ this.textField.roundPixels = true;
433
+ // Принудительно пересчитать текстуру под новую resolution
434
+ this.textField.updateText();
435
+ }
436
+ } catch (_) {}
437
+ }
415
438
  }
@@ -40,12 +40,17 @@ export class ObjectFactory {
40
40
  * Создать инстанс объекта по типу
41
41
  * @param {string} type
42
42
  * @param {Object} objectData
43
+ * @param {Object} eventBus EventBus для объектов, которым он нужен
43
44
  * @returns {any|null}
44
45
  */
45
- static create(type, objectData = {}) {
46
+ static create(type, objectData = {}, eventBus = null) {
46
47
  const Ctor = this.registry.get(type);
47
48
  if (!Ctor) return null;
48
49
  try {
50
+ // Если тип объекта - фрейм, передаем eventBus
51
+ if (type === 'frame' && eventBus) {
52
+ return new Ctor(objectData, eventBus);
53
+ }
49
54
  return new Ctor(objectData);
50
55
  } catch (e) {
51
56
  console.error(`ObjectFactory: failed to create instance for type "${type}"`, e);
@@ -135,9 +135,15 @@ export class SelectTool extends BaseTool {
135
135
  // Обработка удаления объектов (undo создания, delete команды и т.д.)
136
136
  this.eventBus.on(Events.Object.Deleted, (data) => {
137
137
  const objectId = data?.objectId || data;
138
- console.log('🗑️ SelectTool: получено событие удаления объекта:', objectId);
138
+ console.log('🗑️ SelectTool: получено событие удаления объекта:', objectId, 'данные:', data);
139
139
 
140
- if (objectId && this.selection.has(objectId)) {
140
+ // ЗАЩИТА: Проверяем что данные валидны
141
+ if (!objectId) {
142
+ console.warn('⚠️ SelectTool: получено событие удаления с невалидным objectId');
143
+ return;
144
+ }
145
+
146
+ if (this.selection.has(objectId)) {
141
147
  console.log('🗑️ SelectTool: удаляем объект из selection:', objectId);
142
148
  this.removeFromSelection(objectId);
143
149
 
@@ -147,10 +153,9 @@ export class SelectTool extends BaseTool {
147
153
  this.emit(Events.Tool.SelectionClear);
148
154
  this.updateResizeHandles();
149
155
  }
150
- } else if (objectId) {
151
- console.log('🗑️ SelectTool: объект не был в selection, но проверяем ручки');
152
- // Дополнительная проверка - возможно объект был удален не через selection
153
- // Принудительно обновляем ручки
156
+ } else {
157
+ console.log('🗑️ SelectTool: объект не был в selection, обновляем ручки на всякий случай');
158
+ // Принудительно обновляем ручки без излишних действий
154
159
  this.updateResizeHandles();
155
160
  }
156
161
  });
@@ -1865,28 +1870,31 @@ export class SelectTool extends BaseTool {
1865
1870
 
1866
1871
  // Текст у записки центрирован по обеим осям; textarea тоже центрируем
1867
1872
  const horizontalPadding = 16; // немного больше, чем раньше
1868
- const editorWidth = Math.min(360, noteWidth - (horizontalPadding * 2));
1869
- const editorHeight = Math.min(180, noteHeight - (horizontalPadding * 2));
1870
-
1871
- const textCenterX = noteWidth / 2;
1872
- const textCenterY = noteHeight / 2;
1873
-
1874
- const editorLeft = textCenterX - (editorWidth / 2);
1875
- const editorTop = textCenterY - (editorHeight / 2);
1876
-
1877
- wrapper.style.left = `${screenPos.x + editorLeft}px`;
1878
- wrapper.style.top = `${screenPos.y + editorTop}px`;
1879
-
1880
- // Устанавливаем размеры редактора (центрируем по контенту)
1881
- textarea.style.width = `${editorWidth}px`;
1882
- textarea.style.height = `${editorHeight}px`;
1883
- wrapper.style.width = `${editorWidth}px`;
1884
- wrapper.style.height = `${editorHeight}px`;
1873
+ // Преобразуем мировые размеры/отступы в CSS-пиксели с учётом текущего зума
1874
+ const viewResLocal = (this.app?.renderer?.resolution) || (view.width && view.clientWidth ? (view.width / view.clientWidth) : 1);
1875
+ const worldLayerRefForCss = this.textEditor.world || (this.app?.stage);
1876
+ const sForCss = worldLayerRefForCss?.scale?.x || 1;
1877
+ const sCssLocal = sForCss / viewResLocal;
1878
+ const editorWidthWorld = Math.min(360, Math.max(1, noteWidth - (horizontalPadding * 2)));
1879
+ const editorHeightWorld = Math.min(180, Math.max(1, noteHeight - (horizontalPadding * 2)));
1880
+ const editorWidthPx = Math.max(1, Math.round(editorWidthWorld * sCssLocal));
1881
+ const editorHeightPx = Math.max(1, Math.round(editorHeightWorld * sCssLocal));
1882
+ const textCenterXWorld = noteWidth / 2;
1883
+ const textCenterYWorld = noteHeight / 2;
1884
+ const editorLeftWorld = textCenterXWorld - (editorWidthWorld / 2);
1885
+ const editorTopWorld = textCenterYWorld - (editorHeightWorld / 2);
1886
+ wrapper.style.left = `${Math.round(screenPos.x + editorLeftWorld * sCssLocal)}px`;
1887
+ wrapper.style.top = `${Math.round(screenPos.y + editorTopWorld * sCssLocal)}px`;
1888
+ // Устанавливаем размеры редактора (центрируем по контенту) в CSS-пикселях
1889
+ textarea.style.width = `${editorWidthPx}px`;
1890
+ textarea.style.height = `${editorHeightPx}px`;
1891
+ wrapper.style.width = `${editorWidthPx}px`;
1892
+ wrapper.style.height = `${editorHeightPx}px`;
1885
1893
 
1886
1894
  // Для записок: авто-ресайз редактора под содержимое с сохранением центрирования
1887
1895
  textarea.style.textAlign = 'center';
1888
- const maxEditorWidth = Math.max(1, noteWidth - (horizontalPadding * 2));
1889
- const maxEditorHeight = Math.max(1, noteHeight - (horizontalPadding * 2));
1896
+ const maxEditorWidthPx = Math.max(1, Math.round((noteWidth - (horizontalPadding * 2)) * sCssLocal));
1897
+ const maxEditorHeightPx = Math.max(1, Math.round((noteHeight - (horizontalPadding * 2)) * sCssLocal));
1890
1898
  const MIN_NOTE_EDITOR_W = 20;
1891
1899
  const MIN_NOTE_EDITOR_H = Math.max(1, computeLineHeightPx(effectiveFontPx));
1892
1900
 
@@ -1897,9 +1905,9 @@ export class SelectTool extends BaseTool {
1897
1905
  textarea.style.width = 'auto';
1898
1906
  textarea.style.height = 'auto';
1899
1907
 
1900
- // Ширина по содержимому, но не шире границ записки
1908
+ // Ширина по содержимому, но не шире границ записки (в CSS-пикселях)
1901
1909
  const naturalW = Math.ceil(textarea.scrollWidth + 1);
1902
- const targetW = Math.min(maxEditorWidth, Math.max(MIN_NOTE_EDITOR_W, naturalW));
1910
+ const targetW = Math.min(maxEditorWidthPx, Math.max(MIN_NOTE_EDITOR_W, naturalW));
1903
1911
  textarea.style.width = `${targetW}px`;
1904
1912
  wrapper.style.width = `${targetW}px`;
1905
1913
 
@@ -1907,18 +1915,79 @@ export class SelectTool extends BaseTool {
1907
1915
  const computed = (typeof window !== 'undefined') ? window.getComputedStyle(textarea) : null;
1908
1916
  const lineH = (computed ? parseFloat(computed.lineHeight) : computeLineHeightPx(effectiveFontPx));
1909
1917
  const naturalH = Math.ceil(textarea.scrollHeight);
1910
- const targetH = Math.min(maxEditorHeight, Math.max(MIN_NOTE_EDITOR_H, naturalH));
1918
+ const targetH = Math.min(maxEditorHeightPx, Math.max(MIN_NOTE_EDITOR_H, naturalH));
1911
1919
  textarea.style.height = `${targetH}px`;
1912
1920
  wrapper.style.height = `${targetH}px`;
1913
1921
 
1914
- // Центрируем wrapper внутри записки после смены размеров
1915
- const left = screenPos.x + (noteWidth / 2) - (targetW / 2);
1916
- const top = screenPos.y + (noteHeight / 2) - (targetH / 2);
1922
+ // Центрируем wrapper внутри записки после смены размеров (в CSS-пикселях)
1923
+ const left = Math.round(screenPos.x + (noteWidth * sCssLocal) / 2 - (targetW / 2));
1924
+ const top = Math.round(screenPos.y + (noteHeight * sCssLocal) / 2 - (targetH / 2));
1917
1925
  wrapper.style.left = `${left}px`;
1918
1926
  wrapper.style.top = `${top}px`;
1919
1927
  };
1920
1928
  // Первый вызов — синхронизировать с текущим содержимым
1921
1929
  autoSizeNote();
1930
+
1931
+ // Динамическое обновление позиции/размера редактора при зуме/панорамировании/трансформациях
1932
+ const updateNoteEditor = () => {
1933
+ try {
1934
+ // Актуальная позиция и размер объекта в мире
1935
+ const posDataNow = { objectId, position: null };
1936
+ const sizeDataNow = { objectId, size: null };
1937
+ this.eventBus.emit(Events.Tool.GetObjectPosition, posDataNow);
1938
+ this.eventBus.emit(Events.Tool.GetObjectSize, sizeDataNow);
1939
+ const posNow = posDataNow.position || position;
1940
+ const sizeNow = sizeDataNow.size || { width: noteWidth, height: noteHeight };
1941
+ const screenNow = toScreen(posNow.x, posNow.y);
1942
+ // Пересчитываем масштаб в CSS пикселях
1943
+ const vr = (this.app?.renderer?.resolution) || (view.width && view.clientWidth ? (view.width / view.clientWidth) : 1);
1944
+ const wl = this.textEditor.world || (this.app?.stage);
1945
+ const sc = wl?.scale?.x || 1;
1946
+ const sCss = sc / vr;
1947
+ const maxWpx = Math.max(1, Math.round((sizeNow.width - (horizontalPadding * 2)) * sCss));
1948
+ const maxHpx = Math.max(1, Math.round((sizeNow.height - (horizontalPadding * 2)) * sCss));
1949
+ // Измеряем естественный размер по контенту
1950
+ const prevW = textarea.style.width;
1951
+ const prevH = textarea.style.height;
1952
+ textarea.style.width = 'auto';
1953
+ textarea.style.height = 'auto';
1954
+ const naturalW = Math.ceil(textarea.scrollWidth + 1);
1955
+ const naturalH = Math.ceil(textarea.scrollHeight);
1956
+ const wPx = Math.min(maxWpx, Math.max(MIN_NOTE_EDITOR_W, naturalW));
1957
+ const hPx = Math.min(maxHpx, Math.max(MIN_NOTE_EDITOR_H, naturalH));
1958
+ // Применяем размеры редактора
1959
+ textarea.style.width = `${wPx}px`;
1960
+ wrapper.style.width = `${wPx}px`;
1961
+ textarea.style.height = `${hPx}px`;
1962
+ wrapper.style.height = `${hPx}px`;
1963
+ // Центрируем в пределах записки
1964
+ const left = Math.round(screenNow.x + (sizeNow.width * sCss) / 2 - (wPx / 2));
1965
+ const top = Math.round(screenNow.y + (sizeNow.height * sCss) / 2 - (hPx / 2));
1966
+ wrapper.style.left = `${left}px`;
1967
+ wrapper.style.top = `${top}px`;
1968
+ // Восстанавливаем прошлые значения, чтобы избежать мигания в стилях при следующем измерении
1969
+ textarea.style.width = `${wPx}px`;
1970
+ textarea.style.height = `${hPx}px`;
1971
+ } catch (_) {}
1972
+ };
1973
+ const onZoom = () => updateNoteEditor();
1974
+ const onPan = () => updateNoteEditor();
1975
+ const onDrag = (e) => { if (e && e.object === objectId) updateNoteEditor(); };
1976
+ const onResize = (e) => { if (e && e.object === objectId) updateNoteEditor(); };
1977
+ const onRotate = (e) => { if (e && e.object === objectId) updateNoteEditor(); };
1978
+ this.eventBus.on(Events.UI.ZoomPercent, onZoom);
1979
+ this.eventBus.on(Events.Tool.PanUpdate, onPan);
1980
+ this.eventBus.on(Events.Tool.DragUpdate, onDrag);
1981
+ this.eventBus.on(Events.Tool.ResizeUpdate, onResize);
1982
+ this.eventBus.on(Events.Tool.RotateUpdate, onRotate);
1983
+ // Сохраняем слушателей для снятия при закрытии редактора
1984
+ this.textEditor._listeners = [
1985
+ [Events.UI.ZoomPercent, onZoom],
1986
+ [Events.Tool.PanUpdate, onPan],
1987
+ [Events.Tool.DragUpdate, onDrag],
1988
+ [Events.Tool.ResizeUpdate, onResize],
1989
+ [Events.Tool.RotateUpdate, onRotate],
1990
+ ];
1922
1991
  } else {
1923
1992
  // Для обычного текста используем стандартное позиционирование
1924
1993
  wrapper.style.left = `${screenPos.x}px`;
@@ -2114,6 +2183,14 @@ export class SelectTool extends BaseTool {
2114
2183
  }
2115
2184
 
2116
2185
  // Убираем редактор
2186
+ // Снимем навешанные на время редактирования слушатели
2187
+ try {
2188
+ if (this.textEditor && Array.isArray(this.textEditor._listeners)) {
2189
+ this.textEditor._listeners.forEach(([evt, fn]) => {
2190
+ try { this.eventBus.off(evt, fn); } catch (_) {}
2191
+ });
2192
+ }
2193
+ } catch (_) {}
2117
2194
  wrapper.remove();
2118
2195
  this.textEditor = { active: false, objectId: null, textarea: null, wrapper: null, world: null, position: null, properties: null, objectType: 'text' };
2119
2196
  if (currentObjectType === 'note') {
@@ -2214,51 +2291,8 @@ export class SelectTool extends BaseTool {
2214
2291
  if (!isNote) {
2215
2292
  textarea.addEventListener('input', autoSize);
2216
2293
  } else {
2217
- // Для заметок растягиваем редактор по содержимому и центрируем
2218
- textarea.addEventListener('input', () => {
2219
- try {
2220
- // Найдём локальную функцию, если она объявлена выше (в замыкании)
2221
- // В некоторых движках можно хранить ссылку на функцию в data-атрибуте
2222
- // но здесь просто повторим алгоритм: сброс -> измерение -> ограничение -> центрирование
2223
- const view = this.app?.view || document.querySelector('canvas');
2224
- // Безопасно получим текущие размеры записки
2225
- let noteWidth = 300, noteHeight = 300;
2226
- if (objectId) {
2227
- const sizeData = { objectId, size: null };
2228
- this.eventBus.emit(Events.Tool.GetObjectSize, sizeData);
2229
- if (sizeData.size) { noteWidth = sizeData.size.width; noteHeight = sizeData.size.height; }
2230
- }
2231
- const horizontalPadding = 16;
2232
- const maxEditorWidth = Math.max(1, noteWidth - (horizontalPadding * 2));
2233
- const maxEditorHeight = Math.max(1, noteHeight - (horizontalPadding * 2));
2234
- const MIN_NOTE_EDITOR_W = 20;
2235
- const MIN_NOTE_EDITOR_H = Math.max(1, computeLineHeightPx(effectiveFontPx));
2236
-
2237
- textarea.style.width = 'auto';
2238
- textarea.style.height = 'auto';
2239
- const naturalW = Math.ceil(textarea.scrollWidth + 1);
2240
- const targetW = Math.min(maxEditorWidth, Math.max(MIN_NOTE_EDITOR_W, naturalW));
2241
- textarea.style.width = `${targetW}px`;
2242
- const computed = (typeof window !== 'undefined') ? window.getComputedStyle(textarea) : null;
2243
- const lineH = (computed ? parseFloat(computed.lineHeight) : computeLineHeightPx(effectiveFontPx));
2244
- const naturalH = Math.ceil(textarea.scrollHeight);
2245
- const targetH = Math.min(maxEditorHeight, Math.max(MIN_NOTE_EDITOR_H, naturalH));
2246
- textarea.style.height = `${targetH}px`;
2247
- wrapper.style.width = `${targetW}px`;
2248
- wrapper.style.height = `${targetH}px`;
2249
-
2250
- const toScreen = (wx, wy) => {
2251
- const worldLayer = this.textEditor.world || (this.app?.stage);
2252
- if (!worldLayer) return { x: wx, y: wy };
2253
- const global = worldLayer.toGlobal(new PIXI.Point(wx, wy));
2254
- const viewRes = (this.app?.renderer?.resolution) || (view && view.width && view.clientWidth ? (view.width / view.clientWidth) : 1);
2255
- return { x: global.x / viewRes, y: global.y / viewRes };
2256
- };
2257
- const screenPos = toScreen(position.x, position.y);
2258
- wrapper.style.left = `${screenPos.x + (noteWidth / 2) - (targetW / 2)}px`;
2259
- wrapper.style.top = `${screenPos.y + (noteHeight / 2) - (targetH / 2)}px`;
2260
- } catch (_) {}
2261
- });
2294
+ // Для заметок растягиваем редактор по содержимому и центрируем с учётом зума
2295
+ textarea.addEventListener('input', () => { try { updateNoteEditor(); } catch (_) {} });
2262
2296
  }
2263
2297
  }
2264
2298
 
@@ -132,7 +132,8 @@ export class CommentPopover {
132
132
 
133
133
  _onDocMouseDown(e) {
134
134
  if (!this.popover || this.popover.style.display === 'none') return;
135
- if (this.popover.contains(e.target)) return; // клик внутри окна не закрываем
135
+ // ИСПРАВЛЕНИЕ: Защита от null элементов
136
+ if (this.popover && e.target && this.popover.contains(e.target)) return; // клик внутри окна — не закрываем
136
137
  this.hide();
137
138
  }
138
139
 
@@ -51,7 +51,8 @@ export class ContextMenu {
51
51
  // Скрывать при клике вне меню или по Esc
52
52
  document.addEventListener('mousedown', (e) => {
53
53
  if (!this.isVisible) return;
54
- if (!this.element.contains(e.target)) {
54
+ // ИСПРАВЛЕНИЕ: Защита от null элементов
55
+ if (this.element && e.target && !this.element.contains(e.target)) {
55
56
  this.hide();
56
57
  }
57
58
  });
@@ -268,28 +268,52 @@ export class FilePropertiesPanel {
268
268
  reposition() {
269
269
  if (!this.currentId || !this.panel || this.panel.style.display === 'none') return;
270
270
 
271
- const pixiObject = this.core?.pixi?.objects?.get(this.currentId);
272
- if (!pixiObject) return;
271
+ // Проверяем, что наш объект все еще выделен
272
+ const ids = this.core?.selectTool ? Array.from(this.core.selectTool.selectedObjects || []) : [];
273
+ if (!ids.includes(this.currentId)) {
274
+ this.hide();
275
+ return;
276
+ }
273
277
 
274
- try {
275
- // Получаем границы объекта в world координатах
276
- const bounds = pixiObject.getBounds();
277
-
278
- // Преобразуем в screen координаты
279
- const worldToScreen = this.core.pixi.app.stage.worldTransform;
280
- const screenX = bounds.x * worldToScreen.a + worldToScreen.tx;
281
- const screenY = bounds.y * worldToScreen.d + worldToScreen.ty;
282
-
283
- // Позиционируем панель сверху по центру объекта
284
- const panelWidth = this.panel.offsetWidth || 120;
285
- const centerX = screenX + (bounds.width * worldToScreen.a) / 2;
286
-
287
- this.panel.style.left = `${centerX - panelWidth / 2}px`;
288
- this.panel.style.top = `${screenY - 65}px`; // 65px выше объекта (было 45px)
289
-
290
- } catch (error) {
291
- console.warn('FilePropertiesPanel: ошибка позиционирования:', error);
278
+ // Получаем позицию и размеры объекта
279
+ const posData = { objectId: this.currentId, position: null };
280
+ const sizeData = { objectId: this.currentId, size: null };
281
+ this.eventBus.emit(Events.Tool.GetObjectPosition, posData);
282
+ this.eventBus.emit(Events.Tool.GetObjectSize, sizeData);
283
+
284
+ if (!posData.position || !sizeData.size) {
285
+ return;
292
286
  }
287
+
288
+ // Получаем зум и позицию мира
289
+ const worldLayer = this.core?.pixi?.worldLayer;
290
+ const scale = worldLayer?.scale?.x || 1;
291
+ const worldX = worldLayer?.x || 0;
292
+ const worldY = worldLayer?.y || 0;
293
+
294
+ // Преобразуем координаты объекта в экранные координаты
295
+ const screenX = posData.position.x * scale + worldX;
296
+ const screenY = posData.position.y * scale + worldY;
297
+ const objectWidth = sizeData.size.width * scale;
298
+
299
+ // Позиционируем панель над объектом по центру
300
+ const panelWidth = this.panel.offsetWidth || 120;
301
+ const panelHeight = this.panel.offsetHeight || 40;
302
+ const panelX = screenX + (objectWidth / 2) - (panelWidth / 2);
303
+ let panelY = screenY - panelHeight - 40; // отступ 40px над файлом
304
+
305
+ // Если панель уходит за верх, переносим ниже объекта
306
+ if (panelY < 0) {
307
+ panelY = screenY + (sizeData.size.height * scale) + 40;
308
+ }
309
+
310
+ // Проверяем границы контейнера
311
+ const containerRect = this.container.getBoundingClientRect();
312
+ const finalX = Math.max(10, Math.min(panelX, containerRect.width - panelWidth - 10));
313
+ const finalY = Math.max(10, panelY);
314
+
315
+ this.panel.style.left = `${Math.round(finalX)}px`;
316
+ this.panel.style.top = `${Math.round(finalY)}px`;
293
317
  }
294
318
 
295
319
  destroy() {
@@ -147,25 +147,36 @@ export class FramePropertiesPanel {
147
147
  return;
148
148
  }
149
149
 
150
- const { x, y } = posData.position;
151
- const { width, height } = sizeData.size;
150
+ // Получаем зум и позицию мира
151
+ const worldLayer = this.core?.pixi?.worldLayer;
152
+ const scale = worldLayer?.scale?.x || 1;
153
+ const worldX = worldLayer?.x || 0;
154
+ const worldY = worldLayer?.y || 0;
155
+
156
+ // Преобразуем координаты объекта в экранные координаты
157
+ const screenX = posData.position.x * scale + worldX;
158
+ const screenY = posData.position.y * scale + worldY;
159
+ const objectWidth = sizeData.size.width * scale;
160
+ const objectHeight = sizeData.size.height * scale;
152
161
 
153
162
  // Позиционируем панель над фреймом, по центру
154
- const panelRect = this.panel.getBoundingClientRect();
155
- const panelW = Math.max(1, panelRect.width || 280);
156
- const panelH = Math.max(1, panelRect.height || 60);
157
- let panelX = x + (width / 2) - (panelW / 2);
158
- let panelY = y - panelH - 40; // отступ 40px над фреймом
163
+ const panelW = this.panel.offsetWidth || 280;
164
+ const panelH = this.panel.offsetHeight || 60;
165
+ let panelX = screenX + (objectWidth / 2) - (panelW / 2);
166
+ let panelY = screenY - panelH - 40; // отступ 40px над фреймом
159
167
 
160
168
  // Если панель уходит за верх, переносим ниже фрейма
161
169
  if (panelY < 0) {
162
- panelY = y + height + 40;
170
+ panelY = screenY + objectHeight + 40;
163
171
  }
164
172
 
173
+ // Проверяем границы контейнера
174
+ const containerRect = this.container.getBoundingClientRect();
175
+ const finalX = Math.max(10, Math.min(panelX, containerRect.width - panelW - 10));
176
+ const finalY = Math.max(10, panelY);
165
177
 
166
- this.panel.style.left = `${Math.round(panelX)}px`;
167
- this.panel.style.top = `${Math.round(panelY)}px`;
168
-
178
+ this.panel.style.left = `${Math.round(finalX)}px`;
179
+ this.panel.style.top = `${Math.round(finalY)}px`;
169
180
  }
170
181
 
171
182
  _createFrameControls(panel) {
@@ -435,7 +446,8 @@ export class FramePropertiesPanel {
435
446
  }
436
447
 
437
448
  _documentClickHandler(e) {
438
- if (this.colorPalette && !this.colorPalette.contains(e.target) &&
449
+ // ИСПРАВЛЕНИЕ: Защита от null элементов
450
+ if (this.colorPalette && e.target && !this.colorPalette.contains(e.target) &&
439
451
  this.colorButton && !this.colorButton.contains(e.target)) {
440
452
  this._hideColorPalette();
441
453
  }
@@ -37,8 +37,9 @@ export class HtmlHandlesLayer {
37
37
  this.eventBus.on(Events.Tool.DragUpdate, () => this.update());
38
38
 
39
39
  // ИСПРАВЛЕНИЕ: Обработка удаления объектов
40
- this.eventBus.on(Events.Object.Deleted, ({ objectId }) => {
41
- console.log('🗑️ HtmlHandlesLayer: объект удален, принудительно очищаем ручки:', objectId);
40
+ this.eventBus.on(Events.Object.Deleted, (data) => {
41
+ const objectId = data?.objectId || data;
42
+ console.log('🗑️ HtmlHandlesLayer: получено событие удаления:', data, 'objectId:', objectId);
42
43
 
43
44
  // Принудительно скрываем и очищаем все ручки
44
45
  this.hide();
@@ -38,6 +38,8 @@ export class MapPanel {
38
38
  // Закрытие по клику вне панели
39
39
  document.addEventListener('mousedown', (e) => {
40
40
  if (!this.popupEl) return;
41
+ // ИСПРАВЛЕНИЕ: Защита от null элементов
42
+ if (!this.element || !e.target) return;
41
43
  if (this.element.contains(e.target)) return;
42
44
  this.hidePopup();
43
45
  });
@@ -45,7 +47,8 @@ export class MapPanel {
45
47
  // Колесо для зума внутри миникарты
46
48
  this.element.addEventListener('wheel', (e) => {
47
49
  if (!this.popupEl) return;
48
- if (!this.popupEl.contains(e.target)) return;
50
+ // ИСПРАВЛЕНИЕ: Защита от null элементов
51
+ if (!this.popupEl || !e.target || !this.popupEl.contains(e.target)) return;
49
52
  e.preventDefault();
50
53
  // Масштабируем вокруг точки под курсором в миникарте
51
54
  const rect = this.canvas.getBoundingClientRect();
@@ -157,23 +157,36 @@ export class NotePropertiesPanel {
157
157
  return;
158
158
  }
159
159
 
160
- const { x, y } = posData.position;
161
- const { width, height } = sizeData.size;
160
+ // Получаем зум и позицию мира
161
+ const worldLayer = this.core?.pixi?.worldLayer;
162
+ const scale = worldLayer?.scale?.x || 1;
163
+ const worldX = worldLayer?.x || 0;
164
+ const worldY = worldLayer?.y || 0;
165
+
166
+ // Преобразуем координаты объекта в экранные координаты
167
+ const screenX = posData.position.x * scale + worldX;
168
+ const screenY = posData.position.y * scale + worldY;
169
+ const objectWidth = sizeData.size.width * scale;
170
+ const objectHeight = sizeData.size.height * scale;
162
171
 
163
172
  // Позиционируем панель над запиской, по центру
164
- const panelRect = this.panel.getBoundingClientRect();
165
- const panelW = Math.max(1, panelRect.width || 320);
166
- const panelH = Math.max(1, panelRect.height || 40);
167
- const panelX = x + (width / 2) - (panelW / 2);
168
- const panelY = Math.max(0, y - panelH - 40); // отступ 40px над запиской
169
-
170
- console.log('📝 NotePropertiesPanel: Positioning next to note:', {
171
- noteX: x, noteY: y, noteWidth: width, noteHeight: height,
172
- panelX, panelY
173
- });
173
+ const panelW = this.panel.offsetWidth || 320;
174
+ const panelH = this.panel.offsetHeight || 40;
175
+ let panelX = screenX + (objectWidth / 2) - (panelW / 2);
176
+ let panelY = screenY - panelH - 40; // отступ 40px над запиской
177
+
178
+ // Если панель уходит за верх, переносим ниже записки
179
+ if (panelY < 0) {
180
+ panelY = screenY + objectHeight + 40;
181
+ }
182
+
183
+ // Проверяем границы контейнера
184
+ const containerRect = this.container.getBoundingClientRect();
185
+ const finalX = Math.max(10, Math.min(panelX, containerRect.width - panelW - 10));
186
+ const finalY = Math.max(10, panelY);
174
187
 
175
- this.panel.style.left = `${Math.round(panelX)}px`;
176
- this.panel.style.top = `${Math.round(panelY)}px`;
188
+ this.panel.style.left = `${Math.round(finalX)}px`;
189
+ this.panel.style.top = `${Math.round(finalY)}px`;
177
190
  }
178
191
 
179
192
  _createNoteControls(panel) {
@@ -448,14 +461,16 @@ export class NotePropertiesPanel {
448
461
  let shouldClose = true;
449
462
 
450
463
  for (let palette of palettes) {
451
- if (palette && palette.contains(e.target)) {
464
+ // ИСПРАВЛЕНИЕ: Защита от null элементов
465
+ if (palette && e.target && palette.contains(e.target)) {
452
466
  shouldClose = false;
453
467
  break;
454
468
  }
455
469
  }
456
470
 
457
471
  for (let button of buttons) {
458
- if (button && button.contains(e.target)) {
472
+ // ИСПРАВЛЕНИЕ: Защита от null элементов
473
+ if (button && e.target && button.contains(e.target)) {
459
474
  shouldClose = false;
460
475
  break;
461
476
  }
@@ -259,7 +259,8 @@ export class TextPropertiesPanel {
259
259
 
260
260
  // Закрываем панель при клике вне её
261
261
  document.addEventListener('click', (e) => {
262
- if (!colorSelectorContainer.contains(e.target)) {
262
+ // ИСПРАВЛЕНИЕ: Защита от null элементов
263
+ if (!colorSelectorContainer || !e.target || !colorSelectorContainer.contains(e.target)) {
263
264
  this._hideColorDropdown();
264
265
  }
265
266
  });
@@ -460,7 +461,8 @@ export class TextPropertiesPanel {
460
461
 
461
462
  // Закрываем панель при клике вне её
462
463
  document.addEventListener('click', (e) => {
463
- if (!bgSelectorContainer.contains(e.target)) {
464
+ // ИСПРАВЛЕНИЕ: Защита от null элементов
465
+ if (!bgSelectorContainer || !e.target || !bgSelectorContainer.contains(e.target)) {
464
466
  this._hideBgColorDropdown();
465
467
  }
466
468
  });
@@ -871,7 +873,7 @@ export class TextPropertiesPanel {
871
873
  }
872
874
 
873
875
  _onDocMouseDown(e) {
874
- // Скрываем панель при клике вне неё и вне текстового объекта
876
+ // ИСПРАВЛЕНИЕ: Защита от null элементов + скрываем панель при клике вне неё
875
877
  if (!this.panel || !e.target) return;
876
878
 
877
879
  // Если клик внутри панели - не скрываем
package/src/ui/Toolbar.js CHANGED
@@ -555,7 +555,10 @@ export class Toolbar {
555
555
 
556
556
  // Клик вне попапов — закрыть
557
557
  document.addEventListener('click', (e) => {
558
- const isInsideToolbar = this.element.contains(e.target);
558
+ // ИСПРАВЛЕНИЕ: Защита от null элементов
559
+ if (!e.target) return;
560
+
561
+ const isInsideToolbar = this.element && this.element.contains(e.target);
559
562
  const isInsideShapesPopup = this.shapesPopupEl && this.shapesPopupEl.contains(e.target);
560
563
  const isInsideDrawPopup = this.drawPopupEl && this.drawPopupEl.contains(e.target);
561
564
  const isInsideEmojiPopup = this.emojiPopupEl && this.emojiPopupEl.contains(e.target);
@@ -74,6 +74,8 @@ export class ZoomPanel {
74
74
 
75
75
  document.addEventListener('mousedown', (e) => {
76
76
  if (!this.menuEl) return;
77
+ // ИСПРАВЛЕНИЕ: Защита от null элементов
78
+ if (!this.element || !e.target) return;
77
79
  if (this.element.contains(e.target)) return;
78
80
  this.hideMenu();
79
81
  });
@@ -43,7 +43,7 @@
43
43
  }
44
44
  .moodboard-toolbar__button:hover { background: #BBDEFB; border-radius: 50%; }
45
45
  .moodboard-toolbar__button:active { transform: translateY(0); }
46
- .moodboard-toolbar__button--active { background: #00B0FF !important; color: inherit !important; border-radius: 50%; }
46
+ .moodboard-toolbar__button--active { background: #80D8FF !important; color: inherit !important; border-radius: 50%; }
47
47
  .moodboard-toolbar__button--disabled { opacity: 0.4; cursor: not-allowed; }
48
48
  .moodboard-toolbar__button svg { width: 20px; height: 20px; transition: transform 0.2s ease; color: #212121; }
49
49
  .moodboard-toolbar__button--text-add svg { height: 16px; width: auto; }
@@ -47,7 +47,7 @@
47
47
  }
48
48
 
49
49
  .moodboard-topbar__button--active {
50
- background: #dbeafe;
50
+ background: #80D8FF;
51
51
  color: #1d4ed8;
52
52
  }
53
53