@sequent-org/moodboard 1.0.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.
Files changed (123) hide show
  1. package/package.json +44 -0
  2. package/src/assets/icons/README.md +105 -0
  3. package/src/assets/icons/attachments.svg +3 -0
  4. package/src/assets/icons/clear.svg +5 -0
  5. package/src/assets/icons/comments.svg +3 -0
  6. package/src/assets/icons/emoji.svg +6 -0
  7. package/src/assets/icons/frame.svg +3 -0
  8. package/src/assets/icons/image.svg +3 -0
  9. package/src/assets/icons/note.svg +3 -0
  10. package/src/assets/icons/pan.svg +3 -0
  11. package/src/assets/icons/pencil.svg +3 -0
  12. package/src/assets/icons/redo.svg +3 -0
  13. package/src/assets/icons/select.svg +9 -0
  14. package/src/assets/icons/shapes.svg +3 -0
  15. package/src/assets/icons/text-add.svg +3 -0
  16. package/src/assets/icons/topbar/README.md +39 -0
  17. package/src/assets/icons/topbar/grid-cross.svg +6 -0
  18. package/src/assets/icons/topbar/grid-dot.svg +3 -0
  19. package/src/assets/icons/topbar/grid-line.svg +3 -0
  20. package/src/assets/icons/topbar/grid-off.svg +3 -0
  21. package/src/assets/icons/topbar/paint.svg +3 -0
  22. package/src/assets/icons/undo.svg +3 -0
  23. package/src/core/ApiClient.js +309 -0
  24. package/src/core/EventBus.js +42 -0
  25. package/src/core/HistoryManager.js +261 -0
  26. package/src/core/KeyboardManager.js +710 -0
  27. package/src/core/PixiEngine.js +439 -0
  28. package/src/core/SaveManager.js +381 -0
  29. package/src/core/StateManager.js +64 -0
  30. package/src/core/commands/BaseCommand.js +68 -0
  31. package/src/core/commands/CopyObjectCommand.js +44 -0
  32. package/src/core/commands/CreateObjectCommand.js +46 -0
  33. package/src/core/commands/DeleteObjectCommand.js +146 -0
  34. package/src/core/commands/EditFileNameCommand.js +107 -0
  35. package/src/core/commands/GroupMoveCommand.js +47 -0
  36. package/src/core/commands/GroupReorderZCommand.js +74 -0
  37. package/src/core/commands/GroupResizeCommand.js +37 -0
  38. package/src/core/commands/GroupRotateCommand.js +41 -0
  39. package/src/core/commands/MoveObjectCommand.js +89 -0
  40. package/src/core/commands/PasteObjectCommand.js +103 -0
  41. package/src/core/commands/ReorderZCommand.js +45 -0
  42. package/src/core/commands/ResizeObjectCommand.js +135 -0
  43. package/src/core/commands/RotateObjectCommand.js +70 -0
  44. package/src/core/commands/index.js +14 -0
  45. package/src/core/events/Events.js +147 -0
  46. package/src/core/index.js +1632 -0
  47. package/src/core/rendering/GeometryUtils.js +89 -0
  48. package/src/core/rendering/HitTestManager.js +186 -0
  49. package/src/core/rendering/LayerManager.js +137 -0
  50. package/src/core/rendering/ObjectRenderer.js +363 -0
  51. package/src/core/rendering/PixiRenderer.js +140 -0
  52. package/src/core/rendering/index.js +9 -0
  53. package/src/grid/BaseGrid.js +164 -0
  54. package/src/grid/CrossGrid.js +75 -0
  55. package/src/grid/DotGrid.js +148 -0
  56. package/src/grid/GridFactory.js +173 -0
  57. package/src/grid/LineGrid.js +115 -0
  58. package/src/index.js +2 -0
  59. package/src/moodboard/ActionHandler.js +114 -0
  60. package/src/moodboard/DataManager.js +114 -0
  61. package/src/moodboard/MoodBoard.js +359 -0
  62. package/src/moodboard/WorkspaceManager.js +103 -0
  63. package/src/objects/BaseObject.js +1 -0
  64. package/src/objects/CommentObject.js +115 -0
  65. package/src/objects/DrawingObject.js +114 -0
  66. package/src/objects/EmojiObject.js +98 -0
  67. package/src/objects/FileObject.js +318 -0
  68. package/src/objects/FrameObject.js +127 -0
  69. package/src/objects/ImageObject.js +72 -0
  70. package/src/objects/NoteObject.js +227 -0
  71. package/src/objects/ObjectFactory.js +61 -0
  72. package/src/objects/ShapeObject.js +134 -0
  73. package/src/objects/StampObject.js +0 -0
  74. package/src/objects/StickerObject.js +0 -0
  75. package/src/objects/TextObject.js +123 -0
  76. package/src/services/BoardService.js +85 -0
  77. package/src/services/FileUploadService.js +398 -0
  78. package/src/services/FrameService.js +138 -0
  79. package/src/services/ImageUploadService.js +246 -0
  80. package/src/services/ZOrderManager.js +50 -0
  81. package/src/services/ZoomPanController.js +78 -0
  82. package/src/src.7z +0 -0
  83. package/src/src.zip +0 -0
  84. package/src/src2.zip +0 -0
  85. package/src/tools/AlignmentGuides.js +326 -0
  86. package/src/tools/BaseTool.js +257 -0
  87. package/src/tools/ResizeHandles.js +381 -0
  88. package/src/tools/ToolManager.js +580 -0
  89. package/src/tools/board-tools/PanTool.js +43 -0
  90. package/src/tools/board-tools/ZoomTool.js +393 -0
  91. package/src/tools/object-tools/DrawingTool.js +404 -0
  92. package/src/tools/object-tools/PlacementTool.js +1005 -0
  93. package/src/tools/object-tools/SelectTool.js +2183 -0
  94. package/src/tools/object-tools/TextTool.js +416 -0
  95. package/src/tools/object-tools/selection/BoxSelectController.js +105 -0
  96. package/src/tools/object-tools/selection/GeometryUtils.js +101 -0
  97. package/src/tools/object-tools/selection/GroupDragController.js +61 -0
  98. package/src/tools/object-tools/selection/GroupResizeController.js +90 -0
  99. package/src/tools/object-tools/selection/GroupRotateController.js +61 -0
  100. package/src/tools/object-tools/selection/HandlesSync.js +96 -0
  101. package/src/tools/object-tools/selection/ResizeController.js +68 -0
  102. package/src/tools/object-tools/selection/RotateController.js +58 -0
  103. package/src/tools/object-tools/selection/SelectionModel.js +42 -0
  104. package/src/tools/object-tools/selection/SimpleDragController.js +45 -0
  105. package/src/ui/CommentPopover.js +187 -0
  106. package/src/ui/ContextMenu.js +340 -0
  107. package/src/ui/FilePropertiesPanel.js +298 -0
  108. package/src/ui/FramePropertiesPanel.js +462 -0
  109. package/src/ui/HtmlHandlesLayer.js +778 -0
  110. package/src/ui/HtmlTextLayer.js +279 -0
  111. package/src/ui/MapPanel.js +290 -0
  112. package/src/ui/NotePropertiesPanel.js +502 -0
  113. package/src/ui/SaveStatus.js +250 -0
  114. package/src/ui/TextPropertiesPanel.js +911 -0
  115. package/src/ui/Toolbar.js +1118 -0
  116. package/src/ui/Topbar.js +220 -0
  117. package/src/ui/ZoomPanel.js +116 -0
  118. package/src/ui/styles/workspace.css +854 -0
  119. package/src/utils/colors.js +0 -0
  120. package/src/utils/geometry.js +0 -0
  121. package/src/utils/iconLoader.js +270 -0
  122. package/src/utils/objectIdGenerator.js +17 -0
  123. package/src/utils/topbarIconLoader.js +114 -0
