@sequent-org/moodboard 1.0.24 → 1.1.0
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/assets/icons/rotate-icon.svg +3 -0
- package/src/core/PixiEngine.js +32 -0
- package/src/core/commands/CopyObjectCommand.js +20 -9
- package/src/core/commands/PasteObjectCommand.js +26 -15
- package/src/core/index.js +522 -26
- package/src/objects/DrawingObject.js +16 -7
- package/src/objects/FileObject.js +25 -11
- package/src/objects/FrameObject.js +37 -9
- package/src/objects/NoteObject.js +32 -17
- package/src/objects/ShapeObject.js +9 -8
- package/src/objects/TextObject.js +2 -20
- package/src/services/FrameService.js +95 -17
- package/src/tools/object-tools/PlacementTool.js +192 -51
- package/src/tools/object-tools/SelectTool.js +215 -44
- package/src/tools/object-tools/selection/BoxSelectController.js +5 -0
- package/src/ui/FilePropertiesPanel.js +9 -2
- package/src/ui/FramePropertiesPanel.js +177 -34
- package/src/ui/HtmlHandlesLayer.js +145 -89
- package/src/ui/HtmlTextLayer.js +9 -1
- package/src/ui/NotePropertiesPanel.js +13 -6
- package/src/ui/Toolbar.js +118 -15
- package/src/ui/styles/workspace.css +71 -2
|
@@ -1,4 +1,5 @@
|
|
|
1
1
|
import { Events } from '../core/events/Events.js';
|
|
2
|
+
import rotateIconSvg from '../assets/icons/rotate-icon.svg?raw';
|
|
2
3
|
|
|
3
4
|
/**
|
|
4
5
|
* HtmlHandlesLayer — HTML-ручки и рамка для выделенных объектов.
|
|
@@ -20,6 +21,7 @@ export class HtmlHandlesLayer {
|
|
|
20
21
|
this.target = { type: 'none', id: null, bounds: null };
|
|
21
22
|
this.handles = {};
|
|
22
23
|
this._drag = null;
|
|
24
|
+
this._handlesSuppressed = false; // скрывать ручки во время перетаскивания/трансформаций
|
|
23
25
|
}
|
|
24
26
|
|
|
25
27
|
attach() {
|
|
@@ -35,11 +37,23 @@ export class HtmlHandlesLayer {
|
|
|
35
37
|
this.eventBus.on(Events.Tool.SelectionRemove, () => this.update());
|
|
36
38
|
this.eventBus.on(Events.Tool.SelectionClear, () => this.hide());
|
|
37
39
|
this.eventBus.on(Events.Tool.DragUpdate, () => this.update());
|
|
40
|
+
this.eventBus.on(Events.Tool.DragStart, () => { this._handlesSuppressed = true; this._setHandlesVisibility(false); });
|
|
41
|
+
this.eventBus.on(Events.Tool.DragEnd, () => { this._handlesSuppressed = false; this._setHandlesVisibility(true); });
|
|
38
42
|
this.eventBus.on(Events.Tool.ResizeUpdate, () => this.update());
|
|
43
|
+
this.eventBus.on(Events.Tool.ResizeStart, () => { this._handlesSuppressed = true; this._setHandlesVisibility(false); });
|
|
44
|
+
this.eventBus.on(Events.Tool.ResizeEnd, () => { this._handlesSuppressed = false; this._setHandlesVisibility(true); });
|
|
39
45
|
this.eventBus.on(Events.Tool.RotateUpdate, () => this.update());
|
|
46
|
+
this.eventBus.on(Events.Tool.RotateStart, () => { this._handlesSuppressed = true; this._setHandlesVisibility(false); });
|
|
47
|
+
this.eventBus.on(Events.Tool.RotateEnd, () => { this._handlesSuppressed = false; this._setHandlesVisibility(true); });
|
|
40
48
|
this.eventBus.on(Events.Tool.GroupDragUpdate, () => this.update());
|
|
49
|
+
this.eventBus.on(Events.Tool.GroupDragStart, () => { this._handlesSuppressed = true; this._setHandlesVisibility(false); });
|
|
50
|
+
this.eventBus.on(Events.Tool.GroupDragEnd, () => { this._handlesSuppressed = false; this._setHandlesVisibility(true); });
|
|
41
51
|
this.eventBus.on(Events.Tool.GroupResizeUpdate, () => this.update());
|
|
52
|
+
this.eventBus.on(Events.Tool.GroupResizeStart, () => { this._handlesSuppressed = true; this._setHandlesVisibility(false); });
|
|
53
|
+
this.eventBus.on(Events.Tool.GroupResizeEnd, () => { this._handlesSuppressed = false; this._setHandlesVisibility(true); });
|
|
42
54
|
this.eventBus.on(Events.Tool.GroupRotateUpdate, () => this.update());
|
|
55
|
+
this.eventBus.on(Events.Tool.GroupRotateStart, () => { this._handlesSuppressed = true; this._setHandlesVisibility(false); });
|
|
56
|
+
this.eventBus.on(Events.Tool.GroupRotateEnd, () => { this._handlesSuppressed = false; this._setHandlesVisibility(true); });
|
|
43
57
|
this.eventBus.on(Events.UI.ZoomPercent, () => this.update());
|
|
44
58
|
this.eventBus.on(Events.Tool.PanUpdate, () => this.update());
|
|
45
59
|
|
|
@@ -106,6 +120,27 @@ export class HtmlHandlesLayer {
|
|
|
106
120
|
this.visible = false;
|
|
107
121
|
}
|
|
108
122
|
|
|
123
|
+
_setHandlesVisibility(show) {
|
|
124
|
+
if (!this.layer) return;
|
|
125
|
+
const box = this.layer.querySelector('.mb-handles-box');
|
|
126
|
+
if (!box) return;
|
|
127
|
+
// Уголки
|
|
128
|
+
box.querySelectorAll('[data-dir]').forEach(el => {
|
|
129
|
+
el.style.display = show ? '' : 'none';
|
|
130
|
+
});
|
|
131
|
+
// Рёбра
|
|
132
|
+
box.querySelectorAll('[data-edge]').forEach(el => {
|
|
133
|
+
el.style.display = show ? '' : 'none';
|
|
134
|
+
});
|
|
135
|
+
// Ручка вращения
|
|
136
|
+
const rot = box.querySelector('[data-handle="rotate"]');
|
|
137
|
+
if (rot) rot.style.display = show ? '' : 'none';
|
|
138
|
+
// Если нужно показать, но ручек нет (мы их не создавали в suppressed-режиме) — перерисуем
|
|
139
|
+
if (show && !box.querySelector('[data-dir]')) {
|
|
140
|
+
this.update();
|
|
141
|
+
}
|
|
142
|
+
}
|
|
143
|
+
|
|
109
144
|
_showBounds(worldBounds, id) {
|
|
110
145
|
if (!this.layer) return;
|
|
111
146
|
// Преобразуем world координаты в CSS-пиксели
|
|
@@ -122,16 +157,27 @@ export class HtmlHandlesLayer {
|
|
|
122
157
|
const worldX = world?.x || 0;
|
|
123
158
|
const worldY = world?.y || 0;
|
|
124
159
|
|
|
125
|
-
//
|
|
160
|
+
// Узнаём тип объекта (нужно, чтобы для file/frame отключать определённые элементы)
|
|
161
|
+
let isFileTarget = false;
|
|
162
|
+
let isFrameTarget = false;
|
|
163
|
+
if (id !== '__group__') {
|
|
164
|
+
const req = { objectId: id, pixiObject: null };
|
|
165
|
+
this.eventBus.emit(Events.Tool.GetObjectPixi, req);
|
|
166
|
+
const mbType = req.pixiObject && req.pixiObject._mb && req.pixiObject._mb.type;
|
|
167
|
+
isFileTarget = mbType === 'file';
|
|
168
|
+
isFrameTarget = mbType === 'frame';
|
|
169
|
+
}
|
|
170
|
+
|
|
171
|
+
// Вычисляем позицию и размер в CSS координатах, округляем до целых px
|
|
126
172
|
const cssX = offsetLeft + (worldX + worldBounds.x * worldScale) / res;
|
|
127
173
|
const cssY = offsetTop + (worldY + worldBounds.y * worldScale) / res;
|
|
128
174
|
const cssWidth = Math.max(1, (worldBounds.width * worldScale) / res);
|
|
129
175
|
const cssHeight = Math.max(1, (worldBounds.height * worldScale) / res);
|
|
130
|
-
|
|
131
|
-
const left = cssX;
|
|
132
|
-
const top = cssY;
|
|
133
|
-
const width = cssWidth;
|
|
134
|
-
const height = cssHeight;
|
|
176
|
+
|
|
177
|
+
const left = Math.round(cssX);
|
|
178
|
+
const top = Math.round(cssY);
|
|
179
|
+
const width = Math.round(cssWidth);
|
|
180
|
+
const height = Math.round(cssHeight);
|
|
135
181
|
|
|
136
182
|
this.layer.innerHTML = '';
|
|
137
183
|
const box = document.createElement('div');
|
|
@@ -148,11 +194,16 @@ export class HtmlHandlesLayer {
|
|
|
148
194
|
Object.assign(box.style, {
|
|
149
195
|
position: 'absolute', left: `${left}px`, top: `${top}px`,
|
|
150
196
|
width: `${width}px`, height: `${height}px`,
|
|
151
|
-
border: '1px solid #1DE9B6', boxSizing: '
|
|
197
|
+
border: '1px solid #1DE9B6', borderRadius: '3px', boxSizing: 'content-box', pointerEvents: 'none',
|
|
152
198
|
transformOrigin: 'center center', // Поворот вокруг центра
|
|
153
199
|
transform: `rotate(${rotation}deg)` // Применяем поворот
|
|
154
200
|
});
|
|
155
201
|
this.layer.appendChild(box);
|
|
202
|
+
// Если сейчас подавление ручек активно — не создавать ручки вовсе, оставляем только рамку
|
|
203
|
+
if (this._handlesSuppressed) {
|
|
204
|
+
this.visible = true;
|
|
205
|
+
return;
|
|
206
|
+
}
|
|
156
207
|
|
|
157
208
|
// Угловые ручки для ресайза - круглые с мятно-зелёным цветом и белой серединой
|
|
158
209
|
const mkCorner = (dir, x, y, cursor) => {
|
|
@@ -164,12 +215,14 @@ export class HtmlHandlesLayer {
|
|
|
164
215
|
border: '2px solid #1DE9B6',
|
|
165
216
|
borderRadius: '50%', // Делаем круглыми
|
|
166
217
|
boxSizing: 'border-box',
|
|
167
|
-
pointerEvents: 'auto',
|
|
218
|
+
pointerEvents: isFileTarget ? 'none' : 'auto',
|
|
168
219
|
zIndex: 10, // Увеличиваем z-index
|
|
169
220
|
cursor: cursor
|
|
170
221
|
});
|
|
171
222
|
h.style.left = `${x - 6}px`;
|
|
172
223
|
h.style.top = `${y - 6}px`;
|
|
224
|
+
// Для файла скрываем ручки, для остальных показываем
|
|
225
|
+
h.style.display = isFileTarget ? 'none' : 'block';
|
|
173
226
|
|
|
174
227
|
// Создаем внутренний белый круг
|
|
175
228
|
const inner = document.createElement('div');
|
|
@@ -195,12 +248,14 @@ export class HtmlHandlesLayer {
|
|
|
195
248
|
h.style.borderColor = '#1DE9B6';
|
|
196
249
|
});
|
|
197
250
|
|
|
198
|
-
|
|
251
|
+
if (!isFileTarget) {
|
|
252
|
+
h.addEventListener('mousedown', (e) => this._onHandleDown(e, box));
|
|
253
|
+
}
|
|
199
254
|
|
|
200
255
|
box.appendChild(h);
|
|
201
256
|
};
|
|
202
257
|
|
|
203
|
-
const x0 = 0, y0 = 0, x1 = width, y1 = height, cx = width / 2, cy = height / 2;
|
|
258
|
+
const x0 = 0, y0 = 0, x1 = width, y1 = height, cx = Math.round(width / 2), cy = Math.round(height / 2);
|
|
204
259
|
mkCorner('nw', x0, y0, 'nwse-resize');
|
|
205
260
|
mkCorner('ne', x1, y0, 'nesw-resize');
|
|
206
261
|
mkCorner('se', x1, y1, 'nwse-resize');
|
|
@@ -219,12 +274,15 @@ export class HtmlHandlesLayer {
|
|
|
219
274
|
const e = document.createElement('div');
|
|
220
275
|
e.dataset.edge = name; e.dataset.id = id;
|
|
221
276
|
Object.assign(e.style, style, {
|
|
222
|
-
position: 'absolute', pointerEvents: 'auto', cursor,
|
|
277
|
+
position: 'absolute', pointerEvents: isFileTarget ? 'none' : 'auto', cursor,
|
|
223
278
|
zIndex: 5, // Меньше чем у ручек (10)
|
|
224
|
-
background: 'transparent' // невидимые области
|
|
279
|
+
background: 'transparent', // невидимые области
|
|
280
|
+
display: isFileTarget ? 'none' : 'block'
|
|
225
281
|
|
|
226
282
|
});
|
|
227
|
-
|
|
283
|
+
if (!isFileTarget) {
|
|
284
|
+
e.addEventListener('mousedown', (evt) => this._onEdgeResizeDown(evt));
|
|
285
|
+
}
|
|
228
286
|
box.appendChild(e);
|
|
229
287
|
};
|
|
230
288
|
|
|
@@ -263,48 +321,40 @@ export class HtmlHandlesLayer {
|
|
|
263
321
|
height: `${Math.max(0, height - 2 * cornerGap)}px`
|
|
264
322
|
}, 'ew-resize');
|
|
265
323
|
|
|
266
|
-
// Ручка
|
|
324
|
+
// Ручка вращения: SVG-иконка, показываем для всех, кроме файла
|
|
267
325
|
const rotateHandle = document.createElement('div');
|
|
268
326
|
rotateHandle.dataset.handle = 'rotate';
|
|
269
327
|
rotateHandle.dataset.id = id;
|
|
270
|
-
|
|
271
|
-
|
|
272
|
-
|
|
273
|
-
|
|
274
|
-
|
|
275
|
-
|
|
276
|
-
|
|
277
|
-
|
|
278
|
-
|
|
279
|
-
|
|
280
|
-
|
|
281
|
-
|
|
282
|
-
|
|
283
|
-
|
|
284
|
-
|
|
285
|
-
|
|
286
|
-
|
|
287
|
-
|
|
288
|
-
|
|
289
|
-
|
|
290
|
-
|
|
291
|
-
|
|
292
|
-
|
|
293
|
-
|
|
294
|
-
|
|
295
|
-
|
|
296
|
-
|
|
297
|
-
|
|
298
|
-
rotateHandle.
|
|
299
|
-
|
|
300
|
-
});
|
|
301
|
-
rotateHandle.addEventListener('mouseleave', () => {
|
|
302
|
-
rotateHandle.style.background = '#28A745';
|
|
303
|
-
});
|
|
304
|
-
|
|
305
|
-
// Обработчик вращения
|
|
306
|
-
rotateHandle.addEventListener('mousedown', (e) => this._onRotateHandleDown(e, box));
|
|
307
|
-
|
|
328
|
+
if (isFileTarget || isFrameTarget) {
|
|
329
|
+
Object.assign(rotateHandle.style, { display: 'none', pointerEvents: 'none' });
|
|
330
|
+
} else {
|
|
331
|
+
Object.assign(rotateHandle.style, {
|
|
332
|
+
position: 'absolute',
|
|
333
|
+
width: '20px', height: '20px',
|
|
334
|
+
background: 'transparent',
|
|
335
|
+
border: 'none',
|
|
336
|
+
borderRadius: '50%',
|
|
337
|
+
pointerEvents: 'auto',
|
|
338
|
+
cursor: 'grab',
|
|
339
|
+
zIndex: 15,
|
|
340
|
+
display: 'flex', alignItems: 'center', justifyContent: 'center'
|
|
341
|
+
});
|
|
342
|
+
// Фиксированная дистанция 20px по диагонали (top-right → bottom-left) от угла (0, h)
|
|
343
|
+
const d = 38;
|
|
344
|
+
const L = Math.max(1, Math.hypot(width, height));
|
|
345
|
+
const centerX = -(width / L) * d; // влево от левого нижнего угла
|
|
346
|
+
const centerY = height + (height / L) * d; // ниже нижней грани
|
|
347
|
+
rotateHandle.style.left = `${Math.round(centerX - 0)}px`;
|
|
348
|
+
rotateHandle.style.top = `${Math.round(centerY - 10)}px`;
|
|
349
|
+
rotateHandle.innerHTML = rotateIconSvg;
|
|
350
|
+
const svgEl = rotateHandle.querySelector('svg');
|
|
351
|
+
if (svgEl) {
|
|
352
|
+
svgEl.style.width = '100%';
|
|
353
|
+
svgEl.style.height = '100%';
|
|
354
|
+
svgEl.style.display = 'block';
|
|
355
|
+
}
|
|
356
|
+
rotateHandle.addEventListener('mousedown', (e) => this._onRotateHandleDown(e, box));
|
|
357
|
+
}
|
|
308
358
|
box.appendChild(rotateHandle);
|
|
309
359
|
|
|
310
360
|
this.visible = true;
|
|
@@ -388,11 +438,11 @@ export class HtmlHandlesLayer {
|
|
|
388
438
|
newTop = startCSS.top + dy;
|
|
389
439
|
}
|
|
390
440
|
|
|
391
|
-
// Обновим визуально
|
|
392
|
-
box.style.left = `${newLeft}px`;
|
|
393
|
-
box.style.top = `${newTop}px`;
|
|
394
|
-
box.style.width = `${newW}px`;
|
|
395
|
-
box.style.height = `${newH}px`;
|
|
441
|
+
// Обновим визуально (округление до целых для избежания дрожания)
|
|
442
|
+
box.style.left = `${Math.round(newLeft)}px`;
|
|
443
|
+
box.style.top = `${Math.round(newTop)}px`;
|
|
444
|
+
box.style.width = `${Math.round(newW)}px`;
|
|
445
|
+
box.style.height = `${Math.round(newH)}px`;
|
|
396
446
|
// Переставим ручки без перестроения слоя
|
|
397
447
|
this._repositionBoxChildren(box);
|
|
398
448
|
|
|
@@ -416,16 +466,22 @@ export class HtmlHandlesLayer {
|
|
|
416
466
|
newBounds: { x: worldX, y: worldY, width: worldW, height: worldH }
|
|
417
467
|
});
|
|
418
468
|
} else {
|
|
469
|
+
// Определяем тип объекта: для фреймов (locked aspect) позволяем ядру вычислить позицию (симметрия)
|
|
470
|
+
let isFrameTarget = false;
|
|
471
|
+
{
|
|
472
|
+
const req = { objectId: id, pixiObject: null };
|
|
473
|
+
this.eventBus.emit(Events.Tool.GetObjectPixi, req);
|
|
474
|
+
const mbType = req.pixiObject && req.pixiObject._mb && req.pixiObject._mb.type;
|
|
475
|
+
isFrameTarget = mbType === 'frame';
|
|
476
|
+
}
|
|
477
|
+
// Для правой/нижней ручки — фиксируем стартовую позицию; для левой/верхней — новую (не для frame)
|
|
478
|
+
const isLeftOrTop = dir.includes('w') || dir.includes('n');
|
|
419
479
|
const resizeData = {
|
|
420
480
|
object: id,
|
|
421
|
-
size: { width: worldW, height: worldH }
|
|
481
|
+
size: { width: worldW, height: worldH },
|
|
482
|
+
position: isFrameTarget ? null : (isLeftOrTop ? { x: worldX, y: worldY } : { x: startWorld.x, y: startWorld.y })
|
|
422
483
|
};
|
|
423
|
-
|
|
424
|
-
// Отправляем позицию только если она действительно изменилась
|
|
425
|
-
if (positionChanged) {
|
|
426
|
-
resizeData.position = { x: worldX, y: worldY };
|
|
427
|
-
}
|
|
428
|
-
|
|
484
|
+
|
|
429
485
|
this.eventBus.emit(Events.Tool.ResizeUpdate, resizeData);
|
|
430
486
|
}
|
|
431
487
|
};
|
|
@@ -454,18 +510,22 @@ export class HtmlHandlesLayer {
|
|
|
454
510
|
// Определяем, изменилась ли позиция
|
|
455
511
|
const finalPositionChanged = (endCSS.left !== startCSS.left) || (endCSS.top !== startCSS.top);
|
|
456
512
|
|
|
513
|
+
const isEdgeLeftOrTop = dir.includes('w') || dir.includes('n');
|
|
514
|
+
let isFrameTarget = false;
|
|
515
|
+
{
|
|
516
|
+
const req = { objectId: id, pixiObject: null };
|
|
517
|
+
this.eventBus.emit(Events.Tool.GetObjectPixi, req);
|
|
518
|
+
const mbType = req.pixiObject && req.pixiObject._mb && req.pixiObject._mb.type;
|
|
519
|
+
isFrameTarget = mbType === 'frame';
|
|
520
|
+
}
|
|
457
521
|
const resizeEndData = {
|
|
458
522
|
object: id,
|
|
459
523
|
oldSize: { width: startWorld.width, height: startWorld.height },
|
|
460
|
-
newSize: { width: worldW, height: worldH }
|
|
524
|
+
newSize: { width: worldW, height: worldH },
|
|
525
|
+
oldPosition: { x: startWorld.x, y: startWorld.y },
|
|
526
|
+
newPosition: isFrameTarget ? null : (isEdgeLeftOrTop ? { x: worldX, y: worldY } : { x: startWorld.x, y: startWorld.y })
|
|
461
527
|
};
|
|
462
|
-
|
|
463
|
-
// Добавляем информацию о позиции только если она действительно изменилась
|
|
464
|
-
if (finalPositionChanged) {
|
|
465
|
-
resizeEndData.oldPosition = { x: startWorld.x, y: startWorld.y };
|
|
466
|
-
resizeEndData.newPosition = { x: worldX, y: worldY };
|
|
467
|
-
}
|
|
468
|
-
|
|
528
|
+
|
|
469
529
|
this.eventBus.emit(Events.Tool.ResizeEnd, resizeEndData);
|
|
470
530
|
}
|
|
471
531
|
};
|
|
@@ -569,14 +629,10 @@ export class HtmlHandlesLayer {
|
|
|
569
629
|
} else {
|
|
570
630
|
const edgeResizeData = {
|
|
571
631
|
object: id,
|
|
572
|
-
size: { width: worldW, height: worldH }
|
|
632
|
+
size: { width: worldW, height: worldH },
|
|
633
|
+
position: edgePositionChanged ? { x: worldX, y: worldY } : { x: startWorld.x, y: startWorld.y }
|
|
573
634
|
};
|
|
574
|
-
|
|
575
|
-
// Отправляем позицию только если она действительно изменилась
|
|
576
|
-
if (edgePositionChanged) {
|
|
577
|
-
edgeResizeData.position = { x: worldX, y: worldY };
|
|
578
|
-
}
|
|
579
|
-
|
|
635
|
+
|
|
580
636
|
this.eventBus.emit(Events.Tool.ResizeUpdate, edgeResizeData);
|
|
581
637
|
}
|
|
582
638
|
};
|
|
@@ -607,15 +663,11 @@ export class HtmlHandlesLayer {
|
|
|
607
663
|
const edgeResizeEndData = {
|
|
608
664
|
object: id,
|
|
609
665
|
oldSize: { width: startWorld.width, height: startWorld.height },
|
|
610
|
-
newSize: { width: worldW, height: worldH }
|
|
666
|
+
newSize: { width: worldW, height: worldH },
|
|
667
|
+
oldPosition: { x: startWorld.x, y: startWorld.y },
|
|
668
|
+
newPosition: edgeFinalPositionChanged ? { x: worldX, y: worldY } : { x: startWorld.x, y: startWorld.y }
|
|
611
669
|
};
|
|
612
|
-
|
|
613
|
-
// Добавляем информацию о позиции только если она действительно изменилась
|
|
614
|
-
if (edgeFinalPositionChanged) {
|
|
615
|
-
edgeResizeEndData.oldPosition = { x: startWorld.x, y: startWorld.y };
|
|
616
|
-
edgeResizeEndData.newPosition = { x: worldX, y: worldY };
|
|
617
|
-
}
|
|
618
|
-
|
|
670
|
+
|
|
619
671
|
this.eventBus.emit(Events.Tool.ResizeEnd, edgeResizeEndData);
|
|
620
672
|
}
|
|
621
673
|
};
|
|
@@ -770,8 +822,12 @@ export class HtmlHandlesLayer {
|
|
|
770
822
|
// Позиционируем ручку вращения
|
|
771
823
|
const rotateHandle = box.querySelector('[data-handle="rotate"]');
|
|
772
824
|
if (rotateHandle) {
|
|
773
|
-
|
|
774
|
-
|
|
825
|
+
const d = 20;
|
|
826
|
+
const L = Math.max(1, Math.hypot(width, height));
|
|
827
|
+
const centerX = -(width / L) * d;
|
|
828
|
+
const centerY = height + (height / L) * d;
|
|
829
|
+
rotateHandle.style.left = `${Math.round(centerX - 10)}px`;
|
|
830
|
+
rotateHandle.style.top = `${Math.round(centerY - 10)}px`;
|
|
775
831
|
}
|
|
776
832
|
}
|
|
777
833
|
}
|
package/src/ui/HtmlTextLayer.js
CHANGED
|
@@ -180,6 +180,8 @@ export class HtmlTextLayer {
|
|
|
180
180
|
transformOrigin: 'top left',
|
|
181
181
|
color: color,
|
|
182
182
|
whiteSpace: 'pre-wrap',
|
|
183
|
+
overflowWrap: 'anywhere',
|
|
184
|
+
wordBreak: 'break-word',
|
|
183
185
|
pointerEvents: 'none', // всё взаимодействие остаётся на PIXI
|
|
184
186
|
userSelect: 'none',
|
|
185
187
|
fontFamily: fontFamily,
|
|
@@ -236,10 +238,16 @@ export class HtmlTextLayer {
|
|
|
236
238
|
const baseH = parseFloat(el.dataset.baseH || '36') || 36;
|
|
237
239
|
const scaleX = w && baseW ? (w / baseW) : 1;
|
|
238
240
|
const scaleY = h && baseH ? (h / baseH) : 1;
|
|
239
|
-
|
|
241
|
+
// Для текстовых объектов не масштабируем шрифт от изменения размеров блока,
|
|
242
|
+
// чтобы сохранять вид как при редактировании (как в Miro)
|
|
243
|
+
const sObj = (obj?.type === 'text' || obj?.type === 'simple-text')
|
|
244
|
+
? 1
|
|
245
|
+
: Math.min(scaleX, scaleY);
|
|
240
246
|
const sCss = s / res;
|
|
241
247
|
const fontSizePx = Math.max(1, baseFS * sObj * sCss);
|
|
242
248
|
el.style.fontSize = `${fontSizePx}px`;
|
|
249
|
+
// Синхронизируем межстрочный интервал с режимом редактирования
|
|
250
|
+
el.style.lineHeight = `${fontSizePx}px`;
|
|
243
251
|
|
|
244
252
|
// Позиция и габариты в экранных координатах
|
|
245
253
|
const left = (tx + s * x) / res;
|
|
@@ -27,9 +27,13 @@ export class NotePropertiesPanel {
|
|
|
27
27
|
if (this.currentId && objectId === this.currentId) this.hide();
|
|
28
28
|
});
|
|
29
29
|
|
|
30
|
-
// Обновляем позицию
|
|
30
|
+
// Обновляем позицию / скрываем во время перетаскивания
|
|
31
|
+
this.eventBus.on(Events.Tool.DragStart, () => this.hide());
|
|
31
32
|
this.eventBus.on(Events.Tool.DragUpdate, () => this.reposition());
|
|
33
|
+
this.eventBus.on(Events.Tool.DragEnd, () => this.updateFromSelection());
|
|
32
34
|
this.eventBus.on(Events.Tool.GroupDragUpdate, () => this.reposition());
|
|
35
|
+
this.eventBus.on(Events.Tool.GroupDragStart, () => this.hide());
|
|
36
|
+
this.eventBus.on(Events.Tool.GroupDragEnd, () => this.updateFromSelection());
|
|
33
37
|
this.eventBus.on(Events.Tool.ResizeUpdate, () => this.reposition());
|
|
34
38
|
this.eventBus.on(Events.Tool.RotateUpdate, () => this.reposition());
|
|
35
39
|
|
|
@@ -158,17 +162,20 @@ export class NotePropertiesPanel {
|
|
|
158
162
|
const { x, y } = posData.position;
|
|
159
163
|
const { width, height } = sizeData.size;
|
|
160
164
|
|
|
161
|
-
// Позиционируем панель
|
|
162
|
-
const
|
|
163
|
-
const
|
|
165
|
+
// Позиционируем панель над запиской, по центру
|
|
166
|
+
const panelRect = this.panel.getBoundingClientRect();
|
|
167
|
+
const panelW = Math.max(1, panelRect.width || 320);
|
|
168
|
+
const panelH = Math.max(1, panelRect.height || 40);
|
|
169
|
+
const panelX = x + (width / 2) - (panelW / 2);
|
|
170
|
+
const panelY = Math.max(0, y - panelH - 40); // отступ 40px над запиской
|
|
164
171
|
|
|
165
172
|
console.log('📝 NotePropertiesPanel: Positioning next to note:', {
|
|
166
173
|
noteX: x, noteY: y, noteWidth: width, noteHeight: height,
|
|
167
174
|
panelX, panelY
|
|
168
175
|
});
|
|
169
176
|
|
|
170
|
-
this.panel.style.left = `${panelX}px`;
|
|
171
|
-
this.panel.style.top = `${panelY}px`;
|
|
177
|
+
this.panel.style.left = `${Math.round(panelX)}px`;
|
|
178
|
+
this.panel.style.top = `${Math.round(panelY)}px`;
|
|
172
179
|
}
|
|
173
180
|
|
|
174
181
|
_createNoteControls(panel) {
|
package/src/ui/Toolbar.js
CHANGED
|
@@ -60,7 +60,7 @@ export class Toolbar {
|
|
|
60
60
|
|
|
61
61
|
// Существующие элементы ниже новых
|
|
62
62
|
const existingTools = [
|
|
63
|
-
|
|
63
|
+
{ id: 'frame', iconName: 'frame', title: 'Добавить фрейм', type: 'frame' },
|
|
64
64
|
{ id: 'divider', type: 'divider' },
|
|
65
65
|
{ id: 'clear', iconName: 'clear', title: 'Очистить холст', type: 'clear' },
|
|
66
66
|
{ id: 'divider', type: 'divider' },
|
|
@@ -85,6 +85,7 @@ export class Toolbar {
|
|
|
85
85
|
this.createShapesPopup();
|
|
86
86
|
this.createDrawPopup();
|
|
87
87
|
this.createEmojiPopup();
|
|
88
|
+
this.createFramePopup();
|
|
88
89
|
|
|
89
90
|
// Подсветка активной кнопки на тулбаре по активному инструменту
|
|
90
91
|
this.eventBus.on(Events.Tool.Activated, ({ tool }) => {
|
|
@@ -94,6 +95,115 @@ export class Toolbar {
|
|
|
94
95
|
// Текущее состояние попапа рисования
|
|
95
96
|
this.currentDrawTool = 'pencil';
|
|
96
97
|
}
|
|
98
|
+
|
|
99
|
+
createFramePopup() {
|
|
100
|
+
this.framePopupEl = document.createElement('div');
|
|
101
|
+
this.framePopupEl.className = 'moodboard-toolbar__popup frame-popup';
|
|
102
|
+
this.framePopupEl.style.display = 'none';
|
|
103
|
+
|
|
104
|
+
const makeBtn = (label, id, enabled, aspect, options = {}) => {
|
|
105
|
+
const btn = document.createElement('button');
|
|
106
|
+
btn.className = 'frame-popup__btn' + (enabled ? '' : ' is-disabled') + (options.header ? ' frame-popup__btn--header' : '');
|
|
107
|
+
if (options.header) {
|
|
108
|
+
// handled by CSS class
|
|
109
|
+
}
|
|
110
|
+
btn.dataset.id = id;
|
|
111
|
+
// Внутри кнопки — превью (слева) и подпись (справа/ниже)
|
|
112
|
+
const holder = document.createElement('div');
|
|
113
|
+
holder.className = 'frame-popup__holder';
|
|
114
|
+
let preview = document.createElement('div');
|
|
115
|
+
if (options.header) {
|
|
116
|
+
// Для «Произвольный» — горизонтальный пунктирный прямоугольник
|
|
117
|
+
preview.className = 'frame-popup__preview frame-popup__preview--custom';
|
|
118
|
+
} else {
|
|
119
|
+
// Для пресетов — мини-превью с нужными пропорциями, слева от текста
|
|
120
|
+
preview.className = 'frame-popup__preview';
|
|
121
|
+
preview.style.aspectRatio = aspect || '1 / 1';
|
|
122
|
+
}
|
|
123
|
+
const caption = document.createElement('div');
|
|
124
|
+
caption.textContent = label;
|
|
125
|
+
caption.className = 'frame-popup__caption';
|
|
126
|
+
holder.appendChild(preview);
|
|
127
|
+
holder.appendChild(caption);
|
|
128
|
+
btn.appendChild(holder);
|
|
129
|
+
if (enabled) {
|
|
130
|
+
btn.addEventListener('click', (e) => {
|
|
131
|
+
e.stopPropagation();
|
|
132
|
+
// Активируем place, устанавливаем pending для frame (А4)
|
|
133
|
+
this.eventBus.emit(Events.Keyboard.ToolSelect, { tool: 'place' });
|
|
134
|
+
this.placeSelectedButtonId = 'frame';
|
|
135
|
+
this.setActiveToolbarButton('place');
|
|
136
|
+
if (id === 'custom') {
|
|
137
|
+
// Рисовать фрейм вручную прямоугольником
|
|
138
|
+
this.eventBus.emit(Events.Place.Set, { type: 'frame-draw', properties: {} });
|
|
139
|
+
} else {
|
|
140
|
+
// Подбираем размеры по пресету и увеличиваем площадь в 2 раза (масштаб по корню из 2)
|
|
141
|
+
let width = 210, height = 297, titleText = 'A4';
|
|
142
|
+
if (id === '1x1') { width = 300; height = 300; titleText = '1:1'; }
|
|
143
|
+
else if (id === '4x3') { width = 320; height = 240; titleText = '4:3'; }
|
|
144
|
+
else if (id === '16x9') { width = 320; height = 180; titleText = '16:9'; }
|
|
145
|
+
const scale = 2; // х2 по сторонам = х4 по площади
|
|
146
|
+
width = Math.round(width * scale);
|
|
147
|
+
height = Math.round(height * scale);
|
|
148
|
+
// Устанавливаем pending для размещения фрейма указанного размера
|
|
149
|
+
this.eventBus.emit(Events.Place.Set, {
|
|
150
|
+
type: 'frame',
|
|
151
|
+
properties: {
|
|
152
|
+
width,
|
|
153
|
+
height,
|
|
154
|
+
borderColor: 0x333333,
|
|
155
|
+
fillColor: 0xFFFFFF,
|
|
156
|
+
title: titleText,
|
|
157
|
+
lockedAspect: true,
|
|
158
|
+
type: id
|
|
159
|
+
}
|
|
160
|
+
});
|
|
161
|
+
}
|
|
162
|
+
this.closeFramePopup();
|
|
163
|
+
});
|
|
164
|
+
}
|
|
165
|
+
this.framePopupEl.appendChild(btn);
|
|
166
|
+
};
|
|
167
|
+
|
|
168
|
+
// Верхний ряд: одна кнопка «Произвольный» (включаем рисование фрейма)
|
|
169
|
+
makeBtn('Произвольный', 'custom', true, 'none', { header: true });
|
|
170
|
+
|
|
171
|
+
makeBtn('A4', 'a4', true, '210 / 297');
|
|
172
|
+
makeBtn('1:1', '1x1', true, '1 / 1');
|
|
173
|
+
makeBtn('4:3', '4x3', true, '4 / 3');
|
|
174
|
+
makeBtn('16:9', '16x9', true, '16 / 9');
|
|
175
|
+
|
|
176
|
+
this.container.appendChild(this.framePopupEl);
|
|
177
|
+
}
|
|
178
|
+
|
|
179
|
+
toggleFramePopup(anchorBtn) {
|
|
180
|
+
if (!this.framePopupEl) return;
|
|
181
|
+
const visible = this.framePopupEl.style.display !== 'none';
|
|
182
|
+
if (visible) {
|
|
183
|
+
this.closeFramePopup();
|
|
184
|
+
return;
|
|
185
|
+
}
|
|
186
|
+
const buttonRect = anchorBtn.getBoundingClientRect();
|
|
187
|
+
const toolbarRect = this.container.getBoundingClientRect();
|
|
188
|
+
// Сначала показываем невидимо, чтобы измерить размеры
|
|
189
|
+
this.framePopupEl.style.display = 'grid';
|
|
190
|
+
this.framePopupEl.style.visibility = 'hidden';
|
|
191
|
+
const panelW = this.framePopupEl.offsetWidth || 120;
|
|
192
|
+
const panelH = this.framePopupEl.offsetHeight || 120;
|
|
193
|
+
// Горизонтально: как у панели фигур — от правого края тулбара + 8px
|
|
194
|
+
const targetLeft = this.element.offsetWidth + 8;
|
|
195
|
+
// Вертикально: центр панели на уровне центра кнопки, с тем же лёгким смещением -4px как у фигур
|
|
196
|
+
const btnCenterY = buttonRect.top + buttonRect.height / 2;
|
|
197
|
+
const targetTop = Math.max(0, Math.round(btnCenterY - toolbarRect.top - panelH / 2 - 4));
|
|
198
|
+
this.framePopupEl.style.left = `${Math.round(targetLeft)}px`;
|
|
199
|
+
this.framePopupEl.style.top = `${targetTop}px`;
|
|
200
|
+
// Делаем видимой
|
|
201
|
+
this.framePopupEl.style.visibility = '';
|
|
202
|
+
}
|
|
203
|
+
|
|
204
|
+
closeFramePopup() {
|
|
205
|
+
if (this.framePopupEl) this.framePopupEl.style.display = 'none';
|
|
206
|
+
}
|
|
97
207
|
|
|
98
208
|
/**
|
|
99
209
|
* Создает кнопку инструмента
|
|
@@ -316,27 +426,17 @@ export class Toolbar {
|
|
|
316
426
|
return;
|
|
317
427
|
}
|
|
318
428
|
|
|
319
|
-
//
|
|
429
|
+
// Фрейм: показываем всплывающую панель с пресетами
|
|
320
430
|
if (toolType === 'frame') {
|
|
321
431
|
this.animateButton(button);
|
|
432
|
+
this.toggleFramePopup(button);
|
|
322
433
|
this.closeShapesPopup();
|
|
323
434
|
this.closeDrawPopup();
|
|
324
435
|
this.closeEmojiPopup();
|
|
325
|
-
// Активируем place
|
|
436
|
+
// Активируем place и подсвечиваем кнопку Frame
|
|
326
437
|
this.eventBus.emit(Events.Keyboard.ToolSelect, { tool: 'place' });
|
|
327
438
|
this.placeSelectedButtonId = 'frame';
|
|
328
439
|
this.setActiveToolbarButton('place');
|
|
329
|
-
// Устанавливаем свойства фрейма по умолчанию
|
|
330
|
-
this.eventBus.emit(Events.Place.Set, {
|
|
331
|
-
type: 'frame',
|
|
332
|
-
properties: {
|
|
333
|
-
width: 200,
|
|
334
|
-
height: 300,
|
|
335
|
-
borderColor: 0x333333,
|
|
336
|
-
fillColor: 0xFFFFFF,
|
|
337
|
-
title: 'Новый' // Название по умолчанию
|
|
338
|
-
}
|
|
339
|
-
});
|
|
340
440
|
return;
|
|
341
441
|
}
|
|
342
442
|
|
|
@@ -447,13 +547,16 @@ export class Toolbar {
|
|
|
447
547
|
const isInsideShapesPopup = this.shapesPopupEl && this.shapesPopupEl.contains(e.target);
|
|
448
548
|
const isInsideDrawPopup = this.drawPopupEl && this.drawPopupEl.contains(e.target);
|
|
449
549
|
const isInsideEmojiPopup = this.emojiPopupEl && this.emojiPopupEl.contains(e.target);
|
|
550
|
+
const isInsideFramePopup = this.framePopupEl && this.framePopupEl.contains(e.target);
|
|
450
551
|
const isShapesButton = e.target.closest && e.target.closest('.moodboard-toolbar__button--shapes');
|
|
451
552
|
const isDrawButton = e.target.closest && e.target.closest('.moodboard-toolbar__button--pencil');
|
|
452
553
|
const isEmojiButton = e.target.closest && e.target.closest('.moodboard-toolbar__button--emoji');
|
|
453
|
-
|
|
554
|
+
const isFrameButton = e.target.closest && e.target.closest('.moodboard-toolbar__button--frame');
|
|
555
|
+
if (!isInsideToolbar && !isInsideShapesPopup && !isShapesButton && !isInsideDrawPopup && !isDrawButton && !isInsideEmojiPopup && !isEmojiButton && !isInsideFramePopup && !isFrameButton) {
|
|
454
556
|
this.closeShapesPopup();
|
|
455
557
|
this.closeDrawPopup();
|
|
456
558
|
this.closeEmojiPopup();
|
|
559
|
+
this.closeFramePopup();
|
|
457
560
|
}
|
|
458
561
|
});
|
|
459
562
|
}
|