@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 +1 -1
- package/src/core/PixiEngine.js +32 -2
- package/src/core/rendering/ObjectRenderer.js +3 -2
- package/src/objects/FrameObject.js +141 -5
- package/src/objects/NoteObject.js +23 -0
- package/src/objects/ObjectFactory.js +6 -1
- package/src/tools/object-tools/SelectTool.js +110 -76
- package/src/ui/CommentPopover.js +2 -1
- package/src/ui/ContextMenu.js +2 -1
- package/src/ui/FilePropertiesPanel.js +44 -20
- package/src/ui/FramePropertiesPanel.js +24 -12
- package/src/ui/HtmlHandlesLayer.js +3 -2
- package/src/ui/MapPanel.js +4 -1
- package/src/ui/NotePropertiesPanel.js +31 -16
- package/src/ui/TextPropertiesPanel.js +5 -3
- package/src/ui/Toolbar.js +4 -1
- package/src/ui/ZoomPanel.js +2 -0
- package/src/ui/styles/toolbar.css +1 -1
- package/src/ui/styles/topbar.css +1 -1
package/package.json
CHANGED
package/src/core/PixiEngine.js
CHANGED
|
@@ -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:
|
|
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
|
-
|
|
83
|
-
|
|
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
|
-
|
|
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
|
|
151
|
-
console.log('🗑️ SelectTool: объект не был в selection,
|
|
152
|
-
//
|
|
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
|
-
|
|
1869
|
-
const
|
|
1870
|
-
|
|
1871
|
-
const
|
|
1872
|
-
const
|
|
1873
|
-
|
|
1874
|
-
const
|
|
1875
|
-
const
|
|
1876
|
-
|
|
1877
|
-
|
|
1878
|
-
|
|
1879
|
-
|
|
1880
|
-
|
|
1881
|
-
|
|
1882
|
-
|
|
1883
|
-
|
|
1884
|
-
|
|
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
|
|
1889
|
-
const
|
|
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(
|
|
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(
|
|
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
|
|
1916
|
-
const top = screenPos.y + (noteHeight / 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
|
|
package/src/ui/CommentPopover.js
CHANGED
|
@@ -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
|
-
|
|
135
|
+
// ИСПРАВЛЕНИЕ: Защита от null элементов
|
|
136
|
+
if (this.popover && e.target && this.popover.contains(e.target)) return; // клик внутри окна — не закрываем
|
|
136
137
|
this.hide();
|
|
137
138
|
}
|
|
138
139
|
|
package/src/ui/ContextMenu.js
CHANGED
|
@@ -51,7 +51,8 @@ export class ContextMenu {
|
|
|
51
51
|
// Скрывать при клике вне меню или по Esc
|
|
52
52
|
document.addEventListener('mousedown', (e) => {
|
|
53
53
|
if (!this.isVisible) return;
|
|
54
|
-
|
|
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
|
-
|
|
272
|
-
|
|
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
|
-
|
|
275
|
-
|
|
276
|
-
|
|
277
|
-
|
|
278
|
-
|
|
279
|
-
|
|
280
|
-
|
|
281
|
-
|
|
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
|
-
|
|
151
|
-
const
|
|
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
|
|
155
|
-
const
|
|
156
|
-
|
|
157
|
-
let
|
|
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 =
|
|
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(
|
|
167
|
-
this.panel.style.top = `${Math.round(
|
|
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
|
-
|
|
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, (
|
|
41
|
-
|
|
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();
|
package/src/ui/MapPanel.js
CHANGED
|
@@ -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
|
-
|
|
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
|
-
|
|
161
|
-
const
|
|
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
|
|
165
|
-
const
|
|
166
|
-
|
|
167
|
-
|
|
168
|
-
|
|
169
|
-
|
|
170
|
-
|
|
171
|
-
|
|
172
|
-
|
|
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(
|
|
176
|
-
this.panel.style.top = `${Math.round(
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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);
|
package/src/ui/ZoomPanel.js
CHANGED
|
@@ -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: #
|
|
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; }
|