@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.
@@ -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
- // Вычисляем позицию и размер в CSS координатах
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: 'border-box', pointerEvents: 'none',
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
- h.addEventListener('mousedown', (e) => this._onHandleDown(e, box));
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
- e.addEventListener('mousedown', (evt) => this._onEdgeResizeDown(evt));
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
- Object.assign(rotateHandle.style, {
271
- position: 'absolute',
272
- width: '20px', height: '20px',
273
- background: '#28A745',
274
- border: '2px solid #fff',
275
- borderRadius: '50%',
276
- boxSizing: 'border-box',
277
- pointerEvents: 'auto',
278
- cursor: 'grab',
279
- zIndex: 15, // Выше ручек ресайза
280
- display: 'flex',
281
- alignItems: 'center',
282
- justifyContent: 'center',
283
- fontSize: '12px',
284
- color: '#fff',
285
- fontWeight: 'bold',
286
- userSelect: 'none'
287
- });
288
-
289
- // Позиционируем возле левого нижнего угла с отступом
290
- rotateHandle.style.left = `${0 - 10}px`; // центрируем относительно угла
291
- rotateHandle.style.top = `${height + 25 - 10}px`; // отступ 25px от нижней грани
292
-
293
- // Добавляем символ вращения
294
- rotateHandle.innerHTML = '';
295
-
296
- // Эффекты при наведении
297
- rotateHandle.addEventListener('mouseenter', () => {
298
- rotateHandle.style.background = '#34CE57';
299
- rotateHandle.style.cursor = 'grab';
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
- rotateHandle.style.left = `${0 - 10}px`; // центрируем относительно левого нижнего угла
774
- rotateHandle.style.top = `${height + 25 - 10}px`; // отступ 25px от нижней грани
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
  }
@@ -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
- const sObj = Math.min(scaleX, scaleY);
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 panelX = x + width + 10;
163
- const panelY = y;
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
- // { id: 'frame', iconName: 'frame', title: 'Добавить фрейм', type: 'frame' }, // Временно скрыто
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
- // Добавление фрейма: включаем placement и ждём клика для выбора позиции
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, устанавливаем pending для frame
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
- if (!isInsideToolbar && !isInsideShapesPopup && !isShapesButton && !isInsideDrawPopup && !isDrawButton && !isInsideEmojiPopup && !isEmojiButton) {
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
  }