@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,1005 @@
1
+ import { BaseTool } from '../BaseTool.js';
2
+ import { Events } from '../../core/events/Events.js';
3
+ import * as PIXI from 'pixi.js';
4
+
5
+ /**
6
+ * Инструмент одноразового размещения объекта по клику на холст
7
+ * Логика: выбираем инструмент/вариант на тулбаре → кликаем на холст → объект создаётся → возврат к Select
8
+ */
9
+ export class PlacementTool extends BaseTool {
10
+ constructor(eventBus, core = null) {
11
+ super('place', eventBus);
12
+ this.cursor = 'crosshair';
13
+ this.hotkey = null;
14
+ this.app = null;
15
+ this.world = null;
16
+ this.pending = null; // { type, properties }
17
+ this.core = core;
18
+
19
+ // Состояние выбранного файла
20
+ this.selectedFile = null; // { file, fileName, fileSize, mimeType, properties }
21
+ // Состояние выбранного изображения
22
+ this.selectedImage = null; // { file, fileName, fileSize, mimeType, properties }
23
+ this.ghostContainer = null; // Контейнер для "призрака" файла, изображения, текста, записки, эмоджи, фрейма или фигур
24
+
25
+ if (this.eventBus) {
26
+ this.eventBus.on(Events.Place.Set, (cfg) => {
27
+ this.pending = cfg ? { ...cfg } : null;
28
+
29
+ // Показываем призрак для текста, записки, эмоджи, фрейма или фигур, если они активны
30
+ if (this.pending && this.app && this.world) {
31
+ if (this.pending.type === 'text') {
32
+ this.showTextGhost();
33
+ } else if (this.pending.type === 'note') {
34
+ this.showNoteGhost();
35
+ } else if (this.pending.type === 'emoji') {
36
+ this.showEmojiGhost();
37
+ } else if (this.pending.type === 'frame') {
38
+ this.showFrameGhost();
39
+ } else if (this.pending.type === 'shape') {
40
+ this.showShapeGhost();
41
+ }
42
+ }
43
+ });
44
+
45
+ // Сброс pending при явном выборе select-инструмента
46
+ this.eventBus.on(Events.Tool.Activated, ({ tool }) => {
47
+ if (tool === 'select') {
48
+ this.pending = null;
49
+ this.selectedFile = null;
50
+ this.selectedImage = null;
51
+ this.hideGhost();
52
+ }
53
+ });
54
+
55
+ // Обработка выбора файла
56
+ this.eventBus.on(Events.Place.FileSelected, (fileData) => {
57
+ this.selectedFile = fileData;
58
+ this.selectedImage = null;
59
+ this.showFileGhost();
60
+ });
61
+
62
+ // Обработка отмены выбора файла
63
+ this.eventBus.on(Events.Place.FileCanceled, () => {
64
+ this.selectedFile = null;
65
+ this.hideGhost();
66
+ // Возвращаемся к инструменту выделения
67
+ this.eventBus.emit(Events.Keyboard.ToolSelect, { tool: 'select' });
68
+ });
69
+
70
+ // Обработка выбора изображения
71
+ this.eventBus.on(Events.Place.ImageSelected, (imageData) => {
72
+ this.selectedImage = imageData;
73
+ this.selectedFile = null;
74
+ this.showImageGhost();
75
+ });
76
+
77
+ // Обработка отмены выбора изображения
78
+ this.eventBus.on(Events.Place.ImageCanceled, () => {
79
+ this.selectedImage = null;
80
+ this.hideGhost();
81
+ // Возвращаемся к инструменту выделения
82
+ this.eventBus.emit(Events.Keyboard.ToolSelect, { tool: 'select' });
83
+ });
84
+ }
85
+ }
86
+
87
+ activate(app) {
88
+ super.activate();
89
+ this.app = app;
90
+ this.world = this._getWorldLayer();
91
+ // Курсор указывает на размещение (прицел)
92
+ if (this.app && this.app.view) {
93
+ this.app.view.style.cursor = 'crosshair';
94
+ // Добавляем обработчик движения мыши для "призрака"
95
+ this.app.view.addEventListener('mousemove', this._onMouseMove.bind(this));
96
+ }
97
+
98
+ // Если есть выбранный файл или изображение, показываем призрак
99
+ if (this.selectedFile) {
100
+ this.showFileGhost();
101
+ } else if (this.selectedImage) {
102
+ this.showImageGhost();
103
+ } else if (this.pending) {
104
+ if (this.pending.type === 'text') {
105
+ this.showTextGhost();
106
+ } else if (this.pending.type === 'note') {
107
+ this.showNoteGhost();
108
+ } else if (this.pending.type === 'emoji') {
109
+ this.showEmojiGhost();
110
+ } else if (this.pending.type === 'frame') {
111
+ this.showFrameGhost();
112
+ } else if (this.pending.type === 'shape') {
113
+ this.showShapeGhost();
114
+ }
115
+ }
116
+ }
117
+
118
+ deactivate() {
119
+ super.deactivate();
120
+ if (this.app && this.app.view) {
121
+ this.app.view.style.cursor = '';
122
+ // Убираем обработчик движения мыши
123
+ this.app.view.removeEventListener('mousemove', this._onMouseMove.bind(this));
124
+ }
125
+ this.hideGhost();
126
+ this.app = null;
127
+ this.world = null;
128
+ }
129
+
130
+ onMouseDown(event) {
131
+ super.onMouseDown(event);
132
+
133
+ // Если есть выбранный файл, размещаем его
134
+ if (this.selectedFile) {
135
+ this.placeSelectedFile(event);
136
+ return;
137
+ }
138
+
139
+ // Если есть выбранное изображение, размещаем его
140
+ if (this.selectedImage) {
141
+ this.placeSelectedImage(event);
142
+ return;
143
+ }
144
+
145
+ if (!this.pending) return;
146
+
147
+ const worldPoint = this._toWorld(event.x, event.y);
148
+ const halfW = (this.pending.size?.width ?? 100) / 2;
149
+ const halfH = (this.pending.size?.height ?? 100) / 2;
150
+ const position = { x: Math.round(worldPoint.x - halfW), y: Math.round(worldPoint.y - halfH) };
151
+
152
+ const props = this.pending.properties || {};
153
+ const isTextWithEditing = this.pending.type === 'text' && props.editOnCreate;
154
+ const isImage = this.pending.type === 'image';
155
+ const isFile = this.pending.type === 'file';
156
+ const presetSize = {
157
+ width: (this.pending.size && this.pending.size.width) ? this.pending.size.width : 200,
158
+ height: (this.pending.size && this.pending.size.height) ? this.pending.size.height : 150,
159
+ };
160
+
161
+ if (isTextWithEditing) {
162
+ // Слушаем событие создания объекта, чтобы получить его ID
163
+ const handleObjectCreated = (objectData) => {
164
+ if (objectData.type === 'text') {
165
+ // Убираем слушатель, чтобы не реагировать на другие объекты
166
+ this.eventBus.off('object:created', handleObjectCreated);
167
+
168
+ // Переключаемся на select
169
+ this.eventBus.emit(Events.Keyboard.ToolSelect, { tool: 'select' });
170
+
171
+
172
+
173
+ // Даем небольшую задержку, чтобы HTML-элемент успел создаться
174
+ setTimeout(() => {
175
+ // Открываем редактор с правильным ID и данными объекта
176
+ this.eventBus.emit(Events.Tool.ObjectEdit, {
177
+ object: {
178
+ id: objectData.id,
179
+ type: 'text',
180
+ position: objectData.position,
181
+ properties: { fontSize: props.fontSize || 18, content: '' }
182
+ },
183
+ create: true // Это создание нового объекта с редактированием
184
+ });
185
+ }, 50); // 50ms задержка
186
+ }
187
+ };
188
+
189
+ // Подписываемся на событие создания объекта
190
+ this.eventBus.on('object:created', handleObjectCreated);
191
+
192
+ // Создаем объект через обычный канал
193
+ this.eventBus.emit(Events.UI.ToolbarAction, {
194
+ type: 'text',
195
+ id: 'text',
196
+ position,
197
+ properties: {
198
+ fontSize: props.fontSize || 18,
199
+ content: '',
200
+ fontFamily: 'Arial, sans-serif', // Дефолтный шрифт
201
+ color: '#000000', // Дефолтный цвет (черный)
202
+ backgroundColor: 'transparent' // Дефолтный фон (прозрачный)
203
+ }
204
+ });
205
+ } else if (isImage && props.selectFileOnPlace) {
206
+ const input = document.createElement('input');
207
+ input.type = 'file';
208
+ input.accept = 'image/*';
209
+ input.style.display = 'none';
210
+ document.body.appendChild(input);
211
+ input.addEventListener('change', async () => {
212
+ try {
213
+ const file = input.files && input.files[0];
214
+ if (!file) return;
215
+ // Читаем как DataURL, чтобы не использовать blob: URL (устраняем ERR_FILE_NOT_FOUND)
216
+ // Загружаем файл на сервер
217
+ try {
218
+ const uploadResult = await this.core.imageUploadService.uploadImage(file, file.name);
219
+
220
+ // Вычисляем целевой размер
221
+ const natW = uploadResult.width || 1;
222
+ const natH = uploadResult.height || 1;
223
+ const targetW = 300; // дефолтная ширина
224
+ const targetH = Math.max(1, Math.round(natH * (targetW / natW)));
225
+
226
+ this.eventBus.emit(Events.UI.ToolbarAction, {
227
+ type: 'image',
228
+ id: 'image',
229
+ position,
230
+ properties: {
231
+ src: uploadResult.url,
232
+ name: uploadResult.name,
233
+ width: targetW,
234
+ height: targetH
235
+ },
236
+ imageId: uploadResult.id // Сохраняем ID изображения
237
+ });
238
+ } catch (error) {
239
+ console.error('Ошибка загрузки изображения:', error);
240
+ alert('Ошибка загрузки изображения: ' + error.message);
241
+ }
242
+ } finally {
243
+ input.remove();
244
+ }
245
+ }, { once: true });
246
+ input.click();
247
+ } else if (isFile && props.selectFileOnPlace) {
248
+ // Создаем диалог выбора файла
249
+ const input = document.createElement('input');
250
+ input.type = 'file';
251
+ input.accept = '*/*'; // Принимаем любые файлы
252
+ input.style.display = 'none';
253
+ document.body.appendChild(input);
254
+ input.addEventListener('change', async () => {
255
+ try {
256
+ const file = input.files && input.files[0];
257
+ if (!file) return;
258
+
259
+ // Загружаем файл на сервер
260
+ try {
261
+ const uploadResult = await this.core.fileUploadService.uploadFile(file, file.name);
262
+
263
+ // Создаем объект файла с данными с сервера
264
+ this.eventBus.emit(Events.UI.ToolbarAction, {
265
+ type: 'file',
266
+ id: 'file',
267
+ position,
268
+ properties: {
269
+ fileName: uploadResult.name,
270
+ fileSize: uploadResult.size,
271
+ mimeType: uploadResult.mimeType,
272
+ formattedSize: uploadResult.formattedSize,
273
+ url: uploadResult.url,
274
+ width: props.width || 120,
275
+ height: props.height || 140
276
+ },
277
+ fileId: uploadResult.id // Сохраняем ID файла
278
+ });
279
+
280
+ // Возвращаемся к инструменту выделения после создания файла
281
+ this.eventBus.emit(Events.Keyboard.ToolSelect, { tool: 'select' });
282
+ } catch (uploadError) {
283
+ console.error('Ошибка загрузки файла на сервер:', uploadError);
284
+ // Fallback: создаем объект файла с локальными данными
285
+ const fileName = file.name;
286
+ const fileSize = file.size;
287
+ const mimeType = file.type;
288
+
289
+ this.eventBus.emit(Events.UI.ToolbarAction, {
290
+ type: 'file',
291
+ id: 'file',
292
+ position,
293
+ properties: {
294
+ fileName: fileName,
295
+ fileSize: fileSize,
296
+ mimeType: mimeType,
297
+ width: props.width || 120,
298
+ height: props.height || 140
299
+ }
300
+ });
301
+
302
+ // Возвращаемся к инструменту выделения
303
+ this.eventBus.emit(Events.Keyboard.ToolSelect, { tool: 'select' });
304
+
305
+ // Показываем предупреждение пользователю
306
+ alert('Ошибка загрузки файла на сервер. Файл добавлен локально.');
307
+ }
308
+ } catch (error) {
309
+ console.error('Ошибка при выборе файла:', error);
310
+ alert('Ошибка при выборе файла: ' + error.message);
311
+ } finally {
312
+ input.remove();
313
+ }
314
+ }, { once: true });
315
+ input.click();
316
+ } else {
317
+ // Обычное размещение через общий канал
318
+ this.eventBus.emit(Events.UI.ToolbarAction, {
319
+ type: this.pending.type,
320
+ id: this.pending.type,
321
+ position,
322
+ properties: props
323
+ });
324
+ }
325
+
326
+ // Сбрасываем pending и возвращаем стандартное поведение
327
+ this.pending = null;
328
+ this.hideGhost(); // Скрываем призрак после размещения
329
+ if (!isTextWithEditing && !(isFile && props.selectFileOnPlace)) {
330
+ this.eventBus.emit(Events.Keyboard.ToolSelect, { tool: 'select' });
331
+ }
332
+ }
333
+
334
+ _toWorld(x, y) {
335
+ if (!this.world) return { x, y };
336
+ const global = new PIXI.Point(x, y);
337
+ const local = this.world.toLocal(global);
338
+ return { x: local.x, y: local.y };
339
+ }
340
+
341
+ _getWorldLayer() {
342
+ if (!this.app || !this.app.stage) return null;
343
+ const world = this.app.stage.getChildByName && this.app.stage.getChildByName('worldLayer');
344
+ return world || this.app.stage;
345
+ }
346
+
347
+ /**
348
+ * Обработчик движения мыши для обновления позиции "призрака"
349
+ */
350
+ _onMouseMove(event) {
351
+ if ((this.selectedFile || this.selectedImage || this.pending) && this.ghostContainer) {
352
+ const worldPoint = this._toWorld(event.offsetX, event.offsetY);
353
+ this.updateGhostPosition(worldPoint.x, worldPoint.y);
354
+ }
355
+ }
356
+
357
+ /**
358
+ * Показать "призрак" файла
359
+ */
360
+ showFileGhost() {
361
+ if (!this.selectedFile || !this.world) return;
362
+
363
+ this.hideGhost(); // Сначала убираем старый призрак
364
+
365
+ // Создаем контейнер для призрака
366
+ this.ghostContainer = new PIXI.Container();
367
+ this.ghostContainer.alpha = 0.6; // Полупрозрачность
368
+
369
+ // Создаем визуальное представление файла (аналогично FileObject)
370
+ const graphics = new PIXI.Graphics();
371
+ const width = this.selectedFile.properties.width || 120;
372
+ const height = this.selectedFile.properties.height || 140;
373
+
374
+ // Фон файла
375
+ graphics.beginFill(0xF8F9FA, 0.8);
376
+ graphics.lineStyle(2, 0xDEE2E6, 0.8);
377
+ graphics.drawRoundedRect(0, 0, width, height, 8);
378
+ graphics.endFill();
379
+
380
+ // Иконка файла (простой прямоугольник)
381
+ graphics.beginFill(0x6C757D, 0.6);
382
+ graphics.drawRoundedRect(width * 0.2, height * 0.15, width * 0.6, height * 0.3, 4);
383
+ graphics.endFill();
384
+
385
+ // Текст названия файла
386
+ const fileName = this.selectedFile.fileName || 'File';
387
+ const displayName = fileName.length > 15 ? fileName.substring(0, 12) + '...' : fileName;
388
+
389
+ const nameText = new PIXI.Text(displayName, {
390
+ fontFamily: 'Arial, sans-serif',
391
+ fontSize: 12,
392
+ fill: 0x495057,
393
+ align: 'center',
394
+ wordWrap: true,
395
+ wordWrapWidth: width - 10
396
+ });
397
+
398
+ nameText.x = (width - nameText.width) / 2;
399
+ nameText.y = height * 0.55;
400
+
401
+ this.ghostContainer.addChild(graphics);
402
+ this.ghostContainer.addChild(nameText);
403
+
404
+ // Центрируем контейнер относительно курсора
405
+ this.ghostContainer.pivot.x = width / 2;
406
+ this.ghostContainer.pivot.y = height / 2;
407
+
408
+ this.world.addChild(this.ghostContainer);
409
+ }
410
+
411
+ /**
412
+ * Скрыть "призрак" файла
413
+ */
414
+ hideGhost() {
415
+ if (this.ghostContainer && this.world) {
416
+ this.world.removeChild(this.ghostContainer);
417
+ this.ghostContainer.destroy();
418
+ this.ghostContainer = null;
419
+ }
420
+ }
421
+
422
+ /**
423
+ * Обновить позицию "призрака" файла
424
+ */
425
+ updateGhostPosition(x, y) {
426
+ if (this.ghostContainer) {
427
+ this.ghostContainer.x = x;
428
+ this.ghostContainer.y = y;
429
+ }
430
+ }
431
+
432
+ /**
433
+ * Разместить выбранный файл на холсте
434
+ */
435
+ async placeSelectedFile(event) {
436
+ if (!this.selectedFile) return;
437
+
438
+ const worldPoint = this._toWorld(event.x, event.y);
439
+ const props = this.selectedFile.properties;
440
+ const halfW = (props.width || 120) / 2;
441
+ const halfH = (props.height || 140) / 2;
442
+ const position = {
443
+ x: Math.round(worldPoint.x - halfW),
444
+ y: Math.round(worldPoint.y - halfH)
445
+ };
446
+
447
+ try {
448
+ // Загружаем файл на сервер
449
+ const uploadResult = await this.core.fileUploadService.uploadFile(
450
+ this.selectedFile.file,
451
+ this.selectedFile.fileName
452
+ );
453
+
454
+ // Создаем объект файла с данными с сервера
455
+ this.eventBus.emit(Events.UI.ToolbarAction, {
456
+ type: 'file',
457
+ id: 'file',
458
+ position,
459
+ properties: {
460
+ fileName: uploadResult.name,
461
+ fileSize: uploadResult.size,
462
+ mimeType: uploadResult.mimeType,
463
+ formattedSize: uploadResult.formattedSize,
464
+ url: uploadResult.url,
465
+ width: props.width || 120,
466
+ height: props.height || 140
467
+ },
468
+ fileId: uploadResult.id // Сохраняем ID файла
469
+ });
470
+
471
+ } catch (uploadError) {
472
+ console.error('Ошибка загрузки файла на сервер:', uploadError);
473
+ // Fallback: создаем объект файла с локальными данными
474
+ this.eventBus.emit(Events.UI.ToolbarAction, {
475
+ type: 'file',
476
+ id: 'file',
477
+ position,
478
+ properties: {
479
+ fileName: this.selectedFile.fileName,
480
+ fileSize: this.selectedFile.fileSize,
481
+ mimeType: this.selectedFile.mimeType,
482
+ width: props.width || 120,
483
+ height: props.height || 140
484
+ }
485
+ });
486
+
487
+ // Показываем предупреждение пользователю
488
+ alert('Ошибка загрузки файла на сервер. Файл добавлен локально.');
489
+ }
490
+
491
+ // Убираем призрак и возвращаемся к инструменту выделения
492
+ this.selectedFile = null;
493
+ this.hideGhost();
494
+ this.eventBus.emit(Events.Keyboard.ToolSelect, { tool: 'select' });
495
+ }
496
+
497
+ /**
498
+ * Показать "призрак" изображения
499
+ */
500
+ async showImageGhost() {
501
+ if (!this.selectedImage || !this.world) return;
502
+
503
+ this.hideGhost(); // Сначала убираем старый призрак
504
+
505
+ // Создаем контейнер для призрака
506
+ this.ghostContainer = new PIXI.Container();
507
+ this.ghostContainer.alpha = 0.6; // Полупрозрачность
508
+
509
+ // Размеры призрака
510
+ const maxWidth = this.selectedImage.properties.width || 300;
511
+ const maxHeight = this.selectedImage.properties.height || 200;
512
+
513
+ try {
514
+ // Создаем превью изображения
515
+ const imageUrl = URL.createObjectURL(this.selectedImage.file);
516
+ const texture = await PIXI.Texture.fromURL(imageUrl);
517
+
518
+ // Вычисляем пропорциональные размеры
519
+ const imageAspect = texture.width / texture.height;
520
+ let width = maxWidth;
521
+ let height = maxWidth / imageAspect;
522
+
523
+ if (height > maxHeight) {
524
+ height = maxHeight;
525
+ width = maxHeight * imageAspect;
526
+ }
527
+
528
+ // Создаем спрайт изображения
529
+ const sprite = new PIXI.Sprite(texture);
530
+ sprite.width = width;
531
+ sprite.height = height;
532
+
533
+ // Рамка вокруг изображения
534
+ const border = new PIXI.Graphics();
535
+ border.lineStyle(2, 0xDEE2E6, 0.8);
536
+ border.drawRoundedRect(-2, -2, width + 4, height + 4, 4);
537
+
538
+ this.ghostContainer.addChild(border);
539
+ this.ghostContainer.addChild(sprite);
540
+
541
+ // Центрируем контейнер относительно курсора
542
+ this.ghostContainer.pivot.x = width / 2;
543
+ this.ghostContainer.pivot.y = height / 2;
544
+
545
+ // Освобождаем URL
546
+ URL.revokeObjectURL(imageUrl);
547
+
548
+ } catch (error) {
549
+ console.warn('Не удалось загрузить превью изображения, показываем заглушку:', error);
550
+
551
+ // Fallback: простой прямоугольник-заглушка
552
+ const graphics = new PIXI.Graphics();
553
+ graphics.beginFill(0xF8F9FA, 0.8);
554
+ graphics.lineStyle(2, 0xDEE2E6, 0.8);
555
+ graphics.drawRoundedRect(0, 0, maxWidth, maxHeight, 8);
556
+ graphics.endFill();
557
+
558
+ // Иконка изображения
559
+ graphics.beginFill(0x6C757D, 0.6);
560
+ graphics.drawRoundedRect(maxWidth * 0.2, maxHeight * 0.15, maxWidth * 0.6, maxHeight * 0.3, 4);
561
+ graphics.endFill();
562
+
563
+ // Текст названия файла
564
+ const fileName = this.selectedImage.fileName || 'Image';
565
+ const displayName = fileName.length > 20 ? fileName.substring(0, 17) + '...' : fileName;
566
+
567
+ const nameText = new PIXI.Text(displayName, {
568
+ fontFamily: 'Arial, sans-serif',
569
+ fontSize: 12,
570
+ fill: 0x495057,
571
+ align: 'center',
572
+ wordWrap: true,
573
+ wordWrapWidth: maxWidth - 10
574
+ });
575
+
576
+ nameText.x = (maxWidth - nameText.width) / 2;
577
+ nameText.y = maxHeight * 0.55;
578
+
579
+ this.ghostContainer.addChild(graphics);
580
+ this.ghostContainer.addChild(nameText);
581
+
582
+ // Центрируем контейнер относительно курсора
583
+ this.ghostContainer.pivot.x = maxWidth / 2;
584
+ this.ghostContainer.pivot.y = maxHeight / 2;
585
+ }
586
+
587
+ this.world.addChild(this.ghostContainer);
588
+ }
589
+
590
+ /**
591
+ * Показать "призрак" текста
592
+ */
593
+ showTextGhost() {
594
+ if (!this.pending || this.pending.type !== 'text' || !this.world) return;
595
+
596
+ this.hideGhost(); // Сначала убираем старый призрак
597
+
598
+ // Создаем контейнер для призрака
599
+ this.ghostContainer = new PIXI.Container();
600
+ this.ghostContainer.alpha = 0.6; // Полупрозрачность
601
+
602
+ // Размеры призрака текста
603
+ const fontSize = this.pending.properties?.fontSize || 18;
604
+ const width = 120;
605
+ const height = fontSize + 20; // Высота зависит от размера шрифта
606
+
607
+ // Фон для текста (полупрозрачный прямоугольник)
608
+ const background = new PIXI.Graphics();
609
+ background.beginFill(0xFFFFFF, 0.8);
610
+ background.lineStyle(1, 0x007BFF, 0.8);
611
+ background.drawRoundedRect(0, 0, width, height, 4);
612
+ background.endFill();
613
+
614
+ // Текст-заглушка
615
+ const placeholderText = new PIXI.Text('Текст', {
616
+ fontFamily: 'Arial, sans-serif',
617
+ fontSize: fontSize,
618
+ fill: 0x6C757D,
619
+ align: 'left'
620
+ });
621
+
622
+ placeholderText.x = 8;
623
+ placeholderText.y = (height - placeholderText.height) / 2;
624
+
625
+ // Иконка курсора (маленькая вертикальная линия)
626
+ const cursor = new PIXI.Graphics();
627
+ cursor.lineStyle(2, 0x007BFF, 0.8);
628
+ cursor.moveTo(placeholderText.x + placeholderText.width + 4, placeholderText.y);
629
+ cursor.lineTo(placeholderText.x + placeholderText.width + 4, placeholderText.y + placeholderText.height);
630
+
631
+ this.ghostContainer.addChild(background);
632
+ this.ghostContainer.addChild(placeholderText);
633
+ this.ghostContainer.addChild(cursor);
634
+
635
+ // Центрируем контейнер относительно курсора
636
+ this.ghostContainer.pivot.x = width / 2;
637
+ this.ghostContainer.pivot.y = height / 2;
638
+
639
+ this.world.addChild(this.ghostContainer);
640
+ }
641
+
642
+ /**
643
+ * Показать "призрак" записки
644
+ */
645
+ showNoteGhost() {
646
+ if (!this.pending || this.pending.type !== 'note' || !this.world) return;
647
+
648
+ this.hideGhost(); // Сначала убираем старый призрак
649
+
650
+ // Создаем контейнер для призрака
651
+ this.ghostContainer = new PIXI.Container();
652
+ this.ghostContainer.alpha = 0.6; // Полупрозрачность
653
+
654
+ // Размеры призрака записки (из настроек NoteObject)
655
+ const width = this.pending.properties?.width || 160;
656
+ const height = this.pending.properties?.height || 100;
657
+ const fontSize = this.pending.properties?.fontSize || 16;
658
+ const content = this.pending.properties?.content || 'Новая записка';
659
+
660
+ // Фон записки (как в NoteObject)
661
+ const background = new PIXI.Graphics();
662
+ background.beginFill(0xFFF9C4, 0.8); // Светло-желтый с прозрачностью
663
+ background.lineStyle(2, 0xF9A825, 0.8); // Золотистая граница
664
+ background.drawRoundedRect(0, 0, width, height, 8);
665
+ background.endFill();
666
+
667
+ // Добавляем небольшую тень для реалистичности
668
+ const shadow = new PIXI.Graphics();
669
+ shadow.beginFill(0x000000, 0.1);
670
+ shadow.drawRoundedRect(2, 2, width, height, 8);
671
+ shadow.endFill();
672
+
673
+ // Текст записки
674
+ const noteText = new PIXI.Text(content, {
675
+ fontFamily: 'system-ui, -apple-system, Segoe UI, Roboto, Arial, sans-serif',
676
+ fontSize: fontSize,
677
+ fill: 0x1A1A1A, // Темный цвет как в NoteObject
678
+ align: 'center',
679
+ wordWrap: true,
680
+ wordWrapWidth: width - 16, // Отступы по 8px с каждой стороны
681
+ lineHeight: fontSize * 1.2
682
+ });
683
+
684
+ // Центрируем текст в записке
685
+ noteText.x = (width - noteText.width) / 2;
686
+ noteText.y = (height - noteText.height) / 2;
687
+
688
+ // Добавляем элементы в правильном порядке
689
+ this.ghostContainer.addChild(shadow);
690
+ this.ghostContainer.addChild(background);
691
+ this.ghostContainer.addChild(noteText);
692
+
693
+ // Центрируем контейнер относительно курсора
694
+ this.ghostContainer.pivot.x = width / 2;
695
+ this.ghostContainer.pivot.y = height / 2;
696
+
697
+ this.world.addChild(this.ghostContainer);
698
+ }
699
+
700
+ /**
701
+ * Показать "призрак" эмоджи
702
+ */
703
+ showEmojiGhost() {
704
+ if (!this.pending || this.pending.type !== 'emoji' || !this.world) return;
705
+
706
+ this.hideGhost(); // Сначала убираем старый призрак
707
+
708
+ // Создаем контейнер для призрака
709
+ this.ghostContainer = new PIXI.Container();
710
+ this.ghostContainer.alpha = 0.7; // Немного менее прозрачный для эмоджи
711
+
712
+ // Получаем параметры эмоджи из pending
713
+ const content = this.pending.properties?.content || '🙂';
714
+ const fontSize = this.pending.properties?.fontSize || 48;
715
+ const width = this.pending.properties?.width || fontSize;
716
+ const height = this.pending.properties?.height || fontSize;
717
+
718
+ // Создаем эмоджи текст (как в EmojiObject)
719
+ const emojiText = new PIXI.Text(content, {
720
+ fontFamily: 'Segoe UI Emoji, Apple Color Emoji, Noto Color Emoji, Arial',
721
+ fontSize: fontSize
722
+ });
723
+
724
+ // Устанавливаем якорь в левом верхнем углу (как в EmojiObject)
725
+ if (typeof emojiText.anchor?.set === 'function') {
726
+ emojiText.anchor.set(0, 0);
727
+ }
728
+
729
+ // Получаем базовые размеры для масштабирования
730
+ const bounds = emojiText.getLocalBounds();
731
+ const baseW = Math.max(1, bounds.width || 1);
732
+ const baseH = Math.max(1, bounds.height || 1);
733
+
734
+ // Применяем равномерное масштабирование для подгонки под целевые размеры
735
+ const scaleX = width / baseW;
736
+ const scaleY = height / baseH;
737
+ const scale = Math.min(scaleX, scaleY); // Равномерное масштабирование
738
+
739
+ emojiText.scale.set(scale, scale);
740
+
741
+ // Добавляем лёгкий фон для лучшей видимости призрака
742
+ const background = new PIXI.Graphics();
743
+ background.beginFill(0xFFFFFF, 0.3); // Полупрозрачный белый фон
744
+ background.lineStyle(1, 0xDDDDDD, 0.5); // Тонкая граница
745
+ background.drawRoundedRect(-4, -4, width + 8, height + 8, 4);
746
+ background.endFill();
747
+
748
+ this.ghostContainer.addChild(background);
749
+ this.ghostContainer.addChild(emojiText);
750
+
751
+ // Центрируем контейнер относительно курсора
752
+ this.ghostContainer.pivot.x = width / 2;
753
+ this.ghostContainer.pivot.y = height / 2;
754
+
755
+ this.world.addChild(this.ghostContainer);
756
+ }
757
+
758
+ /**
759
+ * Показать "призрак" фрейма
760
+ */
761
+ showFrameGhost() {
762
+ if (!this.pending || this.pending.type !== 'frame' || !this.world) return;
763
+
764
+ this.hideGhost(); // Сначала убираем старый призрак
765
+
766
+ // Создаем контейнер для призрака
767
+ this.ghostContainer = new PIXI.Container();
768
+ this.ghostContainer.alpha = 0.6; // Полупрозрачность
769
+
770
+ // Получаем параметры фрейма из pending
771
+ const width = this.pending.properties?.width || 200;
772
+ const height = this.pending.properties?.height || 300;
773
+ const fillColor = this.pending.properties?.fillColor || 0xFFFFFF;
774
+ const borderColor = this.pending.properties?.borderColor || 0x333333;
775
+ const title = this.pending.properties?.title || 'Новый';
776
+
777
+ // Создаем фон фрейма (как в FrameObject)
778
+ const frameGraphics = new PIXI.Graphics();
779
+ frameGraphics.beginFill(fillColor, 0.8); // Полупрозрачная заливка
780
+ frameGraphics.lineStyle(2, borderColor, 0.8); // Граница
781
+ frameGraphics.drawRect(0, 0, width, height);
782
+ frameGraphics.endFill();
783
+
784
+ // Создаем заголовок фрейма (как в FrameObject)
785
+ const titleText = new PIXI.Text(title, {
786
+ fontFamily: 'Arial, sans-serif',
787
+ fontSize: 14,
788
+ fill: 0x333333,
789
+ fontWeight: 'bold'
790
+ });
791
+ titleText.anchor.set(0, 1); // Левый нижний угол текста
792
+ titleText.y = -5; // Немного выше фрейма
793
+
794
+ // Добавляем пунктирную рамку для лучшей видимости призрака
795
+ const dashedBorder = new PIXI.Graphics();
796
+ dashedBorder.lineStyle(1, 0x007BFF, 0.6);
797
+ // Создаем пунктирную линию вручную
798
+ for (let i = 0; i <= width; i += 10) {
799
+ if ((i / 10) % 2 === 0) {
800
+ dashedBorder.moveTo(i, -2);
801
+ dashedBorder.lineTo(Math.min(i + 5, width), -2);
802
+ }
803
+ }
804
+ for (let i = 0; i <= height; i += 10) {
805
+ if ((i / 10) % 2 === 0) {
806
+ dashedBorder.moveTo(-2, i);
807
+ dashedBorder.lineTo(-2, Math.min(i + 5, height));
808
+ }
809
+ }
810
+ for (let i = 0; i <= width; i += 10) {
811
+ if ((i / 10) % 2 === 0) {
812
+ dashedBorder.moveTo(i, height + 2);
813
+ dashedBorder.lineTo(Math.min(i + 5, width), height + 2);
814
+ }
815
+ }
816
+ for (let i = 0; i <= height; i += 10) {
817
+ if ((i / 10) % 2 === 0) {
818
+ dashedBorder.moveTo(width + 2, i);
819
+ dashedBorder.lineTo(width + 2, Math.min(i + 5, height));
820
+ }
821
+ }
822
+
823
+ this.ghostContainer.addChild(frameGraphics);
824
+ this.ghostContainer.addChild(titleText);
825
+ this.ghostContainer.addChild(dashedBorder);
826
+
827
+ // Центрируем контейнер относительно курсора
828
+ this.ghostContainer.pivot.x = width / 2;
829
+ this.ghostContainer.pivot.y = height / 2;
830
+
831
+ this.world.addChild(this.ghostContainer);
832
+ }
833
+
834
+ /**
835
+ * Показать "призрак" фигуры
836
+ */
837
+ showShapeGhost() {
838
+ if (!this.pending || this.pending.type !== 'shape' || !this.world) return;
839
+
840
+ this.hideGhost(); // Сначала убираем старый призрак
841
+
842
+ // Создаем контейнер для призрака
843
+ this.ghostContainer = new PIXI.Container();
844
+ this.ghostContainer.alpha = 0.6; // Полупрозрачность
845
+
846
+ // Получаем параметры фигуры из pending
847
+ const kind = this.pending.properties?.kind || 'square';
848
+ const width = 100; // Стандартный размер по умолчанию
849
+ const height = 100;
850
+ const fillColor = 0x3b82f6; // Синий цвет как в ShapeObject
851
+ const cornerRadius = this.pending.properties?.cornerRadius || 10;
852
+
853
+ // Создаем графику фигуры (точно как в ShapeObject._draw)
854
+ const shapeGraphics = new PIXI.Graphics();
855
+ shapeGraphics.beginFill(fillColor, 0.8); // Полупрозрачная заливка
856
+
857
+ switch (kind) {
858
+ case 'circle': {
859
+ const r = Math.min(width, height) / 2;
860
+ shapeGraphics.drawCircle(width / 2, height / 2, r);
861
+ break;
862
+ }
863
+ case 'rounded': {
864
+ const r = cornerRadius || 10;
865
+ shapeGraphics.drawRoundedRect(0, 0, width, height, r);
866
+ break;
867
+ }
868
+ case 'triangle': {
869
+ shapeGraphics.moveTo(width / 2, 0);
870
+ shapeGraphics.lineTo(width, height);
871
+ shapeGraphics.lineTo(0, height);
872
+ shapeGraphics.lineTo(width / 2, 0);
873
+ break;
874
+ }
875
+ case 'diamond': {
876
+ shapeGraphics.moveTo(width / 2, 0);
877
+ shapeGraphics.lineTo(width, height / 2);
878
+ shapeGraphics.lineTo(width / 2, height);
879
+ shapeGraphics.lineTo(0, height / 2);
880
+ shapeGraphics.lineTo(width / 2, 0);
881
+ break;
882
+ }
883
+ case 'parallelogram': {
884
+ const skew = Math.min(width * 0.25, 20);
885
+ shapeGraphics.moveTo(skew, 0);
886
+ shapeGraphics.lineTo(width, 0);
887
+ shapeGraphics.lineTo(width - skew, height);
888
+ shapeGraphics.lineTo(0, height);
889
+ shapeGraphics.lineTo(skew, 0);
890
+ break;
891
+ }
892
+ case 'arrow': {
893
+ const shaftH = Math.max(6, height * 0.3);
894
+ const shaftY = (height - shaftH) / 2;
895
+ shapeGraphics.drawRect(0, shaftY, width * 0.6, shaftH);
896
+ shapeGraphics.moveTo(width * 0.6, 0);
897
+ shapeGraphics.lineTo(width, height / 2);
898
+ shapeGraphics.lineTo(width * 0.6, height);
899
+ shapeGraphics.lineTo(width * 0.6, 0);
900
+ break;
901
+ }
902
+ case 'square':
903
+ default: {
904
+ shapeGraphics.drawRect(0, 0, width, height);
905
+ break;
906
+ }
907
+ }
908
+ shapeGraphics.endFill();
909
+
910
+ // Добавляем тонкую рамку для лучшей видимости призрака
911
+ const border = new PIXI.Graphics();
912
+ border.lineStyle(2, 0x007BFF, 0.6);
913
+ border.drawRect(-2, -2, width + 4, height + 4);
914
+
915
+ this.ghostContainer.addChild(border);
916
+ this.ghostContainer.addChild(shapeGraphics);
917
+
918
+ // Центрируем контейнер относительно курсора
919
+ this.ghostContainer.pivot.x = width / 2;
920
+ this.ghostContainer.pivot.y = height / 2;
921
+
922
+ this.world.addChild(this.ghostContainer);
923
+ }
924
+
925
+ /**
926
+ * Разместить выбранное изображение на холсте
927
+ */
928
+ async placeSelectedImage(event) {
929
+ if (!this.selectedImage) return;
930
+
931
+ const worldPoint = this._toWorld(event.x, event.y);
932
+
933
+ try {
934
+ // Загружаем изображение на сервер
935
+ const uploadResult = await this.core.imageUploadService.uploadImage(
936
+ this.selectedImage.file,
937
+ this.selectedImage.fileName
938
+ );
939
+
940
+ // Вычисляем целевой размер
941
+ const natW = uploadResult.width || 1;
942
+ const natH = uploadResult.height || 1;
943
+ const targetW = 300; // дефолтная ширина
944
+ const targetH = Math.max(1, Math.round(natH * (targetW / natW)));
945
+
946
+ const halfW = targetW / 2;
947
+ const halfH = targetH / 2;
948
+ const position = {
949
+ x: Math.round(worldPoint.x - halfW),
950
+ y: Math.round(worldPoint.y - halfH)
951
+ };
952
+
953
+ // Создаем объект изображения с данными с сервера
954
+ this.eventBus.emit(Events.UI.ToolbarAction, {
955
+ type: 'image',
956
+ id: 'image',
957
+ position,
958
+ properties: {
959
+ src: uploadResult.url,
960
+ name: uploadResult.name,
961
+ width: targetW,
962
+ height: targetH
963
+ },
964
+ imageId: uploadResult.id // Сохраняем ID изображения
965
+ });
966
+
967
+ } catch (uploadError) {
968
+ console.error('Ошибка загрузки изображения на сервер:', uploadError);
969
+
970
+ // Fallback: создаем объект изображения с локальными данными
971
+ const imageUrl = URL.createObjectURL(this.selectedImage.file);
972
+ const targetW = this.selectedImage.properties.width || 300;
973
+ const targetH = this.selectedImage.properties.height || 200;
974
+
975
+ const halfW = targetW / 2;
976
+ const halfH = targetH / 2;
977
+ const position = {
978
+ x: Math.round(worldPoint.x - halfW),
979
+ y: Math.round(worldPoint.y - halfH)
980
+ };
981
+
982
+ this.eventBus.emit(Events.UI.ToolbarAction, {
983
+ type: 'image',
984
+ id: 'image',
985
+ position,
986
+ properties: {
987
+ src: imageUrl,
988
+ name: this.selectedImage.fileName,
989
+ width: targetW,
990
+ height: targetH
991
+ }
992
+ });
993
+
994
+ // Показываем предупреждение пользователю
995
+ alert('Ошибка загрузки изображения на сервер. Изображение добавлено локально.');
996
+ }
997
+
998
+ // Убираем призрак и возвращаемся к инструменту выделения
999
+ this.selectedImage = null;
1000
+ this.hideGhost();
1001
+ this.eventBus.emit(Events.Keyboard.ToolSelect, { tool: 'select' });
1002
+ }
1003
+ }
1004
+
1005
+