@sequent-org/moodboard 1.2.16 → 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 +99 -70
- package/src/ui/FilePropertiesPanel.js +44 -20
- package/src/ui/FramePropertiesPanel.js +22 -11
- package/src/ui/NotePropertiesPanel.js +27 -14
- 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);
|
|
@@ -1870,28 +1870,31 @@ export class SelectTool extends BaseTool {
|
|
|
1870
1870
|
|
|
1871
1871
|
// Текст у записки центрирован по обеим осям; textarea тоже центрируем
|
|
1872
1872
|
const horizontalPadding = 16; // немного больше, чем раньше
|
|
1873
|
-
|
|
1874
|
-
const
|
|
1875
|
-
|
|
1876
|
-
const
|
|
1877
|
-
const
|
|
1878
|
-
|
|
1879
|
-
const
|
|
1880
|
-
const
|
|
1881
|
-
|
|
1882
|
-
|
|
1883
|
-
|
|
1884
|
-
|
|
1885
|
-
|
|
1886
|
-
|
|
1887
|
-
|
|
1888
|
-
|
|
1889
|
-
|
|
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
|
|
1894
|
-
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));
|
|
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(
|
|
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(
|
|
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
|
|
1921
|
-
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));
|
|
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
|
-
|
|
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) {
|
|
@@ -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) {
|
|
@@ -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; }
|