@@ -0,0 +1,416 @@
1
+ import { BaseTool } from '../BaseTool.js';
2
+
3
+ /**
4
+ * Инструмент для создания и редактирования текстовых объектов
5
+ */
6
+ export class TextTool extends BaseTool {
7
+ constructor(eventBus) {
8
+ super('text', eventBus);
9
+ this.cursor = 'text';
10
+ this.hotkey = 't';
11
+ this.container = null;
12
+
13
+ // Состояние редактирования
14
+ this.isEditing = false;
15
+ this.editingObject = null;
16
+ this.textInput = null;
17
+
18
+ // Настройки текста по умолчанию
19
+ this.defaultTextSettings = {
20
+ fontSize: 16,
21
+ fontFamily: 'Arial, sans-serif',
22
+ color: '#000000',
23
+ textAlign: 'left',
24
+ fontWeight: 'normal',
25
+ fontStyle: 'normal'
26
+ };
27
+ }
28
+
29
+ /**
30
+ * Клик для создания нового текста или редактирования существующего
31
+ */
32
+ onMouseDown(event) {
33
+ super.onMouseDown(event);
34
+
35
+ // Проверяем, кликнули ли на существующий текстовый объект
36
+ const hitObject = this.getTextObjectAt(event.x, event.y);
37
+
38
+ if (hitObject) {
39
+ this.startEditing(hitObject, event);
40
+ } else {
41
+ this.createNewText(event.x, event.y);
42
+ }
43
+ }
44
+
45
+ /**
46
+ * Двойной клик для быстрого входа в режим редактирования
47
+ */
48
+ onDoubleClick(event) {
49
+ const hitObject = this.getTextObjectAt(event.x, event.y);
50
+
51
+ if (hitObject) {
52
+ this.startEditing(hitObject, event);
53
+ }
54
+ }
55
+
56
+ /**
57
+ * Обработка клавиш во время редактирования
58
+ */
59
+ onKeyDown(event) {
60
+ if (this.isEditing) {
61
+ this.handleEditingKeys(event);
62
+ }
63
+ }
64
+
65
+ /**
66
+ * Создание нового текстового объекта
67
+ */
68
+ createNewText(x, y) {
69
+ const textData = {
70
+ type: 'text',
71
+ position: { x, y },
72
+ content: '',
73
+ settings: { ...this.defaultTextSettings }
74
+ };
75
+
76
+ // Создаем объект через событие
77
+ this.emit('object:create', textData);
78
+
79
+ // Сразу переходим в режим редактирования
80
+ this.startEditingNew(textData, x, y);
81
+ }
82
+
83
+ /**
84
+ * Начало редактирования нового текста
85
+ */
86
+ startEditingNew(textData, x, y) {
87
+ this.isEditing = true;
88
+ this.editingObject = textData;
89
+
90
+ this.createTextInput(x, y, '');
91
+ this.emit('text:edit:start', { object: textData });
92
+ }
93
+
94
+ /**
95
+ * Начало редактирования существующего текста
96
+ */
97
+ startEditing(textObject, event) {
98
+ if (this.isEditing) {
99
+ this.finishEditing();
100
+ }
101
+
102
+ this.isEditing = true;
103
+ this.editingObject = textObject;
104
+
105
+ // Создаем input с текущим содержимым
106
+ this.createTextInput(
107
+ textObject.position.x,
108
+ textObject.position.y,
109
+ textObject.content || ''
110
+ );
111
+
112
+ this.emit('text:edit:start', { object: textObject });
113
+ }
114
+
115
+ /**
116
+ * Создание HTML input для редактирования текста
117
+ */
118
+ createTextInput(x, y, initialText) {
119
+ // Удаляем предыдущий input если есть
120
+ this.removeTextInput();
121
+
122
+ // Создаем новый input
123
+ this.textInput = document.createElement('textarea');
124
+ this.textInput.value = initialText;
125
+ this.textInput.className = 'moodboard-text-input';
126
+
127
+ // Стили input
128
+ Object.assign(this.textInput.style, {
129
+ position: 'absolute',
130
+ left: `${x}px`,
131
+ top: `${y}px`,
132
+ border: '2px solid #007bff',
133
+ borderRadius: '4px',
134
+ padding: '4px 8px',
135
+ fontSize: `${this.defaultTextSettings.fontSize}px`,
136
+ fontFamily: this.defaultTextSettings.fontFamily,
137
+ color: this.defaultTextSettings.color,
138
+ background: 'white',
139
+ outline: 'none',
140
+ resize: 'none',
141
+ minWidth: '100px',
142
+ minHeight: '24px',
143
+ zIndex: '1000'
144
+ });
145
+
146
+ // Обработчики событий input
147
+ this.textInput.addEventListener('blur', () => this.finishEditing());
148
+ this.textInput.addEventListener('keydown', (e) => this.handleInputKeys(e));
149
+ this.textInput.addEventListener('input', (e) => this.handleTextChange(e));
150
+
151
+ // Добавляем в контейнер
152
+ this.getContainer().appendChild(this.textInput);
153
+
154
+ // Фокусируемся и выделяем весь текст
155
+ this.textInput.focus();
156
+ if (initialText) {
157
+ this.textInput.select();
158
+ }
159
+
160
+ // Автоматически подгоняем размер
161
+ this.adjustInputSize();
162
+ }
163
+
164
+ /**
165
+ * Обработка клавиш в input
166
+ */
167
+ handleInputKeys(event) {
168
+ switch (event.key) {
169
+ case 'Enter':
170
+ if (!event.shiftKey) {
171
+ // Enter без Shift - завершаем редактирование
172
+ this.finishEditing();
173
+ event.preventDefault();
174
+ } else {
175
+ // Shift+Enter - новая строка
176
+ setTimeout(() => this.adjustInputSize(), 0);
177
+ }
178
+ break;
179
+
180
+ case 'Escape':
181
+ this.cancelEditing();
182
+ event.preventDefault();
183
+ break;
184
+
185
+ case 'Tab':
186
+ this.finishEditing();
187
+ event.preventDefault();
188
+ break;
189
+ }
190
+ }
191
+
192
+ /**
193
+ * Обработка изменения текста
194
+ */
195
+ handleTextChange(event) {
196
+ this.adjustInputSize();
197
+
198
+ // Обновляем объект в реальном времени
199
+ if (this.editingObject) {
200
+ this.editingObject.content = this.textInput.value;
201
+ this.emit('text:content:change', {
202
+ object: this.editingObject,
203
+ content: this.textInput.value
204
+ });
205
+ }
206
+ }
207
+
208
+ /**
209
+ * Автоматическая подгонка размера input
210
+ */
211
+ adjustInputSize() {
212
+ if (!this.textInput) return;
213
+
214
+ // Временно делаем input очень маленьким
215
+ this.textInput.style.width = '1px';
216
+ this.textInput.style.height = '1px';
217
+
218
+ // Получаем реальные размеры контента
219
+ const scrollWidth = Math.max(this.textInput.scrollWidth, 100);
220
+ const scrollHeight = Math.max(this.textInput.scrollHeight, 24);
221
+
222
+ // Устанавливаем новые размеры
223
+ this.textInput.style.width = `${scrollWidth + 10}px`;
224
+ this.textInput.style.height = `${scrollHeight}px`;
225
+ }
226
+
227
+ /**
228
+ * Завершение редактирования (сохранение)
229
+ */
230
+ finishEditing() {
231
+ if (!this.isEditing || !this.textInput) return;
232
+
233
+ const finalText = this.textInput.value.trim();
234
+
235
+ if (finalText) {
236
+ // Сохраняем текст
237
+ if (this.editingObject) {
238
+ this.editingObject.content = finalText;
239
+ this.emit('text:edit:finish', {
240
+ object: this.editingObject,
241
+ content: finalText
242
+ });
243
+ }
244
+ } else {
245
+ // Пустой текст - удаляем объект
246
+ if (this.editingObject) {
247
+ this.emit('object:delete', { object: this.editingObject });
248
+ }
249
+ }
250
+
251
+ this.cleanupEditing();
252
+ }
253
+
254
+ /**
255
+ * Отмена редактирования
256
+ */
257
+ cancelEditing() {
258
+ if (!this.isEditing) return;
259
+
260
+ // Если это был новый объект с пустым текстом, удаляем его
261
+ if (this.editingObject && !this.editingObject.content) {
262
+ this.emit('object:delete', { object: this.editingObject });
263
+ }
264
+
265
+ this.emit('text:edit:cancel', { object: this.editingObject });
266
+ this.cleanupEditing();
267
+ }
268
+
269
+ /**
270
+ * Очистка после редактирования
271
+ */
272
+ cleanupEditing() {
273
+ this.removeTextInput();
274
+ this.isEditing = false;
275
+ this.editingObject = null;
276
+ }
277
+
278
+ /**
279
+ * Удаление HTML input
280
+ */
281
+ removeTextInput() {
282
+ if (this.textInput) {
283
+ this.textInput.remove();
284
+ this.textInput = null;
285
+ }
286
+ }
287
+
288
+ /**
289
+ * Получение контейнера для размещения input
290
+ */
291
+ getContainer() {
292
+ return this.container || document.body;
293
+ }
294
+
295
+ /**
296
+ * Установка контейнера (вызывается при активации инструмента)
297
+ */
298
+ setContainer(container) {
299
+ this.container = container;
300
+ }
301
+
302
+ /**
303
+ * Поиск текстового объекта в указанной позиции
304
+ */
305
+ getTextObjectAt(x, y) {
306
+ // TODO: Реализовать поиск текстового объекта по координатам
307
+ return null; // Временная заглушка
308
+ }
309
+
310
+ /**
311
+ * Обработка клавиш во время редактирования (глобальные)
312
+ */
313
+ handleEditingKeys(event) {
314
+ // Форматирование текста горячими клавишами
315
+ if (event.ctrlKey || event.metaKey) {
316
+ switch (event.key) {
317
+ case 'b':
318
+ this.toggleBold();
319
+ event.originalEvent.preventDefault();
320
+ break;
321
+ case 'i':
322
+ this.toggleItalic();
323
+ event.originalEvent.preventDefault();
324
+ break;
325
+ }
326
+ }
327
+ }
328
+
329
+ /**
330
+ * Переключение жирного шрифта
331
+ */
332
+ toggleBold() {
333
+ if (this.editingObject) {
334
+ const currentWeight = this.editingObject.settings.fontWeight;
335
+ this.editingObject.settings.fontWeight =
336
+ currentWeight === 'bold' ? 'normal' : 'bold';
337
+
338
+ this.updateInputStyle();
339
+ this.emit('text:format:change', {
340
+ object: this.editingObject,
341
+ property: 'fontWeight',
342
+ value: this.editingObject.settings.fontWeight
343
+ });
344
+ }
345
+ }
346
+
347
+ /**
348
+ * Переключение курсива
349
+ */
350
+ toggleItalic() {
351
+ if (this.editingObject) {
352
+ const currentStyle = this.editingObject.settings.fontStyle;
353
+ this.editingObject.settings.fontStyle =
354
+ currentStyle === 'italic' ? 'normal' : 'italic';
355
+
356
+ this.updateInputStyle();
357
+ this.emit('text:format:change', {
358
+ object: this.editingObject,
359
+ property: 'fontStyle',
360
+ value: this.editingObject.settings.fontStyle
361
+ });
362
+ }
363
+ }
364
+
365
+ /**
366
+ * Обновление стилей input в соответствии с настройками объекта
367
+ */
368
+ updateInputStyle() {
369
+ if (!this.textInput || !this.editingObject) return;
370
+
371
+ const settings = this.editingObject.settings;
372
+ Object.assign(this.textInput.style, {
373
+ fontSize: `${settings.fontSize}px`,
374
+ fontFamily: settings.fontFamily,
375
+ fontWeight: settings.fontWeight,
376
+ fontStyle: settings.fontStyle,
377
+ color: settings.color,
378
+ textAlign: settings.textAlign
379
+ });
380
+ }
381
+
382
+ /**
383
+ * Активация инструмента
384
+ */
385
+ activate(pixiApp) {
386
+ super.activate();
387
+
388
+ // Устанавливаем контейнер для размещения input
389
+ if (pixiApp && pixiApp.view && pixiApp.view.parentElement) {
390
+ this.setContainer(pixiApp.view.parentElement);
391
+ }
392
+ }
393
+
394
+ /**
395
+ * Деактивация инструмента
396
+ */
397
+ onDeactivate() {
398
+ if (this.isEditing) {
399
+ this.finishEditing();
400
+ }
401
+ }
402
+
403
+ /**
404
+ * Установка настроек текста по умолчанию
405
+ */
406
+ setDefaultTextSettings(settings) {
407
+ this.defaultTextSettings = { ...this.defaultTextSettings, ...settings };
408
+ }
409
+
410
+ /**
411
+ * Получение настроек текста по умолчанию
412
+ */
413
+ getDefaultTextSettings() {
414
+ return { ...this.defaultTextSettings };
415
+ }
416
+ }
@@ -0,0 +1,105 @@
1
+ import * as PIXI from 'pixi.js';
2
+
3
+ /**
4
+ * BoxSelectController — управление рамкой выделения и выбором по пересечению
5
+ */
6
+ export class BoxSelectController {
7
+ constructor({ app, selection, emit, setSelection, clearSelection, rectIntersectsRect }) {
8
+ this.app = app;
9
+ this.selection = selection; // SelectionModel
10
+ this.emit = emit;
11
+ this.setSelection = setSelection;
12
+ this.clearSelection = clearSelection;
13
+ this.rectIntersectsRect = rectIntersectsRect;
14
+
15
+ this.isActive = false;
16
+ this.selectionBox = null;
17
+ this.selectionGraphics = null;
18
+ this.initialSelectionBeforeBox = null;
19
+ this.isMultiSelect = false;
20
+ }
21
+
22
+ start(mouse, isMultiSelect) {
23
+ this.isActive = true;
24
+ this.isMultiSelect = !!isMultiSelect;
25
+ this.selectionBox = { startX: mouse.x, startY: mouse.y, endX: mouse.x, endY: mouse.y };
26
+ this.initialSelectionBeforeBox = this.selection.toArray();
27
+ if (!this.isMultiSelect) this.clearSelection();
28
+ if (this.app && this.app.stage) {
29
+ this.app.stage.sortableChildren = true;
30
+ this.selectionGraphics = new PIXI.Graphics();
31
+ this.selectionGraphics.zIndex = 2000;
32
+ this.selectionGraphics.name = 'selection-box';
33
+ this.app.stage.addChild(this.selectionGraphics);
34
+ }
35
+ }
36
+
37
+ update(mouse) {
38
+ if (!this.selectionBox) return;
39
+ this.selectionBox.endX = mouse.x;
40
+ this.selectionBox.endY = mouse.y;
41
+ const x = Math.min(this.selectionBox.startX, this.selectionBox.endX);
42
+ const y = Math.min(this.selectionBox.startY, this.selectionBox.endY);
43
+ const w = Math.abs(this.selectionBox.endX - this.selectionBox.startX);
44
+ const h = Math.abs(this.selectionBox.endY - this.selectionBox.startY);
45
+ if (this.selectionGraphics) {
46
+ this.selectionGraphics.clear();
47
+ this.selectionGraphics.lineStyle(1, 0x3B82F6, 1);
48
+ this.selectionGraphics.beginFill(0x3B82F6, 0.08);
49
+ this.selectionGraphics.drawRect(x, y, w, h);
50
+ this.selectionGraphics.endFill();
51
+ }
52
+ if (w >= 2 && h >= 2) {
53
+ const box = { x, y, width: w, height: h };
54
+ const request = { objects: [] };
55
+ this.emit('get:all:objects', request);
56
+ const matched = [];
57
+ for (const item of request.objects) {
58
+ if (this.rectIntersectsRect(box, item.bounds)) matched.push(item.id);
59
+ }
60
+ let newSelection;
61
+ if (this.isMultiSelect && this.initialSelectionBeforeBox) {
62
+ const base = new Set(this.initialSelectionBeforeBox);
63
+ for (const id of matched) base.add(id);
64
+ newSelection = Array.from(base);
65
+ } else {
66
+ newSelection = matched;
67
+ }
68
+ this.setSelection(newSelection);
69
+ }
70
+ }
71
+
72
+ end() {
73
+ if (!this.selectionBox) {
74
+ this.isActive = false;
75
+ return;
76
+ }
77
+ const x = Math.min(this.selectionBox.startX, this.selectionBox.endX);
78
+ const y = Math.min(this.selectionBox.startY, this.selectionBox.endY);
79
+ const w = Math.abs(this.selectionBox.endX - this.selectionBox.startX);
80
+ const h = Math.abs(this.selectionBox.endY - this.selectionBox.startY);
81
+ if (w >= 2 && h >= 2) {
82
+ const box = { x, y, width: w, height: h };
83
+ const request = { objects: [] };
84
+ this.emit('get:all:objects', request);
85
+ const matched = [];
86
+ for (const item of request.objects) {
87
+ if (this.rectIntersectsRect(box, item.bounds)) matched.push(item.id);
88
+ }
89
+ if (matched.length > 0) {
90
+ if (this.isMultiSelect) {
91
+ for (const id of matched) if (!this.selection.has(id)) this.setSelection([...this.selection.toArray(), id]);
92
+ } else {
93
+ this.setSelection(matched);
94
+ }
95
+ }
96
+ }
97
+ this.isActive = false;
98
+ this.selectionBox = null;
99
+ if (this.selectionGraphics && this.selectionGraphics.parent) this.selectionGraphics.parent.removeChild(this.selectionGraphics);
100
+ this.selectionGraphics?.destroy();
101
+ this.selectionGraphics = null;
102
+ }
103
+ }
104
+
105
+
@@ -0,0 +1,101 @@
1
+ /**
2
+ * Набор чистых функций геометрии для SelectTool и контроллеров
3
+ */
4
+ export function transformHandleType(handleType, rotationDegrees) {
5
+ let angle = rotationDegrees % 360;
6
+ if (angle < 0) angle += 360;
7
+ const rotations = Math.round(angle / 90) % 4;
8
+ if (rotations === 0) return handleType;
9
+ const transformMap = {
10
+ 'nw': ['ne', 'se', 'sw', 'nw'],
11
+ 'n': ['e', 's', 'w', 'n'],
12
+ 'ne': ['se', 'sw', 'nw', 'ne'],
13
+ 'e': ['s', 'w', 'n', 'e'],
14
+ 'se': ['sw', 'nw', 'ne', 'se'],
15
+ 's': ['w', 'n', 'e', 's'],
16
+ 'sw': ['nw', 'ne', 'se', 'sw'],
17
+ 'w': ['n', 'e', 's', 'w']
18
+ };
19
+ return transformMap[handleType] ? transformMap[handleType][rotations - 1] : handleType;
20
+ }
21
+
22
+ export function calculateNewSize(handleType, startBounds, deltaX, deltaY, maintainAspectRatio, rotationDegrees = 0) {
23
+ let newWidth = startBounds.width;
24
+ let newHeight = startBounds.height;
25
+ const transformed = transformHandleType(handleType, rotationDegrees);
26
+ switch (transformed) {
27
+ case 'nw': newWidth = startBounds.width - deltaX; newHeight = startBounds.height - deltaY; break;
28
+ case 'n': newHeight = startBounds.height - deltaY; break;
29
+ case 'ne': newWidth = startBounds.width + deltaX; newHeight = startBounds.height - deltaY; break;
30
+ case 'e': newWidth = startBounds.width + deltaX; break;
31
+ case 'se': newWidth = startBounds.width + deltaX; newHeight = startBounds.height + deltaY; break;
32
+ case 's': newHeight = startBounds.height + deltaY; break;
33
+ case 'sw': newWidth = startBounds.width - deltaX; newHeight = startBounds.height + deltaY; break;
34
+ case 'w': newWidth = startBounds.width - deltaX; break;
35
+ }
36
+ if (maintainAspectRatio) {
37
+ const ar = startBounds.width / startBounds.height;
38
+ if (['nw', 'ne', 'sw', 'se'].includes(handleType)) {
39
+ const dw = Math.abs(newWidth - startBounds.width);
40
+ const dh = Math.abs(newHeight - startBounds.height);
41
+ if (dw > dh) newHeight = newWidth / ar; else newWidth = newHeight * ar;
42
+ } else if (['e', 'w'].includes(handleType)) {
43
+ newHeight = newWidth / ar;
44
+ } else if (['n', 's'].includes(handleType)) {
45
+ newWidth = newHeight * ar;
46
+ }
47
+ }
48
+ return { width: Math.round(newWidth), height: Math.round(newHeight) };
49
+ }
50
+
51
+ export function calculatePositionOffset(handleType, startBounds, newSize, objectRotation = 0) {
52
+ const deltaWidth = newSize.width - startBounds.width;
53
+ const deltaHeight = newSize.height - startBounds.height;
54
+ let localOffsetX = 0, localOffsetY = 0;
55
+
56
+ // Позиция объекта в системе - это левый верхний угол
57
+ // При ресайзе за правые/нижние ручки - левый верхний угол остается на месте (offset = 0)
58
+ // При ресайзе за левые/верхние ручки - левый верхний угол смещается на полную величину изменения
59
+ switch (handleType) {
60
+ case 'nw':
61
+ localOffsetX = -deltaWidth; // левый край смещается влево на полную величину
62
+ localOffsetY = -deltaHeight; // верхний край смещается вверх на полную величину
63
+ break;
64
+ case 'n':
65
+ localOffsetX = 0; // горизонтально не смещается
66
+ localOffsetY = -deltaHeight; // верхний край смещается вверх на полную величину
67
+ break;
68
+ case 'ne':
69
+ localOffsetX = 0; // правый край - левый верхний угол не смещается по X
70
+ localOffsetY = -deltaHeight; // верхний край смещается вверх на полную величину
71
+ break;
72
+ case 'e':
73
+ localOffsetX = 0; // правый край - левый верхний угол не смещается
74
+ localOffsetY = 0; // вертикально не смещается
75
+ break;
76
+ case 'se':
77
+ localOffsetX = 0; // правый край - левый верхний угол не смещается по X
78
+ localOffsetY = 0; // нижний край - левый верхний угол не смещается по Y
79
+ break;
80
+ case 's':
81
+ localOffsetX = 0; // горизонтально не смещается
82
+ localOffsetY = 0; // нижний край - левый верхний угол не смещается по Y
83
+ break;
84
+ case 'sw':
85
+ localOffsetX = -deltaWidth; // левый край смещается влево на полную величину
86
+ localOffsetY = 0; // нижний край - левый верхний угол не смещается по Y
87
+ break;
88
+ case 'w':
89
+ localOffsetX = -deltaWidth; // левый край смещается влево на полную величину
90
+ localOffsetY = 0; // вертикально не смещается
91
+ break;
92
+ }
93
+ const angleRad = objectRotation * Math.PI / 180;
94
+ const cos = Math.cos(angleRad);
95
+ const sin = Math.sin(angleRad);
96
+ const worldOffsetX = localOffsetX * cos - localOffsetY * sin;
97
+ const worldOffsetY = localOffsetX * sin + localOffsetY * cos;
98
+ return { x: worldOffsetX, y: worldOffsetY };
99
+ }
100
+
101
+
@@ -0,0 +1,61 @@
1
+ /**
2
+ * GroupDragController — перетаскивание группы объектов + Alt-клонирование группы
3
+ */
4
+ export class GroupDragController {
5
+ constructor({ emit, selection, updateGroupBoundsByTopLeft }) {
6
+ this.emit = emit;
7
+ this.selection = selection;
8
+ this.updateGroupBoundsByTopLeft = updateGroupBoundsByTopLeft; // (topLeft)=>void
9
+
10
+ this.isActive = false;
11
+ this.groupStartBounds = null;
12
+ this.groupDragOffset = null;
13
+ this.isAltGroupCloneMode = false;
14
+ this.groupClonePending = false;
15
+ this.groupCloneOriginalIds = [];
16
+ }
17
+
18
+ start(groupBounds, mouse) {
19
+ this.isActive = true;
20
+ this.groupStartBounds = groupBounds;
21
+ this.groupDragOffset = { x: mouse.x - groupBounds.x, y: mouse.y - groupBounds.y };
22
+ }
23
+
24
+ update(event) {
25
+ if (!this.isActive || !this.groupStartBounds || !this.groupDragOffset) return;
26
+
27
+ // Alt-клонирование группы на лету
28
+ if (event.originalEvent && event.originalEvent.altKey && !this.isAltGroupCloneMode && !this.groupClonePending) {
29
+ this.isAltGroupCloneMode = true;
30
+ this.groupClonePending = true;
31
+ this.groupCloneOriginalIds = this.selection.toArray();
32
+ this.emit('group:duplicate:request', { objects: this.groupCloneOriginalIds });
33
+ return;
34
+ }
35
+
36
+ const newTopLeft = { x: event.x - this.groupDragOffset.x, y: event.y - this.groupDragOffset.y };
37
+ const delta = { dx: newTopLeft.x - this.groupStartBounds.x, dy: newTopLeft.y - this.groupStartBounds.y };
38
+ this.emit('group:drag:update', { objects: this.selection.toArray(), delta });
39
+ if (this.updateGroupBoundsByTopLeft) this.updateGroupBoundsByTopLeft(newTopLeft);
40
+ }
41
+
42
+ end() {
43
+ if (!this.isActive) return;
44
+ this.emit('group:drag:end', { objects: this.selection.toArray() });
45
+ this.isActive = false;
46
+ this.groupStartBounds = null;
47
+ this.groupDragOffset = null;
48
+ this.isAltGroupCloneMode = false;
49
+ this.groupClonePending = false;
50
+ this.groupCloneOriginalIds = [];
51
+ }
52
+
53
+ onGroupDuplicateReady(map) {
54
+ if (!this.groupClonePending) return;
55
+ // Перенос ответственности за обработку idMap оставляем SelectTool
56
+ this.groupClonePending = false;
57
+ this.isAltGroupCloneMode = false;
58
+ }
59
+ }
60
+
61
+