@sequent-org/moodboard 1.2.16 → 1.2.18

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.16",
3
+ "version": "1.2.18",
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);
package/src/core/index.js CHANGED
@@ -2062,6 +2062,15 @@ export class CoreMoodBoard {
2062
2062
  * Создание объекта из полных данных (для загрузки с сервера)
2063
2063
  */
2064
2064
  createObjectFromData(objectData) {
2065
+ // Инициализируем флаг компенсации пивота для загруженных объектов,
2066
+ // чтобы PIXI не применял компенсацию повторно
2067
+ if (!objectData.transform) {
2068
+ objectData.transform = {};
2069
+ }
2070
+ if (objectData.transform.pivotCompensated === undefined) {
2071
+ objectData.transform.pivotCompensated = true;
2072
+ }
2073
+
2065
2074
  // Используем существующие данные объекта (с его ID, размерами и т.д.)
2066
2075
  this.state.addObject(objectData);
2067
2076
  this.pixi.createObject(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);
@@ -1870,28 +1870,31 @@ export class SelectTool extends BaseTool {
1870
1870
 
1871
1871
  // Текст у записки центрирован по обеим осям; textarea тоже центрируем
1872
1872
  const horizontalPadding = 16; // немного больше, чем раньше
1873
- const editorWidth = Math.min(360, noteWidth - (horizontalPadding * 2));
1874
- const editorHeight = Math.min(180, noteHeight - (horizontalPadding * 2));
1875
-
1876
- const textCenterX = noteWidth / 2;
1877
- const textCenterY = noteHeight / 2;
1878
-
1879
- const editorLeft = textCenterX - (editorWidth / 2);
1880
- const editorTop = textCenterY - (editorHeight / 2);
1881
-
1882
- wrapper.style.left = `${screenPos.x + editorLeft}px`;
1883
- wrapper.style.top = `${screenPos.y + editorTop}px`;
1884
-
1885
- // Устанавливаем размеры редактора (центрируем по контенту)
1886
- textarea.style.width = `${editorWidth}px`;
1887
- textarea.style.height = `${editorHeight}px`;
1888
- wrapper.style.width = `${editorWidth}px`;
1889
- 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`;
1890
1893
 
1891
1894
  // Для записок: авто-ресайз редактора под содержимое с сохранением центрирования
1892
1895
  textarea.style.textAlign = 'center';
1893
- const maxEditorWidth = Math.max(1, noteWidth - (horizontalPadding * 2));
1894
- 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));
1895
1898
  const MIN_NOTE_EDITOR_W = 20;
1896
1899
  const MIN_NOTE_EDITOR_H = Math.max(1, computeLineHeightPx(effectiveFontPx));
1897
1900
 
@@ -1902,9 +1905,9 @@ export class SelectTool extends BaseTool {
1902
1905
  textarea.style.width = 'auto';
1903
1906
  textarea.style.height = 'auto';
1904
1907
 
1905
- // Ширина по содержимому, но не шире границ записки
1908
+ // Ширина по содержимому, но не шире границ записки (в CSS-пикселях)
1906
1909
  const naturalW = Math.ceil(textarea.scrollWidth + 1);
1907
- 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));
1908
1911
  textarea.style.width = `${targetW}px`;
1909
1912
  wrapper.style.width = `${targetW}px`;
1910
1913
 
@@ -1912,18 +1915,79 @@ export class SelectTool extends BaseTool {
1912
1915
  const computed = (typeof window !== 'undefined') ? window.getComputedStyle(textarea) : null;
1913
1916
  const lineH = (computed ? parseFloat(computed.lineHeight) : computeLineHeightPx(effectiveFontPx));
1914
1917
  const naturalH = Math.ceil(textarea.scrollHeight);
1915
- 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));
1916
1919
  textarea.style.height = `${targetH}px`;
1917
1920
  wrapper.style.height = `${targetH}px`;
1918
1921
 
1919
- // Центрируем wrapper внутри записки после смены размеров
1920
- const left = screenPos.x + (noteWidth / 2) - (targetW / 2);
1921
- 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));
1922
1925
  wrapper.style.left = `${left}px`;
1923
1926
  wrapper.style.top = `${top}px`;
1924
1927
  };
1925
1928
  // Первый вызов — синхронизировать с текущим содержимым
1926
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
+ ];
1927
1991
  } else {
1928
1992
  // Для обычного текста используем стандартное позиционирование
1929
1993
  wrapper.style.left = `${screenPos.x}px`;
@@ -2119,6 +2183,14 @@ export class SelectTool extends BaseTool {
2119
2183
  }
2120
2184
 
2121
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 (_) {}
2122
2194
  wrapper.remove();
2123
2195
  this.textEditor = { active: false, objectId: null, textarea: null, wrapper: null, world: null, position: null, properties: null, objectType: 'text' };
2124
2196
  if (currentObjectType === 'note') {
@@ -2219,51 +2291,8 @@ export class SelectTool extends BaseTool {
2219
2291
  if (!isNote) {
2220
2292
  textarea.addEventListener('input', autoSize);
2221
2293
  } else {
2222
- // Для заметок растягиваем редактор по содержимому и центрируем
2223
- textarea.addEventListener('input', () => {
2224
- try {
2225
- // Найдём локальную функцию, если она объявлена выше (в замыкании)
2226
- // В некоторых движках можно хранить ссылку на функцию в data-атрибуте
2227
- // но здесь просто повторим алгоритм: сброс -> измерение -> ограничение -> центрирование
2228
- const view = this.app?.view || document.querySelector('canvas');
2229
- // Безопасно получим текущие размеры записки
2230
- let noteWidth = 300, noteHeight = 300;
2231
- if (objectId) {
2232
- const sizeData = { objectId, size: null };
2233
- this.eventBus.emit(Events.Tool.GetObjectSize, sizeData);
2234
- if (sizeData.size) { noteWidth = sizeData.size.width; noteHeight = sizeData.size.height; }
2235
- }
2236
- const horizontalPadding = 16;
2237
- const maxEditorWidth = Math.max(1, noteWidth - (horizontalPadding * 2));
2238
- const maxEditorHeight = Math.max(1, noteHeight - (horizontalPadding * 2));
2239
- const MIN_NOTE_EDITOR_W = 20;
2240
- const MIN_NOTE_EDITOR_H = Math.max(1, computeLineHeightPx(effectiveFontPx));
2241
-
2242
- textarea.style.width = 'auto';
2243
- textarea.style.height = 'auto';
2244
- const naturalW = Math.ceil(textarea.scrollWidth + 1);
2245
- const targetW = Math.min(maxEditorWidth, Math.max(MIN_NOTE_EDITOR_W, naturalW));
2246
- textarea.style.width = `${targetW}px`;
2247
- const computed = (typeof window !== 'undefined') ? window.getComputedStyle(textarea) : null;
2248
- const lineH = (computed ? parseFloat(computed.lineHeight) : computeLineHeightPx(effectiveFontPx));
2249
- const naturalH = Math.ceil(textarea.scrollHeight);
2250
- const targetH = Math.min(maxEditorHeight, Math.max(MIN_NOTE_EDITOR_H, naturalH));
2251
- textarea.style.height = `${targetH}px`;
2252
- wrapper.style.width = `${targetW}px`;
2253
- wrapper.style.height = `${targetH}px`;
2254
-
2255
- const toScreen = (wx, wy) => {
2256
- const worldLayer = this.textEditor.world || (this.app?.stage);
2257
- if (!worldLayer) return { x: wx, y: wy };
2258
- const global = worldLayer.toGlobal(new PIXI.Point(wx, wy));
2259
- const viewRes = (this.app?.renderer?.resolution) || (view && view.width && view.clientWidth ? (view.width / view.clientWidth) : 1);
2260
- return { x: global.x / viewRes, y: global.y / viewRes };
2261
- };
2262
- const screenPos = toScreen(position.x, position.y);
2263
- wrapper.style.left = `${screenPos.x + (noteWidth / 2) - (targetW / 2)}px`;
2264
- wrapper.style.top = `${screenPos.y + (noteHeight / 2) - (targetH / 2)}px`;
2265
- } catch (_) {}
2266
- });
2294
+ // Для заметок растягиваем редактор по содержимому и центрируем с учётом зума
2295
+ textarea.addEventListener('input', () => { try { updateNoteEditor(); } catch (_) {} });
2267
2296
  }
2268
2297
  }
2269
2298
 
@@ -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) {
@@ -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) {
@@ -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